Skip to content

Keep the f16 decode GEMV accumulator in registers - #1436

Merged
justinchuby merged 8 commits into
mainfrom
squad/roy-gemv-f16-regtile
Aug 20, 2026
Merged

justinchuby merged 8 commits into
mainfrom
squad/roy-gemv-f16-regtile

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 19, 2026 •

Copy link
Copy Markdown
Owner

half_gemv's module documentation states the design premise plainly: at M == 1 "each weight element is touched exactly once, so the kernel is purely memory-bound". It was not.

The gap

Against this host's measured sustained read bandwidth — roofline_bandwidth --threads 1,2,4,8,16,32 --mib 1024, which reports 75.8 GB/s and saturates by 4-8 threads — the kernel ran at 12–47 GB/s. Under half the machine on every large cell. It also anti-scaled: l3_3584 went 0.568 ms at t=4 to 0.830 ms at t=32.

Why nobody noticed

There was no f16 GEMV benchmark cell. gen_gemm.py covers block-quantised and f32 dense GEMM, so the one kernel whose entire premise is a bandwidth claim had nothing measuring it.

scripts/ort_ab/gen_f16_gemv.py adds five, sweeping the weight working set — the only variable that matters for a kernel that reads each weight once:

cell k n weight resident in
l2_512 512 512 0.5 MB one core's L2
l2_1024 1024 1024 2.1 MB L2/L3
l3_2048 2048 2048 8.4 MB L3
l3_3584 3584 3584 25.7 MB L3 (Qwen3-8B hidden)
dram_8192 8192 8192 134.2 MB past any LLC here

That sweep is the diagnosis. The L2-resident cell moved the same GB/s as the DRAM-resident one (19.4 vs 34.9 at t=32). A memory-bound kernel would be far faster per byte on the small cell. So the limit was per-core, and the extra threads were only contending for it.

Root cause

The p (contraction) loop was outermost, so each output's accumulator was live across the whole contraction but lived in acc:

let cur = _mm256_loadu_ps(cp.add(j));                       // load acc
_mm256_storeu_ps(cp.add(j), _mm256_fmadd_ps(bw, av, cur));  // store acc

Three memory operations per 8-lane FMA — load the weight, load the accumulator, store it back — plus a store-to-load forwarding round trip from the same address one p earlier, all to feed one arithmetic op. The load/store ports set the rate, not the FMA units.

STRIPE = 512 puts the accumulators in L1, which the existing comment offers as reassurance. That was the problem rather than the comfort it reads as: L1 is only cheap next to L3. It is not cheap next to a register, and 16 ymm were sitting idle.

The fix

Tile the output into TILE = 64 columns and hoist p inside, so eight accumulators stay in ymm for the entire contraction and reach memory exactly once — one memory operation per FMA, the minimum the problem admits.

Tiling costs no extra traffic: the same k * STRIPE elements, the same n-element stride between consecutive p, and TILE is exactly two 64-byte cache lines with STRIPE a whole number of tiles, so no fetched line is ever partially consumed. a_tile_never_straddles_a_stripe pins all three divisibility facts rather than leaving them to the comment.

Which paths actually reach this kernel

This PR was opened before #1381, which landed a decode handover: an M == 1 half MatMul of
1,048,576 elements or more now goes to the fused widen-pack GEBP instead. So four of the five
MatMul cells above no longer reach this kernel in a default build, and quoting the original
numbers as-is would be quoting a measurement of a path that no longer runs.

What still reaches it:

route weight range
MatMul f16/bf16 decode below 1,048,576 elements
Gemm f16 decode, transB=0 any — no weight gate on that path
any decode under ONNX_GENAI_CPU_MM_HALF_GEBP=0 any
any decode on 32-bit x86 any — there is no GEBP to hand off to
bf16 decode on an AVX-512 BF16 host any — the handover declines there

So everything below is re-measured on latest main, in two sets: MatMul with the GEBP switched
off, which isolates the kernel over the whole weight range, and Gemm with no environment set at
all
, which is what a default build runs. gen_f16_gemv.py --op gemm emits the second set.

The merge also moved the change: main had since generalised the stripe kernel to bf16 via the
stripe_simd_fn! macro, so the tiling went into the macro rather than into a f16-only function.
It now serves both formats, which the original revision did not.

