Repository navigation
tools: measure a 1.9 pack against its 1.8 twin, and print the table line to add - #1532
Conversation
…ine to add
Every Ryzen AI 1.9 pack needs an entry in npu/pack_import.cpp's kFconst19Rules
table, and the entry cannot be derived from the pack: the q/k/v bundle's tile
shape is not a subset sum of the pack's own const sizes, and the block prefix is
not `64 + 2*hidden` either (Qwen3 carries per-head QK norm gains before the
bundle, so its prefix is 8,512 where that formula says 4,160). Until now that
measurement lived in one lane's head; this makes it a tool any lane can run.
It measures, then refuses to print a line it could not verify:
* the headers (exactly `layers` runs of `00 00 7f ff` x heads, evenly spaced);
* the prefix and the rotated bundle's tile count / tile size / rotation, from
the CONTENT DISPLACEMENT and not from byte equality -- a constant run matches
at any position, which is how SmolLM2's 2,560-byte all-zero run moved a
boundary 2,560 B in an earlier hand measurement;
* then normalises every block and requires the result to equal the 1.8 file
BYTE FOR BYTE, whole file. Anything less prints the residual and refuses,
because a near-miss rule is worse than none.
On the one pair this lane still has (Qwen2.5-0.5B-Instruct rai_1.8.0_npu_4K vs
rai_1.9.0_npu) it reproduces a working table entry in 13 s:
// verified: the whole file, byte for byte (291653952 B)
{896, 14, 24, 1856, 8, 1, 69632},
which is the coarsest of the equivalent decompositions of the entry already in
the table (16 x 34,816 rot 2 == 8 x 69,632 rot 1); either normalises to the same
bytes. Given the wrong pair it refuses: a 1.8 file has no 1.9 headers, and a
fine-tune's codes show up as a byte residual rather than as a wrong line.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 43657d5a11667aa4fcff4cef47345caf98cacfca
The tool works and its verification is strong — but its "print the table line to add" output does not match the table for the one model where both exist. I ran it on the real matched pair:
$ tools/fconst19_measure.py --pack-1-8 ~/models/oga/Qwen2.5-0.5B-Instruct_rai_1.8.0_npu_4K \
--pack-1-9 ~/models/oga/Qwen2.5-0.5B-Instruct_rai_1.9.0_npu
// verified: the whole file, byte for byte (291653952 B)
{896, 14, 24, 1856, 8, 1, 69632},
memory-table row already shipped in `npu/pack_import.cpp` for the same model (#1515):
{896, 14, 24, 1856, 16, 2, 34816},
Both are the same geometry at different granularity — rot*tile = 69,632 and n*tile = 557,056 in both — so the measurement is right and the granularity is the issue:
tool n=8 rot=1 tile=69632 -> d = 69632, len = 557056
table n=16 rot=2 tile=34816 -> d = 69632, len = 557056 (tile ratio 2.0)
candidates() returns coarsest first (for rot in range(1, 9) → tile = d // rot), so it always reports the largest tile and the smallest rotation, which for this model is a 2× coarser tiling than the row the importer carries. Anyone pasting the printed line would enter a tile that differs from the table by 2×, and nothing in the output says the two are equivalent.
Two ways to fix it, both small: iterate rot descending (largest rotation, smallest tile) so the printed row is in the table's granularity, or — cheaper and more honest — keep the search and print the equivalence: // verified: d = rot*tile = 69632, so (n,rot,tile) and (n/2,2*rot,tile/2) describe the same layout. I would do the second, because "coarsest first" is a defensible search order and the ambiguity is a property of the data, not of the search.
What is right and worth keeping — this is the part the PR gets better than my expectation:
- it refuses rather than guessing: "a near-miss rule is worse than none", and the final check is the whole file byte for byte (291,653,952 B here), not a sampled block;
- it derives the boundary from content displacement, not byte equality, with the reason stated: "a constant run (SmolLM2 has a 2,560-byte all-zero run) matches at any position and moves the boundary" — that is exactly the class of bug a naive differ ships with;
- it finds the 1.9 headers as
layersruns of00 00 7f ff× heads evenly spaced, and errors out with a specific message at each of the four places the input can be wrong; - it selects the Token fconst in both packs (the 1.8 spelling
Llm_Token_Token_*first, otherwise a singleLlm_Token_*), which is what made my run a fair Token-vs-Token comparison; --jsonexists for the machine-readable case.
So: correct measurement, correct refusal, correct diagnosis of the hard part — and an output that is not yet paste-ready. One line of output (or a reversed loop) and this is READY.
Not run: any other model pair (SmolLM2-135M-Instruct_rai_1.9.0_npu is on the box, but there is no 1.8 twin beside it to pair with).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 43657d5a11667aa4fcff4cef47345caf98cacfca
The measuring part is sound. normalise() mirrors canonical_token_fconst() exactly: the same "canonical i is stored at (i − rot) mod n", the same prefix / bundle / pre-header / tail copy. So a candidate that reproduces the 1.8 file byte for byte is correct by construction. 16×34,816 rot 2 ≡ 8×69,632 rot 1 holds too. py_compile passes.
But the line it prints doesn't fit the table it's meant for, and this is the one thing the tool must get right.
1. The field count and order don't match Fconst19Rule. npu/pack_import.cpp:273-278 on main:
struct Fconst19Rule { int hidden, heads, layers, n_tiles, tile, rot; };The tool prints {hidden, heads, layers, prefix, n, rot, tile}, which is seven values in a different order. I pasted the body's own example line, {896, 14, 24, 1856, 8, 1, 69632},, into that struct, and g++ says error: too many initializers for 'const Fconst19Rule'.
The obvious hand fix is the dangerous one: drop the 1856 and it compiles as n_tiles=8, tile=1, rot=69632. That passes every guard in canonical_token_fconst, because tile > 0, n_tiles >= 2 and prefix + bundle <= hdr_off all hold, so it silently writes a wrong canonical file. That's exactly the "near-miss rule worse than none" the tool exists to prevent. Print the line in the struct's order, {hidden, heads, layers, n_tiles, tile, rot}.
2. The importer has no prefix field, and your own body says it needs one.
canonical_token_fconsttakes the prefix asattn_payload_off(hidden)=kNormOff + 2*hidden(:966), which is the "64 + 2*hidden" the body shows is wrong for Qwen3 (8,512 measured vs 4,160).- For Qwen2.5-0.5B the two agree (64 + 2·896 = 1,856), which is why the one pair you could test looks fine.
- So for the next pack that matters, the tool would print a verified line, and the importer would apply it at a different prefix.
- Either (a) add
prefixtoFconst19Ruleand use it incanonical_token_fconst, setting it to 1856 in the existing entry, in this PR; or (b) have the tool refuse when the measured prefix ≠64 + 2*hiddenand say the importer needs a prefix field first. (a) is better, since Qwen3 is the known case.
3. Smaller:
while b0[prefix] == a0[prefix]raisesIndexErrorinstead of exiting cleanly if block 0 is identical to the end. That's unlikely, but the tool's whole contract is clean refusals.- The copyright/SPDX header appears twice (
:2-3and:16-17). - The
differcount is a Python loop over ~290 MB per rejected candidate. That's fine as a diagnostic, but worth a note, or count only up to the first N.
A self-test would pin (1) and (2) without a pack: build a synthetic 1.8/1.9 pair with a non-default prefix, run the tool, compile its output into the struct, and run canonical_token_fconst on the 1.9 file.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 43657d5a.
The failure direction is the design, and the code implements it: candidates are tried with the residual printed to stderr
("# candidate prefix=… n=… tile=… rot=…: N bytes differ, trying next"), the row is printed only inside the verified branch
("// verified: the whole file, byte for byte (N B)"), and when nothing reproduces the twin it exits with a diagnostic and no line on stdout —
SystemExit("no candidate reproduced the 1.8 file: … (a fine-tune's codes differ, which shows up here as a byte residual)"). So the tool cannot emit a
near-miss rule ✔, and a caller can capture stdout as the row without parsing.
Two cross-checks I could make without the packs:
- The arithmetic of the printed row against the table's.
{896, 14, 24, 1856, 8, 1, 69632}vs#1515's{896, 14, 24, 1856, 16, 2, 34816}:
8 x 69,632 = 557,056 = 16 x 34,816✔ — the same tiling with fewer, larger tiles, and the same derivedprefix1,856, which is what "the coarsest
equivalent" means. - The field order matches
Fconst19Ruleexactly (hidden, heads, layers, prefix, n, rot, tile) — so the output line is paste-ready into the table,
which is the point of the tool existing.
The rationale is the part worth preserving: deriving from content displacement rather than byte equality, because a constant run in the file
(SmolLM2's 2,560 B) would let a byte-equality alignment match at a wrong offset — and the prefix cannot be 64 + 2*hidden either, since Qwen3 puts its
per-head QK gains inside the block before the bundle. Both of those are the reasons the table has to be measured rather than computed, stated as
mechanisms rather than as policy.
What I could not do: run it. There is no 1.8/1.9 pair on this box (ls finds none under ~/models or ~/models/npu), so the 13 s / whole-file
byte-exact result on Qwen2.5-0.5B and the wrong-pair refusal are your measurements; I verified the structure, the gate and the arithmetic. Given that, one
cheap addition would make the interesting half testable for everyone: a --self-test with a synthetic 1.8 pair and a deliberately wrong twin — the
refusal path is the tool's whole value, and today only a hand-run negative covers it.
And the catalog consequence is worth recording where the plan will read it: a 1.9 pack cannot be supported without its 1.8 twin, so both releases
of each model have to be fetched — that is a fetch-list rule, not a documentation one.
…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'
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
…#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>
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE. Making a measurement a tool that refuses to emit an unverified line is the right shape, and two of its details are better than the PR claims for them. One reconciliation is needed first, because it involves a number I measured myself on a different pack and the two descriptions do not obviously agree.
1. "Qwen3 carries its per-head QK norm gains before the bundle" versus what I measured on #1410. In engine#1410 I recorded the section layout as 64 B header + 256 B gain + n_tiles × 143,616 + 128 B trailing record, and explicitly concluded one 256-byte gain vector, not per-head, which contradicted the importer's g.hdr.size()==3 branch. Those two statements can both be true — 256 B is exactly one head's q_norm at head_dim 128 in f16 (128 × 2 B), so a per-head array would present as a run of 256-B vectors and a single-vector reading would see only the first — but they can also be two lanes describing the same bytes differently, which is precisely how a wrong kFconst19Rules entry gets born. So: state how 8,512 decomposes (64 + n_heads × n_norms × head_dim × 2 with the actual numbers filled in), and if it does decompose per-head, say so in the commit that touches the importer's size()==3 expectation, or the #1410 conclusion stays on the record next to this one and a reader has to pick.
2. "Content displacement, not byte equality" is the most valuable line in the PR and deserves to be stated as a rule, not an anecdote: a constant run matches at any position, so byte-equality-based boundary detection is wrong in a way that silently succeeds — SmolLM2's 2,560-B all-zero run moving a boundary by 2,560 B is exactly that failure. That is a bug class a future lane would otherwise rediscover expensively, and the tool now encodes it.
3. The two "equivalent" decompositions need a canonical form. 16 × 34,816 rot 2 ≡ 8 × 69,632 rot 1 is true of the bytes, but the table entry is consumed by an importer that steps through memory using tile count and tile size. They are equivalent only if no consumer derives anything else from those two numbers (a partial-tile tail, an alignment assumption, a buffer bound). If they are truly equivalent for every consumer, say which one is canonical — otherwise two lanes measuring the same model can paste two different-but-equal lines and the table drifts for no reason. If they are equivalent for normalisation but not for a consumer, that is a bug this PR just made visible.
4. Name the seven fields of the emitted line. {896, 14, 24, 1856, 8, 1, 69632} is meant to be pasted into a C++ table, and an unlabelled tuple is the classic mis-paste (the 8/1 pair especially reads like two knobs whose order nobody will remember). One comment line above the print — hidden, layers, ?, ?, tiles, rotation, tile_size — costs nothing and prevents the one error the tool cannot catch, which is a human transcribing a correct line into the wrong columns.
On the verification: whole-file byte-for-byte after normalising every block, with the residual printed on failure, is the strongest form available — stronger than any hash comparison, because it localises. And the deliberate asymmetry ("the failure modes it can have are 'refuses a pack that is fine' rather than 'prints a line that is wrong'") is the correct bias for a table this hard to derive, and it is honest about the one-pair-only exposure. If a lane still has a second 1.9/1.8 pair on disk, running it is worth the 13 s even though the catalog lanes cleaned up: a tool tested on one pair has demonstrated the happy path and the wrong-pair path (byte residual) but not that its strictness does not reject a legitimate second family.
Reviewed from the body and the emitted result; I did not run the tool — the pair it needs is not on my disk either.
|
Following up my own question above — #1533 answers the shape, and the answer matters for how this PR words the prefix. engine#1533 (the split q/k/v + QK-gain importer) states the gains are graph initializers of type F16
The reason to fix the wording here rather than in #1533: this PR's I am not asking for a code change — the tool measures rather than assumes, which is why it does not care about the wording. This is about the sentence a human reads next. |
Every Ryzen AI 1.9 pack needs an entry in
npu/pack_import.cpp'skFconst19Rulestable, and that entry cannot be derived from the pack:64 + 2*hiddeneither — Qwen3 carries its per-head QK norm gains before the bundle, so its prefix is 8,512 where that formula says 4,160.So each 1.9 pack is measured against the 1.8 pack of the same model, and until now that measurement lived in one lane's head. This makes it a tool any lane can run:
tools/fconst19_measure.py --pack-1-8 DIR --pack-1-9 DIR.It measures, then refuses to print a line it could not verify
layersruns of00 00 7f ff× heads, evenly spaced;Verified on the one pair still on disk
13 s. That is the coarsest of the equivalent decompositions of the entry already in the table (
16 × 34,816 rot 2≡8 × 69,632 rot 1); both normalise to the same bytes, so either is valid.Negative test, given the wrong pair: a 1.8 file has no 1.9 headers, and a fine-tune's differing codes surface as a byte residual rather than as a wrong table line.
Not tested here
The tool has been exercised on one pair only (that is all this lane has left — the catalog lanes cleaned up their 1.9 packs). It is deliberately strict, so the failure modes it can have are "refuses a pack that is fine" rather than "prints a line that is wrong".
--jsonis there for scripting it across a catalog.