Repository navigation
npu dequant: decode a plain bf16 tensor as bf16, not as 5120-byte q4_1 tiles (closes #875) - #882
Conversation
…1 tiles (closes #875) q4nx_dequant_tensor() only required nbytes >= q4nx_tensor_bytes(rows, cols), so a plain bf16 2-D weight - what tools/safetensors_to_q4nx.py pack --dtype bf16 writes - was accepted and decoded as 5120-byte q4_1 tiles: it is 2/0.625 = 3.2x the tile size, so two thirds of the buffer were never read and the rest was noise. The container converted and verified (verify compares container-vs-source), loaded, served, and answered on weights that were not the model's. Reproducer, 64x512 tensor of exact bf16 integers, same code both ways: BEFORE rc=0 32519/32768 values wrong max|d|=498 max|decoded|=468 AFTER rc=0 0/32768 values wrong max|d|=0 Note the rc=0 on the way in: nothing refused the mis-read, which is why a whole parity round could be measured on it. The fix makes the decoder honest about what it can read: * new q4nx_dequant_plain_bf16(), the bf16 sibling of the existing q4nx_dequant_plain_i8() (Gemma4's plain int8 + per-32-column scale); * q4nx_dequant_tensor() takes the bf16 path when nbytes == rows*cols*2, before the 32x256 geometry test, because plain bf16 carries no tile grid; * the tile path's size test becomes exact (nbytes != need -> refuse), so a container family whose chunk size nobody taught this decoder is refused instead of being read as tiles anyway. ctest npu_dequant_bf16 (new, plain C, no device) pins 9 cases: bit-exact bf16 decode, the named entry point agreeing with the tensor path, a 3x5 bf16 tensor decoding with no alignment, a real q4_1 grid of the same shape still taking the tile path (codes 1.0/3.0), and buffers that are neither bf16-sized nor the exact tile grid being refused. Measured on the box (host-GEMM, one npu-hold ticket per run, amdxdna errors 0 before and after): the d3-lite-bf16 container now loads and answers, and its parity against the pack's own d3_runtime.py moves worst |dp| 0.8382 -> 0.7319 (3/5 same argmax), which is what the correctly-decoded q4_1 container of the same weights gives (0.7300) - the remaining gap is the separate noncausal/#849 work, not the decoder. Qwen3-0.6B (q4_1) and d3-lite-conv (q4_1) still load. The three 27B directories cannot serve as a load regression here: they fail on origin/main before this patch too, with kpad_==0 at the quantised-embedding dequant - filed separately as #881 with the old/new attribution.
PR Reviewer Guide 🔍(Review updated until commit 68d8457)Here are some key observations to aid the review process:
|
Extra row after the PR went up: the exact-size rule does not break the containers that already work — proven at inference, not at
|
| container | request | result (patched binary) |
|---|---|---|
d3-lite-conv (q4_1) |
POST /v1/systemone, one choice question |
200, {"choice":"b","confidence":0.0957,"probabilities":{"a":0.31995,…}} |
d3-lite-bf16 (bf16) |
same request | 200, {"choice":"b","confidence":0.1303,"probabilities":{"a":0.29077,…}} |
Qwen3-0.6B (q4_1) |
POST /v1/chat/completions, 4 tokens |
200, reasoning_content "\nOkay," |
| any | serve log | no dequant … failed, no forward init failed, no forward step failed |
The two d3 containers now land within 0.03 of each other on the same question — the same weights decoded two ways,
which is the whole point of the change (before it, the bf16 container was 8.2× off in weight magnitude and
uncorrelated).
Also worth recording, because it is what made me re-check: a naive reading of the containers' headers suggests some
tensors are stored at 1.6× the tile grid
(model.layers.0.self_attn.q_proj.weight shape=[256,5120] bytes=1310720, tile grid 819,200). The loader's (rows, cols)
are not the header's shape for those tensors, which is why an exact test derived from headers alone would look
dangerous and an exact test derived from the caller's geometry is not: the three containers above pass it at the
values the loader actually uses. The one family that cannot be validated either way is the 27B int8-embedding one,
which fails on origin/main before this patch (#881) — stated in the description, and its own buffer sizes are exact
(esz == need == 794,624,000 for 248320×5120), so this rule is not what it would trip on.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
I reproduced the bug and the fix on a real bf16 container, not a synthetic buffer — ~/models/npu/d3-lite-bf16
(1,506,003,976 B, q4nx_source.converter: tools/safetensors_to_q4nx.py) against the source it was converted from
(~/models/npu/d3-lite-src/model-00001-of-00001.safetensors), decoding model.embed_tokens.weight
[248320,1024] — a tensor the forward really routes through dequant_any() → q4nx_dequant_tensor(). One harness,
compiled twice: once against this PR's q4nx_dequant.c, once against main's.
container bytes=508559360 tile-need=158924800 bf16-need=508559360 (3.2x, exactly as the body says)
main's decoder: rc=0 254240470/254279680 values differ max|d|=2.34174 max|ref|=0.230469
this PR: rc=0 0/254279680 values differ max|d|=0
So on the real weight, main decoded 254.2 M of 254.3 M values wrong, by up to 10x the true magnitude, and returned 0
— which is the part that matters: the container converted, verified, loaded and answered on those numbers. The fix returns
the tensor exactly.
Also checked:
- the new test is real and its cases bite:
npu_dequant_bf16passes 10 cases, including the two that would catch a
regression the round trip cannot see — "every value equals the bf16 source (not a q4_1 tile read)", "the tile path
yields the packed codes (1.0 / 3.0), so bf16 did not hijack it", and the two refusals (tile-plus-2-bytes, short buffer).
With the tile path's size test now exact, a buffer that is neitherrows*cols*2nor the tile grid is refused instead
of half-read. - the neighbouring container tests are unaffected:
npu_q4nx,npu_pack_import,npu_forward_loaderall still pass
(4/4 with the new test), so the stricter size test did not start refusing a family that used to decode. - CI wiring:
add_test(NAME npu_dequant_bf16 …)plusadd_dependencies(npu_forward_loader_test npu_dequant_bf16_test),
i.e. bound to a targetci.ymlalready builds — the documented workaround, with the comment saying when to drop it.
Two notes, neither blocking:
dequant_any()still refuses a 2-D tensor that is not on the 32x256 grid before reaching the new bf16 branch, so a
plain bf16 tensor with, say, 3 rows is refused rather than decoded. A refusal is the right failure mode and the direct
API handles it (their 3x5 case), but the sentence indocs/npu.mdshould not promise more than the forward path does.- I ran the NPU-lane tests in a configure without the
1bitserve binary, so the repo-widesmoke_serve/recipes_route
rows are red in my log for that reason alone, not because of this change; the dequant path they exercise is the one
covered above.
|
Persistent review updated to latest commit 933e02b |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At 933e02bb2 (merge of main into the head I reviewed; both files blob-identical, so the content is the content I verified).
Build success at this head.
Evidence: reproduced on a real bf16 container (~/models/npu/d3-lite-bf16 vs its d3-lite-src safetensors), model.embed_tokens.weight [248320,1024]: mains decoder returns 254,240,470 of 254,279,680 values wrong, max|d| 2.34 against max|ref| 0.23, and rc=0; this head returns 0 wrong, max|d| 0. The container is 508,559,360 B against a 158,924,800 B tile grid (the 3.2x in the body). 10 new cases pass, the neighbouring container tests still pass under the now-exact size test.
|
Persistent review updated to latest commit 4ea19ed |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY
At 4ea19eda3 (merge of main into the head I reviewed; 4 of 4 files blob-identical, so the content is what I verified).
Build success at this head.
Evidence stands: on the real bf16 container (d3-lite-bf16 vs its safetensors source), main decodes 254,240,470 of 254,279,680 embedding values wrong with rc=0; this head gives 0. 10 cases pass and the neighbouring container tests still pass under the now-exact size test.
|
Persistent review updated to latest commit 5069770 |
|
Persistent review updated to latest commit 68d8457 |
Root cause
q4nx_dequant_tensor()(npu/forward/src/q4nx_dequant.c:126) decoded every 2-D weight as 5120-byte q4_1 tiles andonly required
nbytes >= q4nx_tensor_bytes(rows, cols). A plain bf16 tensor — whattools/safetensors_to_q4nx.py pack --dtype bf16writes — is 2/0.625 = 3.2× the tile size for the same shape, so itpassed that test, had two thirds of its buffer ignored, and the rest read as q4_1 tiles. Nothing refused it: the
container converted,
verifypassed (that compares container-vs-source), the model loaded, served and answered — onweights that were not the model's.
dequant_any()(q4nx_forward.cpp:159-168) routes by bytes-per-tile and bothbranches end in the same tile decoder, so there was no bf16 path at all; 1-D norms were unaffected only because
read_bf16()reads those directly.Reproducer, before and after
Same code both ways, 64×512 tensor of exact bf16 integers (
/tmp/repro875.cin the session log):The
rc=0on the way in is the bug: a whole parity round was measured on this without a single error.Fix
q4nx_dequant_plain_bf16(data, rows, cols, out)— the bf16 sibling of the existingq4nx_dequant_plain_i8()(Gemma4's plain int8 + per-32-column scale), so the "plain, no tile header" family has a named entry point;
q4nx_dequant_tensor()takes that path whennbytes == rows*cols*2, before the 32×256 geometry test, becauseplain bf16 carries no tile grid;
nbytes != need→ refuse), so a container family whose chunk size nobodytaught this decoder is refused rather than read as tiles anyway.
Tests
ctest npu_dequant_bf16(new filetests/npu_dequant_bf16_test.c, plain C, no device) — registered inCMakeLists.txtand bound tonpu_forward_loader_testwithadd_dependenciesbecause this lane's token cannot write.github/workflows/(the same workaround as the other new tests). Nine cases, all pass:ctest -R "npu_dequant_bf16|npu_forward_loader|npu_design_grid|npu_q4x|npu_pack|npu_sampling|systemone|decision|npu_plan"→ 10/10 pass.
python3 tools/copyright.py --checkrc 0.On the box (host-GEMM, one
npu-holdticket per run,amdxdnaerrors 0 before and after)layers.0.linear_attn.qkv_proj.weightrow 0 vs the pack's own row 0,d3-lite-bf16d3-lite-bf16loads (/health)d3_runtime.py, worst |Δp| (10 contract requests)Qwen3-0.6B(q4_1),d3-lite-conv(q4_1) loadSo the bf16 container now behaves like a correctly decoded container instead of a mis-decoded one; the residual ~0.73 is
the separate known gap (noncausal mask + #849), not the decoder, and no accuracy claim is made here.
Known limitation, measured rather than assumed
bonsai2-27b-NPU,-b8and-pb16ifail to load withembedding dtype I8 but dequant failed— onorigin/mainbefore this patch as well (same worktree, patch stashed, identical message), and the temporary diagnostic shows why:
kpad_is 0 when the quantised embedding is dequantized: the assignment moved toq4nx_forward.cpp:730in #854 whilethe reader sits at
:559. Filed as #881 with the old/new attribution and the one-line fix, and kept out of this PRbecause it is a different defect in a different region. Their own buffer sizes are exact for this rule
(
esz == need == 794,624,000for 248320×5120), so the exact-size test is not what breaks them.Closes #875.