Skip to content

npu-pack: read the 1.9 block-32 head from the .fconst tail (Fixes #1740) - #1948

Merged
bong-water-water-bong merged 3 commits into
mainfrom
1bit/npupack-b-1740-block32-head
Nov 10, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
1bit/npupack-b-1740-block32-head

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1740.

What was open

The three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at block_size 128 and /lm_head/MatMul_Q4 at block_size 32. The block-128 tile arithmetic never lands on their tail (#1740: "carves exactly, decodes to nothing"), so main does not read the head at all: a tied pack gets a head synthesised from the embedding (#1633) and an untied one is refused. The thread ended at "the format is declared, the offset space is unidentified".

Reproduction on main's path

Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu (tied, V 151,936, H 1,536), with this branch's reader switched off (if (false && p.head_block == 32), which is main's behaviour on this path):

$ 1bit npu-pack import ~/scratch-packs/pk-q1519 -o out
note: this pack's head is block 32 along K, which this importer cannot walk; the pack is tied, so the head was taken from the embedding (engine#1503)
  self-check: the head reproduces the pack's own embedding (as imported, bf16)  corr 0.996901  rel_err 0.000000

That correlation is the synthesis measured against its own source. The pack's head bytes are never read.

The layout, and how it was measured

After the tail's 64 + 2*hidden envelope the head is stored in 64-row units of 2,560 + 2*(K/32)*640 bytes (64,000 B at K 1,536; 2,374 units; then 110,208,000 B of zeros to the end of the file):

field bytes meaning
unit header 2,560 zero
strip s (640 B, 2*(K/32) per unit) K block s/2, rows 32*(s%2) .. +31 of the unit
codes 512 4-bit, low nibble first; position p is row (p/256)*8 + p%8, column (p/8)%32
zero points 16 one nibble per row (0x8 everywhere on this pack)
gap 48 zero
scales 64 32 signed bf16, one per row

w = (code - zero_point) * scale.

Each step was measured against the pack's own model.embed_tokens (host only, no device):

  1. Scales, without assuming any ordering. The 3,072 bf16 values of a unit have the same distribution as max|w|/8 over the 32-column blocks of the same 64 embedding rows (10/50/90 % quantiles 0.00567627 / 0.00750732 / 0.0098877 on both sides).
  2. Scale slot to (row, K block). scale == bf16(-m/8), m being the block's largest-magnitude weight with its sign, matches for 100 % of slots in units 5, 100 and 2000 (99.97 % in unit 6); the slot order row = i + 32*(s%2), block = s/2 then holds for 100 %.
  3. Code position. Recomputing every code from the embedding and voting each of the 1,024 positions of a strip over 384 strips gives a bijection with mean agreement 0.999 (minimum 0.987).
  4. Whole head. 200 units spread over the vocabulary (12,800 rows): per-row correlation minimum 0.990046, mean 0.996046.

About half the scales are negative. A decode that drops the sign averages to zero correlation, which is consistent with the earlier attempts on the thread that read this field as "values near ±1".

A second pack at a different K agrees. SmolLM2-135M-Instruct_rai_1.9.0_npu (tied, V 49,152, K 576, every op at block 32): the same rule gives a unit of 2,560 + 2*18*640 = 25,600 B, its tail after the envelope is exactly 768 x 25,600 B, and the same decode (host script, decode32.py) over 100 units spread over the vocabulary (6,400 rows) correlates minimum 0.999998, mean 0.999999 with that pack's model.embed_tokens. The importer itself still refuses that pack earlier, at the block gate, because its layers are block 32 too; that is #1503's remaining part and is not changed here.

The change

  • read_block32_head() (npu/pack_import.{h,cpp}) reads that layout into a Q4 at block 32 with positive scales (a negative scale is folded into its codes: 15 - code, 15 - zp). It refuses, with the offset, when the units do not fit, a unit header or a strip's gap is not zero, a scale is not finite, or anything after the last unit is non-zero.
  • import_pack() uses it when the pack declares a block-32 head:
    • tied: the read head must correlate above 0.99 with the embedding. If it does not (or the layout checks fail), the synthesis stands as before and q4nx_head_note says why the read was not taken.
    • untied: the layout checks are all there is, and q4nx_head_note says the values are not verified. If the checks fail, the refusal is the existing one with the reason appended.
  • docs/npu.md: the layout and the rule above, next to the tail-envelope section.

Result on the real pack

$ 1bit npu-pack import ~/scratch-packs/pk-q1519 -o out
  lm head: block 32, 2374 unit(s) of 64000 B at 733567296 B, 110208000 B of zeros after them (tied: verified by correlation with the pack's embedding)
imported 339 tensors, 188424 q4_1 tiles into out
  self-check: the head reproduces the pack's own embedding (as imported, bf16)  corr 0.996185  rel_err 0.091687
              (tied: this correlation is the check, and it passed)
  controls:   row-shifted corr 0.0554 (must fail)
  container:  read back through q4nx::dequant_tile  rel_err 0.000e+00  max |diff| 0.000e+00
  parameters: fp32 scale/zero exact for 100.00% of 32-column groups
"q4nx_head_note": "lm_head was read from the .fconst tail's block 32 head (2374 units of 64000 B), and it reproduces model.embed_tokens"

Named reference: the pack's own model.embed_tokens.weight (the pack declares tie_word_embeddings: true). Correlation 0.996185 over all 151,936 rows; the same rows shifted by one score 0.0554.

Tests

test_block32_tail_head in tests/npu_pack_import_test.cpp, no pack and no device. The fixture head is written by a test-side encoder from the layout description, not through the reader.

  1. Untied, the tail holds a matrix unrelated to the embedding, followed by padding that is not a whole unit: the container's head correlates above 0.99 with the tail's matrix and below 0.3 with the embedding.
  2. Tied, the tail holds the embedding's quantisation: read and verified, not synthesised.
  3. Tied, the tail holds an unrelated matrix: not taken, the synthesis stands and the note says "correlates".
  4. Untied, a non-zero byte after the units: refused with the reason, no head written.
  5. Known answers typed byte by byte on the reader (code position, zero-point nibble, negative scale, zero scale, short tail, padding, non-zero header and gap, K not a multiple of 32).
$ npu_pack_import_test tests/golden
all npu-pack checks passed -- pack leg SKIPPED, no pack directory given

Mutants (each rebuilt, same command):

mutant arms that fail
reader never taken (main's path) 7: arms 1, 2, 3 and 4
code position read row-major 4: arm 1 (corr 0.035), arm 2, two known answers
scale sign not folded 1: "the stored scale is positive"
trailing-zero check removed 3: arm 4 (two checks) and one known answer

The mutant runs were made before one arm-3 threshold was corrected (its synthesis check asked for corr > 0.999 and the 4-bit synthesis scores below that; it is 0.99 now). That check failed in every run including the unmutated one, and is not counted above.

Not covered

  • The importer was run on one pack (the hidden-1,536 one on this box); SmolLM2 was checked with the host script only. The other two packs of the 1.5B trio and any untied block-32 pack have not been run. The unit-header size (2,560 B) is measured at K 1,536 and K 576, not derived, and the reader refuses a tail where it does not hold.
  • The zero-point nibble order (row r takes nibble r, low first) cannot be told from the Qwen pack, where every zero point is 8. It was checked afterwards on SmolLM2's down_proj and q_proj units, whose zero points vary, against the public checkpoint: every row votes for its own index (engine#1503, comment of 2026-11-10). No arm in this PR depends on a pack for it; the known-answer arm pins the order.
  • No device run. This is the host-side import; the container's head tiles are the same q4_1f form every other head uses.
  • The tests ran on the commit before a one-commit rebase onto main (d9d80c2, a llama.cpp bump that touches none of these files).

🤖 Generated with Claude Code

A pack that declares /lm_head/MatMul_Q4 at block_size 32 (the Ryzen AI 1.9
Qwen-1.5B packs) keeps the head after the tail envelope in 64-row units of
2,560 + 2*(K/32)*640 bytes, not in the block-128 tile. read_block32_head()
reads that layout; import_pack() takes it when the layout checks hold and,
on a tied pack, the result correlates above 0.99 with model.embed_tokens.
Otherwise a tied pack keeps the synthesis (and the note says why), and an
untied one is refused with the reason.

Measured on Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu: 2,374 units of 64,000 B,
corr 0.996185 against the embedding over all 151,936 rows, row-shifted
control 0.0554.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 5897213

@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: APPROVE at 5897213

This closes the loop on #1740 properly: the diagnosis was that the three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at block_size 128 and /lm_head/MatMul_Q4 at block_size 32, so the block-128 tile arithmetic "carves exactly and decodes to nothing" — it never lands on the tail, and main therefore did not read the head at all. Reading the head at block 32 from the .fconst tail is the fix the geometry asks for, and Fixes #1740 is honest because the issue's own framing (a carve that is exact but decodes to nothing) is exactly what a block-size mismatch produces.

Two asks, both about the arithmetic being anchored rather than fitted:

  1. How is the tail's start located — from the end of file (len - ntiles*tile), or from the per-layer payload (layers*stride + attn_payload_off(H))? tools/q4nx_tail_head.py records that these two agree only when the file's end is the head and nothing else trails it, and that a file whose end is something else "shows up as a correlation that is not 1 rather than as a silently wrong offset". Whichever this uses, the other should be a check, not an assumption — that is the cheapest way to keep the block-32 path from repeating #1740's failure mode with a different block size.
  2. What the block-32 decode is validated against. The tool's docstring says the checkpoint's own lm_head.weight rows 0–255 correlate +0.994–0.995 against tiles 0–3 when decoded at the right block size — that is the reference this change should be held to, and it is available without a device. If the PR's evidence is "the head now imports", the number that matters is missing: an import that produces a plausible-but-wrong head is precisely the failure #1740 documented.

Also worth keeping: the claim is scoped to these three packs' declaration, not to "1.9 heads", so a pack with a block-128 head cannot be read through this path by accident.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 5897213 — I built read_block32_head() from this head and drove it with a tail I laid out from the header's own measured description: 12 of 12 arms pass.

unit bytes for K=64: 5120            [PASS] block32_unit_bytes() matches the documented formula
ok=1 units=1 bytes=5120 padding=0    [PASS] a tail in the documented layout is read
                                     [PASS] units = ceil(V/64), bytes = units x unit
                                     [PASS] Q4 is N=units*64, K, block 32
                                     [PASS] every decoded weight equals the fixture's
                                     [PASS] every returned scale is finite and positive (negatives folded into codes)
[refusals]  non-zero unit header · non-zero strip padding · NaN scale · trailing non-zero bytes · short buffer · V<64

The round trip is the real check: I encoded a 64×64 block-32 head per the documented strip layout (+0 512 B codes, code position p → row (p/256)*8 + p%8, column (p/8)%32; +512 16 B nibble zero points; +528 48 B zeros; +576 32
signed bf16 scales) and every decoded weight came back equal to the fixture's, with zero = −zp·scale and codes left raw — i.e. code*scale + zero is the documented (code − zp)*scale.

The refusal set is the part that matters for this path, and it is the right set: a wrong region read with confidence is the failure the header says it exists to avoid, so each of the four structural claims is enforced — the unit's
2,560-byte header is zeros, each strip's 48-byte padding is zeros, every scale is finite, and everything after the last unit is zeros — each refused with the offset in the message. My arms checked all four, plus a short buffer and
V < 64 (no crash; it reads one padded unit or refuses).

One process note, because it is the third time today and it is on my side: my first run failed "every decoded weight equals the fixture's" — my fixture had generated a code of +16 (I used % 17 - 8, whose top value is outside int4), so the reader was right and my instrument was wrong. Changing one character of the generator fixed it. The
reader's behaviour on that input (code 0 with zero = −1.0, i.e. −8·scale) is exactly what a 4-bit clamp should produce, which is itself evidence it does not silently wrap.

Why this belongs in main rather than staying a tool: tools/q4nx_tail_head.py measured this layout, and a measurement that lives only in a script is a claim about one pack; this makes it the importer's own reader, with the
layout in a header comment that states each field's provenance ("every one of the 3,072 scales of a unit equals bf16(-m/8) of its embedding block exactly, and the decoded rows correlate 0.990..0.997") and the two-sided test arm in the
PR (c_tail > 0.99 and |c_emb| < 0.3) checking it is the head and not the embedding wearing the head's clothes.

@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 at 5897213: READY

The layout is stated as a measured fact with the arithmetic that makes it checkable: "2,560 zero bytes, then 2 x (K/32) strips of 640 B. Strip s holds K block s/2 for rows 32 x (s%2) .. +31". That is the difference between a parser and a guess — anyone can recompute the strip offsets from that sentence and compare against a real tail.

The setup matters too: the packs quantise every layer at block 128 and /lm_head/MatMul_Q4 at block 32, and the tail after the envelope is 64-row units. Reading the head as if it were layer-shaped is what produced the failure this fixes.

Fixes #1740. I read the diff and CI (green); I did not run it — no build here at the current disk state.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at b0e4e40. This closes the item my W1a gate 3 was waiting on, and I ran the pack it was waiting for.

On the real pack (Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu, tied, V 151,936, H 1,536), with this branch's reader active:

lm head: block 32, 2374 unit(s) of 64000 B at 733567296 B, 110208000 B of zeros after them
         (tied: verified by correlation with the pack's embedding)
imported 339 tensors, 188424 q4_1 tiles
  self-check: corr 0.996185  rel_err 0.091687   (tied: this correlation is the check, and it passed)
  controls:   row-shifted corr 0.0554 (must fail)

And the numbers tell the right story rather than a flattering one. On main's path this pack gets a head synthesised from its embedding and reports corr 0.996901, rel_err 0.000000 — a higher correlation and a zero residual, because the synthesis is the embedding by construction. Reading the pack's own head bytes gives 0.996185 / 0.091687: slightly lower correlation with the embedding, and a non-zero residual against it, which is exactly what you expect when the pack quantised the head its own way. So the run I just did is less similar to the embedding and more correct, and that is the pair of numbers I would put in the PR body — a reader that scores better than the synthesis is the reader that is still echoing its source.

The measurement chain is the strongest part of this PR and it deserves to stay in the description. Four independent steps, each falsifiable on its own: the scale distribution matching max|w|/8 before any ordering is assumed; scale == bf16(-m/8) pinning slot → (row, K block) at 100 % in three units and 99.97 % in a fourth; the code position recovered by voting 1,024 positions over 384 strips into a bijection at mean agreement 0.999 (min 0.987); and the whole head at per-row correlation min 0.990 / mean 0.996 over 12,800 rows. That is how a layout should be established — each step narrows the space, and none of them is "it imported, therefore it is right".

"About half the scales are negative" is the sentence I would highlight, because it explains the whole history of this item: a decode that drops the sign of a bf16 scale averages to zero correlation, which is exactly what earlier attempts saw and read as "values near ±1". A wrong reading that produces a plausible number is the failure mode this table exists to break, and one sentence about the sign would have saved the thread a pass.

On the padding: 110,208,000 B of zeros after them is the issue's own h1,536 measurement reproduced to the byte, and the reader walks units rather than assuming the head ends the file — which is the same lesson my W1a locator learned for the block-128 family.

What this unblocks, concretely: W1a's gate 3 (the 1.5B trio). I have one of the three packs on disk and it now imports with 339 tensors / 188,424 tiles and a verified head; the other two were deleted under the disk floor and are a re-download away. The 1.5B class also stops depending on #1633's synthesis for correctness — the synthesis stays as the fallback for packs whose head genuinely cannot be read, which is what it was written to be.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 5897213

This is the most thoroughly measured format discovery I have reviewed: each field of the block-32 head layout was pinned against an
independent witness, in an order where every step's ambiguity is removed before the next step depends on it.

What makes the evidence trustworthy rather than plausible:

  • The synthesis was measured against its own source and the number says so: corr 0.996901 between a tied pack's synthesised head and
    the embedding it was derived from — and the body states plainly that "the pack's head bytes are never read". A self-correlation presented
    as a gate would be worthless; presented as what it is, it tells you exactly how much of npu-pack: a pack whose head is at another block imports, if it is tied (engine#1503) #1633 was covering for the missing format.
  • The scales were identified without assuming an ordering (same distribution as max|w|/8 over the matching embedding blocks), then
    the slot order was established at 100 % on units 5/100/2000 (99.97 % on unit 6, which is the honest kind of number to publish), then
    code position was voted per-position over 384 strips (mean 0.999, minimum 0.987).
  • The whole-head check is per-row, not aggregate: 12,800 rows, minimum correlation 0.990046, mean 0.996046. A mean alone would hide
    exactly the rows a wrong strip offset damages.
  • A second pack at a different K (SmolLM2-135M, K 576, unit 25,600 B, 768 x 25,600 exactly) reproduces the same rule at minimum
    0.999998
    — and the PR distinguishes that from the importer's own state ("the importer still refuses that pack earlier, at the block gate,
    because its layers are block 32 too; that is npu-pack: the 1.9 packs split on the MatMulNBits K block size (32, and mixed 32/128), before the hidden-896 limit is reached #1503's remaining part and is not changed here").

And the finding that explains the whole thread: about half the scales are negative — a decode that drops the sign averages to zero
correlation, "which is consistent with the earlier attempts that read this field as 'values near ±1'". That is the answer to why this hung:
the field looked like a constant, and it was a signed scale.

The arms carry the two controls that matter: the imported head correlates with the embedding (c_tail > 0.99) and is not the embedding
(|c_emb| < 0.3), so an implementation that simply copied the embedding would fail both; plus the note is carried into config.json so a
container says which head it has.

Head under review: 5897213

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at d471d07: READY — the offset space was identified from the pack, and the branch is provably the one that changes the outcome

The thread had ended at "the format is declared, the offset space is unidentified" — i.e. the previous investigation knew the tail carried a block-32 head and could not locate it. This PR closes that with the pack in hand: the three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at block_size 128 and only /lm_head/MatMul_Q4 at block_size 32, so the block-128 tile arithmetic never lands on their tail ("carves exactly, decodes to nothing"), and main does not read the head at all — a tied pack gets a head synthesised from the embedding (#1633), an untied one is refused.

Two things make this a measurement rather than a claim:

  1. It states the exact configuration — Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu, tied, V 151,936, H 1,536 — so the arithmetic can be checked by anyone: the tail's size is determined by those two numbers and block_size 32.
  2. It flips the branch off to reproduce main (if (false && p.head_block == 32)), which is the strongest available control: the same build, same pack, one condition, and the failure returns. That is how you show a branch is the branch that decides the outcome rather than a branch that happens to correlate with success.

The doc update is load-bearing here. "The format is declared, the offset space is unidentified" was a statement that lived in a thread; if the resolved offset rule is not written into docs/npu.md, the next pack with a block-32 head re-opens the same thread. It is in this PR, which is right.

Two asks, neither blocking:

  1. An arm for the untied pack. The body says a tied pack now reads its head and an untied one was refused — if the reader is what unblocks untied packs, that case is the one that proves the fix is general rather than specific to the Qwen-1.5B trio.
  2. The synthesised-from-embedding fallback (npu-pack: a pack whose head is at another block imports, if it is tied (engine#1503) #1633) should now be asserted to be bypassed when a real block-32 head exists. Otherwise a pack that carries a head could still be served with the synthesised one, which would be correct-looking and permanently slightly wrong — the same shape as the hdr overwrite in npu-pack: the 1.9 header reader fills hdr when the vote found none, and stops overwriting one (fixes #1831) #1836.

Also worth noting for the wider queue: this is the third PR tonight whose fix is "read the artifact's own declaration instead of inferring it from layout arithmetic" (#1938's ONNX names, #1928's offset probe, this) — and the failures were all silent, not refusals.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 5897213: READY — (correcting my previous comment, whose VERDICT line named d471d07, which is #1950's head)

My substance was about this PR; the head in the verdict line was wrong — I copied the neighbouring PR's SHA. Correct head is 5897213. Restating so the merge script has a verdict line that matches:

The thread had ended at "the format is declared, the offset space is unidentified" — the tail carried a block-32 head and nobody could locate it. This closes that from the pack: the three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at block_size 128 and only /lm_head/MatMul_Q4 at block_size 32, so the block-128 arithmetic never lands on their tail ("carves exactly, decodes to nothing"), and main reads no head at all — a tied pack gets one synthesised from the embedding (#1633), an untied one is refused.

Two things make it a measurement: the exact configuration is stated (Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu, tied, V 151,936, H 1,536), so the tail arithmetic is checkable; and the branch is flipped off (if (false && p.head_block == 32)) to reproduce main — same build, same pack, one condition, failure returns. That is how a branch is shown to decide the outcome rather than correlate with it.

Asks (unchanged): an arm for the untied pack, and an assertion that the #1633 synthesised-from-embedding fallback is bypassed when a real block-32 head exists — otherwise a pack carrying a head could still be served with the synthesised one: correct-looking and permanently slightly wrong, the shape of the hdr overwrite in #1836.

On my own error, since it is the second instrument slip in this session: the 7-hex rule caught it within one call, which is the argument for the rule — a verdict that names the wrong head is worse than no verdict, because it reads as reviewed.

Report gains both head_read_note (#1740) and per_layer_P/V (#1725).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

@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 at 3865be4: READY

Re-checked after the merge of main (#1938, #1951, #1950 and others touch npu/pack_import.cpp). The PR's own diff is identical to 5897213, which I passed, and neither changed file has conflict markers.

At 3865be4:

  • npu_pack_import_test tests/golden: all npu-pack checks pass, including the block-32 head arms (tied verified, untied layout-checked, and the fall-back to the synthesis note).
  • Pack leg on Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu (host only, under thermal-run): lm head: block 32, 2374 unit(s) of 64000 B at 733567296 B, 110208000 B of zeros after them (tied: verified …), self-check corr 0.996185, row-shifted control 0.0554 (fails, as it must), container read-back exact, attention 1568/1568 and MLP 4592/4592 byte-for-byte, and q4nx_head_note says the head was read. Those match the description's numbers after #1951's per-layer table changes.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 3865be4

main moved under this PR (#1951 landed) and npu/pack_import.h conflicted: both sides added a field to Report at the same line (head_read_note here, per_layer_P/per_layer_V there). The merge commit keeps both; nothing else changed. npu_pack_import_test on the merged tree: rc 0, all block-32 head arms and the per-layer arms present in the binary and passing.

Canonical order, since it was asked on #1955: this PR is the block-32 head read (Fixes #1740) and lands first. #1955 is stacked on this branch and contains this commit by construction; it adds the all-block-32 layer walk for #1503. Once this merges, #1955's diff against main shrinks to its own commit.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 3865be4 — reads a block_size 32 /lm_head/MatMul_Q4 inside a pack whose layers are block_size 128, and the test carries the two controls that make a correlation assertion mean something.

The shape that makes this a real bug and not an unsupported format

"The three Ryzen AI 1.9 Qwen-1.5B packs declare every layer at block_size 128 and /lm_head/MatMul_Q4 at block_size 32. The block-128 tile arithmetic never lands on their tail (#1740: 'carves exactly, decodes to nothing')"

One pack with two block sizes, and the mismatch is on the tail. That is why #1740 produced the strangest possible symptom — "carves exactly, decodes to nothing": the cut was arithmetically consistent with 128, so nothing refused, and the head simply read the wrong bytes. A reader that got the size wrong but produced shorter output would have been caught by the payload hash; a reader that gets the block size wrong produces plausible garbage.

And the guard is by arithmetic, not by a magic name:

if (V <= 0 || K <= 0 || K % 32 != 0) { h.why = "a block-32 head needs K to be a multiple of 32 (K …)"; }

K % 32 != 0 refuses with the number in the message, which is the pattern this repo has converged on (#1664's refusal, #1955's "unmeasured sizes are refused by number").

The test has both controls, which is what a correlation arm needs

check(std::fabs(ncorr(emb, other)) < 0.3, "block-32 tail: the fixture's two heads are unrelated …")
check(c_tail > 0.99,                      "… decodes to its own head")
check(std::fabs(c_emb) < 0.3,             "block-32 tail: and it is not the embedding")
check(x.said.empty(),                     "…")

Three assertions where one is the answer (> 0.99 to its own head) and two are the negatives — unrelated to the other head, and not the embedding. That is the discipline #1857/#1888 established: a correlation test without its negative passes on a reader that returns any strongly-correlated vector, and c_emb < 0.3 additionally rules out the classic mix-up of reading the embedding table where the head should be. x.said.empty() pins that nothing was reported as refused while all of that was true.

Scope +175 in npu/pack_import.cpp, +38 header, +257 test, +19 docs. #1955 is stacked on this branch (base = this head), so landing this first is the order the two PRs themselves declare.

One ask: the body says "three Ryzen AI 1.9 Qwen-1.5B packs" — if two of the three were only inspected rather than run through the test, say which one the fixture came from, so a future failure names a pack rather than "the three".

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 3865be4 — a layout described by measurement, with the refusals enumerated.

Ryzen AI 1.9 Qwen-1.5B packs quantise every layer at block 128 and `/lm_head/MatMul_Q4` at block 32,
and their tail after the envelope is 64-row *units* instead: 2,560 zero bytes, then `2 x (K/32)`
the measured pack, which is why the block-128 arithmetic never landed there).
each failure is a refusal that says which.

Three things are right:

  • the layout is stated as measured (2,560 zero bytes, then 2 × (K/32) 64-row units) rather than as an inferred pattern — so anyone can check it against a pack without re-deriving the arithmetic;
  • it explains why the old code could not work — the block-128 tile arithmetic "never landed" on a tail whose units are 64-row, which is not a bug in the arithmetic but a mismatch of granularity. That sentence is what keeps someone from "fixing" the tail by widening the block;
  • "each failure is a refusal that says which" — a mixed-granularity tail has several ways to be wrong (no units, the wrong unit count, a short final unit), and naming the one that failed is the difference between a day and an hour.

The ask

The body should say what happens to a pack that declares block 32 for lm_head and layers at block 32 too — that is #1955's case — so the two PRs read as one rule rather than two special cases. Right now this one is written for "layers at 128, head at 32", which is the Qwen-1.5B shape; the general statement is "each tensor is read in its own declared block granularity", and #1955 is the pack where every tensor happens to be 32.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 3865be4 — a layout read where the checks are part of the read, and the one transformation that could silently perturb every weight is exact.

The folding is an identity, not an approximation

const int  zp   = (sp[512 + r/2] >> (4*(r & 1))) & 15;
const bool flip = sc < 0;
const float mag = flip ? -sc : sc;
q.zero[g]   = -float(flip ? 15 - zp : zp) * mag;
dst[c]      = uint8_t(flip ? 15 - code : code);

With sc = −mag: w = (code − zp)·(−mag) = (zp − code)·mag, and the fold gives (15 − code) − (15 − zp) = zp − code. So w = (code' − zp')·mag exactly,
and both 15 − code and 15 − zp stay inside 0…15. A fold that perturbed a value would have moved every weight in the head by up to a scale step and
still correlated ~0.99; this one does not, and the identity is written out in pack_import.h next to the layout.

Refusals are part of the read, and the honest case is named

  • unit count ceil(vocab/64), the zero fields, finite scales, and zeros to end of file are all checked inside read_block32_head, "and each failure is
    a refusal that says which"
    — h.why carries the number and the strip/row, e.g. "strip N has non-zero bytes between its zero points and its scales, so
    this is not the layout measured"
    . That phrasing matters: it says the layout differs from the one measured, not the pack is invalid.
  • Tied packs must clear the tree's existing bar — c32 > kTieCorr (not a new literal 0.99), with selfcheck_tied recording that the check ran; failure
    falls back to building the head from the embedding and q4nx_head_note says why the read was not taken.
  • Untied packs get the layout checks only, "and the note says the values are not verified" — the right way to expose a read that cannot be
    cross-checked, rather than letting it look verified.

The measurement is the kind that can be re-derived

Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu, tied, 151,936 × 1,536, 2,374 units of 64,000 B: "every scale equals bf16(-m/8) of its 32-column embedding block,
m being the block's largest-magnitude weight"
— a closed-form statement about every scale in the file, plus the read head's 0.996 correlation. The 110,208,000
B of trailing zeros is also the explanation for why the block-128 arithmetic never landed on this pack, which is the fact a future reader would otherwise
rediscover.

Note for the queue: #1955 is stacked on this branch (base = 1bit/npupack-b-1740-block32-head), so this verdict gates that one — I will review its own
delta at its head rather than as an extension of this.

@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 3865be4

The measurement is the argument, and it is done the way this family has learned to do it: each step against the pack's own model.embed_tokens, host only, no device — "the 3,072 bf16 values of a unit have the same distribution as max|w|/8 over the 32-column blocks of the same 64 embedding rows (10/50/90 % quantiles 0.00567627 / 0.00750732 / 0.0098877 on both sides)". A distribution match across three quantiles is what identifies an offset space that no declared arithmetic lands on, and it is a much stronger instrument than "the numbers look plausible".

And the self-check states its own limit, which is the part I would keep: "the head reproduces the pack's own embedding (as imported, bf16) corr 0.996901 rel_err 0.000000 … That correlation is the synthesis measured against its own source. The pack's head bytes are never read." That is the exact distinction this family keeps getting wrong in the optimistic direction — a correlation that proves the derivation did what it claims, and says plainly that the pack's own head tiles remain unverified. It is the same discipline as #1846's "what the hash does NOT prove".

Context worth recording: this is the third pack family to hit the block-32 head (#1643's census found the split, #1878/#1928 handled the tied case, this one identifies the offset space). The thread ended at "the format is declared, the offset space is unidentified" — and that is now identified by measurement rather than assumed, which is what makes the answer portable to the other two Qwen-1.5B packs of the set.

No asks.

@bong-water-water-bong
bong-water-water-bong merged commit 74f6db6 into main Nov 10, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npupack-b-1740-block32-head branch November 10, 2026 18:42
bong-water-water-bong pushed a commit that referenced this pull request Nov 10, 2026
#1948 landed on main as a squash, so its text conflicted with this branch's
rewrite of the same code (read_block32_head as a wrapper over
read_block32_units, the take_block32_head lambda, the docs section and the
test list). Every main-side hunk was #1948's text verbatim; each is resolved
to this branch's side. main's other changes (#1995, #1921) merge untouched.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
bong-water-water-bong pushed a commit that referenced this pull request Nov 10, 2026
…/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>
bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…ne#1503, stacked on #1948) (#1955)

* npu-pack: read the 1.9 block-32 head from the .fconst tail (Fixes #1740)

A pack that declares /lm_head/MatMul_Q4 at block_size 32 (the Ryzen AI 1.9
Qwen-1.5B packs) keeps the head after the tail envelope in 64-row units of
2,560 + 2*(K/32)*640 bytes, not in the block-128 tile. read_block32_head()
reads that layout; import_pack() takes it when the layout checks hold and,
on a tied pack, the result correlates above 0.99 with model.embed_tokens.
Otherwise a tied pack keeps the synthesis (and the note says why), and an
untied one is refused with the reason.

Measured on Qwen-2.5_1.5B_Instruct_rai_1.9.0_npu: 2,374 units of 64,000 B,
corr 0.996185 against the embedding over all 151,936 rows, row-shifted
control 0.0554.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* npu-pack: import a 1.9 pack whose every MatMulNBits is block 32 (engine#1503)

An all-block-32 pack (SmolLM2-135M-Instruct 1.9) was refused at the block
gate. Its raw Token .fconst is now read with its own layout: 64-row units
of 2,560 + 2*(K/32)*640 B for q/k/v/o/down and the head, and 64-row units
of 4,864 + 2*(K/32)*1,216 B holding gate and up together. Offsets come
from the geometry and are checked at every epsilon envelope.

read_block32_head becomes a wrapper over read_block32_units; the head
logic #1948 added is shared by the mixed pack and this path.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* npu-pack: one arm per declared block size; unmeasured sizes are refused by number (#1503)

Review on #1955 asked what a pack declaring a block nobody handles does, and
for an arm per declaration rather than one for the class. The all-block-32
fixture is now rebuilt with only cache/ops_meta.json changed:

  every op at 3 / 64 / 256      refused, 'block size along K is N (11 ops)'
  layers 32, head 128           refused, not the all-block-32 class
  layers 32, head 64            refused
  every op at 128               stays off the all-block-32 path and the gate

With the selection made to ignore the declared value, all six arms fail
(each writes a container through the all-block-32 path). The gate's refusal
now says the all-block-32 layout exists and when it applies.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…s / 36 layers), head read from the decode If branch (#2009)

* npu: measure the 1.9 Token fconst rule for hidden 4096 / 32 heads / 36 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.

* npu-pack: read lm_head from the If branch that is the decode path, so 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.

* npu-pack: decide the decode If branch by which branch holds the weights, 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.

* npu: the integer arm's operands come from the engine's packer, not a 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.

* npu: keyed dumps settle it -- the device's weight scales are BIT-IDENTICAL 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.

* npu: the integer head, and the gate it unlocks -- device logits BIT-EXACT 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.

* npu-pack: read the fused SSMLP/GQO FFN attributes, and the 4096/32/40 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.

* docs/npu: say what NPU_INFER_HOST_GEMM=i8 is for, and what it measured

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.

* docs/npu: the two-arm command sequence for the bit-exact gate

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

* npu-pack: withdraw the 4096/32/40 row -- a prefix cannot be inferred, 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)".

* npu-pack: the h4096 36-layer fconst row, so the Qwen3-8B pack imports 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.

* npu-pack test: the new 36-layer row goes in BOTH places, as the guard'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.

* npu-pack: fused-MLP attributes are not graph matmuls, and the 4096/32/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>

* npu-pack test: arm 2b removes its second fixture directory

It was left under the golden directory after a run.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1740 - Partially compliant

Compliant requirements:

  • The tail head is now read correctly from the .fconst tail for block-32 packs
  • The fix handles both tied and untied packs appropriately
  • The layout validation is implemented and tested
  • The fix includes tests to verify the reading and decoding of the block-32 head

Non-compliant requirements:

  • None

Requires further human verification:

  • None

1503 - Partially compliant

Compliant requirements:

  • The importer now handles block-32 head correctly
  • The fix allows the importer to handle mixed 32/128 block sizes

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Layout Mismatch

The new block-32 head reading logic assumes a specific layout for the .fconst tail head. If the actual layout differs from the measured one (as described in the PR), the reader may fail to correctly decode the head, leading to incorrect tensor values or import failures. This is particularly critical for tied packs where the decoded head must correlate with the embedding.

Block32Head read_block32_head(const uint8_t* p, int64_t avail, int64_t V, int64_t K) {
    Block32Head h;
    if (V <= 0 || K <= 0 || K % 32 != 0) {
        h.why = "a block-32 head needs K to be a multiple of 32 (K " + std::to_string(K) + ")";
        return h;
    }
    const int64_t kb = K / 32, strips = 2 * kb, unit = block32_unit_bytes(K);
    h.units = (V + kB32UnitRows - 1) / kB32UnitRows;
    h.bytes = h.units * unit;
    if (avail < h.bytes) {
        h.why = "the .fconst tail has " + std::to_string(avail) + " B after its envelope, and a block-32 head of " +
                std::to_string(h.units) + " units x " + std::to_string(unit) + " B needs " + std::to_string(h.bytes);
        return h;
    }
    for (int64_t i = h.bytes; i < avail; ++i)
        if (p[i] != 0) {
            h.why = "the .fconst tail is not zero after the block-32 head's " + std::to_string(h.units) +
                    " units (first non-zero byte " + std::to_string(i - h.bytes) + " B past them), so the head is not "
                    "where this layout puts it";
            return h;
        }
    h.padding = avail - h.bytes;
    const auto all_zero = [](const uint8_t* b, int64_t n) {
        for (int64_t i = 0; i < n; ++i)
            if (b[i] != 0) return false;
        return true;
    };
    Q4& q = h.q;
    q.N = int(h.units * kB32UnitRows);
    q.K = int(K);
    q.block = 32;
    q.codes.assign(size_t(q.N) * size_t(K), 0);
    q.scale.assign(size_t(q.N) * size_t(kb), 1.0f);
    q.zero.assign(size_t(q.N) * size_t(kb), 0.0f);
    for (int64_t u = 0; u < h.units; ++u) {
        const uint8_t* up = p + u * unit;
        if (!all_zero(up, kB32UnitHeader)) {
            h.why = "block-32 head unit " + std::to_string(u) + " does not open with " +
                    std::to_string(kB32UnitHeader) + " zero bytes, so this is not the layout measured";
            return h;
        }
        for (int64_t s = 0; s < strips; ++s) {
            const uint8_t* sp = up + kB32UnitHeader + s * kB32StripBytes;
            if (!all_zero(sp + 528, 48)) {
                h.why = "block-32 head unit " + std::to_string(u) + " strip " + std::to_string(s) +
                        " has non-zero bytes between its zero points and its scales, so this is not the layout measured";
                return h;
            }
            const int64_t col0 = (s / 2) * 32, row0 = u * kB32UnitRows + 32 * (s % 2);
            for (int r = 0; r < 32; ++r) {
                const uint16_t bits = uint16_t(sp[576 + 2 * r]) | uint16_t(uint16_t(sp[577 + 2 * r]) << 8);
                const uint32_t wide = uint32_t(bits) << 16;
                float sc;
                std::memcpy(&sc, &wide, 4);
                if (!std::isfinite(sc)) {
                    h.why = "block-32 head unit " + std::to_string(u) + " strip " + std::to_string(s) + " row " +
                            std::to_string(r) + " has a scale that is not finite";
                    return h;
                }
                const int zp = (sp[512 + r / 2] >> (4 * (r & 1))) & 15;
                const bool flip = sc < 0;
                const size_t g = size_t(row0 + r) * size_t(kb) + size_t(s / 2);
                if (sc == 0.0f) continue;   // an all-zero block: codes 0, scale 1, zero 0 decode to 0
                const float mag = flip ? -sc : sc;
                q.scale[g] = mag;
                q.zero[g] = -float(flip ? 15 - zp : zp) * mag;
                // position p = band*256 + col*8 + (r%8) holds row band*8 + r%8, column col
                const int base = (r / 8) * 256 + (r % 8);
                uint8_t* dst = q.codes.data() + size_t(row0 + r) * size_t(K) + size_t(col0);
                for (int c = 0; c < 32; ++c) {
                    const int pos = base + c * 8;
                    const int code = (sp[pos / 2] >> (4 * (pos & 1))) & 15;
                    dst[c] = uint8_t(flip ? 15 - code : code);
                }
            }
        }
    }
    h.ok = true;
    return h;
}
Test Coverage for Edge Cases

While the test suite includes various scenarios for block-32 head reading, it may not cover all edge cases such as malformed data or specific combinations of parameters that could lead to unexpected behavior. Ensuring comprehensive test coverage for these cases is important for robustness.

// ── engine#1740: the block-32 head READ from the .fconst tail ──────────────────────────────────────────────────────
// The layout was measured on Qwen-2.5_1.5B_Instruct 1.9 (pack_import.h, above read_block32_head). This writes it from
// that description -- NOT through the reader -- so a reader that disagrees with the description fails here: 64-row
// units of 2,560 zero bytes + 2*(K/32) strips of 640 B; strip s is K block s/2, rows 32*(s%2)..+31; code position
// `(r/8)*256 + c*8 + r%8`, low nibble first; 16 B of zero-point nibbles (8); 48 zero bytes; 32 signed bf16 scales,
// `-m/8` with m the block's largest-magnitude weight.
static std::string encode_block32_units(const std::vector<float>& W, int rows, int K) {
    const int kb = K / 32, units = rows / 64;
    const size_t unit = size_t(2560 + 2 * kb * 640);
    std::string out(size_t(units) * unit, '\0');
    for (int u = 0; u < units; ++u)
        for (int s = 0; s < 2 * kb; ++s) {
            char* sp = out.data() + size_t(u) * unit + 2560 + size_t(s) * 640;
            for (int r = 0; r < 32; ++r) {
                const float* w = W.data() + size_t(u * 64 + 32 * (s % 2) + r) * size_t(K) + size_t((s / 2) * 32);
                float m = 0;
                for (int c = 0; c < 32; ++c)
                    if (std::fabs(w[c]) > std::fabs(m)) m = w[c];
                const uint16_t bits = pi::bf16_bits_of(-m / 8.0f);
                const uint32_t wide = uint32_t(bits) << 16;
                float sc;
                std::memcpy(&sc, &wide, 4);
                sp[576 + 2 * r] = char(bits & 0xFF);
                sp[577 + 2 * r] = char(bits >> 8);
                sp[512 + r / 2] = char(uint8_t(sp[512 + r / 2]) | uint8_t(8 << (4 * (r & 1))));
                for (int c = 0; c < 32; ++c) {
                    const int code = sc == 0 ? 8 : std::min(15, std::max(0, int(std::lround(w[c] / sc)) + 8));
                    const int pos = (r / 8) * 256 + c * 8 + r % 8;
                    sp[pos / 2] = char(uint8_t(sp[pos / 2]) | uint8_t(code << (4 * (pos & 1))));
                }
            }
        }
    return out;
}

void test_block32_tail_head(const std::string& root) {
    const int H = 128, L = 8, V = 64;
    const int R = onebit::npu::q4nx::kRows;   // the rows of container tile 0, which is what the arms decode
    std::vector<float> emb(size_t(V) * H), other(size_t(V) * H);
    for (int r = 0; r < V; ++r)
        for (int c = 0; c < H; ++c) {
            emb[size_t(r) * H + c] = 0.05f * float((r * 7 + c * 13) % 97) - 2.4f;
            other[size_t(r) * H + c] = 0.04f * float((r * 31 + c * 5 + (r * c) % 11) % 89) - 1.7f;   // an UNRELATED head
        }
    // Pearson over the first `a.size()` values of both
    const auto ncorr = [](const std::vector<float>& a, const std::vector<float>& b) {
        double num = 0, da = 0, db = 0, sa = 0, sb = 0;
        const double n = double(a.size());
        for (size_t i = 0; i < a.size(); ++i) {
            num += double(a[i]) * b[i], da += double(a[i]) * a[i], db += double(b[i]) * b[i];
            sa += a[i], sb += b[i];
        }
        const double den = (n * da - sa * sa) * (n * db - sb * sb);
        return den > 0 ? (n * num - sa * sb) / std::sqrt(den) : 0.0;
    };
    // The fixture's own premise, asserted: the two matrices must not resemble each other, or "reads the tail" and
    // "falls back to the embedding" could not be told apart by a correlation.
    check(std::fabs(ncorr(emb, other)) < 0.3,
          "block-32 tail: the fixture's two heads are unrelated (corr " + std::to_string(ncorr(emb, other)) + ")");

    const auto build = [&](bool tied, const char* tag, const std::string& tail_after_env) {
        const fs::path dir = fs::path(root) / tag;
        fs::remove_all(dir);
        fs::create_directories(dir / "cache");
        const int64_t stride = 1 << 20, second = 262144;
        std::string fc(size_t(int64_t(L) * stride + 64 + size_t(H) * 2), '\0');
        auto put_eps = [&](int64_t off) {
            fc[size_t(off)] = char(0x86);
            fc[size_t(off) + 1] = char(0x35);
        };
        for (int l = 0; l < L; ++l) {
            put_eps(int64_t(l) * stride);
            put_eps(int64_t(l) * stride + second);
        }
        put_eps(int64_t(L) * stride);
        for (int i = 0; i < H; ++i) {
            const uint16_t bits = pi::bf16_bits_of(float(i + 1));
            fc[size_t(int64_t(L) * stride + 64 + 2 * i)] = char(bits & 0xFF);
            fc[size_t(int64_t(L) * stride + 64 + 2 * i) + 1] = char(bits >> 8);
        }
        fc += tail_after_env;
        put_file((dir / "dd_metastate_Llm_Token_Token_crafted.fconst").string(), fc);
        put_file((dir / "config.json").string(),
                 std::string(R"({"model_type":"qwen2","hidden_size":128,"intermediate_size":256,"num_hidden_layers":8,)"
                             R"("num_attention_heads":2,"num_key_value_heads":1,"head_dim":64,"vocab_size":64,)"
                             R"("rope_theta":10000.0,"tie_word_embeddings":)") +
                     (tied ? "true" : "false") + R"(,"rms_norm_eps":1e-06})");
        std::string data(size_t(V) * size_t(H) * 2, '\0');
        for (size_t i = 0; i < emb.size(); ++i) {
            const uint16_t bits = pi::bf16_bits_of(emb[i]);
            data[2 * i] = char(bits & 0xFF);
            data[2 * i + 1] = char(bits >> 8);
        }
        put_file((dir / "model.onnx.data").string(), data);
        // the declaration that sets `p.head_block` (see test_tied_block32_head for why it lives in cache/)
        put_file((dir / "cache" / "lm_head_meta.json").string(),
                 R"({"op_list":[{"type":"MladfMatMul","name":"lm_head.MatMulNBits",)"
                 R"("attrs":{"N":{"value":[64]},"K":{"value":[128]},"block_size":{"value":[32]},"bits":{"value":[4]}}}],)"
                 R"("tensor_map":{}})");
        put_file((dir / "model.onnx").string(),
                 pb::model({pb::tensor("model.embed_tokens.weight", {V, H}, "model.onnx.data", 0,
                                       uint64_t(V) * uint64_t(H) * 2, 16),   // 16 = BFLOAT16: the bytes ARE bf16
                            pb::placeholder("lm_head.weight.MatMulNBits.qweight"),
                            pb::placeholder("lm_head.weight.MatMulNBits.scales"),
                            pb::placeholder("lm_head.weight.MatMulNBits.zeros")},
                           {}));
        return dir;
    };
    struct Run {
        pi::Report r;
        std::string said, note;
        bool has_head = false;
        std::vector<float> head;   // the container's lm_head tile 0, decoded from its own bytes: [kRows][H]
    };
    const auto run = [&](const fs::path& dir, const char* tag) {
        Run x;
        const std::string out =
            (fs::temp_directory_path() /
             (std::string("oga_pack_import_b32tail_") + tag + "." + std::to_string(::getpid()))).string();
        fs::remove_all(out);
        pi::Options allow;
        allow.allow_incomplete = true;   // the fixture's layer payload is empty; the head gate is what is under test
        try {
            x.r = pi::import_pack(dir.string(), out, allow);
        } catch (const std::exception& e) {
            x.said = e.what();
        }
        for (const auto& w : x.r.refused)
            if (w.rfind("lm_head.weight", 0) == 0) x.said += " | " + w;
        if (fs::exists(out + "/model.q4nx")) {
            const json tbl = container_table(out + "/model.q4nx");
            x.has_head = tbl.contains("lm_head.weight");
            if (x.has_head) {
                const std::vector<uint8_t> blob = container_payload(out + "/model.q4nx");
                const auto offs = tbl["lm_head.weight"]["data_offsets"].get<std::vector<uint64_t>>();
                std::vector<float> w(size_t(R) * onebit::npu::q4nx::kCols, 0.f);
                onebit::npu::q4nx::dequant_tile(onebit::npu::q4nx::Chunk::Q4_1F, blob.data() + offs[0], w.data(),
                                                onebit::npu::q4nx::kCols);
                x.head.assign(size_t(R) * H, 0.f);
                for (int r = 0; r < R; ++r)
                    for (int c = 0; c < H; ++c) x.head[size_t(r) * H + c] = w[size_t(r) * onebit::npu::q4nx::kCols + c];
            }
            std::ifstream cf(out + "/config.json");
            if (cf.good()) {
                const json cfg = json::parse(cf);
                if (cfg.contains("q4nx_head_note")) x.note = cfg["q4nx_head_note"].get<std::string>();
            }
        }
        fs::remove_all(out);
        fs::remove_all(dir);
        return x;
    };

    {   // arm 1: UNTIED, the tail holds a head that is NOT the embedding, then padding that is not a whole unit
        const Run x = run(build(false, "b32-untied-read", encode_block32_units(other, V, H) + std::string(10000, '\0')),
                          "untied");
        check(x.said.empty(),
              "block-32 tail: an untied pack whose tail holds the head imports it (" + x.said.substr(0, 160) + ")");
        check(x.has_head && !x.head.empty(), "block-32 tail: the container carries the head");
        if (!x.head.empty()) {
            const double c_tail = ncorr(x.head, other), c_emb = ncorr(x.head, emb);
            check(c_tail > 0.99,
                  "block-32 tail: the container's head is the matrix the TAIL holds (corr " + std::to_string(c_tail) + ")");
            check(std::fabs(c_emb) < 0.3, "block-32 tail: and it is not the embedding (corr " + std::to_string(c_emb) + ")");
        }
        check(x.r.head_synthesized_note.empty() && x.r.head_read_note.find("block 32") != std::string::npos &&
                  x.r.head_read_note.find("untied") != std::string::npos,
              "block-32 tail: the report says the head was read, and that an untied one is not verified (" +
                  x.r.head_read_note + ")");
        check(x.note == x.r.head_read_note && !x.note.empty(), "block-32 tail: config.json carries the same note");
    }
    {   // arm 2: TIED, the tail holds the embedding's own quantisation -> read (not synthesised), verified
        const Run x = run(build(true, "b32-tied-read", encode_block32_units(emb, V, H)), "tied");
        check(x.said.empty(), "block-32 tail, tied: imports (" + x.said.substr(0, 160) + ")");
        check(x.r.head_synthesized_note.empty() && x.r.head_read_note.find("reproduces") != std::string::npos,
              "block-32 tail, tied: the head is READ and the note says it reproduces the embedding (" +
                  x.r.head_read_note + ")");
        check(x.r.selfcheck_tied && x.r.lmhead_corr > 0.99,
              "block-32 tail, tied: the self-check measures the read head (corr " + std::to_string(x.r.lmhead_corr) + ")");
        check(!x.head.empty() && ncorr(x.head, emb) > 0.99,
              "block-32 tail, tied: the container's head is the embedding (corr " +
                  std::to_string(x.head.empty() ? 0.0 : ncorr(x.head, emb)) + ")");
    }
    {   // arm 3: TIED, but the tail's head is some other matrix -> the read is NOT taken, the synthesis stands, says why
        const Run x = run(build(true, "b32-tied-mismatch", encode_block32_units(other, V, H)), "mismatch");
        check(x.r.head_read_note.empty() && x.r.head_synthesized_note.find("correlates") != std::string::npos,
              "block-32 tail, tied mismatch: a tail head that is not the embedding is not taken (" +
                  x.r.head_synthesized_note.substr(0, 260) + ")");
        const double c_emb = x.head.empty() ? 0.0 : ncorr(x.head, emb), c_oth = x.head.empty() ? 1.0 : ncorr(x.head, other);
        check(c_emb > 0.99 && std::fabs(c_oth) < 0.3,
              "block-32 tail, tied mismatch: the container's head is the embedding's synthesis, not the tail's matrix "
              "(corr " + std::to_string(c_emb) + " / " + std::to_string(c_oth) + ")");
    }
    {   // arm 4: UNTIED, a non-zero byte after the units -> refused by name with the reason, nothing written
        std::string tail = encode_block32_units(other, V, H) + std::string(4096, '\0');
        tail[tail.size() - 100] = 1;
        const Run x = run(build(false, "b32-untied-junk", tail), "junk");
        check(!x.has_head, "block-32 tail, junk after the head: no head is written");
        check(x.said.find("block 32") != std::string::npos && x.said.find("does not declare tied") != std::string::npos &&
                  x.said.find("not zero after") != std::string::npos,
              "block-32 tail, junk after the head: the refusal names the block, the missing tie and the reason (" +
                  x.said.substr(0, 300) + ")");
    }
    {   // arm 5: known answers on the reader itself, typed byte by byte (K 64: one unit is 2,560 + 4 x 640 = 5,120 B)
        std::vector<uint8_t> u(5120, 0);
        uint8_t* sp = u.data() + 2560 + 3 * 640;          // strip 3: K block 1, rows 32..63
        const auto put_scale = [&](int r, float v) {
            const uint16_t bits = pi::bf16_bits_of(v);
            sp[576 + 2 * r] = uint8_t(bits & 0xFF);
            sp[577 + 2 * r] = uint8_t(bits >> 8);
        };
        put_scale(9, 0.5f);
        put_scale(10, -0.25f);
        sp[512 + 4] = 0x80;                               // row 9's zero point (high nibble of byte 4) = 8
        sp[512 + 5] = 0x06;                               // row 10's zero point (low nibble of byte 5) = 6
        sp[(256 + 5 * 8 + 1) / 2] |= 0xB0;                // row 9 (band 1, r%8 1), column 5: position 297, high nibble
        sp[(256 + 31 * 8 + 2) / 2] |= 0x03;               // row 10 (band 1, r%8 2), column 31: position 506, low nibble
        pi::Block32Head h = pi::read_block32_head(u.data(), int64_t(u.size()), 64, 64);
        check(h.ok && h.units == 1 && h.q.N == 64 && h.q.K == 64 && h.q.block == 32,
              "block-32 reader: one unit of K 64 reads (" + h.why + ")");
        if (h.ok) {
            const auto val = [&](int row, int col) {
                const size_t g = size_t(row) * 2 + size_t(col / 32);
                return double(h.q.codes[size_t(row) * 64 + col]) * h.q.scale[g] + h.q.zero[g];
            };
            check(val(41, 37) == 1.5,
                  "block-32 reader: (row 41, col 37) = (11 - 8) x 0.5 (" + std::to_string(val(41, 37)) + ")");
            check(val(41, 36) == -4.0,
                  "block-32 reader: its neighbour is code 0, (0 - 8) x 0.5 (" + std::to_string(val(41, 36)) + ")");
            check(val(42, 63) == 0.75,
                  "block-32 reader: a NEGATIVE scale, (3 - 6) x -0.25 (" + std::to_string(val(42, 63)) + ")");
            check(val(42, 32) == 1.5,
                  "block-32 reader: and its code 0, (0 - 6) x -0.25 (" + std::to_string(val(42, 32)) + ")");
            check(h.q.scale[size_t(42) * 2 + 1] == 0.25f, "block-32 reader: the stored scale is positive");
            check(val(41, 5) == 0.0 && val(0, 0) == 0.0, "block-32 reader: a block with a zero scale decodes to zero");
        }
        check(!pi::read_block32_head(u.data(), 5119, 64, 64).ok, "block-32 reader: a tail one byte short is refused");
        std::vector<uint8_t> two = u;
        two.resize(5120 + 300, 0);
        check(pi::read_block32_head(two.data(), int64_t(two.size()), 64, 64).padding == 300,
              "block-32 reader: zeros after the last unit are padding, and counted");
        two.back() = 7;
        check(!pi::read_block32_head(two.data(), int64_t(two.size()), 64, 64).ok,
              "block-32 reader: a non-zero byte after the last unit is refused");
        std::vector<uint8_t> hdr = u;
        hdr[100] = 1;
        check(!pi::read_block32_head(hdr.data(), int64_t(hdr.size()), 64, 64).ok,
              "block-32 reader: a non-zero unit header is refused");
        std::vector<uint8_t> gap = u;
        gap[2560 + 640 + 540] = 1;
        check(!pi::read_block32_head(gap.data(), int64_t(gap.size()), 64, 64).ok,
              "block-32 reader: a non-zero byte between the zero points and the scales is refused");
        check(!pi::read_block32_head(u.data(), int64_t(u.size()), 64, 48).ok, "block-32 reader: K 48 is refused");
    }
}

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.

npu-pack: the 1.9 .fconst tail head carves exactly (V/64 tiles) but decodes to nothing -- tied and untied packs, same signature

1 participant