Repository navigation
npu-pack: a placeholder-only graph next to model.bin is refused by name, and the pack leg's failure no longer ends the run (refs #1671) - #1687
Conversation
…me, and the pack leg's failure no longer ends the run (refs #1671) The 0.5B-Instruct 1.9 pack's pack leg threw `model.layers.0.attn.qkv_proj.MatMulNBits.qweight.empty: no data`, and `FAIL exception:` then skipped the summary that says which legs ran. Two separate defects, both measured on `~/models/oga/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu`: **1. The message names a placeholder as though the bytes were absent.** The pack's graph has 592 initializers and only 53 carry bytes (the embedding, cos/sin caches, the norms): every projection weight is a `[0]`-dims zero-byte placeholder, and so is its `.qweight.packed` sibling. The bytes are in the pack's `model.bin` (293,429,248 B) -- the 1.8/hybrid genre this file already names at `:1434` -- and this importer has no `model.bin` reader, so it reads ONNX initializers only. The walk reached `bytes_of()`'s `ext.find("")` and reported "no data" about a tensor whose bytes exist, one file over. Now the import refuses before the walk, naming the file, its size and the count: 25 MatMulNBits weight(s) in this graph are `*.qweight.empty` placeholders and none of their `*.qweight.packed` siblings carries bytes; the pack carries model.bin (293429248 B), which is the 1.8/hybrid genre ... Narrow on purpose: it fires only when NO matmul resolved to bytes, so no pack that imports today can start failing because of it. Measured unchanged on the two other 1.9 packs on this box (SmolLM2-135M, Qwen2-1.5B): both still refuse with their own documented `.fconst` block-shape message. **2. The pack leg's own checks were lost to the abort.** The leg now has its own catch: an unreadable pack is a counted failed check (`FAIL pack leg: <reason>`) and the summary still prints, instead of `FAIL exception:` returning 1 before `N check(s) failed`. Same exit status, but the run now says which legs ran and why one failed. Tests, both directions, in `tests/npu_pack_import_test.cpp`: a crafted pack with a `[0]`-dims `qweight.empty` placeholder, no sibling and a `model.bin` next to it must be refused with a message that names model.bin, does not say "no data", and cites the issue; the CONTROL is the same graph with the placeholder's bytes present, which must not hit this guard (or it would mask a readable pack). Negative control run: dropping the guard makes the suite fail on "a placeholder-only graph next to model.bin was refused" and, worse, "nothing written" -- i.e. without it the pack leg *writes a container built from zero-length placeholders* rather than refusing. Composes with #1675 (part 1 of this issue, the `[0]`-dims guard in `packed_sibling`), which is not touched here.
…eg above `test_crafted_refusals` ends with `fs::remove_all(dir)`; mine left `tests/golden/crafted-modelbin/` behind, which is visible in `git status` after any run and is the kind of untracked debris the copyright gate now walks. Same line, same reason.
|
Head is |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 82838c31bb12f16e20753bd0c2f98e4e5b18b426.
Same shape, and the assertion that earns it is the negative one:
check(what.find("model.bin") != npos, …) # the refusal names the file that is actually there
check(what.find("no data") == npos, …) # and does not blame the wrong thing
so a placeholder-only graph beside model.bin is refused by name, and the message cannot drift into the generic "no data" wording that would send a reader looking for a missing file that is present.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 82838c31bb12f16e20753bd0c2f98e4e5b18b426
Built and run at this head:
modelbin genre refusal: 1 MatMulNBits weight(s) in this graph are `*.qweight.empty` placeholders and none of their
`*.qweight.packed` siblings carries bytes; the pack carries model.bin (8192 B), which is the 1.8/hybrid genre: the
4-bit weights live there and this importer has no model.bin reader, it reads ONNX initializers only.
all npu-pack checks passed
The bug is a wrong diagnosis, which is worse than a missing feature: without this, the walk reaches bytes_of()'s ext.find("") and reports <tensor>.qweight.empty: no data — telling the reader the bytes are missing when they are in a file the importer never opens. Naming model.bin, its size, and the genre turns a dead end into an instruction.
The gate is narrow in exactly the way a new refusal has to be, and the test asserts the part that keeps it honest:
- it fires only when
unresolved == onnx.matmuls.size(), i.e. every matmul is an.emptyplaceholder — one resolvable matmul anywhere and it does not fire, so no pack that imports today can start failing; unresolved > 0keeps an empty matmul list from satisfying0 == 0;model.binmust actually exist (!bin_ec), so the message never names a file that is not there.
The test pins the message, not just the throw, which is the difference between a refusal and a trap:
check(what.find("model.bin") != std::string::npos, "… names model.bin, not a placeholder tensor")
check(what.find("no data") == std::string::npos, …) <-- the old, misleading string is asserted ABSENT
check(what.find("engine#1671") != std::string::npos, …)
Asserting that "no data" is absent is the specific thing I would have added if it were missing: a regression that kept the new message and reintroduced the old one would otherwise pass.
Not verified: the real 1.8/R1-Distill packs (not on this box); the fixture is synthetic, which is the right trade for a message-and-condition test.
|
Cross-PR note, not a verdict on this one's code: four PRs are fixing engine#1671 in the same two files (
I have recommended #1688 as the one to land first, on one check rather than on preference: the fallback it routes into is real and deliberate That does not make the refusal wrong — a genuinely broken pack should be refused by name — but it should not be the PR that closes #1671 while |
|
VERDICT: READY. The measurement is what makes this convincing, and the guard is narrow by construction rather than by care — which is the The narrowness is structural, and I checked it independentlyif (unresolved > 0 && unresolved == onnx.matmuls.size() && !bin_ec)It can only fire when no MatMulNBits weight resolved to bytes. So the case I was looking for before writing this — a legitimate hybrid Two consequences worth keeping in the comment: the guard runs before the walk, so it cannot be reached with a half-parsed graph, and The measurement is the part I would put in the issue, not just the PR
The part to be explicit about in the bodyThis refusal is a correct abort, not a fix: Reviewed read-only: the diff and the guard's logic. I could not run the pack leg ( |
|
Merge-order note for the queue, since #1675 touches the same file. I checked, rather than assuming:
The part worth stating is that they compose rather than merely not-conflict: #1675 stops So: both can merge, #1675 first if the queue wants a reason — it is the smaller, more central change and mine is written against the world where placeholders are never weights. Unrelated, for the file's health: main's CI at |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
Review of the reworked walker: every other branch in the `kept` loop was enumerated, but this one `continue`s without a push -- so a matmul whose `*.empty` placeholder has NO `*.packed` sibling at all is now DROPPED, where before it stayed in `m.matmuls` naming its placeholder. The line's own comment still said "leave it to the reader below", which is what the push restores. It is not cosmetic: `onnx.matmuls.empty()` is the test at :1685 that means "this is a 1.9 fconst pack, take the head from the tail", `:1680` picks the head from the first resolvable matmul, and `:1586` reports the count. So a truncated or mis-packed vendor pack -- real graph matmuls, no `.packed` siblings -- would have silently taken a head from the `.fconst` tail instead of reaching the refusal #1687 asks for. Same failure class as the one this PR removes: a decision made by an absence rather than a measurement. The arm that was missing (`with_sibling=false` on the fixture writer): `.empty` written with no `.packed` at all, asserting `matmulnbits_ops == 1` and `weightless_matmuls == 0`. Verified both ways against a real build in ~/wt/verify-1690 -- with `if (!pw) continue;` restored it reports FAIL placeholder with no sibling: the matmul is KEPT, so the pack is not mistaken for a tail-head pack 1 check(s) failed and with the push back it is silent, `all npu-pack checks passed`. Refs #1690
Review of the reworked walker: every other branch in the `kept` loop was enumerated, but this one `continue`s without a push -- so a matmul whose `*.empty` placeholder has NO `*.packed` sibling at all is now DROPPED, where before it stayed in `m.matmuls` naming its placeholder. The line's own comment still said "leave it to the reader below", which is what the push restores. It is not cosmetic: `onnx.matmuls.empty()` is the test at :1685 that means "this is a 1.9 fconst pack, take the head from the tail", `:1680` picks the head from the first resolvable matmul, and `:1586` reports the count. So a truncated or mis-packed vendor pack -- real graph matmuls, no `.packed` siblings -- would have silently taken a head from the `.fconst` tail instead of reaching the refusal #1687 asks for. Same failure class as the one this PR removes: a decision made by an absence rather than a measurement. The arm that was missing (`with_sibling=false` on the fixture writer): `.empty` written with no `.packed` at all, asserting `matmulnbits_ops == 1` and `weightless_matmuls == 0`. Verified both ways against a real build in ~/wt/verify-1690 -- with `if (!pw) continue;` restored it reports FAIL placeholder with no sibling: the matmul is KEPT, so the pack is not mistaken for a tail-head pack 1 check(s) failed and with the push back it is silent, `all npu-pack checks passed`. Refs #1690
… carry no bytes (fixes #1671) (#1688) * npu-pack: drop a matmul whose placeholder AND its packed sibling carry no bytes (fixes #1671) `npu-pack import` is broken on main for every Ryzen AI 1.9 fconst pack: model.layers.0.attn.qkv_proj.MatMulNBits.qweight.empty: no data The 0.5B-Instruct 1.9 pack declares its whole graph as placeholders -- 363 `*.qweight.empty` AND their 363 `*.qweight.packed` siblings, every one dims [0] with no data -- because the weights are in the Token .fconst. #1643's external-packed reader resolves a node's `*.empty` to its `*.packed` sibling, and when that sibling ALSO has no bytes it leaves the node naming the `.empty` placeholder; the reader then calls bytes_of() on a byte-less tensor and throws. A matmul with no bytes anywhere in its graph has no weight there, so it is now DROPPED rather than left pointing at the placeholder. That is also what tells the rest of the importer the truth about this pack: with no readable graph head, `matmuls` is empty and the head is read from the .fconst tail, which is where it is. Verified on the pack that was throwing: `npu-pack import` lands 291 tensors / 67,088 q4_1 tiles (the known-good counts), 768/768 attention tiles survive decode->encode, and on the host GEMM it answers as before ("' Paris. The capital of the United States is Washington, D'"). Test, with its negative control: a crafted graph whose `.empty` and `.packed` are both placeholders must yield `matmulnbits_ops == 0` and `packed_weight_siblings == 0`. With the fix reverted the arm fails ("FAIL placeholder pair: the weightless matmul is dropped ..."), so it is sensitive to exactly this. Not fixed here, and it is the pack leg's ONLY remaining failure on this pack: its self-check now reports `corr 0.885220` for a tied pack whose text is correct -- so the import is right and the self-check's reference is not. That is the next defect in #1671 and I have not guessed at it. * npu-pack: pin the weightless-matmul drop with the arm the message promised Review finding, and it was right: the previous commit described a crafted arm ("matmulnbits_ops == 0, and with the fix reverted the arm fails") that was not in the tree -- only the no-fconst REFUSAL arm was. Reverting the drop locally left the suite green, so the path that actually fixes engine#1671 (a 1.9 pack whose graph is all placeholders) was pinned by nothing a device-free test could see. The arm now exists: the same crafted graph, with a Token .fconst beside it, read at graph level through the manifest. weightless_matmuls == 1 the matmul is counted as dropped matmulnbits_ops == 0 and is not in the list, so nothing downstream reads a byte-less tensor packed_weight_siblings == 0 `weightless_matmuls` is added to `graph_weights` in the manifest for that, and because `info` should say how many graph weights a pack does not keep in its graph. Negative control, run here: with the drop reverted (the node pushed back into the matmul list) the arm fails with "FAIL placeholder pair: and dropped from the matmul list, so nothing downstream reads a byte-less tensor"; restored, the suite passes. So the arm is sensitive to exactly the line it is about. * npu-pack: a placeholder with no packed sibling keeps its matmul Review of the reworked walker: every other branch in the `kept` loop was enumerated, but this one `continue`s without a push -- so a matmul whose `*.empty` placeholder has NO `*.packed` sibling at all is now DROPPED, where before it stayed in `m.matmuls` naming its placeholder. The line's own comment still said "leave it to the reader below", which is what the push restores. It is not cosmetic: `onnx.matmuls.empty()` is the test at :1685 that means "this is a 1.9 fconst pack, take the head from the tail", `:1680` picks the head from the first resolvable matmul, and `:1586` reports the count. So a truncated or mis-packed vendor pack -- real graph matmuls, no `.packed` siblings -- would have silently taken a head from the `.fconst` tail instead of reaching the refusal #1687 asks for. Same failure class as the one this PR removes: a decision made by an absence rather than a measurement. The arm that was missing (`with_sibling=false` on the fixture writer): `.empty` written with no `.packed` at all, asserting `matmulnbits_ops == 1` and `weightless_matmuls == 0`. Verified both ways against a real build in ~/wt/verify-1690 -- with `if (!pw) continue;` restored it reports FAIL placeholder with no sibling: the matmul is KEPT, so the pack is not mistaken for a tail-head pack 1 check(s) failed and with the push back it is silent, `all npu-pack checks passed`. Refs #1690 --------- Co-authored-by: bong-water-water-bong <bong@users.noreply.github.com> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…ven (bf16) (#1690) * npu-pack: drop a matmul whose placeholder AND its packed sibling carry no bytes (fixes #1671) `npu-pack import` is broken on main for every Ryzen AI 1.9 fconst pack: model.layers.0.attn.qkv_proj.MatMulNBits.qweight.empty: no data The 0.5B-Instruct 1.9 pack declares its whole graph as placeholders -- 363 `*.qweight.empty` AND their 363 `*.qweight.packed` siblings, every one dims [0] with no data -- because the weights are in the Token .fconst. #1643's external-packed reader resolves a node's `*.empty` to its `*.packed` sibling, and when that sibling ALSO has no bytes it leaves the node naming the `.empty` placeholder; the reader then calls bytes_of() on a byte-less tensor and throws. A matmul with no bytes anywhere in its graph has no weight there, so it is now DROPPED rather than left pointing at the placeholder. That is also what tells the rest of the importer the truth about this pack: with no readable graph head, `matmuls` is empty and the head is read from the .fconst tail, which is where it is. Verified on the pack that was throwing: `npu-pack import` lands 291 tensors / 67,088 q4_1 tiles (the known-good counts), 768/768 attention tiles survive decode->encode, and on the host GEMM it answers as before ("' Paris. The capital of the United States is Washington, D'"). Test, with its negative control: a crafted graph whose `.empty` and `.packed` are both placeholders must yield `matmulnbits_ops == 0` and `packed_weight_siblings == 0`. With the fix reverted the arm fails ("FAIL placeholder pair: the weightless matmul is dropped ..."), so it is sensitive to exactly this. Not fixed here, and it is the pack leg's ONLY remaining failure on this pack: its self-check now reports `corr 0.885220` for a tied pack whose text is correct -- so the import is right and the self-check's reference is not. That is the next defect in #1671 and I have not guessed at it. * npu-pack: pin the weightless-matmul drop with the arm the message promised Review finding, and it was right: the previous commit described a crafted arm ("matmulnbits_ops == 0, and with the fix reverted the arm fails") that was not in the tree -- only the no-fconst REFUSAL arm was. Reverting the drop locally left the suite green, so the path that actually fixes engine#1671 (a 1.9 pack whose graph is all placeholders) was pinned by nothing a device-free test could see. The arm now exists: the same crafted graph, with a Token .fconst beside it, read at graph level through the manifest. weightless_matmuls == 1 the matmul is counted as dropped matmulnbits_ops == 0 and is not in the list, so nothing downstream reads a byte-less tensor packed_weight_siblings == 0 `weightless_matmuls` is added to `graph_weights` in the manifest for that, and because `info` should say how many graph weights a pack does not keep in its graph. Negative control, run here: with the drop reverted (the node pushed back into the matmul list) the arm fails with "FAIL placeholder pair: and dropped from the matmul list, so nothing downstream reads a byte-less tensor"; restored, the suite passes. So the arm is sensitive to exactly the line it is about. * npu-pack: a placeholder with no packed sibling keeps its matmul Review of the reworked walker: every other branch in the `kept` loop was enumerated, but this one `continue`s without a push -- so a matmul whose `*.empty` placeholder has NO `*.packed` sibling at all is now DROPPED, where before it stayed in `m.matmuls` naming its placeholder. The line's own comment still said "leave it to the reader below", which is what the push restores. It is not cosmetic: `onnx.matmuls.empty()` is the test at :1685 that means "this is a 1.9 fconst pack, take the head from the tail", `:1680` picks the head from the first resolvable matmul, and `:1586` reports the count. So a truncated or mis-packed vendor pack -- real graph matmuls, no `.packed` siblings -- would have silently taken a head from the `.fconst` tail instead of reaching the refusal #1687 asks for. Same failure class as the one this PR removes: a decision made by an absence rather than a measurement. The arm that was missing (`with_sibling=false` on the fixture writer): `.empty` written with no `.packed` at all, asserting `matmulnbits_ops == 1` and `weightless_matmuls == 0`. Verified both ways against a real build in ~/wt/verify-1690 -- with `if (!pw) continue;` restored it reports FAIL placeholder with no sibling: the matmul is KEPT, so the pack is not mistaken for a tail-head pack 1 check(s) failed and with the push back it is silent, `all npu-pack checks passed`. Refs #1690 * npu-pack: the self-check reads the embedding buffer it is actually given (bf16) engine#1661 made `embed_bytes` point at `embed_bf16` -- the container's bf16 copy -- and left every consumer of it decoding F16. So from that merge on, the head's self-check compared the head against MIS-DECODED embedding values, on every pack: Qwen2.5-0.5B-Instruct_rai_1.9.0_npu corr 0.885220, rel_err 0.987007 (was 0.999999 / 0.000700) and the readout called a pack with tie_word_embeddings: true "untied" The container was always right -- I checked by comparing it tensor by tensor against the container my own branch builds: all 291 identical, so the weights never moved, only the number about them. Which is exactly why this is worth fixing rather than shrugging at: a diagnostic that is confidently wrong about a tied pack is how a real regression gets dismissed later. Fixed at every site that reads that buffer: `corr()` (now documented as BF16 rows), the two `sum_abs`/`sum_ref` loops and the row-shifted control, whose `embed->type == kF16` branches were dead once the buffer became bf16. The readout says "as imported, bf16" instead of "fp16", because it is the copy the container carries. self-check: the head reproduces the pack's own embedding (as imported, bf16) corr 0.999999 rel_err 0.000700 controls: row-shifted corr 0.0716 (must fail) And the test that was failing on this pack now passes end to end: `npu_pack_import_test tests/golden <0.5B 1.9 pack>` returns 0 with "all npu-pack checks passed (including the pack leg)" -- which is engine#1671's acceptance, the throw having been fixed in #1688 (this is stacked on it). Not touched: the two packs whose 1.9 rows live in #1633 still refuse here with "measured on ... only", which is that PR's table, not this one's. * npu-pack: pin the self-check's buffer width, which is the whole defect Review finding, and it was right: this commit fixed four readers and added no test, and the defect survived from #1661 precisely because nothing device-free looked at the number. The arm pins the statistic directly rather than through a pack, because the defect IS the buffer width: `corr_bf16()` is exported (a one-line wrapper over the self-check's own `corr()`), and the arm hands it the same matrix twice -- corr(bf16 buffer, what the importer holds) 1.000000 corr(f16 buffer, what it used to assume) 0.412393 -- and requires the first above 0.999 and the second below 0.99. Negative control, run here: with `corr()` decoding F16 again the arm fails with "FAIL selfcheck-bf16: the statistic reads the bf16 buffer the importer holds (corr 0.7238003327525429)". So it fails for exactly the reason #1661 went unnoticed. No pack, no device, no fixture directory: 64x64, values exact in both formats, so nothing here depends on rounding. End to end unchanged at this head: `npu_pack_import_test tests/golden` passes, and `npu-pack import Qwen2.5-0.5B-Instruct_rai_1.9.0_npu` gives 291 tensors with "corr 0.999999 rel_err 0.000700". --------- Co-authored-by: bong-water-water-bong <bong@users.noreply.github.com> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…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>
Part 2 of #1671 — the half #1675 (part 1, still draft) does not cover. I have not touched #1675, #1673 or anything else open on this issue; my scope was agreed on the issue (comment 6742846408).
What is actually wrong, measured on the pack
~/models/oga/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu, read withonnx.load(load_external_data=False):Ifsubgraph)model.embed_tokens.weight,cos_cache,sin_cache, the per-layer norms[0]dims, zero bytes — and so is its.qweight.packedsiblingmodel.onnx.datamodel.bin:1434: "1.8 and R1-Distill ship placeholders and put the weights in model.bin"So the issue's binary resolves to the second branch: the bytes are not absent, they are in a file this importer does not read (there is no
model.binreader — only the genre report ininfo). The throw wasbytes_of()reachingext.find("")and reporting…qweight.empty: no data, which names a placeholder as though the bytes were missing.What lands
SmolLM2-135M-Instruct,Qwen2-1.5B): both still refuse with their own documented.fconstblock-shape message, unchanged.FAIL exception: …returning 1 beforeN check(s) failed. Same exit status, but the run now says which legs ran and why one failed — the npu-pack: import the .fconst MLP section (gate|up fused, then down_proj) #842 shape, which is how pack-leg breakage stayed invisible behind a green line.Tests, both directions, and the negative control
test_modelbin_genre_refusalcrafts the pack (no 1.9 pack needed): a[0]-dimsqweight.emptyplaceholder with no sibling plus amodel.binnext to it → must be refused, namingmodel.bin, must not say "no data", must cite the issue, and must write nothing. The control is the same graph with the placeholder's bytes present: it must not hit this guard, or the guard would be masking a pack that can be read.Negative control run (guard removed, rebuilt, same command): the suite fails on "a placeholder-only graph next to model.bin was refused" and on "nothing written" — i.e. without the guard the pack leg writes a container built from zero-length placeholders. That is worse than the throw the issue reports, and it is the reason this is a guard rather than a better message at the throw site.
ctest npu_pack_import1/1 passed at this head;tools/copyright.py --checkrc 0.Scope, stated so it is not read as more
This does not add a
model.binreader. That is a format job with its own questions (where the layout and the name→offset map come from), so the issue'smodel.binpacks stay refused — by name now, which is the acceptance. It also composes with #1675 rather than replacing it: that PR stopspacked_siblingtreating a placeholder as a weight, this one stops the leg from reporting a placeholder as missing data and from ending the run.