Repository navigation
npu-pack: see the 1.9 external-data genre -- MatMulNBitsBf, the *.qweight.packed sibling, and the stride as a measured 201/201 (refs #1503) - #1643
Conversation
…r's input kind, and a companion graph refused by name
engine#799's owner entry recorded that a Qwen3-VL hybrid (vision on HRX, decoder on the NPU) is blocked on
schema, not code: the plan describes one decoder stream over token ids and has no field for the position
encoding, for precomputed embeddings, or for a second graph. This decides all three.
`rope` {kind, planes, sections}: plain (absent = every plan written before) is one index; mrope is a 3xN
position block. sections is the pack's own split of the rotary width (Qwen3-VL: [24,20,20]) and is REQUIRED
for mrope -- without it a caller has no declared width per plane. planes defaults to the number of sections
and must agree with it, and a plain rope carrying sections is refused rather than read as if the planes
existed. The fallback this prevents is not cosmetic: text after a 448x448 image resumes at position 19 where
a token-count advance gives 201, and both predict fluently.
`inputs` {kind, vision:{merge, feature_dim}}: tokens (absent = every plan before) or embeds -- rows x hidden
already merged with vision features, the split AMD's reference makes with embedding.onnx -> text.onnx. A
vision block on a tokens plan is refused: ignored vision features serve the text half of a vision model and
answer confidently about an image it never saw.
`companion` is refused by name with the reason: one plan is one decoder stream, and a second graph is its
own model directory the serving layer merges into inputs.embeds -- a companion list would put two graphs and
their scheduling in a file whose only consumer can neither open nor order them.
tools/npu_plan.py writes rope from the pack's own config (top level, else text_config -- where a VL pack keeps
the text tower), so a plan reads as what its positions are.
Not a format bump: absent means the old meaning for both sections, the rule attention.mode, kv and designs
already follow. No forward change and no device: the consumer side (building the planes, feeding embeds) is
the hybrid lane's, and this is the description it was missing.
tests/npu_plan_test.cpp: 11 cases -- absent defaults, mrope with and without explicit planes, the generator's
own plain shape, embeds with an external merge, and five refusals naming rope.sections / rope.planes /
inputs.vision / feature_dim / companion.
…ight.packed sibling, and the stride as a number
Measured on amd/phi-4_rai_1.9.0_npu's metadata (engine#1503): every node's weight slot holds
`...qweight.empty` with dims [0], and the bytes are in a `...qweight.packed` initializer the node never
references, inside eager-mm.onnx.data (9.73 GB). Three separate reasons made this genre look weightless:
1. the walker matched only `com.microsoft:MatMulNBits`, and phi-4's 201 weights are all
`com.ryzenai:MatMulNBitsBf` -- so matmulnbits_ops was 0;
2. the node names its tensor `/model/layers.0/attn/...` while the initializer is
`model.layers.0.attn...` -- so the exact-name lookup found nothing;
3. a placeholder states its length as zero (dims [0]), so the `dims.empty()` test called it real.
With all three fixed, `info` reports 201 matmuls, 201 resolved siblings, and the layout as a measurement
rather than a claim: external_row_stride {matched 201, of 201, rule length == N*(K/128+1)*68} -- the
68-byte row-per-128-K block is the same stride the .fconst attention tile carries (2,176 B = 32 rows x
68 B), so the eventual reader walks a layout this importer already decodes instead of a new one.
intermediate_size for this genre comes from the node's own N/K (gate/up -> N, down -> K), because a 1-D
byte blob has no shape; that is reading the pack's declaration, not guessing from other dimensions.
No import behaviour change: this is the census `info` reports (and the new test), so the genre is
visible and named before anyone writes the reader for it.
tests/npu_pack_import_test.cpp: a synthetic pack in this genre -- placeholder + packed sibling + a
stride that fits (1 of 1) and one that does not (0 of 1, rule named) -- plus a pack whose config states
no intermediate_size, proving the node-derived FFN width.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
VERDICT: CHANGES — on the claim, not on the code. The three findings are excellent and each is evidenced against the real The
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at f9c6f98cb465957a93c2b4e7e6257031952bf88d
The census findings are good. They explain why the 1.9 external-data genre read as weightless: the MatMulNBitsBf op name, the /→. spelling, and the dims [0] placeholder with a .packed sibling. Reporting the stride as "201 of 201 match" rather than assuming it is the right instinct. At this head, npu_plan_test gives npu_plan: ok, and npu_pack_import_test tests/golden gives "all npu-pack checks passed" (built in my engine tree under thermal-run).
But "No import behaviour change" doesn't hold. The resolution is done in the shared parse_onnx(), and import_pack() uses the same function:
import_pack(npu/pack_import.cpp:1940) callsparse_onnx(… graph_filename(pack_dir))at:1962. So after this PR itsonnx.matmulscontains theMatMulNBitsBfnodes, withmm.weightrewritten to the.qweight.packedblob.has_real_graph_weight()(:2021) is "any matmul whose weight has non-emptydims". A 1-D packed blob qualifies, so for this genre it flips from false to true. With no Token.fconst, that switches the import onto the genre-A path (graph_weights_a).- The matmul loop at
:2226runs for every genre. For each node it checksbytes_of(wt, qw_need)withqw_need = N·(K/block)·(block/2). A packed blob isN·(K/128+1)·68bytes, which is larger, so the check passes. For the node with the head's geometry (N == V, K == H), it then decodes the blob as the standard MatMulNBits layout, which this genre isn't (64 B of codes plus a 4 B trailer per block, plus a trailing block per row, by your own rule).
So depending on what the scales input resolves to, npu-pack import on phi-4 now either decodes a wrong lm_head silently or fails at a new point, where before it refused a weightless graph. That's an import behaviour change, and the silent half is the dangerous one.
Suggested:
- Keep the census out of the import path until the writer PR. Either do the placeholder/sibling resolution in
plan()'s own pass (a separate helper overonnx.matmuls) instead of mutatingmm.weightinsideparse_onnx, or makeimport_packrefuse a graph whose matmuls areMatMulNBitsBf-with-packed-siblings, naming the genre, until the decoder exists. - Add one test: import of your synthetic external-data fixture must refuse by name, not decode.
One small note: the intermediate_size fallback picks MLP nodes by substring ("mlp", "gate", "up_proj", "down_proj"). "gate" also matches an attention output gate (attn…gate) or a MoE router (mlp.gate), and since best is a max, an attention gate with N = hidden would beat a narrow expert FFN. Anchoring on gate_proj/up_proj/down_proj would be safer.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY as the census half — with one demonstration worth adding, because the line this PR widens is on the import path too
The three findings are exactly the kind that only a real pack shows, and each is stated as a fact about the pack rather than a guess:
com.ryzenai:MatMulNBitsBf— phi-4's 201 weights are all the RyzenAI spelling, so a walker matchingMatMulNBitsreportedmatmulnbits_ops 0for a pack with 9.73 GB of external data. The fix counts it separately (ryzenai_matmuls) rather than folding it into the generic count, which is the right call: it makes "which genre is this pack" answerable instead of just "it has matmuls"./model/layers.0/…vsmodel.layers.0.…— node names slash-prefixed, initializers dotted; 201 of 201 resolve after normalising. Good, and stated as 201/201 rather than "works".…qweight.emptywith dims[0]and the bytes in a…qweight.packedsibling the node never references. Treatingdims == [0]as a placeholder rather than a zero-length weight is the finding that makes the genre visible at all.
The fixture matches the measurement: a synthetic external-packed pack with placeholders plus a packed sibling, asserting ryzenai_matmuls_ops == 1, matmulnbits_ops == 1, packed_weight_siblings == 1, external_row_stride matched/of == 1, and a control case where matched == 0 — the control is what keeps "matched 1 of 1" from being satisfied by a matcher that says yes to everything.
The one thing I would ask for: the diff changes a line on the shared walk, so "no import behaviour change" needs a demonstration rather than a sentence.
- if (op == "MatMulNBits") {
+ if (op == "MatMulNBits" || op == "MatMulNBitsBf") {
+ if (op == "MatMulNBitsBf") ++m.ryzenai_matmuls;walk_graph feeds the census and the plan/geometry the importer consumes (mi["dims"]["intermediate_size"] is asserted further down the same test file), so widening recognition is exactly the change that could flip a pack from "refused, nothing readable" to "half-imported". The synthetic fixture proves the census sees the genre; it does not prove the import still refuses. Cheapest proof, and it belongs in the test rather than in a PR comment: on that same synthetic pack, also assert the import answer is still the refusal it was before (or, in the body, the before/after of npu-pack import on the real phi-4_rai_1.9.0_npu — same message, same exit code). That is one check() and it turns "no import behaviour change" into a property the next refactor cannot quietly break.
Alternative spelling question, since this is the census half and it is cheap to settle now: the genre appears in at least three files (node.op_type, the .onnx alias table, and the external-data sidecar in cache/*_meta.json). If those ever disagree — a pack whose graph says MatMulNBits but whose metadata says MatMulNBitsBf — the census currently trusts the graph. That is fine as a decision, but it should be a stated one, because the plan (npu_plan.py) reads the metadata and the two walks would then describe the same pack differently.
Nothing here blocks it: it is a measurement PR that does what it says, the numbers are per-pack facts, and it is correctly sequenced before the import change it unblocks.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f9c6f98cb465957a93c2b4e7e6257031952bf88d.
The largest of this family (+455 across 8 files) and the one with the most to get wrong, so the two things I checked are the ones that decide it: both test files are extended (tests/npu_pack_import_test.cpp +98, tests/npu_plan_test.cpp +84) and the arithmetic in the body is exact:
K 5120 -> K/128 = 40 blocks, + 1 = 41 blocks x 68 B = 2,788 B per row (body: 2,788)
N 768 -> 768 x 2,788 = 2,141,184 B (body: 2,141,184)
which is the check the row exists for — "whether the bytes fit the stride the .fconst tile already carries — as a count, not an assumption".
The defect is the shape this family keeps finding, and it is the worst of them: a 1.9 external-data pack (measured on phi-4, 9.7 GB) puts a *.qweight.empty placeholder in every node's qweight slot — no dims — while the bytes sit in a *.qweight.packed sibling the node never references. A reader that follows node inputs reports a weightless graph for a 9.7 GB pack, i.e. the pack "imports" and has no weights. Naming that genre, counting the placeholders, and stating the stride check against the .fconst tile is the right three-part fix, and the plan layer gets its own tests rather than borrowing the importer's.
Docs in the same PR (docs/npu-plan.md, docs/npu.md) as the behaviour they describe. Nothing asked for.
…external-data genre by name on import The review's ask, and it caught a real behaviour change: recognising the genre makes graph_real_weights go 0 -> 201, which flips the importer onto the graph-weights path (written for the 1.3/1.6/1.7.1 line's ordinary 3-D initializers) -- so on a synthetic pack in this genre import no longer refused, it wrote a container from weights whose layout it cannot walk. "No import behaviour change" was simply wrong. So the genre is now refused in import_pack, by name and with the layout quoted: `this pack keeps its 4-bit weights as external-data blocks (N *.qweight.packed tensors behind *.qweight.empty placeholders, matched of total fitting length == N x (K/128 + 1) x 68) and this importer has no writer for that layout yet`. The refusal sits in import_pack and not in plan(), because `info` must keep reporting the genre it can now see -- and it does: the real phi-4 pack still reports packed_weight_siblings 201, stride matched 201/201. The test asserts the property rather than the sentence: import throws, the message names qweight.packed + "no writer", and nothing is written. That is what makes the next refactor unable to quietly turn the census into a half-import. Also stated in code, from the review's second question: the graph is the authority on the op's spelling for THIS reader (the same pack states it in cache/*_meta.json and the .onnx alias table); a disagreement is a pack fault to report, not something to average with what npu_plan.py reads.
|
Both asks done at You were right that the line I widened is shared with the import path, and it is worse than "could flip a pack": it did. So the genre is now refused by name on import, with the layout quoted: The refusal sits in The demonstration is in the test and asserts the property the next refactor must not break: Getting that fixture honest took two corrections of my own: the import check was first placed after On the second question — the spelling authority is now stated where the decision is made: the graph wins for this reader, because the |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 5215529539c59d6a5b492753dac7adc81fd958b0
5215529 closes the import-path concern from my review at f9c6f98c. import_pack() now refuses by name as its first act whenever the plan sees packed siblings (p.graph_packed_siblings > 0), before any of the shared parse_onnx results can reach the genre-A path or the lm_head decoder. The message states the layout and the stride census:
external-packed import refusal: this pack keeps its 4-bit weights as external-data blocks (1 `*.qweight.packed` tensors behind
`*.qweight.empty` placeholders, 1 of 1 fitting length == N x (K/128 + 1) x 68) and this importer has no writer for that layout yet …
So the census widens and the writer doesn't, which is the split the body promised.
At this head:
- I rebuilt in my engine tree (under
thermal-run).npu_pack_import_test tests/goldengives "all npu-pack checks passed", and the new arm asserts the refusal on the answer itself ("external-packed: import still refuses the genre (the census widening did not become a writer)").npu_plan_testgivesnpu_plan: ok. - Putting the refusal in
import_packrather thanplan()is right, soinfokeeps reporting the genre.
Small, carried over, not blocking: the intermediate_size fallback still selects MLP nodes by the bare substring "gate", which also matches an attention output gate or a MoE router. Anchoring on gate_proj/up_proj/down_proj would be safer the first time a pack of that kind reaches this path.
…_dim
Review of this PR found the hole, and it is real: q_proj is (N = heads*head_dim, K = hidden) and o_proj is
(N = hidden, K = heads*head_dim), which is the same agree-with-itself shape the FFN has. With q_dim > hidden the old
rule returned the ATTENTION width -- 4096 where the true FFN was 9728 in the reviewer's reproduction -- from a function
whose whole contract is to refuse rather than guess. gemma-4 e4b hides it because 8 x 256 = 2048 < hidden 2560.
Two witnesses keep attention out, and neither is a guess:
* the role names this genre actually carries (attn/q_proj/k_proj/v_proj/o_proj/qkv/lm_head are skipped). On a pack whose
names are opaque this alone would not help, which is why it is not alone;
* structure: gate AND up read the SAME activation tensor, so two nodes share both an input and an N. A lone q_proj
never does, and o_proj has no partner. That is the witness attention cannot produce.
A fused gate_up is then handled explicitly: one node covers gate and up, so its N is 2*w and the down projection's K is
the same 2*w -- the agreeing value is 2w and the width is half of it.
Tests pin the reviewer's exact case plus the three that already existed:
im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560)
im-from-node-attrs: q_dim 4096 > hidden 2560, fused gate_up -> 9728 (must not be 4096)
im-from-node-attrs: an attention-only graph wider than hidden yields 0, not the attention width
im-from-node-attrs: two agreeing widths are refused rather than picked from
im-from-node-attrs: a graph with no FFN pair yields 0 (the caller keeps refusing)
Re-measured on amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu's own files (with #1643's MatMulNBitsBf line applied
locally): the refusal is still the embed-tower one, so the name filter did not cost the real pack its derivation.
Also took the other reviewer's non-blocking note: the declaration now says the function parses the graph, so it is cheap
only because plan() calls it once.
…matches (Fixes #1662) `canonical_token_fconst()` rewrites the canonical 1.8-form copy of a 1.9 pack's Token `.fconst` on every call -- 291 MB on Qwen2.5-0.5B, about 1 GB on a 1.5B -- even when that copy is already there and identical. Measured on origin/main with a probe that calls the public `plan()` twice on `models/oga/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu` and straces the scratch path: origin/main 2 truncating opens of the 291 MB copy (one per run) this commit 1 (the second run reuses) Three parts: * the copy is reused when it already holds exactly the bytes this run would write. The canonical body's length is a function of the source file alone (`fc.size - layers * hdr_len`), so a length test decides it -- and it is the RIGHT test rather than an existence test: a truncated copy from an interrupted run, a longer copy from another pack, a missing file and a directory on the scratch path are all rejected and rewritten. * the scratch name carries the SOURCE file's identity (size + mtime) besides the pack path and geometry, so a repack at the same path with the same geometry cannot be served a stale copy of the same length. Measured: the two names differ only in the nanosecond part of the mtime. * the name also carries `v1`, the canonical form's version: a change to the RULES (a new `kFconst19Rules` entry, a different tile order) leaves the source untouched, so source identity alone would keep serving a copy of the old form. Bump it when the canonical form changes. The decision is split out as `canonical_copy_reusable()` (declared in the header) so it has a test that needs no 1.9 pack: `test_canonical_copy_reusable()`, seven arms over crafted files. Negative control, with the predicate changed to trust any existing file: 3 of the 7 fail (truncated copy, a longer copy, an empty copy), so the arms are sensitive to the thing that can go wrong. Verified on a real pack, not only in the arms: two plan() runs 1 write, not 2 ok truncated copy (1000 B) rewritten to 291,653,952 B, not trusted ok same-size repack (mtime bump) lands on a NEW scratch name (2 files on disk) ok npu_pack_import_test <golden> crafted + golden legs pass npu_pack_import_test <golden> <real 1.9 pack> FAILS exactly as origin/main does on this pack (the MatMulNBits.qweight.empty external-packed genre, #1643) -- verified by rebuilding origin/main's three files and re-running Behaviour is unchanged for a 1.8 pack: it still comes back as itself and nothing is written.
|
Branch grew by one slice at Since the census now names the layout, the next question was whether the layout is decodable with what the tree has. It is, and the decoder is small enough to belong here rather than in a follow-up: Q4 decode_external_tile(const uint8_t* data, size_t bytes, int N, int K, std::string& err,
bool row_major = kExtQuantsRowMajor); // -> the Q4 the container layer already consumeswith the format it implements measured on the real pack and asserted, not described:
One thing deliberately left as a parameter rather than decided in code: which way a row's 128 code bytes lie inside the 2,048-byte quants region. The The round trip caught two bugs of mine, both the same misreading: I first took the documented byte to hold a column pair rather than a row pair, so odd rows were double-shifted to zero — and had mirrored the same mistake in the test's encoder, which is exactly why the test encodes per branch instead of decoding a blob from the product code. Not here: writing the container (the tile writer already takes a |
…y 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.
…matches (Fixes #1662) `canonical_token_fconst()` rewrites the canonical 1.8-form copy of a 1.9 pack's Token `.fconst` on every call -- 291 MB on Qwen2.5-0.5B, about 1 GB on a 1.5B -- even when that copy is already there and identical. Measured on origin/main with a probe that calls the public `plan()` twice on `models/oga/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu` and straces the scratch path: origin/main 2 truncating opens of the 291 MB copy (one per run) this commit 1 (the second run reuses) Three parts: * the copy is reused when it already holds exactly the bytes this run would write. The canonical body's length is a function of the source file alone (`fc.size - layers * hdr_len`), so a length test decides it -- and it is the RIGHT test rather than an existence test: a truncated copy from an interrupted run, a longer copy from another pack, a missing file and a directory on the scratch path are all rejected and rewritten. * the scratch name carries the SOURCE file's identity (size + mtime) besides the pack path and geometry, so a repack at the same path with the same geometry cannot be served a stale copy of the same length. Measured: the two names differ only in the nanosecond part of the mtime. * the name also carries `v1`, the canonical form's version: a change to the RULES (a new `kFconst19Rules` entry, a different tile order) leaves the source untouched, so source identity alone would keep serving a copy of the old form. Bump it when the canonical form changes. The decision is split out as `canonical_copy_reusable()` (declared in the header) so it has a test that needs no 1.9 pack: `test_canonical_copy_reusable()`, seven arms over crafted files. Negative control, with the predicate changed to trust any existing file: 3 of the 7 fail (truncated copy, a longer copy, an empty copy), so the arms are sensitive to the thing that can go wrong. Verified on a real pack, not only in the arms: two plan() runs 1 write, not 2 ok truncated copy (1000 B) rewritten to 291,653,952 B, not trusted ok same-size repack (mtime bump) lands on a NEW scratch name (2 files on disk) ok npu_pack_import_test <golden> crafted + golden legs pass npu_pack_import_test <golden> <real 1.9 pack> FAILS exactly as origin/main does on this pack (the MatMulNBits.qweight.empty external-packed genre, #1643) -- verified by rebuilding origin/main's three files and re-running Behaviour is unchanged for a 1.8 pack: it still comes back as itself and nothing is written.
…s when no file and no initializer dim states it The 1.9 external-data genre states the FFN width nowhere a reader currently looks: the weights are MatMulNBitsBf nodes whose initializers carry dims [0] (the bytes live in a *.qweight.packed sibling), so plan()'s initializer-dimension heuristic yields nothing and the importer refuses. The NODE carries K and N, and walk_graph already descends into the If branches every OGA pack keeps its decoder in -- so the value is reachable without a second file format. The rule is a pair that agrees with itself, not a name or a ratio: the down projection's K is the gate/up projection's N, and the gate/up projection's K is the hidden size. On amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu (hidden 2560, measured with onnx 1.23.2) 42 activations each feed two (N=10240,K=2560) gate/up projections and 42 down projections are (N=2560,K=10240) -> 10240; its per-token 'mobius' paths (N=512/2048, K=2560) have no matching down projection, so they cannot be mistaken for the FFN. Two widths that both pair up return 0 and the caller keeps refusing rather than picking one. Stacked on the model_config.json branch (#1644) because it needs graph_filename() to find decoder/model.onnx, and it only fires on a real pack once #1643's walker recognises MatMulNBitsBf as a quantised matmul: with that line applied locally the e4b pack's refusal moves from 'the pack states no intermediate_size' to 'the pack has no model.embed_tokens.weight'; without it the same pack still refuses at geometry, which is the before/after this was measured on.
…_dim
Review of this PR found the hole, and it is real: q_proj is (N = heads*head_dim, K = hidden) and o_proj is
(N = hidden, K = heads*head_dim), which is the same agree-with-itself shape the FFN has. With q_dim > hidden the old
rule returned the ATTENTION width -- 4096 where the true FFN was 9728 in the reviewer's reproduction -- from a function
whose whole contract is to refuse rather than guess. gemma-4 e4b hides it because 8 x 256 = 2048 < hidden 2560.
Two witnesses keep attention out, and neither is a guess:
* the role names this genre actually carries (attn/q_proj/k_proj/v_proj/o_proj/qkv/lm_head are skipped). On a pack whose
names are opaque this alone would not help, which is why it is not alone;
* structure: gate AND up read the SAME activation tensor, so two nodes share both an input and an N. A lone q_proj
never does, and o_proj has no partner. That is the witness attention cannot produce.
A fused gate_up is then handled explicitly: one node covers gate and up, so its N is 2*w and the down projection's K is
the same 2*w -- the agreeing value is 2w and the width is half of it.
Tests pin the reviewer's exact case plus the three that already existed:
im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560)
im-from-node-attrs: q_dim 4096 > hidden 2560, fused gate_up -> 9728 (must not be 4096)
im-from-node-attrs: an attention-only graph wider than hidden yields 0, not the attention width
im-from-node-attrs: two agreeing widths are refused rather than picked from
im-from-node-attrs: a graph with no FFN pair yields 0 (the caller keeps refusing)
Re-measured on amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu's own files (with #1643's MatMulNBitsBf line applied
locally): the refusal is still the embed-tower one, so the name filter did not cost the real pack its derivation.
Also took the other reviewer's non-blocking note: the declaration now says the function parses the graph, so it is cheap
only because plan() calls it once.
…matches (Fixes #1662) (#1669) `canonical_token_fconst()` rewrites the canonical 1.8-form copy of a 1.9 pack's Token `.fconst` on every call -- 291 MB on Qwen2.5-0.5B, about 1 GB on a 1.5B -- even when that copy is already there and identical. Measured on origin/main with a probe that calls the public `plan()` twice on `models/oga/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu` and straces the scratch path: origin/main 2 truncating opens of the 291 MB copy (one per run) this commit 1 (the second run reuses) Three parts: * the copy is reused when it already holds exactly the bytes this run would write. The canonical body's length is a function of the source file alone (`fc.size - layers * hdr_len`), so a length test decides it -- and it is the RIGHT test rather than an existence test: a truncated copy from an interrupted run, a longer copy from another pack, a missing file and a directory on the scratch path are all rejected and rewritten. * the scratch name carries the SOURCE file's identity (size + mtime) besides the pack path and geometry, so a repack at the same path with the same geometry cannot be served a stale copy of the same length. Measured: the two names differ only in the nanosecond part of the mtime. * the name also carries `v1`, the canonical form's version: a change to the RULES (a new `kFconst19Rules` entry, a different tile order) leaves the source untouched, so source identity alone would keep serving a copy of the old form. Bump it when the canonical form changes. The decision is split out as `canonical_copy_reusable()` (declared in the header) so it has a test that needs no 1.9 pack: `test_canonical_copy_reusable()`, seven arms over crafted files. Negative control, with the predicate changed to trust any existing file: 3 of the 7 fail (truncated copy, a longer copy, an empty copy), so the arms are sensitive to the thing that can go wrong. Verified on a real pack, not only in the arms: two plan() runs 1 write, not 2 ok truncated copy (1000 B) rewritten to 291,653,952 B, not trusted ok same-size repack (mtime bump) lands on a NEW scratch name (2 files on disk) ok npu_pack_import_test <golden> crafted + golden legs pass npu_pack_import_test <golden> <real 1.9 pack> FAILS exactly as origin/main does on this pack (the MatMulNBits.qweight.empty external-packed genre, #1643) -- verified by rebuilding origin/main's three files and re-running Behaviour is unchanged for a 1.8 pack: it still comes back as itself and nothing is written. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
… 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 when no file and no initializer dim states it The 1.9 external-data genre states the FFN width nowhere a reader currently looks: the weights are MatMulNBitsBf nodes whose initializers carry dims [0] (the bytes live in a *.qweight.packed sibling), so plan()'s initializer-dimension heuristic yields nothing and the importer refuses. The NODE carries K and N, and walk_graph already descends into the If branches every OGA pack keeps its decoder in -- so the value is reachable without a second file format. The rule is a pair that agrees with itself, not a name or a ratio: the down projection's K is the gate/up projection's N, and the gate/up projection's K is the hidden size. On amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu (hidden 2560, measured with onnx 1.23.2) 42 activations each feed two (N=10240,K=2560) gate/up projections and 42 down projections are (N=2560,K=10240) -> 10240; its per-token 'mobius' paths (N=512/2048, K=2560) have no matching down projection, so they cannot be mistaken for the FFN. Two widths that both pair up return 0 and the caller keeps refusing rather than picking one. Stacked on the model_config.json branch (#1644) because it needs graph_filename() to find decoder/model.onnx, and it only fires on a real pack once #1643's walker recognises MatMulNBitsBf as a quantised matmul: with that line applied locally the e4b pack's refusal moves from 'the pack states no intermediate_size' to 'the pack has no model.embed_tokens.weight'; without it the same pack still refuses at geometry, which is the before/after this was measured on.
…_dim
Review of this PR found the hole, and it is real: q_proj is (N = heads*head_dim, K = hidden) and o_proj is
(N = hidden, K = heads*head_dim), which is the same agree-with-itself shape the FFN has. With q_dim > hidden the old
rule returned the ATTENTION width -- 4096 where the true FFN was 9728 in the reviewer's reproduction -- from a function
whose whole contract is to refuse rather than guess. gemma-4 e4b hides it because 8 x 256 = 2048 < hidden 2560.
Two witnesses keep attention out, and neither is a guess:
* the role names this genre actually carries (attn/q_proj/k_proj/v_proj/o_proj/qkv/lm_head are skipped). On a pack whose
names are opaque this alone would not help, which is why it is not alone;
* structure: gate AND up read the SAME activation tensor, so two nodes share both an input and an N. A lone q_proj
never does, and o_proj has no partner. That is the witness attention cannot produce.
A fused gate_up is then handled explicitly: one node covers gate and up, so its N is 2*w and the down projection's K is
the same 2*w -- the agreeing value is 2w and the width is half of it.
Tests pin the reviewer's exact case plus the three that already existed:
im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560)
im-from-node-attrs: q_dim 4096 > hidden 2560, fused gate_up -> 9728 (must not be 4096)
im-from-node-attrs: an attention-only graph wider than hidden yields 0, not the attention width
im-from-node-attrs: two agreeing widths are refused rather than picked from
im-from-node-attrs: a graph with no FFN pair yields 0 (the caller keeps refusing)
Re-measured on amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu's own files (with #1643's MatMulNBitsBf line applied
locally): the refusal is still the embed-tower one, so the name filter did not cost the real pack its derivation.
Also took the other reviewer's non-blocking note: the declaration now says the function parses the graph, so it is cheap
only because plan() calls it once.
…ithout it (refs #1503) (#1712) * npu-pack: decode the 1.9 external-data tile -- subvolume, addressing and the code-order constant, held to a round trip Slice 1 of the writer for the genre #1643 made visible: a decoder from the pack's own blob to the Q4 the container layer already consumes, plus the test that pins the specification. Measured on amd/phi-4_rai_1.9.0_npu's 201 weight blobs: length = N x (K/128 + 1) x 68 (201 of 201) subvolume = 2,176 B = 2,048 quants + 64 B int4 zero points (4 per row, one per 32-column group) + 64 B bf16 scales (one per row), 32 rows x 128 K addressing = tile * 2*(K/128 + 1) + k*2 + half tile = row/64, k = 0 is the tile's zero bias pair Found by scanning for all-zero subvolumes in three tensors: the pair sits at index % (2*(K/128+1)) == 0,1 on lm_head, qkv and down alike -- which also corrected my earlier reading of it as a per-tensor prologue. What the host side could not settle is which way a row's 128 code bytes lie inside the 2,048-byte quants region: the .fconst tile's known-answer-verified formula ((cp%4) + c*4 + (cp/4)*512, one byte per COLUMN holding the row pair 2cp/2cp+1) against a plain row-major reading. Three statistics were tried and they disagreed with each other, which discredits all three (each assumed a row's codes centre on its zero-point, and the measured zero points span 0..11). So the default is the verified prior, the alternative is the function's parameter, and the known-answer run decides. The test therefore holds BOTH orderings to their own spec rather than only the shipped one: it encodes a known code matrix, scale and four zero points per row into a synthetic blob, decodes it, and requires every code, scale and zero point back exactly -- so flipping the constant cannot quietly change what the other branch means. It also refuses a blob one subvolume short with the arithmetic in the message. Not in this slice: writing the container (the tile writer takes a Q4; the role mapping and the QMoEBf/GQA structure of this pack come next). (cherry picked from commit a98d2e0) * npu-pack: the external-data tile's conversion to the container's q4_1, with the identity pinned The writer's numerics half. The pack's convention is w = (code - zp) * scale (zp is an int4 CODE offset) and the container's is w = code*d + m (m is the group MIN), so the mapping is d = scale, m = -zp*scale, CODES UNCHANGED -- verified against the repo's own packers rather than assumed. Why not the obvious implementation (dequantise, then re-quantise with the container's quantiser): measured on a real phi-4 tile, that path reproduced the pack's codes on only 2,826 of 8,192 entries and moved weights by 0.184, i.e. 2.5 % of the tensor's range -- small enough to pass a correlation and wrong enough to matter. This mapping's only loss is the bf16 rounding of m, which is bf16-exact on 86.3 % of group entries (measured) and is the same rounding class the container applies to every parameter. It is COUNTED, not assumed: m_inexact reports how many group offsets were not exactly representable. The test pins the identity rather than the code path: for every entry of a synthetic tile in the pack's convention, code*d + m must equal (code - zp)*scale to within one bf16 ulp of m measured against the group's 15-step range, with codes and scales asserted equal. That is what makes a future "simplification" back to requantise fail loudly. No container is written yet: the role mapping and the container assembly are the next slice, and a mapping without a writer would be a census of nothing. * npu-pack: the external-data genre's role mapping, as a component match The writer's first component: the pack names its projections in the graph (model.layers.7.mlp.gate_proj.MatMulNBits.qweight.packed), and the tile writer speaks roles (QKV, O, G, U, D, HEAD). external_role_of() is that mapping, and it matches a NAME COMPONENT rather than a substring: the needle must sit at a name boundary and be followed by '.' or the end, so `...qkv_proj.MatMulNBits...` maps and `...qkv_proj_matmul` does not. That distinction is not pedantry: catching it was the only failing check when the test was first run, and the substring version would have mapped a real weight to a role it does not play -- a container full of mislabelled weights rather than a refusal. An unmapped name returns nullptr, which the caller must report BY NAME: a projection nothing maps is a weight that would silently not be written. Tests: the six real names (qkv_proj -> QKV, o_proj -> O, gate_proj -> G, up_proj -> U, down_proj -> D, lm_head -> HEAD) and three that must NOT map (a norm, the embedding, and the near-miss suffix). * npu-pack: the assembly's naming half -- the pack's fused qkv split onto the container's three tensors The container stores self_attn.q_proj/k_proj/v_proj separately (only a GDN layer has a fused linear_attn.qkv_proj), while the pack stores q/k/v as ONE matmul: phi-4's is N 7680 = 40 heads x 128 (q 5120) + 2 x 10 kv heads x 128 (k 1280, v 1280). So the split is geometry the pack states, not a guess, and it must close exactly: [0, nh*hd) q, then nkv*hd for k, then nkv*hd for v, summing to the matmul's N. Names are the forward's own, read from npu/forward/src/q4nx_forward.cpp (self_attn.{q,k,v,o}_proj.weight, mlp.{gate,up,down}_proj.weight, lm_head.weight) rather than invented -- a container with plausible-but-wrong names loads and misbehaves instead of refusing. A role with no container tensor (e.g. a routed expert, which needs the MoE writer this importer does not have) is REFUSED by name: a weight with nowhere to go is a refusal, never a silent drop. Same for a QKV split with no geometry. Tests: the three q/k/v tensors and their exact row ranges against phi-4's numbers, the five single-tensor roles, the refusal for an unknown role, and the refusal for QKV without geometry. * npu-pack: import the 1.9 external-data genre -- the writer, end to end phi-4's 201 qweight blobs become a container: decode_external_tile -> container_from_external_q4 -> external_container_tensors (the fused qkv split by geometry) -> tile_q4_1_f32 -> the same I8 tile chunks the .fconst route writes. Measured on the real pack: imported 363 tensors, 1,726,720 q4_1 tiles -> model.q4nx 11.6 GB 281 I8 projections (40 x {q,k,v,o,gate,up,down} + lm_head), 82 BF16 exact (embedding + 81 norms) config.json carries the geometry, the q4nx-2 parameter form and q4nx_source Its own function (import_external) rather than a fourth branch in import_pack: that one is built around the .fconst envelope and the graph-weights path, and threading a third shape through it is how the two existing paths got their special cases. What it shares is what is genuinely shared: plan(), the geometry, the tile writer, the container form. Refusals stay refusals: a projection with no container role, a graph name with no bytes, a qkv split that does not fit N, and (without --allow-incomplete) any refusal at all -- a weight with nowhere to go is never a silent drop. Not verified: a run. The container declares fp32 q4_1 parameters, and no ELF family on this box carries phi-4's K=5120 / K=17920 shapes (the sets cover 768..14336 in the sizes other models use), so serving it refuses before it can answer. That is npu-kernels work, not engine work, and it is the next item for this row -- not a defect in the import. * npu-pack: pin the decoder's scale/zero ADDRESSING, not one row's value repeated The review's blocking point: test_external_tile_decode wrote ONE scale and ONE set of zero points into every row of every subvolume, so a decoder that read row 0's scale for all 32 rows would pass -- the scale/zero addressing (row r*2 in the param regions, group index (kk-1)*4+g, the half and tile choice) was never exercised, and the conversion test inherits whatever the decode gets. The fixture now writes a DISTINCT bf16 scale and four distinct int4 zero points per (tile, half, kk, r), and the test asserts each one lands in q.scale / q.zero at exactly its own slot. A decoder that ignores row, block, half or tile now fails on a specific cell instead of passing on a repeated value. Also updated the genre test's expectation to the writer that now exists: import no longer refuses "because nothing can write this genre" -- it refuses when the container split cannot be satisfied (a small synthetic qkv of N 768 against a 40-head x 128 geometry), and the check pins that refusal with its numbers instead of the old message. all npu-pack checks passed. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…s when no file and no initializer dim states it (#1657) * npu-pack: derive intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it The 1.9 external-data genre states the FFN width nowhere a reader currently looks: the weights are MatMulNBitsBf nodes whose initializers carry dims [0] (the bytes live in a *.qweight.packed sibling), so plan()'s initializer-dimension heuristic yields nothing and the importer refuses. The NODE carries K and N, and walk_graph already descends into the If branches every OGA pack keeps its decoder in -- so the value is reachable without a second file format. The rule is a pair that agrees with itself, not a name or a ratio: the down projection's K is the gate/up projection's N, and the gate/up projection's K is the hidden size. On amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu (hidden 2560, measured with onnx 1.23.2) 42 activations each feed two (N=10240,K=2560) gate/up projections and 42 down projections are (N=2560,K=10240) -> 10240; its per-token 'mobius' paths (N=512/2048, K=2560) have no matching down projection, so they cannot be mistaken for the FFN. Two widths that both pair up return 0 and the caller keeps refusing rather than picking one. Stacked on the model_config.json branch (#1644) because it needs graph_filename() to find decoder/model.onnx, and it only fires on a real pack once #1643's walker recognises MatMulNBitsBf as a quantised matmul: with that line applied locally the e4b pack's refusal moves from 'the pack states no intermediate_size' to 'the pack has no model.embed_tokens.weight'; without it the same pack still refuses at geometry, which is the before/after this was measured on. * npu-pack: the no-intermediate_size refusal was pointing at a file that does not hold it The message still said the FFN width 'has to come from the subgraph or from model.pb.bin'. Both halves of that are wrong on the 1.9 gemma line: model.pb.bin is not an ONNX ModelProto at all (gemma-4-e4b's is 122,651 B with 0 nodes and 0 initializers), and the width is neither in a subgraph *file* nor in an initializer dim -- it is a node attribute inside the If branch, which walk_graph already descends into and intermediate_size_from_graph now reads. So the refusal says what was actually tried: the geometry files, the initializers, and the MatMulNBits/MatMulNBitsBf node attributes including the If branches, with the reason each of them can come up empty on this line. A refusal that names a path nobody needs costs the next reader the same hour it cost me. * npu-pack: attention is not the FFN -- the derived width must not be q_dim Review of this PR found the hole, and it is real: q_proj is (N = heads*head_dim, K = hidden) and o_proj is (N = hidden, K = heads*head_dim), which is the same agree-with-itself shape the FFN has. With q_dim > hidden the old rule returned the ATTENTION width -- 4096 where the true FFN was 9728 in the reviewer's reproduction -- from a function whose whole contract is to refuse rather than guess. gemma-4 e4b hides it because 8 x 256 = 2048 < hidden 2560. Two witnesses keep attention out, and neither is a guess: * the role names this genre actually carries (attn/q_proj/k_proj/v_proj/o_proj/qkv/lm_head are skipped). On a pack whose names are opaque this alone would not help, which is why it is not alone; * structure: gate AND up read the SAME activation tensor, so two nodes share both an input and an N. A lone q_proj never does, and o_proj has no partner. That is the witness attention cannot produce. A fused gate_up is then handled explicitly: one node covers gate and up, so its N is 2*w and the down projection's K is the same 2*w -- the agreeing value is 2w and the width is half of it. Tests pin the reviewer's exact case plus the three that already existed: im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560) im-from-node-attrs: q_dim 4096 > hidden 2560, fused gate_up -> 9728 (must not be 4096) im-from-node-attrs: an attention-only graph wider than hidden yields 0, not the attention width im-from-node-attrs: two agreeing widths are refused rather than picked from im-from-node-attrs: a graph with no FFN pair yields 0 (the caller keeps refusing) Re-measured on amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu's own files (with #1643's MatMulNBitsBf line applied locally): the refusal is still the embed-tower one, so the name filter did not cost the real pack its derivation. Also took the other reviewer's non-blocking note: the declaration now says the function parses the graph, so it is cheap only because plan() calls it once. * npu-pack: look for the Token .fconst where the pack says its decoder is A multi-component pack (`type: compositemodel`) splits itself in two: the files that state the geometry -- config.json, genai_config.json, model_config.json -- stay at the pack root, while the LLM graph and its Token .fconst live in the component genai_config.json names (`decoder/`). #1644 taught graph_filename() to follow that relative path; the .fconst lookup was still scanning only the directory it was handed. Measured on amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu with its weight-carrying files present (decoder/dd_metastate_Llm_Token_*.fconst 1.34 GiB, model.bin 1.36 GiB, model.onnx.data 0.57 GiB), before this change: 1bit npu-pack info <pack root> the pack carries no Token .fconst either (no dd_metastate_Llm_Token_*.fconst): these weights are genuinely absent from the pack's external data, so this pack cannot be imported -- for a pack that has one, two directories down. After this change the same command gets through the weights and the fconst and refuses at the embedding component ("the pack has no model.embed_tokens.weight, and no embedding.onnx"), which is the next thing a multi-component pack actually needs (engine #1661 reads it when present). Pointing npu-pack at decoder/ instead is not a workaround: that directory has no config.json, genai_config.json or model_config.json, so the geometry has nothing to read and the refusal becomes "the pack states no intermediate_size". The two halves are only together at the root, which is why the lookup has to follow the pack's own declaration. A flat pack is unchanged: the root is scanned first, and the component scan only runs when genai_config.json's decoder filename carries a relative path that exists. * npu-pack: read the embedding component where genai_config.json names it, with its data beside it The multi-component packs keep their embedding graph at `embedding/model.onnx` with a 10.26 GiB `model.onnx.data` beside it, and genai_config.json names exactly that. read_embedding_component() looked only for a flat `<pack>/embedding.onnx`, so a pack that declares its component was reported as "the pack has no model.embed_tokens.weight, and no embedding.onnx" (measured on amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu). * the graph path is now `model.embedding.filename` when genai_config.json declares it and the file exists (relative paths only, no "..", the same rule graph_filename() uses), falling back to the flat `embedding.onnx`; * the external data is resolved next to the GRAPH rather than at the pack root, because a component's tensors name it with a bare filename (`embedding.onnx.data` sits in `embedding/`, not at the root). After this the same pack gets past "no embedding.onnx" and refuses with what is actually true of it: "embedding.onnx has no embed_tokens table + scales" -- the component is f32 and unquantised (`embedding.embed_tokens.weight` (262144, 1536) f32 plus a per-layer table (262144, 8960) and a 256-wide projection norm), while the reader wants a 4-bit packed table with scales. That is a separate mechanism and it is not claimed here; this commit only stops the reader looking in the wrong place for a component the pack declares. * npu-pack: read an f32 embedding component, and refuse a per-layer one by name engine#1725. The gemma-4 components are f32 and UNQUANTISED: `embedding.embed_tokens.weight (262144, 1536)` f32 external, plus `embedding.embed_tokens_per_layer.weight (262144, 8960)` -- 35 layers x 256 head_dim, which is what the decoder's declared `per_layer_inputs` input consumes -- plus a 256-wide projection norm. The reader required an `embed_tokens` table WITH a `*scales*` tensor, i.e. a 4-bit packed table, so a component that had just been found was reported as "no embed_tokens table + scales". That message is why this looked like a lookup for hours. Slice 1, deliberately not the whole mechanism: * an unquantised f32 `embed_tokens` table is read (bounds-checked against its external data, converted by the container to BF16 the way it already converts every other table) -- exact, and 1.61 GiB for a 262144x1536 table; * a component that ALSO carries `embed_tokens_per_layer` is refused BY NAME: the per-layer table feeds the decoder's per_layer_inputs and no per-layer embedding mechanism exists here yet, so importing would hand the decoder a model without an input it asks for. The refusal says the table's name, what it feeds, and the issue; * anything unquantised that is not f32 is refused with its dtype rather than silently mis-read. Slice 2 -- the per-layer mechanism itself (host-side gather vs a container representation, 9.40 GiB f32 -> 4.70 GiB BF16) -- is not claimed here and is what #1725 is for. This slice makes the diagnosis true and lets a pack whose decoder does not need per-layer inputs through. --------- Co-authored-by: pi-demo <pi-demo@1.local> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #1503, and the owner's 11-09 pack order (my packs:
gpt-oss-20b,phi-4). The census half:npu-pack infocan now see the 1.9 external-data genre and report its weight layout as a measurement. No import behaviour change.Why the genre looked weightless
amd/phi-4_rai_1.9.0_npucarries 9.73 GB of ordinary ONNX external data and 201 quantised matmuls — but the reader reported a graph with no weights, for three independent reasons, all visible only against the real pack:com.microsoft:MatMulNBits; phi-4's 201 weights are allcom.ryzenai:MatMulNBitsBf, somatmulnbits_opswas 0./model/layers.0/attn/qkv_proj/…while the initializer ismodel.layers.0.attn.qkv_proj…(201 of 201 resolved by normalising/→.and dropping the leading slash).…qweight.**empty**with dims[0], and the bytes are in a…qweight.**packed**sibling the node never references. Adims.empty()test calls[0]a real weight of length zero.What
infosays on the real pack now201 of 201 is the point, not a decoration: the same 68-byte per-row-per-128-K block is the stride the
.fconstattention tile already carries (2,176 B subvolume = 32 rows × 68 B), so the reader this issue is about walks a layout the importer already decodes rather than a new one. It also carries the tile's+1trailing block per row.intermediate_sizefor this genre comes from the node's ownN/K(gate_proj/up_proj→ N,down_proj→ K) — a 1-D byte blob has no shape to read it from, and this is the pack's declaration rather than a guess from other dimensions (the rule that refusal path already states).Verified
ctest-side test (new, synthetic fixture in the genre).packedsibling; the stride rule reports 1 of 1 when the bytes fit and 0 of 1 when they do not (mismatch reported, no crash, rule named)intermediate_sizeall npu-pack checks passedbuild/1bit npu-pack info …/phi-4_rai_1.9.0_npuWhat this does not do, deliberately
It does not import the genre. Writing the container from
N × (K/128+1) × 68is the next PR and needs the 9.7 GB payload; splitting the census from the writer keeps each one checkable — and the census is what tells the next reader whether the stride is an assumption or a fact on each pack.docs/npu.md("From a Ryzen AI OGA NPU pack") gains the paragraph that says a pack can state its weights behind a placeholder, with these numbers.For
gpt-oss-20bthe answer is different and I have not touched it: its 12.23 GBeager.binis referenced by no initializer,eager.pb.bin(12.9 KB) is an index (tensor path → op name) handed to AMD's runtime asexternal_data_file, so it needs the AMD hybrid blob layout — its own item, recorded on #1503.