SQL Server SOS_SCHEDULER_YIELD waits: CPU scheduler pressure explained

When SOS_SCHEDULER_YIELD dominates sys.dm_os_wait_stats, the reflex is to assume CPU pressure and start hunting for bad queries. That reflex is right about half the time. The other half, you are chasing a signal that is doing exactly what it was designed to do: recording every time a worker voluntarily yielded its 4ms quantum because other runnable workers were queued.

The wait type has existed in SQL Server since the SQLOS era and the 4ms quantum is fixed in every version. It cannot be tuned. What you can tune is your interpretation. A workload doing efficient set-based scans of pages already in memory will yield constantly and rack up enormous SOS_SCHEDULER_YIELD numbers without anything being wrong. A VM on an oversubscribed host reports the same wait type while the hypervisor silently steals CPU cycles SQL Server cannot see.

This article covers the mechanism, the signals that separate benign from pathological yielding, the production patterns that drive the pathological case (missing indexes, parameter sniffing, compilation storms, VM steal, soft-NUMA surprises), and the diagnostic queries that take you from “SOS_SCHEDULER_YIELD is high” to “here is the plan consuming the scheduler.”

What this means

SQL Server runs its own cooperative, non-preemptive scheduler through SQLOS. There is one scheduler per logical CPU. Each scheduler has three lists: running (the worker currently on CPU), runnable (workers ready to run, queued for their turn), and waiter (workers blocked on a resource such as I/O, a lock, or memory). Workers are not preempted by the OS. They run until they hit a yield point, at which point SQLOS checks whether the 4ms quantum has been exhausted.

When a worker exhausts its quantum and the runnable queue is nonempty, the worker goes to the bottom of the runnable queue. The time it spends there, waiting to get back on CPU, is recorded as SOS_SCHEDULER_YIELD. Two properties follow that operators must internalize:

  • Resource wait is always zero. wait_time_ms - signal_wait_time_ms for SOS_SCHEDULER_YIELD is always 0. The worker is not waiting on a resource. The entire wait is signal wait: time spent runnable, queued for a CPU.
  • Count alone is meaningless. A high waiting_tasks_count with single-digit-millisecond average signal wait is a healthy busy system. The same count with 50ms or higher average signal wait is scheduler saturation.

This is why the signal wait ratio matters more than the raw wait time. If signal_wait_time_ms across all wait types exceeds roughly 20% of total wait_time_ms, the engine is spending a significant fraction of its wait time queued for CPU. On a VM, that number can climb while OS-reported CPU utilization stays low, because the cycles being waited for were stolen by a co-tenant or consumed by hypervisor overhead.

Common causes

CauseWhat it looks likeFirst thing to check
Real CPU-bound workloadTop waits dominated by SOS_SCHEDULER_YIELD, signal wait ratio above 20%, runnable_tasks_count sustained above 1 per scheduler, SQL Server CPU at 80%+sys.dm_exec_query_stats ordered by total_worker_time
VM CPU steal or co-tenant contentionSame wait profile but SQL Server CPU reports low (30-50%), other process CPU also low, no obvious query culpritHypervisor CPU ready (%RDY on VMware, steal in /proc/stat on Linux hosts, CPU credits on Azure burstable VMs)
Missing indexes forcing scansSOS_SCHEDULER_YIELD rising with specific queries, large total_logical_reads on a few plans, often CXPACKET as a co-wait on parallel scanssys.dm_exec_query_stats ordered by total_logical_reads, missing index DMVs
Parameter sniffing bad planSudden spike after plan recompilation, one plan shape (often nested loops on a large set) burning CPU, query duration regressedQuery Store plan regression, or compare plans in sys.dm_exec_query_plan
Compilation stormSOS_SCHEDULER_YIELD correlated with SQL Compilations/sec tracking Batch Requests/sec, plan cache churningCompilations-to-batch-requests ratio, single-use plan count
Auto soft-NUMA on large systemsSevere SOS_SCHEDULER_YIELD accumulation on systems with 9 or more cores per socket, low CPU utilization, limited parallel workloadssys.dm_os_nodes node type, soft-NUMA configuration
CPU power managementErratic SOS_SCHEDULER_YIELD correlated with CPU frequency transitions, often on idle-to-burst trafficOS power plan, BIOS C-states and P-states
Edition core limitSOS_SCHEDULER_YIELD on Standard or Express when workload exceeds licensed CPU capacity, SQL Server CPU capped below host CPUEdition and licensing, scheduler count vs host CPU

