Repository navigation
fix(cpu): give the reserved dispatcher CPU a compute lane (#1746) - #1748
Conversation
`ONNX_GENAI_CPU_DECODE_THREADS=N` builds N-1 compute lanes in production, for every N. At N=2 that is one lane, which trips the `total_workers <= 1` serial short-circuit in `dispatch_output_rows` -- so the whole persistent pool degenerates to serial dispatch on the engine thread and `=2` is indistinguishable from `=1`. Two individually-correct pieces compose into the off-by-one. `EpFactory::initialize()` calls `bound_process_to_decode_budget()`, which confines the process to exactly N CPUs so a user who caps cores disturbs at most N. Later, on first decode, `node_shards(N)` reads `allowed_cpus()` -- now exactly N -- and `reserve_single_group_headroom(N, N)` returns N-1 to keep one CPU free for the inline dispatcher. That reservation is real and measured: N spinning workers pinned across all N allowed CPUs starve the dispatcher and collapse throughput 20-60x. Neither is wrong alone. But because we build the fully-subscribed cpuset ourselves, the "only fires on an exactly-N-CPU cpuset" caveat in `reserve_single_group_headroom`'s docstring is not a corner case -- it is the guaranteed outcome of every explicit budget. The dispatcher does not compute; it publishes and spins in `wait`. So the reserved CPU is not merely unallocated, it is burned spinning, and the budget buys N-1 lanes. Keep the reservation exactly as measured and let the dispatcher compute the shard it was holding a CPU for: N-1 pinned worker threads (unchanged, so the starvation cliff stays fixed) plus the dispatcher = N lanes on N CPUs. Verified N in -> N lanes for N = 2/4/8/16 through the production sequence. `catch_unwind` around the dispatcher's shard is load-bearing, not defensive: the published `Job` holds a raw pointer to a closure borrowed off the dispatcher's own stack frame, and the workers read through it until the barrier drains, so unwinding straight out of the inline shard is a use-after-free. Miri reports it as a data race between the worker's non-atomic read and the unwinding retag; the crate's Miri lane now covers that test. Restricted to the single-group layout. On a NUMA split the dispatcher's node is not known at build time, so handing it a shard could pull that shard's weights across sockets; those layouts keep the previous behaviour. Also corrects the addendum to the merged width-scaling benchmark, which classified this mechanism as container-only. It is not: the bench binary never calls `initialize()`, which is why its non-vacuity check passed while production lost a worker. Its t=16 row is a 15-worker measurement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1748 +/- ##
==========================================
+ Coverage 80.03% 80.07% +0.03%
==========================================
Files 410 410
Lines 198269 198477 +208
Branches 198269 198477 +208
==========================================
+ Hits 158685 158923 +238
+ Misses 34195 34157 -38
- Partials 5389 5397 +8
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Opus review NIT on #1748: when the dispatcher's inline shard and a worker both panic on the same op, `resume_unwind` carries the dispatcher's payload and returns before `panic_if_poisoned`. The worker's poison is latched, not lost -- the next dispatch reports it before publishing. Make that intent explicit rather than incidental. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adversarial review:
|
…1748 CI caught a real bug in this test: a 4-CPU runner realized 4 workers for a 4-lane request where the test predicted 3. The cause was not host shape -- `taskset -c 0-3` locally realized 3 for the same request. The two were running different code. This branch was based on 07158f6; CI tests a merge with current main, which has #1748. #1748 gives the inline dispatcher the shard `reserve_single_group_headroom` frees, so `total_workers` counts compute lanes rather than spawned threads and now equals the request at every width. The `allowed - 1` special case the test restated no longer exists, so drop it and assert exact equality everywhere. Measured under `taskset` at 1, 2, 4, 8 and 32 CPUs. Keep the fully-subscribed probe: it is the shape #1746 broke and, per #1748's own comment, the shape production always runs, since `bound_process_to_decode_budget` confines the process to exactly the budgeted CPUs at EP init. On a 32-CPU host the published widths 1..16 never reach it. Mutation-proved against production: reverting #1748's dispatcher lane fails this test with `requested width 4 realized 3 compute lanes`, so it would have caught #1746. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…1747) Every decode benchmark row we publish carries a `t=N` label that nothing verified. The harness reports the width it *requested* via `ONNX_GENAI_CPU_DECODE_THREADS`; the pool is free to build something else, and it does. This adds a subprocess sweep asserting that the two agree at the widths we actually publish. ## Why this matters, concretely **#1746 is what an unverified width label costs.** For a while every explicit budget silently bought `N-1` compute lanes, and at `N=2` the single remaining lane tripped the `total_workers <= 1` serial short-circuit — making the knob indistinguishable from `=1`. Nothing failed. No test went red. The numbers were simply answering a different question than their labels claimed, and every `t=2` row published in that window was a serial run wearing a parallel label. This test is mutation-proved to catch exactly that: reverting #1748's dispatcher lane fails it with ``` requested width 4 realized 3 compute lanes, not the 4 it asked for ``` ## What it does - A child process reports what the pool *built* (`requested/available/allowed/nodes/workers/pool_built`) rather than recomputing the code under test. One child per width, so each gets clean process-wide affinity and pool state. - Sweeps the published widths `1/2/4/8/16`, plus one **fully-subscribed** probe asking for the whole allowed cpuset. - Asserts exact equality: realized lanes == requested width. The full-subscription probe is not padding. On a 32-CPU host every published width satisfies `effective < allowed`, so the dispatcher reservation is a no-op and the interesting path is never reached — on exactly the large hosts most likely to run CI. Under the #1748 mutation above, widths 1..16 all **pass** on this host; only the probe catches it. And per #1748's own comment it is the *common* production shape, since `bound_process_to_decode_budget` confines the process to exactly the budgeted CPUs at EP init. ## Measured contract (base `f44460554`) | cpuset | requested → realized lanes | |---|---| | `0` | 1 → 1 | | `0-1` | 1 → 1, 2 → 2 | | `0-3` | 1 → 1, 2 → 2, 3 → 3, 4 → 4 | | `0-31` | 1 → 1, 2 → 2, 4 → 4, 8 → 8, 16 → 16, 31 → 31, 32 → 32 | ## Review history — CI caught a real bug in this test, and local green did not An earlier revision restated `reserve_single_group_headroom`'s `allowed - 1` rule and asserted it end to end. CI failed on a 4-CPU runner: 4 lanes realized where the test predicted 3. Locally, `taskset -c 0-3` gave 3 — the same nominal shape, the opposite answer. The cause was not host shape. **The two were running different code.** This branch was based on `07158f695`; CI tests a *merge* with current main, which contains #1748. That PR gives the dispatcher the shard the reservation frees, so `total_workers` counts lanes rather than threads and the `allowed - 1` special case no longer exists. Merging main and re-measuring collapsed the contract to exact equality at every width. Worth stating plainly: this branch was locally green **21/21 three times**, and passed an adversarial review that found no defects, while carrying an assertion that was false on any host CI actually uses. The 4-CPU shape does not exist on a 32-CPU box, and a stale base is invisible to a local matrix by construction. CI earned its keep here. ## Also in this PR - `is_environmental_access_violation_crash` now takes the caller's success marker instead of hardcoding one, so both child-spawning tests share the #1745 Windows ARM64 `STATUS_ACCESS_VIOLATION` retry guard rather than duplicating it. Per-caller marker cases added to its unit test. ## Validation - Full 21-gate matrix on the merged tree: **21 pass / 0 fail / 0 skip**. - New tests confirmed *executed* (not filtered) in the default, `mlas`, aarch64-QEMU and no-default/no-MLAS lanes. - Width sweep re-run under `taskset -c 0`, `0-1`, `0-3`, `0-7`, `0-31`. - Mutation-proved three ways against **production** code, each reverted after: width clamp to 4 (caught at t=8), clamp to 1 (caught at t=2 on a 4-CPU pin), and reverting #1748's dispatcher lane (caught at t=4 and t=32). `decode_spmd.rs` is not in this diff. Changes are confined to the `#[cfg(test)]` module of `matmul_nbits.rs`. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Closes #1746.
ONNX_GENAI_CPU_DECODE_THREADS=Nbuilds N-1 compute lanes in production, for every N. AtN=2that is one lane, which trips thetotal_workers <= 1serial short-circuit indispatch_output_rows— so the whole persistent pool degenerates to serial dispatch on the engine thread and the knob is indistinguishable from=1.Root cause: two correct pieces composing badly
Neither half is wrong on its own, which is why unit tests on either half could not see this.
provider.rs:340—EpFactory::initialize(), the earliest per-session hook, callsbound_process_to_decode_budget(). With an explicit budget it confines the process to exactly N CPUs, so that "a user who caps cores disturbs at most N CPUs". Deliberate and documented.node_shards(N)readsallowed_cpus()— now exactly N — andreserve_single_group_headroom(N, N)returns N-1, keeping one CPU free for the inline dispatcher. Also deliberate and measured: N spinning workers pinned across all N allowed CPUs starve the dispatcher and collapse throughput 20-60x (1.47 tok/s at 32 workers ontaskset -c 0-31vs ~29 tok/s once one CPU is spare).reserve_single_group_headroom's docstring notes it only fires for "a user who setsONNX_GENAI_CPU_DECODE_THREADS=Non an exactly-N-CPU cpuset". We build that cpuset ourselves in step 1, so it is not a corner case — it is the guaranteed outcome of every explicit budget.Measured through the production sequence, under an outer
taskset -c 0,2,...,30:allowed_before=16 -> allowed_after=Nis the whole mechanism.The dispatcher does not compute — it publishes and spins in
SharedState::wait. So the reserved CPU is not merely unallocated, it is burned spinning.Fix
Keep the reservation exactly as measured, and let the dispatcher compute the shard it was holding a CPU for.
At budget N: N-1 pinned worker threads (unchanged — the starvation cliff stays fixed) plus the dispatcher computing on the reserved CPU = N compute lanes on N CPUs, no thread oversubscribed. Strictly better than N-1 lanes plus a spinning CPU.
Mechanically:
publishcounts downnode_thread_counts(spawned threads only — the dispatcher's shard has no thread to wait for), the dispatcher runs the remaining shard inline, thenwait()s. Partitioning already producestotal_workersshards, so only the pending-count source and the shard-to-thread mapping change.dispatch_inline(the re-entrant fallback) already loops over all shards and stays correct.Scope. Single-group layouts only, and only when the reservation actually fired. A group that already had headroom is untouched, or the pool would be one lane wider than the budget allows. On a NUMA split the dispatcher's node is not known at build time, so handing it a shard could pull that shard's weights across sockets; those layouts keep the previous behaviour.
catch_unwindis load-bearing, not defensiveThe published
Jobholds a raw pointer to a closure borrowed off the dispatcher's own stack frame, and the workers read through it until the barrier drains. Now that the dispatcher also computes, a panic in its shard would unwind that frame while workers are still reading it — a use-after-free, not merely a hang. So: catch, complete the barrier, thenresume_unwind.Miri agrees. Removing the
catch_unwind:decode_spmd's panic-safety test is added to the Miri lane, with the "verified both ways" note the workflow already uses.Falsifiers
Every test was checked to fail without the fix.
...restores_the_requested_width,...fans_out_across_the_dispatcher_and_one_worker,...covers_its_rows_exactly_once)catch_unwindpublishthe shard counts instead of thread countsONNX_GENAI_CPU_DECODE_THREADS=2 must buy 2 compute lanes, got 1 (1 pinned threads on 2 allowed CPUs)The end-to-end test spawns one subprocess per budget — not stylistic. Both halves latch (
PROCESS_BUDGET_BOUNDand the pool areOnceLocks) and the child mutates process-wide CPU affinity, which would poison the test runner. It skips budgets aboveavailable_parallelism()and skips NUMA-split layouts, so it is meaningful on a 2-vCPU runner and inert where it cannot apply.No performance claim
This PR claims a width restoration, proven categorically by thread and lane counts, not a speedup. The host is shared and has been above loadavg 60; per the standing protocol I am not quoting a throughput number I could not measure under control. The arithmetic ceiling is 2x at
N=2and 1.33x atN=4, but realised speedup depends on scaling efficiency and is not asserted here.Corrects the record on #1740
The merged width-scaling benchmark lists this exact mechanism as vacuity case 2, but classifies it as "benchmarking inside a small container hits this".
#1740's measurements are correct and nothing in them is retracted.
int4_decode_loop_abnever callsEpFactory::initialize()— it goes straight towith_decode_pool_scope— so the confinement never runs there, the process keeps its 16tasksetCPUs,reserve_single_group_headroom(2, 16) = 2, and the bench genuinely gets two busy workers. Its 1.96x and its per-thread attribution stand. That is also exactly why its non-vacuity check passed (win gavewout) while production lost a worker.The finding is the divergence: the decode bench does not reproduce the production thread topology, so a width sweep run through it is structurally unable to observe this class of defect.
One correction to that table: its
t=16row is a 15-worker measurement, sincereserve_single_group_headroom(16, 16) = 15fires even without the production confinement. The1/2/4/8checks could not catch it because the reservation only triggers at exactly full subscription. This understates the plateau rather than overstating it, so the conclusion drawn from it is unaffected.Addendum written into
docs/benchmarks/2026-08-22-decode-width-scaling.mdrather than left in a PR comment.I also withdrew my own first reading of this: I initially attributed Roy's
=1/=2bench timings to this defect. That attribution was wrong — he was on the bench binary — and is retracted in #1746 and on #1722.Validation
cargo test -p onnx-runtime-ep-cpu --lib— 1616 passed, 0 failedcargo test --workspace(excludingonnx-genai-bench) — 6303 passed, 0 failedcargo fmt --check;cargo clippy --all-targets -D warnings— clean--features mlas(incl. theStealpath, which keeps its previous width),--no-default-features— cleanaarch64-unknown-linux-gnuclippy-D warnings— cleandecode_spmd::tests::a_panic_in_the_dispatcher— ok in ~23s; reports UB with F2 appliedorigin/mainmerged in before this run