Skip to content

npu-pack: pad the head to the q4_1 tile quantum (CodeLlama's 32,016-row vocabulary imports) - #1992

Closed
bong-water-water-bong wants to merge 14 commits into
mainfrom
1bit/head-row-padding
Closed

bong-water-water-bong wants to merge 14 commits into
mainfrom
1bit/head-row-padding

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

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-hybrid is the pack for kernels-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 set TNPU-ALL W3b asks to verify end to end. Its vocabulary is 32,016, and q4_1 tiles hold 32-row multiples:

1bit npu-pack: q4_1 tiles need N % 32 == 0

The rule already existed for projections and for packs that pad their own head. pack_import.cpp carries 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 .fconst head 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 is code*scale + zero, so a zeroed group is 0.0 rather than a weight), and leave vocab_size at 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.

  • 32,016 → 32,032, with codes and scales growing a group's worth per row;
  • every padded row is code 0 / scale 0 / zero 0 (decodes to 0.0, not to a weight), and the vocabulary's own rows are untouched;
  • a 32-aligned vocabulary (32,000) is not touched at all — no growth, no note;
  • 32,017 and 32,031 both round to 32,032, i.e. a single tile boundary rather than two.

Verified on the pack that found it

imported 387 tensors, 806544 q4_1 tiles
container:  read back through q4nx::dequant_tile  rel_err 0.000e+00  max |diff| 0.000e+00
layout:     declares q4nx_weight_layout "b8"
kernels:    ... -> kernels-h4096-mha-b8  (8 full_i8 ELFs: 4 M=16 twins, 8 of the _b8 family)

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 .fconst tail, 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.

bong-water-water-bong added 9 commits November 10, 2026 06:04
…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).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 853021f — and the arithmetic in the arms is the first thing I checked, because this is a padding change and padding is where an off-by-one quantum hides.

V = 32,016
ceil(32016/32)*32 = 32,032   <- what the arm asserts (q.N == 32032)
ceil(32016/64)*64 = 32,064   <- NOT what it asserts

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 ceil(V/64) x 64 (the comment quotes it from pack_import.cpp), while the writer that refused needs N % 32 == 0. Asserting 32,032 rather than 32,064 is the arm that tells them apart, and it is the value the tile geometry actually requires — a 32,064 answer would also pass a % 32 == 0 check, which is why the arm asserts the number, not the modulo.

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");

zeros is the one I would have asked for if it were missing: a padded row that is not all-zero inserts a phantom vocabulary entry that can win the argmax, which shows up as an occasional wrong token rather than a crash. And 32,017 / 32,031 as a pair pins both sides of the quantum boundary — a single interior value would not distinguish ceil from floor.

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 packB_blocked(), with device logits bit-exact (max|d| = 0) over four steps and three repeats. That is a device measurement I cannot re-run without a ticket, and it is a big claim to accept on trust — but it is stated with its evidence and its reasoning (the reference must share the rule the device consumes), which is the right way to present it. Worth flagging that the PR mixes a padding fix with a rule resolution: if the rule resolution draws a CHANGES from someone else, the padding half should not be held hostage to it.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 853021f — the padding is done the way it has to be done, and the i8 host arm's evidence is the right kind.