Quick checks

-- Snapshot wait stats delta over 60 seconds (read-only)
DECLARE @t1 TABLE (wait_type nvarchar(60), wait_time_ms bigint, signal_wait_time_ms bigint, waiting_tasks_count bigint);
INSERT @t1 SELECT wait_type, wait_time_ms, signal_wait_time_ms, waiting_tasks_count
FROM sys.dm_os_wait_stats WHERE wait_type = 'SOS_SCHEDULER_YIELD';
WAITFOR DELAY '00:00:60';
SELECT
    s.wait_type,
    s.waiting_tasks_count - t.waiting_tasks_count AS new_yields,
    s.wait_time_ms - t.wait_time_ms AS new_wait_ms,
    s.signal_wait_time_ms - t.signal_wait_time_ms AS new_signal_ms
FROM sys.dm_os_wait_stats s
JOIN @t1 t ON t.wait_type = s.wait_type
WHERE s.wait_type = 'SOS_SCHEDULER_YIELD';
-- Signal wait ratio across all waits (read-only)
SELECT
    SUM(signal_wait_time_ms) AS total_signal_ms,
    SUM(wait_time_ms) AS total_wait_ms,
    CAST(100.0 * SUM(signal_wait_time_ms) / NULLIF(SUM(wait_time_ms), 0) AS DECIMAL(5,2)) AS signal_pct
FROM sys.dm_os_wait_stats
WHERE wait_type NOT IN (
    'SLEEP_TASK','BROKER_TO_FLUSH','BROKER_TASK_STOP','CLR_AUTO_EVENT','CLR_MANUAL_EVENT',
    'LAZYWRITER_SLEEP','SQLTRACE_BUFFER_FLUSH','WAITFOR','XE_TIMER_EVENT','XE_DISPATCHER_WAIT',
    'FT_IFTS_SCHEDULER_IDLE_WAIT','BROKER_EVENTHANDLER','SP_SERVER_DIAGNOSTICS_SLEEP',
    'HADR_FILESTREAM_IOMGR_IOCOMPLETION','DIRTY_PAGE_POLL','DISPATCHER_QUEUE_SEMAPHORE',
    'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP','QDS_ASYNC_QUEUE','CHECKPOINT_QUEUE',
    'REQUEST_FOR_DEADLOCK_SEARCH','LOGMGR_QUEUE','ONDEMAND_TASK_QUEUE','HADR_WORK_QUEUE',
    'BROKER_TRANSMITTER','KSOURCE_WAKEUP');
-- Per-scheduler runnable queue depth (read-only)
SELECT scheduler_id, cpu_id, current_tasks_count,
       runnable_tasks_count, active_workers_count, work_queue_count
FROM sys.dm_os_schedulers
WHERE status = 'VISIBLE ONLINE'
ORDER BY runnable_tasks_count DESC;

How to diagnose it

The flow is: confirm the wait is real, find where the CPU is going, then decide whether the cause is inside or outside SQL Server.

