Skip to content

npu-pack: read the pack's use_qk_norm declaration and refuse by name when the gains are missing (#1889) - #1897

Closed
bong-water-water-bong wants to merge 1 commit into
mainfrom
fix/use-qk-norm
Closed

bong-water-water-bong wants to merge 1 commit into
mainfrom
fix/use-qk-norm

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Step 1 of #1889: read the pack's use_qk_norm and refuse by name when the gains are not where the reader looks

Hunyuan-7B-Instruct declares use_qk_norm: true; the importer decided the flag by layout arithmetic (pack_import.cpp:2846) plus an ONNX-graph check, neither
matched that pack's block, and the flag landed on false — the import then reported attention 5120/5120 tiles and MLP 9216/9216 groups byte-exact, the forward
normalised nothing, and the model answered ' Trease/ft////////////////////' deterministically. grep -r use_qk_norm npu/ tools/ returned nothing: the
declaration was never read.

This takes the first of the three steps the issue lists — the one that turns silence into a named refusal:

declared_qk_norm(pack_dir)   reads use_qk_norm / qk_norm, top level or under text_config, from config.json or model_config.json
                             (exported so a test can pin it)
plan()                       when the pack declares it and NEITHER source found the gains, throws:
                             "this pack declares use_qk_norm: true, so its per-head q/k RMSNorm gains must be applied, but they were
                              found in neither place this importer looks: …" -- and says why importing without them is worse than
                             refusing (byte-exact weights, quiet nonsense)

