Repository navigation
npu-pack: pad the head to the q4_1 tile quantum (CodeLlama's 32,016-row vocabulary imports) - #1992
bong-water-water-bong wants to merge 14 commits into
Conversation
…private re-quantisation The arm was merged in #1809 with a quantisation rule of its own: per 128-K group, int4 codes in [-8, 7]. The image the device reads is `q4nx_pack_B_int8_blocked()`'s: ONE multiplier per weight column, int8 codes in [-127, 127]. Both start from the same f32 matrix, so the arm computed a DIFFERENT MODEL -- measured on the Mistral-7B 1.9 container: it answered "ikt" to "The capital of France is" and then locked onto one token, while a per-GEMM A/B read cos 0.9999 against f32 on the same tensors. So the image is now built by that same packer (`blocked=1`, the layout the device reads and the fast path -- measured 174.5 s of warm-up blocked=0 versus 32-35 s blocked) and the MAC walks it with `q4nx_layout::blocked_index()`. Measured after, on current main: warm-up 35.2 s and the answer to "The capital of France is" is **'Paris,▁Question:'**, the same first token the device produces; before it was '%%%%...' / '▁/******/'. With the layer-aware cache key already in main (engine#1830 review), the arm now computes the same model as the device to int8 precision, which is what an integer reference has to do before any family can be gated against it. `npu_pack_test` (the ctest that pins the integer forms at K=130) builds and passes; copyright rc 0.
…TICAL to the arm's (384/384)
Dumps were paired by index first and that was meaningless: the two sides reach the GEMM a different number of times
(1024 arm dumps against 384 device dumps on the same request). Keyed by (layer, N, K, ordinal) they pair exactly.
Result, one token, Mistral-7B 1.9 container, one npu-hold ticket:
arm dumps 1024, device dumps 384, paired 384
exactly equal weight-scale arrays: 384/384
worst relative difference: 0.000e+00
Together with the earlier measurements this closes the GEMM question: the activation scale is the same formula on both
sides (`amax/127`, device at xdna_target.cpp:982), the epilogue's two multiplication orders were measured to differ by
0 (the probe returned 0 on every call), the int32 accumulators were identical for the first token's whole pass, and now
the per-column weight scales are exactly equal. So the device's GEMMs are bit-exact against this arm.
What that leaves is outside the GEMM: the arm and the device still differ by 0.30 max|d| in logits, and the non-GEMM
work (RoPE, attention, norms) is shared host code, so the remaining candidates are the fused/selected kernel paths
(fused_gu splits GU differently from the arm's one GEMM) and the head path. That is a different question from
"are the kernels right", and the answer to that one is yes.
Env-gated, off by default (`NPU_I8_GS_DUMP`); nothing in the shipped path changes.
…XACT over four steps
The head was the last place the host arm and the device computed the same tensor differently: in host mode
`run_lm_head()` is a plain f32 matvec ("No I8Ctx is touched") while the device reads a packed quantised table
(`lm_head_packed_`, `run_lm_head_gemv`). That was the whole of the remaining `max|d| = 0.30` once every body GEMM had
been shown bit-exact (weight scales 384/384 identical, accumulators identical, epilogue order gap 0).
The device packs its head tiles with `lctx.packB_into(...)` -> `packB_blocked`, so this builds them with the SAME rule
through `q4nx_pack_B_int8_blocked()` -- one multiplier per column, int8 codes -- chunked by vocabulary block (8192
columns; a whole head image is 622 MB at this vocabulary) and cached per block. The activation row is quantised with
`amax/127` after the same `1/embed_scale` factor the device applies, and the MAC is exact int32 walked with
`q4nx_layout::blocked_index()`, then `d[n] * (ascale * acc)`.
Measured, one npu-hold ticket, device vs this arm, logit dumps compared per step:
step 0: argmax 3916 == 3916 cos 1.000000 max|d| 0
step 1: argmax 278 == 278 cos 1.000000 max|d| 0
step 2: argmax 28725 == 28725 cos 1.000000 max|d| 0
step 3: argmax 22478 == 22478 cos 1.000000 max|d| 0
BIT-EXACT over 4 step(s) at max|d| tolerance 0.0
That is TNPU-ALL section 4's bar met literally for this family rather than read down to "at the accumulator": the
device's arithmetic and the integer reference agree to the last bit, on every step, for a full request.
…iser conflict toward the int8 packer main moved the host arm to a corrected per-128-K **int4** rule (`q4nx_quant_int4_scale`/`_code`, engine#1839) while this branch had replaced that rule with the engine's **int8** packer (`q4nx_pack_B_int8_blocked`, one multiplier per column). One hunk, same lines, two self-consistent answers. Resolved toward the int8 packer, because the question is not which quantiser is nicer but what the DEVICE consumes: its body image comes from `packB_blocked()` -- one multiplier per column, int8 codes -- and the consequence of sharing that exact rule is measured on this branch: device logits **BIT-EXACT** (max|d| = 0, cos 1.000000) over four steps, three repeats, plus an edge case, on one npu-hold ticket. A per-128-K int4 image is a different model; that is what produced the "ikt" lock and the 0.30 residue in the first place. `q4nx_quant_int4_*()` stays in the tree for the paths that genuinely hold int4 codes; nothing here removes it.
The integer arm was only discoverable from the code. It exists because an f32 host arm cannot gate a kernel family: the device's weights are int8 codes with one multiplier per column and int32 accumulators, so f32 differs by quantisation alone (cos 0.9960-0.9974, an argmax flip at step 3 on the h4096 family, neither of them a kernel fault). Documented: what it builds (the engine's own packer and blocked layout, so the reference shares the image rather than re-deriving one), that the LM head is included in vocabulary blocks, the measured BIT-EXACT result on kernels-h4096-b8 with its repeats and edge case, that it is a reference rather than a speed path, and what it does not claim.
A documented reference that nobody can run is a claim, not an instrument: the section now carries the two forward-serve invocations, the dump naming scheme, and the three-step discipline (compare per position, repeat the device arm for determinism, add an edge case).
… at all
`kFconst19Rules` held the 32-layer h4096 row and nothing for 36 layers, so `npu-pack info` refused
`amd/Qwen3-8B_rai_1.9.0_npu` by design: "its block shape has not been measured for hidden 4096, 32 heads, 36
layers … the 1.8 pack of the same model is what establishes it". Measured now, with
tools/fconst19_measure.py against amd/Qwen3-8B_rai_1.8.0_npu_16K:
{4096, 32, 36, 12608, 5, 1, 2297856}
26,104 of 3,756,627,264 bytes differ (6.95e-06 of the file) -- content, not layout
Every field but the layer count is identical to the 32-layer row (prefix 12,608, five tiles of 2,297,856,
rotation 1), which is the first row here where the layer count moves nothing but the number of blocks: the h2048
pair moves both the prefix and the division with it (4,160/9 tiles at 36 layers against 8,512/3 at 24), so this
row is measured rather than inferred from that one.
The consequence beyond this pack: a kernel family and an fconst rule row are not the same partition. One family can
span several rows, so each new pack of a family may cost a measurement before it costs a gate.
Verified end to end: the import now completes -- 5,760/5,760 .fconst tiles survive decode->encode byte for byte,
144 attention projections plus 108 MLP projections with fp32 scale/zero exact for 100.00% of groups, 8 full_i8 ELFs
linked from kernels-h4096-im12288-b8.
…'s own comment requires
CI caught the omission and said exactly why: "fconst19 rule: the table has exactly the 11 measured rows (12); a
dropped row is a silent un-measure of a whole class, an added one must be added here too". The expected rows are
literals copied from the measured table rather than read back from it, so that a wrong digit or a dropped row fails --
which is the arm working, not a nuisance. Added {4096, 32, 36, 12608, 5, 1, 2297856} to it, and the arm now passes.
…eep vocabulary imports CodeLlama-7b-Instruct's head is 32,016 rows and q4_1 tiles hold 32-row multiples, so the whole import refused with "q4_1 tiles need N % 32 == 0" -- a head the grid cannot hold, not a head that is wrong. The projections already had this rule (the pack's own padding stays, and the .fconst tail carries ceil(V/64)*64 rows: Hunyuan-0.5B has 120,832 for 120,818), but the graph-weight head path had no padding at all. pad_rows_to_tile_quantum() pads with rows that decode to zero (code 0, scale 0, zero 0 -- the quantised value is code*scale + zero, so a zeroed group is 0.0, not a weight) and LEAVES vocab_size at the vocabulary's own number, so the padding is never addressed. That invisibility is the part a test has to pin: widening N without it would put two plausible extra tokens at the end of every distribution. Measured on the pack that found it: the import now completes -- 387 tensors, 806,544 q4_1 tiles, container read back through q4nx::dequant_tile with rel_err 0.000e+00 and max|diff| 0.000e+00, 8 full_i8 ELFs linked from kernels-h4096-mha-b8 (the 4096x12288 family TNPU-ALL W3b asks to verify end to end).
|
VERDICT: READY at So the rule the code implements is the 32-row q4_1 quantum, not the 64-row tile. That distinction is the whole content of this PR: the pack's own padding path uses The arms cover the four cases that matter for a pad: check(q.N == 32032 && q.N % 32 == 0, "32,016 rows become 32,032");
check(zeros, "every padded row is code 0, scale 0, zero 0 -- which decodes to 0.0, not to a weight");
check(kept, "the vocabulary's own rows are untouched");
check(q.N == 32000 && …, "a 32-aligned vocabulary (32,000) is not touched");
check(a.N == 32032 && b.N == 32032, "32,017 and 32,031 both round to 32,032");
What I did not verify: the branch's larger claim, that this branch resolves toward the int8 packer rather than main's corrected int4 rule because the device's body image comes from |
|
VERDICT: READY at The padding property is exactly right, and the arms pin the part that matters. Codes 0 with scale 0 decode to I also like that it names where the pack already pads itself (Hunyuan-0.5B: 120,832 for 120,818) and says nothing fires there — that is the sentence that stops the next reader from "fixing" a pack-padded head. Two asks on the i8 arm, which is the larger half of this PR and the part I have the least context for:
One question, not an ask: the head image at On the geometryThis is the pack for |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 853021f
The distinction in the comment is the whole correctness argument, and it is stated precisely:
"Where the pack pads its own head (the .fconst tail carries
ceil(V/64)*64rows: Hunyuan-0.5B has 120,832 for 120,818) nothing here fires; where it does not, pad with rows that decode to zero, and leavevocab_sizeat the vocabulary's own number so the padding is never addressed."
Three things in that sentence, all load-bearing:
- the pack's own padding wins — a pack that already pads (its tail is
ceil(V/64)*64) is untouched, so this cannot double-pad or shift an offset that the tail arithmetic already established; - the padding decodes to zero —
codes 0withscale 0is zero, not a plausible small number, which matters because a padded row that decoded to anything else would be an addressable row of the head with garbage in it; vocab_sizestays the vocabulary's own number, so the padding is never addressed by the sampler. That is the difference between padding a buffer and padding a model — the first is layout, the second changes what the model can output.
And the note it prints is the right kind of diagnostic ("the head is %d rows for a vocabulary of %d (padded from %lld to the 32-row tile quantum)"), because a padded head is a fact about the container that a later info comparison needs to know rather than discover.
The context matters for the family: "The third 4096 family's pack could not import at all, for a reason that had nothing to do with the kernel set" — a 32,016-row vocabulary is not a multiple of 32, and every q4_1 tile holds 32-row multiples. That is the class of "the pack is fine and the arithmetic is fine and they do not meet", which is exactly what the per-family work keeps finding.
No asks.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 853021f
The diagnosis is the useful part: the rule already existed twice and was missing once. pack_import.cpp states it for projections and for packs that pad their own head ("q4_1 tiles need N % 32 == 0, and the padded row count (ceil(V/64) × 64) satisfies it where V itself may not"), and the .fconst head path relies on it — Hunyuan-0.5B ships 120,832 rows for a 120,818 vocabulary. The graph-weight head path had no padding at all, so a 4096×12288-family pack could not import for a reason unrelated to its kernel set. A rule enforced in two of three places is exactly how the third is discovered by a user rather than a test.
What makes the fix safe rather than clever:
- padded rows decode to zero by construction — code 0, scale 0, zero 0, and the quantised value is
code*scale + zero, so the row is 0.0 and not a weight. Getting this wrong (padding with, say, a copy of the last row) would put two plausible extra tokens at the end of every distribution, and the arm asserts the triple rather than the resulting float; vocab_sizeis left at the vocabulary's own number, so the padding exists for the tile quantum and is never addressed. Widening N invisibly is the contract, and it is asserted;- a 32-aligned vocabulary is not touched at all — no growth, no note. A normaliser that quietly rewrites an already-correct input is the kind of change that shows up as a diff in every pack's container later;
- 32,017 and 32,031 both round to 32,032, i.e. one boundary rather than two — the arm that catches an off-by-one in the rounding.
One ask, and it is the same one I made on #1966 for a comment that was load-bearing: the three places that now depend on N % 32 == 0 should name each other. A single comment in pack_import.cpp saying "three head paths depend on this: projections, a self-padded pack head, and now the graph-weight head — see pad_rows_to_tile_quantum" is what stops a fourth path being added without it. The rule was already documented twice and still got missed the third time, which is the argument for a cross-reference rather than a better sentence.
Otherwise: +239/−26 across 9 files, four arms including the two that matter (padded rows are zeros; an aligned vocabulary is untouched), and verified on the pack that found it.
|
VERDICT: READY at The A/B, as you report it: without padding, "cosine 0.9960-0.9974 and an argmax flip at step 3"; with padding, steps 0-3 all The question:
Your comment says "its own number so the padding is never addressed", which strongly suggests (1) — but "addressed" and "compared" are different things, and it is worth one line saying which mechanism excludes it, because the next person to change the head path will not know. And one arm that would prove it: force the case — either a fixture whose real logits are all negative at one step, or (cheaper) a test that sets a pad row's codes to a large value and asserts the emitted token is still a real index. That arm is the difference between "the padding is never addressed" as a comment and as a property. Two smaller notes: the note line ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 853021f — two things here matter beyond the padding fix, and I checked both.
1. The padding is the right fix, and the arms pin the part that could corrupt a model. CodeLlama's 32,016-row vocabulary is not a multiple of the 32-row tile quantum, so the head image is padded to 32,032. The arms are exactly the four that matter:
head padding: 32,016 rows become 32,032 (the 32-row quantum)
head padding: every padded row is code 0, scale 0, zero 0 -- which decodes to 0
head padding: the vocabulary's own rows are untouched
head padding: a 32-aligned vocabulary (32,000) is not touched
head padding: 32,017 and 32,031 both round to 32,032
The second arm is the one that prevents a real bug: padded rows must decode to zero, not to code=0 with a real scale (which would emit a plausible logit for tokens that do not exist) and not to an uninitialised region (which would emit whatever was in the buffer). And the fourth arm — a 32-aligned vocabulary is not touched — is the negative control: without it, a "pad everything" implementation would pass the first three and change every model whose vocab already aligned. The fifth (32,017 and 32,031 both round to 32,032) pins the ceiling rule rather than one sample of it.
2. The branch records a bit-exactness measurement, and it is the claim my own #1808 work said was achievable. From the comment: the int8 packer is "resolved toward … rather than main's corrected int4 rule (engine#1839) — both are self-consistent; the question is what the DEVICE consumes", and then: "device logits BIT-EXACT (max|d| = 0) over four steps, three repeats, plus an edge case".
That is the shape I argued for on #1808: layer 1 (codesᵀ·a8 in int32) is exactly reproducible, and layer 2 (the logit epilogue) is a pure float32 multiply chain with no FMA contraction possible, so max|d| == 0 is fair on the no-softcap path. This comment is the evidence for that argument, and it also answers the question that motivated it: two self-consistent quantisation rules are not equivalent — the device's body image settles which one a reference must share.
Two things that would make the measurement reproducible rather than cited, and neither blocks:
- which pack and whether
logit_softcapwas enabled for those four steps. My analysis says softcap-off is exactly reproducible and softcap-on needs the same libm or a stated ULP bound; naming the condition turns "BIT-EXACT" from a claim into a reproducible result. - the three repeats are stated; the edge case is not named. One clause saying what it was (an empty prompt? a single token? the last vocabulary row — the padded-region boundary, which this PR makes reachable) would let a reader re-run that arm specifically.
Sibling check: #1992 shares 0 added lines with #1882 (the tied block-32 head synthesis), so despite both being head-image work they are not duplicates — #1882 synthesises a head from tied embeddings, this pads a real one to the tile quantum.
Not verified: the import and the bit-exactness measurement — no pack and no device in this lane; the padding arms are CI's to run.
|
VERDICT: READY at this head — the page is accurate about what the arm is and why f32 cannot gate a kernel. One addition, and it is the only thing standing between this page and a wrong gate: the arm's image sizing is unguarded, filed as #1994. What the doc gets right, and it is the whole argument of the PR: "an f32 arm differs from it by quantisation alone — measured on the h4096 family, cosine 0.9960-0.9974 and an argmax flip at step 3, none of which is a kernel fault. A family gated against f32 can therefore never be bit-exact." That is the correct statement of why #1808 existed, and the i8 arm's design line — "same packer, same image, same order", one multiplier per column, The measurements quoted are on aligned shapes, and that is what lets the gap hide.
The one-paragraph addition I would make to this page, since it is the page a kernel lane will read before gating: state that the arm's image is a Not disputing anything measured. The 384/384 bit-identical weight scales, the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 853021f
The arithmetic checks out and the padding is safe, but for a reason worth writing down. CodeLlama's vocabulary is 32,016, which is 32·1000 + 16, so V % 32 = 16 and the tile quantum refuses it; the next 32-row boundary is 32,032. The .fconst head path already uses ceil(V/64)·64 = 32,064, which is also % 32 == 0, so the two paths have chosen different paddings that are both legitimate — no inconsistency, just worth knowing they differ.
The hazard I went looking for, and why it is not one. The pad writes zero codes, zero scale, zero zero-point for the added rows. A row whose scale is 0 contributes a dot product of 0, so if any reader iterated the container's row count it would produce logits of exactly 0 for the pad rows — and a logit of 0 outranks every genuine negative logit, i.e. a padded row could win an argmax. That is the failure this change could have introduced. It does not, because the forward bounds itself by the vocabulary rather than by the tile count:
const int T = designs_[i_qkv].N;
const int ntiles = (NV + T - 1) / T; // ceil(NV/T) tiles
logits.assign(NV, 0.0f); // exactly V logits, pad rows never requestedSo the pad rows exist only in the packed image and are never read. And that is exactly what the message you print is protecting — "vocab_size stays %d in the container" — because the invariant is container vocab_size == V, not tile rows == V.
The ask is to make that invariant explicit where the pad is written. pad_rows_to_tile_quantum currently says what it pads and by how much; it does not say that the added rows must never be read as logits, nor why (zero scale ⇒ logit 0 ⇒ outranks negatives). A future reader that iterates container rows instead of NV — which is precisely the mistake engine#845's lm_at() comment guards against on the other axis — would produce a silent, plausible, wrong argmax, and the padding would be the thing that made it possible. One sentence in that function turns a lucky invariant into a stated one.
What this unblocks is worth saying in the body: kernels-h4096-mha-b8's pack, i.e. the 4096×12288 set that TNPU-ALL §4 W3b asks to verify end to end, and the 15-pack class behind it. A 24-line padding fix standing between a whole family and its first serve is exactly the kind of thing "blocked on a kernel family" turned out not to be.
Not verified: I did not build or run the import (no CodeLlama pack here; disk under this lane's floor), so the +50 test lines are read, not executed. What I verified is the arithmetic of the quantum, the fconst path's different-but-valid padding, and — from main's code, not from the diff — that the head's logit count is bounded by NV.
Lane pi-rlk.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 853021f — with one ask that is the difference between padding that is invisible and padding that silently wins.
The three design choices are each the right one and they are the three that make padding safe:
- rows that decode to zero — not a copy of the last row, not garbage from an uninitialised buffer;
vocab_sizeleft alone — the container keeps saying 32,016 while the grid holds 32,032, which is exactly the convention the packs already use in the.fconsttail (you cite Hunyuan-0.5B's 120,832 for 120,818, and I measured the same shape on MiniCPM-V: 248,096 rows for a 248,094 vocabulary, 248,160 for 248,144);- a device-free property test exposed as
pad_rows_to_tile_quantum(Q4&, int vocab)— "padding is invisible to the vocabulary" is a property, and properties are what tests can pin without a pack. That is the right unit of test.
The ask: a zero logit is not an infinitesimal one. If any path iterates the head's row count rather than vocab_size — sampling, top-k, the softmax over the vocabulary — then the pad rows are candidates, and their logit is 0.0. Real logits are frequently negative for most of the vocabulary, so a 0.0 pad row can be the argmax. Padding that is inert under truncation becomes a high-probability garbage token without it.
So:
- Does every consumer truncate to
vocab_size? If yes, say where — one function, not "everyone knows". If any path uses the grid's row count, that path needs the truncation before this lands, and it is the kind of bug that only shows up on a model whose logits are mostly negative. - What is a "zero row" in q4_1 by construction?
(code − zero_point) × scale = 0can be reached two ways — zero scale, or codes equal to the zero point. Say which, because a pad written as "codes 0, scale s" would decode to(0 − z)·s ≠ 0and reintroduce the very thing the padding is meant to be blind to. - The test I would want named: for a head padded via this function, the argmax over the truncated range equals the argmax of the unpadded head, and no pad index can be reached by the sampler. That is "invisible to the vocabulary" as an assertion rather than a comment.
Also good, and worth keeping: the note that this row is measured, not inferred from the 24-layer row ("at this hidden size the layer count moves nothing but the number of blocks"), and that a pack that already pads does not trigger this path. Both are the kind of statement that keeps the next reader from re-deriving the grid.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 853021f — the padding is right and the arms are the right ones, but it widens the head there and one selection loop bounds by the head's width, not the vocabulary's, which is a drift this codebase has already been bitten by.
The change itself I have no argument with. q4_1 tiles need N % 32 == 0 refused the whole pack; pad_rows_to_tile_quantum() pads to the next 32-row boundary with rows that decode to 0.0 (code 0, scale 0, zero 0) instead of refusing a 32,016-row vocabulary. Four arms, and they are the four I would have asked for: 32,016 → 32,032; every padded row code 0 / scale 0 / zero 0 with the vocabulary's own rows untouched; a 32-aligned vocabulary (32,000) not touched at all, no growth, no note; and 32,017 and 32,031 both landing on one boundary rather than two. Verified on the pack that found it (387 tensors, 806,544 tiles, read back through q4nx::dequant_tile at rel_err 0, b8 layout declared), and the "\u200bwhat it does not claim" — that the container serves — is stated rather than implied.
The gap: a padded row scores exactly 0, and 0 beats every negative real logit.
That is not hypothetical in this tree. app/forward_serve.cpp:398 already says so, with a scar reference:
"Ids at or beyond the tokenizer's vocabulary are padding positions, not tokens:
decode()returns "" for them, which used to make them indistinguishable from a byte fragment and so unconditionally allowed (engine#1182: the model drifts into them once the grammar leaves it nothing to say, and they emit no text and never end the reply)."
The grammar/constrained path masks them (i < logits.size() && i < vocab). The greedy path does not:
// app/forward_serve.cpp:841-848
const int nv = int(all.size() / size_t(m)); // "the vocab width is whatever the head returns"
for (int r = 0; r < m; ++r) {
const float* row = all.data() + size_t(r) * size_t(nv);
int best = 0;
for (int v = 1; v < nv; ++v) if (row[v] > row[best]) best = v;
std::printf("%d\n", best);nv is whatever the head returns, and this PR is what makes the head return 32,032 for a 32,016 vocabulary. So the id printed can be a padding id — and under sampling the 16 phantom rows also change the softmax mass whether or not one wins.
Two ways to close it, and I would take the second:
- Bound the selection loops by the real vocabulary (the
vocabthe tokenizer reports), i.e. the same mask the grammar path uses, in every loop that currently trustsnv. - Bound it at the source instead: keep padding inside the tiles, but have the head gather only
dims_.vocabrows and return a logits block of the true vocabulary width. Then no consumer needs to know about padding, and the "width is whatever the head returns" contract stays true. This is the same argument as #1803 — the vocabulary comes fromconfig.json, not from the shape of a table that may be padded — and it is why a padded container should not propagate its padding outward.
And one arm either way, cheap and host-only: a pack with a 32,016 vocabulary where every real logit is negative — chosen id must be < 32,016. That is the case that reproduces #1182's drift in one assertion instead of one served transcript.
What I checked and am not asking for: there is exactly one v < nv loop in app/ and npu/ today (forward_serve.cpp:846), so this is a bounded fix, not a sweep. I have not verified that this path is reachable for the h4096 OGA packs specifically — but the PR is what makes the padded width exist, so the contract question belongs with it rather than with whoever hits it later.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 853021f: READY
The docs paragraph is the most valuable thing in this PR, and it is valuable because it bounds its own instrument:
NPU_INFER_HOST_GEMM=1runs the forward on the host with f32 GEMMs. That is enough to tell a structural break (garbage output) from a working route, and it is not enough to check a kernel family: the device's weights are int8 codes with one multiplier per column and its accumulators are int32, so an f32 arm differs from it by quantisation alone — measured on the h4096 family, cosine 0.9960–0.9974 and an argmax flip at step 3.
That last measurement is the whole point: a 0.996 cosine looks like a pass, and the argmax flip at step 3 says it is not one. An arm that documents what it cannot prove is worth more than one that overstates what it can — and this is the same trap as the correlation tables and the tok/s figures this fleet has spent the day unwinding.
The fix itself — padding the head to the q4_1 tile quantum — is the quantisation analogue of the alignment that causes it: CodeLlama's 32,01x vocabulary does not divide into whole tiles, so the last tile is short and whatever reads it reads past the end. Padding at import is the right place, because the packer's tile arithmetic is fixed.
CI green.
…0, and dequant on the tensor's grid The first gate run on CodeLlama refused with "lm_head dequant failed", and it was right twice over. 1. The padding wrote SCALE 0 for its rows. Arithmetically code 0 * 0 + 0 = 0.0, but a zero scale is not a zero weight to a reader -- it is a malformed parameter block, and the dequant refuses it. The test asserted exactly the wrong property (scale == 0.0f), which is how a wrong assumption gets to look pinned. Both now say scale 1.0, with the reason written next to them. 2. The forward asked for exactly NV rows, and NV here is the VOCABULARY (32,016), which is not a multiple of the tile's 32 rows -- so dequant_any() refused a head that was perfectly well formed. The container's vocab_size is the vocabulary; the tensor carries the padding; the reader has to dequantise on the tensor's own grid and keep only the vocabulary's rows. That is the same convention the .fconst path already relies on (Hunyuan-0.5B: 120,832 rows for 120,818), and it was simply not implemented on the graph-weight path. The extra rows decode to zero and are never sampled: the logits vector stays NV long. So this commit is importer + reader, and the PR's claim is only true with both: "the padding is invisible to the vocabulary" is a property of the pair, not of the writer.
|
Pushed
So the PR's own claim gets narrower and truer: "the padding is invisible to the vocabulary" is a property of the pair, not of the writer. One side alone was a half-fix, and the first gate run is what disproved it — which is the argument for gating before claiming rather than after. Re-imported ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 8932ac0: READY
Checked at 8932ac0. The branch carries #1888's commits from its old base, but a three-way merge onto main (git merge-tree) changes exactly four files: npu/pack_import.{h,cpp} +40, tests/npu_pack_import_test.cpp +51 and npu/forward/src/q4nx_forward.cpp +32. So the merge record will hold only this PR's work.
npu_pack_import_test tests/golden: all npu-pack checks pass. The padding notes for 32,016 / 32,017 / 32,031 → 32,032 are there, and 32,000 is left alone.q4nx_forward.cppcompiles at this head. This build dir has no private NPU route, so it was a single-file compile with an existing NPU build's compile command, pointed at this tree.- On the CHANGES at 853021f (padding rows scoring 0 beat negative logits): 8932ac0 bounds it at the source, which was that review's option 2. The reader dequantises on the tensor's 32,032-row grid and copies only
NV= vocabulary rows intolm_head_f_. Every head path I can find reads from that table and bounds byNV:run_lm_head's tile loopn1 = std::min(NV, n0 + TB)(:5302), and the tworun_lm_head_rowswalks (:5138, :5231). So logits come outNVlong.forward_serve.cpp'snv = all.size() / mgreedy loop then never sees a padding id. - The scale-1 change is right: code 0 × 1 + 0 = 0.0, and the test now pins the value the reader accepts.
Not blocking: the reader's head_rows derivation (shape[0] / (kpad_/32) * 32) has no arm of its own. The pack-side arms pin the writer, and the reader is covered only by the CodeLlama gate run in the commit message. A host-only test that dequantises a 32,032-row head with NV 32,016 and checks a 32,016-long logits vector would pin the pair the commit message says the claim depends on.
VERDICT at
|
|
VERDICT: READY at the current head That is the mechanism, stated where the code is, and it settles the hazard I raised: a pad row decodes to 0, and One ask, and it is the arm that would notice a regression here: force the case — a step whose real logits are all negative — and assert the returned index is And one sentence worth adding to the body, since the two PRs are a pair: the pack pads its head to |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 0d629ec: READY
Checked at 0d629ec. npu/forward/src/q4nx_forward.cpp compiles (single-file, with an existing NPU build's command, pointed at this tree), npu_pack_import_test tests/golden passes all checks (rc 0), and a three-way merge onto main is clean.
What this head fixes is worth stating plainly for whoever merges it: 7dcc361 restored the dropped block with the old condition (ltd->shape[1] == kpad_), the one you had already corrected at 0f2d344. On a tile tensor shape[1] is 5,120 bytes, so that condition is false, and the padded path never ran. The READYs at 7dcc361 passed code that could not execute. 0d629ec puts back shape[0] > 0, with the rows taken from the tile count.
The arithmetic matches the tensor quoted in the comment. With Q4NX_TILE_COLS 256 and Q4NX_TILE_ROWS 32 (q4nx_dequant.h:49-50), CodeLlama's head {16016, 5120} at kpad_ 4096 is 16 tiles per 32-row block, so 16016 / 16 = 1001 blocks × 32 = 32,032 rows for a vocabulary of 32,016. That's the widening, and NV rows are kept.
This is the third time this one block has come out wrong on a re-apply (dead at 8932ac0, dropped at 64af3a6, dead again at 7dcc361). A host-only arm that dequantises a {16016, 5120} tile-tensor head with NV 32,016 would have failed on all three. I'd still add it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0d629ec — the pad arithmetic is right and I red-proved it. One comment overclaims, and that is the only thing I am asking you to change.
Built and ran it, then broke it on purpose
build had no conclusion at this head (26 reviews, no CI result), so:
$ cmake --build /tmp/b1778m --target npu_pack_import_test build rc=0
$ /tmp/b1778m/npu_pack_import_test tests/golden run rc=0 all npu-pack checks passed
Red proof on the one choice that matters most in pad_rows_to_tile_quantum — the padded scale:
q.scale.insert(q.scale.end(), gpr, 1.0f); -> 0.0f /* the malformed choice the comment warns about */
mutant -> rc=1
FAIL head padding: every padded row is code 0, scale 1, zero 0 -- which decodes to 0.0, not to a weight
restored -> rc=0The arm set is genuinely good: 32,016 → 32,032; codes and scales grow one group's worth per row; the padded rows decode to 0.0 with a fixture whose real codes are non-zero (so padding is distinguishable from copied data); the vocabulary's own rows verified untouched; an aligned 32,000-row vocabulary not touched at all; 16-row and 1-row slack both rounding to exactly one boundary. That is the shape a padding fix needs.
The one thing to fix: "both halves are pinned here" is not true of the second half
The header comment says:
"…padded with rows that decode to zero, and the container's vocab_size is NOT changed — the padding is invisible to the vocabulary. Both halves are pinned here because widening N without staying invisible is the failure that would show up as two plausible extra tokens at the end of every distribution."
Every arm in test_head_row_padding() tests pad_rows_to_tile_quantum. Nothing asserts the second half:
- no arm reads a container's
dims.vocabafter padding and checks it is still 32,016; - no arm asserts the logits/sampling width stays NV — the reader-side sentence in
q4nx_forward.cpp("The extra rows decode to zero and are never sampled: the logits vector stays NV long") is code plus comment, not a test.
That matters because the failure you describe is precisely a widening failure — and wet code plus a comment is how an overclaim becomes the next reader's assumption. Two ways to close it, either is fine by me:
- an arm, if a host path exists: pad a 32,016 vocabulary, build the container, assert
dims.vocab == 32016whileq.N == 32032— the pair is the invariant, and it needs no device; - or reword the comment to say the pad is pinned here and the invisibility is argued from the reader keeping
NV, with the forward path as its evidence.
I am not blocking the fix on this: the importer change is right, the numbers are right, and the padding really does decode to zero. I am blocking the word "pinned" from covering something no test touches, because that is the same sentence I have had to write about five other PRs today.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0d629ec.
The bug is a false refusal, which is the mirror of the ones this family has been fixing, and the comment explains both ends of it: the importer pads the container's
head to a 32-row q4_1 quantum (CodeLlama's vocabulary is 32,016, not a multiple of 32), so the reader must dequantise on the tensor's own grid and then keep only
the vocabulary's rows — asking for exactly NV rows is what failed, because dequant_any() then refused a head that was perfectly well formed.
Two things I checked and agree with:
- the geometry comes from the tile count and the grid, not from
shape[1]— the tensor is{tiles, bytes-per-tile}(CodeLlama:{16016, 5120}), and the comment
namesshape[1]explicitly as the thing that was wrong. That is the same lesson as #1878's vocab check (the geometry must come from the layout the reader actually
reads) and the reason I would keep the comment verbatim; - the padding is bounded and harmless by construction: the extra rows decode to zero and the logits vector stays
NVlong, so the padding cannot leak into
sampling. A version that slicedNVrows before dequantising would have read the wrong tile boundary; slicing after is the only order that is both correct and
cheap.
One thing I would pin as a test: a head whose row count is NV + pad (e.g. 32,016 → 32,032) must import and produce an NV-long logits vector, and the arm should
assert the length of what comes out, not only that no error was raised. The failure being fixed is a refusal, so "it imported" is the easy half; "and it kept
exactly the vocabulary's rows" is the half that catches an off-by-tile slice.
|
VERDICT: READY at The defect is a quantum mismatch, not a kernel problem: CodeLlama's vocabulary is 32,016 and Why the padding is safe, stated in the terms that decide it: padded rows are code 0, scale 0, zero 0, so they decode to 0.0 rather than to a weight. A padded row that decoded to any non-zero value would be a fabricated logit for a token that does not exist; padding with zeros that the dequantiser The arms are chosen for the invisible-failure mode: the second exists because "widening N invisibly is the whole contract, and getting it wrong would put two plausible …" — i.e. the failure is not a crash but a shifted row. Plus the 32-aligned case (32,000 → untouched, no growth, no note) which is what keeps the change from perturbing packs that are already fine, and the Two asks.
Credit for the framing: "the third 4096 family's pack" ties this to the Not run: I did not import the CodeLlama pack or run the four arms (substrate not mine; the box is at 31 GiB with my #1925 run queued). The review is of what the padding must be invisible to, and of the one number that could still see it. |
|
Overlap alert, and I would take #2020's version of this block. #2020 ( Your delta that must survive either way: the padded-design activation read (chatglm3-6b |
VERDICT at
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Its reader hunk is superseded — please let #2020 own it.
#2020 (38b70f9) rewrites the same block properly: it deletes the shape[1] == kpad_ assumption entirely, derives per_block = kpad_/Q4NX_TILE_COLS and blocks = shape[0]/per_block — correct for both spellings ([rows, kpad_] and [tiles, tile bytes]) — and fixes the call that actually refused the tensor, dequant_any(…, NV, kpad_, …), to dequantise on the tensor's own grid and keep only the vocabulary's rows.
This PR's version (shape[0] > 0) only makes the gate pass; the dequant_any call still asks for exactly NV. That is the permissive-but-incomplete half of the same fix, which I flagged as a question at 0d629ec and #2020 has now answered better than my suggestion.
So my recommendation on this PR is unchanged — READY, land it — with its q4nx_forward.cpp hunk dropped: keep the importer-side padding (pad_rows_to_tile_quantum) and the {4096, 32, 36, 12608, 5, 1, 2297856} table row, which is what the title promises, and let #2020 carry the reader. Landing this one first would put a looser gate in place of the derived one, and the two will conflict on that block either way.
|
Duplicate: this and #1992's own latest commit fix the same defect, and one of them should be dropped. Both change the same test in the same block:
And this branch is stacked on #1992's branch (its parent is A question on the numbers in the new comment, because they do not reconcile on their face. It says the head is Three readings I can see, and I cannot tell from the PR which is meant:
This matters because the guard's acceptance test is Independent of that, the rest of my #1992 review stands: the logits bound stays at |
|
Flagging from the sibling PR: #2020 is the same fix as this one — same file, same |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 0d629ec
Reviewed by reading the arithmetic, building the branch and running npu_pack_import_test (all npu-pack checks pass, test_head_row_padding hooked and silent — this
suite only prints failures).
The defect is real and the fix has the two halves it needs. q4_1 tiles hold 32-row multiples and CodeLlama's vocabulary is 32,016, so the import refused with
q4_1 tiles need N % 32 == 0 — a head the grid cannot hold, not a wrong head. The change pads on the importer side and teaches the reader to dequantise on the
tensor's own grid while keeping only the vocabulary's rows, which is the right split: one side cannot fix this alone.
What I checked, rather than took on trust:
- the quantum arithmetic: 32,016 → 32,032, a 32-aligned vocabulary (32,000) untouched, and 32,017 / 32,031 both rounding up to one boundary rather than two — all
three pinned in the test; - the zero-row encoding is the interesting detail and it is right:
code 0, scale 1, zero 0decodes to exactly0.0, whilescale 0would be a malformed
parameter block to the reader. The comment records that the first version got this wrong and how it was caught (lm_head dequant failed), which is the kind of note
that stops the next person "simplifying" it back; vocab_sizeis left at the vocabulary's own number, so the padding is never addressed (also tested) — the failure otherwise would be two plausible extra tokens
at the tail of every distribution, which is invisible in a spot check and visible in a benchmark;- the reader's row count comes from the tile count, not from
shape[1]— the comment says comparingshape[1]againstkpad_was the wrong test, and the tile
tensor's{16016, 5120}shape makes that concrete. A wrong derivation there would have silently under-dequantised the head.
One note, not blocking: the reader's branch is guarded by kpad_ % Q4NX_TILE_COLS == 0 and a divisibility check on shape[0], and falls back to NV otherwise.
That is conservative and correct, but it is also the sort of fallback that hides a malformed tensor — if it ever fires on a real pack, the symptom will be
"lm_head dequant failed" again rather than "this head's tiles do not describe a whole number of rows". A one-line fprintf when head_rows cannot be derived would
turn that into a named fact.
This is the fourth family's last blocker and the change is scoped to it; nothing else in the file moves.
|
Superseded by #2026 — same change, plus the pure function and a test, and that is the better vehicle. #2026 touches the same four files as this PR (plus What #2026 adds that this one lacked: the row rule was arithmetic in the middle of the head-loading path, testable only against a container, and CI has none. It is now What this PR established, and is worth keeping in the record: the three-way measurement that showed the arm was the outlier on chatglm (f32 tracking the device at 0.985–0.988 while the integer arm read 0.40 → −0.003), which led to the padded-activation read; the sixteen-position localisation on CodeLlama (identical at every step, 16384–16399) that led to the tile-count row derivation; and the block-width sweep ( Result that stands, independent of which PR carries the code: all four h4096 families are bit-exact at forward level — |
|
Closing as superseded by #2026, per the analysis above: same four files plus |
…at broke it (refs #1990, #2020) (#2026) * npu: the integer arm's operands come from the engine's packer, not a private re-quantisation The arm was merged in #1809 with a quantisation rule of its own: per 128-K group, int4 codes in [-8, 7]. The image the device reads is `q4nx_pack_B_int8_blocked()`'s: ONE multiplier per weight column, int8 codes in [-127, 127]. Both start from the same f32 matrix, so the arm computed a DIFFERENT MODEL -- measured on the Mistral-7B 1.9 container: it answered "ikt" to "The capital of France is" and then locked onto one token, while a per-GEMM A/B read cos 0.9999 against f32 on the same tensors. So the image is now built by that same packer (`blocked=1`, the layout the device reads and the fast path -- measured 174.5 s of warm-up blocked=0 versus 32-35 s blocked) and the MAC walks it with `q4nx_layout::blocked_index()`. Measured after, on current main: warm-up 35.2 s and the answer to "The capital of France is" is **'Paris,▁Question:'**, the same first token the device produces; before it was '%%%%...' / '▁/******/'. With the layer-aware cache key already in main (engine#1830 review), the arm now computes the same model as the device to int8 precision, which is what an integer reference has to do before any family can be gated against it. `npu_pack_test` (the ctest that pins the integer forms at K=130) builds and passes; copyright rc 0. * npu: keyed dumps settle it -- the device's weight scales are BIT-IDENTICAL to the arm's (384/384) Dumps were paired by index first and that was meaningless: the two sides reach the GEMM a different number of times (1024 arm dumps against 384 device dumps on the same request). Keyed by (layer, N, K, ordinal) they pair exactly. Result, one token, Mistral-7B 1.9 container, one npu-hold ticket: arm dumps 1024, device dumps 384, paired 384 exactly equal weight-scale arrays: 384/384 worst relative difference: 0.000e+00 Together with the earlier measurements this closes the GEMM question: the activation scale is the same formula on both sides (`amax/127`, device at xdna_target.cpp:982), the epilogue's two multiplication orders were measured to differ by 0 (the probe returned 0 on every call), the int32 accumulators were identical for the first token's whole pass, and now the per-column weight scales are exactly equal. So the device's GEMMs are bit-exact against this arm. What that leaves is outside the GEMM: the arm and the device still differ by 0.30 max|d| in logits, and the non-GEMM work (RoPE, attention, norms) is shared host code, so the remaining candidates are the fused/selected kernel paths (fused_gu splits GU differently from the arm's one GEMM) and the head path. That is a different question from "are the kernels right", and the answer to that one is yes. Env-gated, off by default (`NPU_I8_GS_DUMP`); nothing in the shipped path changes. * npu: the integer head, and the gate it unlocks -- device logits BIT-EXACT over four steps The head was the last place the host arm and the device computed the same tensor differently: in host mode `run_lm_head()` is a plain f32 matvec ("No I8Ctx is touched") while the device reads a packed quantised table (`lm_head_packed_`, `run_lm_head_gemv`). That was the whole of the remaining `max|d| = 0.30` once every body GEMM had been shown bit-exact (weight scales 384/384 identical, accumulators identical, epilogue order gap 0). The device packs its head tiles with `lctx.packB_into(...)` -> `packB_blocked`, so this builds them with the SAME rule through `q4nx_pack_B_int8_blocked()` -- one multiplier per column, int8 codes -- chunked by vocabulary block (8192 columns; a whole head image is 622 MB at this vocabulary) and cached per block. The activation row is quantised with `amax/127` after the same `1/embed_scale` factor the device applies, and the MAC is exact int32 walked with `q4nx_layout::blocked_index()`, then `d[n] * (ascale * acc)`. Measured, one npu-hold ticket, device vs this arm, logit dumps compared per step: step 0: argmax 3916 == 3916 cos 1.000000 max|d| 0 step 1: argmax 278 == 278 cos 1.000000 max|d| 0 step 2: argmax 28725 == 28725 cos 1.000000 max|d| 0 step 3: argmax 22478 == 22478 cos 1.000000 max|d| 0 BIT-EXACT over 4 step(s) at max|d| tolerance 0.0 That is TNPU-ALL section 4's bar met literally for this family rather than read down to "at the accumulator": the device's arithmetic and the integer reference agree to the last bit, on every step, for a full request. * docs/npu: say what NPU_INFER_HOST_GEMM=i8 is for, and what it measured The integer arm was only discoverable from the code. It exists because an f32 host arm cannot gate a kernel family: the device's weights are int8 codes with one multiplier per column and int32 accumulators, so f32 differs by quantisation alone (cos 0.9960-0.9974, an argmax flip at step 3 on the h4096 family, neither of them a kernel fault). Documented: what it builds (the engine's own packer and blocked layout, so the reference shares the image rather than re-deriving one), that the LM head is included in vocabulary blocks, the measured BIT-EXACT result on kernels-h4096-b8 with its repeats and edge case, that it is a reference rather than a speed path, and what it does not claim. * docs/npu: the two-arm command sequence for the bit-exact gate A documented reference that nobody can run is a claim, not an instrument: the section now carries the two forward-serve invocations, the dump naming scheme, and the three-step discipline (compare per position, repeat the device arm for determinism, add an edge case). * npu-pack: the h4096 36-layer fconst row, so the Qwen3-8B pack imports at all `kFconst19Rules` held the 32-layer h4096 row and nothing for 36 layers, so `npu-pack info` refused `amd/Qwen3-8B_rai_1.9.0_npu` by design: "its block shape has not been measured for hidden 4096, 32 heads, 36 layers … the 1.8 pack of the same model is what establishes it". Measured now, with tools/fconst19_measure.py against amd/Qwen3-8B_rai_1.8.0_npu_16K: {4096, 32, 36, 12608, 5, 1, 2297856} 26,104 of 3,756,627,264 bytes differ (6.95e-06 of the file) -- content, not layout Every field but the layer count is identical to the 32-layer row (prefix 12,608, five tiles of 2,297,856, rotation 1), which is the first row here where the layer count moves nothing but the number of blocks: the h2048 pair moves both the prefix and the division with it (4,160/9 tiles at 36 layers against 8,512/3 at 24), so this row is measured rather than inferred from that one. The consequence beyond this pack: a kernel family and an fconst rule row are not the same partition. One family can span several rows, so each new pack of a family may cost a measurement before it costs a gate. Verified end to end: the import now completes -- 5,760/5,760 .fconst tiles survive decode->encode byte for byte, 144 attention projections plus 108 MLP projections with fp32 scale/zero exact for 100.00% of groups, 8 full_i8 ELFs linked from kernels-h4096-im12288-b8. * npu-pack test: the new 36-layer row goes in BOTH places, as the guard's own comment requires CI caught the omission and said exactly why: "fconst19 rule: the table has exactly the 11 measured rows (12); a dropped row is a silent un-measure of a whole class, an added one must be added here too". The expected rows are literals copied from the measured table rather than read back from it, so that a wrong digit or a dropped row fails -- which is the arm working, not a nuisance. Added {4096, 32, 36, 12608, 5, 1, 2297856} to it, and the arm now passes. * npu-pack: pad the head's rows to the q4_1 tile quantum, so a 32,016-deep vocabulary imports CodeLlama-7b-Instruct's head is 32,016 rows and q4_1 tiles hold 32-row multiples, so the whole import refused with "q4_1 tiles need N % 32 == 0" -- a head the grid cannot hold, not a head that is wrong. The projections already had this rule (the pack's own padding stays, and the .fconst tail carries ceil(V/64)*64 rows: Hunyuan-0.5B has 120,832 for 120,818), but the graph-weight head path had no padding at all. pad_rows_to_tile_quantum() pads with rows that decode to zero (code 0, scale 0, zero 0 -- the quantised value is code*scale + zero, so a zeroed group is 0.0, not a weight) and LEAVES vocab_size at the vocabulary's own number, so the padding is never addressed. That invisibility is the part a test has to pin: widening N without it would put two plausible extra tokens at the end of every distribution. Measured on the pack that found it: the import now completes -- 387 tensors, 806,544 q4_1 tiles, container read back through q4nx::dequant_tile with rel_err 0.000e+00 and max|diff| 0.000e+00, 8 full_i8 ELFs linked from kernels-h4096-mha-b8 (the 4096x12288 family TNPU-ALL W3b asks to verify end to end). * npu: a padded head is only invisible if the READER agrees -- scale 1.0, and dequant on the tensor's grid The first gate run on CodeLlama refused with "lm_head dequant failed", and it was right twice over. 1. The padding wrote SCALE 0 for its rows. Arithmetically code 0 * 0 + 0 = 0.0, but a zero scale is not a zero weight to a reader -- it is a malformed parameter block, and the dequant refuses it. The test asserted exactly the wrong property (scale == 0.0f), which is how a wrong assumption gets to look pinned. Both now say scale 1.0, with the reason written next to them. 2. The forward asked for exactly NV rows, and NV here is the VOCABULARY (32,016), which is not a multiple of the tile's 32 rows -- so dequant_any() refused a head that was perfectly well formed. The container's vocab_size is the vocabulary; the tensor carries the padding; the reader has to dequantise on the tensor's own grid and keep only the vocabulary's rows. That is the same convention the .fconst path already relies on (Hunyuan-0.5B: 120,832 rows for 120,818), and it was simply not implemented on the graph-weight path. The extra rows decode to zero and are never sampled: the logits vector stays NV long. So this commit is importer + reader, and the PR's claim is only true with both: "the padding is invisible to the vocabulary" is a property of the pair, not of the writer. * npu: the integer arm reads a padded activation row through the pad, so a non-256-aligned IM gates host_gemm_i8() read its activation row with a[k] for k < K, and K is the PADDED design width. chatglm3-6b's IM is 13,696 and its D design is K = 13,824, so the arm read 128 floats past the caller's row -- into the next row's data, or past the buffer on the last one. Measured before the fix on kernels-h4096-chatglm-b8: cos 0.40 -> -0.003 -> 0.06 -> 0.005 against the device, argmax unrelated, while the f32 arm tracked the device at 0.985-0.988. After: max|d| = 0 on all four steps, cos 1.000000. The f32 leg has always padded -- q4nx_host_gemm_f32_padded() "owns the pad-and-read pair so neither leg can be handed an unpadded A", a comment that records a review catching exactly this -- and the integer leg, written later, did not. One lambda now serves both loops (amax and quantise), so the two cannot drift again. Why this looks like three unrelated mysteries until it is one: the families whose shapes are all 256-aligned were bit-exact (Mistral, Qwen3-8B), the one with a padded K was wildly wrong (chatglm), and the one with no padding but a residual (CodeLlama) is a different, smaller defect. This is the first hybrid pack with a non-aligned IM. * npu: restore the reader's padded-head block, which the merge dropped, and make the head block width a knob The merge that took main's unpadded-image work wholesale over q4nx_forward.cpp also took main's head dequant, which asks for exactly NV rows -- so the padded-head reader (10 references, reduced to 3) was silently lost and CodeLlama went back to "lm_head dequant failed" on the next run. Restored verbatim from 8932ac0. That is the second time a resolve-to-main has cost a fix on this file, which is why the block now carries its own measurement in the comment. Also: NPU_I8_HEAD_TB (default 8192, multiple of 128). CodeLlama's residual is confined to exactly sixteen logit positions starting at 16384, identical at every step -- a WEIGHT difference localised at a block edge, not accumulation drift -- and 16384 is where this arm's first head block ends. Moving the block width decides whether the chunking or the device's head read is responsible, in one ticket instead of an argument. * npu: the padded-head reader counts tile-rows in shape[0], so a [tiles, tile bytes] head dequantises (refs #1990, #1992) `q4nx_forward.cpp`'s padded-head path (engine#1990) gated on `ltd->shape[1] == kpad_` before deriving `head_rows` from the tensor's grid. A container that stores the head as `[tiles, tile bytes]` -- which is what the importer writes, e.g. CodeLlama-7b's `I8 [16016, 6144]` for a 32,032-row head at `kpad_` 4,096 -- fails that test, so `head_rows` stayed at `NV` (32,016) and `dequant_any()` refused the tensor: `lm_head dequant failed`, on a head that is exactly what the importer had just been asked to pad and write. Both spellings count **tile-rows** in `shape[0]` (`[rows, kpad_]` and `[tiles, tile bytes]`), so the number of 32-row blocks is `shape[0] / per_block` either way -- only the row width in `shape[1]` differs. The gate is now just "the block count divides evenly", and the padded rows are decoded and dropped as before (`head_rows != NV` copies only `NV` rows out). ## Measured end to end, on the pack this is for `amd/CodeLlama-7b-Instruct-hf-onnx-ryzenai-hybrid` -- the **first MHA 32/32 member of the 4096 family** (H 4096, QKV 12,288, GU 22,016, D 11,008) -- imported with `--weight-layout b8` (387 tensors, 806,544 q4_1 tiles, head padded 32,016 → 32,032, `vocab_size` untouched), kernels `~/ogaload/oga13/kernels-h4096-mha-b8`: ``` before: 1bit forward-serve: forward init failed: lm_head dequant failed after : 1bit forward-serve: row-cl7b ready on http://127.0.0.1 (full ELF) [npu-target=xdna] "The capital of France is" -> ' Paris. The capital of France is Paris. …' "The first element in the periodic table is" -> ' hydrogen, which is the lightest element.' "2 + 2 =" -> ' 6\n # 1 + 2 + 3 + 4 = 10\n …' (a code model, continuing a list) 15 prompt tokens, ttft 3.0 s; 64 tokens in 66,735 ms -> 0.96 tok/s ``` 0.96 tok/s is a *measurement*, not a pass -- the dx bar is 9.2 tok/s, so the row reads `OOB | 0.96 tok/s` like the other 7B rows, and the point of this PR is that the pack reaches `ready` and answers at all. Refs #1990, #1992 (which pads the head at import; this is the reader half), #1503. * npu: expose the padded-head row arithmetic and arm it on the shape that broke it (refs #1990, #2020) The reader fix in #2020 was arithmetic in the middle of the head-loading path, which is exactly the kind of code that cannot be tested without a container -- and a container is what CI does not have. So the rule is now a pure function: ```c++ int padded_head_rows(int64_t shape0, int64_t shape1, int kpad, int NV); ``` A container stores the head either as `[rows, kpad_]` or as `[tiles, tile bytes]`; both count TILE-ROWS in `shape[0]`, so the number of 32-row blocks is `shape[0] / (kpad / 256)` either way, and the vocabulary need not be a multiple of 32 (CodeLlama-7b: 32,016 -> padded 32,032). The caller decodes that many rows and keeps `NV`, because the padded rows decode to zero. `tests/npu_pad_head_rows_test.cpp` drives it on eight cases, all without a container or a device: * the shape that broke it -- `16016 x 6144`, kpad 4096, NV 32,016 -> **32,032**, whole tiles; * the same tensor in the `[rows, kpad_]` spelling -> the same answer, so the two spellings cannot disagree; * an exactly-aligned vocabulary -> NV (no padding invented); * a kpad that is not whole tile columns, a grid smaller than NV, and a partial block -> NV, so the caller refuses rather than reads past the tensor or invents rows; * NV <= 0 -> passed through, not invented. Mutation: making the invalid-argument branch `return 1` instead of `NV` fails two of the eight. `all padded-head row checks passed`; registered with `add_dependencies(onebit …)` so the target is built by the CI command that runs (engine#1858). Refs #1990, #2020, #1503. * npu: the padded-head arm moves out of the NPU guard, so CI actually runs it (review of #2026) The reviewer's blocking item was right and it is the failure this session keeps finding: `npu_pad_head_rows_test` sat inside `if(ONEBIT_NPU)` because it linked `onebit_npu_forward`, and **CI configures without the NPU runtime** -- so the arm was an assertion nothing ran. Their check, reproduced: ``` $ awk '…guards open at the registration…' -> open guard: 1520: if(ONEBIT_NPU) (closes at 1714) $ ninja npu_pad_head_rows_test -> unknown target, in an ONEBIT_NPU=OFF tree ``` So the rule moves to `npu/forward/include/pad_head_rows.h` -- header-only, inline, no library -- and the registration moves to the top level (line 343, outside every guard), next to the other pure rows. `q4nx_forward.cpp` includes the header instead of defining the function, and the test includes it instead of linking. Verified in **both** configurations, which is the point: ``` ONEBIT_NPU=OFF (~/wt/mtp-holdfix-build) ninja npu_pad_head_rows_test -> builds ./npu_pad_head_rows_test -> all padded-head row checks passed ONEBIT_NPU=ON (~/.cache/scratch-mtploop/build) ninja onebit -> builds (the forward compiles against the header) ``` That is the difference between "the arm exists" and "the arm runs where a regression would land". Refs #1990, #2020, #2026. * npu: the head's stored rows are read from its whole shape: three spellings, one bound (answers CHANGES at 63a47be; engine#1788) padded_head_rows() ignored shape[1] and read shape[0] as a tile count, so a head really stored [rows, kpad] would have been asked for twice its rows (review of #2026 at 63a47be), and FastFlowLM's own three-dimensional spelling [row tiles, column tiles, bytes] was refused: MiniCPM-V 4.7 1B's I8 [7753, 4, 8704] head (248,096 rows for a vocabulary of 248,094) failed with 'lm_head dequant failed'. The rule now takes the shape and says where the row count is in each spelling, and one bound holds for all of them: padding only reaches the next 32-row boundary, so an answer outside [NV, NV + 32) is not this tensor's row count. The arm uses the real row-spelled shape (32032, 4096) and both MiniCPM heads. A duplicated log line is removed. Measured: with the d3-lite kernel set (the same geometry), MiniCPM-V 4.7 1B's text container now reaches ready and answers ' Paris.' on the host arm and on the device. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * npu: the padded-head bound's lower end is pinned in all three spellings (answers CHANGES at 3a1b03a) The review showed that deleting 'rows >= NV' from the bound left every arm green: the existing short-grid arm was declined for not being whole blocks, not for being short. Three arms now feed whole 32-row grids of 1,024 rows for a vocabulary of 32,016, one per spelling ([512, 6144], [32, 16, 6144], [1024, 4096]); only the lower bound declines them. rows >= NV deleted: 3 FAIL (got 1024, want 32016) rows < NV + 32 deleted: 2 FAIL unmodified: all padded-head row checks passed (19) Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: t <t@e.com> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
The third 4096 family's pack could not import at all, for a reason that had nothing to do with the kernel set.
amd/CodeLlama-7b-Instruct-hf-onnx-ryzenai-hybridis the pack forkernels-h4096-mha-b8— H 4096, MHA 32/32, IM 11,008 ⇒ QKV 12,288, GU 22,016, D 11,008, i.e. the 4096×12288 setTNPU-ALLW3b asks to verify end to end. Its vocabulary is 32,016, andq4_1tiles hold 32-row multiples:The rule already existed for projections and for packs that pad their own head.
pack_import.cppcarries it explicitly — "the pack's own padding stays: q4_1 tiles need N % 32 == 0, and the padded row count (ceil(V/64) x 64) satisfies it where V itself may not" — and the.fconsthead path relies on it (Hunyuan-0.5B carries 120,832 rows for a 120,818 vocabulary). What was missing is the graph-weight head path: no padding at all.The change
pad_rows_to_tile_quantum(q, vocab): pad the head to the next 32-row boundary with rows that decode to zero (code 0, scale 0, zero 0 — the quantised value iscode*scale + zero, so a zeroed group is 0.0 rather than a weight), and leavevocab_sizeat the vocabulary's own number so the padding is never addressed.Tests
Four arms, and the second one is why they exist: widening N invisibly is the whole contract, and getting it wrong would put two plausible extra tokens at the end of every distribution.
Verified on the pack that found it
What it does not claim
That the container serves — that is the next measurement (the import notes "layer blocks not located: fewer than 6 RMSNorm epsilon headers in the first GiB", which is expected for a hybrid pack whose norms come from the graph rather than a
.fconsttail, but 'expected' is not 'measured'). And nothing about other non-aligned vocabularies beyond the arithmetic: the padding is a row-count fix, not a quantisation change.