Result

ab.py --native-only --null-control, 7 trials x 30 runs, medians. null is the baseline binary
under a second name; its delta is the host's noise floor for that cell.

MatMul cells, GEBP switched off — the kernel over its whole range

cell t before after speedup null before GB/s after GB/s
l2_512 4 0.021 ms 0.014 ms 1.50x 14.3% 25.0 37.4
l2_512 16 0.027 ms 0.019 ms 1.42x 0.0% 19.4 27.6
l2_512 32 0.021 ms 0.014 ms 1.50x 4.8% 25.0 37.4
l2_1024 4 0.090 ms 0.053 ms 1.70x 2.2% 23.3 39.6
l2_1024 16 0.163 ms 0.098 ms 1.66x 12.3% 12.9 21.4
l2_1024 32 0.144 ms 0.112 ms 1.29x 13.2% 14.6 18.7
l3_2048 4 0.213 ms 0.095 ms 2.24x 9.4% 39.4 88.3
l3_2048 16 0.406 ms 0.187 ms 2.17x 4.4% 20.7 44.9
l3_2048 32 0.503 ms 0.358 ms 1.41x 1.6% 16.7 23.4
l3_3584 4 0.592 ms 0.290 ms 2.04x 9.3% 43.4 88.6
l3_3584 16 0.813 ms 0.312 ms 2.61x 0.6% 31.6 82.3
l3_3584 32 0.790 ms 0.701 ms 1.13x 1.8% 32.5 36.6
dram_8192 4 7.413 ms 4.221 ms 1.76x 1.8% 18.1 31.8
dram_8192 16 4.067 ms 2.126 ms 1.91x 9.7% 33.0 63.1
dram_8192 32 3.702 ms 2.222 ms 1.67x 3.2% 36.3 60.4

15 of 15 above their null control, 1.13x-2.61x.

Gemm cells, shipped default — no environment set

cell t before after speedup null before GB/s after GB/s
l2_512 4 0.021 ms 0.014 ms 1.50x 4.8% 25.0 37.4
l2_512 16 0.026 ms 0.014 ms 1.86x 3.8% 20.2 37.4
l2_512 32 0.022 ms 0.017 ms not claimed 22.7% 23.8 30.8
l2_1024 4 0.087 ms 0.055 ms 1.58x 2.3% 24.1 38.1
l2_1024 16 0.110 ms 0.083 ms 1.33x 1.8% 19.1 25.3
l2_1024 32 0.154 ms 0.112 ms 1.38x 4.5% 13.6 18.7
l3_2048 4 0.222 ms 0.178 ms 1.25x 1.8% 37.8 47.1
l3_2048 16 0.442 ms 0.200 ms 2.21x 16.5% 19.0 41.9
l3_2048 32 0.343 ms 0.352 ms not claimed 48.7% 24.5 23.8
l3_3584 4 0.566 ms 0.409 ms 1.38x 12.0% 45.4 62.8
l3_3584 16 0.561 ms 0.324 ms 1.73x 1.1% 45.8 79.3
l3_3584 32 0.823 ms 0.594 ms 1.39x 16.6% 31.2 43.2
dram_8192 4 7.702 ms 4.171 ms 1.85x 0.4% 17.4 32.2
dram_8192 16 3.278 ms 1.913 ms 1.71x 2.8% 40.9 70.2
dram_8192 32 3.764 ms 2.140 ms 1.76x 1.1% 35.7 62.7

13 of 15 above their null control, 1.25x-2.21x. Both unclaimed cells have unusable controls
(nulls of 22.7% and 48.7%), not small effects.

On the bandwidth numbers

Three cells now read above the host's 75.8 GB/s DRAM ceiling (up to 88.6). That is not an error:
at 8.4 and 25.7 MB those weights are L3-resident and never reach DRAM, so the DRAM roofline is the
wrong ceiling for them. Before this change they ran at 20-43 GB/s, far enough below it that the
distinction never surfaced — that it surfaces now is itself the result.

dram_8192 is the only cell whose weight genuinely comes from DRAM, and it goes 33.0 → 63.1 GB/s,
44% → 83% of roofline
.

