Repository navigation
perf(cpu): bound the decode dispatcher's yield loop and park it instead - #1808
Merged
Merged
Conversation
`SharedState::wait` called `thread::yield_now()` on every iteration once past `DISPATCHER_SPIN_BEFORE_YIELD`, with no upper bound. The dispatcher therefore hammered `sched_yield` for the entire remaining duration of every dispatch it did not win the race on -- a syscall storm proportional to the shard length, not to the wake latency the yield backstop exists to hide. Measured on llama int4 accuracy_level=0 decode, zero inter-token gap, one worker per physical core, quiet host, arms interleaved within a single binary so no host drift separates them: | width | arm | wall ms/token | total cpu ms/token | sys ms/token | |---|---|---|---|---| | 2 | before | 35.72-35.87 | 37.36-37.63 | 15.74-16.10 | | 2 | after | 35.81-35.93 | 35.93-36.85 | 0.59-0.66 | | 3 | before | 23.92-24.06 | 37.22-38.67 | 11.99-12.33 | | 3 | after | 23.93-24.07 | 36.50-36.99 | 1.88-2.21 | | 4 | before | 17.96-18.10 | 37.87-38.21 | 9.85-10.33 | | 4 | after | 18.05-18.09 | 37.26-37.63 | 2.39-2.51 | System CPU per token falls 24x/6x/4x with no overlap between the arms, total CPU falls with it (so this is not a sys-to-user relabel), and wall time is unchanged inside the before-arm's own launch-to-launch spread. This is deliberately *not* the same change as spinning harder. Substituting spinning for yielding in `worker_wait` was previously measured to be a wash -- kernel time down, user time up by the same amount, total CPU flat -- because a worker that stops yielding still burns its core in user mode. A dispatcher that parks burns nothing, which is why the total moves here and did not there. The escalation is now spin -> a bounded number of yields (covering the short waits a park would only slow down) -> park on a new `completion_sense` futex that the worker retiring the last shard bumps. That worker skips the `wake` syscall entirely unless the dispatcher is actually parked, so the fast path costs one relaxed load plus one RMW per dispatch, not per worker. `ONNX_GENAI_CPU_DECODE_DISPATCHER_YIELDS` overrides the yield budget; a very large value restores the previous never-park behaviour, which is how the A/B above was taken. Both new tests are verified by mutation: deleting the `wake_all` makes each of them time out rather than hang, and `repeated_parks_never_lose_a_wakeup` was confirmed to pass unchanged with `wake_all` deleted until its per-iteration sleep was added to force the dispatcher to actually park first. Refs #1801 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1808 +/- ##
==========================================
+ Coverage 80.30% 80.33% +0.02%
==========================================
Files 411 411
Lines 200604 200895 +291
Branches 200604 200895 +291
==========================================
+ Hits 161100 161387 +287
+ Misses 34034 34028 -6
- Partials 5470 5480 +10
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…hang Found by adversarial review of the parent commit, and it is a deadlock rather than a slowdown on any weakly ordered target. Each node's last worker reaches `signal_completion` having just stored zero to its own pending counter, and immediately loads every other node's counter to decide whether it owes the dispatcher a wakeup. With only the release store and acquire loads that is the store-buffering litmus: in a two-node pool both last workers may read the other's pre-decrement value, so neither passes the gate, nobody bumps `completion_sense`, and a dispatcher that has already parked sleeps until the process ends. Parking before completion is the normal case here, so the only rare ingredient is the two cross-node decrements racing. `x86_64` hides this because the `lock`-prefixed decrement is already a full barrier, which is why the suite passes here and would not on `aarch64`. A `SeqCst` fence between the decrement and the scan puts every arrival into one total order, so the worker whose fence is last in it is sequenced after all the other zero stores and cannot miss them -- one guaranteed signaller for any node count, not just two. It runs once per node per op on the path that was about to signal anyway, never on the per-worker fast path. The `park_until_complete` doc previously argued only the `dispatcher_parked`/`completion_sense` pair and read as if that were the whole proof. It is necessary but not sufficient: it orders the dispatcher against a worker that reaches the bump, and says nothing about whether one does. Both halves are now written down and cross-referenced. Also from the same review: - `a_parked_dispatcher_waits_for_every_node_not_just_the_first` covers the cross-node scan, which no previous test reached -- both existing tests build a single node. It asserts the first node draining does *not* release the dispatcher and the second does, and is mutation-verified (deleting `wake_all` makes it time out at 10.30 s). It deliberately does not claim to cover the ordering hazard: on x86 the fence can be deleted and it still passes, and the docstring says so. - `repeated_parks_never_lose_a_wakeup` carried a rationale I had not falsified, that a stale `observed` would strand an iteration. It would not -- a stale `observed` fails the `completion_sense == observed` guard, so the `wait` is skipped and the caller's loop still exits. Confirmed by mutating `observed` to a frozen value: the test passes. Replaced with the coverage it does uniquely carry -- that `completion_sense` advances exactly once per op and that `dispatcher_parked` is cleared on the way out, the latter mutation-verified. Its pre-signal sleep also went 5ms -> 50ms to match the single-op test; a loaded runner could land the signal before the dispatcher parked and silently drop that iteration onto the pre-park re-check. - `barrier_only_shared_state` now takes per-node pending counts instead of a single count, so multi-node fixtures are expressible. - Dropped "an entire core burned in the scheduler" for the measured split: the core is fully burned, but only ~45% of it is inside `sched_yield`. Refs #1801 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
justinchuby
marked this pull request as ready for review
August 23, 2026 05:53
justinchuby
enabled auto-merge (squash)
August 23, 2026 05:54
This was referenced Aug 23, 2026
Open
Merged
justinchuby
added a commit
that referenced
this pull request
Aug 23, 2026
…the dispatcher (#1826) Found while investigating #1745. **This is not a fix for #1745** — that is an access violation on Windows ARM64 and I have not reproduced it. This is a different, deterministic bug in the same code, found by reading it. ## The bug A decode dispatch that lands concurrently with pool shutdown is abandoned without being acknowledged, and the dispatcher waits on it forever. Reproduced deterministically with a probe driving the real `worker_loop`: `PROBE_RESULT node_pending=1` after 5s. ## Why the code was written that way Workers park on their node's sense word, so `shutdown()` **must** bump it — that is the only way to break a parked worker out without a lost wakeup. But that makes an advance mean *"wake up and look"*, not *"there is work"*. One word was answering two questions, and both single-word answers are unsafe: | resolution | consequence | |---|---| | treat a shutdown bump as an op | re-runs the **previous** op's `Job`, whose closure lives on a returned stack frame — **use-after-free** | | decide from the `shutdown` flag (what it did) | a dispatch landing just before a concurrent shutdown is **abandoned unacknowledged** — dispatcher stranded | The old shutdown-first check was correct about a real danger. It just paid for it with the other one. ## The fix Split the word: `NodeSense { wake, ops }`. - `wake` — bumped by both publish and shutdown; what workers park on. - `ops` — bumped **only** by publish; the only thing gating the `Job` read. A worker now retires any outstanding shard *first* and checks the flag *after*. Both words sit in one `Padded` block: the dispatcher writes them back-to-back and each woken worker reads them back-to-back, so a second line would add a coherency miss per worker per op at roughly 400 barriers/token. ## Second commit: the window *inside* `publish` Raised by review of the first commit, and it is the more interesting half. `publish` commits each node's pending count **before** it bumps `ops`. Inside that window a shard is already outstanding but no worker can see that it exists. A shutdown landing there is invisible to the `ops` gate — the worker correctly concludes it has no op, sees the flag, leaves, and the count it never decremented strands the dispatcher just the same. The `ops` split **structurally cannot** close this one, because the op has not been announced yet. It is also pre-existing — the old code hit it too — so the first commit was not a regression, but without this it makes a property true only for part of the race and advertises it for all of it. Closed from the other side instead: **wait for the pool to go quiet before raising the flag**. `dispatch` holds the publish slot from before `publish` until after `wait` returns, so once the slot is free every committed count has already been retired. - It cannot deadlock against a dispatcher blocked in `wait` — that dispatcher is waiting on *workers*, which are still running, because the flag is not up yet. - Bounded at 5s. A dispatcher wedged for an unrelated reason must not convert every teardown into a hang; giving up degrades to the pre-existing behaviour and says so. - The warning is `warn`-level and ungated, not debug-gated. The recurring lesson of this campaign (#1812, and Roy's `OnceLock` A/B in #1736) is that a debug-gated diagnostic is one nobody reads. - Rejected: aborting `wait()` on shutdown. A worker may already be inside the closure, so that is a use-after-free. The wait and the flag store live in one `begin_shutdown` rather than two statements in `shutdown()` — the wait is the only thing that makes the store safe, a later edit should not be able to separate them, and it makes the ordering assertable rather than merely present. ## Why it is quiet in production Since #1808 the dispatcher **parks** instead of spinning, so the symptom is a hang at zero CPU with no runnable thread. My own merge made this failure mode harder to see. ## Tests Three tests, each mutation-verified to fail under its own defect and no other: | mutation | fails | |---|---| | restore the shutdown-first check in `worker_loop` | `a_dispatch_racing_shutdown_is_retired_not_abandoned`, alone | | gate the `Job` read on `wake` instead of `ops` | `a_shutdown_bump_alone_never_re_runs_the_previous_op`, alone | | drop the quiescence wait from `begin_shutdown` | `shutdown_waits_for_an_in_flight_dispatch_before_stopping_workers`, alone | `publish_one` now calls the **real** `SharedState::publish` rather than mirroring it — mirrored, the tests kept passing when `publish` itself regressed. Swapping its `ops` and `wake` bumps is now caught, **though by hang rather than by assertion**, which is weaker than the three above and worth stating plainly. `shutdown_like_the_pool` stays mirrored because the real one needs a whole `SpmdDecodePools`; its quiescence wait is covered directly instead. Also fixed a test-hygiene trap I introduced: the two new tests shared a `static` counter and **each failed depending on the other's timing while both passed in isolation**. The counter now travels through the `Job`'s own `data` pointer. ## Validation - `cargo test --release -p onnx-runtime-ep-cpu --lib` — **1667 passed, 0 failed**, 22 ignored, on latest main - clippy `-D warnings` clean in **both** feature states (default and `--features tracing`) - `cargo fmt` clean **Performance impact is unmeasured.** The host is contended and, per #1812, a wall-time number taken there is not worth quoting. The change adds two loads to the worker fast path and nothing to the dispatcher's; I would rather say that than assert neutrality I have not shown. I will post numbers once the host is quiet. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
SharedState::wait— the dispatcher side of the SPMD decode barrier — calledthread::yield_now()on every iteration once pastDISPATCHER_SPIN_BEFORE_YIELD(~10 µs), with no upper bound:So the dispatcher hammered
sched_yieldfor the entire remaining duration of every dispatch it did not win the race on. The cost is proportional to the shard length, not to the wake latency the yield backstop exists to hide. On llama the decode path issues 5 dispatches per token with millisecond-scale shards, so this is milliseconds of kernel time per token spent producing nothing.Measurement
Interleaved within a single binary via the new env knob, so the two arms cannot be separated by host drift or by a rebuild. Zero inter-token gap, one worker per physical core, quiet host,
PROBE_NULL=0, 300 tokens × 3 reps, 3 launches per arm plus an A/A partner.sys→userrelabel.At width 2 the before-arm burns 3.2 ms of kernel time per dispatch against a ~7 ms dispatch — an entire core, in the scheduler, doing nothing.
Why this is not "spin harder"
Substituting spinning for yielding in
worker_waitwas previously measured to be a wash: kernel time down 5.5×, user time up by the same, total CPU flat 101.4 → 102.3. That result is real and it does not transfer here, for a reason worth stating explicitly: a worker that stops yielding still burns its core in user mode, so only the label changes. A dispatcher that parks burns nothing at all. That is why total CPU moves in this table and did not in that one.The change
Escalation is now spin → a bounded number of yields → park:
completion_sensefutex, bumped by the worker that retires the last outstanding shard.The completing worker skips the
wakesyscall entirely unless the dispatcher is actually parked, so the fast path costs one load and one RMW per dispatch, not per worker. Thedispatcher_parked/completion_sensehandshake is the standard two-sided futex protocol and needsSeqCston both store-then-load pairs; the reasoning is written out inpark_until_complete.ONNX_GENAI_CPU_DECODE_DISPATCHER_YIELDSoverrides the budget. A very large value restores the exact previous behaviour — that is how the before-arm above was taken.Tests
Two new tests, both verified by mutation (each fails under its own defect):
a_parked_dispatcher_is_woken_by_the_last_worker_to_finish— asserted throughrecv_timeouton a channel rather than by callingwaiton the test thread, because a lost wakeup is a hang, and a hang in CI reads as an infrastructure timeout rather than as this test failing. Deletingwake_allmakes it fail in 10.01 s.repeated_parks_never_lose_a_wakeup— 16 consecutive ops, sincecompletion_senseis monotonic and a stale observed value would strand exactly one iteration.One honest note on the second: it was vacuous when first written. Without a sleep before signalling, the completion lands before the dispatcher exhausts its spin budget,
park_until_completereturns on the pre-park re-check, and the loop asserts nothing about waking — confirmed by it passing unchanged withwake_alldeleted. The sleep that forces a real park was added and the mutation then fails it. The comment records this so it is not re-broken.I also wrote and then deleted a third test asserting bounds on the default constant: clippy correctly flagged it as a constant-valued assertion, which is exactly the near-vacuous kind of coverage that should not be added.
The
SeqCstpair is deliberately not claimed as covered — x86 is TSO, so a weakened ordering still passes here and would only fail on a weakly ordered target. It is argued for, not asserted, and the docstring says so.Scope and limits
cargo fmtapplied.dy/token1.3 vs 4.2) and there is correspondingly little to reclaim — the change is neutral there, not negative. I am separately chasing what selects that regime; it is not caused by, and does not depend on, this change.sysis consistently lower in the after-arm across all of them, but launch-to-launch wall variance at those points is far larger than any arm difference, so I am not claiming a wall result there and the table above is confined to the widths where the arms separate cleanly.Update: adversarial review found a blocking bug in the first commit
bb8b21ffixes a deadlock, not a slowdown, and it is worth reading before the rest.Each node's last worker reaches
signal_completionhaving just stored zero to its own pending counter, then loads every other node's counter to decide whether it owes the dispatcher a wakeup. With only the release store and acquire loads that is the store-buffering litmus — in a two-node pool both last workers may each read the other's pre-decrement value, so neither passes the gate, nobody bumpscompletion_sense, and a dispatcher that has already parked sleeps until the process exits. Parking before completion is the normal case for this feature, so the only rare ingredient is the two cross-node decrements racing.x86_64hides it entirely: thelock-prefixed decrement is already a full barrier, so at least one worker always observes all-zero.aarch64does not. The full suite passing on this host was therefore not evidence.The fix is a
SeqCstfence between the decrement and the scan. That puts every arrival into one total order, so the worker whose fence is last in it is sequenced after all the other zero stores and cannot miss them — one guaranteed signaller for any node count, not just two. Pairwise reasoning is not enough here: with three nodes a cycle of pairwise misses is self-consistent, and only the total-order argument rules it out. It runs once per node per op, on the path that was already about to signal, never on the per-worker fast path.I had also written the
park_until_completedoc as if thedispatcher_parked/completion_sensepair were the whole proof. It is necessary but not sufficient — it orders the dispatcher against a worker that reaches the bump and says nothing about whether one does. Both halves are now written down and cross-referenced.Also from the review
a_parked_dispatcher_waits_for_every_node_not_just_the_first. No previous test reached the cross-node scan at all — both build a single node. It asserts that draining the first of two nodes does not release the dispatcher and that draining the second does. Mutation-verified: deletingwake_allmakes it time out at 10.30 s. It explicitly does not claim to cover the ordering hazard — on x86 the fence can be deleted and it still passes, and the docstring says exactly that.repeated_parks_never_lose_a_wakeupcarried a rationale I had not falsified — that a staleobservedwould strand an iteration. It would not: a staleobservedfails thecompletion_sense == observedguard, so thewaitis skipped and the caller's loop still exits onall_workers_done. Confirmed by freezingobservedin a mutant — the test passes. That is the second unfalsified claim I have had to retract on this test, so I replaced it with coverage that is actually unique to it:completion_senseadvances exactly once per op, anddispatcher_parkedis cleared on the way out. The latter is mutation-verified (removing the reset fails it immediately). Its pre-signal sleep also went 5 ms → 50 ms to match the single-op test; a loaded runner could otherwise land the signal before the dispatcher parked and silently drop that iteration onto the pre-park re-check.barrier_only_shared_statenow takes per-node pending counts rather than one count, so multi-node fixtures are expressible.sched_yieldand the rest is spinning between the calls.Re-validated after the fix: full lib suite 1658 passed, 0 failed, 22 ignored; clippy
-D warningsclean on--all-targets;cargo fmtapplied. The fence is onemfenceper node per dispatch against a multi-millisecond dispatch, so it is not measurable in the table above.Update 2: the "regime" caveat above is now explained, and it makes this change matter more
I described the win as regime-dependent without knowing what selected the regime. I do now, and it is not mysterious — it is CPU contention on the confined core set, which is the condition this change exists for.
ONNX_GENAI_CPU_DECODE_THREADS=Nconfines the whole process to N CPUs. At N=2 that is cpus[0, 2]. Because a dispatch is a barrier, a single foreign thread landing on one of those two CPUs halves the throughput of the whole dispatch — the healthy shard finishes and waits. The result is a clean 2× wall regression at unchanged CPU per token, which is exactly the "slow regime" signature I had been unable to attribute.Controlled, reversible demonstration (width 2, one
spinprocess pinned withtaskset):Turning it on and off flips the regime; placing the same load outside the confined set does nothing.
With the cause under control, the two arms separate cleanly and the picture inverts from "sometimes neutral" to "neutral when idle, large when contended":
On an idle machine the dispatcher barely waits, so there is little to reclaim and the change is a small improvement. Under contention it reclaims 26× on
sysand ~1.5 ms/token of total CPU — the yield loop was spending 45% of a core fighting the very co-tenant that was already the problem. That is the production case (concurrent sessions, co-tenanted hosts, oversubscribed containers), not the benchmark case, and it is where an unboundedsched_yieldloop is worst: it burns most exactly when the machine has least to spare.Note
dy/tokenis ~4.2 in both arms under contention. The dispatcher still enters the yield path just as often; the change is that it now stops after a bounded number and parks instead of yielding for the rest of the shard.This also retires the earlier caveat honestly rather than quietly: the wall-time numbers in the first table were taken under an uncontrolled co-tenant, so their absolute values reflect a contended host. The arm-to-arm
sysand total-CPU deltas were always interleaved within one binary and are unaffected, and the table above re-establishes them with contention as a controlled variable rather than an unknown one.