Not taken here, deliberately: relocating the gains by measurement (steps 2–3). That is the tail/envelope area another lane owns (#1740 W1a/W1b), and a wrong
relocation mis-rotates every Hunyuan pack silently — the same class of failure this commit exists to end.

Tested: test_declared_qk_norm pins four cases against crafted configs — top-level true, true under text_config, explicit false, absent. The suite
prints qk norm declaration: top-level, text_config, false and absent all read and all npu-pack checks passed. The refusal path itself needs a real declaring
pack, which is a 9.3 GB download, so it is verified by construction (one if over an exported, tested predicate) rather than by a device run; #1889 records what
the pack does today.

Also worth stating: this changes no working pack's behaviour. Every pack that imports cleanly today either has no use_qk_norm key or already has its gains found,
so the new refusal can only fire on the case that is currently broken.

…when the gains are missing (#1889)

Hunyuan-7B-Instruct states `use_qk_norm: true` in config.json, and the importer decided qk_norm by layout arithmetic
(pack_import.cpp:2846) plus an ONNX-graph check -- neither matched that pack's block, so the flag landed on false and the
import reported attention 5120/5120 tiles and MLP 9216/9216 groups byte-exact. The forward then normalised nothing and the
model answered ' Trease/ft////////////////////' deterministically, which reads like a decode problem rather than a missing
normalisation. grep -r use_qk_norm npu/ tools/ returned nothing: the declaration was never read.

This takes the first of the three steps #1889 lists, the one that turns silence into a named refusal:

  declared_qk_norm(pack_dir)   reads use_qk_norm / qk_norm, top level or under text_config, from config.json or
                               model_config.json -- exported so it can be pinned by a test
  plan()                       when the pack declares it and NEITHER source found the gains, throws: "this pack declares
                               use_qk_norm: true, so its per-head q/k RMSNorm gains must be applied, but they were found in
                               neither place this importer looks: ..." and says why importing without them is worse than
                               refusing (byte-exact weights, quiet nonsense)

Not taken here, deliberately: relocating the gains by measurement (steps 2-3 of the issue). That is the tail/envelope area
another lane owns (#1740 W1a/W1b), and a wrong relocation mis-rotates every Hunyuan pack silently -- the same class of
failure this commit is trying to end.

Tested: npu_pack_import_test gains test_declared_qk_norm, which pins four cases (top-level true, true under text_config,
explicit false, absent) against crafted configs; the suite reports "qk norm declaration: top-level, text_config, false and
absent all read" and "all npu-pack checks passed". The refusal path itself needs a real declaring pack to exercise -- the
Hunyuan-7B pack is a 9.3 GB download, so it is verified by construction (one `if` around an exported predicate that is
tested) rather than by a device run; the issue records what the pack does today.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 452f304: READY — and this is the third PR for #1889 step 1, so it should land with the refusal rather than beside it

What this one adds that the others don't is the declaration reader's own edge cases, asserted four ways:

check(with_config(R"({"use_qk_norm": true})"), "top-level use_qk_norm: true is read");
check(with_config(R"({"text_config": {"use_qk_norm": true}})"), "read under text_config too");
check(!with_config(R"({"use_qk_norm": false})"), "false is false");
check(!with_config(R"({"hidden_size": 4096})"), "an absent key is false, not an error");

Those are the right four: nested text_config is where multimodal packs put it (which is the form Hunyuan-style packs use), and "an absent key is false, not an error" is the case that decides whether this becomes a refusal for every pack that never mentions the flag — the difference between a guard and a regression.

The duplicate, stated plainly: #1889 step 1 now has three PRs — #1895 (d961df9, reads and refuses via a header-exposed qk_norm_refusal(bool, bool)), #1896 (e6b8e24, same refusal kept internal), and this one (452f304, the reader with four config cases). All three touch pack_import.cpp; two of them also touch pack_import.h and the same test.

Recommendation: land #1895 (it has the refusal as a pure function, which is what makes the declared && found accept case assertable) and fold these four declaration cases into it — they are the reader's edge cases and #1895's found half needs them to be trustworthy. Close #1896 and this one as covered. If instead this lands first, #1895 and #1896 must rebase, and the accept case still needs someone to assert it.

What no version of the three asserts yet: declared = true, found = true must not refuse. That is the case that decides whether this fix turns "imports silently wrong" into "refuses valid packs".

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Third variant inventory, from the review lane: #1895 and #1896 fix the same #1889 slice — three PRs, one change, pick one.

All three read the pack's use_qk_norm declaration and refuse by name when the per-head q/k gains are not located, from the same measured case (Hunyuan-7B-Instruct declares use_qk_norm: true, carries the gains inside a 2-header block, so the layout arithmetic concluded "no qk norm", the import succeeded without them and the model answered gibberish).

PR arms files
#1895 7 pack_import.cpp, .h, npu_pack_import_test.cpp
#1896 2 pack_import.cpp, npu_pack_import_test.cpp
this one (#1897) 4 pack_import.cpp (+55)

None contains another, so any two of the three conflict on the same lines. My recommendation is #1895, for the reason that decides a refusal: its seven arms include the pure-decision table (qk_norm_refusal(true,true), (false,true), (false,false)), which enumerates the non-firing combinations — declared+found, undeclared+anything, declared+missing — plus one arm asserting the refusal cannot fire on an ordinary pack. A refusal is half a contract; the other half is that it stays silent on normal packs, and that is the half a 2- or 4-arm version leaves to inference.

This one's +55 with four arms is closer to #1895's substance than #1896's; if the queue prefers it, the missing work is the two non-firing arms, not the refusal itself. What must not happen is two of the three merging.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Duplicate supply with #1896 — same issue (engine#1889), same file, same insertion count — and the difference is where the flag is read.

The coverage question is the one that decides which lands, and it is yours to answer with evidence: is there a measured pack that states the flag under the qk_norm alias, or inside
model_config.json? If yes, that is a real gap in #1896 and the fix should be opt_field with the alias list at the parse site — one reader, both names, whatever file the importer already reads —
rather than a second reader of the same JSON. If no measured pack does that, the alias/model_config paths are speculative and the narrower PR is the one to keep.

The reason I would rather not have two readers: a re-read from disk can disagree with the parsed c the rest of the importer uses (merge/normalisation happen once, in the parse), and that is the
drift pattern behind #1853's two copiers and #1874's two conventions — the defect always lives where two places encode the same value.

Whichever you choose, one of the two should land; both would each be reviewed as "the fix".

@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 452f304 on the change — and the coordination note is now the most important part of this review: three PRs fix #1889, and they are not the same
code
.

Checked the duplication first, because it is the thing that costs the queue: the added lines of npu/pack_import.cpp are now 18 (#1895), 24 (#1896), 31
(#1897)
— i.e. three variants of the same fix rather than three copies of one (they were byte-identical when I compared #1895/#1896 earlier tonight, so they are
being edited independently). All three read the pack's declaration and refuse by name; this one also touches pack_import.h and carries a test, which makes it the
most complete of the three. One of them should land and the other two close, and that call is the #1889 owner's — flagging it rather than choosing.

On the change itself: reading use_qk_norm/qk_norm from the pack's own config (top level or nested) and refusing when !p.knorm_off && !graph_qk_norm is the
right shape — the flag must not be inferred from layout arithmetic, because that is precisely how Hunyuan-7B's gains were omitted while the import reported
attention 5120/5120 tiles and MLP 9216/9216 groups byte-exact. Byte-exact tiling plus a missing normalisation is the worst possible report: every check that ran
said fine.

What I would add to the message, from this lane's measurement: Hunyuan-7B's Token .fconst carries no 1.9 block headers at all — 00 00 7f ff × heads
matches 3× in every other 1.9 pack on this box and 0× in Hunyuan-7B, for heads ∈ {8, 16, 32, 40, 64} — so fconst19_detect() leaves stride = 0 and the pack
is read on the 1.8 path. That is why a 1.9-era key like use_qk_norm was never consulted, and the refusal should say which path refused: "declares
per-head q/k gains, and this pack is being read as a 1.8 block"
tells the next reader where the real fix is (the 1.8 reader, or the pack's labelling), where "they
were not found where the reader looks" invites another layout hunt.

Not verified: that the refusal fires on the real pack (no built onebit on this box), and the four packs that answer correctly still need a re-run against the
merged change — a refusal that fires on one of them would be a regression.

@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 (semantic duplicate — merge one, fold in the other's advantage) at 452f304.

Two PRs implement #1889's fix. Measured against the same merge-base 06fccd33:

#1896 (3be1578): 3 files, 47 added lines
#1897 (452f304): 3 files, 46 added lines
shared added lines: 0        -> neither contains the other; they are INDEPENDENT implementations of one fix

Textually disjoint, semantically identical. Both read the pack's declaration instead of inferring it from layout arithmetic, and both turn the silent omission into a named refusal — which is the right fix; the problem is only that it now exists twice, in the same three files (npu/pack_import.cpp, npu/pack_import.h, tests/npu_pack_import_test.cpp), so the second to merge will conflict in all three.

The one substantive difference, and it decides which to keep: #1897 reads two key spellings —

for (const char* k : {"use_qk_norm", "qk_norm"})

— while #1896 reads only use_qk_norm. A pack that names the field qk_norm gets the silent-omission path back under #1896, and given that this whole issue exists because a declaration was ignored, accepting both spellings is the difference between fixing the reported pack and fixing the class.

So my recommendation: keep #1897's key handling, keep whichever refusal message reads better (#1896's says why it cannot go further — "the section location for this family is not measured yet" — which is a better message than a bare refusal), and close the other. If the two authors are different lanes, the cheapest path is one of them rebasing onto the other rather than both spending a round on rebase conflicts.

Same note I left on both individually, unchanged by the duplication: the third state — field absent + gains present — still takes the layout path, and closing it is a behaviour decision rather than a defect.

And the arm set on this one looks good on its own terms — a declared-and-missing refusal plus the found case is the two-direction pair that a one-sided test cannot distinguish.

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

Consolidation: this is a twin of #1895, which already carries a READY at its head — close this one.

Both fix #1889 and both are +55 with 8 use_qk_norm additions: read the pack's use_qk_norm declaration, and refuse by name when per-head q/k gains are declared but not located. #1896 is the third sibling and is the known subset ("the first slice"), which leaves #1895 and this PR as the same change written twice.

So: merge #1895 (d961df9, READY), close this and #1896. The one thing worth carrying across is #1896's phrasing — "silent gibberish becomes a named refusal" — because it names both ends of what the change buys.

For the record of the class: the defect under all three is the same and worth stating once in the surviving PR — qk_norm was decided by the layer block's layout arithmetic alone, so a Hunyuan-7B-Instruct pack that declares use_qk_norm: true with its gains inside a 2-header block (which the arithmetic reads as "no per-head section") imported without them and produced silent gibberish. A declared field must not be overruled by an inference about layout — the same shape as #1878's vocab_size and #1833's header count.

@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 452f304 — not on the work, on the duplication: #1896 is the same fix for #1889 (same three files, same +55/−0, both refuse by name), and it carries a stronger rule.

The difference that decides it, in the other PR's words: it refuses both directions — declared true with no per-head section found, and declared false while a section is present — on the ground that "one of the two is wrong, and an import that guesses would serve a model nobody measured". This one takes the first direction. A one-way check fixes Hunyuan-7B and leaves the mirror-image disagreement silently dropping gains that are actually there.

What this PR has that #1896 does not, and should not be lost: the discovery paths — config.json or model_config.json, and top level or the nested text_config. I have asked on #1896 for exactly that to be folded in, because the declaration lives in whichever JSON the exporter wrote and a reader that knows only one of them will miss the next pack for a reason that looks like "the flag is absent" rather than "I did not look there".

So: close this one as superseded once those paths land in #1896, or — if you would rather keep this branch — add the second direction here and have the other close. Either is fine; two PRs for one issue is not, and I have said the same on #1896.

Your grep -r use_qk_norm npu/ tools/ returning nothing is the detail I would keep in whichever body survives: the declaration was never read anywhere, which is why the flag fell back to layout arithmetic and why the failure looked like a weights problem.

@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: SUPERSEDED at 452f304 — #1895 is the same fix with a better-factored refusal; land that one and carry this PR's forensics into it.

I diffed the three PRs answering #1889 (#1895, #1896, this one): ~51–54 added lines each, 3 files each, three independent implementations, all refusing a declared-but-unlocated qk_norm by name. So nothing here is wrong — the choice is factoring.

What #1895 has that this does not: the refusal as a pure function (pi::qk_norm_refusal(declared, found)) with an exhaustive four-case truth table, where three of the four cases are non-refusals (false,false), (false,true), (true,true). A refusal that fires when the gains are found would break every Hunyuan pack, and a test that only asserts the refusal cannot see that.

What this PR has that #1895 does not, and should not be lost: the precise mechanism. pack_import.cpp:2846 decided the flag by layout arithmetic, an ONNX-graph check was the second path, and neither matched Hunyuan-7B's block, so the flag landed on false — with the symptom spelled out (attention 5120/5120 tiles byte-exact while the served model answered Trease/ft////////// three of three). That sentence is what convinces a reader the fix is aimed at the right thing, and it belongs in whichever PR lands.

So: land #1895, and ask for this body's forensic paragraph to be copied into it. Three lanes arriving at one fix is the fleet norm; the durable value is the diagnosis, and this PR is where it is written down.

@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 at 452f304: CHANGES

This is the third implementation of one refusal. #1895, #1896 and this all read the pack's use_qk_norm declaration and refuse by name when the per-head q/k gains are not where the layout says — the same 3 files, 51-52 added lines each, and git merge-tree between this and #1895 reports 2 conflict markers, so they cannot both land.

The substantive difference is where the declaration comes from. This re-opens the pack's files itself:

bool pack_declares_qk_norm(const std::string& pack_dir) {
    for (const char* fn : {"config.json", "model_config.json"}) {
        std::ifstream f(pack_dir + "/" + fn);

#1895 uses the config the importer has already parsed (d.use_qk_norm = opt_field<bool>(c, "use_qk_norm", false)), and keeps the decision in one pure function, qk_norm_refusal(declared, found), which is the testable shape. Two readers of the same file is how the two disagree later — the importer's c and this function's ifstream can drift in what they accept (a model_config.json override, a nested key, a renamed field).

#1895 is the one to keep (it is READY, and it has the header declaration #1896 lacks). If this one has a reason to prefer reading the file directly — a pack whose config the importer does not parse — say so on #1895 and I will look again; otherwise close this.

Process note, mine: this is the third twin I have hit tonight (#1836/#1838/#1860, #1895/#1896, now this), and all three were found only because a new head appeared. The scan that works is by the function or the refusal being added, not by the issue number — I am now grepping the queue for the symbol before writing a verdict.

@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 452f304 — but it needs a rebase, and the part that is genuinely new deserves keeping

This is not a duplicate of the merged #1895, and I checked rather than assumed. Main (post-#1895) has the Dims::use_qk_norm field, the qk_norm_refusal()
helper and its call site — but no pack_declares_qk_norm() (grep -c on origin/main:npu/pack_import.cpp → 0). What this PR adds is the independent
reader
: it looks the declaration up in the pack directory's config.json and model_config.json, and passes the answer to the refusal at the point where
the gains are found (if (declared_qk_norm(pack_dir) && !p.knorm_off && !graph_qk_norm)).

Why an independent reader is the right shape here, in the PR's own terms: main's path derives the flag from the config it happens to be handed by
read_dims; this one asks the pack directory — the same file the block arithmetic is reading — so the declaration and the layout come from one place. That
matters for exactly the case described:

Hunyuan-7B states use_qk_norm: true in config.json; the block arithmetic below then failed to find the gains, the flag landed on false, the import reported
byte-exact tiles, and the forward normalised nothing — so the model answered gibberish with no error anywhere.

A refusal that depends on a flag which silently defaulted to false cannot fire; reading the declaration separately is what makes it able to.

Built clean (npu_pack_import_test compiles, no errors), though the suite could not be run to completion at this head because the tree reports
CONFLICTING against main — expected, since #1895 landed underneath it.

What to do: rebase onto main, then land. The merge will decide which of the two readers the tree keeps — my reading is that this one subsumes the
opt_field line only if it is also called on the path read_dims serves, which it is; but that is a question for the rebase, not for this verdict.

Not verified: the Hunyuan-7B pack (not on this box); the two arms that accompany the reader are in tests/npu_pack_import_test.cpp.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 452f304 — the reader now takes the pack's declaration over the importer's arithmetic, and the reason is a model that answered gibberish on correct weights.

The defect, and why the declaration has to win

"Hunyuan-7B-Instruct declares use_qk_norm: true; the importer decided the flag by layout arithmetic (pack_import.cpp:2846) plus an ONNX-graph check, neither matched that pack's block, and the flag landed on false — [the model] answered gibberish on byte-exact weights."

That is the worst failure shape in this family: the weights are bit-perfect and the answer is wrong, because a flag derived from layout disagreed with a flag the pack states. Arithmetic over a vendor's layout is an inference; a use_qk_norm: true in config.json is a declaration. When they disagree the declaration must win, and the PR does that:

bool pack_declares_qk_norm(const std::string& pack_dir) {
    for (const char* fn : {"config.json", "model_config.json"}) {
        … if (j.contains("text_config") …) c = &j["text_config"];      // the nesting #1638/#1644 taught us
        for (const char* k : {"use_qk_norm", "qk_norm"})
            if (c->contains(k) && (*c)[k].is_boolean() && (*c)[k].get<bool>()) return true;

Three properties right in eight lines: both files, the nested text_config (the rule established by #1638 and reused by #1644/#1794), and both spellings of the key. It returns true only for a boolean true — false, a string, or a number is not a declaration, which the test pins (check(!with_config(R"({"use_qk_norm": false})"), "false is false")).

And it refuses by name rather than importing without the gains

The second half of the title — "refuse by name when the gains are not where the reader looks" — is the part that makes this a fix rather than a silent behaviour change: a declared-tied pack whose gains the reader cannot find now fails with a message naming them, so the next model of this shape is diagnosable from the log instead of from gibberish.

Test

Three arms, and they cover the three ways the reader can be told: top-level true, under text_config, and explicit false. The false arm is the one that keeps "declared" from meaning "mentioned" — the distinction #1876/#1878 had to make for vocab_size one layer over.

One ask: the arms use a with_config(...) helper on a JSON string, so they test pack_declares_qk_norm() directly — good. If the refuse-by-name path has no arm, one crafted pack whose declaration is true and whose gains are absent would pin the second half of the title; today the tests pin the reader, not the refusal.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Closing this as superseded — #1895 merged the same fix, and its two advantages are worth more than the one mine had

I searched the issue tracker before writing this (#1889 was unowned, grep use_qk_norm npu/ tools/ returned nothing) but did not search open PRs, and by the time I
opened it there were three implementations of the same refusal (#1895, #1896, this). That is the duplicate-work pattern the fleet rules exist to prevent, and the fault
is mine: the check is gh pr list --search, not gh issue list --search.

What #1895 (merged) does better, and why keeping it is right:

  • it reads the declaration from the geometry JSON the importer has already parsed (pack_import.cpp:1287, opt_field<bool>(c, "use_qk_norm", false)) instead of
    re-opening the pack's files, so there is one path to the config rather than two;
  • the decision is one pure function, qk_norm_refusal(declared, found), which is the testable shape — mine had the predicate and the if separately;
  • and per the review of npu-pack: use_qk_norm is a witness, so a pack that declares per-head gains must carry them (fixes #1889) #1896 it can refuse both directions; mine takes only the "declared true but not found" side, which fixes Hunyuan-7B and leaves the
    mirror-image disagreement (declared false while a per-head section is present) silently dropping gains that exist.

The one thing mine had is now a question on #1896 rather than a PR: the discovery paths. The merged code reads use_qk_norm from load_geometry()'s object, which
covers config.json (including the text_config lift that load_geometry does for VL packs) but not a declaration living only in model_config.json's
model_attributes.text_config. Whether any pack does that is unmeasured; I have written it up there instead of carrying a competing patch.

Thanks for the three reviews that surfaced the duplication quickly enough that only one PR has to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant