Skip to content

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

Merged
bong-water-water-bong merged 3 commits into
mainfrom
1bit/pack-modelbin-genre-refusal
Nov 9, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
1bit/pack-modelbin-genre-refusal

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

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 with onnx.load(load_external_data=False):

initializers in the graph (incl. the If subgraph) 592
those carrying real bytes 53 — model.embed_tokens.weight, cos_cache, sin_cache, the per-layer norms
a projection weight [0] dims, zero bytes — and so is its .qweight.packed sibling
model.onnx.data 283.9 MB, of which the 53 tensors' spans cover 280.7 MB → the packed weights are not in the data file under another name
model.bin 293.4 MB — the 1.8/hybrid genre this file already names at :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.bin reader — only the genre report in info). The throw was bytes_of() reaching ext.find("") and reporting …qweight.empty: no data, which names a placeholder as though the bytes were missing.

What lands

  1. A named refusal before the walk, with the count, the file and its size:
    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: the 4-bit
    weights live there and this importer has no model.bin reader, it reads ONNX initializers only. Refusing rather
    than reading zero-length placeholders as weights (engine#1671)
    
    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 on the two other 1.9 packs here (SmolLM2-135M-Instruct, Qwen2-1.5B): both still refuse with their own documented .fconst block-shape message, unchanged.
  2. The pack leg gets its own catch, so one unreadable pack is a counted failed check and the summary still prints:
    FAIL pack leg: 25 MatMulNBits weight(s) … model.bin (293429248 B) …
    2 check(s) failed
    
    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 — 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_refusal crafts the pack (no 1.9 pack needed): a [0]-dims qweight.empty placeholder with no sibling plus a model.bin next to it → must be refused, naming model.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_import 1/1 passed at this head; tools/copyright.py --check rc 0.

Scope, stated so it is not read as more

This does not add a model.bin reader. That is a format job with its own questions (where the layout and the name→offset map come from), so the issue's model.bin packs stay refused — by name now, which is the acceptance. It also composes with #1675 rather than replacing it: that PR stops packed_sibling treating a placeholder as a weight, this one stops the leg from reporting a placeholder as missing data and from ending the run.

t added 2 commits November 9, 2026 10:58
…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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Head is 82838c31 — one follow-up commit on top of the reviewed diff: the crafted fixture now removes itself (fs::remove_all(dir)), like test_crafted_refusals already does. Mine had been leaving tests/golden/crafted-modelbin/ untracked after every run, which is exactly the debris the copyright gate walks since #1580, and the repo's own convention at the end of the crafted leg is to clean up. No behaviour change; ctest npu_pack_import still 1/1.

@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 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 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 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 .empty placeholder — one resolvable matmul anywhere and it does not fire, so no pack that imports today can start failing;
  • unresolved > 0 keeps an empty matmul list from satisfying 0 == 0;
  • model.bin must 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Cross-PR note, not a verdict on this one's code: four PRs are fixing engine#1671 in the same two files (npu/pack_import.cpp + tests/npu_pack_import_test.cpp), and none is stacked on another — I checked, only the draft #1675 carries the no_bytes predicate, so #1686/#1687/#1688 are three independent implementations.

PR strategy
#1688 drop a matmul whose placeholder and .packed sibling both carry no bytes, which reaches the .fconst tail fallback the importer already maintains for the 1.9 genre
#1686 refuse the pack by name when a .packed sibling is itself empty
#1687 refuse a placeholder-only graph next to model.bin by name
#1675 (draft) no_bytes predicate, treats dims [0] as empty

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
(pack_import.cpp:1633 — "The head is in the .fconst tail (the Ryzen AI 1.9 lines)" — and :2588 computes that tail with the 64 + 2*hidden envelope). So
#1688 removes the thing preventing an existing tested path from being reached, while a refusal makes the pack loud but still not worked.

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 npu-pack import
still fails on every 1.9 pack. So: please either reword to "Refs #1671" (leaving the issue to the fix that makes it work) or be ready to rebase if #1688 lands first.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY. The measurement is what makes this convincing, and the guard is narrow by construction rather than by care — which is the
distinction that decides whether a new refusal is safe.

The narrowness is structural, and I checked it independently

if (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
pack of the #1661 shape (embedding.onnx / model.bin beside a graph whose projections are placeholders but whose embeddings are real) —
cannot be refused: one resolved weight makes unresolved != matmuls.size(). Your comment says the same thing, and it is the same claim I
would have demanded in review, so the only thing I can add is that it is true of the code as written rather than only of the intent.

Two consequences worth keeping in the comment: the guard runs before the walk, so it cannot be reached with a half-parsed graph, and
!bin_ec means the message can name the file and its size instead of guessing.

The measurement is the part I would put in the issue, not just the PR

592 initializers of which 53 carry bytes, the projections at [0] dims with a [0]-dims .qweight.packed sibling, and 280.7 of
283.9 MB of model.onnx.data covered by those 53 tensors' spans — that last figure is the one that rules out "the weights are in the data
file under another name", and it is the difference between a diagnostic and a guess. Turning it into a refusal that prints model.bin's size
means the next person to hit this genre gets the same conclusion in one line.

The part to be explicit about in the body

This refusal is a correct abort, not a fix: _rai_1.9.0_npu will still not import until an importer reads model.bin. That is declared
("the half #1675 does not cover", scope agreed on the issue), and it is the right slice — an error that says where the bytes are is worth
more than a stack trace that says they are missing — but #1671 should stay open for the reader, and the PR body could say so in the sentence
that currently says "part 2 of #1671". Otherwise the queue reads "part 1 + part 2 merged" as "issue done".

Reviewed read-only: the diff and the guard's logic. I could not run the pack leg (_rai_1.9.0_npu is not on this box), and the test's +76
lines are in the tree I read but not executed.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Merge-order note for the queue, since #1675 touches the same file. I checked, rather than assuming:

gh pr diff hunk headers: #1675 sits at @@146 (OnnxModel), @@247 (packed_sibling), @@1622 (plan) and @@2425 (tail of import_pack); mine at @@2127 (top of import_pack, right after parse_onnx) plus the test file. No overlapping hunk, so either order applies cleanly.

The part worth stating is that they compose rather than merely not-conflict: #1675 stops packed_sibling treating a [0]-dims placeholder as a weight, so for the 0.5B-Instruct pack every matmul stays unresolved — which is exactly the unresolved == onnx.matmuls.size() condition my guard needs. Merging #1675 first does not move my count: 25 today, 25 after, because on this pack no sibling resolves in either version. And a pack where a sibling does resolve keeps a non-.empty weight name, so my guard counts it as resolved and does not fire — the narrowing holds in both directions.

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 12618e82 was still running at 15:13 (one run cancelled, no conclusion yet), so I am not claiming main is green — just that nothing in my diff depends on it.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1671 - Partially compliant

Compliant requirements:

  • The pack leg no longer aborts the rest of the suite, and the refusal message now names the file (model.bin) instead of throwing no data
  • The test test_modelbin_genre_refusal verifies that the refusal correctly identifies the model.bin file and does not use the no data phrasing
  • The crafted test ensures that a pack with placeholder tensors and model.bin is refused appropriately, while a pack with the actual tensor data is not refused by this guard

Non-compliant requirements:

  • The PR does not explicitly verify that the two other packs mentioned in the issue still keep their documented refusals unchanged (though the change is designed to be narrow and not affect other packs)

Requires further human verification:

  • The behavior of the two other packs mentioned in the issue (SmolLM2-135M-Instruct_rai_1.9.0_npu and Qwen2-1.5B_rai_1.9.0_npu) should be manually verified to ensure they still refuse with their documented messages
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Incorrect Error Message

The code previously threw an error message "no data" when encountering a *.qweight.empty placeholder without a corresponding packed sibling, which incorrectly implied that the data was missing rather than located elsewhere. This has been corrected to provide a more accurate message that names the file (model.bin) where the data is located.

throw std::runtime_error(
    std::to_string(unresolved) + " MatMulNBits weight(s) in this graph are `*.qweight.empty` placeholders and "
    "none of their `*.qweight.packed` siblings carries bytes; the pack carries model.bin (" +
    std::to_string(static_cast<unsigned long long>(bin_size)) + " 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. "
    "Refusing rather than reading zero-length placeholders as weights (engine#1671)");
Pack Leg Failure Handling

The pack leg previously caused the entire test suite to abort when encountering an unreadable tensor, skipping all subsequent tests. This has been fixed by wrapping the pack test in its own try-catch block, ensuring that a failure in one pack does not prevent other tests from running.

if (pack_ran) {
    try {
        test_pack(pack, golden_dir);
    } catch (const std::exception& e) {
        check(false, std::string("pack leg: ") + e.what());
    }
} else {
    std::printf("skip pack: no pack directory given (the crafted and golden legs only)\n");
}

bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
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
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
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
bong-water-water-bong added a commit that referenced this pull request Nov 9, 2026
… 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>
bong-water-water-bong added a commit that referenced this pull request Nov 9, 2026
…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>
bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant