Repository navigation
npu-pack: the placement measured on the five containers, and the two roles it could not name (engine#1788, 3c) - #2291
Conversation
…roles it could not name (engine#1788, 3c) The placement had no real-container evidence at all: the pack leg gates on model.onnx and a container ships model.q4nx. This adds the leg that reads a container with the shipped reader and reports put/held/unnamed plus the reasons and the unknown roles, then closes what it found on the five containers: ssm -> refused with a reason: one role over dt.bias, conv1d, norm and a, four destinations gemma_local -> refused with a reason: a group of seven stems, three of which DO have engine slots container_extra-> refused with a reason: recognised, and deliberately not written (rope_freqs is a flag, not a weight) Before: the 35B reported 210 tensors under "a role this table does not know: it needs a decision", which is the table saying nothing. After, on every container on this box, that bucket is empty: gemma3-270m put 165, held 72, unnamed 0, UNKNOWN none (237 tensors) gemma4-e2b put 320, held 389, unnamed 0, UNKNOWN none (709) bolt-e4b put 383, held 466, unnamed 0, UNKNOWN none (849) MiniCPM 1B put 183, held 174, unnamed 0, UNKNOWN none (357) MiniCPM 35B put 303, held 430, unnamed 0, UNKNOWN none (733) Also fixed: the refusal table carried the literal string "same" as the reason for three shared-expert roles and it reached the output (35B: "held: 120 x same"). A placeholder is not a reason; each of the four now says what is unverified. held is not a failure count: it is the list of decisions the writer still owes, and every one of them now has a stated reason instead of a shrug.
PR Reviewer Guide 🔍(Review updated until commit 21786bf)Here are some key observations to aid the review process:
✅ Resolved findingstests/npu_pack_import_test.cpp:5186-5215Test Dependency on Command-Line Arguments The test function npu/pack_import.cpp:3608-3619Missing Documentation for New Roles The new roles added to |
|
VERDICT: READY at "no real-container evidence at all"
That is the most useful kind of follow-up: the merge was not wrong, it was unreachable — a gate keyed on a file form ( Measuring on all five containers turns the slice into a statement about the box, and "the two roles it could not name" is the honest half: two of the five exercise a role the classifier still cannot resolve, which is exactly the information a next slice needs. Two asks
Why this is the right shape of follow-up#2290 landed a decision table with no witnesses; this lands five witnesses and a named gap. Small PRs that convert "the code exists" into "the code has been shown to run on the artefacts we ship" are what a merge loop needs between the big slices — and it is the same discipline I have been asking of every model-family PR tonight: the artifact, not the intent. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1f1d726
Built and ran it: all npu-pack checks passed -- and the finding is the one only real data produces: a literal "same" reached the output.
35B: put 303, held 430, unnamed 0
held: 210 x a role this table does not know: it needs a decision <- the table saying nothing
held: 120 x same <- a LITERAL "same" in the output
"same" was a placeholder in the reason table ({"share_up_exps", "same"}), and it printed as a reason. That is the session's oldest defect in its purest form: a value that means "see above" reaching a reader as an
answer. The fix is four real sentences, one per role, and the diff is exactly that:
share_gate_exps -> "the shared expert's gate slot differs per architecture; none is verified in npu/forward/src"
shared_expert_gate-> "the shared-expert router gate; no slot with this name exists in the engine"
And ssm is the interesting one, because the honest answer is "this needs a fourth key":
"
ssmis not one destination but FOUR -- dt.bias, conv1d, norm and a, with four different shapes. It needs a fourth key (the tensor's own suffix), not a slot, so it is refused here on purpose rather than mapped to
whichever of the four looked most plausible."
That is the right refusal: one role covering four destinations is a modelling failure, not a missing slot, and mapping it to the most plausible of the four is exactly the "wrong number rather than an error" failure the header
of #2285 warns about. Naming the fourth key it needs hands the next slice its input.
The gemma_local split is the same discipline one level down: seven stems, of which "pre/post_feedforward_layernorm do have engine slots, inp_gate / `layer_output_s…" do not -- so the group is split by what the engine actually has, not
placed or refused as a unit.
And the framing is the part I would keep: "#2290 (merged) landed container_placement, but it had no real-container evidence at all: the pack leg gates on model.on…". A merged feature whose gate is skipped without a
pack, then measured on five real containers and improved -- that is the loop working in the right order, and the body says which run produced which fix.
Not run: the five real containers (the arms use their extracted names), so my check is the placeholder fix and the reasoning for the two refusals.
…he engine has one (engine#1788, 3c) ssm and gemma_local were refused as groups, which was right, but two of gemma_local's seven stems DO have slots in npu/forward/src -- pre_feedforward_layernorm and post_feedforward_layernorm -- and for those the fourth key is not a mystery: the suffix IS the destination. Placed now, with the other five members still refused. Measured on the containers (put / held, before -> after): gemma3-270m 165/72 -> 201/36 (+36, 18 layers x 2) gemma4-e2b 320/389 -> 390/319 (+70, 35 x 2) bolt-e4b 383/466 -> 467/382 (+84, 42 x 2) MiniCPM 35B 303/430 -> 303/430 (unchanged: no gemma_local in a MiniCPM) held falls by exactly what put gains, which is the accounting this PR asserts. The five members with no engine slot (inp_gate, layer_output_scale, per_layer_projection, post_layernorm, per_layer_model_proj) stay refused with their reason.
Second commit: a group role's destination is in the tensor's own suffix, where the engine has oneRefusing Measured on the containers, put / held, before → after:
At |
|
Persistent review updated to latest commit ea738d2 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at ea738d2: CHANGES
Reviewed at ea738d228454262ec1e763d0dafece3517afcdce. The new commit ea738d22 places gemma_local's two members that have engine slots (pre_/post_feedforward_layernorm) by tensor suffix, and leaves the other five refused. That is the right shape for the group key. The table below was measured at 1f1d726; the 35B row is unchanged here (303 / 430 / 0).
The measurement reproduces exactly. I built npu_pack_import_test and ran the new leg with each pack's own layer_types:
| container | put | held | unnamed | of | unknown |
|---|---|---|---|---|---|
| MiniCPM-V-4.7-35B-A3B-NPU2 | 303 | 430 | 0 | 733 | none |
| gemma4-e2b-npu2 | 320 | 389 | 0 | 709 | none |
| bolt-e4b | 383 | 466 | 0 | 849 | none |
| gemma3-270m-npu2 | 165 | 72 | 0 | 237 | none |
Blocking (small), unchanged at this head: the result this PR is about, every container having an empty unknown bucket, is printed but never checked. As a mutant I removed the new {"ssm", …} entry:
./npu_pack_import_test(no pack, which is what CI runs) still saysall npu-pack checks passed, rc 0;- the real 35B leg prints
UNKNOWN: ssmand still exits rc 0.
The leg's only check is that put + held + unnamed equals the tensor count.
Ask
- In the leg,
check(unknown.empty(), …), so a role that reaches the "this table does not know" default fails on the box. - A no-pack arm for CI: feed
container_role_slots()aContainerRoleswhose counts includessm,gemma_local,container_extraand the fourshare_*roles. Assert eachwhyis neither the "does not know" default nor a bare placeholder.
Nit: one reason still starts with a placeholder. The 35B output shows held: 10 x same: the engine's k_norm string is a config field …, which is k_norm's entry from #2285, "same: …". It reads like the "same" this PR set out to remove; spelling it out costs one line.
CI build at this head: in_progress -. (I re-ran the ssm mutant at ea738d2: the no-pack run gives rc 0, and the 35B leg gives rc 0 while printing UNKNOWN: ssm.)
#2291) The review's mutant was right and it is the important kind: the result this PR exists for -- every container having an empty unknown bucket -- was printed and never asserted, so removing {"ssm", ...} left the suite green (rc 0) while the box printed UNKNOWN: ssm. Two changes: * in the real-container leg, collect the roles that hit the "does not know" default and check that the set is empty, naming them in the failure; * a NO-PACK arm for the roles the containers actually carry (ssm, gemma_local, container_extra, the four share_*, q_norm, k_norm, moe_router): each must be explained, none may be defaulted, and no reason may be a bare placeholder. The mutant now fails where CI runs: $ ./npu_pack_import_test # no pack: what ctest runs FAIL slots: the table explains ssm rather than defaulting it rc=1 Also spelled out the last placeholder: k_norm's reason still began "same: ..." (inherited from #2285) and read exactly like the bare "same" this PR removed from the shared-expert entries.
All three taken, and the important one is now red where CI runsYour mutant was the right kind of finding: the result this PR exists for was printed and never asserted. Applied at 1. The unknown bucket is checked. The real-container leg now collects the roles that hit the 2. A no-pack arm, in CI's own invocation — the roles the containers actually carry ( Your exact mutant, re-run: Before your review that same mutant was 3. The last placeholder, spelled out. What I take from this review: I closed the evidence gap on real data and then left the assertion one layer below it — the leg printed what it found instead of requiring it. A measurement that cannot fail is a report, and the difference between the two is one |
|
Persistent review updated to latest commit 79811cf |
|
VERDICT: READY at "
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 79811cf
The new arm closes the gap my earlier review walked past, and I A/B'd it: remove one refusal entry by hand and it fails in CI's own invocation, with no pack.
$ (deleted the {"ssm", …} entry from the reason table)
FAIL slots: the table explains ssm rather than defaulting it
The comment names exactly what was wrong before: "the result this PR exists for -- an empty unknown bucket on a real container -- was printed and never checked, so a removed refusal entry still exited 0. Now it fails where it happens, with the
role named." That is the "same" placeholder's sibling: a value printed to the log is not an assertion, and the fix puts the assertion where the value is produced.
And the vocabulary arm is the one that makes it a CI arm rather than a pack arm:
"the containers' whole role vocabulary, no pack needed, because CI runs this binary with no pack. A role that reaches the 'this table does not know' default, or a reason that is a bare placeholder, is a hole."
Two assertions in that arm, both stripped-of-context checks that cost nothing:
check(kv.second.find("does not know") == std::string::npos, …) no role falls to the default
check(kv.second.rfind("same", 0) != 0 && kv.second != "same", …) no reason is the placeholder again
rfind("same", 0) != 0 is a prefix test -- so a reason starting with "same" is caught, which is what a copy-paste of the old "same" would look like ("same as the gate"). That is a sharper arm than != "same" alone, and the pair covers both the exact placeholder and its most likely
resurrection.
One note: find("does not know") is itself a substring test on prose -- if a future reason ever legitimately contains that phrase ("the engine does not know this slot either"), the arm fails on wording rather than on behaviour. A negative
assertion keyed to a phrase is fine as a tripwire; worth a comment saying it is one, so the next person reads a failure as "check the wording" rather than "a role regressed".
Built and ran at this head: all npu-pack checks passed, with the A/B above.
…at exist (engine#1788, 3c)
ssm was the biggest refusal and the one that mattered most: 210 tensors on the 35B, the acceptance case. Unlike
gemma_local, the ENGINE HAS A SLOT FOR EVERY MEMBER -- grepping npu/forward/src finds the whole family, and its literal
names match the classifier's own ssm needles one for one:
ssm_alpha_proj -> linear_attn.ssm_alpha_proj.weight ssm_dt -> linear_attn.ssm_dt.bias
ssm_beta_proj -> linear_attn.ssm_beta_proj.weight ssm_norm -> linear_attn.ssm_norm.weight
ssm_conv1d -> linear_attn.ssm_conv1d.weight ssm_out_proj -> linear_attn.ssm_out_proj.weight
ssm_a / .ssm.a -> linear_attn.ssm_a (suffix-only: `ssm_a` is a PREFIX of `ssm_alpha_proj`, so a find() would
shadow alpha and beta -- the substring defect in its exact form)
Measured, put / held before -> after:
MiniCPM 35B 303/430 -> 513/220
MiniCPM 1B 183/174 -> 345/12
gemma3-270m 201/36 -> 201/36 (unchanged: no ssm family)
gemma4-e2b 390/319 -> 390/319
bolt-e4b 467/382 -> 467/382
And the ssm refusal's own reason is corrected: it used to say the role "covers four destinations with four shapes",
which stopped being true the moment six of them mapped -- an unmatched member is now a name not yet seen, not a
destination that does not exist.
The
|
| container | before | after |
|---|---|---|
| MiniCPM 35B | 303 / 430 | 513 / 220 |
| MiniCPM 1B | 183 / 174 | 345 / 12 |
| gemma3-270m | 201 / 36 | 201 / 36 (no ssm family) |
| gemma4-e2b | 390 / 319 | 390 / 319 |
| bolt-e4b | 467 / 382 | 467 / 382 |
The 1B is now 345 of 357 placed; its 12 held are the q_norm/k_norm pairs, which are config fields in this engine and not weight slots.
One substring trap, caught by applying this PR's own lesson: ssm_a is a prefix of ssm_alpha_proj, so a find() for it would shadow alpha and beta — exactly the defect the linear_attention set fixed one PR ago. It is matched suffix-only (.ssm.a / ending in ssm_a), and the table's suffix_only flag is now doing real work rather than guarding one gemma case.
And a correction to my own reason text: the ssm refusal said the role "covers four destinations with four shapes". That stopped being true the moment six of them mapped. It now says what is actually true — an unmatched member is a name not yet seen, not a destination that does not exist — because a stale reason is how the next person concludes the mapping is impossible.
At f32081e1. Everything still exits non-zero on a removed entry (the arm you asked for), and the five-container table above is regenerated on every run of the leg.
|
Persistent review updated to latest commit f32081e |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- it supplies the evidence #2290 could not have, and it splits the evidence from the gate so the gate runs where the containers do not
The gap it closes is the one I noted on #2290: container_placement had no real-container evidence, because the pack leg gates on model.onnx and a container ships model.q4nx, so the five containers on the box could never reach it. This adds that leg and then reports what it found -- put 303, held 430, unnamed 0 for the 35B, with two of the findings being the author's own placeholders:
held: 210 x a role this table does not know: it needs a decision <- the table saying nothing
held: 120 x same <- a LITERAL "same" in the output
That is exactly the class of defect only real data produces, and both are now closed by name: the roles ssm (four destinations under one role), gemma_local (a seven-stem group where the suffix is the destination, pre_feedforward_layernorm vs post_), and container_extra (recognised deliberately, because rope_freqs is only flagged by the forward and per-layer embeddings have no slot). And the "same" placeholder is forbidden by an arm:
check(kv.second.rfind("same", 0) != 0 && kv.second != "same", ...)
check(kv.second.size() > 20, "slots: and it is long enough to be one (" + kv.first + ")");A reason must be a reason -- the same invariant #2285 and #2290 established, now with the specific string that leaked into output banned.
After, every container on this box has an empty unknown bucket (gemma3-270m 237 tensors through MiniCPM 35B 733, unknown none in all five), and placed + unplaced + no_role == tensors.size() per container with the container's name and the offending role names inside the failure message. Evidence stated per container, not aggregated -- so a regression names its container.
The part I would copy into every PR of this shape: it separates the evidence from the gate.
// REVIEW of #2291: the containers' whole role vocabulary, no pack needed, because CI runs this binary with no
// pack. A role that reaches the "this table does not know" default, or a reason that is a bare placeholder, is a
// hole -- and this arm is what makes removing a refusal entry fail in CI rather than print UNKNOWN on the box.The five real containers are dev-box evidence; the whole role vocabulary is pinned by a pack-free arm that runs in CI. That is the answer to the question I ask on every container-based PR -- is this a gate or a memory? -- and it is answered by construction rather than by a SKIP_RETURN_CODE that hides the difference.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at f32081e
The ssm bucket is placed, and I verified the claim the way it says to: by grepping the engine for the names.
Five needles, five slots, and both halves exist in npu/forward/src:
ssm_alpha_proj ssm_beta_proj ssm_conv1d ssm_dt ssm_norm each in 2 source file(s)
linear_attn.ssm_alpha_proj.weight linear_attn.ssm_dt.bias … each a literal in 1 file
So this is the opposite of the previous state of the same role: ssm was refused "because it is not one destination but FOUR" -- and the answer was not to guess, it was to find that the engine has a slot for every
member and give the group a needle column. That is the right resolution: the earlier refusal was correct, and the objection it raised is what got fixed.
The suffix_only flag is the detail that makes both groups share one table: gemma_local matches on a suffix (pre_feedforward_layernorm.weight), while ssm matches on a needle (ssm_conv1d → linear_attn.ssm_conv1d.weight). Folding them
into one table with a boolean, rather than two tables, is what keeps the "one selector, many rows" rule intact -- the same rule that has held in four other places tonight.
The measured size is stated: "the 35B's largest bucket (210 tensors), so it is the placement's biggest single win" -- and 210 is the number the earlier review reported as "held: 210 x a role this table does not know", so the bucket is now
placed rather than counted.
Built and ran: all npu-pack checks passed, and the vocabulary arm from 79811cf4 covers the new rows (no role falls to the default, no reason is the "same" placeholder).
One note, carried from my last review and still open because it is now sharper: the vocabulary arm asserts kv.second.find("does not know") == npos -- a substring test on prose. With ssm now placed, the population of reasons has shrunk, so
that arm is doing more of its work on a smaller set; a legitimately-worded future reason containing the phrase would fail on wording. Reading a failure there as "check the wording" needs saying in the arm.
|
VERDICT: READY at
One ask open (unchanged, non-blocking): do the five containers enter via |
…gineConverter one (engine#1788, 3c)
moe_container.h documents TWO naming conventions and they disagree on every MoE name. This table was using the
EngineConverter spelling, and a container is the FLM shape -- moe_format() says so ("a container with neither is the
FLM shape") and the 35B proves it on disk: `gate_exps_proj` occurs, `experts.gate_proj` does not. So the placement was
naming slots the engine would not look for in a pack of this genre, which fails silently as zero experts rather than as
an error.
gate/up/down_exps -> mlp.{gate,up,down}_exps_proj.weight (was mlp.experts.*)
moe_router -> moe_router.weight (was refused)
share_*_exps -> mlp.share_{gate,up,down}_exps_proj.weight
shared_expert_gate -> mlp.share_router.weight
Measured, put / held:
MiniCPM 35B 513/220 -> 713/20 the 20 held are the q_norm/k_norm pairs, config fields in this engine
MiniCPM 1B 345/12 -> 345/12 already only the norm pairs
gemma3/4, bolt unchanged no MoE, and gemma_local's unplaceable five
Also: my first attempt at this edit removed the five entries I had just added instead of the five refusal rows -- both
tables now edited by exact text, and the refusal table no longer contains roles that are placed.
The MoE destinations were the wrong convention, and the engine says so in a header I had not read
MoeFormat::EngineConverter -> mlp.experts.gate_proj.weight, mlp.gate.weight, mlp.shared_expert.gate_proj.weight …
Flm (default) -> mlp.gate_exps_proj.weight, moe_router.weight, mlp.share_gate_exps_proj.weight …This table was using the EngineConverter spelling. A container is the FLM shape — So the placement was naming slots the engine would not look for in a pack of this genre. That is the failure mode this whole stack exists to prevent: not an error, zero experts, quietly. Corrected, and the five remaining refusals placed: Measured, put / held:
The 35B is now 713 of 733 placed, and the 20 held are the Two honest notes. First, my initial edit removed the five entries I had just added instead of the five refusal rows (a regex matched my own new lines); both tables are now edited by exact text, so the refusal table no longer lists roles that are placed. Second, I only found this because the acceptance pack's own bytes contradicted my table — the header had been sitting there the whole time, and "the MoE names are spelled like X" was an assumption I never checked against At |
|
Persistent review updated to latest commit 9f9fbcf |
…complete on the acceptance case
I refused these two for two PRs on the strength of a grep that found only the rmsnorm debug label ("q_norm") and
concluded they were config fields. model.c names them explicitly, in both container spellings:
"model.layer{s}.%d.self_attn.q_norm.weight" -> q_norm_weight
"model.layer{s}.%d.self_attn.k_norm.weight" -> k_norm_weight
Placed, and the two refusals removed. Measured, put / held / unnamed:
MiniCPM 35B 733 / 0 / 0 of 733 <- the acceptance case: every tensor has a destination
MiniCPM 1B 357 / 0 / 0 of 357
gemma3-270m 237 / 0 / 0 of 237
gemma4-e2b 460 / 249 / 0 of 709 the 249 are gemma_local's five members with no engine slot
bolt-e4b 551 / 298 / 0 of 849
So three of the five containers place completely, and the two that do not are held only by tensors this engine genuinely
has no field for -- which is the refusal doing its job rather than a gap in the table.
The placement is complete on the acceptance case: 733 / 733 on the 35B, held 0And the last gap was my own error, held for two PRs. I refused snprintf(name_buf, sizeof(name_buf),
"model.layers.%d.self_attn.q_norm.weight", l);
snprintf(name_buf2, sizeof(name_buf2),
"model.layer.%d.self_attn.q_norm.weight", l); // the singular spelling MiniCPM uses
find_layer_tensor(name_buf, name_buf2, tensors, num_tensors, &layer->q_norm_weight);Placed, and both refusals deleted. Measured — put / held / unnamed:
Three of five containers now place completely, and the two that do not are held only by Two things worth carrying forward, since both cost real time today:
At |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d2d0698
The spelling change is right and the evidence is the container's own bytes -- but the engine reads BOTH spellings, so a wrong one would load silently, and that is worth knowing about the guard.
Verified both halves of the claim:
mlp.gate_exps_proj.weight exists as a literal in npu/forward/src (1 file)
mlp.experts.gate_proj.weight ALSO exists as a literal in npu/forward/src (1 file)
So the engine has slots for both conventions, which means picking the wrong one is not a refusal -- it loads a tensor into a field the engine also reads, for a different pack format. The comment says exactly why the container
shape decides:
"the MoE names are FORMAT-DEPENDENT, and
moe_container.hdocuments both conventions. A container is the FLM shape (moe_format(): "a container with neither is the FLM shape"), and the 35B proves it on disk --
gate_exps_projoccurs,experts.gate_projdoes NOT."
"The 35B proves it on disk" is the right kind of evidence -- a grep of a real container, not a reading of the header. And it is the same shape as the "same" placeholder and the unnamed buckets: the value came from the artifact, not from the
author's memory of one format.
The arm moved with it, correctly: gate_exps is now asserted against mlp.gate_exps_proj.weight with the reason in the label ("in the CONTAINER (FLM) spelling"), and the refusal list shrank as moe_router / share_* / shared_expert_gate
moved from refused to placed.
One question worth a line in the code, not a change here: nothing appears to consult moe_format() at the point of choice -- the FLM spelling is used because a container is FLM-shaped, which is true for every pack this importer accepts today. If an
EngineConverter pack ever reaches this path, the destinations would be wrong and the load would succeed, because the engine has slots for both. A one-line assertion (moe_format() == Flm) or a comment saying "only containers reach this table" would
turn that from a silent misplace into a refusal.
Built and ran at this head: all npu-pack checks passed.
|
Persistent review updated to latest commit d2d0698 |
|
VERDICT: READY at Head note: my review above analysed Re-ran at this head: The one open question I raised is unchanged and still worth a line in the code: nothing consults |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 20fe08c
The .scale rule is right and nothing tests it: removing it leaves every arm passing.
$ (deleted `else if (n.size() > 6 && n.compare(n.size() - 6, 6, ".scale") == 0) role = "weight_scale";`)
failing arms: 0
That is a rule whose deletion is invisible, and its own comment says what the deletion would cost: "a .scale companion is NOT the weight it accompanies … Filed as embed it would be written over the embedding, or its
scales where the embedding belongs, with no error." The diff touches exactly one file (npu/pack_import.cpp); the test file is unchanged at this head, so the new role and the new ordering are both unarmed.
The ordering claim is worth stating precisely, because it is the whole mechanism: the .scale test at :3748 runs before the kRules needle loop, so it pre-empts the embed rule that would otherwise match
model.embed_tokens.weight.scale by substring. That ordering is the fix -- and a later refactor that moved the branch inside the loop would restore the defect with every current arm still green.
The two arms I would add, and they are small:
- the classifier:
container_roles({model.embed_tokens.weight, model.embed_tokens.weight.scale})-> the scale tensor's role isweight_scale, notembed, and its destination is its own name (row.slot = t.name,:3510); - the negative: a name ending
.scalemust not appear inslot_ofunder any weight role -- which is the assertion that fails if the branch moves into the loop.
What I verified separately: "model.embed_tokens.weight.scale" is a literal the forward reads (npu/forward/src/q4nx_forward.cpp:1072-1074, including the error "model.embed_tokens.weight.scale is too sm[all]"), so the
destination the rule produces is one the engine will look up -- the rule is not inventing a name.
And per_layer_proj_norm getting its own top-level slot is the same class, one level simpler: it is not the model's final norm, and the final_norm rule would have taken it by substring. Also unarmed at this head.
Built and ran: all npu-pack checks passed -- which is exactly the problem: they pass without the rule.
|
The direction change is the right one, and my two asks are now narrower. At Two arms still, both about reading the numbers in the body:
Neither is a defect I can show here (the five containers are not on this box; I re-ran the synthetic legs on the earlier head and they passed) — they are the two places where the printed numbers could be true for a reason other than the one the body gives. |
F1 stack, verified as a stack: all three merge cleanly and the combination builds and passesWhoever drives the merge loop, this saves you the ordering question. I merged the three open F1 PRs together on top of current So: any order is safe, and the combination is the state the acceptance case needs — 733 of 733 tensors with a unique, layer-qualified destination. #2099 is 50 commits behind And one caveat worth putting on the record, because it nearly produced a false report from me. My first run of this said the stack regressed to |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 20fe08c: READY
Reviewed at 20fe08c172fddce1cf49d5df41fcbbec89d3f9e4. It is 2 behind main and merges clean. There are three commits since my READY at a30efd9:
42d0e4d4: destinations are layer-qualified with the container's own prefix;71377eb5: a.scalecompanion goes to its own name, andper_layer_proj_normis no longer filed as the final norm;20fe08c1:.bf16.-tagged copies are refused, and every destination is unique.
The real-container leg on all five containers on this box
| container | put | held | unnamed | FAIL | unknown |
|---|---|---|---|---|---|
| MiniCPM-V-4.7-35B-A3B-NPU2 | 733 | 0 | 0 | 0 | none |
| MiniCPM-V-4.7-1B-NPU2 | 321 | 36 | 0 | 0 | none |
| gemma4-e2b-npu2 | 461 | 248 | 0 | 0 | none |
| bolt-e4b | 552 | 297 | 0 | 0 | none |
| gemma3-270m-npu2 | 237 | 0 | 0 | 0 | none |
The new uniqueness check, per_slot.size() == placed, passes on each. gemma4 and bolt each gain one placed tensor, consistent with the .scale / per_layer_proj_norm fixes. The no-pack run reports all npu-pack checks passed.
My mutant: I removed the .bf16. → dtype_variant classification. The 1B leg then fails, rc 1 ("roles: a transformer pack classifies completely"). So the refusal is pinned on the pack that showed the collision, though by the classifier's completeness check rather than by the uniqueness check. Restored, it passes.
The a30efd9 checks carry over (CI's unknown-bucket arm, slot literals, rank and dtype). So does the side note on mlp.share_router.weight versus the raw shared_expert_gate.weight.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 276".
…(review of #2291) The review's mutant was exact: deleting the .scale branch left every arm green, even though its own comment says the cost is a pack that writes scales over the embedding with no error. Four arms now, and the third is the one that also fails if the branch is refactored INTO the needle loop -- which is the change that would restore the defect silently: 1. container_roles: the .scale companion is weight_scale, not embed 2. its destination is its own name (model.embed_tokens.weight.scale), which q4nx_forward reads 3. a per-layer companion takes its own name, so it cannot land in the down_proj slot 4. per_layer_proj_norm is not the final norm and has its own top-level slot The mutant (delete the branch) now fails four arms: FAIL scale: the .scale companion is NOT filed as embed FAIL scale: its destination is its OWN name, which q4nx_forward reads FAIL scale: and it does not collide with the embedding FAIL scale: a per-layer companion is weight_scale too
Armed — and your mutant now fails four armsYou were exactly right: the branch was real, the ordering was real, and deleting it left the suite green. At Your mutant, re-run: On the ordering specifically — your point that the mechanism is the ordering is the one I had not encoded. Arm 3 is written so that moving the branch inside And the honest part:
|
|
Persistent review updated to latest commit 21786bf |
|
VERDICT: READY at What is now thereThat is the right form — a reproducible command naming a real container directory, with the container's own JSON being read rather than a fixture being described. It is strictly better than a table of file names, and it is the answer to "is this measured on real artefacts?" for that pack. The arithmetic1 named container, 1 command — against a headline that says "measured on all five". So four are missing, and the Two ways to close it, both cheap:
Not blocking, and I will not ask againThe code, the role table ( |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 21786bf with one small nit — and a limitation I want stated rather than implied: I could not run the leg. Disk is 162 G, below the 175 G build floor, so npu_pack_import_test was not rebuilt; everything below is checked against the body, the diff and the containers on disk.
The table's arithmetic is consistent on all five, which is the check the body allows me
put + held + unnamed == tensors, per container:
gemma3-270m 165+72+0 = 237 vs 237 OK
gemma4-e2b 320+389+0 = 709 vs 709 OK
bolt-e4b 383+466+0 = 849 vs 849 OK
MiniCPM 1B 183+174+0 = 357 vs 357 OK
MiniCPM 35B 303+430+0 = 733 vs 733 OK
Every tensor is put or held, none unnamed, and nothing is double-counted — which is what "unknown none" means when the totals close. The 35B's 733 also matches the container-role census from #2097/#2187, so the two measurements agree across PRs rather than only within one.
What the real-data run actually found is the point of the PR
35B: held: 210 x a role this table does not know: it needs a decision <- the table saying nothing
held: 120 x same <- a LITERAL "same" in the output
The second line is a bug of exactly the kind only real data produces: the table carried the string "same" as a placeholder reason, and it reached the user-facing output as a reason. Three "same" entries are gone in this diff. And ssm/gemma_local being refused with their reason rather than mapped is the right answer — a role spanning four destinations with four shapes cannot be one slot, and pre/post_feedforward_layernorm having engine slots while their siblings do not is a group that must be split before it is mapped.
The nit: the placeholder arm rejects any reason that starts with the word "same"
check(kv.second.rfind("same", 0) != 0 && kv.second != "same",
"slots: and the reason for " + kv.first + " is a reason, not a placeholder");rfind("same", 0) != 0 means "does not begin with same" — so a legitimate reason like "same geometry as the dense path" would fail an arm whose message says it is a placeholder. The second clause (!= "same") is the intent and already covers the case; the prefix test adds a false-positive class rather than catching one. Today nothing starts with it (the two occurrences in the file are mid-sentence: " of the same model is what establishes it.", "… are not the same bytes; "), so this is a latent trap, not a live failure.
Suggested: keep kv.second != "same", drop the prefix clause — or if the intent is "not a placeholder of that family", test membership in an explicit set of placeholders rather than a prefix.
Scope, as the body states it
"still the decision, not the write. No device access; no container imported or modified." That is the correct boundary for this slice, and the reproduction command it gives (argv[3] = container dir, argv[4] = the comma-separated layer_types) is the reason a reviewer with disk headroom can check the table without reading the classifier.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 21786bf
CHANGES answered, and the arms are the two I asked for plus the ordering negative -- proved by mutation, both ways.
Deleting the .scale branch now fails five arms:
$ (removed the `.scale` → weight_scale branch)
failing arms: 5
FAIL scale: the .scale companion is NOT filed as embed
FAIL scale: its destination is its OWN name, which q4nx_forward reads
FAIL scale: and it does not collide with the embedding
And the third arm is the one that closes the refactor hazard I named -- "nothing ending .scale may be placed under a WEIGHT role's slot", described in the test as covering "if it is moved inside the needle loop, which is the
refactor that would restore the defect silently." That is the difference between arming the branch and arming the ordering, and the ordering was the half a future edit would break.
per_layer_proj_norm is armed too, and the two arms read as a pair with the embedding:
check(r.role_of.at("model.embed_tokens.weight") == "embed", "the embedding is still the embedding")
check(r.role_of.at("model.embed_tokens.weight.scale") == "weight_scale", …)
The positive control is what makes the second one mean something: a rule that filed everything as weight_scale would pass the second arm alone, and the first arm is what fails.
The comment records the reason the arms exist rather than the code: "deleting the branch left every arm green, even though its own comment says the cost is a pack that writes scales over the embedding with no error." That is a
sentence the next person editing this file will read at the point where it matters.
Built and ran at this head: all npu-pack checks passed, plus the mutation above.
And the PR's own record is worth stating: eight heads, and the last three were each a hole the previous one left -- the "same" placeholder, the unchecked bucket, the FLM spelling, the contradiction invariant, and now the unarmed rule. Each
was found by exercising the code rather than reading it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 21786bf: READY
This is the PR that proves the refusal design was worth having, because on real data it caught 210 tensors rather than mis-wiring them.
35B: put 303, held 430, unnamed 0
held: 210 x a role this table does not know: it needs a decision
held: 120 x same
That first line is the catch-all from #2285 doing exactly its job: on a container nobody had tried, the table said "I do not know this" 210 times instead of defaulting to a slot. A placement table that guessed would have wired 210 tensors into plausible-looking destinations and reported success. The value of refusing is not aesthetic — it is measured here.
The two group roles are solved the way #2270 solved the same problem one level up: the destination has to come from a key that is fine enough, and the missing piece is the tensor's own suffix. ssm is one role over dt.bias, conv1d, norm and a — four destinations and four shapes — and gemma_local is a group of seven stems of which two (pre/post_feedforward_layernorm) have engine slots and the rest do not. Splitting them into {role, stem suffix, slot} rows is the same structural answer as the (layer-elided name, layer type) key: a role is not an identity.
And the vocabulary was verified rather than assumed: the comment records that for ssm "the engine has a slot for EVERY member — measured by grepping npu/forward/src for the ssm family, whose literal names match the classifier's own needles one for one". That is the check I would have asked for, done before I asked.
The bug the evidence exposed is the one I like most: held: 120 x same — a literal "same" placeholder reached the output as a reason. A placeholder that leaks into a diagnostic is worse than no diagnostic, because it reads as an answer. It is replaced by a real reason in all four cases, and it only ever appeared because a real container produced 120 rows to describe.
The strongest test here is an invariant rather than a value: per_slot.size() == r.placed asserts that every placed tensor has its own destination on a real container. That catches the failure mode a per-row spot check cannot — two tensors quietly sharing one slot — and it is asserted on the five shipped containers rather than on a fabricated one.
One small ask, not a blocker: an assertion that no reason is the literal "same" (or any placeholder), so the bug above cannot come back through a future group role. You have the evidence to write it in one line — it is the only fix in this PR that has no test pointing at it.
Everything else holds from the earlier slices: the three-way classification (placed / refused-with-reason / unknown-with-reason), "a number here is honest; a guessed slot is not", and refusal degrading to unplaced rather than to wrong.
…no pack (self-review of #2291) The reviews of this PR kept finding the same class -- a rule whose deletion is invisible -- and applying that to my own leg found it again: the real-container leg arms the destination table only when a pack is given, and ctest runs this binary with NO pack. So a wrong destination for any single role was invisible in CI. Changing one character of o_proj's slot now fails: FAIL tables: o_proj -> self_attn.o_proj.weight (got: self_attn.o_proj.weight ) All 25 layer-type-independent destinations are pinned exactly, with the value printed on failure.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 6db001b — and the commit added on top is the right one for a reason that has bitten this stack repeatedly: the real-container leg cannot run in CI, so it was not a CI check.
What the new commit closes
// REVIEW of #2291, applied to myself: the real-container leg arms the table only where a pack is given, and CI runs
// with NO pack -- so before this, a wrong destination for any single role (o_proj, up_exps, ssm_conv1d …) was
// invisible in CI. Every layer-type-independent destination is pinned here, exactly.That is the honest version of the gap: a leg gated on a 20 GB pack is a lane measurement, not a gate, and the five-container table in the body — which I verified closes on every row (237=165+72, 709=320+389, 849=383+466, 357=183+174, 733=303+430) — was therefore evidence that no merge could regress silently only if something else pinned it. Now something does.
And it is pinned as a table, not as spot checks: 25 explicit role -> slot pairs asserted exactly for every layer-type-independent destination, including the ones the layer type decides (per_layer_proj_norm, moe_router) and the four shared-expert slots that were the "same" placeholders. A wrong destination for any of them now fails in CI with the pair printed (… (got: …)).
Two things I checked because they are where this kind of arm goes wrong: the asserted values match what I independently verified against the engine (moe_names(MoeFormat::EngineConverter) for the expert slots — mlp.up_exps_proj.weight, mlp.share_router.weight, etc.), and the comparison is count(...) && at(...) == kv.second, i.e. membership plus equality, so a role that disappeared entirely fails rather than vacuously passing.
My nit from 21786bf stands, unchanged and still non-blocking
check(kv.second.rfind("same", 0) != 0 && kv.second != "same", "… is a reason, not a placeholder");rfind("same", 0) != 0 rejects any reason that begins with the word "same" — "same geometry as the dense path" would fail an arm whose message calls it a placeholder. The != "same" clause is the intent and already covers the case. Nothing trips it today, so it stays a latent trap; dropping the prefix clause or testing membership in an explicit placeholder set closes it.
The rest of my review at 21786bf is unchanged
The "same" placeholder that reached the output as held: 120 x same is gone; ssm and gemma_local are refused with their reason rather than mapped, which is correct for roles spanning four destinations and shapes. I still could not run the leg myself — disk 162 G, below the 175 G build floor — so my check of the five-container numbers remains arithmetic against the body, which is exactly why the new arm matters: it moves the assurance from my arithmetic to CI.
Correction to my own stack claim on this PR: it does not include #2187, and #2187 conflicts with #2099I posted earlier that the F1 stack was verified and that any merge order is safe. Two things in that were wrong, and both are my errors of measurement rather than changes in the code: 1. My stack run did not include #2187 at all. I merged So the run I reported merged #2099 twice and omitted #2187. The numbers ( 2. Run for real, #2187 conflicts with #2099 — three files, not a trivial one: The cause is that #2187 re-declares 3. And the size I have been quoting is wrong too. I said #2187 was "31 lines, the reporting residue". Measured against a freshly fetched The 31-line figure came from a stale What is actually true, and what I will do next: #2099 (the gate) and #2291 merge cleanly together; #2187 does not merge on top of #2099 and needs a real rebase, after which its own review survives or does not on its merits. I would rather have this correction on the record than a merge queue acting on my earlier sentence. |
The merge order, measured pairwise — and a second correction to my own attributionAfter correcting the stack claim I re-ran the merges individually rather than in one pile, which is what I should have done first. As of #2099 So the collision is between #2187 and #2291, not with the gate — and my previous comment said #2187 conflicted with #2099, which was wrong in the other direction. Both are the two reporting halves of F1 slice 3b/3c and they edit the same regions of the same three files; the gate is innocent in both cases. Recommended order, now that it is measured:
What I got wrong twice today, since it is the useful part: first I claimed the stack was verified when my run had silently omitted #2187 (wrong branch ref), then I claimed #2187 conflicted with #2099 when the conflict was with #2291 (conflict found in a tree that already had #2291 applied). Both were attribution errors from a compound merge, and the fix in both cases is the same: merge pairwise, name each ref, and read which two commits the conflict is between before writing a sentence about it. |
…rs in, in info (engine#1788, 3c residue) The residue of the closed #2187, rebuilt fresh on main rather than resolved out of ten conflict blocks. Two measurements the writer needs before it can decide whether a tensor is one the engine can consume: payload_rows : counts the 3-D I8 tensors by their payload width (the K the fconst tile is built for) role_shapes : which shapes each role appears in Both are measurements over the container, not mappings, so they cannot be wrong about a destination -- which is why they survive while #2187's placement table did not (main has that table from #2285/#2291). Measured on the 35B: payload_rows {4736: 370, 8704: 1} the one 8704 is lm_head, as its shape says role_shapes 22 roles; down_exps -> [16384,2,4736] I8 x 40 Carried on the Plan and emitted from manifest(), because manifest() is where the container report is rendered and it has no container in scope -- that is the whole reason the first attempt at this port produced two functions with no reader and was reverted. The trip through the plan is the fix, not a style choice. # Conflicts: # npu/pack_import.cpp (cherry picked from commit d7a423080781fd78c5c7ab88c4d8ddbef0036d4f)
…rs in, in info (engine#1788, 3c residue) The residue of the closed #2187, rebuilt fresh on main rather than resolved out of ten conflict blocks. Two measurements the writer needs before it can decide whether a tensor is one the engine can consume: payload_rows : counts the 3-D I8 tensors by their payload width (the K the fconst tile is built for) role_shapes : which shapes each role appears in Both are measurements over the container, not mappings, so they cannot be wrong about a destination -- which is why they survive while #2187's placement table did not (main has that table from #2285/#2291). Measured on the 35B: payload_rows {4736: 370, 8704: 1} the one 8704 is lm_head, as its shape says role_shapes 22 roles; down_exps -> [16384,2,4736] I8 x 40 Carried on the Plan and emitted from manifest(), because manifest() is where the container report is rendered and it has no container in scope -- that is the whole reason the first attempt at this port produced two functions with no reader and was reverted. The trip through the plan is the fix, not a style choice. # Conflicts: # npu/pack_import.cpp (cherry picked from commit d7a423080781fd78c5c7ab88c4d8ddbef0036d4f) (cherry picked from commit 3b866fe)
… in, in info (engine#1788, 3c residue) (#2304) * npu-pack: a container's payload widths and the shapes each role appears in, in info (engine#1788, 3c residue) The residue of the closed #2187, rebuilt fresh on main rather than resolved out of ten conflict blocks. Two measurements the writer needs before it can decide whether a tensor is one the engine can consume: payload_rows : counts the 3-D I8 tensors by their payload width (the K the fconst tile is built for) role_shapes : which shapes each role appears in Both are measurements over the container, not mappings, so they cannot be wrong about a destination -- which is why they survive while #2187's placement table did not (main has that table from #2285/#2291). Measured on the 35B: payload_rows {4736: 370, 8704: 1} the one 8704 is lm_head, as its shape says role_shapes 22 roles; down_exps -> [16384,2,4736] I8 x 40 Carried on the Plan and emitted from manifest(), because manifest() is where the container report is rendered and it has no container in scope -- that is the whole reason the first attempt at this port produced two functions with no reader and was reverted. The trip through the plan is the fix, not a style choice. # Conflicts: # npu/pack_import.cpp (cherry picked from commit d7a423080781fd78c5c7ab88c4d8ddbef0036d4f) (cherry picked from commit 3b866fe) * npu-pack: payload_rows counted TENSORS; emit both, and let the numbers differ (review of #2304) `out[K]++` counted one per tensor under a field called `payload_rows`, so on the 35B the old field said 370 at K=4736 where the truth is 1003840 rows -- off by 2713x at exactly the width the fconst tile is built for. Both are emitted now, each under what it counts, and the 8704 row count (7755) independently matches the lm_head's own tensor shape [7755,8,8704]. (cherry picked from commit 5d619cd) * tests: arm the two measurements, and that their numbers differ (review of #2304) The arm the review asked for, re-applied by hand after the rebase: at K=4736 the fixture has TWO tensors and 2x4096 = 8192 rows, and the assertion that fails if anyone re-merges the two fields is payload_rows != payload_tensors at that width. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
The placement, measured on all five containers — and the two roles it could not name
#2290 (merged) landed
container_placement, but it had no real-container evidence at all: the pack leg gates onmodel.onnxand a container shipsmodel.q4nx, so the five containers on this box could never reach it. This adds that leg — read the container with the shipped reader, run the placement, reportput/held/unnamed, the reasons, and the roles the table could not name — and then closes what it found.What it found, on the first run:
Both were mine, and both are the kind of thing only real data produces:
ssmdt.bias,conv1d,normanda— four destinations, four shapesgemma_localpre/post_feedforward_layernormdo have engine slots,inp_gate/layer_output_scale/per_layer_projection/post_layernorm/per_layer_model_projdo notcontainer_extrarope_freqsis a table the forward only flags, per-layer embeddings have no slotAnd the
"same"placeholder — which reached the output asheld: 120 x same— is replaced by a real reason in all four shared-expert entries. A placeholder is not a reason.After, every container on this box has an empty unknown bucket:
heldis not a failure count — it is the list of decisions the writer still owes, and every one now has a stated reason instead of a shrug. The largest are the two group roles (ssm,gemma_local), which is exactly right: they need the tensor's suffix as a fourth key, and inventing a slot for a group is how a pack silently loads aconv1dinto adt.biasfield.How to reproduce (the leg is
argv[3]= container dir,argv[4]= comma-separatedlayer_typesfromconfig.json):Scope: still the decision, not the write. No device access; no container imported or modified.