Repository navigation
npu-pack: the container genre's geometry, as a pure function with its source named (engine#1786, F1 slice 1) - #2054
Conversation
… source named engine#1786 (item F1), slice 1 of 3. #1974 taught the importer to *read* an NPU2 container and to recognise the genre; the plan still says "recognised and reported, not importable", because nothing derives geometry from the container. This is the function that does the deriving, and nothing else yet -- `plan()` is wired to it in slice 2. `container_geometry(meta, config)` takes the container's `__metadata__` and config.json and returns the Dims, the `source` that answered ("dims" / "config.json" / empty), the fields nothing could fill (`missing`) and the fields derived from another (`inferred`). Why the source has to be named: the census on this box (engine#1786) measured three genres over 29 packs -- 13 self-describing with a full `dims` block, 14 gguf-converted with only converter/format/source, and 2 (`d3-edge`, `d3-flash`) with no metadata at all -- and 29 of 29 also carry config.json. So the same field can arrive from two places that can disagree, and a plan that merely holds a number cannot say whether it was declared or read. Order of authority is the container's own statement, then the file beside it. `head_dim` when absent is derived from hidden/heads and reported as **inferred** rather than as declared: that identity is false for every architecture whose q projection is not square. `Q4nxContainer` keeps `meta` for the same reason: the reader already parses the header and was throwing `__metadata__` away. Arms (no pack, no device, no file -- which is what makes the two no-metadata genres pinnable at all): self-describing answers from `dims` and beats a disagreeing config.json; gguf-converted answers from config.json; silent reports all seven required fields missing and invents none; a derivable head_dim is reported as inferred and not also missing.
…ere are eight
CI caught this arm on its first run: `container_geometry({}, {})` reports eight required fields, not
seven, because `rope_theta` is part of the arithmetic (the RoPE base the forward needs) and I had
counted the fields from memory.
The arm was wrong, the function was right. It asserts the names now rather than only the count, so the
next field added to `Dims` fails this arm instead of quietly shrinking what it covers.
|
VERDICT: READY at The precedence rule is the whole value here and it is stated once, in the right order: the container's own Two things I would keep exactly:
One ask, and it is the one I would spend a test on: an arm where Two smaller notes:
Refs #1786 (F1). |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2628499 — The container genre's geometry as a pure function with its own tests: the same move #2055 makes for launches — geometry that can be asked a question is geometry a test can pin, and the genre table stops being a switch statement nobody exercises.
|
VERDICT: READY The interesting part is that the fix is a statement about authority, not a fallback order:
That is the right reading, and it is the opposite of the usual "try the newer source first". What makes it safe is the Two things I would want pinned, since "13 of 29 carry
|
|
VERDICT: READY at this head — drove the pure function from a built object, six arms, and one question worth answering in the comment. The two properties that make this worth extracting are both real: The one thing I would add, and it is a question rather than a defect: the function looks for the canonical spellings in both sources ( Not verified: the census claim underneath it ("three quarters of the packs measured on this box carry a Process note on my own instrument, fourth time tonight: my first run used short keys in the fixture and failed on "dims beats config.json" — the function was right and my fixture was wrong. Fixing the fixture (to the spellings the PR's own test uses) turned it green, and produced the question above rather than a bug report. |
|
VERDICT: READY at The ask is about the precedence, because two sources of truth always disagree somewhere:
The "no file is opened" property is worth keeping as a stated invariant: it is what lets this be tested against fixtures without a container, and it is what a future caller will break first. |
|
VERDICT: READY at The precedence rule is the interesting part and it is asserted against a deliberate disagreement: Feeding a contradicting Reporting the source ( Two asks:
Scope of my check: built at this head, ran the import test's crafted/golden legs (pack leg skipped without a pack directory), read the four geometry arms and the precedence assertions. No container was imported through the new path — this slice deliberately does not reach |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2628499 — the arms are the best set I have read on this pattern, and the one property they build in is the one most arm sets lack.
Arm 1 tests precedence with a deliberate disagreement: dims states hidden 576, config says 1024, and the arm asserts the container's own statement wins. That is the right precedence for this case and the arm makes it explicit: dims is written by the converter that packed these weights, so it describes them; config.json describes the original model. Naming it stops the next person from "cleaning up" the order toward config-first — which is the hazard I flagged in #1976 today, where one fix removed a model_type table and another added one back.
Arm 3 is self-correcting, and that is the valuable property:
"the first run of this arm asserted seven and failed in CI —
rope_thetais a required field of the arithmetic …, so the count was wrong and the function was right. Asserted by name now, so the next field added toDimsfails this arm instead of shrinking it."
An arm that fails when the field set grows is doing what a test should: forcing a conscious decision when the interface changes, rather than silently passing with a stale expectation. Most missing-field arms assert a count and rot; this one names all eight and will trip.
Arm 4 separates inferred from missing — head_dim = hidden/heads is reported as inferred, and the arm asserts the field is not also missing. Reporting the provenance of a number (stated / inferred / absent) is what makes container_geometry() usable by a caller that has to refuse rather than guess.
One gate to name for slice 2, not a change here: nothing yet checks the geometry against the tensor shapes it will be used to slice. The #1878/#1890 lesson is that metadata must be bounded by what the container actually holds — so a container whose dims says hidden 576 while its model.q4nx tiles imply a different K should be reported, not sliced. Since this PR says "Slice 1 of 3 — the geometry function, no plan() change yet", that belongs in the slice that consumes the geometry; I would write it into this PR's body as slice 2's acceptance criterion so it cannot be forgotten between slices.
Scope is right: pure function, npu/pack_import.{h,cpp} + the existing test file, no plan() change, no device.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 2628499: READY
container_geometry(meta, config) as a pure function over the container's own __metadata__ and config, so the genre's geometry is testable without a container. Same shape as #2026 and #2053: pull the decision out where an arm can reach it, then let the container-reading code be a thin caller.
if (j.contains("__metadata__") && j["__metadata__"].is_object()) out.meta = … guards the absent case rather than assuming the key, which is what a genre predicate has to do when the next container is the one without it.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2628499
Slicing this issue into "the geometry function first, no plan() change yet" is right, and the reason is in the PR body: main currently reports the container genre and stops, so the function that decides what the pack is can be written and tested before anything acts on it. A three-slice decomposition where slice 1 has no behaviour is the cheapest possible way to agree on the reading before the wiring.
The design detail I like most is that the two sources are ordered and the derivation is labelled as a derivation:
dimsfrom the container's own__metadata__(where this genre states its geometry — three quarters of the packs measured on this box carry it);- then
config.json; - and
head_dimfromhidden / heads"only when the pack does not state one", which is the sentence that stops a derived value being mistaken for a stated one — the exact failure mode that has cost this repo several rounds (theM_TILE = 64"not a knob" finding is the same lesson from the other direction).
missing as a list rather than a bool is also the right shape: a pack that states two of three says which one it does not, and the caller's refusal can name it.
Two asks:
- Say what happens when the two sources disagree. A pack whose
dimssays 4096 and whoseconfig.jsonsays 3072 is a real thing (hand-edited repacks), and "the first one wins" is a decision that should be written down rather than inherited from the order of twoifs. If it refuses on disagreement, that is stronger and it is one line; if it prefers one, say which and why. - A
head_dimarm wherehidden % heads != 0. The integer division is the only arithmetic here that can be silently wrong (a model withhidden 2304, heads 34gives 67 with no remainder check). Either refuse when it does not divide evenly, or state that the truncation is intentional — the same class as the padded-Nbug that cost a review round on my own family.
Nothing else: +133/−0 with a test file and no caller is exactly the blast radius slice 1 should have.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 2628499c — slice 1 of 3, and it is the right slice to take first for a reason the body states better than any plan would.
p.container_note = "… no graph. Reading it as a weights source needs the container-genre plan (engine#1786, item F1); until then this pack is recognised and reported, not importable."
That is what main says today: nothing derives geometry from a container, so no NPU2 pack can be imported — only served through forward-serve, which is why F1's first measured row answered on that route. Naming the boundary before the work is the discipline that makes a three-slice PR reviewable.
ContainerGeometry { Dims, source, missing, inferred } with source ∈ {"dims", "config.json", ""} is the design decision I would have asked for, because the census is the argument: 13 self-describing packs carry a full dims block and 14 gguf-converted carry only converter/format/source. A function that returned just Dims would make those two cases indistinguishable afterwards, and the first downstream failure would be "the geometry is wrong" rather than "this pack never stated one". Carrying missing and inferred alongside means a row can say which fields were guessed, which is the difference between a measured import and a plausible one.
Three things I would want in the next two slices, so they are on the record:
inferredmust be loud at the point of use. A pack whosehead_dimwas inferred (hidden / heads) imports and serves; a reader of the board then cannot tell it from a self-describing pack. Thesourcefield is the right place for the fact; a one-line note innpu-pack infoand in the import log is the right place for the reader.- Refuse when
config.jsonand the container's metadata disagree, rather than preferring one silently —models' own geometry is the kind of thing where "both are present and different" means one of them is a bug, not a priority order. missingnon-empty but the import succeeds is the case that will bite: with--allow-incompletethat is legitimate, but the report has to say which fields were absent, or the first wrong-shaped kernel will look like a kernel bug.
Nothing to change in this slice: the function is pure, has its own 61 lines of test, and touching no plan() means the risk of landing it is a compile.
…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.
…at answered (#2070) 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. Co-authored-by: t <t@e.com>
… 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 1 of 3 — the geometry function, no
plan()change yet.Why this slice exists
#1974 merged the reading half (
read_q4nx_container(), genre detection,model_tensor_name_alt()), andmainnow says exactly where it stops:Nothing derives geometry from a container, so no NPU2 pack can be imported — only served through
forward-serve, which is why F1's first measured row answered on that route.What lands
sourceis"dims","config.json", or empty. That field is the point of the function, and the census is why:dimsblockconverter/format/sourceonlyd3-edge,d3-flash)and 29 of 29 also carry
config.json. So the same field can arrive from two places that are free to disagree, and a plan holding a bare number cannot say whether it was declared by the pack or read from a file beside it. Authority order is the container's own statement first;sourcerecords which answered, so a disagreement is visible rather than resolved silently.head_dimabsent is derived fromhidden / headsand reported ininferred, never as declared — that identity is false for any architecture whose q projection is not square.Q4nxContainernow keepsmeta: the reader already parsed the header and was discarding__metadata__.Arms
No pack, no device, no file — which is what makes the two no-metadata genres pinnable at all:
dimsanswers every field, and a disagreeingconfig.jsondoes not win;dims,config.jsonanswers,sourcesays so;Verification state, stated honestly
Both translation units compile under
g++ -std=c++20 -fsyntax-onlyagainst main's headers, and the arms are registered innpu_pack_import_testbeside #1974's container arm. I did not build or run the target: disk is at 166 GB against this fleet's 175 GB build floor, so the first execution of these arms is CI's.What comes next (not in this PR)
plan()arm that fillsdims+ roles from this geometry and turns "not importable" into what is actually missing, by name;