Skip to content

npu-pack: a genre-A container states its stop tokens and its kernel family (two commits; main already has the rest) - #1480

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-genre-a-followups
Nov 8, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-genre-a-followups

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Correction first, because the last version of this body overstated it

I claimed main merged "without the four genre-A follow-ups". pi-inferzo measured main and was right; I was reading a
stale origin/main
(my fetch had failed on a submodule and the failure was buried in the output). What main actually
has, confirmed by marker on 209b4dc:

item on main?
per-MatMulNBits geometry validation (the CI fix) yes
attention_bias incremented for the genre-A biases yes (so main does not produce the gibberish container)
final norm by its real name yes
container stop tokens (eos/bos/pad, architectures) no
q4nx_designs + the serve-time refusal no

So this PR is two commits, rebased onto current main, not four.

What the two commits do

  • A genre-A container states the pack's stop tokens and architecture. These packs ship no
    generation_config.json
    at all — which I had assumed they did — so a container built from one said nothing about its
    own eos; the tokens live in genai_config.json's model block, which is now carried over (eos_token_id,
    bos_token_id, pad_token_id, architectures normalised to the Hugging Face spelling — genai says qwen2, runners
    match Qwen2). Measured: Qwen2.5-0.5B eos [151645, 151643], Qwen2ForCausalLM; SmolLM2-135M eos [2],
    LlamaForCausalLM.
  • A container states the kernel family it was imported for, and a serve that derives another one refuses. Import
    writes "q4nx_designs": "QKV:<K>x<N>,O:…,GU:…,D:…"; the forward compares it with what it derives and refuses at init
    on a mismatch. The string uses the forward's own grids (K padded to 256, N to 512, npu/forward/include/q4nx_pack.h)
    — an unpadded string would have refused every correct container — and matches both packs' derived 4 designs lines
    to the number.

Verified on the device, both directions, by two people

Mine (~/wt/oga-kern-build/1bit, the only build with both the guard and the private route), one npu-hold ticket:
arm A (SmolLM2 with its own kernels) served ' 100°C.\n\nThe'; arm B (same container, only the declared family
spliced) refused at init.

pi-inferzo then ran the same three arms on their prompts with that binary, one ticket (309 s): A served in 172 s
("Water boils at a temperature of" -> " 100C.\n\nThe boiling point of water is "), B refused with the message below,
C (Qwen2.5-0.5B with kernels-05b-hyb-b8) served in 14 s -> " 100 degrees Celsius. If the temperature of the water is 9". Their verdict: "the failure class my arm hit is now a refusal with a reason that tells the operator what to do".

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)

Backward compatibility is now measured, not asserted (pi-inferzo, on the device, one ticket: queued 260 s, held
175 s, die 55 C): a container imported by main (origin/main 2c3bfea — attention_bias true, q4nx_designs None,
eos None) served by the guard build serves — no refusal anywhere in the log — and answers correctly:
"Water boils at a temperature of" -> " 100 degrees Celsius. If the temperature of the water is 9", ready in ~110 s
once the device lock was free, with derived 4 designs (fused_gu=1, H=896 NH=14 NKV=2 HD=64 IM=4864) in the log. So a
container without the key is unaffected, which is what every lane holding a pre-guard container needs to know.

Two earlier attempts at that arm failed for harness/box reasons and not for the guard, and they are worth recording so
nobody reads them as evidence either way: one had no ELF directory for the container, the other spent 122 s of its window
waiting on /tmp/1bit-npu-device.lock, held by another lane's resident 27B forward-serve — a ticket is permission to
queue, not exclusivity
, and the engine's per-process lock decides who actually runs.

ctest npu_pack_import + attn_math green on the rebased head. Build note for the next reader: /tmp on this box is a
62 G tmpfs and was at 98 %, which fails a compile with "No space left on device" while / has 240 G free — build
with TMPDIR on disk.