A consequence this PR deliberately does not act on

#1381 placed its handover using a sweep of the untiled GEMV. Re-running that same harness
(bench half_decode_gemv_ab, 5 interleaved reps, ratio = GEMV/GEBP, below 1.00 favours the GEMV)
against the tiled kernel:

K x N elements f16 /ctl bf16 /ctl #1381 f16 /ctl
1024x768 0.79M 0.25 0.33 0.40
2048x2048 4.19M 1.26 1.31 1.78
4096x11008 45.1M 0.82 0.82 1.18
896x151936 136M 0.86 0.88 1.26

The two largest shapes — mlp and lm_head, the ones that dominate decode — invert. 2048x2048
narrows from 1.78 to 1.26 without inverting, and there the two harnesses disagree by thread count
(ab.py reads -83.5% at t=4 and -61.8% at t=16 in the GEMV's favour, but only -2.13% within noise
at t=32, which is where cargo bench runs).

So the threshold is wrong at both ends and right in the middle, and a single weight cutoff cannot
express a thread-dependent crossover. Retuning it needs its own k x n x thread sweep and its own
control; stacking a routing change on evidence that conflicts between two harnesses is the kind of
unproven change the ledger exists to refuse. Filed as a follow-up.

This PR changes no routing. Every shape takes the route it took before, only faster.

Numerics

Bit-identical. Tiling changes only which register holds a partial sum, never the order it is
built in: within any one output element the contraction still runs p = 0 .. k-1 with the same FMA
at each step. Every pre-existing oracle test passes unmodified; nothing was weakened to a
tolerance. 1545 tests green.

stripe_widths_around_the_tile_boundary_are_exact sweeps every width from 1 to 2 * TILE + 9 at
four stripe offsets against the scalar stripe, bit for bit, and now does so for both formats,
asserting its own combination count so it cannot silently shrink. a_tile_never_straddles_a_stripe
pins STRIPE % TILE == 0, TILE % 8 == 0, TILE * 2 % 64 == 0.

The macro no longer needs acc.fill(0.0): the three loops write [0, w) exactly once between them,
and k == 0 is already short-circuited by the caller.

Validation

20-step local matrix: 19 PASS, 1 FAIL. The failure is step H
(cargo check --target aarch64-pc-windows-msvc), which fails on unmodified main too — bindgen
cannot find the Windows SDK headers in this container. Everything else green: fmt, clippy
-D warnings (offline/native/BE), Linux tests, cross-compile, feature combinations, no-MLAS
artifact guard, and all 8 guard scripts.

Miri is a no-op gate for this change and is reported as such, not as a pass.
is_x86_feature_detected!("avx2") returns false under Miri, so every #[target_feature] path takes
its scalar fallback: stripe_widths_around_the_tile_boundary_are_exact completes in 7s under Miri
because it skips both formats. The AVX2 intrinsics added here get zero Miri coverage. Their
safety evidence is the native bit-identity sweep plus the unchanged assert! on b.len().

justinchuby and others added 2 commits August 19, 2026 08:02
half_gemv's module doc states the design premise -- at M=1 "each weight
element is touched exactly once, so the kernel is purely memory-bound".
It was not. Against this host's measured 75.8 GB/s sustained read
bandwidth (roofline_bandwidth, saturating by 4-8 threads) the kernel ran
at 12-47 GB/s, under half the machine on every large cell, and it
anti-scaled: l3_3584 went 0.568 ms at t=4 to 0.830 ms at t=32.