flowchart TD
    A["SOS_SCHEDULER_YIELD dominates waits"] --> B{"signal_wait_time_ms
above 20% of total?"} B -- No --> C["Benign yielding
No action needed"] B -- Yes --> D{"runnable_tasks_count above 0
across schedulers?"} D -- No --> E["Check non-yielding scheduler
or measurement window"] D -- Yes --> F{"SQL Server CPU high?"} F -- Yes --> G["Real CPU pressure
Find top worker_time queries"] F -- No --> H["VM steal or external contention
Check hypervisor CPU ready"] G --> I["Missing index, bad plan,
or compilation storm"] H --> J["Resize, relocate, DRS anti-affinity"]
  1. Snapshot wait stats and compute deltas. sys.dm_os_wait_stats is cumulative since startup, or since the last DBCC SQLPERF('sys.dm_os_wait_stats', CLEAR). A single read tells you nothing about current state. Snapshot, wait 30 to 60 seconds, snapshot again, compute the delta. Do not run DBCC SQLPERF(..., CLEAR) on a production instance unless you accept losing the cumulative history.

  2. Check the signal wait ratio. In the delta window, compute SUM(signal_wait_time_ms) / SUM(wait_time_ms). Above 0.20 means the engine is spending more than 20% of its wait time runnable, queued for CPU. Below 0.10 with high SOS_SCHEDULER_YIELD counts is benign yielding. The ratio is more reliable than any absolute threshold on the wait type itself.

  3. Check runnable queue depth. Sustained runnable_tasks_count above 1 across multiple schedulers is CPU pressure. Any work_queue_count above 0 is worker thread exhaustion: a different and more urgent problem. Check max_worker_threads against current_tasks_count and look for THREADPOOL waits alongside.

  4. Find the queries consuming CPU.

-- Top queries by CPU (read-only, cumulative since plan cached)
SELECT TOP 20
    qs.sql_handle, qs.plan_handle, qs.execution_count,
    qs.total_worker_time / 1000 AS total_cpu_ms,
    qs.total_worker_time / qs.execution_count / 1000 AS avg_cpu_ms,
    qs.total_elapsed_time / qs.execution_count / 1000 AS avg_elapsed_ms,
    SUBSTRING(qt.text, 1, 200) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
ORDER BY qs.total_worker_time DESC;

The gap between avg_cpu_ms and avg_elapsed_ms shows wait time. Queries where the two are close are CPU-bound. Queries where elapsed is much larger than CPU are waiting on something else and are not your SOS_SCHEDULER_YIELD culprit.

  1. Find live requests currently yielding. Queries incurring SOS_SCHEDULER_YIELD do not appear in sys.dm_os_waiting_tasks, because the worker is runnable, not waiting on a resource. Query sys.dm_exec_requests and filter on last_wait_type. A running request’s last_wait_type is the wait it last completed, not necessarily the wait it is currently on.
-- Live requests currently yielding (read-only)
SELECT r.session_id, r.status, r.wait_type,
       r.last_wait_type, r.wait_time,
       r.cpu_time, r.logical_reads,
       SUBSTRING(qt.text, 1, 200) AS query_text
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) qt
WHERE r.last_wait_type = 'SOS_SCHEDULER_YIELD'
ORDER BY r.cpu_time DESC;
  1. If SQL Server CPU is low and the wait is high, suspect the VM. This is the pattern that catches teams off guard. The hypervisor is not giving SQL Server the cycles it expects, so workers exhaust their quantum without actually getting 4ms of CPU time. Check VMware %RDY (sustained above 5-10% per vCPU is worth investigating; above 10% is significant contention ), steal time on Linux KVM hosts, or Azure VM CPU credits and quota. Also check host power management, because aggressive C-state transitions cause the same artifact.

  2. On SQL 2016 or later with a large socket count, check soft-NUMA. Auto soft-NUMA is enabled by default on systems with more than 8 physical cores per socket and has been documented to cause severe SOS_SCHEDULER_YIELD accumulation on large systems running limited parallel workloads. Check sys.dm_os_nodes and evaluate whether disabling auto soft-NUMA changes the profile. The configuration change requires a service restart.

Metrics and signals to monitor

SignalWhy it mattersWarning sign
SOS_SCHEDULER_YIELD wait time, deltaDirect measure of time spent waiting to get back on CPU after yieldingSustained growth above baseline, especially with throughput drop
signal_wait_time_ms ratioSeparates CPU pressure from resource waitsAbove 20% of total wait time
runnable_tasks_count per schedulerCleanest in-engine CPU queue signalSustained above 1 across multiple schedulers
work_queue_count per schedulerWorker thread exhaustion indicatorAny sustained nonzero value
SQL Server CPU vs other CPU vs idleDistinguishes SQL-bound from external contentionSQL CPU low plus wait high equals VM steal
SQL Compilations/sec to Batch Requests/sec ratioCompilation pressureAbove 10-20% sustained
Batch Requests/sec trendWorkload context for interpreting waitsDrop while waits climb means throughput collapse
Top queries by total_worker_timeIdentifies queries burning CPUSudden change in ranking after deployment or stats update

Fixes