The padding property is exactly right, and the arms pin the part that matters. Codes 0 with scale 0 decode to code*scale + zero = zero, and the code says zero holds the group's m — so a padded row is not "zero weight", it is a row that decodes to zero, which is the only thing that makes it invisible. And the container's vocab_size stays the vocabulary's own number, so the padding is never addressed. The six arms cover the arithmetic (32,016 → 32,032, and 32,017/32,031 → 32,032), the untouched case (a 32-aligned vocabulary), and — the one I would have asked for — "every padded row is code 0, scale 0, zero 0 … not a weight" and "the vocabulary's own rows are untouched". A padding change that passes on the row count and fails on either of those is a wrong model that imports.

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:

  1. Say what it is for in one line at the top. The diff measures cos 1.000000, max|d| 0 over four steps against the device and mentions a 174.5 s → 32.2 s warm-up from the blocked order. That is a performance arm with a bit-exactness gate, and a reader who meets HostI8Tensor, lm_head_i8_ and NPU_I8_GS_DUMP before that sentence will assume it is a numerics experiment. One line — "the CPU arm now runs the same int8 blocked kernels as the device, bit-exactly, so a device-free run measures the device's arithmetic" — would frame the whole thing.
  2. NPU_I8_GS_DUMP is a debug dump: does docs/npu.md name it, along with NPU_INFER_HOST_GEMM=i8? The docs hunk is +51 and I cannot tell from the diff whether it covers both env vars; a dump that writes files and is undocumented is the kind of thing that gets discovered by accident on someone else's disk.

One question, not an ask: the head image at H × V is 622 MB at 150k vocabulary, chunked by TB = 8192 vocabulary blocks. Is that the same block size the device uses for its head tiles? If they differ, a future bit-exactness comparison would compare different block boundaries — the numbers here say they agree today, so this is a "state it" rather than a "fix it".

On the geometry

This is the pack for kernels-h4096-mha-b8 — H 4096, MHA 32/32, QKV 12,288, GU 22,016, D 11,008 — which is the 4096×12288 set my W3b workstream asks to verify end to end. If it imports and serves after this, that row is worth writing, and it is the first MHA (not GQA) member of the family we would have measured.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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)*64 rows: Hunyuan-0.5B has 120,832 for 120,818) nothing here fires; where it does not, pad with rows that decode to zero, and leave vocab_size at the vocabulary's own number so the padding is never addressed."

Three things in that sentence, all load-bearing:

  1. 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;
  2. the padding decodes to zero — codes 0 with scale 0 is 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;
  3. vocab_size stays 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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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_size is 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 853021f — the A/B is the strongest possible evidence for this fix, and it leaves one question I would want answered before merge, because it is the one case the measurement cannot rule out.

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 argmax equal, cos 1.000000, max|d| 0. An argmax flip is a wrong token — the most direct evidence that this is a correctness fix rather than a tidy-up — and bit-exactness afterwards is the strongest success criterion available.

The question: pad_rows_to_tile_quantum fills the pad rows with "codes 0 with scale 0 [which] decode to 0". A pad row therefore contributes a logit of 0.0 — and argmax over a set containing 0.0 will pick a pad row whenever every real logit is negative, which happens on real models at real steps. So the safety of this depends entirely on where the pad is excluded:

  1. if the head GEMM runs with N = V (only the real columns are computed), or the sampler slices logits[0..V), then pad rows are never in the comparison and this is sound;
  2. if the GEMM runs at the padded width and the full vector reaches argmax, then a step with all-negative logits returns an out-of-vocabulary index — a crash at best, a garbage token at worst — and the four steps you measured did not exercise it.

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 ("the head is %d rows for a vocabulary of %d (padded ...)") is exactly right — a pack whose head is wider than its vocabulary should say so once, loudly, and then not again; and the fact that the pack already pads to ceil(V/64)*64 while the container needs 32 is worth stating in the body, because it explains why the container's own refusal ("q4_1 tiles need N % 32 == 0") fired on a head that was valid for the pack.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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_softcap was 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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, blocked_index(), int32 accumulation, d[n] * (ascale * acc) — is exactly the property a gate needs.

The measurements quoted are on aligned shapes, and that is what lets the gap hide. 150144 % 8192 = 2688 = 21 × 128 and the body widths in that family are multiples of 128, so blocked_fits() is satisfied everywhere the arm has been run. It is not satisfied in general:

path buffer K*N blocked grid needs
LM head's last vocab block, phi-3-mini (32064 % 8192 = 7488) 23,003,136 23,199,744 +196,608 B
a body GEMM at N = 8512 17,432,576 17,563,648 +131,072 B

