Repository navigation
Decode int4 nibbles once per row tile in MatMulNBits prefill - #1431
Conversation
The scheduler isolated gemm_nbits_llama3_8b_qkv_t8 at ~10x behind ORT measured native-alone and flat across 8/16/32 threads. It reproduces on f8f3878 at 6.837 ms against the report's 6.90 ms, and we actually regress from 16 to 32 threads while ORT keeps scaling. Two defects. First, the fixed path was switched off: borrowed_int4_prefill_block_enabled() defaulted to false, so every build anyone ran took the row-serial route. That is #1091's default-off SGEMV and #1080's mlas-gated f16 fix a third time -- a toggle added "until the win is measured", and then nothing measured it. §36.2 of the phase-18 document had already written down that it was dead in the default build. Second, and this is the expensive one, the kernel re-decoded the whole weight for every activation row. Per 32-lane chunk of one column it spends ~18 instructions unpacking nibbles to feed four FMAs, so >80% of the instruction stream is decode, and the row loop sat outside all of it. Time was exactly linear in m -- 0.890 / 0.859 / 0.851 ms per row at m = 1 / 8 / 128 -- which is what zero row reuse looks like, and 0.61 FLOP/cycle/thread against ORT's 6.07 says a kernel sitting entirely inside one 32 MiB L3 slice is not waiting for memory. borrowed_int4_rowblock_avx2 decodes each K block once and drives PREFILL_ROW_BLOCK = 4 rows through the decoded vectors, taking the budget per 32 MACs from ~22 instructions to ~8.5. Within a row it is instruction-for-instruction the single-column case of the existing NCols4 kernel. Default flipped on, variable kept as a kill switch. Twelve int4 prefill cells, twelve wins, 2.53x-5.22x, no regressions; the target cell goes 7.087 -> 2.339 ms and 9.35x -> 3.09x behind ORT. Per-row cost now falls with m (0.292 / 0.226 / 0.191 at m = 8 / 128 / 512) instead of sitting flat at ~0.9. Two plausible causes were built and rejected rather than assumed: a 64-column L2 tile (bandwidth) moved nothing, and the single-fork-join path is worth 6.897 -> 6.874 ms. Both are recorded. Row blocking ends byte-identity with the per-element path (~2 ULP, the same lanewise-accumulator trade NCols4 already documents; nibble decode is untouched). That test is rewritten to check both paths against an f64 oracle and require the blocked one to be no worse, normalising by sum|a|*|w| because these dot products cancel hard enough that normalising by the result would measure the test data instead of the kernel. Byte-identity is still asserted across fan-out partitions, across row-tile boundaries, and against NCols4 for a single row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Three nits from adversarial review. The sentence justifying ROWS = 4 said 8 leads "only on the t8 cells". Its own table shows 8 taking llama3_8b_qkv_t128 -- a wide cell -- by 1.15x. Since the whole point of that paragraph is to correct an earlier unmeasured ROWS claim, shipping a second inaccurate one about the same sweep is not acceptable. Now states what the table says: 4 takes eight of twelve, 8 takes three t8 cells and that one wide cell, and the wide exception is called out as unexplained rather than smoothed over. The decision does not change. A doc comment still pointed at the pre-rename test name. every_probe_perturbing_test_takes_the_dispatch_lock now also requires the lock from tests naming borrowed_int4_prefill_block_enabled. It moves no probe counter, but this commit's parent makes it read process-global env on the default path, so an unlocked reader races any test flipping ONNX_GENAI_CPU_MM_INT4_PREFILL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Opus adversarial review: APPROVE WITH NITS — all three fixed in The review verified the new kernel line-by-line against 1. The ROWS sweep summary was wrong — again. I wrote that 2. Dangling reference to the renamed test. 3. Hardened the dispatch lock (raised as a latent opportunity, taken). Two review observations I am recording rather than acting on: the kernel is Gates after the fixes: |
Reconciliation required: #1356 and #1431 collide on int4 prefillI dry-ran the full merge order in a scratch worktree. #1356 and #1431 both make
So a naive merge makes #1431's kernel unreachable for every A/B of the two kernelsBoth paths exist in one binary and are selectable by their kill switches, so I
Neither kernel dominates, and the reason is structural: the blocked kernel A useful internal check: at Consequence for #1356's threshold
ResolutionI will carry this in the merge commit when the second of the two lands: keep Reconciled default vs current
Neither PR needs a code change on its own branch; the interaction only exists in |
Latest-main root cause for the
|
| threads | 1 | 2 | 4 | 8 | 16 |
|---|---|---|---|---|---|
| qkv (ms) | 56.2 | 28.1 | 14.2 | 10.7 | 6.43 |
| mlp (ms) | 185.3 | 102.8 | 46.4 | 27.7 | 14.29 |
That is 8.75x / 12.97x on 16 threads. Sustained DRAM on this host is 75.8 GB/s, which a bandwidth-bound kernel would saturate by ~4–6 threads. It scales, so it is issue-bound — consistent with borrowed_int4_nblock4_avx2 spending ~16 decode instructions per 4 FMAs (80% of the instruction stream is nibble unpacking).
2. The decisive measurement: cost per row is flat
Interleaved m-sweep on llama3_8b_qkv, latest main:
| m | 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 |
|---|---|---|---|---|---|---|---|---|
| native / m (ms/row) | 1.209 | 1.446 | 1.325 | 1.348 | 1.276 | 1.307 | 1.256 | 1.284 |
| ORT / m (ms/row) | 0.252 | 0.243 | 0.135 | 0.094 | 0.069 | 0.132 | 0.094 | 0.050 |
Native per-row cost is constant at ~1.28 ms/row from m=1 to m=128 — B is fully re-decoded for every single row, zero amortization. ORT's per-row cost falls 5x (0.252 → 0.050): it dequantizes once and runs SGEMM over all rows.
That is the entire gap, and it is why the deficit grows with m (4.7x at m=1 → 20.8x at m=128). The fix is amortizing the decode across rows — precisely what #1356 (GEBP panels) and #1431 (column-blocked row reuse) do. A better inner loop cannot close this; the f32 widen+convert has a hard floor of ~8 instructions per 32 values.
3. The reconciled stack, validated on latest main
origin/main + #1356 + #1431 with the measured INT4_PREFILL_GEBP_MIN_ROWS = 12 crossover. Three arms interleaved per iteration; statistic is the minimum of 4 (see note 4).
| m | main (ms) | reconciled (ms) | speedup | gap vs ORT (main) | gap vs ORT (reconciled) |
|---|---|---|---|---|---|
| 1 | 1.202 | 1.199 | 1.00x | 4.73x | 4.72x |
| 2 | 2.373 | 1.005 | 2.36x | 5.08x | 2.15x |
| 4 | 4.808 | 1.329 | 3.62x | 8.99x | 2.48x |
| 8 | 10.027 | 2.551 | 3.93x | 12.27x | 3.12x |
| 16 | 20.722 | 4.066 | 5.10x | 5.22x | 1.03x |
| 32 | 40.145 | 4.942 | 8.12x | 10.16x | 1.25x |
| 64 | 82.024 | 6.079 | 13.49x | 15.91x | 1.18x |
| 128 | 159.562 | 9.234 | 17.28x | 20.84x | 1.21x |
native/m for the reconciled stack now falls 1.20 → 0.094 ms/row, the same shape as ORT's curve. m=1 is exactly neutral (1.00x) — as it must be, since both prefill paths are gated (m >= 12 for GEBP, m >= 2 for blocked), so decode executes identical code.
Remaining loss is concentrated at m=4–8 (2.5–3.1x); from m=16 up we are within 1.03–1.25x of ORT.
4. Two measurement artifacts I found and am retracting
Both were mine, and both would have become false claims:
- "16 threads beats 32 by 1.69x." A sequential thread sweep showed
llama3_8b_qkv_t8at 9.975 ms (t32) vs 5.892 ms (t16). Re-run interleaved, the difference vanished: 1.00x on three of four cells. It was host drift, not SMT oversubscription. - "The reconciled stack regresses m=1 by 47%." Medians said 1.209 → 1.777 ms. But intra-arm spread at m=1 is 2.14–2.57x, so a median of 9 is meaningless there; the minima agreed to 1.2% (1.184 vs 1.170), and static analysis confirms no dispatch change is even reachable at m=1.
Methodology this establishes for these cells: interleave the arms every iteration, and use the minimum, not the median — this host is shared, so contention can only ever add time.
Consequence for issue #1453
#1453 proposed an int8-activation inner product (vpmaddubsw/vpmaddwd) on the theory that nibble-decode ALU cost was the binding constraint. The ALU-bound diagnosis is confirmed, but the premise that it needs a numerics-changing fix is not: amortization recovers the gap to 1.03–1.25x for m>=16 while staying bit-identical and fp32-exact. I am deprioritizing #1453 accordingly and retargeting the residual at m=4–8.
Where the residual m=4–8 gap lives: GEBP materializes a panel 6.4x larger than its own inputFollow-up to the amortization result. Measured on latest Fitting the reconciled GEBP curve
The fixed term is the panel, and it is running at DRAM speed:
Why this is the whole remaining storyNibble decode itself is cheap when done once: 25.2M weights at ~12 instructions per 32 values is ~9.4M instructions, which at 16 cores is well under 0.1 ms. The earlier finding that the kernel is ALU-bound was about the row-serial path, which paid that cost This also explains the shape of the residual precisely: the fixed term is amortized by large m (which is why m>=16 is already within 1.03–1.25x of ORT) and is not amortized at m=4–8, which is exactly where the remaining 2.5–3.1x sits. The fixORT's Expected effect on Filed as a separate issue; not in scope for #1356/#1431, which should land as measured (2.36x–17.28x over main, m=1 exactly neutral). |
🔴 Benchmark Regression DetectedComparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).
Visual flags: Host infoWhat this cannot catch
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1431 +/- ##
===========================================
- Coverage 82.10% 80.30% -1.80%
===========================================
Files 12 377 +365
Lines 5471 166791 +161320
Branches 5471 166791 +161320
===========================================
+ Hits 4492 133947 +129455
- Misses 780 28006 +27226
- Partials 199 4838 +4639
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
# Conflicts: # crates/onnx-runtime-ep-cpu/src/kernels/matmul_nbits.rs
#1356's column-blocked borrowed kernel amortises its nibble decode over four rows, so the GEBP crossover tuned at m=4 against the old row-serial kernel is now three to six times too early: every prefill with 4 <= m < 12 was taking a GEBP path the column-blocked kernel beats by up to 3.4x. The crossover tracks whether the packed weight stays cache-resident across the row loop, not m alone: a 1.0 MB weight crosses at m=24 while 12.6 MB and 29.4 MB weights both cross at m=12. Gate on packed-weight bytes accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…reject Review found that raising the int4 GEBP crossover strands block_size=16. Both column-blocked kernels require 32-element blocks, so for the ONNX-minimum block size GEBP is the only vectorised prefill there is: raising its threshold drops m in [4, 12) to a scalar per-block dot on the narrow decode pool instead of to a better kernel. Hold those weights at the pre-retune threshold. Also fixes a clippy::assertions_on_constants failure under -D warnings in the new crossover test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Converged on latest main (
|
m |
qwen3-0.6B QKV (1.0 MB) | llama3-8B QKV (12.6 MB) | llama3-8B MLP (29.4 MB) |
|---|---|---|---|
| 2 | 0.50x | 0.33x | 0.29x |
| 4 | 0.32x | 0.36x | 0.44x |
| 8 | 0.50x | 0.73x | 0.81x |
| 12 | 0.69x | 1.01x | 1.01x |
| 16 | 0.80x | 1.35x | 1.26x |
| 24 | 1.14x | 1.80x | 1.98x |
| 32 | 1.68x | 2.24x | 2.35x |
It is not one row count — it tracks whether the packed weight stays cache-resident across the row loop. So: 12 above 4 MB of packed weight, 24 at or below. A flat 12 would hand the small shape a 1.45x regression at m = 12..23; a flat 24 would give up 1.35x-1.80x on the large shapes at m = 16..23. The 4 MB split is fitted to exactly two weight sizes. It is a threshold, not a model.
Review fix (blocking + substantive). Opus review caught two things I missed:
clippy::assertions_on_constantsin the new test — a hard error under-D warnings, would have failed CI.- Raising the crossover strands
block_size = 16. Both column-blocked kernels require 32-element blocks, so for the ONNX-minimum block size GEBP is the only vectorised prefill that exists; raising its threshold dropsm ∈ [4, 12)to a scalar per-block dot on the decode pool (the comment right above measures that pool at ~2.4x slower for prefill) rather than to a better kernel. Those weights now hold at the pre-retune threshold of 4. Pinned byint4_prefill_gebp_crossover_holds_for_blocks_the_row_kernels_reject.
Non-blocking and accepted as-is: self.k * self.n / 2 under-counts the true packed size for ragged K (real count is n * ceil(k/block_size) * block_size/2). Immaterial against a 4 MB threshold; usize is 64-bit on every target that compiles this path.
#1431 vs origin/main 000694cc7
Separate binaries built from each tree, native-alone, min of 7 interleaved reps, parity=PASS on all 18:
| cell (int4) | main | #1431 | speedup |
|---|---|---|---|
qwen3_0p6b_qkv_t1 |
0.278 | 0.274 | 1.01x |
qwen3_0p6b_qkv_t8 |
0.401 | 0.347 | 1.16x |
qwen3_0p6b_qkv_t128 |
1.464 | 1.485 | 0.99x |
qwen3_0p6b_mlp_t1 |
0.435 | 0.416 | 1.05x |
qwen3_0p6b_mlp_t8 |
0.771 | 0.565 | 1.36x |
qwen3_0p6b_mlp_t128 |
2.357 | 2.343 | 1.01x |
llama3_8b_qkv_t1 |
1.284 | 1.242 | 1.03x |
llama3_8b_qkv_t4 |
2.725 | 1.012 | 2.69x |
llama3_8b_qkv_t8 |
2.961 | 1.933 | 1.53x |
llama3_8b_qkv_t16 |
3.241 | 3.259 | 0.99x |
llama3_8b_qkv_t32 |
4.023 | 4.039 | 1.00x |
llama3_8b_qkv_t128 |
8.280 | 8.301 | 1.00x |
llama3_8b_mlp_t1 |
2.822 | 2.683 | 1.05x |
llama3_8b_mlp_t4 |
6.024 | 2.944 | 2.05x |
llama3_8b_mlp_t8 |
6.481 | 4.424 | 1.46x |
llama3_8b_mlp_t16 |
7.061 | 7.004 | 1.01x |
llama3_8b_mlp_t32 |
8.589 | 8.595 | 1.00x |
llama3_8b_mlp_t128 |
17.387 | 17.483 | 0.99x |
18 cells, worst 0.99x. The wins land exactly where predicted — m = 4..8, the band the retune reclaims — and m = 1, 16, 32, 128 are neutral because both trees dispatch identically there. t1 is untouched by design.
Local gate matrix
Run on the merged head ec1869da8, latest main merged in normally:
| step | result |
|---|---|
A cargo fmt --all --check |
PASS |
| B build offline (default features) | PASS |
C cargo test -p onnx-runtime-ep-cpu |
PASS |
D cargo clippy --all-targets -- -D warnings |
PASS |
E test with mlas feature |
PASS |
| F clippy native backend | PASS |
G clippy --target aarch64-unknown-linux-gnu -- -D warnings |
PASS |
H check --target aarch64-pc-windows-msvc |
FAIL — environmental |
I ep-cpu --no-default-features |
PASS |
J ep-cpu --all-features |
PASS |
| K no-MLAS-symbols in default artifact | PASS (nm -D = 0) |
L scripts/check_cross_compile.sh |
PASS |
| S × 8 guard scripts | PASS |
Miri (strided, provider, dtype, task_runtime) |
PASS, 4/4 suites, 0 failures |
19/20. Step H fails because onnx-genai-ort-sys bindgen needs the Windows SDK (fatal error: 'stdlib.h' file not found) — reproduced on plain origin/main in a clean worktree, so it is pre-existing and not caused by this branch. scripts/check_cross_compile.sh is a no-op for cross-arch on Linux (it resolves TARGET to the native triple), which is why step G runs the aarch64 clippy explicitly rather than trusting L.
Every item touched here is #[cfg(target_arch = "x86_64")] with no non-x86 caller, so aarch64 keeps the pre-existing route unchanged — step G is the evidence for that.
Remaining losses
Unchanged by this PR and stated in the doc: int4 prefill is still 2.5x-7.3x behind ORT, and the gap is now structural rather than a tuning one — MLAS SQNBit's CompInt8 path quantises activations to int8 and uses integer dot products, where this kernel still widens every nibble to f32. AVX-512/VNNI, which is exactly what would make an int8 decode path pay off, is untestable on this host.
#994) — re-measured, ~3x below roofline, not ~63x (#1013) ## What Adds `roofline_gemv`, a CPU-only bench probe (behind a lean `gemv-probe` feature) that measures the **effective memory bandwidth the real CPU int4 `MatMulNBits` decode kernel achieves** at the 14B `lm_head` shape — the last unmeasured term in #994's placement criterion `F/C_slow < W/B_link + F/C_fast`. It is the compute-side companion to `roofline_bandwidth` (host DRAM STREAM ceiling) and `roofline_transfer` (host↔device link). ##⚠️ Retraction: the original "false by ~63×" claim was wrong **This PR originally reported that the CPU `lm_head` GEMV runs at ~0.78 GB/s — 1.6% of the DRAM ceiling — making CPU compute ~500 ms and inverting #994's placement criterion by ~63×. That result does not reproduce and is retracted.** Two things were wrong with it: 1. **It measured a kernel that no longer exists.** The probe was written against the #979 borrowed symmetric-int4 path when that path was a scalar unpack-convert-FMA loop on x86 (only AArch64 had the NEON dot). #1021 (`3cf49d25`, merged 2026-08-16) vectorised that GEMV for AVX2/AVX-512, and #1356/#1431 have since reworked the prefill dispatch around it. The number was real when taken and is meaningless now. 2. **~63× was never the right way to state it even then.** The comparison mixed a weight-only rate against a total-traffic ceiling and reported a single best sample from a heavily shared box. ## Re-measured on `origin/main` (`085c49140`), same box, same probe DRAM read ceiling, `roofline_bandwidth --mib 2048 --seconds 3`: | threads | GB/s | |---:|---:| | 8 | 75.099 | | 16 | 75.460 | | 32 | **75.737** | The int4 `lm_head` GEMV, `roofline_gemv --repeats 7` (K=5120, N=152064, block_size=32, bits=4, accuracy_level=0; weight 389.3 MB + scales 97.3 MB = 487.2 MB total traffic): | threads | per-call ms (median) | per-call ms (min) | weight GB/s (best) | total GB/s (best) | |---:|---:|---:|---:|---:| | 8 | 52.560 | 52.330 | 7.439 | 9.311 | | 16 | 29.349 | 26.388 | 14.752 | 18.464 | | 32 | 19.785 | 17.936 | 21.705 | **27.166** | **Corrected result: the kernel reaches 27.2 GB/s of 75.7 GB/s — it is ~2.8× below the roofline, not ~63×.** Per-token `lm_head` compute is **~18–20 ms**, not ~500 ms. ## What that does to #994's criterion The criterion no longer inverts. At ~18–20 ms of CPU compute against ~33 ms to move the work to the GPU, CPU `lm_head` is **cheaper**, not 15× more expensive — the opposite of what this PR originally concluded. #994's original assumption (that the GEMV runs at DRAM bandwidth, ~7.9 ms) is still optimistic by ~2.5×, but it is the right order of magnitude and the placement decision it drives stands. The remaining 2.8× is the honest open question this probe exists to track: the kernel still widens every nibble to f32 and streams 97 MB of f32 scales alongside 389 MB of packed weights, so it is not purely bandwidth-limited even after vectorisation. ## Why this is still worth merging The conclusion was wrong; the instrument was not. `roofline_gemv` is what caught the error, and it is the only probe in the tree that measures the real quantised decode GEMV against the real DRAM ceiling on the same box. Merging it makes that measurement repeatable instead of a claim in a PR description. ## How it stays faithful - Drives the real kernel via the CPU EP's own `get_kernel`/`execute` — no reimplementation. - Confirms the borrowed symmetric-int4 dispatch is taken (bits=4, accuracy_level=0, no prepack/g_idx, symmetric → `None` zero points). - Runs warmup+timing inside one `with_decode_pool_scope` so each `execute` dispatches to the **persistent SPMD decode pool** the runtime actually uses — not a per-call fork/join of the flat pool (which understated bandwidth ~10×). - Sweeps thread counts one process per count via `set_decode_thread_budget`. - Reports both a weight-only rate (the #994 criterion input) and a total-traffic rate (honest roofline efficiency, incl. the f32 scales), and a distribution rather than one sample. ## Scope **Measurement, not implementation.** No kernels are restructured. `mlas`/`accuracy_level=4` (MLAS SQNBit) deliberately not measured — the default artifact carries zero MLAS symbols by design, so it is not the path the runtime takes. --------- Co-authored-by: justinchuby <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roy <roy@squad.local>
Closes the scheduler's
llama3_8b_qkv_t8report.The report
gemm_nbits_llama3_8b_qkv_t8was ~10x behind ORT measured native-alone (not the 41x paired artifact), flat across 8/16/32 threads. It reproduces onf8f3878baat 6.837 ms against the note's 6.90 ms — three significant figures — and we actually regress from 16 to 32 threads while ORT keeps scaling.Shape is
k = 4096,n = 6144,m = 8: 201 M MACs against 12.58 MiB of packed int4.Two defects
1. The fixed path was switched off.
borrowed_int4_prefill_block_enabled()defaulted tofalse, so every build anyone ran took the row-serial route. This is #1091's default-off SGEMV and #1080'smlas-gated f16 fix a third time: a toggle added "until the win is measured", and then nothing measured it. §36.2 of the phase-18 document had already written down "it is dead in the default build".2. The kernel re-decoded the weight for every activation row. Per 32-lane chunk of one column the int4 inner loop spends ~18 instructions unpacking nibbles — load, two masks, a shift, two
unpacks, eight widen/converts — to feed four FMAs. >80% of the instruction stream is decode, and the row loop sat outside all of it.Two independent measurements agree:
m: 0.890 / 0.859 / 0.851 ms per row atm= 1 / 8 / 128. That is what zero row reuse looks like.What it was not — built and rejected, not assumed
ONNX_GENAI_CPU_MM_INT4_PREFILL=1selects the path whose entire purpose is replacingmfork-joins with one: 6.874 ms vs the default's 6.897 ms.The fix
borrowed_int4_rowblock_avx2decodes each K block's nibbles once and drivesPREFILL_ROW_BLOCK = 4rows through the decoded vectors — ~22 instructions per 32 MACs down to ~8.5. Within any one row it is instruction-for-instruction the single-column case of the existingNCols4kernel. Default flipped on; the variable stays as a kill switch.ROWS = 4is chosen by a sweep, not by assertion — I had written "ROWS = 8measured slower" into the constant's docs before measuring, and 8 is in fact faster on the target cell. 4 takes eight of the twelve cells. 8 takes threet8cells and one wide one,llama3_8b_qkv_t128, by 1.15x — which the register-pressure argument does not predict and I cannot explain; it is one cell against the seven other wide prefills, which 4 takes by up to 1.31x. Full table in the constant's docs.Result — 32 threads, native-alone
Separate
--native-only/--ort-onlyarms persebastian-paired-harness-coresidency. "before" isONNX_GENAI_CPU_MM_INT4_PREFILL=0, exactly the path that shipped.llama3_8b_qkv_t8llama3_8b_qkv_t128llama3_8b_qkv_t512llama3_8b_mlp_t8llama3_8b_mlp_t128llama3_8b_mlp_t512qwen3_0p6b_qkv_t8qwen3_0p6b_qkv_t128qwen3_0p6b_qkv_t512qwen3_0p6b_mlp_t8qwen3_0p6b_mlp_t128qwen3_0p6b_mlp_t512Twelve cells, twelve wins, 2.53x–5.22x, nothing regressed. It also scales again — target cell 12.182 → 4.714 (t=8), 5.646 → 1.893 (t=16), 6.596 → 2.332 (t=32).
Row reuse now shows up where its absence used to: per-row cost falls with
m— 0.292 / 0.226 / 0.191 ms atm= 8 / 128 / 512, against a flat ~0.9 before.Numerics
Row blocking ends byte-identity with the per-element path (~2 ULP): that path reduces every 32-lane block to a scalar, this one keeps a lanewise accumulator and reduces once — the trade
borrowed_int4_nblock4_avx2already documents. Nibble decode is untouched.The test that asserted byte-identity is rewritten, not deleted: it now checks both paths against an f64 oracle over the dequantized weights and requires the blocked path to be no worse, so "it reassociates" cannot quietly become "it lost accuracy". Errors normalise by
sum |a| * |w|rather than by the result — these dot products cancel hard (ak = 256cell sums terms of order 0.25 down to a result of order 0.005), so normalising by the result would measure the conditioning of the test data instead of the kernel.Byte-identity is still asserted everywhere it is load-bearing: across fan-out partitions, across row-tile boundaries (
prefill_is_independent_of_the_row_tiling), and againstNCols4for a single row (rowblock_matches_nblock_for_a_single_row).the_blocked_int4_prefill_is_on_by_defaultpins the default — the part a future "add a toggle, default off" would silently undo.Constraints
Pure native throughout: no ORT fallback, no runtime MLAS, no admin CI bypass. Based on merged
main(f8f3878ba).Gates
cargo fmt --check; 1450onnx-runtime-ep-cpulib tests (4 new, 1 rewritten); clippy x86_64--all-targets -D warnings;verify_documented_env_vars.py. aarch64 clippy fails on three pre-existing dead-code errors (WIDE_PREFILL_MACS,prefill_fan_out,prefill_column_grain) that reproduce on a pristineorigin/mainworktree — filed as #1415, not from this diff.What this does not fix
Still 2.5x–7.3x behind ORT. The remaining gap is the decode itself: MLAS SQNBit's CompInt8 path quantizes activations to int8 and uses integer dot products, avoiding f32 dequantization entirely, where this kernel still widens every nibble to f32. That is structural, not tuning, and VNNI — which this host lacks — is what makes it pay.
qwen3_0p6b_qkv_t128at 7.29x is now the outlier. Them == 1decode route is untouched.Full record:
docs/benchmarks/2026-08-19-int4-prefill-row-blocking.md.