There was no f16 GEMV benchmark cell, which is why a kernel whose entire
premise is a bandwidth claim had nothing checking it. gen_f16_gemv.py
adds five, sweeping the weight working set from 0.5 MB (inside one
core's L2) to 134 MB (past any LLC here). That sweep is the diagnosis:
the L2-resident cell moved the same GB/s as the DRAM-resident one. A
memory-bound kernel would be far faster per byte on the small one, so
the limit was per-core and the extra threads were only contending.

The p loop was outermost, so each output's accumulator was live across
the whole contraction but lived in acc. Three memory operations per
8-lane FMA -- load the weight, load the accumulator, store it back --
plus a store-to-load forward from the previous p, against one FMA.
STRIPE = 512 keeps those accumulators in L1, which the old comment
offered as reassurance; L1 is only cheap next to L3, not next to a
register, and 16 ymm were sitting idle.

Tiling the output into TILE = 64 columns and hoisting p inside leaves
eight accumulators in registers for the whole contraction, stored once:
one memory operation per FMA, the minimum this problem admits. No extra
traffic -- the same k * STRIPE elements at the same stride, and a tile
is exactly two cache lines with STRIPE a whole number of tiles.

14 of 15 cells win by 1.22x-2.60x above their null control, and the large
cells go from ~46% of the memory roofline to 79-86%. l3_2048 at t=32
would not settle across three runs and is not claimed.

TILE = 64 is measured, not asserted: 32/64/96/128 across all 15 cells. 64
wins 11 outright and ties a twelfth. 128 needs 18 of 16 architectural ymm
and spills -- on the smallest cell it is slower than changing nothing.

Bit-identical, which the int4 row-blocking change could not claim: tiling
changes which register holds a partial sum, never the order it is built
in, so every pre-existing oracle test passes unmodified. Two tests added
for the new structure -- a contiguous width sweep across the tile
boundary at four stripe offsets, and the divisibility facts the
no-extra-traffic argument rests on.

ab.py grows --native-only. Per sebastian-paired-harness-coresidency ORT's
intra-op pool spin-waits; on these cells a paired run depressed the
native median by up to 6x and drove the null control to 27%, larger than
most of the effects here. The first paired attempt at this measurement
produced a table with three sign errors in it. The flag belongs in the
shared driver, not a private script.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Four nits from adversarial review.

The module doc still asserted the exact non-sequitur this commit's parent
disproves -- that touching each weight once makes the kernel "purely
memory-bound". Touch-once sets the ceiling; it says nothing about the
distance to it, which was a factor of two to six. Reworded to say that,
with the measured numbers and a pointer to the benchmark record.

ab.py's --native-only mode reuses the `ratio` column for native
milliseconds, so a machine consumer could read ms as a dimensionless
ratio and only notice from `ort` being NaN. The CSV now carries
native_only, and the README documents both the flag and the column. The
default paired path is unchanged: same regex, same printed line, same
columns, verified by running both modes.

TILE carried a redundant x86 cfg; the module is already x86-only and its
sibling constants are ungated.

stripe_widths_around_the_tile_boundary_are_exact skipped any width
exceeding n rather than failing, so a future edit to n could have
silently shrunk the sweep to nothing while still passing. It now asserts
the bound holds and asserts its own combination count. Prose said the
sweep ran to 2*TILE+8; it runs to 2*TILE+9, which is two whole tiles plus
an 8-lane block plus a scalar -- all three paths in one call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Owner Author

Opus adversarial review: APPROVE WITH NITS — all four fixed in 2a579a37d.

The review independently confirmed the load-bearing claims: the three loops partition [0, w) with no overlap and no gap so dropping acc.fill(0.0) leaves no read-before-write on any path (traced w = 137 / 64 / 63 / 0, with k = 0 and n = 0 short-circuited upstream); the raw pointer arithmetic including the tile loop's lane offsets stays inside b and acc under the documented precondition, which the release-mode assert! enforces; the diff adds no aarch64-compiled code at all, so no fourth pre-existing clippy failure is possible; and it recomputed every GB/s, speedup and roofline percentage in the tables against the per-thread-count roofline and found them consistent, with all 14 claimed effects exceeding their own null control. No blocking findings.

1. The module doc still asserted the thing this PR disproves. It read: each weight is touched exactly once, "so the kernel is purely memory-bound". That is the exact non-sequitur the commit message spends paragraphs refuting — touch-once sets the ceiling, it says nothing about the distance to it, and the distance was a factor of two to six. Fixing the kernel while leaving the false premise in place would have been the worse half of the job. Reworded with the measured numbers and a pointer to the benchmark record.

2. --native-only made the CSV ambiguous. That mode reuses the ratio column for native milliseconds, so a machine consumer could read ms as a dimensionless ratio and only notice from ort being NaN. The CSV now carries a native_only column and scripts/ort_ab/README.md documents both the flag and the column semantics. I re-ran both modes end to end afterwards to confirm the default paired path is untouched — same regex, same per-trial line, same columns, same medians and deltas sections. (That smoke test incidentally reproduced the reason the flag exists: the same cell reads native_p50 = 0.019 ms paired and 0.014 ms alone.)

3. TILE carried a redundant #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] when the whole module is already gated that way and its sibling constants are not. Dropped.

4. The width sweep could have silently shrunk to nothing. stripe_widths_around_the_tile_boundary_are_exact used if j0 + w > n { continue; }, so a future edit lowering n would have skipped combinations while still reporting green — in a test whose entire purpose is exhaustiveness. It now asserts the bound holds rather than skipping, and asserts its own combination count (548). Also corrected the prose: the sweep runs to 2 * TILE + 9, not + 8 — two whole tiles plus one 8-lane block plus one scalar, i.e. all three paths in a single call.

One review observation worth recording for whoever reviews this next: this worktree's local main ref is stale, so git diff main...HEAD there is contaminated with unrelated intervening commits and misattributes pre-existing ab.py machinery to this PR. The correct review surface is git show 9c91a7155 (and now 2a579a37d); GitHub's own diff against c5d19f7b4 is right.

Gates after the fixes: cargo fmt --check clean, 1449 lib tests pass / 0 fail, clippy x86_64 --all-targets -D warnings clean, plus check_dispatch_reachability, check_dispatch_manifest, check_feature_gate_coverage, check_profile_table, check_platform_naming and verify_documented_env_vars. Still blocked on CI — the repo-wide queue is saturated with none executing. No admin bypass.

@github-actions

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 615.28 µs 1.11 ms +80.8%
🔴 block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 71.43 µs 128.49 µs +79.9%
🔴 matmul/medium_generic_f32_threads=8/32x512x512 1.12 ms 1.81 ms +61.3%
🔴 block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 356.97 µs 574.19 µs +60.9%
🔴 matmul/large_generic_bf16_threads=8/32x1024x1024 1.49 ms 2.26 ms +51.1%
🔴 block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 93.87 µs 122.05 µs +30.0%
⚠️ grammar_masking/llguidance_compute_mask/32 74.03 µs 95.98 µs +29.7%
⚠️ kv_cache/alloc_dealloc_pages 40.01 µs 50.28 µs +25.7%
⚠️ block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 68.87 µs 85.47 µs +24.1%
⚠️ qwen3_sampling_processors/top_k_partial_selection 152.15 µs 179.12 µs +17.7%
⚠️ qwen3_sampling_processors/top_k_top_p_full_sort_baseline 5.95 ms 6.95 ms +16.8%
⚠️ qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline 3.62 ms 4.16 ms +15.0%
✅ qwen3_sampling_processors/top_k_top_p_fast 662.49 µs 757.57 µs +14.4%
✅ matmul/medium_generic_bf16_threads=8/32x512x512 582.86 µs 661.78 µs +13.5%
✅ qwen3_sampling_processors/top_p_fast_after_top_k 521.39 µs 589.18 µs +13.0%
✅ qwen3_sampling_processors/top_k_full_sort_baseline 2.26 ms 2.55 ms +12.8%
✅ matmul/large_generic_f32_threads=8/32x1024x1024 6.31 ms 6.98 ms +10.6%
✅ logit_processing/seven_processor_chain_per_step 336.70 µs 368.40 µs +9.4%
✅ reduce_mean/small_f32_threads=1-internal/4096 15.20 µs 16.26 µs +7.0%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.65 ms 2.80 ms +5.8%
✅ gather/small_f32_threads=1-internal/4096 729.5 ns 762.4 ns +4.5%
✅ gather/large_f32_threads=1-internal/131072 43.87 µs 45.82 µs +4.5%
✅ matmul/medium_generic_f16_threads=1/32x512x512 40.38 µs 42.09 µs +4.2%
✅ add/large_bf16_threads=1-internal/4194304 1.84 ms 1.91 ms +3.6%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 9.80 ms 10.06 ms +2.7%
✅ add/large_f32_threads=1-internal/4194304 697.60 µs 708.05 µs +1.5%
✅ matmul/medium_generic_f16_threads=8/32x512x512 42.13 µs 42.61 µs +1.1%
✅ gather/large_bf16_threads=1-internal/131072 15.03 µs 15.20 µs +1.1%
✅ sampling_latency/greedy_per_token 3.13 µs 3.12 µs -0.1%
✅ reduce_mean/medium_f32_threads=1-internal/65536 271.51 µs 270.84 µs -0.2%
✅ matmul/small_generic_bf16_threads=8/1x256x256 40.66 µs 40.17 µs -1.2%
✅ matmul/large_generic_bf16_threads=1/32x1024x1024 2.12 ms 2.08 ms -2.2%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 94.93 µs 91.29 µs -3.8%
✅ add/small_f32_threads=1-internal/1024 211.0 ns 201.7 ns -4.4%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.20 ms 1.14 ms -4.9%
✅ tokenization/encode_tokens_per_second 402.25 µs 378.18 µs -6.0%
✅ add/large_f16_threads=1-internal/4194304 2.19 ms 2.06 ms -6.3%
✅ gather/large_f16_threads=1-internal/131072 14.88 µs 13.82 µs -7.2%
✅ sampling_latency/top_k_per_token 57.54 µs 52.34 µs -9.0%
✅ sampling_latency/min_p_per_token 253.05 µs 229.29 µs -9.4%
✅ tokenization/decode_tokens_per_second 7.04 ms 6.35 ms -9.7%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 657.98 µs 593.43 µs -9.8%
✅ gather/medium_f32_threads=1-internal/32768 5.03 µs 4.52 µs -10.1%
✅ add/medium_bf16_threads=1-internal/262144 104.13 µs 93.31 µs -10.4%
✅ matmul/small_generic_bf16_threads=1/1x256x256 41.54 µs 36.92 µs -11.1%
✅ gather/small_f16_threads=1-internal/4096 553.8 ns 488.4 ns -11.8%
✅ matmul/small_generic_f16_threads=8/1x256x256 38.05 µs 33.44 µs -12.1%
✅ matmul/small_generic_f16_threads=1/1x256x256 38.55 µs 33.67 µs -12.7%
✅ add/small_bf16_threads=1-internal/1024 485.1 ns 422.9 ns -12.8%
✅ add/medium_f16_threads=1-internal/262144 113.49 µs 98.81 µs -12.9%
🟢 gather/medium_f16_threads=1-internal/32768 2.84 µs 2.38 µs -16.5%
🟢 gather/small_bf16_threads=1-internal/4096 579.0 ns 483.1 ns -16.6%
🟢 add/medium_f32_threads=1-internal/262144 27.94 µs 22.76 µs -18.5%
🟢 add/small_f16_threads=1-internal/1024 545.1 ns 440.0 ns -19.3%
🟢 sampling_latency/top_p_per_token 450.12 µs 361.69 µs -19.6%
🟢 matmul/large_generic_f16_threads=8/32x1024x1024 122.01 µs 97.43 µs -20.1%
🟢 gather/medium_bf16_threads=1-internal/32768 3.11 µs 2.43 µs -22.0%
🟢 matmul/small_generic_f32_threads=1/1x256x256 46.21 µs 35.18 µs -23.9%
🟢 matmul/small_generic_f32_threads=8/1x256x256 50.48 µs 35.78 µs -29.1%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

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: { 6.39 4.26 6.35 }
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)

