Repository navigation
npu-pack: read a Ryzen AI 1.9 Token .fconst in its 1.8 form, head included - #1507
Conversation
…rchitecture
These packs ship no generation_config.json at all -- I had assumed they did, and the gap only showed up in a config diff
against the 1.8 container of the same model, which carries eos_token_id, bos_token_id, architectures and friends. The
container is what a serve reads months later, so it should state them rather than relying on a file the pack does not
have.
load_geometry now carries eos_token_id / bos_token_id / pad_token_id and `architectures` out of the pack's
genai_config.json's model block (normalising the family name to the Hugging Face spelling -- genai_config says "qwen2",
runners match "Qwen2"), and the container writer keeps the plan's resolved eos when it has one.
Measured on both hybrid packs:
Qwen2.5-0.5B rai_1.7.1: eos_token_id [151645, 151643], bos 151643, pad 151643, architectures ["Qwen2ForCausalLM"],
import unchanged at 291 tensors / 67,088 tiles / self-check 0.994548
SmolLM2-135M rai_1.7.1: eos_token_id [2], architectures ["LlamaForCausalLM"], import unchanged at 363 tensors /
20,808 tiles / self-check 0.995685, and the text arm still answers
"The capital of France is" -> " Paris. Paris is the largest city in the world"
ctest npu_pack_import + attn_math pass
…nd a serve that derives another one refuses
pi-inferzo's request, and the failure mode behind it is real: two containers can share a hidden size and differ in
intermediate width, head count or head dim, i.e. in every K/N the kernels are looked up by, so serving one against the
other's ELF set used to be a silent wrong answer. Import now writes the family into the container, and the forward
compares it against what it derives:
"q4nx_designs": "QKV:<K>x<N>,O:<K>x<N>,GU:<K>x<N>,D:<K>x<N>"
with K on the forward's 256-column grid and N on its 512-column one (npu/forward/include/q4nx_pack.h), because that is
what the loader derives and looks up -- an unpadded string would have refused every correct container. Measured against
both packs' own "derived 4 designs" lines: SmolLM2-135M QKV 768x1024 / O 768x1024 / GU 768x3072 / D 1536x1024, and
Qwen2.5-0.5B QKV 1024x1536 / O 1024x1024 / GU 1024x9728 / D 4864x1024 -- both match to the number.
Verified both directions, device-free:
matching container -> serves
same container with the other family's string -> refuses at init:
"this container declares its kernel family as \"QKV:1024x1536,...\", but this model derives
\"QKV:768x1024,...\": the ELFs it was imported for are not the ones this geometry needs, so it would answer wrong
text rather than fail (re-import, or serve the container that matches)"
A container without the key (everything imported before this) is unaffected, so no existing directory starts refusing.
ctest npu_pack_import + attn_math pass.
…luded
A 1.9 pack of the same model differs from its 1.8 twin in exactly two ways, both
measured by whole-file diff and both confirmed on three models:
* a 4*NH-byte header is inserted after the q/k/v bundle, the four bytes
00 00 7f ff repeated once per head (14 groups on Qwen2.5-0.5B, 32 on Qwen3-8B,
9 at 36 B on SmolLM2-135M, 16 at 64 B on Qwen3-1.7B);
* the q/k/v bundle is stored q,k,v where 1.8 stores k,q,v.
Everything else in the block is verbatim, the tail is identical, and the whole
file difference is `layers x 4*NH` bytes (24 x 56 = 1,344 on Qwen2.5-0.5B). The
tile shape cannot be derived from the pack itself -- it is not a subset sum of
the pack's own const sizes -- so the parameters are a measured table keyed by
hidden/heads/layers, and a 1.9 pack outside it is refused by name rather than
decoded on a guess.
Reading a 1.9 pack as its 1.8 form means one set of offsets serves both: the
normalised bytes are written once and every reader above (geometry, norms,
attention payload, MLP, tail) is unchanged.
This also imports the lm_head that a 1.9 pack carries only in the .fconst tail
(the 1.9 Qwen2.5-0.5B has no host MatMulNBits head at all): the tail opens with
the same 1,856-byte envelope the final norm sits in, then the head's own 64-row
tiles at K = hidden, and `1,856 + (vocab/64) x tile(hidden)` must equal the tail
length to the byte or the head is refused by name.
Evidence (Qwen2.5-0.5B-Instruct, rai_1.8.0_npu_4K vs rai_1.9.0_npu):
* 24/24 layer blocks normalise byte-identically to the 1.8 .fconst, tail
identical -- the whole file, both directions;
* the 1.9 import lands 291 tensors / 67,088 tiles, the same counts as the 1.8
import, with 768/768 attention tiles and 2,160/2,160 MLP groups surviving
decode->encode and 48/48 RMSNorm anchors found;
* the two containers are then payload-identical: all 291 tensor blobs compare
equal, and so does the manifest's attention payload, MLP section and
parameter census -- including lm_head, whose tail tiles therefore decode to
exactly the same Q4 as the 1.8 pack's host-side initializer;
* the remaining file difference between the two packs' containers is metadata
only (pack name, the packs' own quantization_config version strings).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d3050b75.
Both of the "could mis-parse rather than fail" paths are strict, and I checked them at the condition level rather than the summary level.
(1) Detection — fconst19_detect requires all three of the things you said, and refuses otherwise:
if (heads <= 0 || layers < 2) return false; // no degenerate geometry
… full-pattern std::memcmp (not just the 00 00 7f ff prefix) …
if (int(runs.size()) != layers) return false; // EXACTLY `layers` runs
const int64_t s = runs[1] - runs[0];
if (s <= int64_t(pat.size())) return false; // a stride that can hold a block
for (k) if (runs[k+1] - runs[k] != s) return false; // UNIFORM spacing, every gapA wrong head count makes pat the wrong length (no match), a wrong layer count fails the run count, and any non-uniform spacing fails the last loop — so the
three ways a 1.9 file could look right and not be are all refused. The byte-wise comparison (rather than an integer read) is the right call and your comment
says why: the little-endian value of the same bytes is 0xff7f0000.
(2) The table is one measured row, {896, 14, 24, 16, 34816, 2}, matched on the exact hidden/heads/layers triple, and anything else is refused by
name with its geometry and the reason ("its block shape has not been measured for hidden …, heads …, layers …"). Refusing rather than generalising is
correct given the stated justification — the tile shape is not a subset sum of the pack's own const sizes — and it is the same discipline as #1409's rule
(b): an unmeasured geometry is a named refusal, not a best-effort decode.
(3) The tail read asserts the arithmetic before touching a tile, and names both numbers:
const int64_t want = kTailNorm + rows * tile; rows = V / kTileRows; tile = attn_tile_bytes(H)
const int64_t have = int64_t(fc.size) - tail_off;
if (V % kTileRows != 0 || want != have) → refuse, quoting have, want and "1856 + rows x tile"
else → read tilesand it runs only when onnx.matmuls.empty() (no host-side head at all), so 1.8 packs are untouched.
The evidence has an independent cross-check that I can confirm from my own record: the 1.9 import landing 291 tensors / 67088 tiles is exactly what
#1412's 1.8 import of the same model produced — I recorded "291 tensors / 67088 tiles / 0.994548" reviewing that PR. Two different parse routes agreeing on
the tensor and tile counts, plus payload-identical blobs across all 291, is the strongest available confirmation of the tail layout: a wrong offset would
change the tile count or the bytes, not both silently.
Two small things to fix while you are here (neither affects this pack):
kTailNorm = 1856encodes hidden 896 —[2 eps][62 pad][1792 norm], and1792 = 2 x 896. The table is keyed generally but this constant is not, so
the day a second row is measured the tail assertion would be silently wrong for it. Either derive it (64 + 2 * rule->hidden) or assert
rule->hidden == 896where it is used, so a second row fails loudly instead of mis-parsing — which is the principle the rest of this PR follows.kFconstHidden = 896is declared and never used (one occurrence in the file, the declaration). Dead const, and a-Wunused-const-variablecandidate.
Merge-order note: the branch predates #1480's merge (b00845ad8 is not an ancestor), so ~21 of the +238 lines are that PR's q4nx_designs guard arriving
again. They drop on an update-from-main — no correctness issue, just a smaller diff when it lands.
The tail-head import added in #1507 decoded the head but skipped the numeric check the host-head path has always run, so an import of a 1.9 pack reported "self-check: skipped -- the head is not tied to the embedding" while the pack's own embedding was right there as an independent reference. Run it: on a tied model the pack's fp16 embedding must reproduce its 4-bit head, and an untied one simply comes out low -- either way it is evidence. The nibble-swapped and no-zero-point decodes stay as the controls that must fail. Measured on amd/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu: corr 0.999999, rel_err 0.000700, controls 0.0000 and 0.0000. That is the same evidence the 1.8 pack's host head carries, and it is what makes the tail layout self-verifying rather than merely self-consistent.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at d3050b75 — the table carries one geometry while the body claims three are confirmed, and the byte
counts in the body read wrong. The mechanics themselves check out as far as I can verify them without an NPU build.
1. "Confirmed on three models" vs a one-row table — so two of the three are refused in practice
The body says the 1.9 shape was "confirmed on three models (Qwen2.5-0.5B, SmolLM2-135M, Qwen3-1.7B)", and the code says a
1.9 pack outside the table "is refused by name rather than decoded on a guess". The table in this head has exactly one
row:
constexpr Fconst19Rule kFconst19Rules[] = {
// Qwen2.5-0.5B-Instruct: normalises to its 1.8 twin byte for byte, whole file, all 24 blocks.
{896, 14, 24, 16, 34816, 2},
};A SmolLM2-135M or Qwen3-1.7B 1.9 pack therefore lands in the refusal path, which makes "confirmed" read as "supported" when
it is not yet. Either the two measured rows belong in the table (if the tile/tile-size came out of the same whole-file diff),
or the body should say plainly that only Qwen2.5-0.5B imports today and the other two are the next rows to measure. As
written the PR's own discipline is right and the description is a step ahead of the code.
2. The header byte counts in the body read as 4× too large
4*NH-byte header … the four bytes00 00 7f ff, repeated once per head (14 groups on Qwen2.5-0.5B, 32 on Qwen3-8B,
9 × 36 B on SmolLM2-135M, 16 × 64 B on Qwen3-1.7B)
4*NH for those two models is 36 B (9 heads) and 64 B (16 heads) in total; "9 × 36 B" and "16 × 64 B" read as 324 B
and 1024 B, i.e. as if the group size were 36/64 B. The comment above the table (4*NH-byte header … once per head) is
correct — it is only the model list in the description that can be misread, and this paragraph is the one a future reader will
use to decide whether their pack is in the family. Suggest "36 B (9 heads × 4)" / "64 B (16 heads × 4)".
3. The arithmetic I could check, checks out
24 layers × 4 × 14 heads = 1,344 B total difference on Qwen2.5-0.5B, which matches the table row ({896, 14, 24, …}) and the
body's own figure. The q,k,v → k,q,v reorder plus the -4*NH read offset are internally consistent in the two places the diff
touches them (fconst19_header, the header test), and the q4nx_designs config field is written by the importer with the same
ROLE:KxN shape the forward compares against — a drift there fails loudly, which is the right direction for a check whose
alternative is silently wrong text.
4. No test for a byte-layout parser with a refusal table
Two files changed, no test. The behaviours worth pinning are exactly the ones that cannot be eyeballed in review: fconst19_header
detection (counts layers headers, evenly spaced, else "not a 1.9 form"), the refusal message for an out-of-table geometry, and
the q4nx_designs round-trip (importer writes it, forward compares it, a mismatch refuses). The repo has precedent for unit tests on
parsers; a fixture built from the 1.9 twin's first block plus the 1.8 one would cover all three without an NPU.
Not claimed: no build or run of this PR from me, and I have not re-derived any measurement — the review is the table/claim gap,
the byte-count wording, the arithmetic cross-check and the missing test.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
The tail-head import added in #1507 decoded the head but skipped the numeric check the host-head path has always run, so an import of a 1.9 pack reported "self-check: skipped -- the head is not tied to the embedding" while the pack's own embedding was right there as an independent reference. Run it: on a tied model the pack's fp16 embedding must reproduce its 4-bit head, and an untied one simply comes out low -- either way it is evidence. The nibble-swapped and no-zero-point decodes stay as the controls that must fail. Measured on amd/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu: corr 0.999999, rel_err 0.000700, controls 0.0000 and 0.0000. That is the same evidence the 1.8 pack's host head carries, and it is what makes the tail layout self-verifying rather than merely self-consistent.
…#1515) * npu-pack: the 1.9 tail head gets the same self-check as the host head The tail-head import added in #1507 decoded the head but skipped the numeric check the host-head path has always run, so an import of a 1.9 pack reported "self-check: skipped -- the head is not tied to the embedding" while the pack's own embedding was right there as an independent reference. Run it: on a tied model the pack's fp16 embedding must reproduce its 4-bit head, and an untied one simply comes out low -- either way it is evidence. The nibble-swapped and no-zero-point decodes stay as the controls that must fail. Measured on amd/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu: corr 0.999999, rel_err 0.000700, controls 0.0000 and 0.0000. That is the same evidence the 1.8 pack's host head carries, and it is what makes the tail layout self-verifying rather than merely self-consistent. * npu-pack: the tail envelope is 64 + 2*hidden, and Qwen3-1.7B's 1.9 shape is the second measured entry The tail-head read assumed the head's tiles start at a fixed 1,856 bytes into the tail. That number is only qwen2.5-0.5B's, where 1,856 == 64 + 2*896 by coincidence: the envelope is the same `64 + 2*hidden` a layer block opens with (eps, padding, the norm). On Qwen3-1.7B the constant missed the head's start by 2,304 bytes and the import refused -- correctly, since it checks the arithmetic to the byte instead of trusting the offset. With the envelope derived from the hidden size, Qwen3-1.7B's tail is 4,160 + 2,374 x 73,984 exactly. That refusal is what surfaced the second measured 1.9 entry, and the entry is worth having because the prefix is not `64 + 2*hidden` either: Qwen3 carries its per-head QK norm gains inside the block before the q/k/v bundle, so the prefix is 8,512 at hidden 2,048 where the same formula would say 4,160. Both numbers are therefore rule parameters, measured per pack against the 1.8 twin, never derived. Verified for amd/Qwen3-1.7B_rai_1.9.0_npu against its 1.8 twin: * import lands 311 tensors / 210,016 tiles, the same counts as the 1.8 pack, with 2,688/2,688 attention tiles and 3,584/3,584 MLP groups surviving decode->encode, 56/56 RMSNorm anchors, self-check corr 0.999999 / rel_err 0.000764 (controls 0.0000 and 0.0000); * greedy text is identical to the 1.8 container on three prompts on the host GEMM, and identical again on the NPU with the existing Qwen3-1.7B b8 kernel set -- including the one prompt whose continuation differs between backends, which is the int8 EP vs fp32 host difference and appears identically in both releases. The two builds' fconsts differ in 535 code bytes, all in layer 24 (0.00006 % of the file): content, not layout, and not enough to move greedy text. * npu-pack: an untied pack's self-check line must not read like a failed import Phi-3-mini-4k-instruct_rai_1.7.1_hybrid imports cleanly and then printed self-check: lm_head vs the pack's fp16 embedding corr -0.005766 rel_err 1.638304 which looks like a broken decode and is not: Phi-3 is untied, so lm_head and model.embed_tokens are separate matrices and correlating them tests nothing. The container was already right (it leaves tie_word_embeddings absent); only the wording misled, and it would send someone chasing a phantom on every Phi-3 pack. The report now distinguishes the two cases. Tied packs keep the existing line and controls. Untied ones say what they are: self-check: not a check here -- this pack is untied (lm_head and the embedding are separate matrices), so their correlation -0.005766 and rel_err 1.638304 are what an untied pack looks like, not a decode failure; the controls still run controls: nibble-swapped corr 0.0015, no zero-point corr -0.0126 (both must fail) The controls still run in both cases, because they are meaningful either way. Measured on models/amd-rai/hybrid-probe/phi3mini-1.7.1: 387 tensors / 454,392 tiles, exactly as before -- the import itself is unchanged. * npu-pack: a container says whether its head is tied, and an untied pack's does not answer garbage Phi-3-mini-4k-instruct_rai_1.7.1_hybrid imports cleanly (387 tensors, 454,392 tiles) and then answers garbage on the NPU -- "slower slower slower slower" -- and the same garbage on the HOST GEMM, which puts the fault upstream of every ELF and every artifact. The cause is one absent key. The forward decides between "logits from the token embedding" and "logits from a separate lm_head.weight" on `tie_word_embeddings` and defaults to TIED when the key is absent (npu/forward/src/q4nx_semantics.c). A genre-A pack ships no config.json at all, so the importer never wrote the key, so the forward decoded Phi-3's logits from its embedding matrix while the head tensor it had just imported sat unused in the container. Measured, same weights and same backend, only the key changed: (absent) ' slower slower slower slower slower ...' tie_word_embeddings: false ' Paris.\n\n- [Student]: What is the capital' " 4.\n\nNow, let's calculate the" " The sky appears blue to us because of the way Earth'" The container now states it, and where the pack gives no answer the DATA gives one, by the same correlation the self-check already reports: the imported head reproduces the embedding (>= 0.99) means tied, a separate matrix means not. So Phi-3-mini gets `tie_word_embeddings: false` with a `q4nx_tie_note` saying why, and SmolLM2-135M's hybrid (corr 0.9957) still gets true. A pack that declares the key is untouched: Llama-3.2-1B and Qwen2.5-0.5B keep the true they ship. The readout follows the same number, not the pack's declaration, because the two were disagreeing in the other direction: on a genre-A pack `tied` is false (no config.json), so a tied pack at corr 0.9957 printed "this pack is untied ... what an untied pack looks like" while the container it wrote said tied. Both now branch on the one threshold, and the line says which case it is and that it passed: self-check: the head reproduces the pack's fp16 embedding corr 0.995685 rel_err 0.103587 (tied: this correlation is the check, and it passed) Verified that the message and the container field agree on both sides of the threshold: smollm2-135m hybrid (0.9957) and qwen2.5-0.5b-1.7.1 hybrid (0.9945) both read tied and both write true; phi3mini-1.7.1 (-0.0058) reads untied and writes false. Two other candidates for the same garbage were ruled out first, with numbers, so this does not get re-litigated later: Phi-3's longrope is plain RoPE at theta 1e4 here (the pack's own cos_cache correlates 1.0000, and the implied inv_freq 0.825282 matches 0.825404), and the untied head decodes correctly (its correlation against the embedding is -0.0058, which is what an untied head is supposed to give). * npu-pack: Qwen3-4B's 1.9 shape, and o_proj's K in the kernel family is q_proj's width The measurement tool from #1532 found Qwen3-4B's entry on its first real pair, and importing with it exposed a second bug the family guard caught before it could serve wrong text: declaring "QKV:2560x6144,O:2560x2560,GU:2560x19456,D:9728x2560" this model derives "QKV:2560x6144,O:4096x2560,GU:2560x19456,D:9728x2560" o_proj's K is q_proj's OUTPUT width, which equals hidden only when heads*head_dim == hidden. Qwen3-1.7B satisfies that (16 x 128 = 2048), Qwen3-4B does not (32 x 128 = 4096 against a hidden of 2560), so the family string was wrong for exactly the models where the two differ. The guard refused the container rather than serving it, which is the behaviour #1480 was merged for. Qwen3-4B's 1.9 entry: prefix 9,536, 5 tiles of 1,462,272 rotated by 1, and 2,933 of 2,208,061,248 bytes differ from the 1.8 twin (1.3e-6) -- content, not layout. The prefix corroborates the Qwen3 offset at a second hidden size: 64 + 2*hidden + 4,352 gives 8,512 at 2,048 and 9,536 at 2,560. Verified: import 399 tensors / 491,000 tiles, 4,896/4,896 attention tiles and 6,912/6,912 MLP groups survive decode->encode, 108 norm anchors, self-check corr 0.999999 / rel_err 0.000737, and greedy text on the host GEMM: 'The capital of France is' -> ' in which country? Also, could you explain the difference between' '2 + 2 =' -> ' 4, but 2 + 2 = 4' 'Why is the sky blue?' -> ' What is the scientific explanation for this phenomenon?\n\nThe sky appears' * npu-pack: the tail self-check carries its own control, and 0.99 has a name Review finding, and it was right: the tail (.fconst) path set selfcheck_reference and selfcheck_tied, so it routed into the reporting branches, but it computed no controls -- so a 1.9 import printed controls: nibble-swapped corr 0.0000, no zero-point corr 0.0000 (both must fail) straight from the struct initialisers, and in the untied case asserted "the controls still run" when nothing had run. The negative case is the half of this PR's argument that carries it, so printing its numbers from defaults is exactly the wrong failure. The tail path has no MatMulNBits bytes to mis-decode, so its control is an ALIGNMENT one, on the same tiles and through the same decoder: each head row matched against the NEXT embedding row. A correct decode correlates ~0.999999 with the embedding; a misaligned head must not, and does not: controls: row-shifted corr 0.0716 (must fail) The host path keeps its nibble-swapped / no-zero-point controls, which are the right ones there because those bytes exist. Each path now prints only what it measured. Also from the review: the 0.99 tied threshold is now `constexpr double kTieCorr`, used by the two selfcheck_tied assignments, the container field and its note -- three literals that had just been reconciled by hand, which is how they drifted apart in the first place. The reviewer's other point, that a tied-by-inference container with a separate head imported decodes from the embedding and ignores that head, is left as the reviewer advised: the correlation is the best signal in the ambiguous case, and the note says which way it was decided and why. * npu-pack: Llama-3.2-1B's 1.9 shape, and the row that refutes my own explanation of the prefix The measurement tool found this entry on a non-Qwen family for the first time: {2048, 32, 16, 8512, 5, 1, 591872}, with 1,857 of 686,518,336 bytes differing from the 1.8 twin (2.7e-6 -- content, not layout). It also corrects a comment I wrote two entries ago. I had explained the extra 4,352 bytes in the prefix as Qwen3's per-head QK norm gains sitting inside the block before the bundle. Llama-3.2-1B has no QK norms at all and carries the same prefix at the same hidden size (8,512 at hidden 2,048), so the extra is a property of the geometry and not of the architecture. The table keeps doing its job -- it is measured -- but the explanation next to it was a plausible story that would have become the reason some future row was added wrongly. That is the second time this week a tidy explanation survived exactly one model. What differs from Qwen3-1.7B at the same hidden size is the bundle: 5 tiles of 591,872 against 3 of 1,183,744, so the two models cut the same payload into different pieces and the tile count alone identifies neither. Verified: import 147 tensors / 150,848 tiles (the same counts as its 1.8 twin), 1,280/1,280 attention tiles survive decode->encode, self-check corr 0.999999, and greedy text on the host GEMM: 'The capital of France is' -> ' Paris.\nThe capital of France is Paris. \nThe capital' '2 + 2 =' -> ' ?\nTo add 2 + 2, you simply count' 'Why is the sky blue?' -> ' The sky appears blue because of a phenomenon called Rayleigh scattering' --------- Co-authored-by: bong-water-water-bong <bong@users.noreply.github.com>
Reads a Ryzen AI 1.9 OGA NPU pack's Token
.fconstthrough the 1.8 reader, and imports thelm_headthat a 1.9 pack carries only in the.fconsttail.What actually differs in a 1.9 pack
Measured by whole-file diff against the 1.8 pack of the same model, and confirmed on three models (Qwen2.5-0.5B, SmolLM2-135M, Qwen3-1.7B):
4*NH-byte header is inserted after the q/k/v bundle — the four bytes00 00 7f ff, repeated once per head (14 groups on Qwen2.5-0.5B, 32 on Qwen3-8B, 9 × 36 B on SmolLM2-135M, 16 × 64 B on Qwen3-1.7B). Its length being the head count is what makes it a test rather than a magic number;Everything else in a block is verbatim, the stream after the header reads at
-4*NH, and the tail is identical — the entire file difference islayers × 4*NHbytes (24 × 56 = 1,344 on Qwen2.5-0.5B).The tile shape cannot be derived from the pack (it is not a subset sum of the pack's own const sizes — checked exhaustively), so the parameters are a measured table keyed by hidden/heads/layers, and a 1.9 pack outside that table is refused by name rather than decoded on a guess. That is the same rule this importer applies everywhere else: a confident parse of the wrong offsets is the worst failure mode.
How it is read
The normalised bytes are written once and the rest of the importer is unchanged — geometry, norms, attention payload, MLP section and tail all read one set of offsets, and the 1.8 path costs nothing (a 1.8 pack comes back as itself, no copy).
lm_head: a 1.9 pack has no host-side MatMulNBits head at all. The tail opens with the same 1,856-byte envelope the final norm sits in, then the head's own 64-row tiles at K = hidden, and1,856 + (vocab/64) × tile(hidden)must equal the tail length to the byte or the head is refused by name.Evidence (Qwen2.5-0.5B-Instruct,
rai_1.8.0_npu_4Kvsrai_1.9.0_npu).fconst, and the 82,655,040-byte tail is identical — the whole file;decode->encode, 48/48 RMSNorm anchors found, and the final norm read from the tail envelope;lm_head, whose tail tiles therefore decode to exactly the same Q4 as the 1.8 pack's host-side initializer;quantization_configversion strings,0.12rc4vs0.13).Testing
ctest -j6: 128 of 129 pass. The one failure,npu_runtime_option, is pre-existing and unrelated — it fails identically on a clean tree (it is a CMake-configure probe whose expected refusal message does appear).Scope
Qwen2.5-0.5B 1.9 is the verified entry in the table. Other 1.9 packs are refused with a message naming their hidden/heads/layers until their shape is measured against the 1.8 twin of the same model — which is the only thing that can establish it.