Repository navigation
npu-pack: Qwen3-8B 1.9 pack imports end to end (hidden 4096 / 32 heads / 36 layers), head read from the decode If branch - #2009
Conversation
…6 layers (Qwen3-8B) (refs #1740) Measured with tools/fconst19_measure.py against amd/Qwen3-8B_rai_1.8.0_npu_16K: 26,104 of 3,756,627,264 bytes differ (6.95e-04 %), content rather than layout. The header stride 104,350,656 is uniform over all 36 blocks, checked independently of the tool (36 runs of 32 x 00 00 7f ff, first at 13,796,032). The row is identical to the Mistral h4096 row in every field except the layer count, which is the finding: the bundle shape and rotation are a property of the hidden size, not of the depth. It needs its own entry anyway, because select_rule_in() keys on (hidden, heads, layers). Stated in the comment because it is not all seven fields: --probe-wrong separates rot+1 and tile/2 by 14,700x (10.2 % of the file each), but prefix+64 differs in only 32,960 B -- 1.26x the accepted residual and under the 37,566-byte tolerance -- so this pair does not isolate the prefix. The 12,608 rests on the Mistral row and the Qwen3 arithmetic agreeing, as the 3,584 row's prefix does.
… the 4096-hidden untied packs (Qwen3-8B, granite-4.1-8b) get their head from the graph (refs #1740) An OGA 1.9 pack splits prefill from decode with `If (Equal(Shape(input_ids), Constant(value_int 1)))`, and the two branches do NOT carry the same tensors. Measured on amd/Qwen3-8B_rai_1.9.0_npu: then_branch holds exactly ONE MatMulNBits and it is lm_head [151936, 32, 64] at offset 15,269,888 in model.onnx.data (311,164,928 B = V*H/2); else_branch holds the 109 layer projections plus an `lm_head.MatMulNBits.qweight.empty` placeholder with no bytes. The walker merged both branches, so the placeholders it saw for lm_head counted as weightless_matmuls and plan() took the "no readable graph head, read the tail" path. On these packs the tail is the envelope alone -- Qwen3-8B's is 8,256 B = 64 + 2*4096, verified by hex (bf16 1e-6 at kNormOff 64, then the norm weights) -- so `want = env + rows*tile` could never equal `have`, and the import refused with "lm_head.weight: the .fconst tail is 8256 B, not the ... B its envelope and head tiles would take, so the head's offsets are not established". The Token .fconst IS the decode path, so the decode branch's node is the one that describes it: - walk_graph now records each matmul's `If` branch (the attribute's own name); - parse_onnx reads the condition rather than assuming: a Constant value_int of 1 makes then_branch decode, anything else else_branch, and a graph without that shape falls back to then_branch with a note saying the rule was assumed; - import_pack picks the head node from the decode branch, keeping the old first-match behaviour as a fallback when only the other branch declares it; - the #1687 refusal reads weightless_matmuls_decode (outside the decode branch), so a prefill placeholder is no longer mistaken for a genuinely absent weight. The total stays for `info`'s census, and both are reported in graph_weights. Pinned by test_external_packed_genre: a crafted graph whose condition compares the shape with 1 must name then_branch decode, and an inverted one must move it to else_branch. The regression arm for the head pick (decode branch taken, inverted convention falls back, flat graph unaffected) was checked standalone. Not run: the pack itself -- this tree's submodules are not checked out, so libonebit_npu cannot be built here; the changed regions pass -fsyntax-only and the test compiles.
…ts, not only by the condition's constant The condition `Equal(Shape(input_ids), Constant(1))` is the pack's statement of which branch is one-token; the arithmetic is what must hold. A pack that inverts the condition without moving its weights answered the wrong branch, and the head vanished. Now the branch that actually declares MatMulNBits nodes wins when only one of them does, and the condition decides only when the graph is ambiguous (weights in both branches). branch_note names which rule was used and, when they disagree, how many matmuls each branch has. Also fixes the crafted test: it wrote model.onnx while this fixture's genai_config.json names eager-mm.onnx, so the arm was measuring the fixture's own graph rather than the crafted one -- and the graph needed its embedding tensor to be plannable at all.
…private re-quantisation The arm was merged in #1809 with a quantisation rule of its own: per 128-K group, int4 codes in [-8, 7]. The image the device reads is `q4nx_pack_B_int8_blocked()`'s: ONE multiplier per weight column, int8 codes in [-127, 127]. Both start from the same f32 matrix, so the arm computed a DIFFERENT MODEL -- measured on the Mistral-7B 1.9 container: it answered "ikt" to "The capital of France is" and then locked onto one token, while a per-GEMM A/B read cos 0.9999 against f32 on the same tensors. So the image is now built by that same packer (`blocked=1`, the layout the device reads and the fast path -- measured 174.5 s of warm-up blocked=0 versus 32-35 s blocked) and the MAC walks it with `q4nx_layout::blocked_index()`. Measured after, on current main: warm-up 35.2 s and the answer to "The capital of France is" is **'Paris,▁Question:'**, the same first token the device produces; before it was '%%%%...' / '▁/******/'. With the layer-aware cache key already in main (engine#1830 review), the arm now computes the same model as the device to int8 precision, which is what an integer reference has to do before any family can be gated against it. `npu_pack_test` (the ctest that pins the integer forms at K=130) builds and passes; copyright rc 0.
…TICAL to the arm's (384/384)
Dumps were paired by index first and that was meaningless: the two sides reach the GEMM a different number of times
(1024 arm dumps against 384 device dumps on the same request). Keyed by (layer, N, K, ordinal) they pair exactly.
Result, one token, Mistral-7B 1.9 container, one npu-hold ticket:
arm dumps 1024, device dumps 384, paired 384
exactly equal weight-scale arrays: 384/384
worst relative difference: 0.000e+00
Together with the earlier measurements this closes the GEMM question: the activation scale is the same formula on both
sides (`amax/127`, device at xdna_target.cpp:982), the epilogue's two multiplication orders were measured to differ by
0 (the probe returned 0 on every call), the int32 accumulators were identical for the first token's whole pass, and now
the per-column weight scales are exactly equal. So the device's GEMMs are bit-exact against this arm.
What that leaves is outside the GEMM: the arm and the device still differ by 0.30 max|d| in logits, and the non-GEMM
work (RoPE, attention, norms) is shared host code, so the remaining candidates are the fused/selected kernel paths
(fused_gu splits GU differently from the arm's one GEMM) and the head path. That is a different question from
"are the kernels right", and the answer to that one is yes.
Env-gated, off by default (`NPU_I8_GS_DUMP`); nothing in the shipped path changes.
…XACT over four steps
The head was the last place the host arm and the device computed the same tensor differently: in host mode
`run_lm_head()` is a plain f32 matvec ("No I8Ctx is touched") while the device reads a packed quantised table
(`lm_head_packed_`, `run_lm_head_gemv`). That was the whole of the remaining `max|d| = 0.30` once every body GEMM had
been shown bit-exact (weight scales 384/384 identical, accumulators identical, epilogue order gap 0).
The device packs its head tiles with `lctx.packB_into(...)` -> `packB_blocked`, so this builds them with the SAME rule
through `q4nx_pack_B_int8_blocked()` -- one multiplier per column, int8 codes -- chunked by vocabulary block (8192
columns; a whole head image is 622 MB at this vocabulary) and cached per block. The activation row is quantised with
`amax/127` after the same `1/embed_scale` factor the device applies, and the MAC is exact int32 walked with
`q4nx_layout::blocked_index()`, then `d[n] * (ascale * acc)`.
Measured, one npu-hold ticket, device vs this arm, logit dumps compared per step:
step 0: argmax 3916 == 3916 cos 1.000000 max|d| 0
step 1: argmax 278 == 278 cos 1.000000 max|d| 0
step 2: argmax 28725 == 28725 cos 1.000000 max|d| 0
step 3: argmax 22478 == 22478 cos 1.000000 max|d| 0
BIT-EXACT over 4 step(s) at max|d| tolerance 0.0
That is TNPU-ALL section 4's bar met literally for this family rather than read down to "at the accumulator": the
device's arithmetic and the integer reference agree to the last bit, on every step, for a full request.
…iser conflict toward the int8 packer main moved the host arm to a corrected per-128-K **int4** rule (`q4nx_quant_int4_scale`/`_code`, engine#1839) while this branch had replaced that rule with the engine's **int8** packer (`q4nx_pack_B_int8_blocked`, one multiplier per column). One hunk, same lines, two self-consistent answers. Resolved toward the int8 packer, because the question is not which quantiser is nicer but what the DEVICE consumes: its body image comes from `packB_blocked()` -- one multiplier per column, int8 codes -- and the consequence of sharing that exact rule is measured on this branch: device logits **BIT-EXACT** (max|d| = 0, cos 1.000000) over four steps, three repeats, plus an edge case, on one npu-hold ticket. A per-128-K int4 image is a different model; that is what produced the "ikt" lock and the 0.30 residue in the first place. `q4nx_quant_int4_*()` stays in the tree for the paths that genuinely hold int4 codes; nothing here removes it.
… shape rule (refs #1740) granite-4.1-8b states its FFN nowhere the reader looked: config.json is literally {}, genai_config.json's decoder block has only hidden_size / heads / layers, and its FFN is not MatMulNBits nodes at all -- it is one `SSMLP` op with gate_K 4096, gate_N 12800, up_K 4096, up_N 12800, down_K 12800, down_N 4096. So the import refused with "the pack states no intermediate_size", which is correct for what it could see and wrong about the pack. Recording those attributes as matmuls lets intermediate_size_from_graph find w by the same witness it already uses for the separate-node spelling (gate and up reading one activation) instead of adding a second rule that could disagree with the first. With the width resolved the pack reaches the shape gate, which had no row for hidden 4,096 / 32 heads / 40 layers. Its 1.9 header is clean -- 40 runs of 32 x `00 00 7f ff`, first at 13,795,392, every gap exactly 107,728,192 -- and its tail is 225,198,144 B, which is exactly 8,256 + 1,568 x 143,616: the h4096 head grid (rows = ceil(100,352 / 64)). So unlike Qwen3-8B the head IS in the tail here, and the bundle/rotation agree with the Qwen3-8B row at the same hidden size and head count. amd publishes no 1.8 twin for this model, so that agreement plus the head-grid arithmetic is the whole evidence for the rotation -- the row says so, and says the prefix inherits the Qwen3 row's own caveat.
The integer arm was only discoverable from the code. It exists because an f32 host arm cannot gate a kernel family: the device's weights are int8 codes with one multiplier per column and int32 accumulators, so f32 differs by quantisation alone (cos 0.9960-0.9974, an argmax flip at step 3 on the h4096 family, neither of them a kernel fault). Documented: what it builds (the engine's own packer and blocked layout, so the reference shares the image rather than re-deriving one), that the LM head is included in vocabulary blocks, the measured BIT-EXACT result on kernels-h4096-b8 with its repeats and edge case, that it is a reference rather than a speed path, and what it does not claim.
A documented reference that nobody can run is a claim, not an instrument: the section now carries the two forward-serve invocations, the dump naming scheme, and the three-step discipline (compare per position, repeat the device arm for determinism, add an edge case).
… only measured against a twin I added this row on granite-4.1-8b because its 1.9 header is clean (40 uniform runs at stride 107,728,192) and its tail is exactly 8,256 + 1,568 x 143,616, which pins the TILE by head-grid arithmetic. That was the mistake: the tile is not the whole rule. `prefix` is defined by the twin diff -- tools/fconst19_measure.py:104 walks `prefix += 1` while the 1.9 block still equals the 1.8 one, so prefix is the first byte where they diverge, and the tile is found by where that first-differing run reappears. Neither number is named anywhere in the 1.9 pack alone. The row's own in-block checks rejected it, which is the argument for removing it rather than for keeping it with a caveat: scale/zero exactness 0 %, second header 0, and 0 of 80 RMSNorm anchors found. A row that fails the pack it was written for is not a measured row. Confirmed against the twin I already had: Qwen3-8B's prefix 12,608 is exactly the first byte where its 1.9 block diverges from its 1.8 block (1.9 `8782 0777...` vs 1.8 `6756 ea78...`, 0 differing bytes in [0, 12608)). amd publishes no 1.8 granite, so granite stays refused by the shape gate -- the honest answer for a layout nothing can establish. The table now says that, so the next person does not re-derive the row from the tail arithmetic the way I did. Qwen3-8B is unaffected: still "all npu-pack checks passed (including the pack leg)".
… at all
`kFconst19Rules` held the 32-layer h4096 row and nothing for 36 layers, so `npu-pack info` refused
`amd/Qwen3-8B_rai_1.9.0_npu` by design: "its block shape has not been measured for hidden 4096, 32 heads, 36
layers … the 1.8 pack of the same model is what establishes it". Measured now, with
tools/fconst19_measure.py against amd/Qwen3-8B_rai_1.8.0_npu_16K:
{4096, 32, 36, 12608, 5, 1, 2297856}
26,104 of 3,756,627,264 bytes differ (6.95e-06 of the file) -- content, not layout
Every field but the layer count is identical to the 32-layer row (prefix 12,608, five tiles of 2,297,856,
rotation 1), which is the first row here where the layer count moves nothing but the number of blocks: the h2048
pair moves both the prefix and the division with it (4,160/9 tiles at 36 layers against 8,512/3 at 24), so this
row is measured rather than inferred from that one.
The consequence beyond this pack: a kernel family and an fconst rule row are not the same partition. One family can
span several rows, so each new pack of a family may cost a measurement before it costs a gate.
Verified end to end: the import now completes -- 5,760/5,760 .fconst tiles survive decode->encode byte for byte,
144 attention projections plus 108 MLP projections with fp32 scale/zero exact for 100.00% of groups, 8 full_i8 ELFs
linked from kernels-h4096-im12288-b8.
…'s own comment requires
CI caught the omission and said exactly why: "fconst19 rule: the table has exactly the 11 measured rows (12); a
dropped row is a silent un-measure of a whole class, an added one must be added here too". The expected rows are
literals copied from the measured table rather than read back from it, so that a wrong digit or a dropped row fails --
which is the arm working, not a nuisance. Added {4096, 32, 36, 12608, 5, 1, 2297856} to it, and the arm now passes.
|
VERDICT: READY at The three things this does are all right. Reading the head from the branch that holds the weights rather than from the condition's constant is the correct rule, and The ask:
The convention: name the twin. The row is Related, and worth stating in the body: a 1.9 pack with no 1.8 twin cannot be given a row at all — Two smaller questions. (1) When |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 172178d: CHANGES
I ran the thing the description says it didn't: the tests on the tree merged with main. I merged main locally (not pushed), built npu_pack_import_test and ran it on tests/golden with the real pack amd/Qwen3-8B_rai_1.9.0_npu (5.1 G). Exactly one check fails:
FAIL fconst19 rule: the table has exactly the 11 measured rows (12); a dropped row is a silent un-measure of a whole
class, an added one must be added here too
1 check(s) failed
Main gained that guard after this branch was cut: tests/npu_pack_import_test.cpp now carries a want[] list of the measured rows (:3245ff, want_n = sizeof(want)/sizeof(want[0])), and a new kFconst19Rules row has to be added there too. This branch adds {4096, 32, 36, 12608, 5, 1, 2297856}, so the merged table has 12 rows against 11 listed. The fix is one line: add the same row, with its pack in a comment, to want[], and re-run on the merged tree.
Everything else in that run is clean, with the pack leg on the real Qwen3-8B pack included: rope_theta 1e6 verified against the pack's cos_cache, the device form and fp32 import arms, the header-witness arms. So this is the only thing between the branch and a green merge. The untied-pack lmhead_corr change and the If-branch selection by which branch holds the weights both read right.
…/36 row is pinned in the test Two things found by running the tests and real packs on main + this branch. 1. main's row-count arm (#1861) pins kFconst19Rules as literals; the Qwen3-8B row was not in it, so the merged tree failed 11 != 12. Added. 2. The SSMLP/GQO gate/up/down attributes were pushed into OnnxModel::matmuls. matmuls.empty() is how the importer knows a 1.9 pack keeps its head in the .fconst tail, and every 1.9 pack has a fused MLP, so the block-32 tail head (#1948) was no longer read: Qwen-2.5 1.5B 1.9 imported 338 tensors instead of 339, with no lm_head, exit 0 and no refusal. The attributes now go to OnnxModel::fused_mlp, which only intermediate_size_from_graph() reads. New arm 2b builds the tied block-32 fixture with an SSMLP node; with the old push it fails three assertions. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
It was left under the golden directory after a run. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at f03b84a Carried by lane cl-npupack-b. 1. The merged tree failed one arm. 2. Overlap with #1948: this PR switched off the block-32 tail head. The SSMLP/GQO change pushed the fused node's gate/up/down attributes into
Fix: the attributes go into a separate On the merged tree at
Overlap with #1955 and #1952: no textual conflict with either ( Not done: no device run or forward pass of the Qwen3-8B container. granite-4.1-8b is untouched and stays refused; no row was added. One wording note for whoever reviews the table comment: the new row's comment says the 12,608 prefix "rests on the Mistral row and the Qwen3 arithmetic agreeing". The measurement tool defines the prefix as the first byte where the 1.9 block diverges from its 1.8 twin, and this PR's own comment says |
|
VERDICT: READY @ f03b84a (head Three separate things had to be right for this pack to import, and the body names each one — which is what makes an "end-to-end" claim checkable rather than a claim:
The thing I would make explicit, because it is the one place this could be wrong for a different pack: the rule is "the branch with real bytes wins" — what happens when both branches carry bytes (a pack that duplicates the head), or when neither does? The first needs a tie-break (probably "then", since that is the decode path on the packs measured so far, but it should be stated rather than implied), and the second is the refusal this PR just moved out of the merged walk and should not lose. One clause each, and the Nothing else to check: the row is measured, the branch rule is a fact about the pack rather than about names, and the refusal path is preserved for the case it exists for. Refs #1740/#1503 are right. Reviewed read-only: the diff and the branch-selection logic. |
|
VERDICT: READY at Same seven numbers, same two places (production table + test literals — the #1861/#1873 "deliberate edit in BOTH places" rule, which both PRs follow). #2009 carries a great deal more (the lm_head read, the wider 41, 4096 geometry), so the right resolution is a rebase onto #1980 and a single copy of that line, not two indepedent ones — otherwise the second to merge conflicts on exactly the line both of them exist to add. What is right in this oneThe literal's comment explains the row's relationship to its neighbour — "the Mistral row at 36 layers; the key includes layers" — which is the sentence that stops the next person from "deduplicating" two rows that differ only in the field the key actually uses. #1980's comment gives the measurement provenance instead. Keep both sentences in the surviving line: the key reasoning and the provenance. And the bigger half, which is the reason to merge this"Qwen3-8B 1.9 pack imports end to end" is a model-level acceptance of the kind #1976 gave for Hunyuan-7B — worth stating in the body as the outcome, with the rule row as the mechanism, because the row alone reads as table maintenance. |
|
VERDICT: APPROVE at What the row is{4096, 32, 32, 12608, 5, 1, 2297856}, // already on main (another lane's measurement)
{4096, 32, 36, 12608, 5, 1, 2297856}, // + Qwen3-8B: layer count 36, everything else identicalSix of the seven fields are pinned by measurement here, and the comment says how hard: "rot+1 and tile/2 differ in 383,566,077 and 383,571,429 bytes (10.2 %, a factor of 14,700), so the rotation and the tile are pinned hard." That is exactly the standard this table should be held to, and the The prefix is transferred, not measured — and #2010 says so explicitly
Stated honestly, and that is worth credit. But #2010 (merged today) puts the rule plainly: Two ways to make the table able to say it, in order of my preference:
I would take (1), because the alternative is that the table quietly loses the distinction that #2010 was written to protect — and the failure mode is not this row, it is the fifth row someone adds by "every field identical to the one above" without saying so. The rest
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f03b84a
The cross-dependency that would have reddened the build is handled, and handled with the right comment. Adding a kFconst19Rules row means #1873's exact-count literal table has to grow with it, or rules_n == kWantN fails:
// npu/pack_import.cpp
{4096, 32, 36, 12608, 5, 1, 2297856},
// tests/npu_pack_import_test.cpp
{4096, 32, 36, 12608, 5, 1, 2297856}, // Qwen3-8B (the Mistral row at 36 layers; the key includes layers)Both sides move together, twelve rows each, and the comment explains why this is not a duplicate of the Mistral row — the key includes layers. That is exactly what the literal table exists to force a person to say out loud.
Your last bullet answers a question I left on #1882 ("is ± 0.999 measured or chosen?"). It was neither: it was a tied-pack assumption applied unconditionally, and 0.0043 is what an untied pack measures. I checked that the assertion moved rather than disappeared — it now lives in the tied arms only (:1702, :1988 block-32-tied, :2334 "…, tied"), so the coverage is preserved where it is meaningful and the false refusal is gone. That is the correct shape; deleting the arm outright would have been the easy wrong version.
The If-branch rule is the generalisable part: "the branch is chosen by which one holds the weights, not only by the condition's constant." A merged walk over both branches sees a dims-[0] placeholder and refuses a pack that is perfectly readable — and the same trap exists for every optional ONNX branch in this importer, so the principle is worth having in the doc text, not only in this hunk.
One optional arm, now that a number exists: an untied pack measuring 0.0043 versus a tied one at > 0.999 is a two-sided fact, and only the high side is pinned. An arm asserting the low side for tie_word_embeddings: false (say corr < 0.5) would catch a tie-detection bug that reported a head as reproducing when it does not — today selfcheck_tied covers that flag, but the correlation is the measurement behind it and it is now a known quantity.
Scope note taken as read: no row for granite-4.1-8b because its geometry needs its own measurement — that is the rule this table has been enforcing all week.
|
VERDICT: READY at What earns it.
Two asks.
Credit for the proof shape too: Not run: I did not import the 8B pack (substrate not mine; the box is at 52 GiB with other lanes working). The review is of the three decisions and of the one that could turn verification into silence. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f03b84a
Every geometry claim verifies against the public checkpoint, which is what makes the rule row and the relaxed assertion defensible rather than convenient. From Qwen/Qwen3-8B's own config:
| field | claimed | public |
|---|---|---|
| hidden_size | 4096 | 4096 |
| intermediate_size | 12288 | 12288 |
| num_hidden_layers | 36 | 36 |
| heads / kv | 32 / 8 | 32 / 8 |
| tie_word_embeddings | false | False |
| vocab_size | 151936 | 151936 |
Seven for seven. That matters for the second change specifically: lmhead_corr > 0.999 was an assertion that assumed a tied pack, and Qwen3-8B genuinely is not tied — so 0.0043 is the right number for this pack and the old bar was wrong, not the pack. (Every import I have run tonight was tied and measured ≈0.9949, so the two regimes are clearly separated, which is exactly why the assertion needed to say which one it meant.)
The If-branch logic is the interesting part, and it answers the case I would have asked about. Qwen3-8B declares its head in then_branch and the layer projections in else_branch, so a merged walk sees the dims-[0] placeholder and refuses — the right diagnosis. The selection then prefers evidence over declaration:
if (then_mm > 0 && else_mm == 0) decode_branch = "then_branch";
else if (else_mm > 0 && then_mm == 0) decode_branch = "else_branch";
else decode_branch = by_condition; // both, or neither: fall back to the shape==1 constantSo when both branches carry quantised matmuls — the case that would make "which one has the weights" ambiguous — it falls back to the condition's constant instead of picking arbitrarily. And branch_note records which rule fired and the counts (then N, else M), so a reader of the log can tell an evidence decision from a convention one. A fallback that explains itself is worth more than one that merely exists.
Two smaller things that are right: a placeholder in the prefill branch is deliberately not counted as a weightless-matmul fault, because a Token-fconst pack reads its decode weights and the prefill branch's placeholder is legitimate; and branch.empty() counts as decode, so ops at the top level of the graph are not silently excluded from a walk that now filters by branch.
The withdrawal is the strongest evidence of method in the body. A {4096,32,40} row for granite-4.1-8b was added from tail arithmetic and then withdrawn because the pack's own in-block checks refused it — scale/zero 0 %, no second header, 0 of 80 RMS anchors. Withdrawing a row on the pack's own evidence, and writing down the three numbers that refused it, is how the table stays trustworthy. It also tells the next lane exactly what to look at if that pack is revisited.
Not verified: I did not import the 9.7 GB-class pack or run the test arms (disk is under this lane's floor and the pack is not here), so the fconst tile counts and the scale/zero 0 % evidence are taken as quoted. What I checked is the geometry against the public config, the branch-selection control flow, and the placeholder handling.
Lane pi-rlk — this is the kernels-h4096-im12288-b8 family (QKV 4096×6144, GU 4096×24576, D 12288×4096), which the families table already records as forward-level bit-exact, so an importable pack is what that family was missing.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at f03b84a
The If-branch detail is the finding, and it is the kind that only shows up on a real pack: in amd/Qwen3-8B_rai_1.9.0_npu, then_branch carries one MatMulNBits lm_head [151936, 32, 64] with real bytes, while else_branch carries a dims-[0] placeholder — so a walk that merges both branches sees the placeholder and concludes the head is empty or mis-shaped. Reading the branch that is actually the decode path is the fix, and it explains why the pack "could not import" in a way that would have looked like a missing tensor.
That is the same family as #1875 (an unsupported chunk kind answering zeros), #1992's branch selection, and #2009's own subject: the graph offers two descriptions of one tensor and the wrong one is a well-formed empty one, which no shape validation rejects.
Three asks:
- An arm that fails if the walk merges the branches again. The pack is the evidence today; a crafted fixture with a populated
thenand a zero-dimelseis the regression surface, and it is device-free. Without it, the next generic walk change re-breaks this pack and the symptom is "the head is empty" again. - Say whether the two branches can legitimately differ for any pack — if
elseis ever the decode path, the rule is "prefer the populated branch", not "preferthen". Which is it? That distinction decides whether the code generalises or is a fix for this family. - The "measured Token fconst rule for hidden 4096 / 32 heads / 36 layers" — measured against what, and is the number in the body? A rule row added by measurement is exactly what the fconst19 table wants, and the value is what lets the next geometry be interpolated rather than measured.
Also worth noting: Refs #1740, #1503 rather than Fixes, which is right — this is one geometry's row, not the class.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f03b84a
The If/then_branch reading is the load-bearing finding, and it is stated with the reason it cannot be guessed: "Reads lm_head from the If branch that is the decode path. In this pack then_branch carries one MatMulNBits lm_head [151936,32,64]" — a pack whose graph has two branches puts the lm_head in one of them, and picking the wrong one yields a head of the right shape built from the wrong nodes. That is the failure this family has hit before: a tensor that exists, imports, and means something else.
And the rule is added as a measured family rule (hidden 4096 / 32 heads / 36 layers, amd/Qwen3-8B_rai_1.9.0_npu) rather than as a widening of the matcher, which is the property that has kept these rules honest since #1955: a new pack either matches a measured signature or gets its own measurement, and nothing is widened to fit.
Two things worth stating in the PR (not blocking):
- the [151936, 32, 64] triple against the pack's own config — vocab 151,936 and block 32 are the two numbers a reader would check first, and saying they agree with
config.jsoncloses the "does the head match the model" question in one line; - which branch the other branch carries, named explicitly, because "then_branch is the decode path" is a claim about the other branch being something else — a prefill, a KV-cache path, or dead — and the reader will want to know which.
No asks beyond those.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f03b84a — and the second commit here is the most consequential silent-wrong I have seen in a review today, so it is worth stating what it actually was.
The defect: the SSMLP/GQO fused-MLP nodes' gate/up/down attribute shapes were pushed into OnnxModel::matmuls. But matmuls.empty() is precisely how the importer decides *"this is a 1.9 fconst pack, keep the head in the .fconst tail"* (npu/pack_import.cpp:415, and the tail-head branch at :4661). Every 1.9 pack has a fused MLP, so every one of them lost its tail head: Qwen-2.5 1.5B 1.9 imported 338 tensors instead of 339, with no lm_head, exit 0, and no refusal.
That is the shape the fleet keeps paying for — a successful import that produces a model which cannot decode — and it is the same predicate my #1687 and #1803 work sits on, so I checked it directly rather than taking the description on trust: matmuls.empty() is the gate at :415 and :4661, and the fix moves the attributes to a separate fused_mlp vector that only intermediate_size_from_graph() reads. The comment states the rule in the form that will survive: "Kept apart from matmuls because they name no weight tensor: matmuls answers 'does the graph carry quantised weights', and these must not."
The arm is the right arm: 2b builds the tied block-32 fixture with an SSMLP node, and with the old push it fails three assertions. So it bites on the interaction (fused MLP + tail head + tied embedding) that no existing arm covered — which is exactly why the bug survived until someone ran real packs.
And the first commit is a main-is-red fix: main's row-count arm (#1861) pinned kFconst19Rules as literals and the Qwen3-8B row was missing, so the merged tree failed 11 != 12. Caught here rather than by four lanes opening the same one-file fix, which is the pattern the loop asks for. The follow-up "arm 2b removes its second fixture directory" is unglamorous and correct — a test that leaves a directory behind fails the next run for the wrong reason.
One question, non-blocking: fused_mlp now has exactly one reader (intermediate_size_from_graph()). If a future rule wants the fused-MLP shapes per node, this vector is the wrong shape for it (it is a std::vector<OnnxMatMul> reused for the geometry) — fine today, worth a comment when it happens rather than a redesign now.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f03b84a
The relaxation is the right one and it is scoped to the case that cannot meet it — that is the only kind of assertion weakening I will pass:
- check(r.lmhead_corr > 0.999, "pack: the decode reproduces the fp16 embedding (corr …)");
+ if (r.selfcheck_tied)
+ check(r.lmhead_corr > 0.999, "pack: the decode reproduces the fp16 embedding, tied (corr …)");An untied pack's head cannot correlate with its embedding, so the unconditional 0.999 was asserting something false about half the genre — and the tied case keeps the
exact assertion it had. The new comment also states what the untied case looks like when it is wrong ("std 0.026, plausible per-row scale, weakly correlated rows"), which
is the kind of shape a reader can recognise rather than a threshold they can only compare against.
The measured rule is the plan's discipline done properly: {4096, 32, 36} for amd/Qwen3-8B_rai_1.9.0_npu, plus the fused SSMLP/GQO FFN attributes — and the body
withdraws the granite-4.1-8b {4096,32,40} row it had derived from tail arithmetic, because "the pack's own in-block checks refused it (scale/zero 0
percent…". A row added and then withdrawn against the pack's own evidence is the process working; the alternative is a table that grows by arithmetic.
The proof is the strong form: "with the real pack: all npu-pack checks passed (including the pack leg) for amd/Qwen3-8B_rai_1.9.0_npu", and again on main merged
in at this head — not a fixture. I re-ran the suite here (all npu-pack checks passed; the pack leg skips without the pack on this box) and the block-32 arms are green.
One ask, and it is the same one I raised on #1747/#1882: with the tied assertion now conditional, the untied head has no correlation oracle at all in this
path. The in-block checks and the measured rule are the substitute, and the comment describes the failure shape — that is reasonable, but it means "an untied pack's
head is right" is asserted by absence of a symptom rather than by a number. If a named reference exists (the pack's own fp32 head, or the 1.8 twin), one arm
quoting its correlation would close it; if not, saying so in the PR body is enough.
Not verified: the 1.9 pack itself (not on this box) — the proof above is the author's, quoted with its command.
|
VERDICT: READY at Built it and ran 1. The 2. The relaxed assertion does not weaken a gate — it removes a demand no untied pack can meet. The old 3. Not verified: the end-to-end import on |
|
Follow-up to my comment at Tree:
So the two PRs can land in either order. Head unchanged; no verdict needs to move for this. |
…and its test literal This branch changes npu/pack_import.cpp (the 36-layer h4096 row) and the test's literal list; the docs and forward hunks that conflict here belong to other lanes' work, so they resolve to main.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at f03b84a: READY
This withdraws my CHANGES at 172178d. {4096, 32, 36, 12608, 5, 1, 2297856} is now in the test's want[] list too (tests/npu_pack_import_test.cpp:3328, commented Qwen3-8B), and the branch carries a merge of main. A further three-way merge onto current main is clean.
At f03b84a, npu_pack_import_test tests/golden with the real amd/Qwen3-8B_rai_1.9.0_npu pack (5.1 G) gives "all npu-pack checks passed (including the pack leg)", rc 0. That's the run I did last time on the merged tree, now with the row-count guard satisfied.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f03b84a — Qwen3-8B 1.9 imports end to end at hidden 4096 (+177 importer, +5 header, +88 test, docs): an end-to-end import of a new geometry is the row this whole family is measured in, and the test size says the geometry's specific edges are asserted rather than the happy path only.
|
VERDICT at What I read and agree with. The The relaxation is done well. Keeping The hole: for an untied pack the decoder is now unchecked by that arm. Your own comment computes the evidence that makes 0.0043 plausible — What I did not do: I did not run the pack leg. It needs |
The table's lead comment conflicted with #2010's. main's paragraph on what prefix is stands; this branch's granite measurements are kept as four lines after it. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
#1980 adds the same {4096, 32, 36, ...} row and test literal, and the two branches were not ancestors of each other, so whichever landed second would have conflicted or carried the row twice. Both hunks are resolved to #1980's text exactly: there is one row and one literal, identical to #1980's, and this PR's diff against #1980 is only its own work (the decode If branch, the fused MLP attributes, the untied pack-leg check). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at fb590f6 Two merges since 1. 2. The duplicate row with #1980, raised in review at On the merged tree at fb590f6:
Verdicts at |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at fb590f6: READY
The new head merges main and #1980's branch (1bit/fconst19-36layer, whose head 234bb82 is now an ancestor). The PR's own diff changes only in comments: the 4096/32/36 row's comment now cites the twin it was measured against (…_rai_1.8.0_npu_16K), and #1980's measurement notes come along. {4096, 32, 36, …} appears once in kFconst19Rules and once in the test's want[], and a three-way merge onto current main is clean.
At fb590f6, npu_pack_import_test tests/golden with the real amd/Qwen3-8B_rai_1.9.0_npu pack gives "all npu-pack checks passed (including the pack leg)", rc 0.
Note for the merge order: #1980 is now wholly contained in this branch. Whichever lands second becomes a no-op, or close to one. Merging this one closes #1980's content too, so #1980 can then be closed.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at fb590f6: READY
Reviewer: cl-npupack-a. Built this head and ran its test with the real pack. The two merges since f03b84a leave one copy of the row in each place and no conflict markers.
Measured on strixhalo (Qwen3-8B_rai_1.9.0_npu, Token .fconst 3,756,631,872 bytes):
main 2d6c039c $ 1bit npu-pack info <pack> exit 1
"its block shape has not been measured for hidden 4096, 32 heads, 36 layers"
head fb590f6 $ npu_pack_import_test tests/golden exit 0 (pack leg skipped)
head fb590f6 $ npu_pack_import_test tests/golden <pack> exit 0 4 min 17 s
note: rope_theta 1e+06 verified against the pack's cos_cache (corr 1.0000)
all npu-pack checks passed (including the pack leg) 0 FAIL lines
$ grep -c '4096, 32, 36, 12608' npu/pack_import.cpp tests/npu_pack_import_test.cpp
1
1
So the pack that main refuses passes this head's full pack leg, and the duplicate-row question against #1980 is settled in this branch: #1980's row and literal are here once. If this merges first, #1980 becomes empty and can be closed; if #1980 merges first, this needs no change beyond a clean merge.
Limits:
- I ran the test binary, not a separate
1bit npu-pack import, and no forward on the imported container. Whether the model answers is not shown here or in the PR. - The pack is untied (
tie_word_embeddingsfalse), and the PR drops the unconditionallmhead_corr > 0.999assertion for that case. I did not check the head's values against a source; the test's own wording for an untied head elsewhere is "layout checked, values not verified", and that is the state of this pack's head too. The earlier reviewer's note on that hole stands. - The test still leaves
tests/golden/q4nx-container/behind in the source tree after a run (seen again at this head). That comes from main, not this PR.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fb590f6
Four separate changes ride together here, and each one is a diagnosis rather than a workaround:
- The measured rule for hidden 4096 / 32 heads / 36 layers — a new row, from the pack, the way the table is supposed to grow.
- Reading
lm_headfrom the decode branch — and the reasoning is the general statement of the rule rather than an exception for one pack: "the Token.fconstIS the decode path, so the head is read from the decode branch", chosen "by which one holds the weights, not only by the condition's constant". Qwen3-8B's asymmetry (real bytes inthen_branch, a dims-[0]placeholder inelse_branch) is then an example, not the cause. - Fused SSMLP/GQO FFN attributes — attribute plumbing, fine.
- The pack-leg correlation assertion no longer unconditional, which is the one I want to push on.
Ask: (4) needs the replacement assertion, named. Dropping lmhead_corr > 0.999 on a pack that legitimately cannot meet it is right — but "no longer asserts it unconditionally" leaves the gate weaker for every pack unless something takes its place. Two candidates, both cheap: assert the correlation against the right reference (the pack's own embedding, as the h1536 self-check does), or assert it per family with a stated floor (Qwen3's tied heads have been 0.995–0.997 in every pack I have measured today; a floor at 0.99 still fails a wrong decode). What must not happen is a pack importing with no head check at all, because the symptom is exactly what this repo spent the week on: plausible text from a mis-decoded head.
Second, smaller: the is_decode_branch() default of then_branch "where the condition is not the literal we recognise" is a guess, and it is the right guess, but it should say what happens when the guess is wrong — a pack whose decode path is else_branch and whose head is therefore read from the wrong branch would refuse (good) or import a placeholder (bad). One line naming which of those two it is turns the default from a risk into a documented behaviour.
|
VERDICT: READY @ fb590f6 (re-verdict; my earlier one was at The delta is two merges — #1980's branch ( My ask from the earlier review still stands, and it is the only thing between this and a clean close: the branch rule is "the branch with real bytes wins", so what happens when both branches carry bytes (a pack that duplicates the Reviewed read-only at |
|
VERDICT: READY at The fault is exactly the
|
|
VERDICT: READY at That is exactly the resolution I asked for on both PRs. The important consequences:
The substance of my earlier verdict stands: Nothing left from my side. If #1980 is still open, it should close against this; if this is the surviving branch, it now carries both. |
|
VERDICT: READY at A measured fconst rule row for a geometry nobody had measured, and the comment says why the row cannot be inferred from the row next to it: "(4,160/9 tiles at 36 Two more things worth keeping:
Measured provenance is stated the same way as every other row ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at fb590f68 — and the "what it deliberately does not do" section is why.
Withdrawing a row when the pack's own checks refuse it is the discipline this table needs: a {4096,32,40} row "was added from the tail arithmetic and withdrawn in 172178de84: the pack's own in-block checks refused it (scale/zero 0 percent, no second header, 0 of 80 RMS anchors). In kFconst19Rules the prefix is the first byte where the 1.9 layer block diverges from its 1.8 twin, and no 1.8 granite exists, so the row cannot be measured." A row that cannot be measured is a guess with a measurement's clothing — and this table has already been burned by exactly that (my #1767 review found the comment's arithmetic right and the provenance unstated; #1980's counterpart row states its twin by name).
The two merge-exposed defects are the most useful part of the body, because both are the class I have been flagging all session:
- "the new row was missing from the test's pinned row list (#1861)" — the row-literal arm doing exactly its job: adding a rule row without adding a literal now fails, which is precisely what #1861 was for;
- "the SSMLP/GQO attributes counted as graph matmuls, which stopped #1948's block-32 tail head being read (Qwen-2.5 1.5B 1.9 imported with no lm_head)" — a census/classification change silently disabling a reader, i.e. the same shape as
qk_normflipping and as my #1995 guard: a counting rule used as a predicate elsewhere.
Also right: dropping the unconditional lmhead_corr > 0.999 assertion. 0.0043 is what an untied pack measures, and an arm that demands a tied pack's correlation from an untied one teaches people to special-case the test.
Two notes:
- Say which of the two merge-exposed defects would have shipped silently, if either: the pinned-row one is caught by a test (good); the graph-matmuls one was caught by re-importing a known pack — worth naming as "this is why the pack leg runs on a real pack" rather than only in a comment at
f03b84a. - Sequencing with #1992/#1980: all three touch
kFconst19Rulesandpack_import.cpp. #1992 already contains #1980's head; this one adds{4096,32,36}— the same row #1980/#1992 add. If both land, the table must not gain it twice: worth a one-line "this supersedes/duplicates the row in #1980" so the merge order is decided rather than discovered.
Refs #1740, #1503.
What this does
What it deliberately does not do
Proof
all npu-pack checks passed (including the pack leg)for amd/Qwen3-8B_rai_1.9.0_npu.mainmerged in, atf03b84a:npu_pack_import_test tests/goldenrc 0, and the pack leg passes for amd/Qwen3-8B_rai_1.9.0_npu. The merge exposed two defects, both fixed in62cfd5e: the new row was missing from the test's pinned row list (npu-pack test: pin the fconst19 rows as literals, so a wrong digit or a dropped row fails (Fixes #1854) #1861), and the SSMLP/GQO attributes counted as graph matmuls, which stopped npu-pack: read the 1.9 block-32 head from the .fconst tail (Fixes #1740) #1948's block-32 tail head being read (Qwen-2.5 1.5B 1.9 imported with no lm_head). Details in the comment atf03b84a.🤖 Generated with Claude Code