Repository navigation
perf(ep-cpu): pre-pack QLinearMatMul's constant weight and translate signedness - #1086
Conversation
…signedness `QLinearMatMul` re-packed the whole `K x N` weight inside MLAS on every call (`BIsPacked` was never set) *and* copied it into a fresh dense buffer first, so at K=N=3584 every call paid 12.8 MB of copy plus a full MLAS pack. At M=1 that was the entire call: 1.77 ms of which 1.7 ms was setup. Separately, MLAS documents signed activations as unsupported off ARM and `u8 x i8` can saturate its `i16` pair accumulator on AVX2 without VNNI, so those combinations were declined to a scalar loop and lost to ONNX Runtime by 5-6x. Three changes: * Bind MLAS's quantized `MlasGemmPackBSize`/`MlasGemmPackB` and a `BIsPacked` GEMM entry point, and hold the pack for a constant B for the kernel's lifetime, keyed on the weight's full identity (address, K, N, both signedness flags, and whether the bytes were sign translated). Once the pack exists the dense copy is skipped entirely. * Translate any operand MLAS has no kernel for into the unsigned domain -- `XOR 0x80` on the bytes, `+128` on the zero point. The kernel computes `sum_k (a_k - za)(b_k - zb)`, so shifting an operand and its zero point by the same constant leaves every `i32` accumulator bit-identical, and the call lands on the `u8 x u8` kernel instead of the scalar loop. * Requantize rows in parallel above `PARALLEL_MIN_WORK`, writing into a pre-sized buffer instead of pushing per element, and evaluate `a_scale * b_scale / y_scale` once per column rather than once per element when `a_scale` is per tensor. The association is untouched, so the results are the same `f32` bits. Measured against ONNX Runtime 1.27 on AMD EPYC 9V74 (AVX2+FMA+F16C), K=N=3584, interleaved A/B, p50, ratios are ours/ORT and lower is better: | threads | u8 M=1 | u8 M=128 | u8 M=512 | i8 M=128 | |---------|--------------:|-------------:|-------------:|-------------:| | before | 20.59 (T=8) | 4.02 (T=8) | 2.28 (T=8) | 5.20 (T=8) | | 1 | 1.13 | 1.20 | 1.20 | 0.25 | | 8 | 2.33 | 2.43 | 2.12 | 0.47 | | 16 | 2.34 | 2.65 | 2.08 | 0.60 | Cold cost is repaid on the first call: at K=N=3584 M=1 the first call costs 6.35 ms including the pack, against 5.90 ms for the never-packed path, and every later call is 0.108 ms. The assignment matrix is updated accordingly: `i8 x i8` is now claimed (it wins by 1.7x-33x at every measured point, because ORT's own signed path is far slower than its unsigned one), and `u8 x u8` stays deferred with an honest 1.13-2.65x and a size gate, since its residual gap is thread scaling rather than packing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1086 +/- ##
==========================================
- Coverage 80.39% 79.83% -0.56%
==========================================
Files 369 369
Lines 161791 162043 +252
Branches 161791 162043 +252
==========================================
- Hits 130072 129368 -704
- Misses 26967 27926 +959
+ Partials 4752 4749 -3
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
| Status | Scenario | Base | PR | Change |
|---|---|---|---|---|
add/large_f32_threads=1-internal/4194304 |
664.31 µs | 769.15 µs | +15.8% | |
| ✅ | add/medium_bf16_threads=1-internal/262144 |
95.50 µs | 103.49 µs | +8.4% |
| ✅ | block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 |
41.22 µs | 43.90 µs | +6.5% |
| ✅ | matmul/large_generic_f32_threads=1/32x1024x1024 |
8.71 ms | 9.24 ms | +6.2% |
| ✅ | gather/medium_f32_threads=1-internal/32768 |
3.48 µs | 3.66 µs | +5.4% |
| ✅ | add/small_f16_threads=1-internal/1024 |
418.0 ns | 440.0 ns | +5.2% |
| ✅ | matmul/large_generic_bf16_threads=8/32x1024x1024 |
1.24 ms | 1.29 ms | +4.0% |
| ✅ | add/small_f32_threads=1-internal/1024 |
186.8 ns | 193.8 ns | +3.7% |
| ✅ | qwen3_sampling_processors/top_k_partial_selection |
130.82 µs | 135.61 µs | +3.7% |
| ✅ | matmul/medium_generic_bf16_threads=8/32x512x512 |
354.43 µs | 367.17 µs | +3.6% |
| ✅ | logit_processing/seven_processor_chain_per_step |
300.58 µs | 310.09 µs | +3.2% |
| ✅ | gather/small_bf16_threads=1-internal/4096 |
440.8 ns | 454.5 ns | +3.1% |
| ✅ | add/medium_f16_threads=1-internal/262144 |
99.60 µs | 102.60 µs | +3.0% |
| ✅ | kv_cache/alloc_dealloc_pages |
36.72 µs | 37.83 µs | +3.0% |
| ✅ | add/large_bf16_threads=1-internal/4194304 |
1.53 ms | 1.58 ms | +3.0% |
| ✅ | matmul/small_generic_bf16_threads=8/1x256x256 |
29.72 µs | 30.55 µs | +2.8% |
| ✅ | tokenization/decode_tokens_per_second |
5.78 ms | 5.92 ms | +2.5% |
| ✅ | block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 |
517.49 µs | 529.85 µs | +2.4% |
| ✅ | qwen3_sampling_processors/top_k_top_p_full_sort_baseline |
5.30 ms | 5.42 ms | +2.2% |
| ✅ | add/large_f16_threads=1-internal/4194304 |
1.55 ms | 1.58 ms | +2.2% |
| ✅ | matmul/medium_generic_f16_threads=8/32x512x512 |
28.37 µs | 28.97 µs | +2.1% |
| ✅ | tokenization/encode_tokens_per_second |
352.95 µs | 360.15 µs | +2.0% |
| ✅ | reduce_mean/medium_f32_threads=1-internal/65536 |
228.35 µs | 231.92 µs | +1.6% |
| ✅ | add/small_bf16_threads=1-internal/1024 |
416.4 ns | 422.6 ns | +1.5% |
| ✅ | qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline |
3.29 ms | 3.34 ms | +1.3% |
| ✅ | qwen3_sampling_processors/top_k_top_p_fast |
616.99 µs | 624.90 µs | +1.3% |
| ✅ | add/medium_f32_threads=1-internal/262144 |
23.70 µs | 23.93 µs | +1.0% |
| ✅ | matmul/small_generic_f16_threads=8/1x256x256 |
28.41 µs | 28.66 µs | +0.9% |
| ✅ | reduce_mean/large_f32_threads=1-internal/262144 |
921.87 µs | 929.33 µs | +0.8% |
| ✅ | gather/medium_f16_threads=1-internal/32768 |
2.25 µs | 2.27 µs | +0.5% |
| ✅ | matmul/medium_generic_f32_threads=1/32x512x512 |
2.19 ms | 2.20 ms | +0.5% |
| ✅ | block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 |
162.08 µs | 162.78 µs | +0.4% |
| ✅ | matmul/small_generic_f16_threads=1/1x256x256 |
28.47 µs | 28.57 µs | +0.4% |
| ✅ | block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 |
42.88 µs | 42.98 µs | +0.2% |
| ✅ | matmul/medium_generic_bf16_threads=1/32x512x512 |
496.00 µs | 497.10 µs | +0.2% |
| ✅ | matmul/small_generic_bf16_threads=1/1x256x256 |
29.56 µs | 29.60 µs | +0.1% |
| ✅ | qwen3_sampling_processors/top_k_full_sort_baseline |
1.99 ms | 1.99 ms | +0.0% |
| ✅ | reduce_mean/small_f32_threads=1-internal/4096 |
14.02 µs | 14.02 µs | -0.0% |
| ✅ | matmul/large_generic_bf16_threads=1/32x1024x1024 |
1.86 ms | 1.86 ms | -0.1% |
| ✅ | matmul/small_generic_f32_threads=1/1x256x256 |
35.07 µs | 35.04 µs | -0.1% |
| ✅ | matmul/medium_generic_f16_threads=1/32x512x512 |
28.68 µs | 28.64 µs | -0.2% |
| ✅ | gather/small_f16_threads=1-internal/4096 |
444.9 ns | 443.9 ns | -0.2% |
| ✅ | grammar_masking/llguidance_compute_mask/32 |
71.51 µs | 71.28 µs | -0.3% |
| ✅ | sampling_latency/greedy_per_token |
3.06 µs | 3.04 µs | -0.4% |
| ✅ | sampling_latency/top_p_per_token |
360.19 µs | 358.31 µs | -0.5% |
| ✅ | gather/large_f16_threads=1-internal/131072 |
11.16 µs | 11.10 µs | -0.6% |
| ✅ | sampling_latency/top_k_per_token |
49.74 µs | 49.43 µs | -0.6% |
| ✅ | sampling_latency/min_p_per_token |
196.72 µs | 195.36 µs | -0.7% |
| ✅ | gather/medium_bf16_threads=1-internal/32768 |
2.26 µs | 2.24 µs | -0.8% |
| ✅ | matmul/large_generic_f16_threads=1/32x1024x1024 |
76.65 µs | 75.93 µs | -0.9% |
| ✅ | matmul/small_generic_f32_threads=8/1x256x256 |
33.29 µs | 32.82 µs | -1.4% |
| ✅ | qwen3_sampling_processors/top_p_fast_after_top_k |
499.56 µs | 492.46 µs | -1.4% |
| ✅ | matmul/large_generic_f16_threads=8/32x1024x1024 |
81.86 µs | 80.46 µs | -1.7% |
| ✅ | gather/small_f32_threads=1-internal/4096 |
637.0 ns | 623.1 ns | -2.2% |
| ✅ | matmul/medium_generic_f32_threads=8/32x512x512 |
928.45 µs | 905.33 µs | -2.5% |
| ✅ | block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 |
460.17 µs | 445.75 µs | -3.1% |
| ✅ | gather/large_f32_threads=1-internal/131072 |
29.30 µs | 27.08 µs | -7.6% |
| ✅ | matmul/large_generic_f32_threads=8/32x1024x1024 |
4.05 ms | 3.65 ms | -10.0% |
| ✅ | gather/large_bf16_threads=1-internal/131072 |
12.65 µs | 10.78 µs | -14.7% |
Visual flags:
Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 3.07 3.03 3.12 }
What this cannot catch
- Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
- Sub-threshold regressions that compound over multiple PRs
- Performance changes that only manifest under GPU execution
- Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)
Review follow-ups on the QLinearMatMul assignment change: * Mixed signedness (`u8 x i8`, `i8 x u8`) follows the u8 rule rather than borrowing the signed claim, but only the deferral above the measured region was pinned by a test. The claim below it is now pinned too, in both operand orders, so the intended behaviour cannot drift silently. * Both rules are measured on x86-64 AVX2 and applied on every architecture. That is the convention the rest of the module uses, but it was implied rather than stated; the code and the doc now say so, and say explicitly that ARM speed is not claimed to be measured. * The `i8 x i8` "before" ratios were re-measured against ORT 1.27.0 for this round and are worse than an earlier round recorded for the same unchanged declined path. Noted so the baseline shift is not read as a regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…pool The matrix has said since #1054 that the residual 1.4-2.4x at 2-16 threads is "a threadpool/partitioning problem". Measured directly, it is not. Driving MLAS's packed integer GEMM straight through this crate's work-stealing pool at K=N=3584 M=512 gives 99.9 ms at one thread, 14.3 ms at eight and 13.6 ms at sixteen -- a 7.0-7.4x speedup, about what ORT achieves. MLAS requests exactly `pool_threads` partitions per call and every one is dispatched, which `mlas_threading_stats()` confirms. What does not scale is the per-call work *around* the primitive: densifying the activation, allocating and zeroing the `i32` accumulator buffer, requantizing, and the executor's own tensor handling. At M=512 that is ~2 ms against a 100 ms GEMM at one thread and the same ~2 ms against a 14 ms GEMM at eight. Amdahl, not the pool. Adds `qlinear_phase_report` (`#[ignore]`d) which reproduces the split, and records that this host is shared enough that the unattributed remainder varies 3x run to run -- so it is stated as a bound, not a breakdown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Same correction as the previous commit, applied to the in-tree note that the deferral reasons are written from. The note claimed the residual 1.4-2.4x at 2-16 threads is "a threadpool/partitioning problem". Measured directly it is not: MLAS's packed GEMM through this crate's pool scales 7.0-7.4x at 8-16 threads and receives exactly `pool_threads` partitions per call. The serial per-call work around the primitive is what fails to shrink. The deferral itself is unchanged -- we still lose, so we still defer. Only the stated cause changes, and with it the direction of the open work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Three things in one PR, and they land in three different places under the strategy the owner set today. Reviewing them separately. First, a blocker that applies regardless: the pre-pack is a fourth unaccounted resident weight buffer"The kernel now takes We found three of these in the CPU path this week and they cost real money:
The rule from #1056: any allocation that outlives a single kernel call and scales with weight size must be declared to the plan before it is allocated, in the bytes actually allocated, and must be declinable. Please also report peak RSS beside the timings. At Second: change 2 is the best thing in this PR and it is ours, not MLAS'sThe signedness translation is a genuine piece of knowledge, and the argument is airtight: the kernel computes Third: change 1 deepens the MLAS dependency, and today's direction is the oppositeThe owner's direction, set today: do not bundle MLAS by default; progressively absorb its optimizations into our native kernels. MLAS is a reference implementation we learn from, not a dependency we ship behind. There is a hard fact behind that (filed as #1091): What I would like:
Not merging until the pre-pack is accounted and declinable. The rest I am happy with. |
Review findings on the previous two commits, all in prose: MAJOR - the reversal was argued entirely from the integer `QLinearMatMul` proxy, but the conclusion governs f32 dense and int4 `MatMulNBits`. Those kernels have no requantize and no `i32` accumulator staging, so the named phases and their milliseconds do not transfer. Only "the pool can drive an MLAS GEMM to 7x on this host, so it is not the ceiling" transfers, because all three kernels share the pool. Now stated explicitly, with the f32/int4 decomposition called out as open work rather than implied to be done. MAJOR - "~2 ms, roughly constant in the thread count" was the repeatable requantize+alloc slice presented as the whole serial cost. The instrument says non-GEMM work is ~2-4% of the call at one thread and 22-40% at eight, dominated by an unattributed term that is *larger* at eight threads than at one and varies 3x run to run. Reported as a bound with the phase table, and the "roughly constant" claim dropped for the term it is false of. MINOR - "exactly `pool_threads` partitions per call" was cited from counters the committed test never printed. The report now prints `sched_per_call` and `serial_fallback`, and the doc distinguishes the proven inequality from the observed equality. MINOR - labelled the carried-over 1.4-2.4x as ours/ORT at point of use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
## What `cargo fmt --all -- --check` currently **fails on `origin/main`** (`bce03cabb`) in five places across `crates/onnx-runtime-ep-cpu/src/kernels/gemm.rs` and `crates/onnx-runtime-ep-cpu/src/kernels/matmul.rs`. This is the `cargo fmt --all` output and nothing else. ## Why it happened Nobody wrote badly-formatted code. #1073, #1079 and #1080 each touched these two files and each was fmt-clean against its own base. Squash-merging them produced a combined text that rustfmt formats differently: - `TRANSPOSE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())` is 61 characters, which exceeds rustfmt's default `chain_width` of 60 once it sits at test-body indentation (two sites). - The `onnx_runtime_ir` import list grew past `max_width` and now wants braces on their own lines. - One `Gemm`/`transB` attribute chain became short enough to fit on a single line after a neighbouring edit. - One stray double blank line at end of a `mod tests`. `main` is unprotected, so no required check re-ran fmt on the merge result and the breakage landed silently. ## How it was found It blocked #1086: that PR's `Fast (Linux x86_64)` and `Rust quality` jobs failed on the PR **merge ref** with diffs in files #1086 does not touch. Reproduced independently by checking out `origin/main` into a clean worktree and running `cargo fmt --all -- --check`. ## Verification - `cargo fmt --all -- --check` -> clean (was: 5 diffs). - Diff is whitespace/line-breaking only; `git diff -w` on the two files is empty apart from the import-brace move. No logic, no behaviour, no test changes. ## Risk None. Mechanical formatter output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Renamed the new table heading. #1086 landed `i8 x i8` `QLinearMatMul` as **claimed** rows inside the very table this PR had titled "Deferred ranges", which would have made the heading a lie the moment both merged. It is now "Measured ranges below the 8-bit `MatMulNBits` win" and says explicitly that the i8 rows at the bottom are claimed. - `MatMul` f32 M=128 T=8 is tagged `defer (noisy)` again. The commit message called 2.76/2.85 "repeatable"; four independent re-runs gave p50 1.79, 1.71, 2.62, 2.21. It is a range, not a point. The deferral is unaffected. - The two `Gemm` "before" figures (6.57, 46.67) are now attributed to #1080's report in a new column. They are not re-measurements and could not be -- the pre-#1080 kernel no longer exists to run, and `Gemm` had no f16 rows here because it had no f16 GEMV. - "~1.16x win at M=1" is softened to a 1.0x-1.16x band. An independent re-run of `Gemm` M=1 T=1 gave 0.96 against the recorded 0.86; both are sub-1.00, so "no longer a loss" holds, but the size of the win is inside this host's noise. - "2.4x-14.3x faster than it was" is attributed to #1080 and marked as ours-before/ours-after, a different quantity from this table's ours/ORT ratios which cannot be re-derived by dividing them. - `Gemm` f16 M=1 T=4 at 0.98 is called out as the one sub-1.00 entry in the 2-16 thread band, with its p90 of 1.85 and spread of 1.01, rather than being quietly excluded from the stated range. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#1092) ## What Three corrections to `docs/performance/CPU_MATMUL_ASSIGNMENT.md`, plus review follow-ups. **Documentation only, no code.** **Convention for every number here: p50/p90 are `ours / ORT`, lower is better.** Below 1.00 we are faster than ORT, above 1.00 we are slower. ## 1. The f16 rows predate #1080 and were stale by up to 14x #1080 (`bce03cabb`) routed constant-weight f16 `MatMul`/`Gemm` prefill through MLAS SGEMM and added the f16 GEMV `Gemm` never had. The matrix was never re-measured. Re-measured now on the same host with the same interleaved A/B harness, `K = N = 3584`, five reps of thirty runs after five warmups, steady state: | threads | 1 | 2 | 4 | 8 | 16 | 32 | |---|---:|---:|---:|---:|---:|---:| | `MatMul` f16 M=1 | **0.86** | 1.47 | 2.06 | 1.93 | 2.01 | 3.83 | | `MatMul` f16 M=128 | 1.00 | 1.68 | 1.76 | 1.72 | 1.30 | 0.91 | | `Gemm` f16 M=1 | **0.86** | 1.13 | 0.98 | 1.83 | 2.05 | 4.20 | | `Gemm` f16 M=128 | 1.03 | 1.82 | 1.90 | 1.86 | 1.44 | 1.19 | All 24 cells go into the matrix. `Gemm` had no f16 rows at all before, because it had no f16 GEMV. Against the previous revision, ours/ORT p50: | | before #1080 | after #1080 | source of the "before" | |---|---:|---:|---| | `MatMul` f16 M=128, T=1 | 2.47 | **1.00** | this matrix, previous revision | | `MatMul` f16 M=128, T=4 | 6.57 | **1.76** | this matrix, previous revision | | `MatMul` f16 M=128, T=16 | 7.10 | **1.30** | this matrix, previous revision | | `Gemm` f16 M=1, T=1 | 6.57 | **0.86** | #1080's report | | `Gemm` f16 M=1, T=8 | 46.67 | **1.83** | #1080's report | The last two are **not** re-measurements and could not be — the pre-#1080 kernel no longer exists to run — and the table says so. Section 2 was titled "A kernel gap — f16 dense" and proposed exactly the fix #1080 shipped, so it is rewritten as closed. The one-thread kernel gap is gone: parity at M=128 (1.00 / 1.03) and **parity to a modest win at M=1** (0.86 p50 recorded, 0.96 on an independent re-run, so the honest band is ~1.0x-1.16x faster than ORT). **It stays deferred, and that is not a contradiction.** The thread count is not visible at capability time, so a claim must hold at *every* count, and f16 now loses at 2-16 threads exactly the way f32 and int4 do. f16 has stopped being a kernel-quality story and joined the parallel-efficiency story in section 1; it should be re-evaluated for a claim when that is fixed. Meanwhile deferral only applies where a host fallback exists, so native-only sessions still run our f16 kernel — which #1080 reports as 2.4x-14.3x faster than its predecessor (ours-before/ours-after, a different quantity from this table's ours/ORT ratios). ## 2. One stale f32 row `MatMul` f32 M=128 T=8 was recorded at 1.38 / 2.49. It now measures 2.76 / 2.85, and is tagged `defer (noisy)` because four independent re-runs gave p50 1.79, 1.71, 2.62, 2.21 — it is a range, not a point. The deferral is unchanged. Other rows spot-checked and still accurate within noise: f32 M=128 T=1 1.00 (recorded 0.97), int4 acc-0 M=1 T=8 2.22 (recorded 2.23), int4 acc-0 M=128 T=8 2.31 (recorded 2.41). ## 3. Half the matrix did not render at all Every row from `MatMulNBits` 4-bit onwards — about forty rows, the entire "why we defer" half — was glued to the end of the preceding paragraph with **no blank line and no header row**. GitHub rendered it as literal pipe-separated text, not a table. Pre-existing on `main`; found while editing those rows. It now has its own heading, a header row, and an explicit restatement of the ours/ORT direction convention at the point of use. Verified every row in both matrix tables has exactly 8 columns. ## Review follow-ups applied - The new heading is **"Measured ranges below the 8-bit `MatMulNBits` win"**, not "Deferred ranges". #1086 landed `i8 x i8` `QLinearMatMul` as **claimed** rows inside this very table, so the original heading would have become false the moment both merged. Merged `main` and resolved that conflict here, keeping the new f16 rows *and* #1086's new QLinear rows; verified #1086's rewrite of section 1 and the stale f16 section-2 text did not come back. - Attributed the two `Gemm` "before" figures and the 2.4x-14.3x native speedup to #1080 rather than implying they were measured here. - Softened the M=1 win to a band and disclosed the 0.96 re-run. - Called out `Gemm` f16 M=1 T=4 = 0.98 as the one sub-1.00 entry in the 2-16 thread band (p90 1.85, spread 1.01, i.e. noise) instead of quietly excluding it from the stated range. ## Verification - Every f16 and f32 number was produced by `perf-scratch/p50p90.py` driving `bench_generic` built with `--features mlas`, interleaving ours and ORT in the same process, with a parity check before timing (all `PASS`). - Independently reproduced by the reviewer with their own build: `matmul_f16` M=128 T=1 → 1.00/1.01 (exact match), T=8 → 1.70/1.98 (match), `gemm_f16` M=1 T=1 → 0.96/1.13 (consistent; drove the softening above). - Host: AMD EPYC 9V74, 32 vCPU / 16 physical cores, AVX2+FMA+F16C, no AVX-512/VNNI/AMX. Shared and contended, hence p50 **and** p90 and the "noisy" tags. - `assignment_policy.rs` is untouched and still defers f16 (`assignment_policy.rs:566`), so the doc has not drifted from the code. ## Risk None. Markdown only. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Seven session-lifetime, weight-scaled caches exist in the CPU EP and only three were ever declared to the memory-strategy plan (#1056). Each ungoverned one was written by someone who did not know the rule existed -- a bare `OnceLock<Vec<T>>` field is easy to add and invisible to the plan. Two of the three that are governed were governed only after doing damage: one took a 14B int4 model to ~66 GB, another took its peak working set from 8.17 GB to 25.5 GB while the ledger read zero. A seventh landed on main today (#1086) after I asked for accounting. `GovernedWeightCache<T>` (21cd05b) makes the accounting structural, but nothing stopped a new bare `OnceLock` field. This is the crude defence: the pattern was found by grep in twenty minutes, so a grep can guard it. It asks the author one question rather than blocking them -- if the buffer does not scale with weight size, say so and add the `weight-cache-reviewed` label to record the judgement. Validated against real history, four commits: | commit | contains | verdict | | --- | --- | --- | | `d0fdfa47` (#105) | introduces `packed_filter`/`packed_bias` | flagged | | `ac394fd6` (#1086) | introduces the QLinearMatMul pre-pack | flagged | | `21cd05b3` | adds `GovernedWeightCache` itself | passes | | `af04a613` | unrelated Exp vectorisation | passes | My first pattern missed both real cases -- it matched `OnceLock<Vec<..>>` but not `OnceLock<Option<Vec<..>>>` or `OnceLock<Option<(Key, PackedB)>>`. Worth recording because it is the same failure mode as a vacuous test: a guard that cannot fire on the cases that motivated it looks like protection and is not. The falsification against known-bad commits is what caught it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d60eb808-7cc6-4abc-b48d-2a6dd3841624
What
QLinearMatMulre-packed the wholeK x Nweight inside MLAS on every call —mlas_qgemm_i32leftdata.BIsPacked = false— and copied the weight into a fresh dense buffer first (to_dense_bytes(&inputs[3]), 12.8 MB atK=N=3584). AtM=1that setup was the call.Separately, MLAS documents signed activations as unsupported off ARM, and
u8 x i8can saturate itsi16pair accumulator on AVX2 without VNNI, so those combinations were declined to a scalar loop and lost to ONNX Runtime by 5-6x.Three changes:
MlasGemmPackBSize/MlasGemmPackBand aBIsPackedGEMM entry point. The kernel now takesset_constant_inputsand holds the pack for its lifetime, keyed on the weight's full identity — address,K,N, both signedness flags, and whether the bytes were sign translated. Once the pack exists the dense copy is skipped entirely.XOR 0x80on its bytes,+128on its zero point. The kernel computessum_k (a_k - za)(b_k - zb), so shifting an operand and its zero point by the same constant leaves everyi32accumulator bit-identical, and the call lands on theu8 x u8kernel this file already trusts as exact.PARALLEL_MIN_WORKthey run onpar_chunks_mutinto a pre-sized buffer instead ofVec::pushper element, anda_scale * b_scale / y_scaleis evaluated once per column rather than once per element whena_scaleis per tensor (which the MLAS route already requires). The association is untouched, so the results are the samef32bits.Measurements
AMD EPYC 9V74 (32 vCPU / 16 physical cores, AVX2 + FMA + F16C, no AVX-512/VNNI/AMX), ONNX Runtime 1.27.0, same process, same binary, interleaved A/B, parity asserted on every rep,
K = N = 3584, 3 warmups, 7 runs x 5 reps.All ratios are
ours / ORT— lower is better,< 1.00means we are faster. Steady state (weight pre-packed on both sides).Before this PR (
main, 8 threads)(The
i8column is the declined scalar loop. An earlier round recorded 2.23-3.07 for that same unchanged path; the difference is the ORT 1.27.0 baseline and the harness on this shared host, not a regression — the kernel side did not change between the two rounds.)After this PR
Absolute milliseconds for the widest case,
M=512, so the ratios can be divided out:i8wins because ORT's own signed path is the slow one — ORT needs 469.78 ms where it needs 100.45 ms for the same shape inu8. After the translation both dtypes run the identical MLASu8 x u8kernel here at the same speed (124.13 vs 121.28 ms), so this is a real assignment win rather than an artefact of our two paths differing.Cold vs steady
The benchmark harness runs a parity check before it starts timing, so it never sees a first call. Measured separately by
qlinear_pack_cost_report(#[ignore]d),K = N = 3584,M = 1:The pack is repaid on the first call: +0.45 ms once, then -5.79 ms on every call after.
Memory: one MLAS packed panel per distinct
(weight address, K, N, signedness), ~12.9 MB atK=N=3584. The kernel cache keys on node plus input shapes, so a model executed at several bucketed shapes holds one panel per bucket.Assignment matrix change
i8 x i8u8 x u8K*N >= 2^20u8 x u8K*N < 2^20u8 x i8,i8 x u8K*N >= 2^20u8 x i8,i8 x u8K*N < 2^20Both rules are measured on x86-64 AVX2 only and applied on every architecture, which is the convention the rest of that module uses. aarch64 has native
i8 x i8kernels (SDOT/SMMLA) that need no translation at all, so the claim there is if anything conservative — but its speed is unmeasured here and is not claimed to be measured. Correctness on that lane is covered unconditionally byqgemm_i32_matches_the_integer_oracle_for_every_signedness.This does not make u8 fast. The residual
u8 x u8gap is thread scaling — 1.13-1.20x at one thread, widening to 2.08-2.65x at sixteen — which is the same root cause already documented for f32 dense and int4MatMulNBits. It is deferred, anddocs/performance/CPU_MATMUL_ASSIGNMENT.mdsays so.Correctness
Nine new tests, and every new behaviour is falsified by a single injection (each was run with the guard removed and confirmed to fail):
flip_sign_domainuses0x40instead of0x80qlinear_matmul_reordered_accumulation_is_bit_identicalqlinear_matmul_reordered_accumulation_is_bit_identicalqlinear_matmul_reordered_accumulation_is_bit_identicala_different_weight_is_never_served_the_cached_packa_constant_weight_is_packed_once_and_reusedpack_keydrops the batch guardeach_pack_guard_declines_on_its_ownpack_builddrops thek * nlength guardeach_pack_guard_declines_on_its_ownqlinear_matmul_defers_unsigned_activations_in_the_measured_regionqlinear_matmul_claims_signed_activationsqlinear_matmul_claims_signed_activationsqlinear_matmul_claims_signed_activationsalso pins mixed signedness in both operand orders, above and below the measured region, so it cannot silently drift into either rule.The bit-identity sweep now includes
(96, 40, 900), which is abovePARALLEL_MIN_WORK, so the parallel and serial row walks are both compared against a transcription of the previous loop, for both dtypes and per-tensor as well as per-axis quantization.Three tests were also added in
mlas-sysproving the packed quantized path is bit-identical to the unpacked one for every signedness and both zero-point layouts, with achecked > 0guard so they cannot pass vacuously.Validation
cargo test -p onnx-runtime-ep-cpu --features mlas— 1269 passcargo test -p onnx-runtime-ep-cpu(default features) — 1259 passcargo test -p mlas-sys— 37 passcargo clippy --all-targetsclean with and without--features mlas(CI'sRust qualityruns it without)cargo clippy --all-targets --target aarch64-unknown-linux-gnucleancargo fmt --all -- --checkcleanNXRT_CPU_GEMM_BACKEND=generic(the x86-side proxy for the ARM lanes) leaves only the 3 pre-existing tests that assert auto-detection itselfAlso here: the parallel-scaling gap is not the threadpool
The matrix has claimed since #1054 that the residual 1.4-2.4x loss at 2-16 threads on f32
dense and int4
MatMulNBitsis "a threadpool/partitioning problem". Building the packedQGEMM entry point above made that claim directly testable, and it is wrong.
Driving
qgemm_i32_packedstraight through this crate's work-stealing pool,K = N = 3584,M = 512, warmed:7.0x at eight threads and 7.4x at sixteen -- about what ORT achieves.
mlas_threading_stats()shows MLAS requesting exactly
pool_threadspartitions per call at every count, all of themdispatched, and
serial_fallback_calls == 0. The primitive and the pool both scale.What does not scale is the serial per-call work around the primitive: densifying the
activation, allocating and zeroing the
i32accumulator, requantizing, and the executor's owntensor handling. At
M = 512that is ~2 ms against a 100 ms GEMM at one thread (2%) and thesame ~2 ms against a 14 ms GEMM at eight (14%). Amdahl, not scheduling.
So the two commits after the review correct the attribution in
docs/performance/CPU_MATMUL_ASSIGNMENT.mdand inassignment_policy.rs'sDECODE_PARALLEL_NOTE, and addqlinear_phase_report(#[ignore]d) which reproduces thesplit. No deferral changes -- we still lose, so we still defer; only the stated cause, and
with it the direction of the open work, changes.
Honesty note: this host is shared and heavily contended. The GEMM and requantize columns above
are repeatable; the unattributed remainder varied 7.7 / 11.1 / 22.7 ms across three
consecutive runs of the identical configuration, so it is reported as a bound, not a
breakdown. Separately, the end-to-end harness reports 33.6 ms at
M = 512,T = 8where thekernel alone measures 23.8 ms, implying ~10 ms/call of executor/session overhead outside the
kernel entirely. That is an unexplored lead, not a claim.
Not addressed here
u8 x u8thread scaling (deferred, documented).u8 x i8at runtime, so a VNNI or AMX host picks the native combination up automatically rather than through an ISA table.