Skip to content

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

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

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

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1503, and the owner's 11-09 pack order (my packs: gpt-oss-20b, phi-4). The census half: npu-pack info can 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_npu carries 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:

  1. the op name. The walker matched com.microsoft:MatMulNBits; phi-4's 201 weights are all com.ryzenai:MatMulNBitsBf, so matmulnbits_ops was 0.
  2. the spelling. A node names its tensor /model/layers.0/attn/qkv_proj/… while the initializer is model.layers.0.attn.qkv_proj… (201 of 201 resolved by normalising /→. and dropping the leading slash).
  3. the placeholder. The node's weight slot holds …qweight.**empty** with dims [0], and the bytes are in a …qweight.**packed** sibling the node never references. A dims.empty() test calls [0] a real weight of length zero.

What info says on the real pack now

"graph_weights": {
  "matmulnbits_ops": 201,
  "ryzenai_matmuls_ops": 201,
  "packed_weight_siblings": 201,
  "real_weight_initializers": 201,
  "block_size": 128,
  "external_tensors": 285,
  "external_row_stride": { "matched": 201, "of": 201, "rule": "length == N * (K/128 + 1) * 68" }
}
"dims": { "intermediate_size": 17920, ... }

201 of 201 is the point, not a decoration: the same 68-byte per-row-per-128-K block is the stride the .fconst attention 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 +1 trailing block per row.

intermediate_size for this genre comes from the node's own N/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

check result
ctest-side test (new, synthetic fixture in the genre) placeholder resolves to its .packed sibling; 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)
a pack whose config states no intermediate_size readable, FFN width from the nodes
the whole existing suite all npu-pack checks passed
the real pack's metadata (no 9.7 GB download) numbers above, straight from build/1bit npu-pack info …/phi-4_rai_1.9.0_npu

What this does not do, deliberately

It does not import the genre. Writing the container from N × (K/128+1) × 68 is 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-20b the answer is different and I have not touched it: its 12.23 GB eager.bin is referenced by no initializer, eager.pb.bin (12.9 KB) is an index (tensor path → op name) handed to AMD's runtime as external_data_file, so it needs the AMD hybrid blob layout — its own item, recorded on #1503.

bong-water-water-bong added 2 commits November 9, 2026 04:36
…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.
@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 🔶

1503 - Partially compliant

Compliant requirements:

  • The importer now recognizes com.ryzenai:MatMulNBitsBf ops in addition to com.microsoft:MatMulNBits
  • The importer resolves *.qweight.empty placeholders to their *.qweight.packed siblings
  • The importer reports ryzenai_matmuls_ops and packed_weight_siblings counts
  • The importer measures and reports the external row stride to match length == N × (K/128 + 1) × 68
  • The importer correctly handles the FFN width from node's N/K attributes for external-data genre

Non-compliant requirements:

  • The PR does not explicitly address the block-32 vs block-128 handling in terms of different decoders

Requires further human verification:

  • The claim about block-32 handling and different decoders needs verification on actual hardware
  • The performance impact of the new resolution logic needs to be measured
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Placeholder Resolution Logic

The new logic for resolving *.qweight.empty placeholders to *.qweight.packed siblings may have performance implications, especially in the loop that iterates through all matmuls. The resolution involves string operations and map lookups that could be expensive in a tight loop. While the code is correct, it's worth noting that this logic is executed for every matmul, and could potentially be optimized if it becomes a bottleneck.

for (auto& mm : m.matmuls) {
    if (mm.weight.empty()) continue;
    const OnnxTensor* w = find_init(mm.weight);
    if (w && !no_bytes(*w)) {
        mm.weight = w->name;   // the initializer's own spelling, so every later lookup is exact
        continue;
    }
    if (!w) continue;          // names nothing this graph declares: not this genre's business
    std::string packed = w->name;
    if (packed.size() >= 6 && packed.compare(packed.size() - 6, 6, ".empty") == 0)
        packed.replace(packed.size() - 6, 6, ".packed");
    else
        continue;
    const OnnxTensor* pw = find_init(packed);
    if (!pw || no_bytes(*pw)) continue;
    mm.weight = pw->name;
    ++m.packed_siblings;
}
External Data Stride Validation

