Repository navigation
npu-pack: read the 1.9 block-32 head from the .fconst tail (Fixes #1740) - #1948
Conversation
A pack that declares /lm_head/MatMul_Q4 at block_size 32 (the Ryzen AI 1.9 Qwen-1.5B packs) keeps the head after the tail envelope in 64-row units of 2,560 + 2*(K/32)*640 bytes, not in the block-128 tile. read_block32_head() reads that layout; import_pack() takes it when the layout checks hold and, on a tied pack, the result correlates above 0.99 with model.embed_tokens. Otherwise a tied pack keeps the synthesis (and the note says why), and an untied one is refused with the reason. Measured on Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu: 2,374 units of 64,000 B, corr 0.996185 against the embedding over all 151,936 rows, row-shifted control 0.0554. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at 5897213 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 5897213
This closes the loop on #1740 properly: the diagnosis was that the three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at block_size 128 and /lm_head/MatMul_Q4 at block_size 32, so the block-128 tile arithmetic "carves exactly and decodes to nothing" — it never lands on the tail, and main therefore did not read the head at all. Reading the head at block 32 from the .fconst tail is the fix the geometry asks for, and Fixes #1740 is honest because the issue's own framing (a carve that is exact but decodes to nothing) is exactly what a block-size mismatch produces.
Two asks, both about the arithmetic being anchored rather than fitted:
- How is the tail's start located — from the end of file (
len - ntiles*tile), or from the per-layer payload (layers*stride + attn_payload_off(H))?tools/q4nx_tail_head.pyrecords that these two agree only when the file's end is the head and nothing else trails it, and that a file whose end is something else "shows up as a correlation that is not 1 rather than as a silently wrong offset". Whichever this uses, the other should be a check, not an assumption — that is the cheapest way to keep the block-32 path from repeating #1740's failure mode with a different block size. - What the block-32 decode is validated against. The tool's docstring says the checkpoint's own
lm_head.weightrows 0–255 correlate +0.994–0.995 against tiles 0–3 when decoded at the right block size — that is the reference this change should be held to, and it is available without a device. If the PR's evidence is "the head now imports", the number that matters is missing: an import that produces a plausible-but-wrong head is precisely the failure #1740 documented.
Also worth keeping: the claim is scoped to these three packs' declaration, not to "1.9 heads", so a pack with a block-128 head cannot be read through this path by accident.
|
VERDICT: READY at 5897213 — I built The round trip is the real check: I encoded a 64×64 block-32 head per the documented strip layout ( The refusal set is the part that matters for this path, and it is the right set: a wrong region read with confidence is the failure the header says it exists to avoid, so each of the four structural claims is enforced — the unit's One process note, because it is the third time today and it is on my side: my first run failed "every decoded weight equals the fixture's" — my fixture had generated a code of Why this belongs in |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 5897213: READY
The layout is stated as a measured fact with the arithmetic that makes it checkable: "2,560 zero bytes, then 2 x (K/32) strips of 640 B. Strip s holds K block s/2 for rows 32 x (s%2) .. +31". That is the difference between a parser and a guess — anyone can recompute the strip offsets from that sentence and compare against a real tail.
The setup matters too: the packs quantise every layer at block 128 and /lm_head/MatMul_Q4 at block 32, and the tail after the envelope is 64-row units. Reading the head as if it were layer-shaped is what produced the failure this fixes.
Fixes #1740. I read the diff and CI (green); I did not run it — no build here at the current disk state.
|
VERDICT: READY at On the real pack ( And the numbers tell the right story rather than a flattering one. On The measurement chain is the strongest part of this PR and it deserves to stay in the description. Four independent steps, each falsifiable on its own: the scale distribution matching "About half the scales are negative" is the sentence I would highlight, because it explains the whole history of this item: a decode that drops the sign of a bf16 scale averages to zero correlation, which is exactly what earlier attempts saw and read as "values near ±1". A wrong reading that produces a plausible number is the failure mode this table exists to break, and one sentence about the sign would have saved the thread a pass. On the padding: What this unblocks, concretely: W1a's gate 3 (the 1.5B trio). I have one of the three packs on disk and it now imports with 339 tensors / 188,424 tiles and a verified head; the other two were deleted under the disk floor and are a re-download away. The 1.5B class also stops depending on |
|
VERDICT: READY at 5897213 This is the most thoroughly measured format discovery I have reviewed: each field of the block-32 head layout was pinned against an What makes the evidence trustworthy rather than plausible:
And the finding that explains the whole thread: about half the scales are negative — a decode that drops the sign averages to zero The arms carry the two controls that matter: the imported head correlates with the embedding ( Head under review: 5897213 |
VERDICT at
|
VERDICT at
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 3865be4: READY
Re-checked after the merge of main (#1938, #1951, #1950 and others touch npu/pack_import.cpp). The PR's own diff is identical to 5897213, which I passed, and neither changed file has conflict markers.
At 3865be4:
npu_pack_import_test tests/golden: all npu-pack checks pass, including the block-32 head arms (tied verified, untied layout-checked, and the fall-back to the synthesis note).- Pack leg on
Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu(host only, under thermal-run):lm head: block 32, 2374 unit(s) of 64000 B at 733567296 B, 110208000 B of zeros after them (tied: verified …), self-check corr 0.996185, row-shifted control 0.0554 (fails, as it must), container read-back exact, attention 1568/1568 and MLP 4592/4592 byte-for-byte, andq4nx_head_notesays the head was read. Those match the description's numbers after #1951's per-layer table changes.
|
ready for review at 3865be4
Canonical order, since it was asked on #1955: this PR is the block-32 head read (Fixes #1740) and lands first. #1955 is stacked on this branch and contains this commit by construction; it adds the all-block-32 layer walk for #1503. Once this merges, #1955's diff against |
|
VERDICT: READY at The shape that makes this a real bug and not an unsupported format
One pack with two block sizes, and the mismatch is on the tail. That is why And the guard is by arithmetic, not by a magic name: if (V <= 0 || K <= 0 || K % 32 != 0) { h.why = "a block-32 head needs K to be a multiple of 32 (K …)"; }
The test has both controls, which is what a correlation arm needsThree assertions where one is the answer ( Scope One ask: the body says "three Ryzen AI 1.9 Qwen-1.5B packs" — if two of the three were only inspected rather than run through the test, say which one the fixture came from, so a future failure names a pack rather than "the three". |
|
VERDICT: READY at Three things are right:
The askThe body should say what happens to a pack that declares block 32 for |
|
VERDICT: READY at The folding is an identity, not an approximationconst int zp = (sp[512 + r/2] >> (4*(r & 1))) & 15;
const bool flip = sc < 0;
const float mag = flip ? -sc : sc;
q.zero[g] = -float(flip ? 15 - zp : zp) * mag;
dst[c] = uint8_t(flip ? 15 - code : code);With Refusals are part of the read, and the honest case is named
The measurement is the kind that can be re-derived
Note for the queue: #1955 is stacked on this branch ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 3865be4
The measurement is the argument, and it is done the way this family has learned to do it: each step against the pack's own model.embed_tokens, host only, no device — "the 3,072 bf16 values of a unit have the same distribution as max|w|/8 over the 32-column blocks of the same 64 embedding rows (10/50/90 % quantiles 0.00567627 / 0.00750732 / 0.0098877 on both sides)". A distribution match across three quantiles is what identifies an offset space that no declared arithmetic lands on, and it is a much stronger instrument than "the numbers look plausible".
And the self-check states its own limit, which is the part I would keep: "the head reproduces the pack's own embedding (as imported, bf16) corr 0.996901 rel_err 0.000000 … That correlation is the synthesis measured against its own source. The pack's head bytes are never read." That is the exact distinction this family keeps getting wrong in the optimistic direction — a correlation that proves the derivation did what it claims, and says plainly that the pack's own head tiles remain unverified. It is the same discipline as #1846's "what the hash does NOT prove".
Context worth recording: this is the third pack family to hit the block-32 head (#1643's census found the split, #1878/#1928 handled the tied case, this one identifies the offset space). The thread ended at "the format is declared, the offset space is unidentified" — and that is now identified by measurement rather than assumed, which is what makes the answer portable to the other two Qwen-1.5B packs of the set.
No asks.
#1948 landed on main as a squash, so its text conflicted with this branch's rewrite of the same code (read_block32_head as a wrapper over read_block32_units, the take_block32_head lambda, the docs section and the test list). Every main-side hunk was #1948's text verbatim; each is resolved to this branch's side. main's other changes (#1995, #1921) merge untouched. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…/36 row is pinned in the test Two things found by running the tests and real packs on main + this branch. 1. main's row-count arm (#1861) pins kFconst19Rules as literals; the Qwen3-8B row was not in it, so the merged tree failed 11 != 12. Added. 2. The SSMLP/GQO gate/up/down attributes were pushed into OnnxModel::matmuls. matmuls.empty() is how the importer knows a 1.9 pack keeps its head in the .fconst tail, and every 1.9 pack has a fused MLP, so the block-32 tail head (#1948) was no longer read: Qwen-2.5 1.5B 1.9 imported 338 tensors instead of 339, with no lm_head, exit 0 and no refusal. The attributes now go to OnnxModel::fused_mlp, which only intermediate_size_from_graph() reads. New arm 2b builds the tied block-32 fixture with an SSMLP node; with the old push it fails three assertions. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ne#1503, stacked on #1948) (#1955) * npu-pack: read the 1.9 block-32 head from the .fconst tail (Fixes #1740) A pack that declares /lm_head/MatMul_Q4 at block_size 32 (the Ryzen AI 1.9 Qwen-1.5B packs) keeps the head after the tail envelope in 64-row units of 2,560 + 2*(K/32)*640 bytes, not in the block-128 tile. read_block32_head() reads that layout; import_pack() takes it when the layout checks hold and, on a tied pack, the result correlates above 0.99 with model.embed_tokens. Otherwise a tied pack keeps the synthesis (and the note says why), and an untied one is refused with the reason. Measured on Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu: 2,374 units of 64,000 B, corr 0.996185 against the embedding over all 151,936 rows, row-shifted control 0.0554. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * npu-pack: import a 1.9 pack whose every MatMulNBits is block 32 (engine#1503) An all-block-32 pack (SmolLM2-135M-Instruct 1.9) was refused at the block gate. Its raw Token .fconst is now read with its own layout: 64-row units of 2,560 + 2*(K/32)*640 B for q/k/v/o/down and the head, and 64-row units of 4,864 + 2*(K/32)*1,216 B holding gate and up together. Offsets come from the geometry and are checked at every epsilon envelope. read_block32_head becomes a wrapper over read_block32_units; the head logic #1948 added is shared by the mixed pack and this path. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * npu-pack: one arm per declared block size; unmeasured sizes are refused by number (#1503) Review on #1955 asked what a pack declaring a block nobody handles does, and for an arm per declaration rather than one for the class. The all-block-32 fixture is now rebuilt with only cache/ops_meta.json changed: every op at 3 / 64 / 256 refused, 'block size along K is N (11 ops)' layers 32, head 128 refused, not the all-block-32 class layers 32, head 64 refused every op at 128 stays off the all-block-32 path and the gate With the selection made to ignore the declared value, all six arms fail (each writes a container through the all-block-32 path). The gate's refusal now says the all-block-32 layout exists and when it applies. 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: Claude Fable 5.2 <noreply@anthropic.com>
…s / 36 layers), head read from the decode If branch (#2009) * npu: measure the 1.9 Token fconst rule for hidden 4096 / 32 heads / 36 layers (Qwen3-8B) (refs #1740) Measured with tools/fconst19_measure.py against amd/Qwen3-8B_rai_1.8.0_npu_16K: 26,104 of 3,756,627,264 bytes differ (6.95e-04 %), content rather than layout. The header stride 104,350,656 is uniform over all 36 blocks, checked independently of the tool (36 runs of 32 x 00 00 7f ff, first at 13,796,032). The row is identical to the Mistral h4096 row in every field except the layer count, which is the finding: the bundle shape and rotation are a property of the hidden size, not of the depth. It needs its own entry anyway, because select_rule_in() keys on (hidden, heads, layers). Stated in the comment because it is not all seven fields: --probe-wrong separates rot+1 and tile/2 by 14,700x (10.2 % of the file each), but prefix+64 differs in only 32,960 B -- 1.26x the accepted residual and under the 37,566-byte tolerance -- so this pair does not isolate the prefix. The 12,608 rests on the Mistral row and the Qwen3 arithmetic agreeing, as the 3,584 row's prefix does. * npu-pack: read lm_head from the If branch that is the decode path, so the 4096-hidden untied packs (Qwen3-8B, granite-4.1-8b) get their head from the graph (refs #1740) An OGA 1.9 pack splits prefill from decode with `If (Equal(Shape(input_ids), Constant(value_int 1)))`, and the two branches do NOT carry the same tensors. Measured on amd/Qwen3-8B_rai_1.9.0_npu: then_branch holds exactly ONE MatMulNBits and it is lm_head [151936, 32, 64] at offset 15,269,888 in model.onnx.data (311,164,928 B = V*H/2); else_branch holds the 109 layer projections plus an `lm_head.MatMulNBits.qweight.empty` placeholder with no bytes. The walker merged both branches, so the placeholders it saw for lm_head counted as weightless_matmuls and plan() took the "no readable graph head, read the tail" path. On these packs the tail is the envelope alone -- Qwen3-8B's is 8,256 B = 64 + 2*4096, verified by hex (bf16 1e-6 at kNormOff 64, then the norm weights) -- so `want = env + rows*tile` could never equal `have`, and the import refused with "lm_head.weight: the .fconst tail is 8256 B, not the ... B its envelope and head tiles would take, so the head's offsets are not established". The Token .fconst IS the decode path, so the decode branch's node is the one that describes it: - walk_graph now records each matmul's `If` branch (the attribute's own name); - parse_onnx reads the condition rather than assuming: a Constant value_int of 1 makes then_branch decode, anything else else_branch, and a graph without that shape falls back to then_branch with a note saying the rule was assumed; - import_pack picks the head node from the decode branch, keeping the old first-match behaviour as a fallback when only the other branch declares it; - the #1687 refusal reads weightless_matmuls_decode (outside the decode branch), so a prefill placeholder is no longer mistaken for a genuinely absent weight. The total stays for `info`'s census, and both are reported in graph_weights. Pinned by test_external_packed_genre: a crafted graph whose condition compares the shape with 1 must name then_branch decode, and an inverted one must move it to else_branch. The regression arm for the head pick (decode branch taken, inverted convention falls back, flat graph unaffected) was checked standalone. Not run: the pack itself -- this tree's submodules are not checked out, so libonebit_npu cannot be built here; the changed regions pass -fsyntax-only and the test compiles. * npu-pack: decide the decode If branch by which branch holds the weights, not only by the condition's constant The condition `Equal(Shape(input_ids), Constant(1))` is the pack's statement of which branch is one-token; the arithmetic is what must hold. A pack that inverts the condition without moving its weights answered the wrong branch, and the head vanished. Now the branch that actually declares MatMulNBits nodes wins when only one of them does, and the condition decides only when the graph is ambiguous (weights in both branches). branch_note names which rule was used and, when they disagree, how many matmuls each branch has. Also fixes the crafted test: it wrote model.onnx while this fixture's genai_config.json names eager-mm.onnx, so the arm was measuring the fixture's own graph rather than the crafted one -- and the graph needed its embedding tensor to be plannable at all. * 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. * npu-pack: read the fused SSMLP/GQO FFN attributes, and the 4096/32/40 shape rule (refs #1740) granite-4.1-8b states its FFN nowhere the reader looked: config.json is literally {}, genai_config.json's decoder block has only hidden_size / heads / layers, and its FFN is not MatMulNBits nodes at all -- it is one `SSMLP` op with gate_K 4096, gate_N 12800, up_K 4096, up_N 12800, down_K 12800, down_N 4096. So the import refused with "the pack states no intermediate_size", which is correct for what it could see and wrong about the pack. Recording those attributes as matmuls lets intermediate_size_from_graph find w by the same witness it already uses for the separate-node spelling (gate and up reading one activation) instead of adding a second rule that could disagree with the first. With the width resolved the pack reaches the shape gate, which had no row for hidden 4,096 / 32 heads / 40 layers. Its 1.9 header is clean -- 40 runs of 32 x `00 00 7f ff`, first at 13,795,392, every gap exactly 107,728,192 -- and its tail is 225,198,144 B, which is exactly 8,256 + 1,568 x 143,616: the h4096 head grid (rows = ceil(100,352 / 64)). So unlike Qwen3-8B the head IS in the tail here, and the bundle/rotation agree with the Qwen3-8B row at the same hidden size and head count. amd publishes no 1.8 twin for this model, so that agreement plus the head-grid arithmetic is the whole evidence for the rotation -- the row says so, and says the prefix inherits the Qwen3 row's own caveat. * 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: withdraw the 4096/32/40 row -- a prefix cannot be inferred, only measured against a twin I added this row on granite-4.1-8b because its 1.9 header is clean (40 uniform runs at stride 107,728,192) and its tail is exactly 8,256 + 1,568 x 143,616, which pins the TILE by head-grid arithmetic. That was the mistake: the tile is not the whole rule. `prefix` is defined by the twin diff -- tools/fconst19_measure.py:104 walks `prefix += 1` while the 1.9 block still equals the 1.8 one, so prefix is the first byte where they diverge, and the tile is found by where that first-differing run reappears. Neither number is named anywhere in the 1.9 pack alone. The row's own in-block checks rejected it, which is the argument for removing it rather than for keeping it with a caveat: scale/zero exactness 0 %, second header 0, and 0 of 80 RMSNorm anchors found. A row that fails the pack it was written for is not a measured row. Confirmed against the twin I already had: Qwen3-8B's prefix 12,608 is exactly the first byte where its 1.9 block diverges from its 1.8 block (1.9 `8782 0777...` vs 1.8 `6756 ea78...`, 0 differing bytes in [0, 12608)). amd publishes no 1.8 granite, so granite stays refused by the shape gate -- the honest answer for a layout nothing can establish. The table now says that, so the next person does not re-derive the row from the tail arithmetic the way I did. Qwen3-8B is unaffected: still "all npu-pack checks passed (including the pack leg)". * 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: fused-MLP attributes are not graph matmuls, and the 4096/32/36 row is pinned in the test Two things found by running the tests and real packs on main + this branch. 1. main's row-count arm (#1861) pins kFconst19Rules as literals; the Qwen3-8B row was not in it, so the merged tree failed 11 != 12. Added. 2. The SSMLP/GQO gate/up/down attributes were pushed into OnnxModel::matmuls. matmuls.empty() is how the importer knows a 1.9 pack keeps its head in the .fconst tail, and every 1.9 pack has a fused MLP, so the block-32 tail head (#1948) was no longer read: Qwen-2.5 1.5B 1.9 imported 338 tensors instead of 339, with no lm_head, exit 0 and no refusal. The attributes now go to OnnxModel::fused_mlp, which only intermediate_size_from_graph() reads. New arm 2b builds the tied block-32 fixture with an SSMLP node; with the old push it fails three assertions. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * npu-pack test: arm 2b removes its second fixture directory It was left under the golden directory after a run. 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: Claude Fable 5.2 <noreply@anthropic.com>
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
Fixes #1740.
What was open
The three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at
block_size 128and/lm_head/MatMul_Q4atblock_size 32. The block-128 tile arithmetic never lands on their tail (#1740: "carves exactly, decodes to nothing"), somaindoes not read the head at all: a tied pack gets a head synthesised from the embedding (#1633) and an untied one is refused. The thread ended at "the format is declared, the offset space is unidentified".Reproduction on
main's pathQwen-2.5_1.5B_Instruct_rai_1.9.0_npu(tied, V 151,936, H 1,536), with this branch's reader switched off (if (false && p.head_block == 32), which ismain's behaviour on this path):That correlation is the synthesis measured against its own source. The pack's head bytes are never read.
The layout, and how it was measured
After the tail's
64 + 2*hiddenenvelope the head is stored in 64-row units of2,560 + 2*(K/32)*640bytes (64,000 B at K 1,536; 2,374 units; then 110,208,000 B of zeros to the end of the file):s(640 B,2*(K/32)per unit)s/2, rows32*(s%2) .. +31of the unitpis row(p/256)*8 + p%8, column(p/8)%32w = (code - zero_point) * scale.Each step was measured against the pack's own
model.embed_tokens(host only, no device):max|w|/8over the 32-column blocks of the same 64 embedding rows (10/50/90 % quantiles 0.00567627 / 0.00750732 / 0.0098877 on both sides).scale == bf16(-m/8),mbeing the block's largest-magnitude weight with its sign, matches for 100 % of slots in units 5, 100 and 2000 (99.97 % in unit 6); the slot orderrow = i + 32*(s%2), block = s/2then holds for 100 %.About half the scales are negative. A decode that drops the sign averages to zero correlation, which is consistent with the earlier attempts on the thread that read this field as "values near ±1".
A second pack at a different K agrees.
SmolLM2-135M-Instruct_rai_1.9.0_npu(tied, V 49,152, K 576, every op at block 32): the same rule gives a unit of2,560 + 2*18*640 = 25,600B, its tail after the envelope is exactly768 x 25,600B, and the same decode (host script,decode32.py) over 100 units spread over the vocabulary (6,400 rows) correlates minimum 0.999998, mean 0.999999 with that pack'smodel.embed_tokens. The importer itself still refuses that pack earlier, at the block gate, because its layers are block 32 too; that is #1503's remaining part and is not changed here.The change
read_block32_head()(npu/pack_import.{h,cpp}) reads that layout into aQ4at block 32 with positive scales (a negative scale is folded into its codes:15 - code,15 - zp). It refuses, with the offset, when the units do not fit, a unit header or a strip's gap is not zero, a scale is not finite, or anything after the last unit is non-zero.import_pack()uses it when the pack declares a block-32 head:q4nx_head_notesays why the read was not taken.q4nx_head_notesays the values are not verified. If the checks fail, the refusal is the existing one with the reason appended.docs/npu.md: the layout and the rule above, next to the tail-envelope section.Result on the real pack
Named reference: the pack's own
model.embed_tokens.weight(the pack declarestie_word_embeddings: true). Correlation 0.996185 over all 151,936 rows; the same rows shifted by one score 0.0554.Tests
test_block32_tail_headintests/npu_pack_import_test.cpp, no pack and no device. The fixture head is written by a test-side encoder from the layout description, not through the reader.Mutants (each rebuilt, same command):
main's path)The mutant runs were made before one arm-3 threshold was corrected (its synthesis check asked for corr > 0.999 and the 4-bit synthesis scores below that; it is 0.99 now). That check failed in every run including the unmutated one, and is not counted above.
Not covered
rtakes nibbler, low first) cannot be told from the Qwen pack, where every zero point is 8. It was checked afterwards on SmolLM2'sdown_projandq_projunits, whose zero points vary, against the public checkpoint: every row votes for its own index (engine#1503, comment of 2026-11-10). No arm in this PR depends on a pack for it; the known-answer arm pins the order.main(d9d80c2, a llama.cpp bump that touches none of these files).🤖 Generated with Claude Code