Repository navigation
npu-pack: the classifier fixes and the container import refusal, on main (engine#1788) - #2277
Conversation
…ain (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.
PR Reviewer Guide 🔍(Review updated until commit a05c468)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/pack_import.cpp:3523-3530MoE Role Mapping The MoE family mappings were added to correctly classify tensors from models like MiniCPM-V. However, the order of these mappings is critical, as npu/pack_import.cpp:3579-3581Incorrect Final Norm Classification The fix for classifying |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 5c02cad: CHANGES
Reviewed at 5c02cad2a84842d9d1f85f4e55f9f3c1440ba59a. It is 1 behind main and merges clean. The diff is npu/pack_import.cpp +62/−2 and the test +66.
One small blocker: the attention-gate fix has no arm.
The body lists "the attention gate versus the FFN gate" among the arms, but no line of the test diff mentions self_attn.gate_proj. As a mutant, I removed the {"self_attn_gate_proj", "self_attn.gate_proj"} entry, which is main's behaviour: MiniCPM-V's 30 attention gates are filed as the dense FFN gate_proj again. npu_pack_import_test still reports all npu-pack checks passed, rc 0.
Ask: one arm in the 35B fixture with model.layer.0.self_attn.gate_proj.weight beside an mlp.gate_proj.weight. It should check that the first is self_attn_gate_proj, the second gate_proj, and counts.at("gate_proj") == 1.
What holds
-
The other two fixes are pinned. My mutants on them fail:
mutant failing arms the final-norm guard without layer.2: "exactly one final_norm, even with model.layer.N.blocks", "a per-layer ssm_norm is ssm"the share_gate_expsentry removed2: "share_gate_exps_proj is the shared expert, not the routed gate", "the shared spelling does not also count as the routed one" -
The container refusal works on a real pack.
1bit npu-pack import ~/scratch-packs/MiniCPM-V-4.7-35B-A3B-NPU2 -o …prints "this pack is a safetensors container (model.q4nx): 733 tensor(s); importing it needs the container-genre writer …", exits rc 1, and writes no output directory. -
The needle order is right.
self_attn.gate_projcomes beforegate_proj, andshare_*before its substring.layer.does not matchlayernorm. -
Tests.
npu_pack_import_testpasses andtools/copyright.py --checkreturns rc 0.
The body's "36 → 0 / 319 → 0 / 382 → 0 unassigned" figures cannot be checked from this head: main's npu-pack info does not report role counts (that is #2099/#2187's reporting half). The unit arms are the evidence here.
CI build at this head: in_progress -.
|
VERDICT: READY at The three things I would have flagged if they were wrong, and are not:
Verified: |
|
VERDICT: READY Splitting the behaviour half onto The substring trap is tested directly, not just avoided. The headline bug is pinned with its reason.
One thing I would add if this grows: the two new roles are matched by substring over lists ( Not run by me: the arms need the two packs ( |
|
VERDICT: READY at Splitting by dependency, not by size
That is the correct cut: the classifier and the refusals are behaviour that stands on It also means the refusal messages (the half that turns a bad pack into a one-line diagnosis) reach The two shared files are the merge-order factBoth this PR and #2187 touch them, so #2187 must merge first (it is the superset, or nearly) or this one rebases onto it. Worth one line in the body: "if #2187 lands first, this reduces to One askSay which of the classifier fixes are shared with #2187 and which are new here. If the same hunks appear in both branches, the pair is a stack and should be declared as one (as #2099/#2187 do); if this PR carries fixes #2187 does not, that is the reason to land it independently and belongs in the first paragraph. Right now the body says the two halves "touch none of that" — true of the reporting fields, but the two shared code files say the boundary is not file-clean, and a reviewer will want to know which side owns them. |
…gine#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.
The mutant was right, and it was my error: the arm is in, and removing the rule now fails itYour mutant removed Added at const auto r = roles({"model.layer.0.self_attn.gate_proj.weight", "model.layer.0.mlp.gate_proj.weight"});
check(r.role_of.at("model.layer.0.self_attn.gate_proj.weight") == "self_attn_gate_proj",
"35b: an attention output gate is not the FFN gate");
check(r.role_of.at("model.layer.0.mlp.gate_proj.weight") == "gate_proj", "35b: the FFN gate is still gate_proj");
check(r.counts.at("gate_proj") == 1, "35b: the attention gate does not also count as gate_proj");Your mutant now bites, run here with the flag restored afterwards: And you were right about the unassigned figures too — |
|
Persistent review updated to latest commit a05c468 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- and the two things I would keep are the collision guard and the author correcting his own claim
The classifier fixes are armed at the level that matters: the collisions. A rename that collides is the failure mode here, and both collisions are pinned rather than assumed:
self_attn.gate_projbecomesself_attn_gate_projandmodel.layer.0.mlp.gate_projstaysgate_proj, withcounts.at("gate_proj") == 1-- "the attention gate does not also count as gate_proj". That third assertion is the one a rename usually forgets: the wrong mapping still produces a plausible role table, it just files two different tensors under one name.- the eight MoE families are matched
share_*first, becauseshare_gate_exps_projcontainsgate_exps_proj-- an ordering-sensitive substring match that is called out in the comment and pinned byshare_gate_exps_proj -> share_gate_expsnext togate_exps_proj -> gate_exps. Substring matching in the wrong order is exactly howshare_tensors get silently mis-filed, and here it cannot.
unassigned keeping its meaning is the design decision I would defend hardest. For tensors the forward has no field for (pre_/post_feedforward_layernorm, inp_gate, per_layer_projection, layer_output_scale, post_layernorm, per_layer_model_proj, plus rope_freqs and per_layer_token_embd), the PR adds two distinct roles rather than mapping them onto something plausible -- so the counter still means "nothing knows this", which is the only thing that makes it worth reporting. Inventing a role to drive the number to zero would have been the easy move and would have destroyed the signal.
The exit-visible change is the right kind of fix: a container pack now refuses by name --
this pack is a safetensors container (model.q4nx): 733 tensor(s); importing it needs the container-genre
writer (engine#1786, item F1), which the importer does not have yet
-- instead of falling through to the ONNX importer and reporting a missing model.onnx on a pack that has no graph by design. That is a misleading-error fix, which is the same class as this issue series has been grinding through all night.
And the honesty is the best part of the description: "These figures were measured on #2187's branch … they cannot be reproduced from this head, whose evidence is the unit arms. Corrected after review." An author annotating a number as un-reproducible from the PR under review is rarer than it should be, and it is what makes the rest of the description trustworthy.
Two notes, neither blocking.
- Since the pack-level table cannot be reproduced here, the pattern the tree already uses would make it reproducible later: an arm that runs only when a pack directory is named in the environment (as
ONEBIT_TEST_Q4NXdoes for the forward tests), assertingunassigned == 0on gemma3-270m. Skip without it, so a developer with the pack gets the claim and CI keeps its unit arms. - My question from #2270 is still open, and this is the same component: what does
npu-pack importdo when a(role, layer type)pair spans two shapes (spans > 0)? This PR adds a by-name refusal for a different case (container packs), so the span case may now have a place to live -- if import still proceeds with that field unresolved, that is the silent partial mapping the third-key slice inherits.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a05c468 — the three classifier fixes are each a named victim of the same root cause, and the file documents every ordering with the tensor it broke. That documentation is the strength and also the thing I would change.
What is right, and precisely right
final_norm's guard now spells "never inside a layer" positively:n.find("layers.") == npos && n.find("layer.") == npos, with the comment naming the pack ("MiniCPM-V 4.7 spells its blocksmodel.layer.N.(singular), so a guard againstlayers.alone filed 30 per-layerlinear_attn.ssm_normtensors asfinal_norm"). A guard that hard-codes one spelling of a container's convention is the same class as theattn_q-prefix bug, and spelling it as both forms is the correct repair.self_attn_gate_projbeforegate_proj, with the semantic reason: an attention output gate wired into the dense-FFN path would be a real mis-wire, and the forward keeps them apart (gate_proj_weightvsself_attn_gate_proj_weight) — so the fix moves toward the forward's existing vocabulary rather than inventing a name.share_*before*_exps_proj—share_gate_exps_projcontainsgate_exps_proj, and the arm asserts the negative (counts.at("gate_proj") == 1, "the attention gate does not also count as gate_proj").- The refusal: a safetensors container pack now says "this pack is a safetensors container (model.q4nx): 733 tensor(s); importing it needs the container-genre writer … which the importer does not have yet" instead of falling through to the ONNX importer and reporting a missing
model.onnxon a pack with no graph by design. A refusal that names what is missing is worth more than a passing fall-through.
My one substantive ask: make the ordering invariant, not the documentation
kRules is a first-match-wins substring table (for (const auto& r : kRules) { if (n.find(r.needle) == npos) continue; role = r.role; break; }). Every one of these fixes has the same shape — a needle that is a substring of a later, more specific needle, placed before it:
attn_q ⊂ attn_q_norm (#2097) in_proj ⊂ shortconv.in_proj (#2097)
gate_proj ⊂ self_attn.gate_proj (this PR) gate_exps_proj ⊂ share_gate_exps_proj (this PR)
Four incidents, one mechanism, and each is now guarded only by the position of a line in a table annotated with a comment. The next contributor adding down_proj-family or another *_gate needle has to notice the hazard and place their line correctly; nothing tells them if they don't.
Two ways to make the class impossible, either small:
- match the longest needle instead of the first — collect every needle that occurs, take the longest (ties broken by table order). Then table order stops being load-bearing and the comments become history rather than instructions; or
- keep first-match and assert the invariant in an arm: for every pair of needles where one is a substring of the other, the longer must appear earlier in
kRules. That fails at test time on the next misplaced line, with both needles named.
I would take the longest-match rule, because it removes a class rather than policing it — and because the arm is the thing people delete when it gets in the way.
One honesty note I want to underline rather than leave buried
"These figures were measured on #2187's branch, where
npu-pack inforeports role counts — they cannot be reproduced from this branch."
gemma3-270m 36 → 0, gemma4-e2b 319 → 0, bolt-e4b 382 → 0 are measured elsewhere. That is stated plainly, which is the right thing to do, and it is worth being explicit about what it means: on this branch the numbers are an inference from code that is claimed identical, not a measurement. The unit arms carry this PR on their own, and they do — gemma3-270m's post_layernorm/per_layer_model_proj reasoning ("gemma4-e2b carries BOTH post_attention_layernorm (35, classified post_norm) and post_layernorm (35), so the second cannot be the forward's post-attention norm") is the kind of evidence that does not need the counts to be believed.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at a05c468: READY
The defect this fixes is the nastiest kind in a name-needle table: it is not a lookup that fails, it is a lookup that succeeds with the wrong answer. MiniCPM-V 4.7 carries attn_output_gate: true, so it has self_attn.gate_proj.weight tensors — and every one of the 30 was filed under gate_proj, the dense-FFN role, so a consumer would have wired an attention output gate into the FFN path. Shape-wise plausible, behaviourally wrong, and invisible without a test that gives the classifier both names at once.
The fix is a specificity ordering, and the ordering is the whole fix:
// ... this uses its name and must precede the `gate_proj` needle.
{"self_attn_gate_proj", "self_attn.gate_proj"},In a first-match-wins needle table, correctness lives in the order, not in the entries. That is exactly the class I have filed twice against this file (#1840's missing arm, #1841's missing dot), and the comment stating why the line must precede the general one is the part that keeps it fixed — the next person adding a gate needle will read it.
The test arm is a real negative control, which is what I would have asked for: it feeds model.layer.0.self_attn.gate_proj.weight and model.layer.0.mlp.gate_proj.weight into container_roles() together and asserts they land in different roles. With gate_proj ahead of the specific needle, both collapse onto one and the arm fails. A test that only checked the attention name would pass either way.
The comment also records that the forward already kept these apart (model.h: gate_proj_weight vs self_attn_gate_proj_weight), so the container writer was the one inconsistent with the runtime — good to see the fix align the writer to the already-correct reader rather than inventing a third convention.
The expert-name entries (share_gate_exps, gate_exps, up_exps, down_exps, and down_proj also matching ffn_down) follow the same one-table shape, which keeps the rule readable in one place. No concerns.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a05c468
Built and ran it: all npu-pack checks passed -- and the classifier arms are the pair that a substring rule hides behind, again.
The trap this time is self_attn.gate_proj vs mlp.gate_proj, and the counts catch it:
check(r.role_of.at("model.layer.0.self_attn.gate_proj.weight") == "self_attn_gate_proj", …)
check(r.role_of.at("model.layer.0.mlp.gate_proj.weight") == "gate_proj", "35b: the FFN gate is still gate_proj")
check(r.counts.at("gate_proj") == 1, "35b: the attention gate does not also count as gate_proj")
self_attn.gate_proj contains gate_proj, so a rule that matches the tail classifies the attention gate as the FFN gate -- and the counts.at("gate_proj") == 1 arm is what turns that from a silent mislabel into a failure. Same
shape as share_gate_exps_proj in #2187 and *.norm.weight vs model.norm.weight: three instances of "a shorter name is a suffix of a longer one" in one PR family, each caught by a count rather than by the string.
The refusal is the half I checked hardest, because it is new behaviour rather than a report:
throw std::runtime_error(
"this pack is a safetensors container (model.q4nx): " + std::to_string(p.container_tensors) +
" tensor(s); importing it needs the container-genre writer (engine#1786, item F1), which the importer does not have yet. "
"`1bit npu-pack info` reports what the container holds");That is the right refusal for a capability that does not exist yet: it names the file, the tensor count, the missing mechanism by issue and item, and the command that still works (npu-pack info). A refusal that leaves the reader with
"not supported" sends them to the source; this one sends them to the tool that answers their actual question.
And one gap worth naming: I could not find an arm asserting that message. The test file's new lines are all container_roles, and external_container_tensors has its four arms (including !…bad && !e3.empty() for a bad geometry) --
but the safetensors refusal's wording is new text with no assertion on it. Given this fleet's own record (a CHANGES merged over because a message was untested, three times tonight), one REQUIRE_THROWS_WITH-style arm on
"container-genre writer" and the tensor count would pin the sentence that a lane will depend on when F1 lands.
The split is stated well: "the behaviour half of #2187, on main" -- #2187 keeps the reporting half because it needs #2099's plan fields, so the two halves land on different bases rather than one PR waiting for the other.
Not run: the real Hy-Memory/35B containers (the arms use fixture tensors).
…g (review of #2285) find("linear") was a substring test where an identity was meant -- the defect family this stack exists to fix (#1840, #1841, #2277's needle order). Now kLinearAttentionLayerTypes is an exact set, so a declared value that merely CONTAINS 'linear' does not map a fused qkv into linear_attn.qkv_proj.weight, and a new family (gdn, mamba-style) has to be added deliberately, with a test failure if the vocabulary moves without it. Two arms pin the difference: 'nonlinear_attention' and 'linear_sliding_attention' must both stay unplaced. With the substring restored (the mutant) both fail; a missed match still degrades to 'unplaced', never to a wrong slot.
…hange (engine#1788, 3c placement) (#2285) * npu-pack: role -> engine slot for the roles the layer type does not change (engine#1788, 3c placement) An import has two sides. The classifier says WHAT a tensor is; the engine reads weights by SLOT name, so the writer needs role -> slot. This lands the seventeen roles whose destination does not depend on the layer type, each one a string literal that exists in npu/forward/src (grepped, not inferred from the HF name), and names the nine it does NOT place with the reason: qkv_proj's prefix is the layer type's, q_norm/k_norm have no verified weight slot, gate_up_proj needs a measured split, and the shared-expert family differs per architecture. The arm with teeth is the coverage one: every role the classifier can emit must be either placed or explained, so a new recogniser without a destination decision fails the test instead of landing a tensor in a slot the engine reads with another shape. Removing qkv_proj's explanation fails it: 'no role falls through the table (qkv_proj)'. * npu-pack: the layer type decides exactly one destination, and it decides it the way the engine does (3c, engine#1788) qkv_proj was the role this slice refused first, because its prefix belongs to the layer type. Now it is placed where the ENGINE has a slot for it: grep of npu/forward/src finds 'linear_attn.qkv_proj.weight' and no 'self_attn.qkv_proj' at all, so a fused qkv on a linear layer has a destination and on a full or sliding attention layer it has none. The tempting mapping -- always emit self_attn.qkv_proj.weight -- is the mutant this pins: it fails two arms. container_role_slot(role, layer_type) returns when there is no destination, and the caller cannot read that as 'unchanged', which is why the report carries the reasons and this returns a string. * npu-pack: linear attention is an exact layer-type set, not a substring (review of #2285) find("linear") was a substring test where an identity was meant -- the defect family this stack exists to fix (#1840, #1841, #2277's needle order). Now kLinearAttentionLayerTypes is an exact set, so a declared value that merely CONTAINS 'linear' does not map a fused qkv into linear_attn.qkv_proj.weight, and a new family (gdn, mamba-style) has to be added deliberately, with a test failure if the vocabulary moves without it. Two arms pin the difference: 'nonlinear_attention' and 'linear_sliding_attention' must both stay unplaced. With the substring restored (the mutant) both fail; a missed match still degrades to 'unplaced', never to a wrong slot. --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
… bytes verified against the source (engine#1788, 3c) (#2326) * npu-pack: the I/O half -- write the layout, and refuse to write a partial pack (engine#1788, 3c) Copies each planned byte range out of the container's payload into one file per destination slot. The refusal is the point: when the plan has tensors with no destination it writes NOTHING unless the caller passes allow_unplaced, because a pack that silently omits a fifth of the model is worse than no pack and only the caller can decide an omission is intended. Armed by writing to a scratch directory and reading the files back, which is the only way to know the ranges are the right bytes: embedding 0..8 -> "ABCDEFGH" q_proj 8..24 -> "IJKLMNOPQRSTUVWX" and the refusal arm asserts three things at once -- not ok, a reason that names the problem, and no file on disk. Mutant (drop the unplaced guard): FAIL write: an incomplete plan is refused FAIL write: and says why FAIL write: and writes NOTHING, not a partial pack (cherry picked from commit 75bb5622cbbf61c27f75411796f3dd9103b355da) * npu-pack: the layout writes a manifest, so it is self-describing (engine#1788, 3c) A directory of files whose names are engine slots is not enough: a reader also needs to know which container tensor each came from and what shape and dtype it is. So the write emits manifest.json alongside the files, carrying per slot the source tensor, byte count, shape and dtype, plus the plan totals (files, bytes, payload_bytes) and the unplaced list. That last field matters: a layout written with allow_unplaced says so IN THE OUTPUT, so a later reader cannot mistake a deliberately partial layout for a complete one. (cherry picked from commit 972b841691a020eb54ea1fc385c48f9f3426e6e0) * npu-pack: record why the layout is a byte copy and must stay one (engine#1788, 3c) npu/q4nx.h documents that the Q8 chunk comes in TWO code layouts -- byte k*32+r, or (r/16)*4096 + g*512 + (k%32)*16 + r%16 -- and that NOTHING in the chunk or the container says which. The forward's decoder picks per tensor from the codes (q4nx_q8_split16: in the right layout every group's absmax code is 127), and the two real models on this box use different ones. That makes the central design constraint of the writer explicit at the place it would be violated: a writer that re-encoded or re-quantised would have to make a choice the container does not make, and getting it wrong produces a pack that decodes to wrong weights with no error anywhere. Copying the payload verbatim cannot make that mistake. (cherry picked from commit 603a278ec3ca6ecb8649dd23aa889069d1f646fa) * npu-pack: the container import emits a container of the same genre (engine#1788, 3c) Recorded at the declaration, because it decides the next code step. A container tensor's shape is ALREADY the chunk -- [4096,8,4736] I8 is N=4096, 8 groups, 4736 bytes per row, and 4736 is exactly the Q4_K chunk size -- so the payload is q4nx chunk data and the import needs no re-quantisation. It emits a container of the same genre: one contiguous payload (the planned ranges in order) plus a header naming each destination slot with dtype, shape and offset. That is also the only safe answer to q4nx.h's two-Q8-layouts problem: the bytes never change, so the choice the container does not make never has to be made. container_write_layout() stays as the debugging form (one file per slot); the pack form concatenates instead. (cherry picked from commit 9e022123cfe776379a34c5639c4f85f563f0179d) * npu-pack: the pack form -- one contiguous payload and a header of offsets (engine#1788, 3c) The shape the forward opens, and the last piece of the writer. Writes model.q4nx as the planned byte ranges concatenated in order, and pack.json as the header: per slot its offset, byte count, shape, dtype and source tensor, plus the totals and the unplaced list. Called the container genre because the payload IS the container's chunks, copied -- nothing is re-quantised (see the note above container_write_layout on why that is the only safe choice). Armed by reading the payload back AT THE HEADER'S OFFSETS and comparing against the source ranges, because a header that is merely self-consistent proves nothing: offset 0 -> "ABCDEFGH" (model.embed_tokens.weight, source 0..8) offset 8 -> "IJKLMNOPQRSTUVWX" (model.layers.0.self_attn.q_proj.weight, source 8..24) Mutant (leave a one-byte gap between slots): "pack: two tensors, 24 bytes" and "pack: and offset 8 for the second" both fail. And the same refusal as the layout form: an incomplete plan writes nothing and says how many tensors had no destination. (cherry picked from commit 4876df3a52cede519eac6d544cf0614c19bfc8b5) * npu-pack: the container import is wired -- 1bit npu-pack import now writes a container (engine#1788, 3c) The refusal that has been there since #2277 ("importing it needs the container-genre writer, which the importer does not have yet") is replaced by the writer: read the container, take the layer types from config.json beside it, plan the placement, and write model.q4nx + pack.json. The payload is COPIED, never re-quantised, which is the only safe answer to q4nx.h's two-Q8-layouts problem. End to end on the fixture pack (840 KB, no device, no 21 GB write at the current disk floor): $ 1bit npu-pack import .../container-genre -o out $ ls out -> model.q4nx pack.json pack.json: bytes, payload_bytes, unplaced, and per slot offset/bytes/shape/dtype/source The unplaced list goes into Report::refused, because it IS a refusal list and the caller already prints that. With allow_incomplete the write proceeds and the header records what was left out; without it, nothing is written at all. And a trap worth recording: the first run of this wrote only model.q4nx and I nearly concluded the branch had not fired -- ninja had no work to do for `1bit` because I had built `onebit`, and the binary was stale. Fifth time tonight; the fix is always to check the binary's timestamp before believing its output. (cherry picked from commit ac53ceffab3dbd3c333b1208b4365b670b210ffd) * npu-pack: seek from the PAYLOAD base, not the file start (engine#1788, 3c) The first end-to-end run wrote the right TOTAL (1024+128 = 1152 bytes) and the wrong BYTES: Q4nxTensor::begin/end are payload-relative, so seeking to `begin` in the file read the safetensors header. `header_bytes` is the JSON length alone, so the payload starts at 8 + header_bytes -- and that +8 only exists when there IS a header (a bare payload has header_bytes 0 and no prefix), which is why the fix is conditional. Verified by comparing every written byte against the source container's payload: $ 1bit npu-pack import .../container-genre -o out OK model.embed_tokens.weight <- model.embed_tokens.weight OK model.layers.0.mlp.down_proj.weight <- model.layers.0.mlp.down_proj.weight EVERY BYTE MATCHES: True out size 1152 == pack.json bytes 1152 and armed so it cannot regress: a fixture whose payload has a 24-byte header must produce the PAYLOAD's bytes, and the mutant (seek without the base) fails it with exactly the symptom production had -- "got: 012345678901", the header bytes. The arm that could have caught this the first time is the one with a NON-ZERO header: the earlier arms all had base 0, so a missing base was invisible to every one of them. (cherry picked from commit 0517978b344d3934d6353ae6ab7c9dd978888ccf) --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
The behaviour half of #2187, on
main#2187 is stacked on #2099 (slice 3b) for its reporting half — the
roles/vision/payload_rowsfields come from 3b's plan members. But the parts that classify and refuse touch none of that, and #2270 already showed the value of shipping a main-based piece while the stack waits: it merged the same day, while #2099 has been clean and unmerged for nine hours.So this is that half, on
main, with no 3b symbol in it (checked:container_roles_unassigned,container_role_counts,role_note,payload_rows,role_shapes,write_plan— none appear).Classifier fixes:
final_norm's guard testedlayers., and MiniCPM-V spells its blockslayer.N, so 30 per-layerlinear_attn.ssm_normtensors were filed as the model's final norm.self_attn.gate_projis an attention output gate and was filed as the dense FFN gate; the name comes frommodel.h's ownself_attn_gate_proj_weight.share_*first, becauseshare_gate_exps_projcontainsgate_exps_proj), mapped to fieldsmodel.halready reads.Recognition for what this forward does not read (measured, and the reason it is recognition rather than mapping): gemma3/gemma4/bolt carry
pre_/post_feedforward_layernorm,inp_gate,per_layer_projection,layer_output_scale,post_layernormandper_layer_model_proj— nomodel.hfield exists for any of them — plusrope_freqsandper_layer_token_embd, which are known for a different reason (the forward only flags a rope-freqs file; the per-layer token embedding is what our ONNX path reads asembed_tokens_per_layer). They get two distinct roles sounassignedkeeps meaning "nothing knows this".(These figures were measured on #2187's branch, where
npu-pack inforeports role counts — they cannot be reproduced from this head, whose evidence is the unit arms. Corrected after review.)And the exit-visible change:
npu-pack importon a container pack now refuses by name —— instead of falling through to the ONNX importer and reporting a missing
model.onnxon a pack that has no graph by design.Arms: the attention gate versus 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 does read is not swallowed by the recognisers.
What this does to #2187: once this lands, #2187's behaviour hunks become identical to main's, so its diff shrinks to the reporting half on rebase. That is the split I offered in its review guide, done in the direction that unblocks something today.