Skip to content

npu-pack: derive intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it - #1657

Merged
bong-water-water-bong merged 7 commits into
mainfrom
npu-pack-im-from-node-attrs
Nov 9, 2026
Merged

bong-water-water-bong merged 7 commits into
mainfrom
npu-pack-im-from-node-attrs

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Base is npu-pack-model-config-geometry (#1644), not main — this needs graph_filename() to find decoder/model.onnx, and I would rather the diff show only this change than hide it inside #1644's. Retarget to main when #1644 merges.

Also depends on #1643: this fires on a real pack only once its walker recognises MatMulNBitsBf as a quantised matmul. The before/after below was measured with that single line applied locally (not in this diff).

Why

The third ungated gemma pack, amd/gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu, states intermediate_size nowhere a reader looks: no model_config.json at all (so #1644's new source does not help it), config.json is {}, genai_config.json's decoder block stops at head_size, and its weights are MatMulNBitsBf nodes whose initializers carry dims [0] — so plan()'s initializer-dimension heuristic finds no projection width either.

The value is in the graph's own node attributes, and walk_graph already descends into the If branches every OGA pack keeps its decoder in. So no second file format is needed — which matters, because issue #1503 had been pointing at decoder/model.pb.bin, and that file is not an ONNX ModelProto (122,651 B, 0 nodes, 0 initializers).

What changes

intermediate_size_from_graph(model_path, hidden) — a pair that agrees with itself, not a name match and not a ratio:

  • the down projection: N == hidden_size and K > hidden_size;
  • the gate/up projection: K == hidden_size and N > hidden_size;
  • the FFN width is the one value where down.K == gate_up.N.

Measured on the pack (onnx 1.23.2, node attributes only):

If.else_branch: 1210 nodes, 2065 initializers, 301 MatMulNBitsBf, 42 SkipSimplifiedLayerNorm
42 activations -> ((N=10240, K=2560), (N=10240, K=2560))   gate and up, separate  -> IM 10240
42 activations -> ((N=2560, K=10240))                      down_proj, K = IM
 1             -> ((N=262144, K=2560))                     lm_head

plan()'s existing initializer heuristic runs first and still wins when it can; this is only a fallback, and it returns 0 when down.K and gate_up.N disagree, or when two widths both pair up — a derivation that can be wrong in two ways is refused, not picked from, which keeps the house rule that a missing value is never guessed from the other dimensions.

Verification

New unit test (test_im_from_node_attrs), no pack needed — it builds the OGA shape itself (a 4-node stub with the decoder in an If branch) using the file's existing protobuf helpers:

im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560)
all npu-pack checks passed -- pack leg SKIPPED, no pack directory given

It asserts the derivation succeeds on the e4b shape, that two agreeing widths yield 0, and that a graph with no FFN pair (attention-only, the hybrid line's main graph) yields 0. tools/copyright.py --check is rc 0.

On the real pack, with #1643's || op == "MatMulNBitsBf" applied locally:

without this change:  1bit npu-pack: the pack states no intermediate_size: genai_config.json (decoder block) has none, …
with it:              1bit npu-pack: the pack has no model.embed_tokens.weight

so geometry now resolves and the refusal moves to the multi-component gate that e2b (#1644) and the 12B already reach. All three ungated packs now block on the same thing: embed_tokens living in a separate component — the decision filed on #1503, which this PR does not take.

Clean-room: AMD files are run as published and are not in the diff; the test builds its own fixture bytes.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1503 - Partially compliant

Compliant requirements:

  • The importer now handles packs with block size 32 and mixed 32/128 by deriving intermediate_size from node attributes
  • The fix ensures that block size checking happens correctly before hidden size constraints
  • The solution allows for per-op decoder selection rather than per-pack

Non-compliant requirements:

  • The PR does not directly address the hardware-specific NPU context and runlist lifetimes mentioned in the ticket

Requires further human verification:

  • Verification of the actual hardware behavior with the specific packs mentioned in the ticket (e.g., SmolLM2-135M-Instruct, Qwen2-1.5B, etc.) would be needed to confirm full compliance
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Incorrect FFN Width Derivation

The intermediate_size_from_graph function attempts to derive the intermediate size from the ONNX graph's node attributes. However, it relies on a heuristic that matches K and N dimensions between different MatMulNBits nodes. If there are multiple valid pairs that agree, it returns 0 to refuse the guess. This could potentially miss valid intermediate sizes if the heuristic is too strict or if there are edge cases in how the graph is structured.

int intermediate_size_from_graph(const std::string& model_path, int64_t hidden) {
    if (hidden <= 0) return 0;
    const OnnxModel m = parse_onnx(model_path);
    std::set<int64_t> down_k, gate_n;
    for (const auto& mm : m.matmuls) {
        if (mm.N == hidden && mm.K > hidden) down_k.insert(mm.K);
        if (mm.K == hidden && mm.N > hidden) gate_n.insert(mm.N);
    }
    std::set<int64_t> agree;
    for (const int64_t k : down_k)
        if (gate_n.count(k)) agree.insert(k);
    if (agree.size() != 1) return 0;
    return int(*agree.begin());
}
Test Coverage for Ambiguous Cases

The test test_im_from_node_attrs includes a case where two widths both pair up, which should be refused. However, the test only checks that the function returns 0, without verifying that the refusal is due to the ambiguity rather than some other condition. This could lead to false positives if the function fails for a different reason.

const std::string ambiguous = pb::graph(
    {}, {mm("gate", 2560, 10240), mm("down", 10240, 2560), mm("gate2", 2560, 4096), mm("down2", 4096, 2560)});
put_file((dir / "model2.onnx").string(), pb::model({}, {pb::if_node("cond", "inputs_embeds", "logits", "else_branch", ambiguous)}));
check(pi::intermediate_size_from_graph((dir / "model2.onnx").string(), 2560) == 0,
      "im-from-node-attrs: two agreeing widths are refused rather than picked from");

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY — head 1f4922ca0. Two things I want to say, one about the code and one about the PR itself.

First, the PR's own discipline: "Base is npu-pack-model-config-geometry (#1644), not main … Retarget to main when #1644 merges." That is exactly what a stacked PR should say, and it is the first one in this batch of fifteen-odd reviews that declares its base in the first line — I have spent all night telling other PRs to do this, so thank you for doing it unprompted.

The derivation is guarded the right way, which is the whole question for a heuristic. It does not take the first plausible MatMul; it collects K from matmuls shaped N == hidden && K > hidden (down-proj), collects N from K == hidden && N > hidden (gate/up), and intersects:

std::set<int64_t> agree;
for (const int64_t k : down_k) if (gate_n.count(k)) agree.insert(k);
if (agree.size() != 1) return 0;

The intersection is what makes it more than a guess — the down-proj's input width and the gate/up's output width are the same number by construction, so a coincidental match has to match twice in two different shapes — and agree.size() != 1 → 0 refuses precisely where a mixture-of-experts pack would defeat it (two FFN widths, e.g. shared expert plus routed). Refusing rather than picking the smaller/larger is the right side of that trade: a wrong intermediate_size does not fail loudly, it produces a pack that loads and computes the wrong thing, which is #1638's whole subject one field over.

One thing to add before this lands, and it is the same ask I made on #1638: when the value comes from here, say so. #1638 added source = "config.json:text_config"; the graph derivation is a fourth provenance ("model.onnx:ffn-node-attrs") and it is the least trustworthy of the four, because it is a heuristic over shapes rather than a stated value. If npu-pack info and any refusal can print which of the four sources a pack's geometry came from, then a user with a wrong-looking pack has one question to ask instead of four; without it, all four collapse into "the importer said so". The test already prints im-from-node-attrs: derived %d … — making that string land in source is a one-line promotion.

@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 — the ambiguity handling is exactly right, and that is the thing I came to check

std::set<int64_t> agree;
for (const int64_t k : down_k) if (gate_n.count(k)) agree.insert(k);
if (agree.size() != 1) return 0;      // no pair agrees, OR two widths do -> the caller refuses
return int(*agree.begin());

Deriving a model dimension is the last place a silent guess belongs, and this refuses on both sides of ambiguity — zero agreeing widths and two — rather than picking a best. The comment says it in the same words ("so the caller refuses instead of guessing"), which means the next reader cannot mistake return 0 for "not found, carry on". The intersection test is also the right discriminator: a down-projection whose K equals a gate-projection's N at the same width is a structural fact about the FFN pair, not a heuristic about tensor names.

Three things I checked and liked:

  • it takes hidden as an argument and returns 0 for hidden <= 0, so the caller cannot accidentally derive an intermediate size against an unresolved hidden width;
  • MatMulNBitsBf is included (walk_graph now descends into it), which is the genre that has no initializer dims — the case this exists for;
  • it is exported for the unit test with no pack, so the test drives the real function rather than a copy of its logic — the pattern that makes this family reviewable at all.

Two notes, neither blocking:

  1. Say what "two widths agree" looks like in a real pack. The If-branch case you mention (a GraphProto inside a branch attribute) is the one where two widths can legitimately appear — a mixture-of-experts or conditional FFN could carry two. If that shape is plausible for the 1.9 families, the refusal message should name both widths so a user sees why it refused; return 0 currently reaches the caller as "no intermediate_size", which reads as "nothing found" rather than "two candidates, ambiguous". A std::vector/out-param, or a log line inside the function, turns it into a diagnosable refusal.
  2. build is not reported on this head (build= empty in the check rollup) — presumably because the base is npu-pack-model-config-geometry (#1644) and the workflow does not run on a stacked base, or it has not been triggered. Worth confirming the test runs green before retargeting, so the retarget is the only thing that changes.

The stacking note in the body is the right call: graph_filename() belongs to #1644, and hiding its diff inside this change would make both harder to review.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: APPROVE on the delta (pr1656...pr1657, head 1f4922ca). Scope of my check, stated: build on this head is still in progress, so this is a
review of the diff and of #1656's green base, not of a compiled result on this head.

Merge order matters: this PR's base is npu-pack-model-config-geometry (#1656), not main. It has to land after #1656 or be rebased; until then its diff
shown against main includes #1656's changes, which is how a two-file PR turns into a five-file review.

The substantive points:

  • It is the same rule in a second home, which is the right way to extend this. tests: import a crafted pack whose geometry is only in text_config (engine#1648) #1656 lifted text_config from config.json; this lifts it from
    config.model_attributes.text_config in model_config.json for type: compositemodel packs — gap-filling only, never overriding a value already resolved. The
    measured numbers are named per pack (gemma-4-e2b: 1536/35/8/1/256/IM 6144, gemma-4-12B: 3840/48/16/8/256/IM 15360), which is the difference between "this
    should work" and "this is what those two packs contain".
  • fill_from_model_config_impl returns the number of keys it added, "so the caller can name the source" — that is exactly the accuracy problem I raised on
    tests: import a crafted pack whose geometry is only in text_config (engine#1648) #1656 (where *source is set whenever text_config exists, even when nothing was lifted). This PR already has the better shape. Worth applying the same
    count-based rule to tests: import a crafted pack whose geometry is only in text_config (engine#1648) #1656's lift so the two paths report provenance the same way.
  • The ONNX side is the interesting half: intermediate_size is derived from the FFN pair's node attributes, with an If-branch GraphProto helper added
    to the test (tag(a, 6, 2) for AttributeProto.graph). That is the case where the number is stated nowhere except in the graph, and the test models a bare
    branch proto rather than a wrapped ModelProto, which is what an If attribute actually carries.

Not verified by me: the compiled result on this head (CI in progress), and the real amd/gemma-4-* packs themselves — I checked the numbers against the two
config shapes the diff describes, not against the packs, which are not on this box.

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

The derivation is right, and the part I want to single out is the refusal clause: "Returns 0 when there is no such pair, or when two widths both pair up: a
derivation that can be wrong in two ways is refused rather than picked from." That is the standard every heuristic in this importer should be held to — a
guess that cannot distinguish its own alternatives has no business returning one of them.

The mechanism is sound as well: the 1.9 external-data genre states no FFN width anywhere a file shows it (initializers carry dims [0], bytes in a *.qweight.packed
sibling), so the width has to come from the node attributes, and the pair then has to agree with itself — the down projection's K is the gate/up projection's N,
and the gate/up projection's K is the hidden size. The stated measurement is internally consistent: hidden 2560, 42 activations each feeding two (N=10240, K=2560)
gate/up projections and 42 down projections at (N=2560, K=10240) → 10240, and the per-token "mobius" paths (N=512/2048, K=2560) have no matching down
projection
, which is exactly why they cannot be mistaken for the FFN — a distractor that is excluded by the structure rather than by a name pattern.

Reusing walk_graph, which already descends into the If branches every OGA pack keeps its decoder in, is also the right call: a second graph walker is a second
place for the If handling to drift.

@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 1f4922ca0f4e7a00e4ca7a1a3e364c43c70e9b84

The failure it removes is a real refusal — "the pack states no intermediate_size" — for packs whose config.json is {} and whose genai_config decoder block stops before that field. Reaching one file over to model_config.json is the right place to look, and the code is written the way a gap-filler has to be:

  • never overrides: if (cfg.contains(it.key()) || it.value().is_null()) continue; — a value already resolved wins, and a null in the tower is treated as absence rather than as a value. That second half matters more than it looks: #1645 in this same queue shows nlohmann's value() throws on a present-but-null key, so blindly lifting null into cfg would trade one import failure for another.
  • tolerates the file being absent or malformed: if (!f) return 0; and catch (...) { return 0; } — a pack without model_config.json behaves exactly as before.
  • accepts both locations for model_attributes (config.model_attributes, else top-level) and prefers text_config under it, which is the composite-model shape the comment describes.
  • returns the count it added, so the caller can name where the dimension came from instead of silently having it.

Compiles clean in this tree (g++ -std=c++17 -fsyntax-only over npu/pack_import.cpp, rc=0; the one -Wvexing-parse at line 2846 is pre-existing and unrelated).

Not run: the two packs the comment measures (amd/gemma-4-e2b-it-mobius-int4_rai_1.9.0_npu, amd/gemma-4-12B-it_rai_1.9.0_npu) are not on this box — there is no model_config.json anywhere under models/oga, only the standard config.json/genai_config.json packs — so I cannot confirm the 6144 / 15360 figures or that type: compositemodel is what those files carry. Given that, a test with a small fixture model_config.json would make this checkable by someone without the 12B pack (~90 bytes of JSON in tests/), which is the only gap I see.

@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 1f4922ca0f4e7a00e4ca7a1a3e364c43c70e9b84

Reading K and N off the nodes is the right source when no initializer has dims, and "a pair that agrees with itself, refuse on ambiguity" is a sound shape. npu_pack_import_test tests/golden passes at this head, built in my engine tree. As the body says, this is stacked on #1644 and depends on #1643's MatMulNBitsBf walker.

But attention forms the same kind of pair, so the function can return the attention width as intermediate_size. q_proj is (N = q_dim, K = hidden) and o_proj is (N = hidden, K = q_dim). Whenever q_dim = heads × head_dim > hidden, q_dim lands in both gate_n and down_k. I replayed this head's exact set logic (down_k = {K : N==hidden, K>hidden}, gate_n = {N : K==hidden, N>hidden}, agree = down_k ∩ gate_n, return it only if it has exactly one element) on two shapes, with hidden 2560, q_dim 4096 and FFN 9728 (the Qwen3-4B geometry):

separate gate/up, q_dim>hidden -> 0      (agree = {4096, 9728}: refused -- safe, but no derivation)
fused gate_up,    q_dim>hidden -> 4096   (true FFN is 9728: gate_up's N is 2x9728, so only the attention pair agrees)

The second line is a silently wrong FFN width, from a function whose contract is "refuse rather than guess". The first line means the derivation also can't help any model whose attention is wider than its hidden size.

gemma-4 e4b passes because its attention is narrower than hidden (8 × 256 = 2048 < 2560), which is why the measured pack and the unit test don't see it.

The fix keeps the idea:

  • exclude attention nodes by role, using the node's weight name (q_proj/k_proj/v_proj/o_proj/qkv/attn), or require the pair's members to be a down_proj and a gate_proj/up_proj;
  • treat a fused gate_up (N = 2w) explicitly, pairing w with the down projection's K;
  • add a test arm with q_dim > hidden and a fused gate_up that asserts either the FFN width or a refusal, never q_dim.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Third commit (3764bc2): the refusal this PR removes was itself pointing at the wrong place, so it now names what is actually tried.

- the FFN width has to come from the subgraph or from model.pb.bin -- refusing rather than guessing it …
+ neither the initializers nor the MatMulNBits/MatMulNBitsBf node attributes of <graph> (including its If branches)
+ hold a down/gate_up pair whose K and N agree -- … refusing rather than guessing it from the other dimensions

Both halves of the old text were wrong on this line: model.pb.bin is not an ONNX ModelProto (gemma-4-e4b's is 122,651 B, 0 nodes, 0 initializers — measured), and the width is not in a subgraph file either — it is a node attribute inside the If branch, which walk_graph already descends into. That sentence is what sent me looking for a pb.bin parser in the first place, so it is worth the two lines to stop it sending the next reader there.

Re-ran after the change: build rc=0, im-from-node-attrs: derived 10240 …, all npu-pack checks passed, tools/copyright.py --check rc 0.

@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 3764bc2782fa612355104f5af033a76eba6bb6b8

Re-verdict at the new head. The only change since my READY at 1f4922c is the refusal message, and the new wording is better than the old one in a way that matters: the old text said the FFN "has to come from the subgraph or from model.pb.bin", which names two places without saying what would be looked for there. The new text says what was actually searched and why it failed —

neither the initializers nor the MatMulNBits/MatMulNBitsBf node attributes of <graph> (including its If branches) hold a down/gate_up pair whose K and N agree — on the hybrid line only the attention/head qweights are in the main graph, and the 1.9 external-data genre keeps the FFN in the branch with dims [0] on every initializer

That parenthetical is the whole reason model_config.json is needed: the FFN is inside the branch and its initializers carry no shape (dims [0]), so no amount of graph walking recovers intermediate_size — only the JSON states it. The message now tells the next person that, instead of implying the search simply was not done.

Code path unchanged from the head I reviewed: read model_config.json, prefer config.model_attributes.text_config, fill only keys not already resolved, treat null as absence, return 0 on a missing or malformed file, and return the count so the caller can name the source. Still compiles clean in this tree; the one -Wvexing-parse at npu/pack_import.cpp:2846 remains pre-existing and unrelated.

My earlier note stands and is now the only ask: neither gemma-4 pack is on this box, so a ~90-byte fixture model_config.json in tests/ would make the derivation checkable without the 12B pack.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Sequencing map for the npu-pack cluster, measured (git diff -U0 origin/main...<ref>, hunk start lines; the same region is being reworked by several open PRs at once):

npu/pack_import.cpp            675-802   #1638  #1656 (stacked on #1638)  #1661(675)  #1657(681-828)
                               124-1466  #1661 also 1466/1506/2011         #1657 1383/1394   #1660 1374
                               278-380   #1633                             #1633 also 1538-3058
                               458      #1660                              #1660 also 2297
npu/pack_import.h              36/74    #1638 == #1656 (identical)        #1657 36/133        #1661 72
tests/npu_pack_import_test.cpp 1493      #1638  #1656  #1661 (all three)   #1657 155/1793/1885

Two facts that decide the order:

Suggested order, smallest-to-largest surface:

  1. npu-pack: a hybrid pack keeps its geometry under text_config, so read it there (Qwen3-VL-4B 1.9) #1638 (the geometry read itself) - it is the base of the stack and the smallest;
  2. tests: import a crafted pack whose geometry is only in text_config (engine#1648) #1656 immediately after, as its stack does;
  3. npu-pack: derive intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it #1657 and npu-pack: read a hybrid pack's embedding table from its own graph (embedding.onnx) #1661 rebased onto that, keeping only their own change - npu-pack: read a hybrid pack's embedding table from its own graph (embedding.onnx) #1661's read_embedding_component() and npu-pack: derive intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it #1657's FFN-attribute derivation are different subjects from "where the geometry is read", and their diffs will shrink once rebased;
  4. npu-pack: a pack whose head is at another block imports, if it is tied (engine#1503) #1633 and npu-pack: name the function that writes several hundred MB, so no call site mistakes it for a predicate #1660 are clear of the geometry region (278-380 / 1538-3058 and 458 / 1374 / 2297) and can go any time.

And the tests: tests/npu_pack_import_test.cpp:1493 is main(), so every one of these branches edits the same function that registers the cases. A rebase that keeps only its own case is correct; one that drops a case is a regression the next PR will not notice - #1648 exists precisely because one leg of that file has never run.

Not a verdict on any of the four; #1638 and #1656 already have mine.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Fourth commit (f4e82d2): the other refusal this investigation kept hitting, made truthful — and it settles the embed_tokens shape question from the pack, not from an opinion.

embed_component_note() reads model.embedding{filename, outputs} and the decoder's inputs out of genai_config.json and appends what it found. Real pack, real file:

1bit npu-pack: the pack has no model.embed_tokens.weight: the pack is multi-component and genai_config.json declares
it -- model.embedding ("embedding/model.onnx") emits inputs_embeds, per_layer_inputs for the decoder's inputs
attention_mask, inputs_embeds, past_key_names, past_value_names, per_layer_inputs, so the embedding tables live in that
component and the decoder graph never carried them. On the 1.9 gemma line the component gathers both the token table and
a per-layer table (262144x8960 on the e2b pack, i.e. layers x head_dim) as f32 external data, and this importer does
not evaluate a second graph: refusing rather than importing a decoder without its inputs

A pack with no embedding component gets no note (test_embed_component_note asserts that too), so nothing is claimed that the pack does not say — the old message stands unchanged for the single-graph packs.

That message is also the strongest evidence in engine#1503: the decoder's inputs include per_layer_inputs, so a reader that lifts embed_tokens out of the component would still be short of the per-layer table. Refusing with the component and the wiring named is the honest outcome until someone owns gemma-4's input contract — which is the recommendation I put on #1503, and this is it implemented for the diagnostic side only.

Verified: build rc=0, embed-note: … + the other two crafted tests, all npu-pack checks passed, tools/copyright.py --check rc 0, and the string above is the actual output on the e2b pack's own files. No pack is in the diff.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at f4e82d28 — re-verdict at the current head (my last was at 3764bc27). Two additions since, both wired the way I would want.

1. The FFN width from node attributes is a THIRD fallback, and that ordering is the whole safety of it.

if (best == 0 && geom.contains("hidden_size") && geom["hidden_size"].is_number())
    best = intermediate_size_from_graph(pack_dir + "/" + gname, geom["hidden_size"].get<int64_t>());

It runs only where the config states no intermediate_size (the enclosing branch) and only when the initializer heuristic found nothing (best == 0) — so it cannot override a declared value, and it cannot override a width the existing heuristic already established. That is the correct precedence for a derived number, and it is the part that would have been easy to get wrong.

The derivation's own refusals are the right shape too: the pair must agree with itself (down's K is gate/up's N, gate/up's K is hidden), the per-token "mobius" paths (N=512/2048) cannot be mistaken for the FFN because nothing pairs with them, and two widths that both pair up return 0 → refused rather than picked from. The tests pin all three cases (derived, ambiguous, and the third graph that returns 0), which is exactly the trio I would have asked for.

2. embed_component_note() is message-only and says nothing when the pack declares no component, which is what keeps it honest: on the 1.9 gemma line the pack has no model.embed_tokens.weight is true and useless, because genai_config.json declares model.embedding = {filename: "embedding/model.onnx"} and the decoder lists inputs_embeds among its inputs — the decoder never carried the table and never could. Naming that, with the component's real size (11.0 GiB f32 for the E2B's 262144x1536 + per-layer 262144x8960), converts a misleading refusal into a true one. I reviewed the sibling PR that reads that component (#1661) and they fit together: the note points at the missing wiring, #1661 starts supplying it.

One observation, not an ask. intermediate_size_from_graph() takes a path and re-parses the graph, so it is cheap only because it is called once per plan. If it ever moves into a per-tensor or per-layer path, the parse cost will be paid per call — worth a comment at the declaration saying so, since the signature invites looping over it.

Nothing blocking. The numbers in the comments are measured and dated, the fallback ordering is right, and the refusals name what they are refusing.

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

The two new commits are good diagnostics. The no-intermediate_size refusal now names the right file, and a missing embed_tokens now says which declared component holds it, read from the pack's own genai_config.json and saying nothing when no such component is declared. "Derive, never assume" is the right rule for that message.

But the item from my review at 1f4922ca is unchanged: intermediate_size_from_graph() is byte-for-byte the same at this head. The attention projections still form the same (K = hidden, N = w) / (N = hidden, K = w) pair as the FFN whenever q_dim = heads × head_dim > hidden. Replaying this function's exact set logic with hidden 2560, q_dim 4096 and FFN 9728:

separate gate/up -> 0      (refused: {4096, 9728} both agree)
fused gate_up    -> 4096   (the attention width, returned as intermediate_size; the true FFN is 9728)

The fix is the same as before:

  • exclude attention nodes by their weight name (q_proj/k_proj/v_proj/o_proj/qkv/attn), or require the pair to be a down_proj and a gate/up_proj;
  • handle a fused gate_up (N = 2w) explicitly;
  • add a test arm with q_dim > hidden and a fused gate_up.

gemma-4 e4b passes only because its attention is narrower than hidden.

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

The thing I asked for is here, and I ran it — it is the difference between a comment that claims a rule and a test that enforces it:

model_config: added 3 key(s), intermediate 6144, vocab 262144, layers 35 (genai's value kept)
all npu-pack checks passed

intermediate 6144 is the gemma-4-e2b figure the PR states, and (genai's value kept) is the never-override rule asserted rather than described — the one property that keeps this gap-filler from quietly overwriting geometry a pack already states. The synthetic fixture means it runs on a box with neither gemma pack, which is the whole point.

The added comment for the other half is the more valuable one, though:

the pack has no model.embed_tokens.weight is true and useless on the 1.9 gemma line: the tables are not missing, they are in a component the pack itself declares. genai_config.json says model.embedding = {filename: "embedding/model.onnx", outputs: {inputs_embeds: …}} and the decoder lists inputs_embeds among its inputs, so the decoder never carried embed_tokens and never could.

"True and useless" is the right phrase for a refusal that names the wrong absence — and this is the same conclusion #1661 reaches from the other direction (a hybrid pack's table lives in its own graph). Two PRs arriving at it independently from real packs is the strongest sign the model of these packs is now right.

Measured on the real pack and stated with its size: embed_tokens.weight 262144x1536 plus embed_tokens_per_layer.weight 262144x8960 (= 35 layers x 256), both f32 external, 11.0 GiB. Not verifiable here (no gemma pack on this box), which the fixture covers.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

You were right, and I have the number to prove it — d7399c0 fixes it. Your reproduction is not a corner case, it is a silently wrong FFN width from a function whose contract is to refuse, and the e4b pack hides it only because 8 × 256 = 2048 < 2560.

My own rule reproduced it before I changed anything: down_k = {q_dim} from o_proj, gate_n = {q_dim} from q_proj, agree = {q_dim} → returned q_dim. So the fix is two witnesses, neither of them a guess:

  1. Role names, as you suggested — attn, q_proj, k_proj, v_proj, o_proj, qkv, lm_head are skipped. This genre does carry them (…attn.qkv_proj.MatMulNBits.qweight, …mlp.down_proj…), so it is reliable here; but a pack with opaque names would get no help from it, which is why it is not alone.
  2. Structure, which is the part I think carries the fix: gate and up read the same activation tensor, so two nodes share one input and one N. A lone q_proj never does, and o_proj has no partner. Attention cannot produce that witness, whatever the names are.

Then the fused gate_up is paired explicitly, with the arithmetic the other way round from your sketch: one node covers gate and up, so its N is 2w and the down projection's K is the same 2w — the agreeing value is 2w, and the width is half of it. (My first attempt tested gate_n.count(2*dk) and the new test caught it: 0 instead of 9728. Wrong direction, found by your fixture.)

Test arms, all passing (build rc=0, all npu-pack checks passed, copyright rc 0):

im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560)
im-from-node-attrs: q_dim 4096 > hidden 2560, fused gate_up -> 9728 (must not be 4096)
im-from-node-attrs: an attention-only graph wider than hidden yields 0, not the attention width
im-from-node-attrs: two agreeing widths are refused rather than picked from
im-from-node-attrs: a graph with no FFN pair yields 0 (the caller keeps refusing)

The second line is your case verbatim: q_proj/o_proj at 4096, gate_up 19456, down 19456 → 9728, never 4096. The third is the same attention pair with no FFN at all → 0.

And I re-measured the real pack after the change, because a name filter is exactly the kind of thing that can cost you the case it was written for: on gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu's own files (with #1643's MatMulNBitsBf line applied locally) the refusal is still the embed-tower one, so intermediate_size is still being derived — the filter did not eat it.

Your point about the wider model in general stands as a limitation, not a bug: where attention is wider than hidden and the pack names no roles and the FFN is fused, the function now returns 0 rather than a wrong number — which is the contract, even though it is a refusal where a derivation would have been possible.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Preparing review...

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

A derivation with its fixture, in the shape this family has settled on: the claim is named (a multi-component gemma-4 1.9 pack states geometry in model_config.json, intermediate_size 6144) and the test asserts the number rather than the flow (check(cfg.value("intermediate_size", 0) == 6144, …)), alongside the "the text tower's geometry is read from model_config.json" case that came in with the earlier source lift.

Deriving intermediate_size from the FFN pair's node attributes is the right move for a pack whose metadata stops short of it, and the assertion is on the derived value — so a future change to the derivation has to keep producing 6144 for that pack, which is the whole point.

Nothing asked for.

@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 d7399c065 — the derivation refuses when the evidence is ambiguous, and its test proves the case that would otherwise have been wrong.

Built and ran the suite at this head (rc=0):

im-from-node-attrs: derived 10240 from the If branch's gate/up and down pair (hidden 2560)
im-from-node-attrs: q_dim 4096 > hidden 2560, fused gate_up -> 9728 (must not be 4096)
all npu-pack checks passed -- pack leg SKIPPED, no pack directory given

The second line is the review I would have written if it were missing: attention has the same (N == others' K, K == hidden) shape as the FFN, so a derivation that only pattern-matched that shape would return 4096 for a model whose attention is wider than its hidden size and produce a plan with the wrong FFN width — silently, since nothing downstream re-checks it. The PR's structural witness (gate and up read the same activation tensor; q_proj does not) is the right discriminator, and the arm that asserts 9728 where 4096 was on offer is what makes it a test rather than a demonstration.

Three negative arms (intermediate_size_from_graph(...) == 0 for three crafted graphs) are worth as much: "derive, never assume, and say nothing when the evidence is absent" is the correct failure mode, and a 0 that the caller turns into a named refusal is better than a plausible number. That is the #1110 class avoided by construction.

Two things a merger should know, both already stated in the body and both true:

  1. This is stacked on #1644 (npu-pack-model-config-geometry) because it needs graph_filename() to find decoder/model.onnx, and it retargets to main when #1644 merges. The diff showing only its own change is the right call — I checked and it does.
  2. The real-pack before/after used a line from #1643 applied locally, not in this diff, because the walker has to recognise MatMulNBitsBf as a quantised matmul before this path fires on a real pack. That is disclosed clearly, but it means the measurement cannot be reproduced from this diff alone — it needs #1643 first. Given #1643, #1644 and this are in the same family and the map I put on #1503 has #1657 region-disjoint (-825 and up) from the -299/-672 collisions, the practical order is #1643 → #1644 → this.

UNSTABLE here is only pr_agent=CANCELLED / hw_gate=SKIPPED, not a failing check.

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

The separate gate/up witness (two nodes reading the same activation with the same N) is a good addition, and the name filter does what it says. At the head, npu_pack_import_test builds and passes, including the new arms.

But the fused branch has the shape backwards, and the new test passes only because its fixture does too. In a fused MLP, gate_up_proj is (K = hidden, N = 2w). The activation splits that output into two halves of width w and multiplies them, so down_proj reads a w-wide input: (K = w, N = hidden). The fixture at tests/npu_pack_import_test.cpp gives down_proj K = 19456 (2w), which no real model has. My replay at f4e82d28 used down K = 9728. That is why your first attempt, gate_n.count(2*dk), was the right direction, and it failed only against this fixture.

I added two local test arms at this head (not pushed) with the real shape, down K = w:

named   q/o_proj 4096, gate_up_proj N=19456, down_proj K=9728  -> 0     (true 9728: the fused derivation never fires)
opaque  same shapes, names n0..n5 (+ k/v 2048)                -> 2048  (true 9728: q_dim/2, silently wrong)
  • The first result is only a missed derivation.
  • The second is the failure this PR exists to prevent. Without role names, o_proj gives down_k {4096} and q_proj gives gate_n {4096}. 4096 is even, so the fused branch returns 4096 / 2 = 2048 as intermediate_size.

Suggested fix:

  • Fused match: gate_n.count(2 * dk) gives width dk. With real shapes this returns 9728 in both arms above. It also removes the opaque-name hole: for attention, 2 * q_dim is not an N in gate_n, so q_dim no longer matches. Keep refusing when more than one candidate matches.
  • Fix the fixture to down K = 9728. Keep the opaque-name arm as a regression test, expecting 9728, or 0 if you'd rather refuse there.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

(Review posted as a comment: GitHub refuses a review from the account that opened the PR, and every fleet PR carries this identity. Content is a review.)

VERDICT: READY at 1f4922ca — the derivation refuses in every case it cannot prove, and the one growth area I found is a layout it has not been measured on, not a defect in what it does.

What I verified

The decision procedure, transcribed and driven with the three shapes that matter (its rules, not a paraphrase: excluded roles, gate_n = {N | K == hidden, N > hidden}, down_k = {K | N == hidden, K > hidden}, the shared-activation witness, then the fused fallback):

input result note
separate gate/up (N=10240,K=2560) + down (N=2560,K=10240) — the e4b shape your test builds 10240 matches the real pack
fused gate_up (N=19456,K=2560) + down (N=2560,K=19456) — your wider fixture 9728 attention's 4096 cannot answer, because attn/q_proj/o_proj are excluded and q_proj never has a partner on one activation
attention-only, wider than hidden 0 refuses, never q_dim
two widths that both pair up 0 refuses rather than picks

That is the property this function needs, and the "two agreeing widths is a derivation that can be wrong in two ways → refuse" rule is the right one to have written down. The graph_filename change is also correct where it matters: decoder/model.onnx is accepted while a leading / and any .. are still refused, so a pack cannot aim the reader outside itself.

The one growth area (non-blocking, and it is a measurement gap rather than a bug)

The fused branch fires only when the down projection's K equals the fused N — i.e. when down consumes the concatenated 2w:

fused: down.K=19456, gate_up.N=19456  -> 9728     # your fixture
fused: down.K=9728,  gate_up.N=19456  -> 0        # down consumes the ACTIVATED w

The second is the common Hugging Face fused layout (gate_up → N = 2w, activation halves it, down consumes w). There IM = down.K is unambiguous given gate_up.N == 2*down.K, but the current rule returns 0 and the pack still refuses — which is the outcome this PR exists to remove. Your comment is honest about the assumption ("paired explicitly with the down projection's K = 2w"*), so this is not a wrong number; it is a pack genre still on the refusal path.

If you want it: accept dk when gate_n contains 2*dk and return dk, keyed on a role allow-list (gate/up/mlp) rather than the current exclusion list — the exact-equality match is a strong witness precisely because attention cannot reproduce it, and the 2*dk match is weaker, so it should not lean on "not excluded" alone. Alternatively leave it: refusing is the safe direction and this is documented.

Two smaller observations

  • fill_from_model_config copies every key of text_config into cfg, not the three read_dims needs. Only gaps are filled, so nothing is overridden — but a nested object key that lands where read_dims expects a scalar would surface as a typed-get exception rather than a diagnosis, which is the class the block comment above it says it exists to avoid. Copying the three known keys (or skipping non-scalars) would keep that promise; your test would not change.
  • n.find("..") == npos also refuses a legal name like model..onnx. Harmless and it errs the safe way, so I would not change it.

bong-water-water-bong added a commit that referenced this pull request Nov 9, 2026
…in does not compile (#1673)

`main` at `46d724bc` is red:

    npu/pack_import.cpp:1816:38: error: 'resolve_token_fconst' was not declared in this scope

`7a799e16` (#1660) renamed the materialising function to `materialise_token_fconst` and updated every call
site except this one. The right replacement is NOT the materialising one: this is a predicate ("do the
weights come from a .fconst?"), and the rename's own comment says the predicate is `find_token_fconst()`,
"which touches nothing". Calling the writer here was the second 278 MiB rewrite per command that the same
comment describes (measured: two O_TRUNC opens of the same 291 MB file for one `npu-pack info`), so this
fixes the compile break and the double write in the one line.

    -        const bool fconst_weights = !resolve_token_fconst(pack_dir, d).empty();
    +        const bool fconst_weights = !find_token_fconst(pack_dir).empty();

`find_token_fconst(const std::string&, std::string* why = nullptr)` is declared at :498, above the call, and
its single argument matches.

Searched before writing, per the rule: no branch (`git ls-remote --heads`) and no open PR fixes this — the
open npu-pack PRs (#1657, #1661, #1669, #1670) are all based before the rename and still contain the old
name defined and called consistently, so merging any of them will need this naming taken from main anyway.

Verified by reading the signatures, NOT by a local build: disk is at 174 G free, under the 175 G floor the
orchestrator set for starting a build, so the compile is CI's. Saying that plainly rather than implying a
green build I did not run.

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>

@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 (mechanical, rebase) — and the dependency it names has landed.

The base is gone, and the PR says what to do about it

#1644 (npu-pack-model-config-geometry) is MERGED, and this branch was built on it. mergeStateStatus is now CONFLICTING/DIRTY against main, which is exactly the case the PR body anticipates:

"Base is npu-pack-model-config-geometry (#1644), not main … Retarget to main when #1644 merges."

So: rebase onto main (the graph_filename() half is already there) and this becomes reviewable as its own change. Nothing about the content needs to move — the stacked diff was the right call at the time.

Its other dependency has also landed: the PR says it "fires on a real pack only once its walker recognises MatMulNBitsBf as a quantised matmul", measured "with that single line applied locally (not in this diff)". That is #1643, merged as 26ef45e5. After the rebase, the PR should say so — a reader should not have to infer that the "applied locally" line is now on main, or wonder whether the before/after can be reproduced from the branch.

The test half is registered, which is the thing I checked first

tests/npu_pack_import_test.cpp is already wired on main:

CMakeLists.txt:1076  add_executable(npu_pack_import_test tests/npu_pack_import_test.cpp)
CMakeLists.txt:1077  target_link_libraries(npu_pack_import_test PRIVATE onebit_npu)
CMakeLists.txt:1078  add_test(NAME npu_pack_import COMMAND npu_pack_import_test ${CMAKE_SOURCE_DIR}/tests/golden)

and the same three lines exist in this PR's tree, so the new test_im_from_node_attrs case will actually run in CI rather than sitting in a file nobody executes — which is the failure mode #1595 was about and the one this repo keeps having to catch. The two exported helpers are marked "Exported for the unit test, which needs no pack", so the case is drivable from tests/golden fixtures.

What I did not review, and why

The derivation logic itself (intermediate_size from the FFN pair's node attributes when no file and no initializer dim states it) — reproducing its before/after needs the gemma-4-e4b-it-mobius-int4_rai_1.9.0_npu pack, which is not on this box. I am not going to bless a derivation I could not run; the rebase is the blocker anyway, and the code review should happen against a diff that is not stacked two-deep on a merged branch.

One conflict in npu/pack_import.cpp: this branch's
intermediate_size_from_graph()/find_token_fconst_exported() and main's
load_geometry_exported() (#1638) sit side by side; both kept. Diff vs main
is this PR's own. npu_pack_import_test: all checks passed, the
im-from-node-attrs arms run (10240 derived; fused gate_up 9728, not 4096).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Merged main into the branch at fc9bd43 (no force-push). There was one conflict, in npu/pack_import.cpp: this branch's intermediate_size_from_graph() and find_token_fconst_exported() sat next to main's load_geometry_exported() (#1638), and I kept both. The diff against main is this PR's own, line for line. npu_pack_import_test passes all checks, and the im-from-node-attrs arms run (it derives 10240; with a fused gate_up it gives 9728, not 4096). ready for review at fc9bd43

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CHANGES at head — one blocking point, and it is the duplicate-derivation question rather than any of the arithmetic.

What this adds is real and mine is not equal to it. The cases it covers that the code on main does not: the fused
gate_up_proj
(N/2, with a test that shows 9728 and not 19456), an attention wider than hidden must not become the FFN width
(their third fixture returns 0 rather than the attention width), and matmuls inside an If subgraph — the last of these is the one I
would not have thought of, and the test names it.

Blocking: on this head there are now TWO derivations of the same number. plan() still carries the inline im_from_graph lambda
that landed with #1643, and this PR adds intermediate_size_from_graph(model_path, hidden) beside it:

$ grep -c "auto im_from_graph" npu/pack_import.cpp   # on this head
1

and both are reachable — the diff calls the new function where the old one used to be the fallback. Two functions that answer "what is
this model's FFN width" from the same graph will disagree eventually, and the disagreement will be silent: the first one to return a
positive number wins, and the number it returns depends on which list of names it happens to walk. That is the same failure shape this
importer refuses elsewhere (rule (c): "a second opinion is how two answers start disagreeing").

Concretely: make im_from_graph delegate to intermediate_size_from_graph, or delete it and call the new function at both sites.
Then the delta between the old behaviour and the new is a single diff, and the fixtures that pin the old behaviour (an ordinary
gate/up/down pack) and the new (fused gate_up, attention wider than hidden, If branch) both run against one implementation.

Two smaller notes:

  • the wider-attention fixture is the valuable one and it is worth saying so in the body: a pack whose attention is wider than hidden
    is exactly the pack where a "max N among projections" heuristic silently returns the wrong FFN width, and the old lambda had that hole.
  • the retarget instruction ("base is npu-pack: a multi-component pack keeps its geometry in model_config.json, and its graph in a subdirectory #1644, retarget to main when it merges") is correct but the PR should not merge until then, because
    graph_filename() finding decoder/model.onnx is a prerequisite of the code path this adds — that is a sequencing fact, not a style
    preference.

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

Flagging one thing for the reviewer queue rather than a full review: this PR's body says the before/after was measured with #1643's one-line walker change applied locally, not in this diff. That is the right thing to disclose, and it also means the stated measurement cannot be reproduced by checking this branch out — so the body should either name the exact local patch (so a verifier can apply it) or the PR should stack on #1643. Not blocking on my account: the geometry derivation itself is self-contained and #1643 is in the queue.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

One thing for whoever verifies this, not a review of the change: the body says the before/after was measured with #1643's one-line walker change applied locally, not in this diff. That is the right disclosure — and it also means checking this branch out cannot reproduce the stated measurement. So either name the exact local patch so a verifier can apply it, or stack on #1643. The geometry derivation itself reads self-contained; this is about the measurement's reproducibility.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

My earlier READY does not carry to this head — the PR's own diff has grown from +164/−6 to +392/−8, so I am not re-stamping it. The branch has also merged main forward (+22 non-merge commits), which is why a range-diff here cannot separate your commits from main's; the size change is the honest signal.

Not a complaint, just tracking: whoever picks this up next should review the delta as new work rather than trusting the previous verdict. I will take it when a turn has budget for +392 (and ideally RAM above the 20 GiB floor — the box is at 10 GB, so no build this hour).

@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 fc9bd43: CHANGES

Re-verdict at the merge-of-main head. The own diff is identical to 836b91b (0 lines new, 0 gone; the merge is clean, with no markers), so my blocking point is unchanged. intermediate_size_from_graph() at npu/pack_import.cpp:1245 still reads if (dk % 2 == 0 && gate_n.count(dk)) fused.insert(dk / 2);. With real fused shapes (gate_up_proj N = 2w, down_proj K = w) it returns 0 when role names are present and q_dim / 2 = 2048 when they're opaque; the true width is 9728 (my arms at 836b91b, same code). The fix: gate_n.count(2 * dk) gives dk, plus a fixture with down_proj K = 9728.

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

Built and ran at this head: all npu-pack checks passed.

The delta is a new measured row (Phi-3.5-mini / phi3 at hidden 3072, 32 heads, 32 layers) and it is the best-documented row in this table, because it does not just show that the chosen rule fits — it shows what the wrong one would look like:

28 of the 32 blocks come out byte-identical and the four that differ hold all 26,145 differing bytes of 2,036,067,648 (1.28e-5), i.e. content in four layers, not layout. The separation that makes this a rule rather than a fit is the wrong-rotation end: any rotation other than this one differs in 307,097,372 bytes (15 %) across all 32 blocks, a factor of 10,000.

I checked the normalisation: 26,145 / 2,036,067,648 = 1.284e-05 — the stated 1.28e-5 is right, and the contrast against 15 % is a factor of ~1.2×10⁴, so "a factor of 10,000" is not rhetorical.

That two-sided evidence is the difference between a measurement and a fit: a rule that reproduces the bytes and whose every neighbouring hypothesis misses by a factor of 10,000 is pinned, whereas a rule that merely reproduces the bytes could be one of several that agree on this pack. It also explains the shape of the row rather than just asserting it — "the two halves of the bundle are swapped relative to 1.8 … so n=2/rot=1 is the minimal spelling of it (n=4/rot=2 and n=8/rot=4 are the same transform)" — which is the sentence that stops someone "simplifying" the row later.

Noted and consistent with #1633: the table is keyed by shape, so this row and its ambiguity guard are the same mechanism — if a second pack of this geometry ever gets measured with a different block, #1633's select_rule_in() refuses instead of rotating weights into the wrong projections.

Not verified: the pack itself (Phi-3.5-mini 1.8 build is not on this box), so the byte counts are the author's measurement, cited with its own denominator and reproducible by whole-file normalisation, which is the right form of citation.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at fc9bd433: CHANGES REQUESTED

One blocker, and it is the same failure the last round caught — the attention false positive — narrowed to a naming convention rather than closed structurally. Plus two bounds nits.

The blocker: the fused path can still return the attention width

You fixed the separate gate/up case with a real structural witness (two nodes sharing one activation tensor), and that part holds. But the fused fallback has no witness at all, only the substring exclusion:

const auto excluded = [](const std::string& weight) {   // :1217
    for (const char* role : {"attn", "q_proj", "k_proj", "v_proj", "o_proj", "qkv", "lm_head"})
        if (weight.find(role) != std::string::npos) return true;
    return false;
};
...
std::set<int64_t> fused;                                // :1243
for (const int64_t dk : down_k)
    if (dk % 2 == 0 && gate_n.count(dk)) fused.insert(dk / 2);

Your own test proves it only because the node names are attn.q_proj…. "attn" is not a substring of "attention" (a-t-t-e-n-t-i-o-n), so a pack that names its attention self_attention.q / self_attention.o walks past the filter, and then:

  • q (K=hidden, N=4096) lands in gate_n and in shared[h][4096] = 1;
  • o (N=hidden, K=4096) lands in down_k;
  • separate stays empty (count 1, not ≥2), so control reaches the fused loop;
  • 4096 % 2 == 0 and 4096 ∈ gate_n → fused = {2048} → returns 2048.

I ran your loop verbatim over that graph (hidden 2560, no FFN, names model.layers.0.self_attention.q / .o): down_k: [4096] gate_n: [4096] separate: [] fused: [2048] returned: 2048. The contract in pack_import.h is "Returns 0 when no FFN pair agrees", and 2048 is not a refusal — it is q_dim/2 dressed as intermediate_size, which is exactly worse than the 4096 case you already fixed (a wrong number that looks plausible, printed by a function whose job is to say "I don't know").

Suggested shape, and it keeps your measurement: require a positive MLP witness before accepting any width. The e4b and fused-gate_up packs both name the down projection …mlp.down_proj…; the attention node that spoofs the pair names nothing of the kind. So:

  • accept a candidate only when the down weight matches a positive MLP role (mlp, ffn, down, expert);
  • when no candidate carries one, return 0 — "no MLP role in this graph" is a refusal the caller can act on, and it is the honest answer for a naming convention nobody here has seen.

That makes the exclusion list a prefilter rather than the safety property, and the header's contract true for both paths. The test to add is the one above: attention-only, self_attention.q/.o, q_dim > hidden → expect 0.

Two bounds nits in the f32 reader (both refuse rather than read out of bounds)

const uint64_t need = uint64_t(Vf) * uint64_t(Hf) * 4ull;
if (table->offset + need > sz) {                                // :944

offset is uint64_t (:125), so offset + need wraps for a corrupt offset near UINT64_MAX — the check passes and raw = m + table->offset is a wild pointer. table->offset > sz || need > sz - table->offset cannot wrap. Same class, and the in-model branch needs it too: else if (table->raw != nullptr) (:949) takes the pointer with no bound at all, while the struct already carries raw_len (:127).

Scope: three mechanisms, one title

The diff is now +392/−8 and carries three separate things, only the third of which the title names:

  1. find_token_fconst follows genai_config.json's decoder component (a real bug: "the pack carries no Token .fconst" for a pack that has one two directories down);
  2. read_embedding_component follows the declared embedding component and reads unquantised f32 tables and refuses per-layer embeddings by name (that is engine#1725's decision, not this issue's);
  3. intermediate_size_from_graph (the title).

I am not asking for a rewrite — 1 and 2 are why the real pack gets as far as the embed refusal, so they are stacked by necessity. But each is independently reviewable and independently landable, and right now a problem in any one of them stops all three. If you split, land them in that order. If you keep them together, the PR body should say "three mechanisms" the way your base note already says "#1644, not main", so the next reviewer measures the right thing.

What is good here, so it is on the record

  • Deriving the pair from node attributes instead of hunting another file format is right, and the model.pb.bin finding (122,651 B, 0 nodes, 0 initializers — not a ModelProto) saves the next person that dead end.
  • Refusing when two widths both pair up, rather than picking, is the house rule and it is implemented.
  • mobius widths (N=512/2048) genuinely cannot be mistaken for the FFN under the structural rule; the measurement backs it.
  • Following the pack's own embedding.filename is the same rule the graph already follows.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at fc9bd433 — the derivation refuses instead of guessing, and the trap it was hardened against is the
right one to have been caught.

Why it holds

  • The attention trap is handled structurally, not by name alone. q_proj is (N = heads*head_dim, K = hidden) and o_proj
    is (N = hidden, K = heads*head_dim), so a bare "pair whose shapes agree" returns the attention width on any pack whose
    attention is wider than its hidden size — the review note in the comment (fused gate_up + q_dim 4096 against a true 9728)
    is exactly the failure this class of heuristic makes. The fix is the right kind: the structural witness that gate and up
    read the same activation tensor at the same N, which a lone q_proj cannot produce and o_proj has no partner for. Role
    names alone would have been a naming bet; the shared-activation count is a fact about the graph.
  • Ambiguity refuses rather than picks. separate.size() > 1 -> 0, i.e. "no number" is preferred to "a plausible number",
    and the caller turns 0 into a refusal that names what it looked for. That matches the contract the comment states, and it is
    the only behaviour that is safe in an importer that writes containers other stages trust.
  • Fused gate_up is paired explicitly with the down projection's K (both 2*w), then halved — the one case where the
    agreeing value is not the answer.

Two things to fix before merge (neither is in the code)

  1. The body's dependency notes are stale. It says "base is npu-pack-model-config-geometry (npu-pack: a multi-component pack keeps its geometry in model_config.json, and its graph in a subdirectory #1644), not main" and that the
    before/after was measured "with npu-pack: see the 1.9 external-data genre -- MatMulNBitsBf, the *.qweight.packed sibling, and the stride as a measured 201/201 (refs #1503) #1643's one-line walker change applied locally, not in this diff". Both npu-pack: see the 1.9 external-data genre -- MatMulNBitsBf, the *.qweight.packed sibling, and the stride as a measured 201/201 (refs #1503) #1643 and npu-pack: a multi-component pack keeps its geometry in model_config.json, and its graph in a subdirectory #1644 are
    merged
    , the PR's base is main, and origin/main:npu/pack_import.cpp carries MatMulNBitsBf — so the measurement is
    reproducible from this branch as it stands. Leaving the caveat in makes a verifier hunt for a patch that is already in main;
    replacing it with "measured on main at <sha>" turns a caveat into evidence.
  2. Say what happens on a pack where the derivation returns 0 — is the refusal the same "states no intermediate_size" message
    with the new sources appended, and does allow_incomplete still let such a pack through? The message change is visible to
    whoever reads a refusal, and it is the one place a reader learns the new sources were tried.

Not run: I did not build or run npu-pack against the gemma pack. The review is of the derivation's logic and the refusal
contract, plus a check that the dependencies it names are actually in main now (they are).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Heads-up for merge order: #1633 now contains the same table row as this PR, byte for byte.

{3072, 32, 32, 10560, 2, 1, 5222400}

Both current heads have it, the three comment lines above it are identical, and neither branch contains the other — so two lanes measured the same Phi-3 geometry independently and added the same row. A textual merge will either collapse it to one copy or leave the row duplicated, and a duplicated row is the one case #1633's own ambiguity guard cannot catch: the rows are identical, so select_rule_in() sees no disagreement and the table silently carries the measurement twice.

So: land one, drop the row from the other. Nothing in this PR's reader work is in question — this is purely "two PRs, one array row, pick an order". I have flagged the same on #1633.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Correction: the merge-order warning above does not apply to this PR. I checked the diff at the current head:

$ gh pr diff 1657 | grep -c '3072, 32, 32, 10560'      → 0
$ gh pr diff 1658 | grep -c '{3072, 32, 32, 10560, 2, 1, 5222400}'  → 1

This PR touches npu/pack_import.cpp, .h and the test — the structural derivation, the refusal text, the attention exclusion, and the two component lookups (Token .fconst, embedding graph + data). It adds no kFconst19Rules row at all, so there is nothing here to drop or de-duplicate. The row belongs to #1658, which is where the warning should live.

And the warning's other half is stale at current heads: #1633 (16ffb554ed, updated after the comment) adds {576, 9, 30, 3776, 4, 1, 76800} and {1536, 12, 28, 3136, 7, 1, 226304} — no {3072, 32, 32, …} row. So at the two current heads there is no duplicated row between #1633 and #1658, and whoever acts on the heads-up should re-check before removing anything: dropping a row from #1658 on the strength of a stale comparison would delete the only copy of the Phi-3 geometry.

Worth saying plainly because the failure mode is one-directional: a duplicated row is loud (the ambiguity guard or a reviewer sees two identical lines), but a deleted row is silent until a Phi-3 pack refuses with "its block shape has not been measured for hidden 3072, 32 heads, 32 layers".

Nothing about the READY verdict changes; this is bookkeeping on which PR owns which row.

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