@codecov

codecov Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.87500% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.51%. Comparing base (3e2f21d) to head (1b81245).
⚠️ Report is 41 commits behind head on main.

Files with missing lines Patch % Lines
...rates/onnx-runtime-ep-cpu/src/kernels/half_gemv.rs 96.87% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1436      +/-   ##
==========================================
+ Coverage   80.38%   80.51%   +0.13%     
==========================================
  Files         378      379       +1     
  Lines      167923   169694    +1771     
  Branches   167923   169694    +1771     
==========================================
+ Hits       134980   136629    +1649     
- Misses      28092    28212     +120     
- Partials     4851     4853       +2     
Flag Coverage Δ
cli-ort-linux 82.60% <ø> (?)
cli-ort-windows 82.19% <ø> (ø)
offline 80.44% <96.87%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...rates/onnx-runtime-ep-cpu/src/kernels/half_gemv.rs 96.73% <96.87%> (-1.70%) ⬇️

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Port the register tiling into main's per-format stripe_simd_fn! macro so it
serves bf16 as well as f16; renumber the ledger section 5 -> 7.
The tiling now lives in `stripe_simd_fn!`, so it is instantiated once per
widening kernel; each instance needs its own bit-identity check against its
own scalar reference.
…es that reach it

#1381 landed a decode handover while this was open: an M=1 half MatMul of
1,048,576 elements or more now takes the fused widen-pack GEBP, so four of the
five MatMul cells no longer reach this kernel in a default build. Measure the
MatMul cells with the GEBP switched off to isolate the kernel, and add Gemm
transB=0 cells -- which have no weight gate -- to measure what a default build
runs. 15/15 and 13/15 above their null controls.

