Repository navigation
bench(cpu): put benchmark processes in the production decode topology - #1766
Conversation
None of the 12 CPU benchmarks called `EpFactory::initialize`, so none of them ran in the thread topology a served session runs in. `initialize` is the earliest per-session hook and the only place an explicit `ONNX_GENAI_CPU_DECODE_THREADS` budget becomes a process-wide bound on prefill/MLAS Rayon parallelism and, on Linux, on CPU affinity. Without it a `t=N` row measures N decode workers competing with a full-width Rayon pool across every logical CPU -- a configuration nothing ships. Measured on this 16-physical/32-logical host with `int4_decode_loop_ab`: | budget | threads before | affinity before | threads after | affinity after | |---|---|---|---|---| | 4 | 6 | 0-31 | 9 | 0,2,4,6 | | 16 | 18 | 0-31 | 33 | 16 physical cores | Before, the budget changed the decode pool width and nothing else: the process stayed spread across all 32 logical CPUs at every budget, so both SMT siblings of each core were in play and prefill/MLAS kept a full-width Rayon pool. That is not a smaller-budget measurement, it is the same machine with a differently sized decode pool bolted on. `gqa_decode` already had `bind_decode_pool_width`, but it caps the *global Rayon* pool rather than applying the production bound, so it does not stand in for this. The helper calls the real `CpuExecutionProvider::initialize` rather than reimplementing what it currently does, so it cannot drift the moment `initialize` grows a second responsibility -- which is precisely the drift that leaves a benchmark measuring something nothing ships. It is a no-op unless a budget is set, and idempotent. No kernel, scheduler, or library code changes; benches only. No performance claim is made or revised here -- the point is that rows taken from here on describe the configuration they are labelled with. Closes #1749. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1766 +/- ##
==========================================
- Coverage 80.58% 80.19% -0.40%
==========================================
Files 411 411
Lines 198803 198803
Branches 198803 198803
==========================================
- Hits 160210 159430 -780
- Misses 33168 33948 +780
Partials 5425 5425
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
…ain main Review caught that `kernels` -- one of the 12 the previous commit claimed to cover -- got no call at all, so `cargo bench --bench kernels` with a budget set still measured the unbounded topology #1749 is about. Verified by running the binary with a budget: no "confined the process" line was emitted. It now is. The criterion benches also had the call in only the first `criterion_group!` member. That happened to work, because criterion runs the first target's body before any measurement or analysis, but it made coverage ride on target order: prepending or removing a target would leave the new first benchmark measuring unbounded, and its post-measurement analysis builds the global Rayon pool at full width, after which every later call hits `build_global`'s already-built path and the Rayon bound is silently lost for the whole file. That is the same silent-drift failure the helper's own doc warns about, reintroduced through ordering. The call is idempotent, so every group member now makes it. Coverage is now 19 of 19 entry points across 12 of 12 benches, and all three criterion binaries emit the bound exactly once under a budget. `kernels` installs its own fixed-width Rayon pool (it sweeps `[1, 8]`), so under a smaller budget it will oversubscribe that pool onto the budget's cores. That is what production does with the same two settings, so it is applied there rather than special-cased, and the helper's doc now says so instead of leaving it to be rediscovered. Also corrected `EpFactory::initialize` to `CpuExecutionProvider::initialize` in the helper doc -- there is no Rust `EpFactory::initialize`; `EpFactory` is the ORT C ABI type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adversarial review (Opus) — verdictOne BLOCKING, one SHOULD-FIX, one NIT. All three fixed in BLOCKING —
|
) ## What `int4_decode_loop_ab` sweeps `ONNX_GENAI_CPU_DECODE_THREADS` and prints a row per width, but nothing in the row reported the width the run **actually built**. Adds `common::report_decode_width()` and calls it after the measured phases. ``` steady 20.053 20.625 48.6 0.0 decode_width requested=2 realized=2 path=spmd-pool as_requested ``` ## Why this is not hypothetical At least four paths silently reduce realized width below the request — the pre-clamp in `resolve_persistent_decode_threads_with_override`, both headroom reservations, and the single-CPU-cpuset branch that drops decode to the flat path. Only one is even `NXRT_CALIB_DEBUG`-visible. `ONNX_GENAI_CPU_DECODE_THREADS=2` was recorded in `docs/benchmarks/2026-08-21-int4-acc4-execution-regime.md` as producing timings *identical* to `=1`. The row was dropped rather than explained. **It was an unbounded-topology artifact, and it is already fixed.** Measured at `9747b4971` (current main minus #1766), same binary otherwise, qwen/acc0/block32/s=1: | t | ms/token | CPU% | vs t=1 | |---|---|---|---| | 1 | 40.298 | 100% | 1.00x | | 2 | 37.166 | 267% | **1.08x for 2.67 cores** | Before #1766 the benches never called `initialize()`, so the process was unbounded and global Rayon ran full width at *every* budget. The "t=1" arm was not 1-wide. On current main, same sweep, both directions (<2% between passes): | t | ms/token ↑ | ↓ | speedup | CPU% | path | |---|---|---|---|---|---| | 1 | 39.649 | 39.532 | 1.00x | 99% | flat | | 2 | 20.053 | 20.053 | **1.98x** | 187% | spmd-pool | | 4 | 10.133 | 10.138 | 3.91x | 326% | spmd-pool | | 8 | 5.230 | 5.279 | 7.55x | 549% | spmd-pool | | 16 | 2.664 | 2.609 | **15.0x** | 886% | spmd-pool | A/A null control at t=4: 10.179 vs 10.077 (1.0%). ## Design notes - **Reports, does not assert.** A reduced width is legitimate when the host cannot honour the request (8 lanes in a 2-CPU cpuset); aborting would leave a constrained container unable to benchmark at all. `WIDTH-MISMATCH` is for the caller to discard or mark the row, as a contended cell is already marked UNTRUSTED. - **Called after the phases, never before.** The pool is built lazily at first decode and `decode_width()` is deliberately non-forcing; reading it earlier reports `path=unresolved`, and a forcing read would build the pool and change the topology being measured. - **Scoped to `int4_decode_loop_ab`.** `gqa_decode` also reads a decode width but pins the ambient Rayon pool by design, so a width line there would report `path=flat` and mislead rather than verify. ## Validation - `cargo fmt --check` clean; `clippy --benches --all-targets -D warnings` clean - All 12 benches build (release) - Width line verified **by execution** at t=1/2/4/8/16 — `realized` tracks the request at every width, `path` flips `flat`→`spmd-pool` at t=2 Bench-only; no shipped code changes. Refs #1763, #1766 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…erseded (#1772) #1771 found that every `t=N` decode row published before #1766 (`11cb8e5f3`) was taken on a process that never called `CpuExecutionProvider::initialize()`. The decode budget sized the SPMD pool but did not *bound the process*: affinity stayed at all logical CPUs and the global Rayon pool ran full width at every budget. The `t=1` arm was never 1-wide. This marks the affected rows. It does not delete them, and it tries to be exact about the blast radius rather than blanket-invalidating work that is still sound. ## The distinction that matters Each acc4 speedup is a **paired A/B whose two arms shared the same broken topology**. A ratio measured that way is still a ratio: 1.686x at t=1 and 1.380x at block 64 remain the measured effect of removing per-block bookkeeping, and the recommendation built on them stands. What does not survive is any claim that attributes an effect **to a width**, because the width on the label was not the width that ran. Those are called out individually: the "wash at t=8", the t=16 rows, "the one place bandwidth does bind is full width", and the cross-width comparisons in *The regime*. ## §27's `sys` claim is mine and was worse than imprecise The section records "`sys` time rising ~20x from `t<=2` to `t>=4`" as the evidence handed to the runtime owner. The pre-#1766 column had the **opposite shape** — `sys` was highest at `t=2` (2.19s) and *decreased* with width. On bounded topology the onset is `t=16` and an order of magnitude smaller (1.14s). I wrote that characterisation from my own contaminated column, so it is corrected in my voice rather than attributed to the runtime owner. What stands: the `yield_now`-per-iteration ramp is in the source, and the blocktime `500us -> 0` A/B (`sys` 28.1s -> 5.1s, latency-neutral) is a genuine within-configuration result. What does not: its magnitude, since it was measured with the pool oversubscribed against a full-width Rayon pool, which inflates barrier time by itself. Recorded as unquantified pending a re-run, not as refuted. ## Also - The withdrawn "`=2` is inert" claim was previously explained as contention. #1771 supplies the mechanical cause, which is better: the unbounded process made the bottom of the sweep flat. Contention was a real confound in that window but is not what produced the identity. The independent falsifier against `9747b4971` reproduces the correction from the other direction — **1.98x at t=2, 187% CPU**. - §27 recommends verifying a knob *structurally* rather than by timings. That guard now exists in CI (#1747, `b9d9c48`) and is mutation-proved against production, so the note points at it. Ticks the first box of #1771's suggested actions. The second (re-deriving the `sys`/`worker_wait` onset) is runtime-owned and I have handed the correction over with the evidence. Docs-only; `ci.yml` skips the Rust jobs for `docs/*|*.md` by design. Refs #1771, #1766, #1747 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…=8 3.08x Review of the previous commit made the right objection: the ORT arm reproducing to +4.4% shows the *ORT* ruler did not move and says nothing about the native one, which changed repeatedly over the same window (#1722 is literally titled "make the acc0 native and ORT arms measure one quantity"). So the inference is replaced with a measurement. `e9754e7ef`'s bench is rebuilt in a second worktree and run beside current main's -- same host, same environment, `PROBE_REPS=1` on both so neither gets a rep loop the other lacks, arms interleaved and the order alternated: | width | kernel-only, measured | published pair implies | verdict | |------:|----------------------:|-----------------------:|---------| | 1 | 1.64x [1.61-1.88], 12 cells | 1.59x | movement is kernel | | 8 | 1.82x [1.78-1.89], 6 cells | 3.08x | 3.08x RETRACTED | Both old figures reproduce to within 0.4% -- but only **unpinned**. The old bench never called `EpFactory::initialize`, so it never ran `bound_process_to_decode_budget()`; that function, physical-core selection included, already existed at `e9754e7ef` and production always called it. The old t=8 row therefore measured eight decode workers scattered over 32 logical CPUs onto SMT siblings -- a topology no served session ever ran in. #1766 added the call. Pinned to eight physical cores the same old binary gives 8.430 ms against its unpinned 14.115, and forced onto `0-7`, 16.121. 1.67x of the claimed 3.08x was placement, not kernel work. This is the effect 2026-08-21-decode-worker-cpu-placement.md (#1680) already recorded, landing on a number I quoted two days later. Two corrections that look right and are not, recorded so they are not re-applied: the ~11% warmup/spawn handicap of §27 is in `tokens_s_total`, and both published figures are `ms_token` -- the old ORT harness docstring names "the native harness's `steady` column-2 median" as its comparand and the reproductions land on it. Deducting 11% yields a number neither tree produces. The asymmetry that *is* real, old ORT `min` over reps against old native single-shot, biases in ORT's favour. The gap conclusion is unchanged and is measured on today's tree, both arms, matched pins -- but the headline table is rebuilt to fix three defects: - it mixed statistics (native median latency over ORT throughput-equivalent) so its columns did not yield its own gap figure. Both sides are now `tokens_s_total`, with the mixed variant shown and labelled; - `t=4` was quoted as 1.148x when its A/A null spans 0.868-1.150, so the gap is inside its own noise floor there. Now "~1.15x, does not resolve"; - "three independent launches per width" was false (3/5/3 cells across two invocations) and the `t=1` 1.4% spread depended on an undisclosed post-hoc discard. Retained-cell figures are published beside the headline (1.112x [0.927-1.128], n=3) and the discard rule is stated prospectively. `acc0_gap_matrix.py` gains `--launches`, a per-width `--tokens 1:64,4:192` map and a `gap` column in ORT/native orientation beside `ratio`, so the Reproduce block names a command that produces the published table. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t=1/4/8 (#1852) ## The 1.84x acc0 gap is stale. Re-measured, it is ~1.12x. The published acc0 (`accuracy_level = 0`, the production default) int4 decode gap against ORT — **1.84x at t=1** — is what made acc0 the top remaining CPU MatMulNBits target in the ledger. It dates from `e9754e7ef` (#1628) and **eight merges have landed since**, three of them direct acc0 kernel work. On current main the gap measures **~1.12x**. | width | native tok/s | ORT tok/s | **gap** | gap range | cells (trusted/taken) | A/A range | |---:|---:|---:|---:|---:|---:|---:| | 1 | 27.9 | 31.2 | **1.120x** | 1.112–1.128 | 3/3, **2 retained** | 1.025–1.036 | | 4 | 107.2 | 122.3 | ~1.15x | 1.087–1.284 | 4/6 | **0.868–1.150** | | 8 | 211.0 | 238.0 | **1.120x** | 1.089–1.145 | 3/3 | 0.997–1.028 | | 16 | — | — | ~1.64x, **does not resolve** | 1.456–1.831 | 2/3 | **0.969–1.295** | Both arms are `tokens_s_total`, paired within each launch and then medianed. *Trusted/taken* is the harness's verdict; *retained* is editorial — all three `t=1` cells passed the guard and one was dropped afterwards by me, disclosed below. Only `t=1` and `t=8` resolve. `t=4` sits inside its own A/A null (0.868–1.150). **`t=16` reads ~1.64x and is the open row** — see below; a second revision of this PR corrects an earlier claim that it was wholly contaminated. **acc0 is no longer the top CPU target — conditional on `t=16`.** At the two widths that resolve, the remaining ~12% is a kernel efficiency difference that sits below several other open items. `t=16` is the width closest to an unconfined production process, and a confirmed 1.64x there would reverse that. ## Why the movement is kernel — measured, not inferred The first revision of this PR argued from a control: ORT re-measures at 31.99 ms, within 4.4% of its published 30.632, therefore the harness is comparable and *"the movement is entirely on our side."* **Review objected that this does not follow, and review was right.** The ORT arm reproducing shows the *ORT* ruler did not move. It says nothing about the native ruler, which sits in a different binary and changed repeatedly over the same window — `81e611c03` (#1722) is literally titled *"make the acc0 native and ORT arms measure one quantity"*. So the inference was replaced with a measurement. `e9754e7ef`'s tree is checked out in a second worktree, its `int4_decode_loop_ab` rebuilt, and run **beside** current main's on the same host, same environment, `PROBE_REPS=1` on both so neither gets a rep loop the other lacks, arms interleaved and the launch order alternated: | width | kernel-only, measured | published pair implies | verdict | |---:|---:|---:|---| | 1 | **1.64x** [1.61–1.88], 12 paired cells | 1.59x | apparent movement **is** kernel | | 8 | **1.82x** [1.78–1.89], 6 paired cells | 3.08x | **3.08x retracted** | ### Retracting the t=8 3.08x Both old figures reproduce today to within 0.4% — but only **unpinned**: | published | rebuilt `e9754e7ef` today, unpinned | delta | |---|---:|---:| | `56.307 ms` (t=1) | 56.519 (56.402 / 56.519 / 56.878) | +0.4% | | `14.091 ms` (t=8) | 14.115 (14.105 / 14.115 / 14.196) | +0.2% | The old bench never called `EpFactory::initialize`, so it never ran `bound_process_to_decode_budget()` and its process was never confined. That function — physical-core `select_budget_cpus` included — **already existed at `e9754e7ef`**, and production always called it; only the bench was missing the call, which #1766 `11cb8e5f3` added. The old `t=8` row therefore measured eight decode workers scattered over 32 logical CPUs onto SMT siblings: **a topology no served session ever ran in.** | binary at t=8 | 8 physical cores | 4 cores + SMT siblings | unpinned | |---|---:|---:|---:| | `e9754e7ef` | 8.430 ms | 16.121 ms | **14.115 ms** | | current main | 4.664 ms | 7.988 ms | **4.619 ms** | **1.67x of the claimed 3.08x was placement, not kernel work.** Today's binary is pin-insensitive (0.99x) because it confines itself. This is exactly the effect `docs/benchmarks/2026-08-21-decode-worker-cpu-placement.md` (#1680, ledger §24) already recorded — landing on a number I quoted two days later. ### Two corrections that look right and are not Recorded so they are not re-applied at this site: - **The ~11% warmup/spawn handicap of §27 is in `tokens_s_total`.** Both published figures are **`ms_token`** — the old `ort_matmulnbits_baseline.py` docstring names *"the native harness's `steady` column-2 median"* as its comparand, and the reproductions above land on it to 0.4%. Deducting 11% from `56.307` yields a number no run of either tree produces. - **The statistic asymmetry that *is* real points the other way.** Old ORT took `min` over reps of a per-`Run` median; old native was single-shot with no rep loop. Best-of-N against single-shot flatters ORT, so it made the old gap look *worse*. Calling the two arms "the same statistic", as the first revision did, was wrong. ## Headline-table defects fixed in this revision - **Mixed statistics.** The first table printed native *median latency* beside ORT *throughput-equivalent* and called the ratio a gap, so its columns did not yield its own gap figure. Both sides are now `tokens_s_total`; the mixed variant is shown, labelled, and noted to decline (1.113 → 1.098 → 1.085) rather than be flat. - **False precision at t=4.** `1.148x` quoted against an A/A null of 0.868–1.150. Now "~1.15x, does not resolve". - **Undisclosed post-hoc discard.** "three independent launches per width" was false (3 / 6 / 3 / 3 cells across two invocations), and the `t=1` 1.4% spread depended on discarding a cell after seeing it. Retained-cell figures are now published beside the headline (**1.112x [0.927–1.128], 18.1%, n=3** — the median barely moves, the *precision* does not survive), and the discard rule is stated prospectively for next time. - **Reproducibility.** `acc0_gap_matrix.py` gains `--launches`, a per-width `--tokens 1:64,4:192,8:384` map, and a `gap` column in ORT÷native orientation beside `ratio`, so the Reproduce block names a command that produces the published table. ## Method preconditions added 1. **The two arms were not getting the same machine.** `ONNX_GENAI_CPU_DECODE_THREADS=w` confines the *whole native process* to `w` CPUs; the script pinned ORT to all 16 even CPUs at every width. Measured effect on a quiet host: **1–2%** — real, small, and now data rather than argument. 2. **The realized width is read back and checked** (`decode_width requested=4 realized=4 as_requested`). Timings cannot detect a vacuous sweep. 3. **`LoadWatch` samples the runnable count *during* every arm**, refusing above `width + slack`. A pre-check cannot see a competitor that arrives mid-cell — one did, and four cells were discarded because of it. 4. **The wide-pin arm turned out to be a contention detector.** One `t=1` cell passed every host-level guard while CPU 0 alone was busy: both matched-pin arms ~2x slow, the roaming arm normal. A single-CPU pin is the most fragile cell in any width sweep, and it is what every speedup is quoted against. ## Still unresolved — and a correction to the first revision **`t=16`, and it is the row that matters.** The first revision of this PR wrote the width off as "every cell contaminated". **That was wrong, and wrong in the direction that flattered the conclusion.** Two of the three `t=16` cells passed the load guard cleanly (runnable 6, no competitor recorded): | `t=16` | gap | A/A | native spread | ORT spread | |---|---:|---:|---:|---:| | launch 1 | 1.831 | 1.295 | 17.8% | **55.4%** | | launch 2 | 1.456 | 0.969 | 27.7% | **19.6%** | | **median** | **1.643** | — | — | — | So it is **~1.64x from two accepted cells**, not "no data". It still does not resolve, but on the correct ground: the A/A null spans 0.969–1.295 (±30%, against 3.6% at `t=1` and 2.8% at `t=8`) and both arms are unstable at this width. The cell the guard *did* refuse reads 1.585 — between the two retained — so the discard is not load-bearing either way. This is the one open cell that could reverse the re-ranking, and it needs a dedicated quiet-host study with launch distributions and a pre-registered A/A acceptance threshold. ## Second-review fixes (this revision) An adversarial review returned MERGE AFTER FIXES with all six core claims surviving falsification and seven defects. All are fixed: 1. **`t=16` mischaracterised** — the headline fix above, propagated to all four sites that carried the re-ranking claim. 2. **Cell counts** — `t=4` is 4 trusted of **6** taken; the published table came from **two** script invocations, not three. 3. **Column semantics** — harness-trust and editorial retention were conflated in one column; now separated, which makes the `t=1` discard visible in the table rather than only in prose. 4. **`t=1` placement probe samples published**, including a `114.94 ms` outlier on one pinned rep of three (the old binary's bimodality — it is why the `t=1` A/B range reaches 1.88x). Placement is worth 1.9% at this width, against 1.67x at `t=8`, as expected for a single-threaded process with no SMT sibling to hit. 5. **ORT-side spread disclosed at `t=16`** (55.4%) — the denominator is no better behaved than the numerator there. 6. **Stale checksum constants** in `int4_decode_loop_ab`'s module doc corrected, with a note that they drift under reduction reassociation (#1667, #1783) and that the *pattern* — block 16 moves under `ONNX_GENAI_CPU_MM_INT4_GEBP=0`, block 32 does not — is the route evidence, not the digits. 7. **`--tokens` map footgun** — a map missing a `--threads` width died with a bare `KeyError` after the first cell had already waited out the load guard. It now refuses at parse time, naming the missing widths. ## Validation Docs plus one benchmark harness; no library code. The harness was stub-validated end to end (launch loop, per-width token map, `gap` column, paired per-width summary) and both binaries were run for the numbers above. Required CI (`Fast (Linux x86_64)`, `Rust quality`) must be green before merge — no admin bypass. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roy <roy@squad.local>
Closes #1749.
None of the 12 CPU benchmarks called
EpFactory::initialize, so none of them ran in the thread topology a served session runs in.initializeis the earliest per-session hook and the only place an explicitONNX_GENAI_CPU_DECODE_THREADSbudget becomes a process-wide bound on prefill/MLAS Rayon parallelism and, on Linux, on CPU affinity (provider.rs:334-343). Without it, at=Nrow measures N decode workers competing with a full-width Rayon pool across every logical CPU.Measured,
int4_decode_loop_ab, 16-physical / 32-logical host0-310,2,4,60-310,2,...,30(16 physical cores)Before, the budget changed the decode pool width and nothing else. The process stayed spread across all 32 logical CPUs at every budget, so both SMT siblings of each core were in play and prefill/MLAS kept a full-width Rayon pool. That is not a smaller-budget measurement; it is the same machine with a differently sized decode pool bolted on.
After, the process reports what production reports:
Neither line could be emitted by any benchmark before this change, because nothing in
benches/reached the only call site that prints them.Why a helper rather than inlining the current behaviour
common::init_decode_topology()calls the realCpuExecutionProvider::initialize. A reimplementation of "whatinitializedoes today" would silently stop matching the momentinitializegrows a second responsibility — exactly the drift that leaves a benchmark measuring a configuration nothing ships. It is a no-op unless a budget is set, and idempotent (the underlying bound latches on first call).gqa_decodealready hadbind_decode_pool_width, but that caps the global Rayon pool rather than applying the production bound, so it does not stand in for this. It keeps its existing call; the two are not in conflict.Scope
Benches only — no kernel, scheduler, or library code changes. No performance claim is made or revised here. The point is narrower and prior to any claim: rows taken from here on describe the configuration they are labelled with. Previously published
t=Nrows from these binaries should be treated as untrusted, not as re-baselined.Related: #1763 (a requested width is still unverifiable even once a bench is initialized correctly). The two are complementary — this one makes a bench have production topology, that one makes it checkable. Both are needed before a
t=Nscaling curve means anything.Validation
cargo build -p onnx-runtime-ep-cpu --benches(debug + release) — all 12 buildcargo clippy -p onnx-runtime-ep-cpu --all-targets -D warnings— clean, also under--features mlas,--no-default-features, and--target aarch64-unknown-linux-gnucargo fmt --all --check— cleanRust qualityscripts — pass/proc/<pid>/taskandtaskset -cp, table above