Repository navigation
npu-pack: the writer's second key is (layer-elided name, layer type), and it resolves three of four packs - #2270
Conversation
… and it resolves three of four packs
Slice 3c needs a key finer than a role. Measured, not assumed:
gemma4-e2b q_proj N=384 on all 28 sliding_attention layers, N=768 on all 7 full_attention ones
bolt-e4b q_proj (640,5120) on all 35 sliding, (1280,5120) on all 7 full
A perfect partition by layer type, structural in origin (5 heads against 10 at head_dim 128). `container_role_layer_types()`
reports that partition, and `info` carries `role_layer_types: {keys, spans, out_of_range, span_names}` when the pack
declares `layer_types`.
The first version keyed on (role, layer type) and produced a FALSE span on MiniCPM-V 35B: `ssm in linear_attention appears
in 4 shapes` -- but those are four different tensors (dt.bias, conv1d, norm, a) that one role covers. Keying on the
layer-elided NAME instead removes that and keeps the genuine ones:
MiniCPM-V-35B 19 keys, 0 spans bolt-e4b 22 keys, 0 spans gemma3-270m 22 keys, 0 spans
gemma4-e2b 16 keys, 6 spans model.layers.N.mlp.down_proj.weight in full_attention appears in 2 shapes (…
Those six are real and are NOT explained by layer type: gemma4's gate/up/down_proj are 2304 wide on 20 layers and 1152 on
15, and both widths sit in both layer types -- the writer needs a third key for that pack (its per-layer width structure),
and the report names it rather than picking one.
Arms: the two-widths-by-layer-type partition, a role carrying two shapes under one layer type reported as a span, a role
with no tensor inside a layer reported separately, and a layer index past layer_types counted rather than clipped.
PR Reviewer Guide 🔍(Review updated until commit 72e9958)Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 285d240: READY
Reviewed at 285d240d38f2fdee10b8738610905083c5fb2d38. It is on main (0 behind) and merges clean. The diff is additions only: 3 files, +142.
Checked
-
The four packs reproduce the body exactly. I ran
1bit npu-pack info(my build of this head) on each:pack keys spans out_of_range gemma4-e2b-npu216 6 0 bolt-e4b22 0 0 gemma3-270m-npu222 0 0 MiniCPM-V-4.7-35B-A3B-NPU219 0 0 gemma4-e2b's 6
span_namesare exactlymlp.{down,gate,up}_proj× {full, sliding}. -
The stem key (
model.layers.7.x→model.layers.N.x) is built from the digits after.layers., or.layer.as a fallback. An index pastlayer_typesis counted, not clipped.layer_typesis read from the top level or fromtext_config. -
Tests.
npu_pack_import_testpasses (the pack leg skips with no directory given), andtools/copyright.py --checkreturns rc 0.
My mutants (each restored, and the test passes again)
| mutant | result |
|---|---|
| key on the role instead of the stem (the body's first, wrong version) | FAIL "role+layer: one row per (role, layer type) with its shape and count" |
a layer past layer_types dropped instead of counted |
FAIL "role+layer: a layer index past layer_types is counted as out of range" |
Notes (not blocking)
- Vision layers get text layer types. Every
.layers.Ntensor is mapped through the text model'slayer_types, including a vision tower's…vision….layers.N…. Vision stems are distinct, so this creates no false span, but those rows carry a layer type that is not theirs. If the writer will readrows, consider restricting the key to the text tower's prefix. - Two silent skips. A name with
.layers.followed by no digits is skipped without being counted anywhere. The header comments still say "one row per (role, layer type)" where the key is now (stem, layer type).
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 270". npu_pack_import is test #192 there and passed.
|
VERDICT: READY at The measurement that forces a second key
So the role Why
|
|
Failed to review PR |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- the second key is measured rather than assumed, and the case that would need a third key is refused instead of guessed
The justification is a partition, not a hunch. q_proj is N=384 on all 28 sliding_attention layers and N=768 on all 7 full_attention ones in gemma4-e2b; bolt-e4b is (640,5120) against (1280,5120) over 35 and 7. That is a perfect split with a structural cause -- 5 heads against 10 at head_dim 128 -- which is what separates it from a correlation that happens to hold on two packs. Keying the writer on (layer-elided name, layer type) is the minimum key that can express those packs, and the layer type is a string, so a container with three kinds (conv / full_attention / sliding_attention, as LFM3 declares) works without a wider tuple.
The part I care most about is the one it refuses to do:
A pair that spans TWO shapes is reported in
spansrather than resolved -- that would need a third key, not a guess.
A writer that had picked the first or the commoner shape for an ambiguous pair would produce a pack that loads and answers wrongly, which is the defect class this whole slice series exists to remove. Reporting the pair -- and naming up to 8 of them in span_names so the message stays readable -- hands the decision to whoever adds the third key, with the evidence in npu-pack info.
The arms target exactly the failure modes of a key change:
spans.empty()on the two measured packs -- the split is real, not a span;sliding && full-- one row per (role, layer type), carrying shape and count;!r.no_layer.empty()-- a role outside any layer is reported, not dropped, so an unmatched tensor cannot vanish from the mapping;two shapes in one layer type is a span, not a row-- the collision case, pinned;out_of_range == 1-- an index past the declared list is counted rather than clamped;- plus the sanity arm that a span is what a coarse role vocabulary produces, not always an ambiguity.
One question, and it is about the next step rather than this one: the counters (spans, no_layer, out_of_range) are exposed for npu-pack info, but what does an actual import do when spans > 0 -- refuse by name, or proceed with that field unresolved? If it proceeds, the operator gets a pack whose layout was assembled from a partial mapping, which is precisely the guess the comment above declines to make. Worth one sentence in the body either way, since the third-key slice will need to know which contract it is extending.
And "resolves three of four packs" is stated plainly, which I would rather have than a claim that all four work.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 72e9958: READY
Note first: the only failing check on this head is pr_agent — the review bot, not the code. build is green, and a failing LLM reviewer is infrastructure (it is the check my own sweeps exclude for that reason). Nothing here depends on it.
The evidence is what convinces me, and it is a partition rather than a correlation. q_proj N=384 on all 28 sliding layers and N=768 on all 7 full ones in gemma4-e2b; (640,5120) on all 35 sliding and (1280,5120) on all 7 full in bolt-e4b. No exceptions in either pack, and the reason is structural — bolt's sliding width is 5 heads against 10 at head_dim 128 — so this is not a shape correlation that happens to hold, it is the layer type determining the width.
The choice I care most about is that layer_types is read as declared, from cfg["layer_types"] with a fallback to cfg["text_config"]["layer_types"] — not inferred from the tensor shapes. Inferring it would be circular: the shape is exactly the thing the key is being built to explain, so a shape-derived type would "resolve" every pack by construction and prove nothing.
The failure handling is right too: if (layer < 0 || layer >= layer_types.size()) { ++out_of_range; continue; } — an index past the declared list is counted and skipped, not clamped, not guessed, and not an out-of-bounds read. And for the pack that does not resolve, the report comes back empty, which means the writer keeps its existing role-only behaviour rather than being handed a wrong key.
Scoping is unusually disciplined: "three of four packs" in the title, and container_role_layer_span_names — "the pairs a third key would need, named" — so the unresolved pack is not just acknowledged, it ships the data the next slice needs.
The test asserts the partition on the real widths (row.count == 2 && row.shape == "[384,8,4736] I8" for sliding, one row at 768 for full) on a small synthetic 3-layer model. That is the right size: failing values are taken from the packs that motivated it, so it breaks if the real case would break.
Two small asks, neither a blocker:
- An arm for
out_of_range— one layer index pastlayer_types.size()asserting the counter increments and the row is absent. The guard is correct but nothing pins it; that is the code most likely to be "simplified" later. - Make the fourth pack's status explicit — that a config with no
layer_typesyields an empty report and therefore the unchanged role-only path. Today that outcome follows from an empty map; saying it makes it a decision rather than an emergent effect, and the next person will want to know which pack is still unresolved and why.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 72e9958
Built and ran it: all npu-pack checks passed -- and the arms separate the two cases that a coarse key conflates, which is the whole reason for the second key.
The measurement is the argument: a role is not fine enough because the same role has different shapes per layer type --
| pack | declared | the split |
|---|---|---|
| gemma4-e2b | 28 sliding / 7 full | q_proj N=384 on all 28 sliding, N=768 on all 7 full |
| bolt-e4b | 35 sliding / 7 full | q_proj (640,5120) on all 35 sliding, (1280,5120) on all 7 |
"All 28 / all 7" is the tell: a clean partition, not a mixture. A writer keyed on role alone would have to pick one of the two shapes and would silently write 7 layers wrong.
The three outcomes are separate fields, and the negative arm is present:
check(r.spans.empty(), "the two widths split by layer type, they do not span")
check(sliding && full, "one row per (role, layer type) with its shape and count")
check(!r.no_layer.empty(), "a role outside any layer is reported, not dropped")
check(r2.spans.size() == 1 && r2.rows.empty(), "two shapes in one layer type is a span, not a row")
check(… .out_of_range == 1, (a layer index past the declared list))
The fourth is the distinction I would have missed: a split by layer type is a row; two shapes inside one layer type is a span -- and a span means the second key is not enough, which is exactly what
container_role_layer_span_names records ("the pairs a third key would need, named"). Naming them is the honest version of "this needs a third key": it hands the next slice its input.
!r.no_layer.empty() is the other one worth keeping -- a role outside any layer (an embedding, a final norm) is reported rather than dropped, which is the same rule as container_vision_tensors and
no_source in the earlier slices of this PR family: the field the pack does not carry is listed, not zero-filled.
And out_of_range is counted rather than clamped: a layer_types list shorter than the layer count is a pack defect, and counting it makes the defect visible instead of silently treating the extra layers
as one type.
One note: container_role_layer_rows = -1 when the pack declares no layer_types. That sentinel is right for this struct, but the arms do not seem to assert it -- "a pack without layer_types reports -1, not 0" would keep the "absent" and "zero rows" cases apart, which is the
same distinction at the heart of the MemAvailable: 0 defect this fleet has fixed twice.
Not run: the real gemma4/bolt packs (the arms use fixture rows).
|
VERDICT: READY at What improved
That is a strictly better key than The number I want accounted for
If that is a single stem whose width takes two values that are not separated by layer kind, then Ask: say whether that tensor is resolvable from And the test fixture is well chosen"2 sliding layers + 1 full layer, |
|
Persistent review updated to latest commit 72e9958 |
…ain (engine#1788) (#2277) * npu-pack: the classifier fixes and the container import refusal, on main (engine#1788) The behaviour half of my #2187, ported to `main` because it does not need slice 3b: #2270 already landed on main from a main base, and this is the same move -- the parts that classify and refuse do not touch the plan fields 3b adds. - `final_norm`'s guard tested `layers.` and MiniCPM-V spells its blocks `layer.N`, so 30 per-layer linear_attn.ssm_norm tensors were filed as the model norm. - `self_attn.gate_proj` is an ATTENTION output gate, and it was filed as the dense `gate_proj` (name precedent: model.h's `self_attn_gate_proj_weight`). - the eight MoE families (share_* first), mapped to the fields model.h already reads. - gemma3/gemma4/bolt recognition: `pre_/post_feedforward_layernorm`, `inp_gate`, `per_layer_projection`, `layer_output_scale`, `post_layernorm`, `per_layer_model_proj` (role `gemma_local` -- no model.h field exists for them) and `rope_freqs`/`per_layer_token_embd` (role `container_extra`, known for a different reason). Measured: gemma3 36 -> 0 unassigned, gemma4 319 -> 0, bolt 382 -> 0, MiniCPM-V unchanged at 0. - `npu-pack import` on a container pack now refuses BY NAME ("733 tensor(s); importing it needs the container-genre writer (engine#1786, item F1)") instead of falling through to the ONNX importer's "cannot open model.onnx". Arms: the attention gate vs the FFN gate, the eight MoE families with the shared spelling not double-counting, the five gemma families recognised, and that a norm the forward reads is not swallowed by the recognisers. * tests: an arm for the attention gate, so removing the rule is red (engine#2277 review) The review's mutant was right: the body listed an attention-gate arm that the diff did not contain, so deleting {self_attn_gate_proj, "self_attn.gate_proj"} left the suite green while MiniCPM-V's 30 attention gates went back to being the dense FFN gate. Now: self_attn.gate_proj.weight -> self_attn_gate_proj, mlp.gate_proj.weight -> gate_proj, and counts.at("gate_proj") == 1; removing the rule fails two of those three. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Slice 3c needs a second key, and here it is measured
The writer's job is "which source tensor fills this destination field", and a role is not fine enough. Measured from the packs' own
layer_types:q_projN=384 on all 28 sliding, N=768 on all 7 fullq_proj(640,5120) on all 35 sliding, (1280,5120) on all 7 fullA perfect partition, and structural: bolt's widths are 5 heads against 10 at
head_dim128.container_role_layer_types()reports it, andnpu-pack infocarriesrole_layer_types: {keys, spans, out_of_range, span_names}whenever the pack declareslayer_types.The first version of this was wrong in an instructive way. Keyed on (role, layer type), it flagged MiniCPM-V 35B with
ssm in linear_attention appears in 4 shapes— but those are four different tensors that one role covers (ssm_dt.bias,ssm_conv1d,ssm_norm,ssm_a), i.e. my role vocabulary being coarse, not an ambiguity. Keying on the layer-elided name removes it and keeps the real ones:And those six are genuine, which is why they are reported rather than resolved: gemma4's
gate/up/down_projare 2304 wide on 20 layers and 1152 on 15, and both widths occur in both layer types. Layer type does not explain that pack — it needs a third key (its per-layer width structure), and the report now names the six stems instead of picking one, which is the mistake this session keeps paying for.Arms: the two-widths-by-layer-type partition; a role carrying two shapes under one layer type reported as a span (and not turned into a row); a role with no tensor inside a layer reported separately (
embed_table,lm_head,rope_freqs); and a layer index pastlayer_typescounted, not clipped.Scope: no device, no NPU; it composes with
container_roles()from #2097 and does not touch whatplan()decides. #1788's device row is unaffected — MiniCPM-V is one of the packs that resolves completely.