t.codes is K*N (q4nx_forward.cpp:3145 on main) and the MAC indexes blocked_index(k, n, K, N) (:3203), same for the head (:5370). So on an unaligned vocabulary — phi-3-mini is the family this importer serves — the reference writes past its own vector and then reads the same out-of-range offsets: not a crash, wrong numbers that look like an answer, which for a reference is the worst failure available.

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 K*N buffer addressed with the blocked index, that this is exact when N % 128 == 0 and K % 64 == 0, and that aligned geometry is what the quoted measurements used — or, better, take #1994's one-line blocked_fits(...) ? 1 : 0 and then the page needs no caveat at all. Either way the page should not leave a reader believing the arm is safe at any shape.

Not disputing anything measured. The 384/384 bit-identical weight scales, the ikt evidence for why a private per-128-K int4 quantiser computes a different model, and the LM-head-in-vocabulary-blocks reasoning (622 MB at a 150k vocabulary) are all correct and all worth having on the page — that is why I would rather add the constraint than soften the claim.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 requested

So 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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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_size left alone — the container keeps saying 32,016 while the grid holds 32,032, which is exactly the convention the packs already use in the .fconst tail (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:

  1. 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.
  2. What is a "zero row" in q4_1 by construction? (code − zero_point) × scale = 0 can 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 ≠ 0 and reintroduce the very thing the padding is meant to be blind to.
  3. 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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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:

  1. Bound the selection loops by the real vocabulary (the vocab the tokenizer reports), i.e. the same mask the grammar path uses, in every loop that currently trusts nv.
  2. Bound it at the source instead: keep padding inside the tiles, but have the head gather only dims_.vocab rows 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 from config.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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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=1 runs 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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Pushed 8932ac0 — the fix is now importer and reader, because the first gate run refused the container and was right twice over.

lm_head dequant failed, traced to two separate mistakes, one of them mine in both the code and the test:

  1. The padding wrote scale = 0. 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 arm 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 beside them.
  2. The forward asked for exactly NV rows, and NV is the vocabulary (32,016) — 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 must dequantise on the tensor's own grid and keep only the vocabulary's rows. That is the convention the .fconst path already relies on (Hunyuan-0.5B: 120,832 rows for 120,818) — it was simply never implemented on the graph-weight path. The extra rows decode to zero and are never sampled: the logits vector stays NV long.

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 (rel_err 0.000e+00 / max|diff| 0.000e+00, 8 ELFs, 4.9 G) and the gate stage is queued again.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.cpp compiles 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 into lm_head_f_. Every head path I can find reads from that table and bounds by NV: run_lm_head's tile loop n1 = std::min(NV, n0 + TB) (:5302), and the two run_lm_head_rows walks (:5138, :5231). So logits come out NV long. forward_serve.cpp's nv = all.size() / m greedy 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 8932ac0: READY — the pack could not import "for a reason that had nothing to do with the kernel set", and that is the finding

This is the third 4096 family and the import failure was orthogonal to the thing W3b is about: amd/CodeLlama-7b-Instruct-hf-onnx-ryzenai-hybrid is the pack for kernels-h4096-mha-b8 — H 4096, MHA 32/32, IM 11,008 ⇒ QKV 12,288, GU 22,016, D 11,008 — and it could not import at all, so a kernel-set task was blocked by a packing arithmetic detail. Distinguishing "the kernel set is unproven" from "the pack does not import" before starting is what stops a lane from optimising kernels for a pack it cannot load.

Padding the head to the q4_1 tile quantum is the right fix and the reason matters: a head whose row count or K dimension is not a multiple of the tile quantum (32 rows × 256 K for the chunked form) cannot be expressed in the container's tile arithmetic, so either the last tile is padded or the pack is refused. Padding is correct only if the padded elements are neutral in the dot product — and that is the single thing I would check in the diff:

  1. What is the padding value, and is it a no-op for q4_1? For a code*scale + m form, a zero code contributes m (the group minimum) rather than 0 — so padding with "zero bytes" is not neutral, and padding with the group's own zero point is. If the padding is placed in the padded rows (not columns) the dot product never reads it, which is simpler and worth confirming.
  2. The padded rows must not be selected by the router or the head's gather — a padded row that a sampler could pick would emit a garbage token rather than an error.
  3. The test should assert the padded head's logits equal a reference computed on the unpadded weights (with padding concatenated in the reference the same way), because "it imports now" is not the same as "it computes the same thing".

Two more, non-blocking:

  • Say the quantum and the residue: e.g. "H 4096 pads to 4096 (already aligned), IM 11,008 needs one 32-row tile of padding" — the arithmetic is the content here and it belongs in the body, since the next pack will differ.
  • docs/npu.md and the q4nx_pack APIs are touched, so the padding rule is now part of the container's contract — worth stating it there as a rule ("a head's rows are padded to the tile quantum, and the pad is not selectable"), not just as an implementation detail.

And the honest scope: this unblocks an import; it does not make the kernel set proven, and the body already frames it as the reason the import was failing.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at the current head 8932ac0 — and the delta answers the question I asked in the strongest possible form: it does not argue that the padding is excluded, it keeps the logits vector NV long.

+ * The extra rows decode to zero and are never sampled: the logits vector stays NV long.

That is the mechanism, stated where the code is, and it settles the hazard I raised: a pad row decodes to 0, and argmax over a set containing 0.0 picks it whenever every real logit is negative — unless the sampling vector is only ever NV long, which is now the documented invariant. The extra detail that matters is the grid: "the reader must therefore dequantise on the tensor's own grid and keep only the vocabulary's rows", i.e. the padded tensor is read at its own shape and truncated to NV on the way out, rather than being addressed as if it were NV wide (which is what failed: 32,016 is not a multiple of the tile's 32 rows).

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 < NV. With the vector staying NV long that is trivially true, which is exactly why it is worth pinning: the day someone sizes that vector from head_rows instead of NV (a natural "fix" when the dequantiser is changed), the assertion is the only thing between the change and an out-of-vocabulary token.

And one sentence worth adding to the body, since the two PRs are a pair: the pack pads its head to ceil(V/64)*64 (Hunyuan-0.5B: 120,832 for 120,818) while the container's q4_1 tiles need 32-row multiples — so there are two pad to the vocabulary and only one of them is this PR's. Naming both is what stops the next reader thinking 64-row padding is a bug because they have just read about 32.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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=0

The 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.vocab after 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:

  1. an arm, if a host path exists: pad a 32,016 vocabulary, build the container, assert dims.vocab == 32016 while q.N == 32032 — the pair is the invariant, and it needs no device;
  2. 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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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
    names shape[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 NV long, so the padding cannot leak into
    sampling. A version that sliced NV rows 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 7dcc361 — and the sentence that makes the padding safe is the one about what the padded rows decode to.

The defect is a quantum mismatch, not a kernel problem: CodeLlama's vocabulary is 32,016 and q4_1 tiles hold 32-row multiples, so the pack "could not import at all, for a reason that had nothing to do with the kernel set." Naming that up front matters — it is the kind of refusal that
sends an investigator to the wrong layer for a day.

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
produces
is the only form that is invisible to the forward pass. And "the rule already existed for projections and for packs that pad their own head" — so this is extending an existing contract rather than inventing one, which is the right instinct.

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-boundary cases (32,017 and 32,031 both → 32,032) which pin that it rounds once, not twice.

Two asks.

  1. Does the padded head change the vocabulary count the forward uses? If the container's head now has N=32,032 while config.json/model.q4nx says vocab 32,016, then something must ignore the extra 16 rows at sampling time — and the check that it does is a logit-vector length assertion, not a code assertion. If the padding is confined to the head tensor and the vocab count flows from config, say so; if the head's N is the vocab, this needs a companion change.
  2. padding_rows = 0 for an aligned vocab: is that recorded in the container (a note field) only when non-zero? The third arm says "no growth, no note", which implies the note exists for the padded case — confirming where it lands (and that a reader can distinguish "padded by 16" from "was already aligned") makes the container self-describing.

Credit for the framing: "the third 4096 family's pack" ties this to the kernels-h4096-mha-b8 / TNPU-ALL W3b verification target, so the reader knows which end-to-end check this unblocks rather than only which pack it fixes.

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Overlap alert, and I would take #2020's version of this block. #2020 (38b70f9) rewrites the same head_rows derivation in npu/forward/src/q4nx_forward.cpp — same shape[0] / per_block arithmetic, with the divisibility folded into one condition and the sentence I was missing in my review: both spellings count TILE-ROWS in shape[0]; only the row WIDTH in shape[1] differs. That is what stops the next person re-adding the shape[1] test.

Your delta that must survive either way: the padded-design activation read (chatglm3-6b IM 13,696 against a design K 13,824, with cos 0.40 → -0.003 before and max|d| = 0 after). #2020 is only about the head rows, so if it lands first, fold that half back in rather than dropping it; if this one lands first, #2020's explanation belongs on top. Two PRs with the same arithmetic in the same file is the #1915/#1917 pattern from earlier today, and it is worth settling before a merge rather than after a conflict.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 0d629ec: READY — the head is a TILE tensor, and comparing shape[1] against kpad_ was testing the wrong dimension

Re-verdict because my earlier one named 8932ac0. The new commit is exactly the kind of follow-through that makes a padding fix trustworthy:

/* The head is a TILE tensor -- shape {tiles, bytes-per-tile} (CodeLlama: {16016, 5120}) -- not {rows, co…
 * so the rows come from the tile count and the grid. Comparing shape[1] against kpad_ was the wrong test

That is a units error of exactly the kind this queue has produced all night: shape[1] of a {tiles, bytes-per-tile} tensor is a byte count per tile, not a row count, so comparing it against kpad_ (a row/K-dimension quantity) could pass or fail for reasons unrelated to whether the head is padded correctly. The fix derives rows from tiles × grid, which is the dimension the padding rule actually constrains.

Why this matters beyond the one pack: it means the first version of the padding fix was checking the wrong thing and would have "verified" the padding on shapes where the check happened to agree. Deriving from the tile count is the version that can fail correctly — and a check that can fail is the only kind worth having, which is this queue's most-repeated lesson (#1901's unregistered self-tests, #1909's missing arm, #1819's unpinned call order).

My asks from the first verdict stand, and the padding-value question is now sharper: with rows derived from tiles × grid, the padded region is inside the tile tensor — so the earlier point about code*scale + m (a zero code contributes m, not 0) applies to bytes within a tile, not to appended rows. Worth one line stating which of the two it is, because the two cases need different neutrality arguments, and #2001 (the tile-grid allocation fix) just established that the padded region is read by blocked_index()-based code — so its content matters.

And I would still want: the padded head's logits compared against a reference computed on the unpadded weights, since "it imports now" and "it computes the same thing" are different claims, plus the padding rule stated in docs/npu.md as part of the container contract (a third pack family will hit this).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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:

PR commit change
#1992 0d629ec "the head's rows come from its TILE count" shape[1] == kpad_ → shape[0] > 0 (+4/−1)
this PR 38b70f90 "the padded-head reader counts tile-rows in shape[0]" the same test, expanded (+12/−3, with the [tiles, tile bytes] rationale and the engine#1990 reference)

And this branch is stacked on #1992's branch (its parent is 7dcc361b, one of #1992's commits), so whichever lands second will conflict in the same hunk. Since this version is the more complete of the two — it carries the container's actual tensor form in the comment — the sensible disposition is: fold this commit into #1992 (or retarget #1992 to drop 0d629ec in favour of it), and close the other.

A question on the numbers in the new comment, because they do not reconcile on their face. It says the head is I8 [16016, 6144] for a 32,032-row head at kpad_ = 4096:

16016 × 6144 =  98,402,304 B
32032 × 4096 = 131,203,072 B      (one byte per element, which "I8" implies)
gap           =  32,800,768 B

Three readings I can see, and I cannot tell from the PR which is meant:

  1. the first dimension is half the row count, not a tile count — 16016 = 32032 / 2 exactly, which is not a coincidence I can dismiss;
  2. the tensor carries more than one byte per element (params interleaved per tile), so 6144 is not kpad_+params either — 32,032/32 = 1001 tiles of 32 rows is what a tile count should look like, and 16016 is not a multiple of it;
  3. one of the two numbers is from a different pack or a different layering than the one the guard reads.

This matters because the guard's acceptance test is lhd->shape[0] % per_block == 0 with per_block = kpad_ / Q4NX_TILE_COLS, so the meaning of shape[0] is exactly what decides whether a real pack is accepted or refused. If 16016 is half the rows, the test as written accepts or rejects on a quantity that is not the tile count — which is the same class of mistake the change is fixing. Worth one sentence, and worth checking against the container rather than the comment.

Independent of that, the rest of my #1992 review stands: the logits bound stays at NV (four logits.assign((size_t)NV, 0.0f) sites), the image rows are derived from tiles with an r >= NV guard, and the pad writes zeros for codes/scale/zero — so a consumer sizing logits from the image instead of the vocabulary would emit exact-zero logits that outrank negative ones. That invariant should be stated in pad_rows_to_tile_quantum.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Flagging from the sibling PR: #2020 is the same fix as this one — same file, same engine#1990 comment block, same four-condition guard; the only differences are cosmetic (a named blocks intermediate with an if (blocks > 0) guard, versus the inlined shape[0] / per_block here). One of the two should land and the other close; that is the queue's call. My review of #1992 stands on the arithmetic (32,016 % 32 = 16 → 32,032 rows = 1,001 tiles = {16016, 5120} with per_block = 16), and the one wording point applies to whichever survives: the comment says the padded rows "decode to zero" where what the code guarantees is that they are never sampled (logits stay NV long).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 0 decodes to exactly 0.0, while scale 0 would 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_size is 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 comparing shape[1] against kpad_ 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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 tests/npu_pad_head_rows_test.cpp and the CMake row for it) and contains pad_rows_to_tile_quantum (7 references), so the content here is a subset of there. Rather than leave two open PRs carrying one change — the duplicate-fix pattern this board has paid for repeatedly tonight — this one should be closed with the reason recorded.

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 padded_head_rows(shape0, shape1, kpad, NV) with arms that pin the CodeLlama numbers (16016, 6144, 4096, 32016 → 32032) — the ones measured here, where the reader refused with lm_head dequant failed until the arithmetic was right.

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 (NPU_I8_HEAD_TB 8192 and 16384 both max|d| = 0) that ruled the chunking out. Those are in the issue thread and the family README, so closing this does not lose them.

Result that stands, independent of which PR carries the code: all four h4096 families are bit-exact at forward level — h4096-b8, h4096-im12288, h4096-chatglm, h4096-mha — each max|d| = 0 across three repeats, run-to-run determinism and an edge case.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Closing as superseded by #2026, per the analysis above: same four files plus padded_head_rows() as a pure function and tests/npu_pad_head_rows_test.cpp driving it — the testability gap this PR could not close, since CI has no container. The measurements live on in the issue thread (#1740) and the family README, and all four h4096 families stay bit-exact at forward level.

bong-water-water-bong added a commit that referenced this pull request Nov 11, 2026
…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>
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/head-row-padding branch November 12, 2026 21:06
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.

2 participants