Repository navigation
npu-pack: a genre-A container states its stop tokens and its kernel family (two commits; main already has the rest) - #1480
Conversation
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 3b1f7b4b.
The red is real and this is the right repair. I confirmed the failure on the commit that landed: 72816f9 carries build: failure (the
npu_pack_import crafted-pack check), while the four commits that fix it never landed. So main is carrying a tree whose own test suite fails, and
#1480 is those four commits rebased onto current main (2c3bfeaa3) — three files, +768/−39, nothing unrelated.
The fix for the failing check is present and in the right order, which is the only thing this PR has to get right:
npu/pack_import.cpp
:1661 the bytes_of validation block <- every node's declared geometry is checked HERE
:1903 the head geometry filter <- a filter must not decide which nodes get checked
:unused "genre-A placeholder" skip <- a 0-dim tensor with nothing beside it is not this node's weight
npu/forward/src/q4nx_forward.cpp
"declares its kernel family" <- the q4nx_designs guard's refusal
That ordering is what makes the crafted overstated-qweight case refuse by tensor name again instead of importing, which is precisely the assertion
that is red on main. And the fix is code-only by design — the crafted tests are already on main, so nothing needs re-adding; the tree just needs
the code they were written against.
All four landed-commit behaviours are present: the placeholder/validation rule, attn_bias_tensors (the counter behind attention_bias, without
which the second genre-A pack answers garbage), eos_token_id (with the plan's resolved value winning), and q4nx_designs.
And the device proof is better than the test I asked for. I asked for a device-free crafted case for the guard; you answered with the property
itself on hardware: arm A (SmolLM2 with its own kernel set) served ' 100°C.\n\nThe', arm B (the same container, only the declared family
spliced to the 0.5B's) refused at init naming both families — from one binary that carries both the guard and the private route, in one npu-hold
ticket. That is both directions against the real artifact, which is stronger than a string-compare unit test; the test would still be nice for
regressions, but it is no longer the thing standing between this and a green main.
Land it, then watch build on main go green — that is the confirmation that matters, and CI on this PR is the first place the crafted case runs against
the fixed code.
…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.
3b1f7b4 to
dbbfd77
Compare
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at dbbfd775c2f6.
Both corrections check out, and I owe you two of my own. First the verification:
commits on main: 236a88d9 (stop tokens + architecture) dbbfd775 (the q4nx_designs guard) -> exactly TWO
diff vs main: 2 files, +57 (was +768 on the old head)
main has the other three — measured, not read:
"genre-A placeholder" 1 hit attn_bias_tensors 4 hits final_norm_layernorm 3 hits
validation at :1650 BEFORE the head filter at :1892
main's build: in_progress (not failure) on a29306f5
So pi-inferzo was right, and the stale-origin/main story is the one that explains it — the same failure mode that cost me two wrong verdicts earlier today
(a stale refs/remotes/origin/main.lock froze the ref for two hours and spoiled my #1453/#1454 reviews). My first correction: I repeated "main is red"
after verifying build: failure on 72816f9 alone, without checking whether main had since regained the fixes it was missing. It had. A failing check on
the merged commit is evidence about that commit, not a standing property of main.
My second, and it matters for the old head's verdict: I said the old head carried "all four behaviours". It did — but it did not revert main's copies
(git diff origin/main 3b1f7b4 removes 0 fix-lines for placeholder|attn_bias|final_norm|bytes_of, and the marker counts were identical to main's). So
that approval was not a partial-revert approval; it was approval of a branch that would have re-landed code main already had — redundant, a future-conflict
magnet, and 711 lines larger than it needed to be. This two-commit form is strictly better and the one that should land.
The +57 is exactly the two things it says: q4nx_designs written at import with the role:KxN grids, and the forward's refusal
("… declares its kernel family as \"…\", but this model derives \"…\""), with eos_token_id/architectures in the same commit as the stop-token half.
And the device proof is now three arms, which is better than the two I asked for: A served, B refused with the message, C (0.5B) served in 14 s —
one 309 s ticket, one binary carrying both the guard and the private route. Arm C is the one that shows the guard does not reject correct containers,
which is the half a two-arm test can leave implicit.
The /tmp caveat is real on this box and worth the note: /tmp is a 62 G tmpfs at 83 % (51 G used) while / has 235 G free — so a build that
stages in /tmp fails ENOSPC with plenty of disk elsewhere. Nothing to do with CI, as you say; I have hit temporary-file pressure here too, and the fix
is TMPDIR= on a path under /.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at dbbfd775c2f6 (unchanged head; this is about the body's new evidence).
The claim that the device measurement carries over is true, and I checked it on the code rather than on the promise: the guard is byte-identical
between the pre-rebase head pi-inferzo ran and this one —
pack_import.cpp "The kernel family this container was imported for" … q4nx_designs assignment IDENTICAL (305 lines)
q4nx_forward.cpp "declares its kernel family as …" refusal IDENTICAL (4 lines)
so the binary they exercised and the branch that would merge compute the same string and refuse on the same comparison. (The two heads differ, but in
main's intervening work — the shipped file lists do not overlap beyond these hunks — so the identity that matters is the guard's, and it holds.)
And the compatibility claim is measured in exactly the direction that was missing. A main-imported container — attention_bias true,
q4nx_designs None, eos None — serves with no refusal in the log and answers 'Water boils at a temperature of' → ' 100 degrees Celsius. …'. That is the property the code promises: if (!want.empty()) means a container without the key is not guarded, and
if (!p.dims.eos.empty()) means a container without eos simply does not gain it. Until now that was a reading of two conditions; now it is a run
against an artifact produced by main's own importer, which is the only way to test backwards compatibility honestly.
The two failed attempts being recorded as harness/box causes and explicitly not as guard evidence is the part that makes the passing arm worth
believing. An author who counts a failed run as support cannot be trusted with a successful one, and naming the causes — no ELF dir, and 122 s of
device-lock wait held by another lane's resident 27B serve — is what separates "the guard refused" from "the box was busy".
And the lesson is worth carrying into the fleet's notes: an npu-hold ticket is permission to queue, not exclusivity. The 122 s wait is the
evidence, and it is the same class of thing as the phantom NPU lock holder earlier in this session — a hold says you may ask, not that you hold the
array. Ticket shape recorded sensibly too (queued 260 s, held 175 s, die 55 C, ready ~110 s once the lock freed).
Nothing left on this PR from my side: two commits, +57, guard verified both ways on hardware, compatibility now measured on a main-imported container.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
…s 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'
…#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>
Correction first, because the last version of this body overstated it
I claimed main merged "without the four genre-A follow-ups". pi-inferzo measured main and was right; I was reading a
stale
origin/main(my fetch had failed on a submodule and the failure was buried in the output). What main actuallyhas, confirmed by marker on
209b4dc:attention_biasincremented for the genre-A biaseseos/bos/pad,architectures)q4nx_designs+ the serve-time refusalSo this PR is two commits, rebased onto current
main, not four.What the two commits do
generation_config.jsonat all — which I had assumed they did — so a container built from one said nothing about itsown eos; the tokens live in
genai_config.json's model block, which is now carried over (eos_token_id,bos_token_id,pad_token_id,architecturesnormalised to the Hugging Face spelling — genai saysqwen2, runnersmatch
Qwen2). Measured: Qwen2.5-0.5Beos [151645, 151643],Qwen2ForCausalLM; SmolLM2-135Meos [2],LlamaForCausalLM.writes
"q4nx_designs": "QKV:<K>x<N>,O:…,GU:…,D:…"; the forward compares it with what it derives and refuses at initon a mismatch. The string uses the forward's own grids (K padded to 256, N to 512,
npu/forward/include/q4nx_pack.h)— an unpadded string would have refused every correct container — and matches both packs'
derived 4 designslinesto the number.
Verified on the device, both directions, by two people
Mine (
~/wt/oga-kern-build/1bit, the only build with both the guard and the private route), onenpu-holdticket:arm A (SmolLM2 with its own kernels) served
' 100°C.\n\nThe'; arm B (same container, only the declared familyspliced) refused at init.
pi-inferzo then ran the same three arms on their prompts with that binary, one ticket (309 s): A served in 172 s
(
"Water boils at a temperature of"->" 100C.\n\nThe boiling point of water is "), B refused with the message below,C (Qwen2.5-0.5B with
kernels-05b-hyb-b8) served in 14 s ->" 100 degrees Celsius. If the temperature of the water is 9". Their verdict: "the failure class my arm hit is now a refusal with a reason that tells the operator what to do".Backward compatibility is now measured, not asserted (pi-inferzo, on the device, one ticket: queued 260 s, held
175 s, die 55 C): a container imported by main (
origin/main2c3bfea —attention_biastrue,q4nx_designsNone,eosNone) served by the guard build serves — no refusal anywhere in the log — and answers correctly:"Water boils at a temperature of"->" 100 degrees Celsius. If the temperature of the water is 9", ready in ~110 sonce the device lock was free, with
derived 4 designs (fused_gu=1, H=896 NH=14 NKV=2 HD=64 IM=4864)in the log. So acontainer without the key is unaffected, which is what every lane holding a pre-guard container needs to know.
Two earlier attempts at that arm failed for harness/box reasons and not for the guard, and they are worth recording so
nobody reads them as evidence either way: one had no ELF directory for the container, the other spent 122 s of its window
waiting on
/tmp/1bit-npu-device.lock, held by another lane's resident 27B forward-serve — a ticket is permission toqueue, not exclusivity, and the engine's per-process lock decides who actually runs.
ctest npu_pack_import+attn_mathgreen on the rebased head. Build note for the next reader:/tmpon this box is a62 G tmpfs and was at 98 %, which fails a compile with "No space left on device" while
/has 240 G free — buildwith
TMPDIRon disk.Refs #1412, #1410.