Repository navigation
npu-pack: plan() fills a container's geometry and names the source that answered (engine#1786, F1 slice 2) - #2070
Conversation
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 5be730a: READY
Reviewed as the slice it is: the delta from its base npu/f1-container-geometry (#2054) to 5be730a. The description says it was never built or run locally, so this is its first execution I know of. I built it incrementally in an existing build dir (no new build):
-
npu_pack_import_test tests/golden: all npu-pack checks pass.tests/goldenis clean afterwards (no untracked files), so the arm does remove its fixtures on exit, unlike its neighbour before #2024. -
The arms can fail. With
plan()passing an emptymeta(sodimscan never answer), the suite fails "a self-describing container reports source=dims", "dims.hidden comes from the container, not the disagreeing config.json" and "the container's geometry reaches the plan". Restored, it passes. -
On real NPU2 packs,
1bit npu-pack info→container.noteanddims:pack note's geometry clause dims MiniCPM-V-4.7-1B-NPU2 geometry from config.json hidden 1024, 24 layers gemma4-e2b-npu2 geometry from config.json; no source states rope_theta hidden 1536, 35 layers Qwen3.8-27B-NPU2 geometry from config.json hidden 5120, 64 layers MiniCPM-V 1B's 1024 agrees with what its vision merger projects to (#2040's reader says "→ 1024 (the text model's hidden size)"), and Gemma-4's missing field is named instead of printed as a zero. None of these three carries
dims, so the "dims wins" path is covered by the arms rather than by real data here.
Routing config.json through load_geometry() instead of a private parse is right: one meaning of config.json across genres. container_geometry_source / _missing / _inferred are on the Plan but not in the info JSON; the note carries them, which is enough for now. Base is the stacked branch, so this lands after #2054.
…at answered engine#1786 (item F1), slice 2 of 3 -- stacked on slice 1 (#2054), which added container_geometry(). This wires it into the container-genre arm of plan(), so a recognised NPU2 container now carries its geometry instead of only its tensor count. Plan gains container_geometry_source / _missing / _inferred, and container_note names the source inline ("geometry from dims", "geometry from config.json", or "the geometry is not known here"), plus any field derived rather than declared and any field no source states. config.json is read through load_geometry() rather than a private parse, so this genre and every other agree about what config.json means (the text_config lift and the top-level-wins rule); container_geometry() still prefers the container's own `dims` over it. Still not importable, and the note says so: what is missing now is the role map (which tensor is which projection), not the geometry. That is slice 3. Arms: dims answers and beats a disagreeing config.json; config.json answers when there is no dims; with neither, the plan reports every required field missing and says the geometry is unknown rather than reporting zeros as facts. The arm removes its fixture directories on every exit -- the defect #2024 fixes for its neighbour is not repeated here.
5be730a to
1632630
Compare
|
VERDICT: READY at Why the slice split is the right one
What I would want in the body
And one property worth asserting: Stack declared, retarget stated, arithmetic separated. Nothing else. |
|
VERDICT: READY
That two-sentence rule is the whole design, and it is worth having written down: one meaning for config.json across genres (so a pack cannot be read two ways One thing I would want pinned, since this is the slice where the two sources meet:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1632630 — plan() filling a container's geometry and naming the source of each field: on a path where geometry can come from config.json, genai_config.json or model_config.json, recording which one answered is what turns "the number is wrong" into "the wrong file was read".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1632630cc — slice 2 names its provenance in the plan, and the one thing I asked for in slice 1 is now the natural next arm rather than a missing property.
What lands is exactly the right shape for a plan field: container_geometry_source / _missing / _inferred, plus a container_note that says "geometry from dims", "geometry from config.json" or "the geometry is not known here". A plan that carries a geometry and says where it came from is the difference between a number a caller can trust and one it has to re-derive.
And it fixes a divergence I would not have caught from slice 1's diff: config.json is read through the existing load_geometry() rather than a private parse, "so this genre and every other agree about what config.json means (the text_config lift and the top-level-wins rule)". Two readers of one file is precisely how a geometry ends up different depending on which path asked — the double-implementation hazard the q4nx packer had (#1857/#1874).
The measured context is worth keeping: "13 of the 29 packs measured carry a dims block; all 29 carry config.json" — so the dims path is the minority and the config path is the one that matters in bulk, while the precedence still has to be right for the 13. Both arms are present, including the deliberate disagreement (dims 576 vs config 1024 → source == "dims", hidden == 576).
The arm I would add, and it is the one I flagged on #2054: nothing here compares the resulting geometry against the tensors it will slice. The embed_tokens header is right there in the fixture (shape:[4,2], BF16, 16 bytes) — so the cheapest possible bounds arm is: a container whose dims.hidden disagrees with the tensors it declares is reported (a note or a container_geometry_missing entry), not silently planned. That is #1878/#1890's rule — metadata bounded by what the container holds — and slice 3 will consume pl.dims to slice tensors, so this is the last slice where a wrong geometry can be caught cheaply.
Not a blocker: slice 2 is "fill and name", slice 3 is "consume", and the check could equally live at the consumption site. If it goes there, one line in this PR's body saying so keeps it from falling between the slices.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1632630
Slice 2 does the thing slice 1 was there for, and it does it with the right conservatism: the container's own dims still wins, config.json is read through the ordinary loader so this genre and every other agree about what that file means (the text_config lift, top-level-wins), and the result is not just p.dims = g.dims but four fields — geometry, source, missing, inferred — so the note can say "geometry from X (head_dim inferred)" rather than a bare value.
Two properties of this diff that matter more than they look:
- absence is a stated outcome, not a silence:
g.source.empty()produces "the container declares nodimsand no config.json answered, so its geometry is not known here". That is the difference between a refusal a reader can act on and a plan with zeroes in it. inferredtravels with the value. Slice 1 derivedhead_dimwhen the pack does not state one; this is the slice where that derivation would become invisible, and instead it is carried into the operator-facing string.
Two asks:
- The disagreement case from slice 1 is now load-bearing. I asked there whether
dimsvsconfig.jsondisagreement refuses; in slice 1 the answer only affected a return value, here it decidesp.dimsfor a pack that will be served. If it prefersdims, say so in the note ("geometry from the container's dims, config.json disagrees") rather than silently preferring — a hand-edited repack is exactly the pack where the two differ, and it is also the pack most likely to be wrong. - One arm that the stacked state is honest. The base is
npu/f1-container-geometry, so this retargets tomainwhen slice 1 lands; the body says so, which is the right habit. If slice 1 gets a change request that alterscontainer_geometry()'s signature, this PR will need a rebase nobody will remember — naming the shape it depends on (four fields out of one function) in the body is the cheap insurance.
Nothing else: +99/−3 with a test file, no ELF, no device, and the geometry is carried rather than acted on — slice 3 is where a pack starts being planned from it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1632630 — slice 1 is in main (#2054 merged) and this diff uses it cleanly; three notes, all cosmetic. But one thing in the body is now wrong and will mislead a merger.
Checked the two ways this could have broken and neither does.
load_geometry()is failure-tolerant:readable_object()returns false on a missing or unparseable file and the function returns an empty object, so a self-describing container with noconfig.jsonstill plans exactly as before. That was the regression risk in adding aconfig.jsonread to this genre, and it is not there.p.dims = g.dimscannot clobber anything: the container branch runs before the graph paths and returnspfrom inside its own block, sodimsis default there.
The body's stacking note is stale, and it is the kind of stale that costs a merge cycle: it says "base branch is npu/f1-container-geometry, so this retargets to main when that lands" — GitHub shows base main, #2054 is MERGED, and container_geometry() is present on main today. So the PR is not stacked and should not be described as stacked; a merger reading the body will look for a dependency that no longer exists. (The header line of the body is the one place in a PR a merger trusts without re-deriving.)
Three notes.
- The fields this PR adds have no machine-readable consumer.
container_geometry_sourceappears exactly once in the tree — the assignment inplan().info's JSON emitscontainer = {format, tensors, payload_bytes, dtypes, note}, so the same fact reaches a reader only as English insidenote. Three lines in that block (geometry: {source, missing, inferred}) would makenpu-pack info | jq .container.geometry.sourceanswer the census's question, which is what these fields were split out for. Otherwise say inpack_import.hthat they are inventory for slice 3. std::string cfg_source;is written and never read — the answer used isg.source. Passingnullptr(or dropping the local) says so.- The test is good and its three cases are the three genres:
dimsbeating a disagreeingconfig.json,config.jsonanswering when there is nodims, and silent → 8 fields named missing with a note that says "not known here" rather than printing zeros — that last one is the case the body argues about, and it is asserted rather than described. It also removes its own fixtures in aCleanup, citing #2024, which is the right instinct one PR after that lesson.
What I could not reproduce: the census counts (13 of 29 self-describing, 14 gguf-converted, d3-edge/d3-flash as the silent genre). Those packs are not all on this box, so they stand on the author's measurement. Everything I could check structurally — the code paths, the fields, the tests — holds.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 1632630: READY
#2054 (slice 1) merged at 23:56Z, and this head is slice 2 rebased onto main. Its diff against main is line for line the same as 5be730a's diff against npu/f1-container-geometry, which I passed (sorted +/- lines, 0 differences). A three-way merge onto current main is clean.
At 1632630 (worktree checked out at that SHA), npu_pack_import_test tests/golden passes all npu-pack checks (rc 0), and tests/golden is clean afterwards. My earlier evidence still applies unchanged:
- the empty-
metamutant fails the three dims arms; npu-pack infogives "geometry from config.json" with plausible dims on MiniCPM-V-4.7-1B / gemma4-e2b / Qwen3.8-27B NPU2, and Gemma-4 names its missingrope_theta.
|
VERDICT: READY at Built it and ran Checked on a real container too: "container": { "format": "safetensors (model.qnx)", "note": "…851 tensors, 16832323584 B of payload, no graph; geometry from config.json…" }so the source is visible in the same line as the geometry — which is the whole point of item F1 and the same rule as #1975's section offsets: a number computed What I like about the shape: three states, not two. The one condition: this is stacked on #2054 ( Not verified: nothing device-side is involved in this slice, and no pack-level import was run (the pack leg is skipped without |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 1632630 — and the base has moved under it: #2054 is merged, so this now targets main (the PR page still says it retargets "when that lands"; it is already
clean against it, 3 commits behind, git merge-tree → no conflict).
Measured
$ cmake -S . -B b -G Ninja -DCMAKE_BUILD_TYPE=Release cfg rc=0
$ cmake --build b --target npu_pack_import_test build rc=0
$ b/npu_pack_import_test tests/golden run rc=0 all npu-pack checks passed
The arm that matters is the negative one
The set is well built — source=dims for a self-describing container, dims.hidden == 576 coming from the container and not the disagreeing config.json, the geometry reaching
plan, nothing reported missing for a complete container, geometry from dims in the note, source=config.json for a converted container — but the one I care about is:
check(pl.container_geometry_source.empty(), "plan: the silent genre claims no source");
A plan that invents a provenance is worse than one that has none, because the next reader stops looking. So I broke exactly that:
p.container_geometry_source = g.source.empty() ? "dims" : g.source; /* MUTANT */
-> run rc=1, ONE failure:
FAIL plan: the silent genre claims no source
restored -> rc=0One failure, on the one arm, with nothing else disturbed — which is what a well-scoped arm set does when you break one rule.
Scope
This is slice 2 of 3 as the body says: plan() filling the geometry and naming the source. I have not looked at slice 3, and I cannot check the container-side reading beyond what
the test fixtures construct — the fixtures are crafted, which is the right call for a plan() arm (no pack needed, so the row runs anywhere).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 1632630 — no verdict token (author's lane). Slice 1 (#2054) is merged and this is retargeted to main, so the diff I read is the real slice-2 delta (3 files, +99/−3) rather than slice 1 riding along.
The bug this PR could have had, and does not. plan() assigns p.dims here (:3400) and again nine hundred lines later at :3488 (p.dims = read_dims(geom, geom_src)) on the graph path. If both ran, the later assignment would silently void exactly what this slice exists to carry — the geometry would be filled and then overwritten, with the note and the three new fields still reporting honestly about a value that no longer reaches the plan. I checked the branch structure: the container genre returns p early (:3423), so the two assignments are exclusive. Worth keeping that way, and worth a line in the comment, because the failure would look like a wrong-geometry bug rather than an ordering one.
The strong arm is the precedence one, and it is the right fixture: dims.hidden == 576 with a disagreeing config.json proves the container's own dims won, rather than asserting agreement (which a fixture built from the same source could never fail). The third case is handled the way the rest of this file handles not-knowing — "the container declares no dims and no config.json answered, so its geometry is not known here" — instead of reporting zeros as facts, which is the failure mode that reads as a valid measurement.
One dead variable, and it is the interesting kind: cfg_source is declared and passed to load_geometry(pack_dir, &cfg_source) and then never read — the note uses g.source. Because it goes through a pointer the compiler will not warn, so it will sit there. Two options, both one line:
- pass
nullptrand drop it; or - keep it and make it earn its place by asserting the two agree —
g.source == "config.json"impliescfg_sourceis non-empty — which would catch a future divergence between "the loader read a config" and "the geometry says config.json answered".
The second is worth the line, since those two statements coming apart is precisely the kind of thing this slice is meant to make visible.
… residues recognised (engine#1786, F1 slice 3a) (#2097) * npu-pack: a container's roles from names alone, with the two measured residues recognised engine#1786 (item F1), slice 3a -- independent of slice 2 (#2070), since it needs only the container reader that slice 1 (#2054, merged) put in main. `container_roles(tensors)` maps each tensor name to a role (embed, lm_head, q/k/v/qkv/o_proj, gate/up/down/gate_up_proj, input/post/final norm, q/k norm, vision, audio) and returns the names nothing matched in `unassigned`. Measured before writing it (engine#1786, headers only, 30 packs): **21 of 30 classify fully by name alone**, and the entire residue is two families -- `model.layers.N.linear_attn.ssm_*` and `model.mtp.*`. Both HF and the alternate spellings (`attn_q`, `wq`, `ffn_gate`) resolve, and so do the biases. So this is a rule, not a per-pack table. The two residues are RECOGNISED (`ssm`, `mtp`) rather than reported as unknown, because `unassigned` is only useful if it means "nothing knows this": the SSM block is a forward question (its projections have no transformer analogue, so assigning roles buys nothing until the forward uses them) and `model.mtp.*` is a draft head, not a model -- the same packages that carry no tokenizer.json, which #1957's gate exists to state up front. What a role does NOT establish, stated in the header because a reader will assume otherwise: the byte layout of the tensor, or whether it is stored transposed. A name says which projection; `graph_weights` (present on 13 of the packs) and the pack's own `dims` are what check the rest. Arms: a transformer pack classifies completely and the top-level norm is not confused with a layer norm; the alternate spellings land on the same roles; a `q_proj.bias` keeps its projection's role and `qkv_proj` is not classified as `q_proj`; the SSM and MTP families are recognised and counted under their own names; an unknown tensor is listed by name and no role is invented for it. * npu-pack: the two dead needle rules and the shortconv misclassification (engine#2097 review) Answers the CHANGES at 9c2d5b6, all three asks: 1. `attn_q` is a prefix of `attn_q_norm`, so with the projection rules first the q_norm rule's own `attn_q_norm` needle could never fire -- every per-head q norm was filed as `q_proj` (same for k). The norm rules now come first. 2. `in_proj` alone is not attention: LFM2's `shortconv.in_proj.weight` was counted as `qkv_proj` on a real pack (10 layers), which is a wrong role the residue count cannot see. shortconv gets its own role from model.h's `shortconv_in_proj_weight`, and the attention needle now requires `attn.in_proj`. 3. Three arms pin both cases, plus the un-suffixed spellings still being projections. Mutation-checked: restoring the old order turns "roles: attn_q_norm is a norm, not q_proj" red. Full suite green. --------- Co-authored-by: t <t@e.com> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…gnised (engine#1786, F1 slice 3b) (#2099) * npu-pack: a container's roles from names alone, with the two measured residues recognised engine#1786 (item F1), slice 3a -- independent of slice 2 (#2070), since it needs only the container reader that slice 1 (#2054, merged) put in main. `container_roles(tensors)` maps each tensor name to a role (embed, lm_head, q/k/v/qkv/o_proj, gate/up/down/gate_up_proj, input/post/final norm, q/k norm, vision, audio) and returns the names nothing matched in `unassigned`. Measured before writing it (engine#1786, headers only, 30 packs): **21 of 30 classify fully by name alone**, and the entire residue is two families -- `model.layers.N.linear_attn.ssm_*` and `model.mtp.*`. Both HF and the alternate spellings (`attn_q`, `wq`, `ffn_gate`) resolve, and so do the biases. So this is a rule, not a per-pack table. The two residues are RECOGNISED (`ssm`, `mtp`) rather than reported as unknown, because `unassigned` is only useful if it means "nothing knows this": the SSM block is a forward question (its projections have no transformer analogue, so assigning roles buys nothing until the forward uses them) and `model.mtp.*` is a draft head, not a model -- the same packages that carry no tokenizer.json, which #1957's gate exists to state up front. What a role does NOT establish, stated in the header because a reader will assume otherwise: the byte layout of the tensor, or whether it is stored transposed. A name says which projection; `graph_weights` (present on 13 of the packs) and the pack's own `dims` are what check the rest. Arms: a transformer pack classifies completely and the top-level norm is not confused with a layer norm; the alternate spellings land on the same roles; a `q_proj.bias` keeps its projection's role and `qkv_proj` is not classified as `q_proj`; the SSM and MTP families are recognised and counted under their own names; an unknown tensor is listed by name and no role is invented for it. * npu-pack: plan() reports the pack's roles, and names what nothing recognised engine#1786 (item F1), slice 3b -- stacked on slice 3a (#2097), which added container_roles(). Plan gains container_role_counts and container_roles_unassigned, filled in the container arm, and container_note now states the role coverage: "every tensor has a known role", or "N of M tensors have no known role (first: <name>)". The point is the shape of the statement. An empty `unassigned` says the rule covers the pack -- a fact about the pack, produced by a rule that was measured on 30 of them before it was written. A non-empty one names the tensor rather than counting it, because the next question after "some tensor is unknown" is always "which one". Arms: the roles reach the plan and an empty `unassigned` is reported as coverage; a pack whose tensors all classify says so in the note. Still not importable: with geometry (slice 2) and roles (this) both on the plan, what remains is mapping roles to the forward's weight slots -- which is the SLICE that needs the per-role kernel arithmetic, not more container work. --------- Co-authored-by: t <t@e.com> Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Refs #1786 (item F1). Slice 2 of 3 — stacked on #2054 (slice 1,
container_geometry()); base branch isnpu/f1-container-geometry, so this retargets tomainwhen that lands.What this adds
Slice 1 gave the container genre a way to derive its geometry and a field naming the source that answered. This puts it into
plan(): a recognised NPU2 container now carries its geometry, not only its tensor count.std::string container_geometry_source; // "dims" | "config.json" | "" std::vector<std::string> container_geometry_missing; std::vector<std::string> container_geometry_inferred;…and
container_notenames the source inline, sonpu-pack infoanswers the question the census raised:geometry from dims— self-describing (13 of the 29 packs measured)geometry from config.json— gguf-converted (14)the geometry is not known here— the silent genre (d3-edge,d3-flash), where reporting zeros as facts would be worse than saying nothingplus
(head_dim inferred)when a field is derived rather than declared, and; no source states <fields>when fields are missing.config.json goes through
load_geometry(), not a private parse, so this genre and every other agree about what config.json means (thetext_configlift and the top-level-wins rule).container_geometry()still prefers the container's owndimsover it.Still not importable, and the note keeps saying so
What is missing now is the role map — which tensor is which projection — not the geometry. That is slice 3.
Arms
Real packs are written to a temp dir (a 16-byte payload, so the file is a valid container and not a 2 GB one):
config.json; the note namesdims;dims; the source says so;The arm removes its fixture directories on every exit path. That is deliberate:
test_q4nx_container()writing fixtures intotests/goldenunder ctest is the defect #2024 fixes for its neighbour, and there is no reason to introduce a second instance of it in the same function's file.Verification state
Both translation units compile under
g++ -std=c++20 -fsyntax-onlyagainst the slice-1 tree. Not built or run locally — disk is below this fleet's 175 GB build floor — so CI is the first execution, which is how slice 1's own arm defect was caught.