Refs #1412, #1410.

@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 3b1f7b4b.

The red is real and this is the right repair. I confirmed the failure on the commit that landed: 72816f9 carries build: failure (the
npu_pack_import crafted-pack check), while the four commits that fix it never landed. So main is carrying a tree whose own test suite fails, and
#1480 is those four commits rebased onto current main (2c3bfeaa3) — three files, +768/−39, nothing unrelated.

The fix for the failing check is present and in the right order, which is the only thing this PR has to get right:

npu/pack_import.cpp
  :1661   the bytes_of validation block      <- every node's declared geometry is checked HERE
  :1903   the head geometry filter           <- a filter must not decide which nodes get checked
  :unused  "genre-A placeholder" skip        <- a 0-dim tensor with nothing beside it is not this node's weight
npu/forward/src/q4nx_forward.cpp
  "declares its kernel family"               <- the q4nx_designs guard's refusal

That ordering is what makes the crafted overstated-qweight case refuse by tensor name again instead of importing, which is precisely the assertion
that is red on main. And the fix is code-only by design — the crafted tests are already on main, so nothing needs re-adding; the tree just needs
the code they were written against.

All four landed-commit behaviours are present: the placeholder/validation rule, attn_bias_tensors (the counter behind attention_bias, without
which the second genre-A pack answers garbage), eos_token_id (with the plan's resolved value winning), and q4nx_designs.

And the device proof is better than the test I asked for. I asked for a device-free crafted case for the guard; you answered with the property
itself on hardware: arm A (SmolLM2 with its own kernel set) served ' 100°C.\n\nThe', arm B (the same container, only the declared family
spliced to the 0.5B's) refused at init naming both families — from one binary that carries both the guard and the private route, in one npu-hold
ticket. That is both directions against the real artifact, which is stronger than a string-compare unit test; the test would still be nice for
regressions, but it is no longer the thing standing between this and a green main.

Land it, then watch build on main go green — that is the confirmation that matters, and CI on this PR is the first place the crafted case runs against
the fixed code.

bong-water-water-bong added 2 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.
@bong-water-water-bong bong-water-water-bong changed the title npu-pack: the four genre-A follow-ups main merged without (its npu_pack_import is red at 72816f9) — geometry validation, attention_bias, stop tokens, design-family guard npu-pack: a genre-A container states its stop tokens and its kernel family (two commits; main already has the rest) Nov 8, 2026

@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 dbbfd775c2f6.

Both corrections check out, and I owe you two of my own. First the verification:

commits on main: 236a88d9 (stop tokens + architecture)  dbbfd775 (the q4nx_designs guard)   -> exactly TWO
diff vs main:    2 files, +57   (was +768 on the old head)
main has the other three — measured, not read:
  "genre-A placeholder" 1 hit   attn_bias_tensors 4 hits   final_norm_layernorm 3 hits
  validation at :1650 BEFORE the head filter at :1892
  main's build: in_progress (not failure) on a29306f5

So pi-inferzo was right, and the stale-origin/main story is the one that explains it — the same failure mode that cost me two wrong verdicts earlier today
(a stale refs/remotes/origin/main.lock froze the ref for two hours and spoiled my #1453/#1454 reviews). My first correction: I repeated "main is red"
after verifying build: failure on 72816f9 alone, without checking whether main had since regained the fixes it was missing. It had.
A failing check on
the merged commit is evidence about that commit, not a standing property of main.

My second, and it matters for the old head's verdict: I said the old head carried "all four behaviours". It did — but it did not revert main's copies
(git diff origin/main 3b1f7b4 removes 0 fix-lines for placeholder|attn_bias|final_norm|bytes_of, and the marker counts were identical to main's). So
that approval was not a partial-revert approval; it was approval of a branch that would have re-landed code main already had — redundant, a future-conflict
magnet, and 711 lines larger than it needed to be. This two-commit form is strictly better and the one that should land.

The +57 is exactly the two things it says: q4nx_designs written at import with the role:KxN grids, and the forward's refusal
("… declares its kernel family as \"…\", but this model derives \"…\""), with eos_token_id/architectures in the same commit as the stop-token half.

And the device proof is now three arms, which is better than the two I asked for: A served, B refused with the message, C (0.5B) served in 14 s —
one 309 s ticket, one binary carrying both the guard and the private route. Arm C is the one that shows the guard does not reject correct containers,
which is the half a two-arm test can leave implicit.

The /tmp caveat is real on this box and worth the note: /tmp is a 62 G tmpfs at 83 % (51 G used) while / has 235 G free — so a build that
stages in /tmp fails ENOSPC with plenty of disk elsewhere. Nothing to do with CI, as you say; I have hit temporary-file pressure here too, and the fix
is TMPDIR= on a path under /.

@bong-water-water-bong 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 dbbfd775c2f6 (unchanged head; this is about the body's new evidence).

The claim that the device measurement carries over is true, and I checked it on the code rather than on the promise: the guard is byte-identical
between the pre-rebase head pi-inferzo ran and this one —

pack_import.cpp  "The kernel family this container was imported for" … q4nx_designs assignment   IDENTICAL (305 lines)
q4nx_forward.cpp "declares its kernel family as …" refusal                                       IDENTICAL (4 lines)

so the binary they exercised and the branch that would merge compute the same string and refuse on the same comparison. (The two heads differ, but in
main's intervening work — the shipped file lists do not overlap beyond these hunks — so the identity that matters is the guard's, and it holds.)

And the compatibility claim is measured in exactly the direction that was missing. A main-imported container — attention_bias true,
q4nx_designs None, eos None — serves with no refusal in the log and answers 'Water boils at a temperature of' → ' 100 degrees Celsius. …'. That is the property the code promises: if (!want.empty()) means a container without the key is not guarded, and
if (!p.dims.eos.empty()) means a container without eos simply does not gain it. Until now that was a reading of two conditions; now it is a run
against an artifact produced by main's own importer, which is the only way to test backwards compatibility honestly.

The two failed attempts being recorded as harness/box causes and explicitly not as guard evidence is the part that makes the passing arm worth
believing.
An author who counts a failed run as support cannot be trusted with a successful one, and naming the causes — no ELF dir, and 122 s of
device-lock wait held by another lane's resident 27B serve — is what separates "the guard refused" from "the box was busy".

And the lesson is worth carrying into the fleet's notes: an npu-hold ticket is permission to queue, not exclusivity. The 122 s wait is the
evidence, and it is the same class of thing as the phantom NPU lock holder earlier in this session — a hold says you may ask, not that you hold the
array. Ticket shape recorded sensibly too (queued 260 s, held 175 s, die 55 C, ready ~110 s once the lock freed).

Nothing left on this PR from my side: two commits, +57, guard verified both ways on hardware, compatibility now measured on a main-imported container.

@bong-water-water-bong
bong-water-water-bong merged commit b00845a into main Nov 8, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/npu-genre-a-followups branch November 8, 2026 15:28
@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:

🎫 Ticket compliance analysis 🔶

1410 - Partially compliant

Compliant requirements:

  • The Token .fconst file name handling is updated to support both 1.8 and 1.9 naming conventions
  • The geometry detector is updated to accept the new block stride of 104,350,656 B
  • The importer's section arithmetic accounts for the 128-byte difference in the o_proj section
  • The pack import process correctly includes stop tokens and architectures from genai_config.json
  • The container records the kernel family it was imported for

Non-compliant requirements:

  • The PR does not fully address the 128-byte field issue in the o_proj section, as it only adjusts the import logic but does not resolve the underlying format difference

Requires further human verification:

  • Verification of the exact 128-byte field in the o_proj section and its impact on the model's correctness
  • Confirmation that the updated geometry detector correctly handles all 1.9 pack variations
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Geometry Detector Issue

The updated geometry detector logic for 1.9 packs relies on a secondary pass that only activates when the strict pass fails. This could lead to incorrect detection if the strict pass fails for reasons unrelated to the stride, potentially causing valid 1.9 packs to be rejected.

            "and has NOT been verified against those caches, and this forward computes RoPE from theta";
        std::fprintf(stderr,
                     "WARNING: this pack ships precomputed RoPE caches, so its declared rope_theta (%.6g) is "
                     "unverified; the engine computes RoPE from theta, so long-context output may be wrong\n",
                     (double)p.dims.rope_theta);
    }
}
c["rms_norm_eps"] = p.dims.rms_norm_eps;
// The kernel family this container was imported for, so a serve that derives a different one refuses instead of
// answering garbage (the forward compares this exact string). Shape per role, in the order the importer plans
// them: QKV is the fused q|k|v width, GU the fused gate|up width, both on the padded K grid.
{
    // The forward's own grids (npu/forward/include/q4nx_pack.h): K pads to the 256-column grid, N to the
    // 512-column one. Spelled out here because the importer is a different build target from that helper;
    // the forward compares this exact string, so a drift between the two shows up as a refusal (loud)
    // rather than as wrong text (silent). Verified against both packs' `derived 4 designs` lines:
    // SmolLM2 QKV 768x1024 / O 768x1024 / GU 768x3072 / D 1536x1024; Qwen2.5-0.5B 1024x1536 / 1024x1024 /
    // 1024x9728 / 4864x1024.
    auto pad_k = [](int k) { return (k + 255) / 256 * 256; };
    auto pad_n = [](int n) { return (n + 511) / 512 * 512; };
    const int kpad = pad_k(p.dims.hidden);
    const int q_out = p.dims.heads * p.dims.head_dim, kv_out = p.dims.kv_heads * p.dims.head_dim;
    c["q4nx_designs"] = "QKV:" + std::to_string(kpad) + "x" + std::to_string(pad_n(q_out + 2 * kv_out)) +
                        ",O:" + std::to_string(kpad) + "x" + std::to_string(pad_n(p.dims.hidden)) +
                        ",GU:" + std::to_string(kpad) + "x" + std::to_string(pad_n(2 * p.dims.intermediate)) +
                        ",D:" + std::to_string(pad_k(p.dims.intermediate)) + "x" +
                        std::to_string(pad_n(p.dims.hidden));
}
// The stop tokens: `load_geometry` already copied eos/bos/pad and `architectures` out of the pack's
Kernel Family Mismatch Check

The new kernel family check in the forward pass is a good safety measure, but it assumes that the container's q4nx_designs field is always correctly populated. If the import process fails to populate this field correctly, the forward pass will refuse to run, potentially causing issues with older containers that do not have this field.

// The container states which design family it was imported for, and a serve that derives a different one refuses
// rather than answering garbage. Two containers with the same hidden size can differ in IM, head count or head dim
// (so in every K/N the kernels are looked up by), and pointing one at the other's ELF set used to be a silent
// wrong answer. The field is written by `1bit npu-pack import` as "ROLE:KxN,ROLE:KxN,..."; a container without it
// (everything imported before this) is unaffected.
{
    const std::string want = read_config_string(cfg_path, "\"q4nx_designs\"", "");
    if (!want.empty()) {
        std::string have;
        for (int i = 0; i < ndesigns_; i++) {
            have += (have.empty() ? "" : ",") + std::string(role_tag(designs_[i].role)) + ":" +
                    std::to_string(designs_[i].K) + "x" + std::to_string(designs_[i].N);
        }
        if (have != want) {
            err_ = "this container declares its kernel family as \"" + want + "\", but this model derives \"" +
                   have + "\": 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)";
            return false;
        }
    }
}

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'
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.

1 participant