fix(cpu/matmul): widen GEMV weight row offsets to long (#429) - #431
fix(cpu/matmul): widen GEMV weight row offsets to long (#429)#431jamesburton wants to merge 3 commits into
Conversation
Two GEMV weight-offset expressions computed a row offset with int arithmetic, so a single tensor past the wrap point produced a negative offset and an out-of-bounds read rather than a clean failure. ComputeRows (`weightsQ8 + row * rowBytes`, 7 sites across the AVX-512+VNNI, AVX-512, AVX2 and scalar tiers) is the reachable one: the stride is in bytes (~1.0625 per weight), so the product wraps at 2 GB of tensor. Llama 3.1 405B's LM head — vocab 128256, hidden 16384, rowBytes (16384/32)*34 = 17408 — gives 128256 * 17408 = 2,232,680,448, over int.MaxValue by ~85 MB. The first affected row is 123,362, so the last 4,894 rows (3.8% of vocab) read from a wrapped negative offset on a shipping model. GemvF16 (`weightsHalf + row * k`, 2 sites) counts elements, not bytes, so on that same model m*k = 2,101,346,304 stays ~2.2% under the limit. Widened for consistency, but it needs a tensor larger than any current model to trigger. Only the GEMV paths were exposed. ComputeGemmTiled clamps row < tileRows <= 256 and pre-offsets its base pointer via `(long)mStart * q8RowBytes`, so the tiled path was already safe and is untouched. Activation-buffer offsets are left alone: each indexes a buffer whose size is the same product, so wrapping would need an >8 GB logits allocation. This matches the widening convention already used by GemmF16, GemmF32, ComputeGemmTiled and the F16 tiled row read — the Gemm paths had the cast, the Gemv paths did not. No performance cost. Verified by diffing the JIT's x64 for GemvQ8_0 between builds: the baseline already sign-extended the product (`imul ecx,r12d` then `movsxd rcx,ecx`); the cast moves the widening ahead of the multiply (`imul rcx,rax` on an already-widened row), which is what fixes the overflow. The 4-row fast path is one instruction shorter; total method size 1024 -> 1028 bytes. The arithmetic is in the outer row loop, amortized over 16-128 VNNI block iterations per row. A position-balanced timing harness could not resolve any delta: within-arm spread was 46-88%, dwarfing all measured differences. Reported-by: unsafePtr
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #429 by widening GEMV weight row-offset arithmetic in the CPU matmul kernels to long, aligning GEMV’s pointer math with the existing GEMM conventions and preventing int overflow for very large (multi‑GB) weight matrices.
Changes:
- Widened Q8_0 GEMV row pointer arithmetic in
ComputeRowsto avoid 32-bit overflow on large tensors. - Widened F16 GEMV row pointer arithmetic in
GemvF16to avoid 32-bit overflow for largem*kelement counts.
Suppressed comments (2)
src/DotLLM.Cpu/Kernels/MatMul.cs:162
- Only the first of the four row pointers is widened to long. The (row + 1/2/3) * rowBytes expressions still execute in 32-bit int arithmetic and can overflow for the same >2GB Q8_0 tensors, leading to incorrect/out-of-bounds reads for rows near the end of the matrix.
weightsQ8 + (long)row * rowBytes,
weightsQ8 + (row + 1) * rowBytes,
weightsQ8 + (row + 2) * rowBytes,
weightsQ8 + (row + 3) * rowBytes,
src/DotLLM.Cpu/Kernels/MatMul.cs:180
- Only the first of the four row pointers is widened to long. The (row + 1/2/3) * rowBytes expressions still execute in 32-bit int arithmetic and can overflow for the same >2GB Q8_0 tensors, leading to incorrect/out-of-bounds reads for rows near the end of the matrix.
weightsQ8 + (long)row * rowBytes,
weightsQ8 + (row + 1) * rowBytes,
weightsQ8 + (row + 2) * rowBytes,
weightsQ8 + (row + 3) * rowBytes,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| weightsQ8 + (long)row * rowBytes, | ||
| weightsQ8 + (row + 1) * rowBytes, | ||
| weightsQ8 + (row + 2) * rowBytes, | ||
| weightsQ8 + (row + 3) * rowBytes, |
The first pass widened only `weightsQ8 + row * rowBytes` and left the three sibling pointers in the VNNI/AVX-512 4-row unrolled calls (`weightsQ8 + (row + 1|2|3) * rowBytes`) in 32-bit arithmetic. Those overflow on exactly the same tensors — and slightly sooner, since they address further into the matrix — so the original fix was incomplete on the hot path it was meant to protect. Also widens GemvF32's weight row offsets (`a + row * k`, 2 sites). `a` is documented as "weight matrix A [M×K]", making these the same class as the GemvF16 sites; wrapping needs >2^31 float elements (~8.6 GB), so like F16 this is consistency rather than a reachable defect. The tiled F16 row read (`tileWeightsHalf + row * k`) remains deliberately untouched: row < tileRows <= 256 and the base is pre-offset via `(long)mStart * k`. Caught by Copilot review on the PR.
Completes the previous port. The first pass widened only `weightsQ8 + row * rowBytes` and left the three sibling pointers of the 4-row unrolled VNNI/AVX-512 calls (`weightsQ8 + (row + 1|2|3) * rowBytes`) in 32-bit arithmetic, in both MatMul.cs and the fork-only MatMulVnni.cs. Those overflow on the same tensors and slightly sooner, since they address further into the matrix, so the hot path the fix was meant to protect was still exposed. Also widens GemvF32's weight row offsets (`a + row * k`); `a` is documented as "weight matrix A [M×K]", so it is the same class as the GemvF16 sites. Needs >2^31 float elements (~8.6 GB) to wrap, so consistency rather than a reachable defect. MatMulMxfp4.cs needs no change: its GEMV already declares `long rowBytes = (long)blockCount * Mxfp4BlockBytes`, so the multiply is already 64-bit. Caught by Copilot review on kkokosa#431.
|
Thanks — this catch is correct and it mattered. Fixed in The review is right and my original fix was incomplete. I widened Worth naming the process failure, since it is the reusable lesson: I applied the change with a regex on That now returns exactly one hit in this file — the tiled F16 row read at line 1624 — which stays as-is deliberately: Also widened in the same commit: Re-tested: The codegen argument in the PR description is unaffected — the widening still moves ahead of the multiply on every one of these sites, and it all remains in the outer row loop. |
|
Why there are so many open PRs created by you? Like this it looks like created fully automatically. |
A follow-up audit of the whole CPU backend for this bug class found no further reachable sites, but the two tiled F16 row reads look exactly like the bug and will keep attracting "fix" attempts. Records the bound that makes them safe: the tensor-scale offset is already carried in 64-bit by `tileWeightsHalf` (`(long)mStart * k`), and `row < tileRows <= tileM <= 256` because every TileM in the project originates from ComputeTileM's `Math.Clamp(tileM, 4, 256)`, so the residual product tops out at 255 * k — 4.2M for a 16384-wide 405B tensor. Comments only, no behaviour change.
|
Pushed I ran a follow-up audit of the whole CPU backend for this bug class rather than just the sites #429 lists, to check nothing else was lurking. Result: no further reachable sites. The widenings in this PR cover the CPU weight-offset surface. What that audit did change is that the two tiled F16 row reads ( Worth noting for the record that several sites would have been live bugs had they been written with One thing I deliberately left alone, flagging it rather than quietly deciding: the caller-supplied The same audit found real sites on the CUDA side, which are not in scope here — filed separately as #432 with #433 for the fix. |
Closes #429.
Widens the two GEMV weight row-offset expressions in
src/DotLLM.Cpu/Kernels/MatMul.cstolong, matching the(long)mStart * ...convention the Gemm paths already use.ComputeRows—weightsQ8 + row * rowBytes, 7 sites across the AVX-512+VNNI, AVX-512, AVX2 and scalar tiers.GemvF16—weightsHalf + row * k, 2 sites.Out of scope exactly as the issue specifies: activation-buffer offsets are untouched, and
ComputeGemmTiled's row loop (row < tileRows <= 256, base pre-offset via(long)mStart * q8RowBytes) was already safe, so the tiled F16 row read at line 1624 is deliberately left as-is.Confirming the reachability analysis
I reproduced both calculations. @unsafePtr's follow-up correction is right, and it matters for how this should be prioritised:
int.MaxValueComputeRowsGemvF16So site 1 is reachable on a shipping model — first affected row 123,362, meaning the last 4,894 rows (3.8% of vocab) read from a wrapped negative offset — while site 2 needs a tensor larger than anything current. Both are still one-line widenings, so both are fixed here.
Performance
The AC asks for a before/after benchmark since
ComputeRowsis the Q8_0 decode hot path. Timing could not answer this on my hardware, so I diffed the JIT's x64 instead, which turned out to be decisive.The baseline was already sign-extending — just after the multiply. The 4-row loop body:
The cast does not add widening, it moves it ahead of the multiply — which is precisely the fix. That path comes out one instruction shorter; the scalar tail gains a spill/reload of the widened
row. Total method size 1024 → 1028 bytes (+4). All of it sits in the outer row loop, amortized over 16–128 VNNI block iterations of inner work per row, so there is no mechanism for a measurable regression.For completeness, the timing attempt: both
DotLLM.Cpu.dlls loaded into one process via separateAssemblyLoadContexts, interleaved per-invocation in position-balancedBASE, CHG, CHG, BASEgroups, 8 runs with slot assignment swapped halfway, plus A/A controls.Within-arm spread swamps every delta, and the deltas disagree in sign and magnitude across shapes. A/A controls came in under 1%, so the harness itself was sound — the machine simply is not quiet enough to resolve a change this small. Worth flagging one trap I hit: a ~30% bimodal artifact attached to whichever assembly loaded second, not to either DLL. Without the swapped-slot control I would have reported a false 33% regression.
Testing
--filter "FullyQualifiedName~MatMul|FullyQualifiedName~Gemv|FullyQualifiedName~Gemm"→ 207 passed, 0 failed, 7 skipped.No regression test accompanies this. Triggering it needs a >2 GB tensor allocated and mapped, which is not something a unit test can reasonably do — I did not want to add a test that only appears to cover the case. Happy to add one if you would rather have it gated behind an explicit opt-in environment variable.