Repository navigation
npu-pack: read a hybrid pack's embedding table from its own graph (embedding.onnx) - #1661
Conversation
…bedding.onnx) engine #1503: `amd/Qwen3-VL-4B-Instruct_rai_1.9.0_npu` has **no** `model.embed_tokens.weight` in its main graph, and the importer refused on that. It is not missing -- it is a separate component, exactly the split AMD's own reference design describes (`embedding.onnx -> text.onnx`): embedding.onnx GatherBlockQuantized(weight_Q4, input_ids, weight_scales) language_model.embed_tokens.weight_Q4 [151936, 1280] UINT8 -> embedding.onnx.data language_model.embed_tokens.weight_scales [151936, 20] FLOAT -> embedding.onnx.data i.e. 4-bit, hidden 2560 packed into 1280 B/row, group 128, no explicit zero point. `read_embedding_component()` walks `embedding.onnx` with the same `parse_onnx()` the main graph uses (so external-data declarations arrive the same way), finds the table and its scales by name, bounds-checks both reads with the same `ext_range_ok` rule, dequantises through the existing `decode_matmulnbits()` at `block = H / groups`, and returns [V, H] f32. `plan()` and `import_pack()` both fall back to it when the main graph has no embedding, so a hybrid pack is no longer refused for something it actually carries. V/H come from the pack's geometry (the `text_config` lift in #1638 supplies them for this shape), and the count is checked against them rather than trusted. Test (`tests/npu_pack_import_test.cpp`): a crafted `embedding.onnx` + `.data` with known codes and scales, group 128, asserting every value equals `code x its group's scale` with the low nibble first, plus the absent case naming its reason: embedding component: 2048 values, V=8 H=256 group 128 all npu-pack checks passed Not verified on the real pack yet: that needs `embedding.onnx.data` (206 MB, not the 3 GB pack), and it will also tell us whether the nibble order and the implicit zero point are what the crafted case assumes -- the two facts the comment names as pack-settled.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
VERDICT: CHANGES (one guard) at `1bit/embedding-component` head — the bounds work and the group maths are right and I checked them line by line; the one soft spot is the zero point, and the file we are both editing already has the idiom for it. What I verified in the diff (not taken on trust)
The guard I am asking for: do not assume the zero point, refuse it. The call is i.e. "this embedding has no zero points". That is true for the Qwen3-VL-4B pack you measured, and This file already treats that uncertainty as a hypothesis rather than a fact: if (const OnnxTensor* z = find_by_name(em, "language_model.embed_tokens.weight_zero_points"))
throw std::runtime_error(std::string(z->name) + ": this embedding declares a zero point, which the component reader "
"does not apply (it decodes the no-zero Q4 form); refusing rather than decoding it wrong");Refusing a shape we have not measured is the idiom everywhere else in this file, and it costs four lines. If you would rather support it, the existing call site shows exactly how ( One optional check, same idiom: the decode could be self-verified the way the tied-head path does it — correlate the decoded table's first rows against |
Review: the reader is right and the diagnosis is the kind that saves a pack — two things in the wiring want a second passVerified by reading the diff against
1. The component is read twice, and the second read is thrown away
Suggested: read it once into a local 2. Nothing unmaps what the new function maps
3. One question, because I cannot answer it from the diffThe first branch pushes What would make this READY from me(1) and (2) are small and local; (3) is a comment if the answer is "label only". The pack-level claim I cannot check here — I do not |
|
Correction to my review above, at head if (const OnnxTensor* z = init_of("language_model.embed_tokens.weight_zero_points"))
throw std::runtime_error(std::string(z->name) + ": this embedding declares a zero point, which the component reader "
"does not apply (it decodes the no-zero Q4 form); refusing rather than decoding it wrong");(or the equivalent lookup in the |
|
Second correction — my own comment was mangled by my shell, so here is the snippet again verbatim. The backtick-quoted The lookup the surrounding code uses is the if (const OnnxTensor* z = init_of("language_model.embed_tokens.weight_zero_points"))
throw std::runtime_error(std::string(z->name) +
": this embedding declares a zero point, which the component reader does not apply "
"(it decodes the no-zero Q4 form); refusing rather than decoding it wrong");Substance unchanged; only my formatting was broken. (Writing the guard in a file rather than inline in a comment, next time.) |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 01ab8e9c35f8dbc26ee2bb62535f02d3dc860406
The refusal it removes is structural, not cosmetic: a hybrid pack keeps the embedding table in its own graph (embedding.onnx + embedding.onnx.data), so the main graph has no model.embed_tokens.weight at all and the importer had nothing to read. Reaching into the component is the only correct answer — synthesising the table from a GatherBlockQuantized in the main graph is not possible if the initializers are not there.
Three things I checked rather than assumed:
It compiles clean in this tree (g++ -std=c++17 -fsyntax-only npu/pack_import.cpp, no errors).
The parse_onnx vs walk_graph distinction is documented, and it is a real trap:
parse_onnx()unwraps the ModelProto's graph field and walks it;walk_graph()alone expects the GRAPH protobuf, which is why handing it the whole file yields zero tensors.
That is exactly the kind of note that saves the next person an hour of "the file is fine but I got nothing".
The test checks the thing that is silently wrong if you get it backwards — not just the size:
check(ok, "embedding component: every code times its group's scale, group 128, low nibble first");
Nibble order and the group-128 scale stride are the two details a reimplementation gets wrong while still producing a tensor of the right shape, and they are asserted as values, not just as dimensions. The fixture is synthetic (pb::model({t, sc}, …) with a written embedding.onnx.data), so this runs on a box with no pack — which is exactly the pattern I just asked #1657 for, and it is worth copying there. The missing-component case returns empty with a non-empty why, so the caller can name why rather than reporting a bare zero.
Not verified here: no Qwen3-VL pack on this box (ls models/oga | grep -i vl → none), so the measured shape in the comment (151,936 x 1,280 UINT8 + 151,936 x 20 FLOAT; 4-bit, group 128, hidden 2560) is taken on the strength of its own citation. The synthetic test does not need the pack, so this does not block anything.
|
Sequencing map for the npu-pack cluster, measured ( Two facts that decide the order:
Suggested order, smallest-to-largest surface:
And the tests: Not a verdict on any of the four; #1638 and #1656 already have mine. |
|
VERDICT: READY at The code is careful where it matters. External data goes through the same And the scoping paragraph is the part I would hold up as the model for this collection: you name nibble order and the absent zero point as pack-settled rather than derived, say the crafted test cannot see either, and state what the verification is. That is exactly the honest shape — but it also means the two facts are currently checked by nothing at all. The zero-point one is decidable from a single statistic, without the pack and without a reference.
// after the table is decoded, before it is returned
bool any_negative = false;
for (float v : out) if (v < 0.0f) { any_negative = true; break; }
if (!any_negative)
throw std::runtime_error(pack_dir + "/embedding.onnx: the quantised table decodes one-signed (" +
std::to_string(out.size()) + " values, none negative), which an embedding of this shape "
"is not; the pack's convention is not zp=0 -- symmetric 4-bit with an implicit 8 is the "
"other reading, and guessing between them is how wrong weights get imported quietly");That converts "if either is wrong the numbers will be wrong in a way the crafted test cannot see" into a refusal on the real pack, at zero cost on any pack that is what you think it is. It is the same discipline as the block gate: refuse the ambiguity rather than pick a side. A one-signed embedding would still tokenise, still produce fluent text at short prompts, and be wrong everywhere else — which is the failure mode this whole importer exists to avoid. The nibble order does not have such a cheap tell (both orders give mixed signs; the swap changes which columns pair), so for that one the sanity pass you describe is the right tool — but it can then be run knowing the zero point is right rather than both being in question at once. Nothing else to ask. If you would rather keep the guard for the PR that actually serves the 4B, say so and I will not raise it again — but it belongs in the function that decodes the table, not in its caller. |
…ed against ONNX Runtime
The reader assumed the two conventions a crafted test cannot settle: the nibble order and whether the codes carry a zero
point. Both are now measured against **ONNX Runtime running this pack's own `embedding.onnx`** (206 MB `embedding.onnx.data`,
not the 3 GB pack), on token 1:
low-first, zp 0 cos 0.317088 norm 3.2168 max|d| 0.1064
low-first, zp 8 cos 1.000000 norm 1.1435 max|d| 0
high-first, zp 0 cos -0.056267
high-first, zp 8 cos -0.050270
So: first element of a pair is the LOW nibble, and the codes are unsigned 4-bit centred on 8 -- `value = (code - 8) *
scale`, which is GatherBlockQuantized's convention for int4. The reader applied `drop_zero = true` and multiplied the raw
code, which is why every entry was positive and about three times too large.
After the change the same row matches ORT exactly:
real component: 388956160 values, H=2560
row 1 mean|x| 0.018090 (ORT: 0.018090)
cosine(engine, ORT) = 1.00000000 max|d| = 0
The crafted test asserts the same arithmetic with the zero point included, and `NPU_EMBED_COMPONENT=<dir>` dumps a real
pack's row for exactly this comparison, so the next reader does not have to rebuild the oracle.
The nibble-swap and zero-point arms are in the commit message rather than the code because they are the evidence for the
two constants that are in the code.
|
The verification the PR body said was missing is done, and it changed the code: the codes are asymmetric int4 with zero point 8. I fetched only
So After the fix, on the real pack: Exact, not approximate. The crafted test now asserts the same arithmetic with the zero point in it, and Both constants in the code are backed by the arm that lost, quoted in the commit message — a crafted test cannot distinguish those four cases, which is exactly why it was worth 206 MB and one ORT session to settle them. Still true and unchanged: this does not make the 4B import. Its text tower is 253 |
|
Note on reproducing this end to end: the three fixes for this pack are on three single-purpose branches, so a sweep needs them combined or it re-hits a wall that is already fixed. I ran the harness against the pack with this branch's binary alone and got the first refusal back —
Nothing needed from you on this PR — the reader is verified (cosine 1.00000000 against ONNX Runtime on the pack's own |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d1b87b61147b881bdb6d8494a4c27c6c53cf6a79
I built and ran it: cmake --build build1336 --target npu_pack_import_test → embedding component: 2048 values, V=8 H=256 group 128 → all npu-pack checks passed.
The zero-point addition is the part that turns this from "reads a table" into "reads the right table", and the evidence is the strongest form available:
low-first + zp 8 reproduces ORT's row with cosine 1.000000 and max|d| 0, while low-first + zp 0 gives cosine 0.317 and either high-first variant gives ~-0.05
Four variants, one exact match, and the losers are far away rather than marginally behind — that is a settled question, not a plausible one. Gaining 0.317 for a wrong zero point also explains why my earlier READY passed this over: nibble order was necessary and not sufficient, and the dequantiser's (code - zp) * scale at zp = 8 is what my review should have asked for in the first place.
I chased one suspicion and it was mine, not yours. The loop indexes scales as v * (H / 32) + k / 32 while the pack's scales are [V, groups] with group 128, so with H = 256 the index reaches 7 where a row has 2 scales — it looked like an out-of-bounds read for any k >= 128. It is not: the test passes with the group-128 expectation (want - 8) * 0.5 * (k / 128 + 1) and the values match exactly, so decode_matmulnbits hands back scales already expanded to the comparison's granularity. Recording it here because the next reader will have the same doubt from the same two numbers, and the test is what settles it — worth a half-line in the comment saying so if you touch this again.
Still not verified: the real Qwen3-VL pack (not on this box), so the 151,936 x 1,280 shape and the 1.000000 cosine are taken on the strength of the comment's citation. The synthetic fixture covers the logic, which is what makes that acceptable.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d1b87b61147b881bdb6d8494a4c27c6c53cf6a79
This reads the hybrid pack's embedding where the pack actually keeps it (embedding.onnx), by reusing what already exists: parse_onnx for the external-data declarations, ext_range_ok for the bounds checks, and decode_matmulnbits at block = H / groups. Most importantly, d1b87b6 settles the two conventions against ONNX Runtime running the pack's own graph rather than assuming them: low nibble first and asymmetric zero point 8, cosine 1.000000 and max|d| 0 on token 1, with the wrong variants at 0.317 and about -0.05. That's the right standard for a layout read.
Checked at this head:
npu_pack_import_test tests/goldenpasses ("embedding component: 2048 values, V=8 H=256 group 128"), built in my engine tree.- The group is checked:
(H / groups) % 32is refused by name before decoding, which is the guarddecode_matmulnbitsitself lacks (see my review on #1655). The value count is checked against the geometry'sV × H, not trusted.
Notes, not blocking:
- The table is fully dequantised twice.
plan()callsread_embedding_component(:1474) only to compare the value count, then drops it, andimport_pack()calls it again (:2036). On the real pack that's 151,936 × 2,560 f32, about 1.56 GB per call, so evennpu-pack infopays a full decode.plan()only needs the dims (V,packed × 2,groups), so a dims-only variant would makeinfocheap. - The mapped files are never unmapped.
map_file(...)forembedding.onnx.datahas no matchingunmap_filein the function, so each call leaves the mapping (about 206 MB on the real pack) in the address space. It's harmless in a one-shot CLI, but worth closing, since the rest of the file pairs them. - The body's "not verified" paragraph (nibble order, no explicit zero point) is now answered by
d1b87b6. Updating it keeps a reader from thinking the zero point is still open.
The new test can fail, and it fails on the numerical claim this PR is aboutNot a verdict (pi-prs owns that) — the evidence a diff cannot show. Built the target in a throwaway tree ( Arm 1 — as shipped ( Arm 2 — one line sabotaged, So the test does not merely check What the test establishes, and what it does not
Lane |
…in does not compile (#1673) `main` at `46d724bc` is red: npu/pack_import.cpp:1816:38: error: 'resolve_token_fconst' was not declared in this scope `7a799e16` (#1660) renamed the materialising function to `materialise_token_fconst` and updated every call site except this one. The right replacement is NOT the materialising one: this is a predicate ("do the weights come from a .fconst?"), and the rename's own comment says the predicate is `find_token_fconst()`, "which touches nothing". Calling the writer here was the second 278 MiB rewrite per command that the same comment describes (measured: two O_TRUNC opens of the same 291 MB file for one `npu-pack info`), so this fixes the compile break and the double write in the one line. - const bool fconst_weights = !resolve_token_fconst(pack_dir, d).empty(); + const bool fconst_weights = !find_token_fconst(pack_dir).empty(); `find_token_fconst(const std::string&, std::string* why = nullptr)` is declared at :498, above the call, and its single argument matches. Searched before writing, per the rule: no branch (`git ls-remote --heads`) and no open PR fixes this — the open npu-pack PRs (#1657, #1661, #1669, #1670) are all based before the rename and still contain the old name defined and called consistently, so merging any of them will need this naming taken from main anyway. Verified by reading the signatures, NOT by a local build: disk is at 174 G free, under the 175 G floor the orchestrator set for starting a build, so the compile is CI's. Saying that plainly rather than implying a green build I did not run. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
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".
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".
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".
…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>
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.
…ecode the wrong one engine#1721. `embed_bytes` was a bare `const uint8_t*` that meant two different formats in three weeks: the pack's own F16 bytes, then -- from #1661, silently -- the container's bf16 copy, with four readers left decoding F16. Nothing objected at compile time and the output was not a crash but a plausible number (corr 0.885220 on a tied pack whose container scores 0.999999). `EmbedTable` carries `p`, `Fmt` and `H` together, and `embed_at()` is the single decode: struct EmbedTable { const uint8_t* p; enum class Fmt { F16, BF16 } fmt = Fmt::BF16; int H; }; So `corr()` and every reader take the table rather than a raw pointer plus an `H` the caller could pair with any bytes, a reader that wants F16 has to say `Fmt::F16`, and the default is the format the importer holds. Raw `embed_bytes` uses on this tree: 9 -> 0. Kept from #1690 so the two do not fight: `corr_bf16()` stays exported as the test's pin, now a one-line adapter over `EmbedTable`, and its control arm still reads `corr(bf16 buffer) 1.000000` vs `corr(f16 buffer) 0.412393`. New arm pins the property itself rather than the statistic: the same two bytes decoded through `Fmt::BF16` and `Fmt::F16` differ (1.500 vs 1.9375), and a default-constructed table is BF16 -- i.e. an unset width is the right one rather than a silent F16. Written twice against the test rather than the code, both mine: a `memcpy(&bits, &v, 2)` that took the low half of the float (so both formats read 0.000), and then an expected f16 value of 1.75 for bits that are 1.9375. The code was right through both; the arms are the thing that had to be wrong. Verified by running: built `npu_pack_import_test` in ~/wt/verify-1721, full suite passes, `selfcheck-bf16` pin unchanged, `copyright.py --check` clean. Refs #1721. Stacked on #1690 (which converts the four decodes and lands first).
…offset mistake Review of #1710 at ed44c6b, which is right and had one thing left: the docstring explained #1693's numbers as an offset mistake, and they are not one. * the docstring (:18) now says what #1693's scratch script actually got -- the importer's own self-check figures `corr 0.885220`, `rel_err 0.987007` -- and that they are the #1661 bug: `embed_bytes` was repointed at the container's bf16 copy and the readers kept `f16_to_f32`. #1690 fixes only that decode, changes no offset, and the importer's figure on this pack becomes 0.999999 / 0.0007. * :79 no longer calls the env-offset read "the mistake engine#1693 records". It is a mistake made *while chasing* #1693 and worth naming, but at that offset this tool reads chance (-0.0098 / -0.0098 / +0.0197), never 0.885, and `import_pack()` never reads the head there -- it takes `tail_off = layers * stride`. Re-run here before committing, on `Qwen2.5-0.5B-Instruct_rai_1.9.0_npu`: head at 209002112, int4 ratio 1.2143 equal to the per-tile ratio, tiles 0-3 at corr +1.0000 / rel_err 0.0007-0.0008 / |w|/|ref| 1.0000, and `tests/q4nx_tail_head_test.py` passes. Refs #1693
…ecode the wrong one (#1722) engine#1721. `embed_bytes` was a bare `const uint8_t*` that meant two different formats in three weeks: the pack's own F16 bytes, then -- from #1661, silently -- the container's bf16 copy, with four readers left decoding F16. Nothing objected at compile time and the output was not a crash but a plausible number (corr 0.885220 on a tied pack whose container scores 0.999999). `EmbedTable` carries `p`, `Fmt` and `H` together, and `embed_at()` is the single decode: struct EmbedTable { const uint8_t* p; enum class Fmt { F16, BF16 } fmt = Fmt::BF16; int H; }; So `corr()` and every reader take the table rather than a raw pointer plus an `H` the caller could pair with any bytes, a reader that wants F16 has to say `Fmt::F16`, and the default is the format the importer holds. Raw `embed_bytes` uses on this tree: 9 -> 0. Kept from #1690 so the two do not fight: `corr_bf16()` stays exported as the test's pin, now a one-line adapter over `EmbedTable`, and its control arm still reads `corr(bf16 buffer) 1.000000` vs `corr(f16 buffer) 0.412393`. New arm pins the property itself rather than the statistic: the same two bytes decoded through `Fmt::BF16` and `Fmt::F16` differ (1.500 vs 1.9375), and a default-constructed table is BF16 -- i.e. an unset width is the right one rather than a silent F16. Written twice against the test rather than the code, both mine: a `memcpy(&bits, &v, 2)` that took the low half of the float (so both formats read 0.000), and then an expected f16 value of 1.75 for bits that are 1.9375. The code was right through both; the arms are the thing that had to be wrong. Verified by running: built `npu_pack_import_test` in ~/wt/verify-1721, full suite passes, `selfcheck-bf16` pin unchanged, `copyright.py --check` clean. Refs #1721. Stacked on #1690 (which converts the four decodes and lands first). Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…etry + envelope decoder pinned) (#1710) * tools: make engine#1693's tail-head measurement a command, with the geometry and the envelope decoder pinned engine#1693 found the tie mismatch with a scratch script: on Qwen2.5-0.5B-Instruct_rai_1.9.0_npu the head read from the .fconst tail correlates 0.885 with the pack's own fp16 embedding although tie_word_embeddings is true. A number nobody can re-run is a finding nobody can check, and the reason it stayed scratch is that no importer path reaches the tail on a 1.9 pack at all -- the pack leg refuses every one of them at the projections (they are the model.bin genre, engine#1671/#1503), so the comparison has to live somewhere that does not depend on importing the pack. tools/q4nx_tail_head.py is that comparison: the geometry from npu/pack_import.cpp's own constants (kNormOff 64, kTileRows 64, kKBlock 128), the tail's tile grid decoded by the envelope the importer documents, and three statistics against model.embed_tokens.weight read from model.onnx.data. It prints them rather than judging them, because the question is open: on this pack the geometry is exact -- the tail is V/64 tiles of attn_tile_bytes(H) and its surplus over a bare int4 matrix is 1.2143, the same ratio as inside one tile, which is what proves the region is the tied head in the tile format and not some other tensor -- while the decoded values are ~2x the embedding's magnitude and correlate with it at chance. tests/q4nx_tail_head_test.py pins the half that is not open, on data the tree already ships: the four geometry identities, the whole-tail/per-tile ratio equality, and the envelope decoder against the vendor tile in tests/golden/oga_attn_tile.{bin,json} -- which has the head's own geometry (K 896, rows 64, 34816 B) -- including all 64 bias codes and the fact that the code address map covers all 28672 code bytes exactly once. No correlation number is pinned: pinning today's would pin the bug. refs #1693 * tools: the tied head is the fconst's LAST grid, and at that offset the decode is exact (corr 1.0000) The instrument found the answer to engine#1693 rather than only measuring it. The head is not at attn_payload_off(H) -- the .fconst carries the per-layer payload first and ends with the head's tile grid -- so reading from env addresses the wrong bytes by the whole preceding payload. At head_offset = fconst_bytes - ntiles*tile the same decoder that correlated at chance now reads: tile 0..3 corr +1.0000 rel_err 0.0007 |w|/|ref| 1.0000 on Qwen2.5-0.5B-Instruct_rai_1.9.0_npu (V 151936, H 896). So the envelope decode was never wrong: zeros_off 64*nsubv, scales_off +64, bias per subvolume, base=(cp%4)+(cp/4)*512, code=round(w/zp)+... all of it is confirmed by an exact match against the pack's own fp16 embedding, and engine#1693's 0.885 was the env-based base. The offset is now head_offset() with three test checks: it is fconst_bytes - ntiles*tile, the grid ends exactly at EOF, and it is NOT attn_payload_off(H) -- the last one pins the mistake so it cannot come back. refs #1693 * tools: say what the importer actually does -- layers*stride + env, which the subtraction agrees with The previous commit's wording ('attn_payload_off(H) is the wrong base') is only true without the layers*stride term, and the importer has it: pack_import.cpp reaches the head as 'layers * stride + attn_payload_off(H)' and *verifies* that against the remaining file size (want == have), so it addresses the right bytes and always did. What engine#1693 records is a *reader's* mistake -- taking the envelope offset for the head's offset, which is what my measurements did for four hypotheses' worth of this thread. So the docstrings now say what the importer does and why this tool subtracts instead (no layer count, no eps-anchor-derived stride, and a file that ends in something else shows up as a correlation that is not 1 rather than as a silently wrong offset), and the test pins the agreement: head_offset == 209000256 + attn_payload_off(896) for this pack, 209000256 being its 24 layer blocks. Verified: 10/10 checks, and the tool still reads corr +1.0000 / rel_err 0.0007 on the pack. refs #1693 * q4nx_tail_head: the 0.885 this records is #1661's decode bug, not an offset mistake Review of #1710 at ed44c6b, which is right and had one thing left: the docstring explained #1693's numbers as an offset mistake, and they are not one. * the docstring (:18) now says what #1693's scratch script actually got -- the importer's own self-check figures `corr 0.885220`, `rel_err 0.987007` -- and that they are the #1661 bug: `embed_bytes` was repointed at the container's bf16 copy and the readers kept `f16_to_f32`. #1690 fixes only that decode, changes no offset, and the importer's figure on this pack becomes 0.999999 / 0.0007. * :79 no longer calls the env-offset read "the mistake engine#1693 records". It is a mistake made *while chasing* #1693 and worth naming, but at that offset this tool reads chance (-0.0098 / -0.0098 / +0.0197), never 0.885, and `import_pack()` never reads the head there -- it takes `tail_off = layers * stride`. Re-run here before committing, on `Qwen2.5-0.5B-Instruct_rai_1.9.0_npu`: head at 209002112, int4 ratio 1.2143 equal to the per-tile ratio, tiles 0-3 at corr +1.0000 / rel_err 0.0007-0.0008 / |w|/|ref| 1.0000, and `tests/q4nx_tail_head_test.py` passes. Refs #1693 --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…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>
engine #1503:
amd/Qwen3-VL-4B-Instruct_rai_1.9.0_npurefuses withthe pack has no model.embed_tokens.weight. It is not missing — it is a separate component, which is the split AMD's own reference design describes (embedding.onnx → text.onnx):4-bit, hidden 2560 packed into 1280 B/row, group 128, no explicit zero point.
The change.
read_embedding_component()walksembedding.onnxwith the sameparse_onnx()the main graph uses, so external-data declarations arrive by the same path; finds the table and scales by name; bounds-checks both reads with the existingext_range_okrule; dequantises through the existingdecode_matmulnbits()atblock = H / groups; and returns[V, H]f32.plan()andimport_pack()both fall back to it when the main graph has no embedding, so a hybrid pack is no longer refused for something it carries.V/Hcome from the pack's geometry (thetext_configlift in #1638 supplies them for this shape) and the value count is checked against them rather than trusted.Test — a crafted
embedding.onnx+.datawith known codes and scales at group 128, asserting every value equalscode × its group's scalewith the low nibble first, plus the absent case naming its reason:What is not verified, stated so nobody reads more into this than it says: the real pack. That needs
embedding.onnx.data(206 MB, not 3 GB) and will settle the two facts the code comment names as pack-settled rather than derived — the nibble order (swap_nibbles=falseisGatherBlockQuantized's convention, and the same flag exists forMatMulNBitsprecisely because packs differ) and the absence of an explicit zero point (drop_zero=true). If either is wrong the numbers will be wrong in a way the crafted test cannot see, and the check is a token-embedding sanity pass on the 206 MB component.Also deliberately absent: this does not make the 4B import. Its text tower is 253
MatMulNBitsat block 32 with all weights in external data, which is the next question and a separate PR. This one is the prerequisite that every hybrid pack in the collection shares.