Skip to content

tools: measure a 1.9 pack against its 1.8 twin, and print the table line to add - #1532

Merged
bong-water-water-bong merged 1 commit into
mainfrom
1bit/fconst19-measure-tool
Nov 8, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
1bit/fconst19-measure-tool

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Every Ryzen AI 1.9 pack needs an entry in npu/pack_import.cpp's kFconst19Rules table, and that 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 (checked exhaustively — and the "packed q/k/v bundle" reading leaves 17,280 B unexplained, which is exactly the zero-point total again: the kind of near-miss that makes a wrong rule look plausible);
  • the block prefix is not 64 + 2*hidden either — 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

  1. the headers — exactly layers runs of 00 00 7f ff × heads, evenly spaced;
  2. the prefix and the rotated bundle's tile count / tile size / rotation, from the content displacement, 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 by 2,560 B in an earlier hand measurement;
  3. then it 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. A near-miss rule is worse than none, so the tool will not emit one.

Verified on the one pair still on disk

$ tools/fconst19_measure.py --pack-1-8 Qwen2.5-0.5B-Instruct_rai_1.8.0_npu_4K \
                            --pack-1-9 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},

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". --json is there for scripting it across a catalog.

…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 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: 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 layers runs of 00 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 single Llm_Token_*), which is what made my run a fair Token-vs-Token comparison;
  • --json exists 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 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: 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_fconst takes the prefix as attn_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 prefix to Fconst19Rule and use it in canonical_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*hidden and say the importer needs a prefix field first. (a) is better, since Qwen3 is the known case.

3. Smaller:

  • while b0[prefix] == a0[prefix] raises IndexError instead 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-3 and :16-17).
  • The differ count 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 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 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:

  1. 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 derived prefix 1,856, which is what "the coarsest
    equivalent" means.
  2. The field order matches Fconst19Rule exactly (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.

@bong-water-water-bong
bong-water-water-bong merged commit 452f07f into main Nov 8, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/fconst19-measure-tool branch November 8, 2026 19:40
bong-water-water-bong pushed a commit that referenced this pull request Nov 8, 2026
…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'
@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Missing Input Validation for File Paths

The script does not validate that the provided --pack-1-8 and --pack-1-9 directories exist and contain valid token .fconst files. This could lead to runtime errors or unexpected behavior if the paths are incorrect or inaccessible.

ap.add_argument("--pack-1-8", required=True, help="the 1.8 pack of the same model")
ap.add_argument("--pack-1-9", required=True, help="the 1.9 pack to measure")
ap.add_argument("--json", action="store_true", help="print the result as JSON instead of the table line")
args = ap.parse_args()

hidden, heads, layers = geometry(args.pack_1_9)
a = open(token_fconst(args.pack_1_8), "rb").read()
b = open(token_fconst(args.pack_1_9), "rb").read()
Potential Performance Bottleneck in Block Matching

The candidates function includes nested loops and repeated slicing operations which may cause performance issues when processing large files or complex block structures. This could slow down the verification process significantly.

def candidates(a, b, heads, layers):
    """Every (prefix, n_tiles, tile, rot) that fits the first block, coarsest first."""
    hdr_len = heads * 4
    header_off, stride9 = find_headers(b, heads, layers)
    stride8 = stride9 - hdr_len
    if len(a) - layers * stride8 != len(b) - layers * stride9:
        raise SystemExit("the two files' tails differ in length")
    a0, b0 = a[:stride8], b[:stride9]
    if a0[header_off:] != b0[header_off + hdr_len:]:
        raise SystemExit("block 0's tail does not match at -4*NH")
    prefix = 0
    while b0[prefix] == a0[prefix]:
        prefix += 1
    probe = b0[prefix:prefix + 4096]
    d = next((c for c in range(4096, (header_off - prefix) // 2) if a0[prefix + c:prefix + c + 4096] == probe), None)
    if d is None:
        raise SystemExit("the payload head does not reappear in the 1.8 block at any displacement")
    out = []
    for rot in range(1, 9):
        if d % rot or d // rot < 4096:
            continue
        tile = d // rot
        for n in range((header_off - prefix) // tile, 1, -1):
            if all(b0[prefix + i * tile:prefix + (i + 1) * tile]
                   == a0[prefix + ((i + rot) % n) * tile:prefix + ((i + rot) % n + 1) * tile] for i in range(n)) \
               and b0[prefix + n * tile:header_off] == a0[prefix + n * tile:header_off]:
                out.append((prefix, n, tile, rot, header_off, stride9))
                break
    if not out:
        raise SystemExit("no rotation fits block 0")
    return out

bong-water-water-bong added a commit that referenced this pull request Nov 8, 2026
…#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 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. 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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 [head_dim] — one vector per projection, 56 = 28 layers × 2 — and the norm is applied per head at forward time. So:

The reason to fix the wording here rather than in #1533: this PR's 8,512 note is what a future lane will use to size a prefix, and "per-head gains" invites [n_heads, head_dim] (16× too large at 16 heads) — which is precisely the near-miss class this tool refuses to emit. Suggested phrasing: "Qwen3 carries its QK norm gains ([head_dim] each, one per projection, applied per head) before the bundle, so its prefix is 8,512 where 64 + 2*hidden says 4,160."

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.

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.

2 participants