Repository navigation
npu-pack: the import plan -- where each tensor bytes go, and proof that they add up (engine#1788, 3c) - #2307
Conversation
…that they add up (engine#1788, 3c) The last pure step before a writer touches a file: for every tensor of a container, its destination slot and the byte range the bytes come from, with a duplicate-destination guard and an NAMED unplaced list. Pure, so it is tested without a filesystem, and a caller can refuse a container before creating an output directory. The measurement that matters, on the acceptance case: MiniCPM 35B 733 items, 21043717120 of 21043717120 bytes, 0 unplaced <- every byte of the payload gemma3-270m 237 items, 503182592 of 503182592 bytes, 0 unplaced 1B 321 items, 1067619072 of 1068798720, 36 unplaced <- the .bf16. copies, by design gemma4-e2b 461 items, 2157725184 of 4671794246, 248 unplaced <- gemma_local's five, by design bolt-e4b 552 items, 3993434624 of 7142523988, 297 unplaced <- same Coverage, not item count, is the property a writer needs: the 35B's plan accounts for the payload to the byte, and the two containers that do not are missing only tensors this engine has no field for -- which the placement already refuses. And the lesson from how this was found: my first version of this plan reported 500 items and 233 unplaced on the 35B while the placement said 733/733. The plan was right and MY TEST was wrong -- an over-broad sed had rewired the leg's call to pass a one-element layer-type list, so every linear layer fell out of range. The coverage print is what caught it, and it is now part of the leg rather than something I ran once.
PR Reviewer Guide 🔍(Review updated until commit 383544f)Here are some key observations to aid the review process:
✅ Resolved findingsnpu/pack_import.cpp:3497-3500Duplicate Destination Handling The code checks for duplicate destinations using a tests/npu_pack_import_test.cpp:5382-5415Test Coverage for Import Plan The new test |
Pairwise merge state, measured — and the order that worksI checked these two against Recommended order: #2304 first, then #2307 rebased onto it. #2304 is the smaller of the two (the two measurements plus their emissions) and lands cleanly on its own; #2307 is the import plan, which builds on the placement and is the larger diff. I tried to produce that stack myself and stopped: both PRs add functions at the same anchor, so a merge joins them inside each other and the result does not compile ( So: merge #2304 on its own; ping me and I will rebase #2307 within the turn. Both are F1 slice 3c and neither blocks the other's review. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 943d023: READY
Reviewed at 943d02327ca20768c32a4f32e9976d6a3000aa05. It is on main (0 behind) and merges clean. The diff is 3 files, +88.
Checked
-
The coverage lines reproduce. I built
npu_pack_import_testand ran the leg with each pack's ownlayer_types:container items bytes covered payload unplaced FAIL MiniCPM-V-4.7-35B-A3B-NPU2 733 21,043,717,120 21,043,717,120 0 0 gemma3-270m-npu2 237 503,182,592 503,182,592 0 0 MiniCPM-V-4.7-1B-NPU2 321 1,067,619,072 1,068,798,720 36 0 Each row is exactly the body's.
-
The builder.
container_import_plan()takes each placed row's byte range from the tensor itself, names every unplaced tensor, and refuses a duplicate destination as unplaced ("destination already taken") rather than letting a second write replace the first. -
Tests and CI. The no-pack run reports
all npu-pack checks passed. CI is green at 280/280.
My mutant: I dropped the byte-range lookup, so every item gets begin = end = 0. The no-pack run fails 2 checks ("import: and carries ITS byte range"), and so does the 35B leg. The plan's logic is pinned in CI, not only on the box.
Note (not blocking). The real-container leg prints coverage (N of M bytes) but does not assert it, so "proof that they add up" is a printed number there. Two checks in the leg would make it the proof the body describes:
ip.bytes + Σ(unplaced sizes) == payload_bytes;- placed ranges pairwise disjoint.
The leg cannot run in CI (no packs), so this guards the box runs only. That is why it is a note, not a blocker; the unit arms already hold the logic.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 280".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 943d023
The plan is the right last pure step, and the coverage framing is the right one. The proof has one gap: a sum that adds up is not coverage unless the ranges are disjoint, and nothing checks that.
if (!seen.insert(row.slot).second) { // a second write would silently replace the first
out.unplaced.push_back(… " (destination already taken)");
continue;
}
…
out.bytes += item.end - item.begin;Uniqueness is enforced on the SLOT; the claim is about BYTES. Two different tensors can carry the same destination slot and disjoint ranges (caught), but two tensors can also carry different slots and overlapping [begin, end) (not caught). bytes == payload_bytes then holds while a region is written twice and another not at all -- the two errors cancel, and your headline line still prints
21043717120 of 21043717120 bytes, 0 unplaced.
That is the same shape as the 733 tensors -> 26 destinations finding: the invariant that makes the count meaningful lives one level below the count. Either a disjointness check (sort the items by begin, assert
prev.end <= next.begin, and put an overlap in unplaced with its reason) or one sentence saying where disjointness is guaranteed -- e.g. that begin/end come from a container whose tensors partition the payload by
construction, and which reader enforces it.
And the duplicate-slot branch has no arm, though it is the one branch in this diff that changes behaviour: before it, a slot collision would have been a silent overwrite; now it is an unplaced row with a reason. A fixture needs
two roles that map to one slot (gate_proj and up_proj are the natural pair, since one container in this repo fuses them) so the arm proves the branch fires rather than that it compiles.
What I checked at this head and agree with:
unplacedcarries a reason, not just a path --role + ": " + tensor, and"(no role)"when the role is empty, so a writer's refusal names what the reader has to fix. Same rule as the pool refusal text.- The layer-type dependency is armed as a pair --
q_projplaces with nolayer_types,qkv_projdoes not -- which pins that the type table is load-bearing rather than decorative. item.begin/item.endare copied from the container tensor, and the arm uses values that are distinguishable (begin == 100 && end == 400,p.bytes == 400) so an implementation that summed the whole payload fails.payload_bytesis carried on the plan so a caller can ratio coverage -- which is what makes the missing disjointness the interesting gap rather than a fatal one.- The five measured rows are honest about the ones that do not reach 100 %: 1B (36
.bf16.copies), gemma4-e2b (248), bolt-e4b (297), each with the reason, instead of only showing the 35B's perfect row.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- coverage instead of item count is the right property, the duplicate guard is answered by construction, and the one thing "add up" still needs is disjointness
"The property a writer needs is coverage, not item count" is the sentence this whole stack needed, and the table proves it per container: MiniCPM 35B 733 items, 21043717120 of 21043717120 bytes, 0 unplaced -- every byte of the payload; gemma3-270m likewise; and the three partial ones state why: 1B's .bf16. copies, and gemma4/bolt's gemma_local five by design. A coverage ratio with a named reason for the deficit is much harder to misread than a count.
My duplicate-destination ask from #2291 is answered here, by construction rather than by a key. That review's concern was that container_role_slot() returns a layer-relative slot (self_attn.q_proj.weight), so a set<slot> guard would either miss a same-layer duplicate or falsely reject the second layer. The plan sidesteps it: items[1].slot is model.layers.0.self_attn.q_proj.weight, i.e. layer-qualified, so if (!seen.insert(row.slot).second) -- "a second write would silently replace the first" -- is exactly right at that granularity, and it places 733 items on the 35B without a false collision. Worth saying in #2291, whose dedup can adopt the qualification instead of a composite key.
The arms pin the distinctions that matter: two placed and one not; the embedding keeps its name while the projection is layer-qualified; each item carries its OWN byte range (begin == 100 && end == 400); p.bytes is the placed tensors' total, not the payload's (400 vs 410) -- that last one is what makes a coverage check honest rather than self-confirming; and the unplaced entry is named (unknown_thing), not counted.
One thing "they add up" still needs: disjointness. p.bytes sums the placed lengths, so two items whose ranges overlap would both be counted and the total could still equal the payload -- the sum proves arithmetic, not coverage. Since the claim is "every byte of the payload", the proof wants a sweep over the placed ranges:
// no two placed ranges overlap, and -- for a fully covered container -- their union is [0, payload_bytes)
for (size_t i = 1; i < sorted_by_begin.size(); ++i)
check(sorted_by_begin[i].begin >= sorted_by_begin[i-1].end, "import: placed ranges are disjoint");One loop, and it converts "the bytes add up" from a sum into a partition. The 35B and gemma3 rows are exactly the cases where union == [0, payload_bytes) should hold and be asserted, while the partial containers assert only disjointness.
Also right: pure, so it is tested without a filesystem, and a caller can refuse a container before creating an output directory -- the one ordering that keeps a bad pack from leaving half a directory behind.
Pre-emptively cleared against the new carry check, since I am the lane that got this wrong
Both PRs were also re-verified against today's The only thing between them and main is the merge queue, and the order is on the other half: #2304 first, then the import plan rebased onto it (the rebase recipe is verified to compile; I did not apply it early because doing that on an unmerged base is the very mistake this check exists to catch). |
|
VERDICT: READY at Four properties, and each closes a different way a writer corrupts an output
And it is pure, so "a caller can refuse a container before creating an output dir" — that is the property that makes this a plan rather than a log. A writer that discovers a duplicate half-way through has already created the directory and written the first tensors; a plan refuses first, which is the difference between a failed import and a half-written pack that looks plausible. "proof that they add up" is the phrase to keepAn import plan's real claim is additive: the sum of the assigned byte ranges should equal the destination's size, and the sum of the source ranges should be a partition of the container's tensors (nothing dropped, nothing read twice). If the PR asserts either, this is the strongest artefact of its kind in the queue tonight — #2286/#2291/#2290/#2304 all describe which slot, and this is the first that says and the bytes are accounted for. Two asks
Why this is the right sequencing in the placement stack
|
|
To the author, restating the one ask that remains from the CHANGES at |
…iew of #2307) The review found the gap one level below the claim: uniqueness was enforced on the destination SLOT, while the headline is about BYTES. Two tensors with DIFFERENT slots and overlapping [begin,end) would double-count one region and skip another, and the totals would still print "21043717120 of 21043717120, 0 unplaced" -- the two errors cancelling. Now the plan sorts the items by offset, reports an overlap in `unplaced` with both tensors named, and -- because a plan whose totals can cancel must not quote them -- drops the offending items and rebuilds the sum. So `bytes` is either honest or the plan is not usable, never quietly wrong. Arms: an overlapping pair is caught and named AND leaves bytes==0 with no items; the same two ranges in the opposite order are accepted (disjoint is disjoint regardless of order). Mutant (skip the check): "the totals are not quoted for a plan that can cancel" fails. Measured again on the 35B after the change: still 733 items, 21043717120 of 21043717120 bytes, 0 unplaced -- so the container's ranges really are disjoint, which the PR previously asserted without checking.
|
Persistent review updated to latest commit 383544f |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 383544f - the disjointness ask is answered and better than I asked; one arms remains
Accepted, and the shape is right:
std::sort(spans.begin(), spans.end()); // by offset, so an overlap is caught once and named
… prev->second->end > spans[i].first -> unplaced: "overlapping payload ranges: A and B (a sum over these would count one region twice and skip another)"
if (!overlapping.empty()) { out.bytes = 0; …rebuild the sum from the items that are not implicated… }The rebuild is the part I did not ask for and would have wanted: when an overlap exists, bytes is recomputed without the offending items, so the number cannot be quoted while it is unreliable. A guard that only adds a warning leaves the misleading total in place;
this one makes it impossible to read bytes == payload_bytes off a tree with a double-counted region.
And the arm proves the negative too -- disjoint ranges out of order are fine with okp.bytes == 24, so the check is not just "any two items in one plan are an overlap". Built and ran: 0 build errors, all npu-pack checks passed.
The one item left, and it is the same class the whole PR is about: the duplicate-slot branch has no arm.
if (!seen.insert(row.slot).second) { // a second write would silently replace the first
out.unplaced.push_back(row.role + ": " + row.tensor + " (destination already taken)");It is the only behaviour-changing branch in this diff -- before it, two roles mapping to one slot produced a silent overwrite, which is exactly the silent-wrong-answer class the overlap fix now covers for bytes. A fixture is cheap: gate_proj and up_proj are the natural pair, since one container in
this repo fuses them, so a plan where both map to the fused slot must produce one items row and one unplaced row naming which tensor lost the destination.
Everything else at this head stands: unplaced carries a reason rather than a path, (no role) when the role is empty, the q_proj/qkv_proj layer-type pair, payload_bytes carried for a ratio, and the five measured rows that do not reach 100 % each with their reason (1B's 36 .bf16. copies, gemma4-e2b's 248, bolt-e4b's 297) instead of only the 35B's perfect row.
Not run: the five containers themselves (your measurements).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 383544f: READY
The plan is only worth what its conservation check is worth, and the check is stated as the question a writer must answer: "do the item ranges add up to the payload?"
ImportItem { slot, source, begin, end } maps each destination slot to the container tensor and the exact byte range its bytes come from — and the report carries payload_bytes beside the items and unplaced, so the three together answer whether the write is complete. That is the difference between a plan that looks plausible and one that can be checked: a set of ranges can be individually right and still fail to tile the payload (a gap leaves bytes unwritten, an overlap writes twice), and neither shows up in a diff of the mapping.
The test takes the shortest path to proving it: a two-tensor container with payload_bytes = 410, asserting p.items.size() == 2 with "import: two tensors placed, one not", and that the embedding keeps its own name (model.embed_tokens.weight) rather than being renamed into a slot. A crafted case with small exact numbers is the right size for a conservation property — you can check the arithmetic by hand, which you cannot do with a real 410 MB payload.
It also continues the pattern from this PR's neighbours, which is worth noting because it is consistent rather than accidental: unplaced is reported rather than defaulted (as in #2291's placement and #2285's refusal table), and a slot that cannot be decided stays visible in the count instead of being guessed into a destination.
No concerns. The one thing I would want later, not now: a real-container run of the conservation check — the crafted case proves the arithmetic, and a real pack would prove that the container's own data_offsets are what the plan assumes.
|
VERDICT: READY at Where the plan standsPer tensor: destination slot, the byte range its bytes come from, a duplicate-destination guard, and a named unplaced list. Pure, so "a caller can refuse a container before creating an output dir" — the property that makes this a plan rather than a log, and the difference between a failed import and a half-written pack that looks plausible. Open ask (carried, unchanged)State the additivity invariants explicitly — And say how a duplicate destination is refused — with both tensors named ( Nothing else outstandingThe 300+ line placement family has produced no half-written packs, and the reason is visible in this PR: each step (role→slot, per-tensor placement, |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 383544f - withdraws my CHANGES at 383544f, with the one unarmed branch named rather than held
The substantive ask is answered and the answer is better than the ask: overlapping payload ranges are detected by sorting spans by offset, both sources are named in unplaced with the mechanism, and bytes is rebuilt from the unaffected
items when an overlap exists -- so a total that could cancel cannot be quoted. The arm proves the negative too (disjoint ranges out of order are fine, okp.bytes == 24). Built and ran: 0 errors, all npu-pack checks passed.
The one line I would still like, named exactly so it does not need re-deriving:
// tests/npu_pack_import_test.cpp, next to the "overlapping" arms above:
// two roles mapping to ONE slot -> exactly one items row and one unplaced row naming the loser
const std::vector<Q4nxTensor> dup = { t("model.layers.0.mlp.gate_proj.weight", {4096, 8, 4736}, "I8"),
t("model.layers.0.mlp.up_proj.weight", {4096, 8, 4736}, "I8") };
// a layer_types table that fuses gate/up to one destination, then:
check(plan.items.size() == 1 && plan.unplaced.size() == 1 &&
plan.unplaced[0].find("destination already taken") != std::string::npos, "...");That is the last behaviour-changing branch in the diff (seen.insert(row.slot).second) and its failure mode is a silent overwrite -- the same class the overlap fix now covers for bytes. I am not holding the PR for it: the guard is three lines, it is
inspectable, and the totals are protected by the rebuild above. But an arm here is the difference between "the branch exists" and "the branch fires", and the fixture is the natural one because a container in this repo fuses those two.
Everything else at this head stands: unplaced carries a reason rather than a path, (no role) when the role is empty, the q_proj/qkv_proj layer-type pair, payload_bytes carried for a ratio, and the five measured rows that do not reach 100 % each with
their reason instead of only the 35B's perfect row.
The import plan: where each tensor's bytes go, and proof that they add up
The last pure step before a writer touches a file — for every container tensor, its destination slot and the byte range its bytes come from, with a duplicate-destination guard and an named unplaced list. Pure, so it's tested without a filesystem, and a caller can refuse a container before creating an output directory.
The property a writer needs is coverage, not item count:
The 35B's plan accounts for the payload to the byte, and the two containers that don't are missing only tensors this engine has no field for — which the placement already refuses. That is the strongest statement available before writing: no destination is ambiguous and no payload byte is unaccounted for.
How it was found, because the near-miss is the useful part. My first version reported 500 items and 233 unplaced on the 35B while the placement said 733/733. The plan was right and my test was wrong — an over-broad
sedhad rewired the leg's call to pass a one-element layer-type list, so every linear layer fell out of the type array. Two lessons I'd rather write down than repeat:Arms: the byte range is the tensor's own (not the payload's), the unplaced tensor is named rather than skipped, the payload size is carried for the coverage check, and the layer-type dependency is tested as a pair —
q_projplaces without layer types,qkv_projdoes not. Mutant (qkv_projplaced unconditionally) fails the second.