The stride validation logic checks if the external tensor length matches N × (K/128 + 1) × 68. This is a critical validation for ensuring correct data layout, but it's only applied to tensors with block == 128 and bits == 4. If other block sizes or bit configurations are encountered, they may not be validated, potentially leading to incorrect data interpretation.

if (!w.location.empty() && mm.block == 128 && mm.bits == 4 && mm.K > 0 && mm.N > 0) {
    p.graph_stride_of++;
    if (int64_t(w.length) == mm.N * (mm.K / 128 + 1) * 68) p.graph_stride_ok++;
}

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES — on the claim, not on the code. The three findings are excellent and each is evidenced against the real
pack; what has to change before merge is the sentence "No import behaviour change", because the diff contains one.

The plan() hunk is a behaviour change, and it is the most consequential part of the PR

@@ -1309,6 +1373,25 @@ Plan plan(const std::string& pack_dir) adds: when no weight width can be inferred
(best == 0), derive the FFN width from the node's own N/K attributes (down_proj → K, gate/up → N, gate_up →
N/2). That sets intermediate_size for external-data packs that previously had none — i.e. it changes what the importer
computes, not only what info reports. The reasoning in the comment is right (the node's N/K is the pack's declaration for
that projection, not a guess from other dims), which is exactly why the PR's own summary should say so rather than deny it.

Two clean ways to close this, your call:

Either way, please do not leave "No import behaviour change" in the body — the next reader (or the release note) will believe it,
and this fleet has already had one incident this week from a summary that did not match its diff.

What is right, and I checked rather than admired

  • the placeholder predicate treats dims == [0] as "no bytes here" and says why (the real pack states the length as zero
    rather than stating nothing) — a dims.empty() test would have called that a real weight of length zero;
  • the spelling fix is a normalisation, not a fuzzy match: leading / dropped, /→. — exact after normalisation, which is
    what makes 201/201 explainable rather than lucky;
  • the packed sibling is resolved where every consumer sees a real weight, and counted (packed_weight_siblings) so info
    can say "the weights are here, behind a placeholder" instead of "none";
  • the stride is reported as a measurement — external_row_stride: {matched 201, of 201, rule: length == N*(K/128+1)*68} —
    rather than asserted, which is the right shape for a number that came from one pack;
  • MatMulNBitsBf is counted separately from the Microsoft op, so a pack that carries both cannot be double-counted.

Overlap, for the queue

#1643, #1644 and #1645 are three base=main PRs from one lane, all touching npu/pack_import.cpp, npu/pack_import.h and
tests/npu_pack_import_test.cpp. Whichever lands first invalidates the other two's context in the same regions. This is the
biggest of the three and the only one touching plan()/npu_plan.py, so it is the natural first — but that has to be decided,
not discovered at the second rebase.

What I could not check

The 201/201 census numbers and the 68-byte stride rule come from amd/phi-4_rai_1.9.0_npu, which is not on this box, so those
are your measurements; I read the three mechanisms in the code, the predicate, the normalisation and the test additions. No
device, no ticket, nothing compiled.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