Real CPU-bound queries (missing indexes, bad plans). This is the most common actionable cause. Use the top-worker-time query above to identify culprits, then inspect their plans. Missing indexes show up as large scans with high logical reads. Parameter sniffing regressions show up as plan changes visible in Query Store. Force the known-good plan in Query Store with sp_query_store_force_plan for immediate relief, then address the root cause (hint, statistics update, query rewrite, covering index). On SQL 2022, the Parameter Sensitive Plan optimization can address the most common parameter-sniffing pattern automatically.

Compilation storms. If SQL Compilations/sec tracks Batch Requests/sec at near 1:1, the plan cache is providing no benefit. Enable optimize for ad hoc workloads at the server level to stub single-use plans on first execution and cache the full plan only on second execution. Consider forced parameterization for workloads dominated by non-parameterized ad-hoc SQL. Address the source of the ad-hoc queries if possible.

VM steal. Not fixable inside SQL Server. Work with the virtualization team to reduce host oversubscription, configure DRS anti-affinity rules to keep noisy co-tenants away, size the VM so the vCPU-to-core ratio is 1:1 for SQL Server workloads, and verify power management is set to high performance at both host and guest levels.

Auto soft-NUMA surprises. On large systems with the symptom, test disabling auto soft-NUMA in a controlled change window. Measure CPU utilization and SOS_SCHEDULER_YIELD before and after. In the documented reproduction, disabling auto soft-NUMA took CPU utilization from 33% to 90% and eliminated the wait accumulation, because parallel query schedulers were no longer artificially partitioned.

SQL 2022 compilation regression. A documented case shows SQL 2022 producing significantly more SOS_SCHEDULER_YIELD waits during query compilation than SQL 2019 on identical hardware, with first execution taking 14 seconds on 2022 versus 2 seconds on 2019. As of early 2026 this remained under Microsoft support investigation. If you see SOS_SCHEDULER_YIELD concentrated on compile operations after upgrading, open a support case and reference the compilation-time regression.

Edition core limits. If Standard or Express edition is capping CPU below what the workload needs, no amount of query tuning will help. The fix is licensing or workload reduction.

Prevention

  • Baseline the signal wait ratio. Track SUM(signal_wait_time_ms) / SUM(wait_time_ms) as a regular time series. Most teams first notice CPU pressure after it is already impacting users; the signal ratio starts climbing hours or days earlier.
  • Snapshot wait stats on a 30 to 60 second cadence. Cumulative sys.dm_os_wait_stats hides current behavior. Without deltas you cannot tell whether SOS_SCHEDULER_YIELD is climbing now or whether it has been accumulating since the last restart three months ago.
  • Track the top-N queries by total_worker_time over time. Sudden changes in ranking are the earliest indicator of plan regression or workload shift.
  • On VMs, monitor hypervisor CPU ready time alongside SQL Server metrics. SQL Server cannot see CPU steal; the only in-engine symptom is SOS_SCHEDULER_YIELD with low reported CPU utilization.
  • Keep power management on high performance. Aggressive C-state and P-state transitions cause the same artifact as VM steal and are easy to overlook.
  • Validate after every SQL Server upgrade. Compilation behavior and scheduler logic have changed across versions. An upgrade that felt slow may have a SOS_SCHEDULER_YIELD fingerprint worth investigating before you start tuning queries.

How Netdata helps

  • The SQL Server collector surfaces sys.dm_os_wait_stats deltas at per-second granularity, so SOS_SCHEDULER_YIELD shows up as a current trend rather than as a cumulative number that obscures recent behavior.
  • signal_wait_time_ms ratio is computed and charted directly, which removes the most common misread of this wait type.
  • runnable_tasks_count and work_queue_count per scheduler are collected alongside CPU utilization, so real CPU pressure (high runnable, high SQL CPU) is distinguishable from VM steal (high runnable, low SQL CPU, low host CPU) in one view.
  • Wait stats correlate with Batch Requests/sec, SQL Compilations/sec, and the top-query metrics, so a compilation storm or throughput collapse appears next to the wait signal.
  • Anomaly detection on the signal wait ratio and runnable queue depth surfaces slow-build CPU pressure before users report latency.
  • Host-level CPU steal metrics sit alongside the SQL Server metrics where the hypervisor exposes them, making the VM-steal case diagnosable from the same dashboard.

Netdata’s Microsoft SQL Server monitoring with Netdata brings these signals together with per-second metrics and ML anomaly detection.