Skip to content

npu-pack: read a Ryzen AI 1.9 Token .fconst in its 1.8 form, head included - #1507

Merged
bong-water-water-bong merged 3 commits into
mainfrom
1bit/npu-fconst-19
Nov 8, 2026
Merged

bong-water-water-bong merged 3 commits into
mainfrom
1bit/npu-fconst-19

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Reads a Ryzen AI 1.9 OGA NPU pack's Token .fconst through the 1.8 reader, and imports the lm_head that a 1.9 pack carries only in the .fconst tail.

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

  • 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 × 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;
  • the q/k/v bundle is stored q,k,v where 1.8 stores k,q,v.

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 is layers × 4*NH bytes (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, and 1,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_4K vs rai_1.9.0_npu)

  • 24/24 layer blocks normalise byte-identically to the 1.8 .fconst, and the 82,655,040-byte tail is identical — the whole file;
  • 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, 48/48 RMSNorm anchors found, and the final norm read from the tail envelope;
  • the two resulting containers are then payload-identical: all 291 tensor blobs compare equal, as do 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 difference between the two containers is metadata only (pack name, and the packs' own quantization_config version strings, 0.12rc4 vs 0.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.

bong-water-water-bong and others added 3 commits November 8, 2026 10:23
…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 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 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 gap

A 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 tiles

and 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):

  1. kTailNorm = 1856 encodes hidden 896 — [2 eps][62 pad][1792 norm], and 1792 = 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 == 896 where it is used, so a second row fails loudly instead of mis-parsing — which is the principle the rest of this PR follows.
  2. kFconstHidden = 896 is declared and never used (one occurrence in the file, the declaration). Dead const, and a -Wunused-const-variable candidate.

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.

@bong-water-water-bong
bong-water-water-bong merged commit 8e7fb13 into main Nov 8, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npu-fconst-19 branch November 8, 2026 17:09
bong-water-water-bong pushed a commit that referenced this pull request Nov 8, 2026
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 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 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 bytes 00 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.

@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: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Incorrect Assumption on Tile Shape Derivation

The code assumes that the tile shape for Ryzen AI 1.9 packs cannot be derived from the pack itself and must be measured empirically. While this is stated as a design choice, it introduces a potential point of failure if the measured rules do not cover all possible configurations or if new models are introduced without corresponding measurements. This could lead to a refusal to process valid packs that fall outside the known rules.

struct Fconst19Rule {
    int hidden, heads, layers, n_tiles, tile, rot;
};
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},
};
Risk of Incorrect Header Detection

The fconst19_detect function relies on detecting a specific pattern (00 00 7f ff) repeated per head to identify the header. If the pattern is not consistently applied or if there are false positives in the detection logic, it could incorrectly identify a 1.8 pack as a 1.9 pack, leading to incorrect processing and potential runtime errors.

bool fconst19_detect(const Fconst& fc, int heads, int layers, int64_t* hdr_off, int64_t* stride) {
    if (heads <= 0 || layers < 2) return false;
    const std::vector<uint8_t> pat = fconst19_header(heads);
    std::vector<int64_t> runs;
    for (size_t i = 0; i + pat.size() <= fc.size;) {
        if (fc.map[i] == pat[0] && fc.map[i + 2] == 0x7f && fc.map[i + 3] == 0xff &&
            std::memcmp(fc.map + i, pat.data(), pat.size()) == 0) {
            runs.push_back(int64_t(i));
            i += pat.size();
        } else {
            ++i;
        }
    }
    if (int(runs.size()) != layers) return false;
    const int64_t s = runs[1] - runs[0];
    if (s <= int64_t(pat.size())) return false;
    for (size_t k = 0; k + 1 < runs.size(); ++k)
        if (runs[k + 1] - runs[k] != s) return false;
    *hdr_off = runs[0];
    *stride = s;
    return true;
}
Inconsistent Handling of lm_head in 1.9 Packs

The code handles the lm_head differently for 1.9 packs compared to 1.8 packs by reading it from the tail of the .fconst file. If the arithmetic to determine the tail offset or the tile layout is incorrect, it could lead to reading invalid memory or refusing valid heads, which might cause import failures.

if (onnx.matmuls.empty()) {
    const int64_t tail_off = int64_t(p.dims.layers) * g.stride;
    const int64_t tile = attn_tile_bytes(H);
    const int64_t rows = V / kTileRows;
    const int64_t want = kTailNorm + rows * tile;
    const int64_t have = int64_t(fc.size) - tail_off;
    if (V % kTileRows != 0 || want != have) {
        late.push_back("lm_head.weight: the .fconst tail is " + std::to_string(have) + " B, not the " +
                       std::to_string(want) + " B its envelope and head tiles would take (" +
                       std::to_string(kTailNorm) + " + " + std::to_string(rows) + " x " +
                       std::to_string(tile) + " B), so the head's offsets are not established");
    } else {
        std::vector<TileW> tiles;
        tiles.reserve(size_t(rows));
        bool exact = true;
        for (int64_t i = 0; i < rows; ++i) {
            const uint8_t* src = at(fc, tail_off + kTailNorm + i * tile, tile);
            tiles.push_back(decode_tile(src, int(H)));
            const std::vector<uint8_t> back = encode_tile(tiles.back());
            if (std::memcmp(back.data(), src, size_t(tile)) != 0) exact = false;
        }
        if (!exact) {
            late.push_back("lm_head.weight: the .fconst tail's tiles do not survive decode->encode, "
                           "so their fields are not understood");
        } else {
            Q4 q = q_from_tiles(tiles);
            std::vector<uint8_t> tiled;
            long long pinexact = 0;
            tile_q4_1_f32(q, tiled, &pinexact);
            r.codes_total = q.codes.size();
            r.zero_exact_pct = 100.0 * double(q.scale.size() - pinexact) / double(q.scale.size());
            const int64_t head_tiles = int64_t(tiled.size() / kImportTileBytes);
            r.tiles += head_tiles;
            w.add("lm_head.weight", "I8", {head_tiles, kImportTileBytes}, std::move(tiled));
            ++r.tensors;
        }
    }
}

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