Repository navigation
npu-pack: read a FastFlowLM NPU2 model source's container, report its genre, and match both layer-name spellings (engine#1786, item F1) - #1974
Conversation
…a device (engine#1786, item F1)
F1 of the FLM 1.0.12 parity work is 'every FastFlowLM/*-NPU2 repo imports and serves on --device npu'.
The first row showed the packs are a genre our importer does not know: model.q4nx is a safetensors
container (8-byte LE header length + JSON of name -> {dtype, shape, data_offsets}), the tensor NAMES are
the model's own HF names (no graph, no model.onnx), and the pack holds only BF16 (norms/embedding) and
I8 (matrices, packed -- down_proj is [640,1280] for a 640-wide FFN).
This tool reads the header only: no weights, no device, no runtime. It exists so each of the 59 rows in
the board's FLM NPU2 section can be characterised in one command and the importer's work list is a fact
rather than a guess.
Measured on FastFlowLM/Gemma3-270M-NPU2 (0.54 GB): header 26496 B, 237 tensors, payload 0.503 GB,
dtypes {BF16: 110, I8: 127}, 18 layers, metadata keys [] (empty -- this pack has no __metadata__ block).
…6, item F1)
The first row of the F1 sweep showed why the importer refuses every NPU2 pack: its genre is an ONNX graph
plus external data, while an NPU2 model source is a safetensors container named model.q4nx (8-byte LE header
length, then JSON of name -> {dtype, shape, data_offsets}). Measured on FastFlowLM/Gemma3-270M-NPU2:
header 26496 B, 237 tensors, payload 0.503 GB, dtypes {BF16: 110, I8: 127}, 18 layers, no __metadata__.
This is the reader for that container, header-only -- no weights, no device -- exported so the half that can
be wrong silently is testable without a pack. Each failure names what was wrong, because the caller's next
move depends on which: a truncated header must not read as "a pack with no tensors".
Arms in the existing registered test (no new target): the real two-tensor shape (BF16 vector + I8 matrix at
twice its logical width), __metadata__ skipped rather than counted, a truncated header named, and an
implausible header length refused by the number so a mis-typed length cannot make the caller allocate it.
Verified by -fsyntax-only of pack_import.cpp and the test against a configured tree's json dep; NOT built or
run: disk is at 146 GB, below this repo's 150 GB floor and the 175 GB build threshold. The syntax check found
one real defect already (my ContainerTensor collided with the existing ONNX-role type of that name; renamed
Q4nxTensor). Next: wire it into plan()/info so an NPU2 pack reports its inventory instead of refusing for
model.onnx, then import -> --device npu -> known-answer prompt -> tok/s under npu-hold.
… of refusing at the first byte (engine#1786, item F1)
Reading the container header (previous commit) is only half of it: plan() then went on to open a graph the
pack does not have, so `npu-pack info` answered a 0.5 GB model source with "cannot open <pack>/model.onnx"
-- a missing file on a pack that has no graph by design.
plan() now detects the genre first (model.q4nx present, no resolvable graph file), reads the container, and
records the census on the Plan: tensor count, payload bytes, a dtype map, and a note saying what the genre
still needs. manifest() reports it under "container", so info prints it and a test can pin it.
Measured on FastFlowLM/Gemma3-270M-NPU2: 237 tensors, 0.503 GB payload, {BF16: 110, I8: 127}.
The note is deliberately explicit that the pack is RECOGNISED AND REPORTED, NOT IMPORTABLE: reading a
container as a weights source is the container-genre plan this item still owes, and a plan that quietly
listed 237 tensors as importable would be worse than the refusal it replaces.
Syntax-checked against a configured tree's json dep; still not built or run (disk 149 GB, under the 175 GB
build threshold).
…importable (engine#1786, item F1) The plan() detection had no arm, which for this change is the difference between a report and a claim: a plan that listed a container's tensors as importable would be worse than the refusal it replaces, because it would assert a compute mapping this importer does not have. The arm builds a pack directory that IS a container and has NO graph, then checks plan() reports the tensor count, the payload the offsets imply, both dtypes, and a note containing "not importable" -- and that manifest() carries it under "container", which is what npu-pack info prints. Syntax-checked against a configured tree's json dep; still not built or run (disk 148 GB, under the 175 GB build threshold).
…nd run (engine#1786)
Built npu_pack_import_test for the first time and ran it: two of my four arms failed, and both were the arm's
fault, not the reader's. Worth recording precisely because the reader was right either way:
- the payload arm expected 819200 but the reader returned 820480 -- and the reader was correct: the sum of
the data_offsets SPANS is 1280 (first tensor) + 819200 (second). I had summed only the second span, i.e.
the arm asserted a number I had derived by hand instead of the number the offsets imply.
- the implausible-length arm left its std::ofstream alive while the reader opened the same path, so the
reader saw an empty file and reported "shorter than the 8-byte header length" -- the arm was testing its
own missing flush. Scoped the writer so it closes first.
With those fixed: all npu-pack checks pass (pack leg SKIPPED, no pack directory given), which is the whole
suite, so the container reader and the genre report are now verified on this box rather than only
syntax-checked.
Also: the two failures are a small demonstration of the night's recurring rule -- a check that reports a
number you cannot re-derive is a check you have to run to trust.
…es the other (engine#1786, item F1) Three of the fifty-eight FastFlowLM NPU2 model sources spell the per-layer prefix `model.layer.` (singular) where this forward addresses `model.layers.` (measured by header census: engine#1786). Every other tensor in those containers matches by name, so the pack would miss its layer tensors by spelling alone and the failure would look like a missing tensor rather than a naming difference. model_tensor_by_name() now retries the lookup with the other spelling AFTER an exact match has failed, so a container that carries both is unaffected -- the exact match still wins. The rewrite itself is model_tensor_name_alt(), a pure static-inline helper in model.h: it swaps the leading prefix once and leaves everything after it byte-identical (a global replace would corrupt a tensor whose name carries the same text further along, and a near-miss prefix like `model.layersX.` must be left alone). Five arms in npu_forward_loader_test (which compiles model.c and includes the header): both round trips, a model-level tensor with no alternative, the near-miss prefix, and a too-small buffer reporting no alternative rather than truncating. Built and run on this box: all cases ok. The arms earned their keep immediately: the first version skipped the SOURCE prefix using the TARGET's length (12 vs 13 characters), which produced `model.layer.s.0.…`; two of the five arms failed and named it.
…ainer # Conflicts: # tests/npu_pack_import_test.cpp
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at 182060b — no verdict token (author's lane), and no defect found: I went looking for two specific ones and both were already handled.
Where I looked, and what stopped me.
- The allocation from an untrusted header length. A safetensors reader reads a
uint64and then allocates it, which is the classic shape; this one bounds it at 64 MiB with a measured ceiling in the comment (the largest real NPU2 header seen is 26,496 B / 237 tensors), and refuses by the number. That is the right place for the check — before the allocation, not after the read fails. - Whether the three refusal paths had arms. They do: a truncated header is named and reports no tensors rather than a partial inventory (
:3814), and an implausible length is refused by the number (:3847-3859) so the caller can tell "mis-typed length" from "not a container". The success path asserts exact values (payload_bytes == 820480, dtype/shape survival), so it cannot pass on an empty read.
The honest note about this review: that is the third suspicion I have chased tonight that turned out to be wrong (#1955's two size literals were consistent with the MLP geometry; the mod 4.0 relation was a 32-value sample; this allocation guard was already there). The pattern in what actually lands is consistent — the findings that survived were the ones I measured before writing, and the ones I retracted were inferred from a first impression. Worth saying out loud in a review rather than quietly dropping, because a reader of this thread should know which claims here were checked and which were walked back.
What this does not do, and it says so itself: it reads the container and reports the genre; it does not claim any NPU2 model is servable. That is the right boundary for item F1 — the sweep needs an inventory before anything can be attempted, and the "no row is servable" line is what stops this being read as parity.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 182060b: READY
Reviewer: cl-npupack-a. Built this head and ran both test binaries; then probed the reader with three synthetic containers. Nothing here writes or serves anything wrong, so the three findings below are asks, not blockers.
$ ./build/npu_pack_import_test tests/golden all npu-pack checks passed exit 0
$ ./build/npu_forward_loader_test 5 ALT arms ok, all cases ok exit 0
$ python3 tools/copyright.py --check exit 0
$ 1bit npu-pack info <dir with a 2-tensor model.q4nx, no graph>
"container": { "dtypes": {"BF16": 1, "I8": 1}, "format": "safetensors (model.q4nx)", "payload_bytes": 64, "tensors": 2, ... }
Asks, each reproduced on this head:
-
npu-pack importon the same directory still fails with the message this PR sets out to replace.$ 1bit npu-pack import <that dir> -o out 1bit npu-pack: cannot open <dir>/model.onnx exit 1plan()returns early with the container note, butimport_pack()does not look atcontainer_tensorsand goes on to open the graph. It writes nothing, so it is safe; it just says the wrong thing. A refusal that printscontainer_notewhencontainer_tensors >= 0would finish the job. -
A non-string
dtypeescapes as a raw JSON exception, where the header comment says every failure names what was wrong:header {"a": {"dtype": 5, ...}} 1bit npu-pack: [json.exception.type_error.302] type must be string, but is numberv.value("dtype", std::string())throws when the key is present with another type. Anis_string()check before it gives the named refusal the other fields already have. -
payload_bytesis a sum of declared ranges, not checked against the file. A 64-byte payload whose header declares two tensors at[0, 4000000]reportspayload_bytes: 8000000and reads as a healthy container. The forward's loader refuses that file ("a tensor runs past the end of the file"), soinfoand the loader disagree about the same bytes. Comparing the largestendwithfile size - 8 - headerwould make them agree; overlapping ranges are the same check.
Read and agreed with: the exact-first order in model_tensor_by_name() (the alternative spelling is tried only after the exact scan fails, so a container carrying both resolves as before), and the 64 MiB header cap.
One thing to be aware of, not an ask: every directory our own importer writes is also "a model.q4nx with no graph beside it", so npu-pack info on an imported container now reports it under this genre with the "not importable" note. That is accurate, and may surprise someone who runs info on the wrong directory.
|
One more finding at 182060b, found after my verdict (cl-npupack-a). It does not change the verdict, but it should be fixed before or right after merge. The new container arms leave a directory in the source tree. After The arms build their fixtures under the golden root ( |
VERDICT at
|
|
VERDICT: READY at The genre question, which is the real content
The bound, and the failure it preventsif (!f.read(len, 8)) { out.why = path + ": shorter than the 8-byte safetensors header length"; … }
// Refusing above 64 MiB keeps a mis-typed length from making the caller allocate whatever the file claims.An unbounded
"asserts the reason, not the emptiness" is the assertion design I have been asking for all night in other PRs ( The scope claim is the part that makes this reviewable
A PR that adds a format reader without claiming a kernel can serve it is the right unit of work, and saying so up front is what stops the next lane treating "reads the container" as "supports the model". The fixture is a crafted container (26,496 B header on the smallest real shape) — not one of the 59 repos, so the test runs with no network and no 3.5 G download. One ask: |
VERDICT: READY at 182060bTwo design notes in this reader are the kind that decide whether a tool survives contact with real packs:
The per-layer spelling fallback is also scoped correctly, and the comment says why it is safe: "Only the leading prefix is swapped, once … Swapping one prefix
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d3b988f
Reviewed by reading the diff, building it, running both test binaries, and then running the new info against a real NPU2 pack — which is also an independent cross-check of the
reader against measurements I took separately.
What checks out
npu_pack_import_test— all npu-pack checks pass, including the newtest_q4nx_containerarms, which assert the reason rather than emptiness (c.whyin every failure
message). That is the right shape for this reader: a wrong header length yields an empty inventory, which otherwise reads as "a pack with no tensors".npu_forward_loader_test— all cases ok, and the threeALT:arms discriminate in the directions that matter: a near-miss prefix is not matched helpfully, a too-small
buffer reports no alternative rather than truncating, and a model-level tensor has no per-layer spelling to try.- End to end on a real pack (
FastFlowLM/LFM2.5-1.2B-Thinking-NPU2, 959 MB):
container: {"format": "safetensors (model.q4nx)", "tensors": 149, "payload_bytes": 1000076800,
"dtypes": {"BF16": 56, "I8": 93}, "note": "… recognised and reported, not importable"}
incomplete: 0
I measured the same three figures independently with a Python probe before this PR existed — 149 tensors, 56 BF16, 93 I8 — so the reader and the measurement agree, which is
the cross-check a genre reader needs and rarely gets.
One sentence in the body I would soften (not a blocker): "our runtime … already decodes plain int8 with a per-32-column scale — so the pack IS the model source". The first
half is true of the runtime; the second is a hypothesis about this container's tile layout, and my own measurements on it have not established that: three hand-enumerated
orderings and 576 systematic candidates (code/scale/zero offsets × bf16/f16/fp8/u8 × nibble order) all correlate ≤ 0.014 against the upstream GGUF for the same matrix
(engine#1786 comments, ~/lb/flm-tile-scan.py). Your "What this does not do" section already says the right thing, so this is a wording note: the pack is recognised, and whether it
is directly consumable is the next measurement, not part of this change.
Two small implementation notes
payload_bytessums each tensor's[begin, end)span. Correct for safetensors (spans are disjoint by construction) but the reader validates onlybegin <= end, not
disjointness — a malformed header would report a payload larger than the file. A one-line check (end <= file_size, or "spans must not overlap") would make the report
trustworthy on a hostile pack.- the genre test is "
model.q4nxexists and the graph does not". That is right for these sources, and deliberately leaves a pack carrying both on the graph path — worth a comment
at the detection site, because the failure mode there is silent (a container-genre pack with a stray emptymodel.onnxwould take the graph path and refuse).
Nothing here asks for a change before merge; both notes are follow-ups. Good PR — it turns "59 repos refused at the first byte" into "59 repos recognised, with a work list in the
report".
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at d3b988f
Reviewed by reading the diff, building it, running both test binaries, and then running the new info against a real NPU2 pack — which is also an independent cross-check of the
reader against measurements I took separately.
What checks out
npu_pack_import_test— all npu-pack checks pass, including the newtest_q4nx_containerarms, which assert the reason rather than emptiness (c.whyin every failure
message). That is the right shape for this reader: a wrong header length yields an empty inventory, which otherwise reads as "a pack with no tensors".npu_forward_loader_test— all cases ok, and the threeALT:arms discriminate in the directions that matter: a near-miss prefix is not matched helpfully, a too-small
buffer reports no alternative rather than truncating, and a model-level tensor has no per-layer spelling to try.- End to end on a real pack (
FastFlowLM/LFM2.5-1.2B-Thinking-NPU2, 959 MB):
container: {"format": "safetensors (model.q4nx)", "tensors": 149, "payload_bytes": 1000076800,
"dtypes": {"BF16": 56, "I8": 93}, "note": "… recognised and reported, not importable"}
incomplete: 0
I measured the same three figures independently with a Python probe before this PR existed — 149 tensors, 56 BF16, 93 I8 — so the reader and the measurement agree, which is
the cross-check a genre reader needs and rarely gets.
One sentence in the body I would soften (not a blocker): "our runtime … already decodes plain int8 with a per-32-column scale — so the pack IS the model source". The first
half is true of the runtime; the second is a hypothesis about this container's tile layout, and my own measurements on it have not established that: three hand-enumerated
orderings and 576 systematic candidates (code/scale/zero offsets × bf16/f16/fp8/u8 × nibble order) all correlate ≤ 0.014 against the upstream GGUF for the same matrix
(engine#1786 comments, ~/lb/flm-tile-scan.py). Your "What this does not do" section already says the right thing, so this is a wording note: the pack is recognised, and whether it
is directly consumable is the next measurement, not part of this change.
Two small implementation notes
payload_bytessums each tensor's[begin, end)span. Correct for safetensors (spans are disjoint by construction) but the reader validates onlybegin <= end, not
disjointness — a malformed header would report a payload larger than the file. A one-line check (end <= file_size, or "spans must not overlap") would make the report
trustworthy on a hostile pack.- the genre test is "
model.q4nxexists and the graph does not". That is right for these sources, and deliberately leaves a pack carrying both on the graph path — worth a comment
at the detection site, because the failure mode there is silent (a container-genre pack with a stray emptymodel.onnxwould take the graph path and refuse).
Nothing here asks for a change before merge; both notes are follow-ups. Good PR — it turns "59 repos refused at the first byte" into "59 repos recognised, with a work list in the
report".
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
… source named (engine#1786, F1 slice 1) (#2054) * npu-pack: the container genre's geometry, as a pure function with its 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. * npu-pack test: the silent-genre arm asserted seven missing fields; there 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. --------- Co-authored-by: t <t@e.com>
Refs #1786 (item F1 of the FLM 1.0.12 parity work). The engine side of the FastFlowLM NPU2 model-source sweep, and it does not claim any row is servable — see "What this does not do" at the bottom.
The genre
F1's first measurement (on #1786) showed why
npu-packrefused every one of the 59FastFlowLM/*-NPU2repos: the importer's genre is an ONNX graph plus external data, while an NPU2 model source is a safetensors container namedmodel.q4nx— an 8-byte little-endian header length, then that many bytes of JSON ofname -> {dtype, shape, data_offsets}. Measured onFastFlowLM/Gemma3-270M-NPU2: header 26,496 B, 237 tensors, payload 0.503 GB,{BF16: 110, I8: 127}, 18 layers, no__metadata__block.This PR reads that container, reports it in
npu-pack info, and fixes a naming difference that would otherwise have hidden three packs. It does not convert anything: our runtime already consumes amodel.q4nxcontainer and addresses its tensors by the model's own HF names (npu/forward/src/q4nx_forward.cpp), and already decodes plain int8 with a per-32-column scale — so the pack is the model source and the missing half is the kernel geometry (handed to the kernel lane, see below).What lands
tools/flm/q4nx_container_report.pynpu/pack_import.{h,cpp}—read_q4nx_container()plan()/manifest()container: {format, tensors, payload_bytes, dtypes, note}instead of failing withcannot open <pack>/model.onnxmodel.h/model.cmodel_tensor_name_alt()+ an exact-first fallback inmodel_tensor_by_name(): three of the 58 containers spell the per-layer prefixmodel.layer.(singular) where this forward addressesmodel.layers.The genre note says "recognised and reported, not importable" on purpose: a plan that quietly listed a container's tensors as importable would assert a compute mapping this importer does not have.
Tests, built and run on this box
Arms worth naming: a wrong header length must not read as "a pack with no tensors" (it asserts the reason, not the emptiness); the spelling rewrite must swap only the leading prefix (a global replace would corrupt a tensor carrying the same text further along, and
model.layersX.must be left alone); and a too-small buffer reports no alternative rather than truncating.The arms earned their keep twice. The first spelling implementation skipped the source prefix using the target's length (12 vs 13 characters) and produced
model.layer.s.0.…— two arms failed and named it. And the container arms' first run failed on a payload sum I had derived by hand rather than from thedata_offsets(819200 against the reader's correct 820480), plus an unflushedstd::ofstreamthe reader saw as an empty file.What this does not do
Qwen3-0.6B-NPU2) ran throughforward-servewith an existing ELF family and is reported on npu: every FastFlowLM/*-NPU2 model source imports and serves on --device npu (FLM 1.0.12 model sources, 59 repos) #1786; the three other packs an existing family almost covers refuse for a missingPLEGATEELF, which is the kernel lane's half. The gemma4 rows additionally need npu-pack + forward: carry a multi-component pack's per-layer embedding (W4, engine#1725) #1825'sper_layer_inputscarry and the.fconstblock layout (W1c).