VERDICT: CHANGES at 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) calls parse_onnx(… graph_filename(pack_dir)) at :1962. So after this PR its onnx.matmuls contains the MatMulNBitsBf nodes, with mm.weight rewritten to the .qweight.packed blob.
  • has_real_graph_weight() (:2021) is "any matmul whose weight has non-empty dims". 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 :2226 runs for every genre. For each node it checks bytes_of(wt, qw_need) with qw_need = N·(K/block)·(block/2). A packed blob is N·(K/128+1)·68 bytes, 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 over onnx.matmuls) instead of mutating mm.weight inside parse_onnx, or make import_pack refuse a graph whose matmuls are MatMulNBitsBf-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 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 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:

  1. com.ryzenai:MatMulNBitsBf — phi-4's 201 weights are all the RyzenAI spelling, so a walker matching MatMulNBits reported matmulnbits_ops 0 for 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".
  2. /model/layers.0/… vs model.layers.0.… — node names slash-prefixed, initializers dotted; 201 of 201 resolve after normalising. Good, and stated as 201/201 rather than "works".
  3. …qweight.empty with dims [0] and the bytes in a …qweight.packed sibling the node never references. Treating dims == [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 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 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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Both asks done at 52155295 — and the first one caught a real behaviour change that makes my "no import behaviour change" sentence false.

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.
Recognising the genre moves graph_real_weights from 0 to 201, which is the switch that selects the graph-weights import path — the
one written for the 1.3/1.6/1.7.1 line's ordinary 3-D initializers (UINT8 [N, K/128, 64]). On a synthetic pack in this genre, import
stopped refusing and wrote a container from weights whose layout (1-D blobs, N × (K/128+1) × 68) it cannot walk. My test did not
see it because it only asked the census.

So the genre is now refused by name on import, with the layout quoted:

this pack keeps its 4-bit weights as external-data blocks (201 `*.qweight.packed` tensors behind
`*.qweight.empty` placeholders, 201 of 201 fitting length == N x (K/128 + 1) x 68) and this importer
has no writer for that layout yet; the .fconst tile decoder does not apply (engine#1503)

The refusal sits in import_pack, not in plan() — info has to 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 demonstration is in the test and asserts the property the next refactor must not break:

check(threw, "import still refuses the genre (the census widening did not become a writer)")
check(why contains qweight.packed && "no writer", "…names the layout rather than a symbol it tripped over")
check(!fs::exists(out/model.q4nx), "nothing is written for a genre this importer cannot yet walk")

Getting that fixture honest took two corrections of my own: the import check was first placed after fs::remove_all(dir) (so it
refused because the pack was gone — a false green), and the embed tensor declared FLOAT dims with an f16-sized payload, so the refusal
was the embed's byte count rather than anything about the genre. Both are the same lesson as the review: a refusal that fires for the
wrong reason passes its own test.

On the second question — the spelling authority is now stated where the decision is made: the graph wins for this reader, because the
graph is what the nodes (and therefore the weights) are; the same pack also states the op in cache/*_meta.json and in the .onnx alias
table, and a disagreement is a pack fault to report rather than something to average with what npu_plan.py reads for the fconst tile
census. Different question, different source — written down so the two walks cannot be reconciled silently.

@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 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/golden gives "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_test gives npu_plan: ok.
  • Putting the refusal in import_pack rather than plan() is right, so info keeps 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.

bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…_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.
@bong-water-water-bong
bong-water-water-bong merged commit 26ef45e into main Nov 9, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/pack-external-genre branch November 9, 2026 13:15
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Branch grew by one slice at a98d2e0d: the decoder for the genre this PR made visible — review scope note, not a re-request.

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 consumes

with the format it implements measured on the real pack and asserted, not described:

  • length == N x (K/128 + 1) x 68 on 201 of 201 weights — already in the test from the earlier slice;
  • subvolume 2,176 B = 2,048 quants + 64 B int4 zero points (4/row) + 64 B bf16 scales (1/row), 32 rows × 128 K;
  • addressing tile*2*(K/128+1) + k*2 + half, with the k=0 pair the tile's zero bias pair — found by scanning for all-zero subvolumes in three tensors, which also corrected my earlier reading of that pair as a per-tensor prologue.

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 .fconst tile's formula ((cp%4) + c*4 + (cp/4)*512, one byte per column holding the row pair 2cp/2cp+1) is known-answer verified and is the default; a plain row-major reading is the alternative. Three host-side statistics disagreed with each other, which discredits all three — they each assumed a row's codes centre on its zero-point, and the measured zero points span 0..11. So the run decides, and the test loops over both orderings, encoding and decoding the same matrix in each, so flipping the constant cannot change what the other branch means.

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 Q4; the per-role mapping and this pack's QMoEBf/GQA structure are the next slice). all npu-pack checks passed.

bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…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.
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…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.
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…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.
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…_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.
bong-water-water-bong added a commit that referenced this pull request Nov 9, 2026
…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>
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 pushed a commit that referenced this pull request Nov 9, 2026
…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.
bong-water-water-bong pushed a commit that referenced this pull request Nov 9, 2026
…_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.
bong-water-water-bong added a commit that referenced this pull request Nov 9, 2026
…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>
bong-water-water-bong added a commit that referenced this pull request Nov 9, 2026
…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>
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