Measure Internal Latch Contention with sys.dm_os_latch_stats
When LATCH_* waits show up near the top of sys.dm_os_wait_stats, you know threads are waiting on non-buffer latches. You don't know which engine structure they're waiting for. sys.dm_os_latch_stats splits those waits by latch class. The script below ranks the non-buffer classes that make up the first 95% of latch wait time and returns the seconds waited, the wait count, the percentage and the average wait for each class.
Purpose and Overview
The job is to turn a generic latch wait into a named structure, such as a filegroup, the log manager or the root of a B-tree. Non-buffer latch waits appear in wait stats as a LATCH_* wait type, and once they appear, sys.dm_os_latch_stats gives the cumulative breakdown by class. A latch wait starts when a request cannot be granted immediately because another thread holds the latch in a conflicting mode.
The script leaves out one class. All buffer latch waits fall under BUFFER, and that class covers every use of page latches. Those waits are already reported in wait stats as PAGELATCH_* and PAGEIOLATCH_*. With BUFFER filtered out, the result covers non-buffer latches only and says nothing about page latch contention. The counters are cumulative since the last reset or startup and are lost on restart.
The view applies to SQL Server, Azure SQL Database, Azure SQL Managed Instance, Azure Synapse Analytics and SQL database in Microsoft Fabric. It is documented back to SQL Server 2005, and every column the script reads exists there. On SQL Server and Managed Instance, reading it requires VIEW SERVER STATE. On SQL Server 2022 and later, it requires VIEW SERVER PERFORMANCE STATE. On Azure SQL Database Basic, S0, S1 and elastic pools, it requires the server admin account, the Microsoft Entra admin account or membership in ##MS_ServerStateReader##.
Code Breakdown
The script reads the view once into a CTE, joins the CTE to itself to build a running total and stops at 95% of non-buffer latch wait time.
1WITH [Latches] AS
2 (SELECT
3 [latch_class],
4 [wait_time_ms] / 1000.0 AS [WaitS],
5 [waiting_requests_count] AS [WaitCount],
6 100.0 * [wait_time_ms] / SUM ([wait_time_ms]) OVER() AS [Percentage],
7 ROW_NUMBER() OVER(ORDER BY [wait_time_ms] DESC) AS [RowNum]
8 FROM sys.dm_os_latch_stats
9 WHERE [latch_class] NOT IN (
10 N'BUFFER')
11 AND [wait_time_ms] > 0
12)
13SELECT
14 MAX ([W1].[latch_class]) AS [LatchClass],
15 CAST (MAX ([W1].[WaitS]) AS DECIMAL(14, 2)) AS [Wait_S],
16 MAX ([W1].[WaitCount]) AS [WaitCount],
17 CAST (MAX ([W1].[Percentage]) AS DECIMAL(14, 2)) AS [Percentage],
18 CAST ((MAX ([W1].[WaitS]) / MAX ([W1].[WaitCount])) AS DECIMAL (14, 4)) AS [AvgWait_S]
19FROM [Latches] AS [W1]
20INNER JOIN [Latches] AS [W2]
21 ON [W2].[RowNum] <= [W1].[RowNum]
22GROUP BY [W1].[RowNum]
23HAVING SUM ([W2].[Percentage]) - MAX ([W1].[Percentage]) < 95; -- percentage threshold
24GO
The Latches CTE
The WHERE clause removes BUFFER and every class with zero wait time. Dropping the idle classes keeps them out of the result, and it means the average calculation later in the script can't divide by zero.
WaitS divides by 1000.0 rather than 1000. The decimal literal converts milliseconds to seconds without integer division cutting off the fraction.
SUM ([wait_time_ms]) OVER() has an empty OVER clause, so the total is taken across every row that passed the WHERE filter. Percentage is therefore each class's share of non-buffer latch wait time, not its share of all wait time on the instance. A class at 80% here can still be a minor problem if LATCH_* waits are a small part of total waits.
Ranking with ROW_NUMBER
ROW_NUMBER() OVER(ORDER BY [wait_time_ms] DESC) numbers the classes from most to least wait time. The running total in the outer query depends on this number.
wait_time_ms is updated every five minutes during a latch wait and again when the wait ends. A long wait that is still running can be missing up to five minutes of time, so a class with one stuck thread ranks lower than it will once that wait finishes.
The triangular self-join and the 95% cut-off
[W2].[RowNum] <= [W1].[RowNum] joins each class to itself and to every class ranked above it. Row 3 joins to rows 1, 2 and 3. Grouped by [W1].[RowNum], SUM ([W2].[Percentage]) is the running total up to and including that class.
The HAVING clause subtracts the class's own percentage, which leaves the running total of the classes above it. A class is kept while that earlier total is under 95. The class that crosses 95% is therefore included, and every class below it is cut. To see more of the tail, raise the 95 on the commented line.
Each W1 group holds one row, so the MAX() calls around the W1 columns return that row's values. They are there only so the columns can appear in a grouped query.
AvgWait_S and the casts
AvgWait_S divides total seconds by the number of waits. waiting_requests_count is incremented at the start of a latch wait, so any class with recorded wait time has a count of at least one. Rows with zero wait time are filtered out, so the division is safe.
The same timing rule skews the average during a long wait. The count goes up when the wait starts, but its time is added only in five-minute steps. A class with waits in progress shows a lower average than it will later.
Wait_S and Percentage are cast to DECIMAL(14, 2). AvgWait_S uses four decimal places, so it shows down to a tenth of a millisecond. Smaller averages show as 0.0000.
Reading the output
What the common classes mean:
- ACCESS_METHODS_HOBT_COUNT synchronizes access to a HoBt's page and row counters. It's used to flush page and row count deltas.
- ACCESS_METHODS_HOBT_VIRTUAL_ROOT synchronizes access to the root page abstraction of an internal B-tree. When concurrency is high and data is constantly inserted and deleted, B-tree root splits can occur, and other inserts wait on non-buffer latches. The queue-table pattern shows up as a large number of waits on this class.
- LOG_MANAGER is internal use only. Waits on it almost certainly mean a transaction log is growing.
- FGCB_ADD_REMOVE synchronizes filegroup add, drop, grow and shrink operations. The most common cause of waits on it is a lot of file auto-growth.
- ALLOC_FREESPACE_CACHE contention comes from several connections inserting into a heap or BLOB at once, and partitioning the object reduces it. ALLOC_EXTENT_CACHE contention comes from several connections allocating data pages in the same allocation unit at once.
- ACCESS_METHODS_DATASET_PARENT and ACCESS_METHODS_SCAN_RANGE_GENERATOR are used in parallel scans to give each thread a range of page IDs.
- DATABASE_CHECKPOINT serializes checkpoints within a database.
The script doesn't return max_wait_time_ms. If that value is unusually high, it might indicate an internal deadlock, so query it separately when one class looks stuck.
Key Benefits and Use Cases
- Read-only on a live instance — the script only selects from the view and resets nothing, so other tools that read the same counters see no change.
- Checks a partitioning fix — partitioning a heap with ALLOC_FREESPACE_CACHE contention gives each partition its own latch. On a non-production instance, clear the counters with
DBCC SQLPERF ('sys.dm_os_latch_stats', CLEAR)and re-run the workload, and the ranking shows whether the class has dropped. - Same query on Azure — the view keeps its name in Azure SQL Database and Azure SQL Managed Instance, so one script covers on-premises and cloud instances.
- Gives support a ranked list — some latch contention requires a case with Microsoft Customer Support Services, and a short list of ranked classes is a clear place to start one.
Performance Considerations
- Size of the read: the view returns its wait data by latch class, and the self-join runs only over classes with non-zero wait time, so the join works on at most one row per latch class that has waited.
- Synapse limits: in Azure Synapse Analytics the view is
sys.dm_pdw_nodes_os_latch_stats, adds apdw_node_idcolumn, and isn't supported by serverless SQL pool, so the script needs the view name changed there. - What isn't tracked: requests granted immediately, or that failed without waiting, never appear, so the view measures waiting, not total latch activity.
- Lifetime totals: on an instance that has run for a long time, statistics such as max_wait_time_ms are rarely useful, and lifetime percentages mix a current problem with months of old history.
Practical Tips
- Check the window first — read
sqlserver_start_timeinsys.dm_os_sys_infoto see how much time the counters cover. - Measure with snapshots — save the view to a table before and after peak load and compute the difference, the same method used for
sys.dm_os_wait_stats, then run the ranking against the deltas. - Clear only outside production —
DBCC SQLPERF ('sys.dm_os_latch_stats', CLEAR);resets all counters to 0 and Microsoft advises clearing wait counters only in a non-production environment. - Start from wait stats — check what share of total wait time buffer and non-buffer latches account for, and resolve the largest wait first. In most cases, suboptimal CPU utilization comes from other waits, such as lock blocking, I/O or network issues.
- Know when latches are the problem — contention matters when it reduces CPU utilization and throughput, and excessive contention has been seen most on systems with 16 or more CPU cores.
Conclusion
This script is the step after wait stats. Once LATCH_* waits take a real share of wait time, it names the classes responsible and how much each contributes. After it come the fixes for each class (file sizing, log truncation, partitioning, queue-table design) and, for the hardest non-buffer cases, memory dumps analyzed with Windows debugging tools.
References
- LatchStats.sql (ktaranov/sqlserver-kit) — the script, including the 95% threshold and the zero-wait filter
- sys.dm_os_latch_stats (Transact-SQL) — columns, the products it applies to, Synapse naming, permissions, latch class definitions, reset and restart behavior
- **sys.dm_os_latch_stats (Transact-SQL), SQL Server 2005 — the earliest documented version: the view and the columns the script reads
- Diagnose and resolve latch contention on SQL Server — buffer versus non-buffer latches, troubleshooting order, snapshots, clearing outside production, root splits, the 16-core observation
- Most common latch classes and what they mean — what HOBT_COUNT, LOG_MANAGER, FGCB_ADD_REMOVE and the parallel-scan latches mean
Posts in this series
- SQL Server UPDATE STATISTICS and SELECT Dynamic Scripts
- SQL Server Wait Statistics Report: dm_os_wait_stats
- SQL Server Missing Indexes Report: dm_db_missing_index
- SQL Server Unused Indexes: sys.dm_db_index_usage_stats
- SQL Server Top Queries by CPU and IO: dm_exec_query_stats
- SQL Server CPU Utilization History Report
- SQL Server Identify Heap Tables Without Clustered Indexes
- SQL Server DBCC FREEPROCCACHE: Clear the Plan Cache Safely
- Measure Disk I/O Performance per Database File
- Break Down Buffer Pool Memory by Database
- Rank Top Memory Consumers with sys.dm_os_memory_clerks
- Rank Expensive Queries with sys.query_store_runtime_stats
- Find Ad Hoc Plan Cache Bloat with sys.dm_exec_cached_plans
- Profile Costly Cached Procs with sys.dm_exec_procedure_stats
- Catch Memory Grant Waits via sys.dm_exec_query_memory_grants
- Measure Internal Latch Contention with sys.dm_os_latch_stats