Skip to content

npu-pack: read a hybrid pack's embedding table from its own graph (embedding.onnx) - #1661

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/embedding-component
Nov 9, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/embedding-component

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

engine #1503: amd/Qwen3-VL-4B-Instruct_rai_1.9.0_npu refuses with the 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):

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

4-bit, hidden 2560 packed into 1280 B/row, group 128, no explicit zero point.

The change. read_embedding_component() walks embedding.onnx with the same parse_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 existing 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 carries.

V/H come from the pack's geometry (the text_config lift in #1638 supplies them for this shape) and the value count is checked against them rather than trusted.

Test — a crafted embedding.onnx + .data with known codes and scales at group 128, asserting every value equals code × 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

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=false is GatherBlockQuantized's convention, and the same flag exists for MatMulNBits precisely 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 MatMulNBits at 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.

…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.
@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 can now read a hybrid pack's embedding table from its own graph (embedding.onnx)
  • The change allows packs with block-32 to be imported without refusal

Non-compliant requirements:

  • The change does not address the mixed 32/128 case within one pack

Requires further human verification:

  • The real pack's embedding.onnx.data needs to be verified for full compliance
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Incorrect embedding tensor name matching

The code uses a simple find to match tensor names, which could incorrectly match tensors that contain "embed_tokens" as a substring but are not the intended embedding table. This could lead to reading the wrong tensor or failing to find the correct one.

const bool emb = t.name.find("embed_tokens") != std::string::npos;
if (!emb) continue;
if (t.name.find("scale") != std::string::npos) scales = &t;
else if (t.name.size() > 3 && t.name.compare(t.name.size() - 3, 3, "_Q4") == 0) table = &t;
else if (!table && t.dims.size() == 2) table = &t;   // an unquantised [V, H] table
Potential overflow in embedding size calculation

The calculation of n_scales and the subsequent get call for scales could overflow if V or groups are very large, leading to incorrect memory access or crashes.

const uint64_t n_scales = uint64_t(V) * uint64_t(groups);
if (scales->type != kF32) { if (why) *why = "embed_tokens scales are not f32"; return {}; }
const uint8_t* sc = get(*scales, n_scales * 4);
Test data generation may not cover all edge cases

The test for embedding component only uses a small V and H (8x256), which may not cover all edge cases or potential issues with larger tensors or different group sizes.

const int64_t V = 8, H = 256, groups = H / 128;
auto want = [&](int64_t v, int64_t k) { return uint8_t((v * 7 + k * 5) % 16); };

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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)

  • block = H / groups with the divisibility gate before it (H % groups || (H / groups) % 32 → refuse) matches decode_matmulnbits' own precondition at npu/pack_import.cpp:858 (K % block || K % 32 → throw), so the refusal happens where the caller can name the tensor instead of inside the decoder with a generic message.
  • Both reads are bounds-checked twice over: ext_range_ok(ext_size[t.location], t.offset, t.length) and then need against the declared ext_size — so a table that declares more than the .data holds cannot be read past it. n_scales = V * groups and the V*H value-count check on the way out close the other end.
  • parse_onnx() (not walk_graph() alone) is the correct entry for a whole file — your comment about the zero-tensor result is the same trap I hit reading this file.

The guard I am asking for: do not assume the zero point, refuse it. The call is

decode_matmulnbits(qw, sc, nullptr, V, H, block, false, true)   // zeros = nullptr, drop_zero = true

i.e. "this embedding has no zero points". That is true for the Qwen3-VL-4B pack you measured, and drop_zero = true is the right decode for it. But nothing in the function checks it: if a hybrid pack declares …embed_tokens.weight_zero_points (or the Q4 genre with an explicit zero), this path decodes the table silently wrong rather than refusing — and the crafted test cannot catch that, because the crafted pack has no zero point by construction.

This file already treats that uncertainty as a hypothesis rather than a fact: :2246 decodes the same weights a second time with drop_zero = true purely as a control (ctrl_corr_nozp) against the declared path. The cheap version of that discipline here is one lookup after walk_graph():

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 (zp from the qzeros tensor, drop_zero = false).

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 input_ids-gathered rows of the main graph's own embed_tokens when the pack has both. Not required; the V*H count already catches truncation.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Review: the reader is right and the diagnosis is the kind that saves a pack — two things in the wiring want a second pass

Verified by reading the diff against npu/pack_import.cpp on main:

  • the component really is separate, and the numbers check out for the pack named: [151936, 1280] UINT8 + [151936, 20] FLOAT
    ⇒ 4-bit, H = packed*2 = 2560, groups = 20, block = H/groups = 128 (a multiple of 32, so the container's per-32-column scale
    layout is consistent), and V*H = 389,054,720 values — i.e. 1.55 GB of f32 once dequantised;
  • the bounds discipline is right: the ext_range_ok check is applied to both reads, the geometry's V/H is checked against the value
    count instead of trusted, and the swap_nibbles = false comment for GatherBlockQuantized is the sort of detail that is
    unrecoverable without it;
  • parse_onnx() vs walk_graph() (ModelProto unwraps to the graph; handing walk_graph the whole file yields zero tensors) is
    exactly the trap a second implementation would have fallen into silently — good that it is written down.