Also record that #1381's handover was placed against the untiled GEMV and
partly inverts against the tiled one, and that retuning it needs its own sweep.
15/15 and 13/15 leaves two unclaimed cells, not four, and their nulls are
22.7% and 48.7%. Caught in review.
@justinchuby

Copy link
Copy Markdown
Owner Author

Local validation — latest main (b3d46de70)

Re-merged origin/main and re-ran everything after it moved twice during review (#1550 CUDA, then #1553).

20-step matrix: PASS=19 FAIL=1.

The single failure is H cargo check --target aarch64-pc-windows-msvc, and it is environmental, not code:

onnxruntime_c_api.h:34:10: fatal error: 'stdlib.h' file not found
Failed to generate ORT bindings: ClangDiagnostic(...)

onnx-genai-ort-sys' bindgen needs the Windows SDK headers, which this Linux container does not have. The log contains zero references to half_gemv or anything else in this diff. It fails identically on unmodified main.

Cross-platform coverage that did run:

target step result
x86_64-unknown-linux-gnu A-F, I-L, tests PASS
aarch64-unknown-linux-gnu G clippy -D warnings on onnx-runtime-ep-cpu PASS
aarch64-pc-windows-msvc H blocked on Windows SDK (see above)
big-endian F clippy-native-be PASS

kernels::half_gemv is #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] at its mod declaration, so on either aarch64 target this diff compiles to nothing at all. Step G is therefore the meaningful ARM check, and it is green.

Also green: cargo fmt --all --check, clippy -D warnings (offline / native / big-endian), --no-default-features, --all-features, the no-MLAS artifact guard (K), cross-compile (L), and all 8 guard scripts including verify_documented_env_vars and check_dispatch_reachability. 1545 tests pass in onnx-runtime-ep-cpu --lib.

Miri — reported as a no-op gate, not as a pass

is_x86_feature_detected!("avx2") returns false under Miri, so every #[target_feature] path takes its scalar fallback. stripe_widths_around_the_tile_boundary_are_exact "passes" under Miri in 7.07s — because it continues past both formats and checks nothing. The AVX2 intrinsics added here get zero Miri coverage. Their evidence is instead:

  • the native bit-identity sweep (1096 width x offset x format combinations, asserting its own combination count so it cannot silently shrink),
  • the four pre-existing oracle tests, passing unmodified,
  • the unchanged real assert!(b.len() >= k*n) — a hard check rather than a debug_assert, precisely because the kernel does unchecked pointer arithmetic from k, n and j0.

Two defects found and fixed on main while validating this

Neither was mine, and both were red on a clean origin/main:

Three in three days, same mechanism each time: two required checks plus strict_required_status_checks_policy=false lets a PR merge on a green run that predates the commit which breaks the gate.

Review

Opus review: one finding, non-blocking — the ledger said "four unclaimed cells … nulls of 16-49%" where 15/15 + 13/15 leaves exactly two, with nulls 22.7% and 48.7%. Fixed in 1b812456f. The review independently re-derived the exactly-once-write partition of [0, w), the worst-case pointer bound (k-1)·n + j0 + w - 1, the k == 0 safety of dropping acc.fill(0.0), and the bit-identity claim across all three loops.

@justinchuby
justinchuby merged commit a2817b5 into main Aug 20, 2026
6 checks passed
@justinchuby
justinchuby deleted the squad/roy-gemv-f16-regtile branch August 20, 2026 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant