Skip to content

fix(cpu/matmul): widen GEMV weight row offsets to long (#429) - #431

Open
jamesburton wants to merge 3 commits into
kkokosa:mainfrom
jamesburton:issue/429-gemv-row-offset-overflow
Open

fix(cpu/matmul): widen GEMV weight row offsets to long (#429)#431
jamesburton wants to merge 3 commits into
kkokosa:mainfrom
jamesburton:issue/429-gemv-row-offset-overflow

Conversation

@jamesburton

Copy link
Copy Markdown

Closes #429.

Widens the two GEMV weight row-offset expressions in src/DotLLM.Cpu/Kernels/MatMul.cs to long, matching the (long)mStart * ... convention the Gemm paths already use.

  • ComputeRowsweightsQ8 + row * rowBytes, 7 sites across the AVX-512+VNNI, AVX-512, AVX2 and scalar tiers.
  • GemvF16weightsHalf + 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:

site stride Llama 3.1 405B LM head (vocab 128256, hidden 16384) vs int.MaxValue
ComputeRows 17408 bytes/row 128256 × 17408 = 2,232,680,448 over by ~85 MB
GemvF16 16384 elements/row 128256 × 16384 = 2,101,346,304 ~2.2% under

So 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 ComputeRows is 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:

BASELINE                          THIS PR
mov      ecx, r14d      ; row     mov      eax, r12d     ; rowBytes
imul     ecx, r12d      ; 32-bit  imul     rcx, rax      ; 64-bit
movsxd   rcx, ecx       ; widen                          ; row already widened

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 separate AssemblyLoadContexts, interleaved per-invocation in position-balanced BASE, CHG, CHG, BASE groups, 8 runs with slot assignment swapped halfway, plus A/A controls.

shape (m×k) base median this PR median within-arm run-to-run spread
4096×512 0.145 ms 0.157 ms 46%
11008×4096 5.10 ms 5.94 ms 88%
32000×4096 16.68 ms 16.69 ms 78%

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.

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
Copilot AI lite review requested due to automatic review settings August 7, 2026 12:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ComputeRows to avoid 32-bit overflow on large tensors.
  • Widened F16 GEMV row pointer arithmetic in GemvF16 to avoid 32-bit overflow for large m*k element 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.

Comment thread src/DotLLM.Cpu/Kernels/MatMul.cs Outdated
Comment on lines 141 to 144
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.
jamesburton added a commit to jamesburton/dotLLM that referenced this pull request Aug 7, 2026
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.
@jamesburton

Copy link
Copy Markdown
Author

Thanks — this catch is correct and it mattered. Fixed in 09242339.

The review is right and my original fix was incomplete. I widened weightsQ8 + row * rowBytes but left the three sibling pointers of the 4-row unrolled calls — weightsQ8 + (row + 1|2|3) * rowBytes — in 32-bit arithmetic, across all three unrolled tiers. Those overflow on exactly the same tensors, and marginally sooner, since they address further into the matrix. So the specific hot path this PR exists to protect was still exposed: on the Llama 3.1 405B LM head the base pointer would be correct while the +1/+2/+3 rows wrapped, giving a partially-corrupt 4-row block rather than a clean failure.

Worth naming the process failure, since it is the reusable lesson: I applied the change with a regex on weightsQ8 + row * rowBytes and then verified with a grep for the same pattern. The check could not fail — it was the edit expressed twice. The correct check was a pattern matching any row-derived multiply, which is what I used this time:

grep -rnE "\+ \(?row( \+ [0-9]+)?\)? \* (rowBytes|k)" src/DotLLM.Cpu/Kernels/ | grep -v "(long)"

That now returns exactly one hit in this file — the tiled F16 row read at line 1624 — which stays as-is deliberately: row < tileRows <= 256 and the base is pre-offset via (long)mStart * k.

Also widened in the same commit: GemvF32's two weight row offsets (a + row * k). a is documented as "weight matrix A [M×K]", so these are the same class as the GemvF16 sites already covered here. Wrapping needs >2^31 float elements (~8.6 GB), so like F16 this is consistency rather than a reachable defect — flagging it explicitly since it slightly widens the diff beyond what #429 lists.

Re-tested: --filter "FullyQualifiedName~MatMul|FullyQualifiedName~Gemv|FullyQualifiedName~Gemm"206 passed, 0 failed, 7 skipped.

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.

@unsafePtr

Copy link
Copy Markdown

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.
@jamesburton

Copy link
Copy Markdown
Author

Pushed 58cdf4cb — comments only, no behaviour change.

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 (GemmF16 and GemmTiledF16Worker) now carry a comment recording why they stay int — they look exactly like the bug, and without a note they will keep attracting well-meant "fix" attempts. The bound: 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, about 4.2M for a 16384-wide 405B tensor.

Worth noting for the record that several sites would have been live bugs had they been written with int: DeepSeek-V3's Q6_K expert offsets reach 3,070,771,200, well past int.MaxValue. They are already long in MoeQuantSwiGluMlp / MoeSwiGluMlp. That is the existing code getting it right, not luck.

One thing I deliberately left alone, flagging it rather than quietly deciding: the caller-supplied preQuantizedInput GEMM paths (MatMul.cs, MatMulKQuants.cs, MatMulQ5_0.cs) are the only activation-offset sites not structurally bounded by an int-sized internal ArrayPool.Rent — the buffer comes from outside, so the self-limiting argument rests on the caller rather than on the kernel. Wrapping still needs ~123k tokens in a single un-chunked prefill, and #429 explicitly scopes activation offsets out, so I kept the diff honest to the issue. It is three free (long) casts on loop-invariant expressions if you would rather have belt-and-braces.

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.

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.

bug(cpu/matmul): int overflow in Q8_0/F16 GEMV weight row offsets for >2 GB tensors

3 participants