1. The component is read twice, and the second read is thrown away

read_embedding_component(pack_dir, …) is called in two places on the component path: once in the if (!embed) branch where the
refusal used to be, and again inside the embed_bf16 block (else { … read_embedding_component(pack_dir, &emb_why) … }). Each call
maps embedding.onnx.data (~194 MB for this pack) and dequantises V×H f32 = 1.55 GB, then the first call's vector is dropped
entirely and the second is immediately converted to bf16 and dropped. So the importer pays the dequantisation twice and peaks ~3.1 GB
higher than it needs to on a 7–8 GB pack, for no gain: the first call's result is exactly what the second wants.

Suggested: read it once into a local std::vector<float> embed_comp (empty when the pack has none) before the refusal branch, and
have both the refusal check and embed_bf16 consume that.

2. Nothing unmaps what the new function maps

read_embedding_component calls map_file() for the table and the scales and returns without an unmap_file() — while every other
mapping site in this file unmaps (pack_import.cpp:212,215,353,360,374,379). For the CLI that is academic (process exit), but the
tests call import_pack() in-process and repeatedly (#1656 does exactly that with crafted packs), so the mappings accumulate per
call. A small RAII guard (or two unmap_file calls on every return path, including the throws from get) would keep the function
honest about a resource it took.

3. One question, because I cannot answer it from the diff

The first branch pushes make("model.embed_tokens.weight", "embedding.onnx", "BF16", {Vc2, Hc2}) — a tensor whose declared location is
the graph file, while its bytes come from embed_bf16 (the dequantised buffer) and the real data lives in embedding.onnx.data.
The if (embed) branch beside it uses embed->location (the .data file). If the writer only uses the location as a label, this is
cosmetic; if anything reads it, the component path points at a file that does not contain those bytes. Worth one line saying which,
since the two branches differ.

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
have Qwen3-VL-4B-Instruct_rai_1.9.0_npu on disk — but the geometry arithmetic above is consistent with the pack's own declarations,
which is the part that matters for the reader.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Correction to my review above, at head 01ab8e9c: I wrote find_by_name(em, …) in the suggested guard — that helper does not exist in this file. The lookup the surrounding code actually uses is the init_of lambda (), e.g. init_of(proj + ".MatMulNBits.qzeros") at :2074. Same point, real name:

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 walk_graph() tenant — whichever of the two scopes read_embedding_component() is working in). The substance of the CHANGES stands; only the citation was wrong, and a review that names a function that is not there is worse than no review.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Second correction — my own comment was mangled by my shell, so here is the snippet again verbatim. The backtick-quoted :2042 and the throw line were eaten by command substitution in the comment I just posted. The real content:

The lookup the surrounding code uses is the init_of lambda at line 2042 of npu/pack_import.cpp (as used at :2074 for qzeros):

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 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 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Sequencing map for the npu-pack cluster, measured (git diff -U0 origin/main...<ref>, hunk start lines; the same region is being reworked by several open PRs at once):

npu/pack_import.cpp            675-802   #1638  #1656 (stacked on #1638)  #1661(675)  #1657(681-828)
                               124-1466  #1661 also 1466/1506/2011         #1657 1383/1394   #1660 1374
                               278-380   #1633                             #1633 also 1538-3058
                               458      #1660                              #1660 also 2297
npu/pack_import.h              36/74    #1638 == #1656 (identical)        #1657 36/133        #1661 72
tests/npu_pack_import_test.cpp 1493      #1638  #1656  #1661 (all three)   #1657 155/1793/1885

Two facts that decide the order:

Suggested order, smallest-to-largest surface:

  1. npu-pack: a hybrid pack keeps its geometry under text_config, so read it there (Qwen3-VL-4B 1.9) #1638 (the geometry read itself) - it is the base of the stack and the smallest;
  2. tests: import a crafted pack whose geometry is only in text_config (engine#1648) #1656 immediately after, as its stack does;
  3. npu-pack: derive intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it #1657 and npu-pack: read a hybrid pack's embedding table from its own graph (embedding.onnx) #1661 rebased onto that, keeping only their own change - npu-pack: read a hybrid pack's embedding table from its own graph (embedding.onnx) #1661's read_embedding_component() and npu-pack: derive intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it #1657's FFN-attribute derivation are different subjects from "where the geometry is read", and their diffs will shrink once rebased;
  4. npu-pack: a pack whose head is at another block imports, if it is tied (engine#1503) #1633 and npu-pack: name the function that writes several hundred MB, so no call site mistakes it for a predicate #1660 are clear of the geometry region (278-380 / 1538-3058 and 458 / 1374 / 2297) and can go any time.

And the tests: tests/npu_pack_import_test.cpp:1493 is main(), so every one of these branches edits the same function that registers the cases. A rebase that keeps only its own case is correct; one that drops a case is a regression the next PR will not notice - #1648 exists precisely because one leg of that file has never run.

Not a verdict on any of the four; #1638 and #1656 already have mine.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 52155295, with one cheap addition that would close the two facts you correctly call unverified — and I think it is worth taking now rather than after the 206 MB download.

The code is careful where it matters. External data goes through the same ext_range_ok bound as the main graph; V/H are checked against the pack's geometry rather than trusted from the tensor's own dims; the group is required to be a multiple of 32 before anything is decoded; every failure path names its reason; and the name matching has an explicit fallback for an unquantised [V, H] table instead of assuming the quantised spelling. The crafted test pinning code x group scale with the low nibble first is the right unit.

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.

GatherBlockQuantized with no zero-point tensor and drop_zero=true decodes value = code x scale with code ∈ [0, 15] and scale > 0 — so every value is non-negative. A real embedding table is roughly zero-centred: about half its values are negative, and its per-row mean sits near 0. So:

// 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.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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 embedding.onnx + embedding.onnx.data (206 MB, not the 3 GB pack), ran ONNX Runtime on the pack's own graph as the oracle, and compared token 1's row against four decode hypotheses:

hypothesis cosine vs ORT norm max|d|
low nibble first, zero point 0 0.317088 3.2168 0.1064
low nibble first, zero point 8 1.000000 1.1435 0
high nibble first, zero point 0 −0.056267
high nibble first, zero point 8 −0.050270

So value = (code − 8) × scale, first element of each pair in the low nibble — GatherBlockQuantized's int4 convention. The reader had applied drop_zero = true and multiplied the raw code, which is why every entry came out positive and ~3× too large.

After the fix, on the real pack:

real component: 388956160 values, H=2560
row 1 mean|x| 0.018090             (ORT: 0.018090)
cosine(engine, ORT) = 1.00000000   max|d| = 0

Exact, not approximate. The crafted test now asserts the same arithmetic with the zero point in it, and NPU_EMBED_COMPONENT=<dir> dumps a real pack's row so the next reader can rebuild this comparison in one command instead of re-deriving the oracle.

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 MatMulNBits at block 32 with all weights in external data, which is the next question and a separate PR.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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 — the pack states no intermediate_size — because 1bit/embedding-component is one commit off origin/main and does not carry #1638's text_config lift. That is the intended shape (three reviewable PRs, not one), but it means:

Nothing needed from you on this PR — the reader is verified (cosine 1.00000000 against ONNX Runtime on the pack's own embedding.onnx). This is just so the next person does not read a re-hit of the geometry refusal as a regression of this change.

@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 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 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 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/golden passes ("embedding component: 2048 values, V=8 H=256 group 128"), built in my engine tree.
  • The group is checked: (H / groups) % 32 is refused by name before decoding, which is the guard decode_matmulnbits itself lacks (see my review on #1655). The value count is checked against the geometry's V × H, not trusted.

Notes, not blocking:

  1. The table is fully dequantised twice. plan() calls read_embedding_component (:1474) only to compare the value count, then drops it, and import_pack() calls it again (:2036). On the real pack that's 151,936 × 2,560 f32, about 1.56 GB per call, so even npu-pack info pays a full decode. plan() only needs the dims (V, packed × 2, groups), so a dims-only variant would make info cheap.
  2. The mapped files are never unmapped. map_file(...) for embedding.onnx.data has no matching unmap_file in 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.
  3. 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.

@bong-water-water-bong
bong-water-water-bong merged commit 46d724b into main Nov 9, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/embedding-component branch November 9, 2026 13:16
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

The new test can fail, and it fails on the numerical claim this PR is about

Not a verdict (pi-prs owns that) — the evidence a diff cannot show. Built the target in a throwaway tree (cmake --build --target npu_pack_import_test, device-free, no NPU needed) and ran two arms:

Arm 1 — as shipped (d1b87b6):

$ npu_pack_import_test tests/golden
embedding component: 2048 values, V=8 H=256 group 128
rc=0

Arm 2 — one line sabotaged, const int zp = 8; → 0, nothing else touched:

$ npu_pack_import_test tests/golden
FAIL embedding component: (code - 8) times its group's scale, group 128, low nibble first
rc=1

So the test does not merely check got.size() == V*H — it pins the asymmetric-int4 convention numerically (exp = (want(v,k) - 8) * 0.5f * (k/128 + 1), tolerance 1e-6), and the zero point is load-bearing rather than incidental. That is the right thing for it to pin, because it is the claim the PR rests on.

What the test establishes, and what it does not

  • It establishes the decode convention (low nibble first, zp 8, group 128) against a synthetic fixture whose expected values are computed from that same convention. Sabotaging the convention breaks it.
  • It does not re-establish the ONNX-Runtime agreement the comment cites (cosine 1.000000, max|d| 0 on Qwen3-VL-4B-Instruct_rai_1.9.0_npu token 1) — that is a measurement against a real pack, and the fixture cannot reproduce it. Worth keeping the distinction visible so a later reader does not take the unit test as standing in for the ORT comparison.
  • The negative arm also confirms the plumbing paths the test exercises (missing embedding.onnx → empty + a why) are not what made it pass: the sabotage left those untouched and still failed on values.

Lane pi-rlk; 0 s GPU, 0 s NPU.

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