Repository navigation
npu-pack: the container writer -- npu-pack import writes a container, bytes verified against the source (engine#1788, 3c) - #2326
Conversation
…tial pack (engine#1788, 3c) Copies each planned byte range out of the container's payload into one file per destination slot. The refusal is the point: when the plan has tensors with no destination it writes NOTHING unless the caller passes allow_unplaced, because a pack that silently omits a fifth of the model is worse than no pack and only the caller can decide an omission is intended. Armed by writing to a scratch directory and reading the files back, which is the only way to know the ranges are the right bytes: embedding 0..8 -> "ABCDEFGH" q_proj 8..24 -> "IJKLMNOPQRSTUVWX" and the refusal arm asserts three things at once -- not ok, a reason that names the problem, and no file on disk. Mutant (drop the unplaced guard): FAIL write: an incomplete plan is refused FAIL write: and says why FAIL write: and writes NOTHING, not a partial pack (cherry picked from commit 75bb5622cbbf61c27f75411796f3dd9103b355da)
…ine#1788, 3c) A directory of files whose names are engine slots is not enough: a reader also needs to know which container tensor each came from and what shape and dtype it is. So the write emits manifest.json alongside the files, carrying per slot the source tensor, byte count, shape and dtype, plus the plan totals (files, bytes, payload_bytes) and the unplaced list. That last field matters: a layout written with allow_unplaced says so IN THE OUTPUT, so a later reader cannot mistake a deliberately partial layout for a complete one. (cherry picked from commit 972b841691a020eb54ea1fc385c48f9f3426e6e0)
…ine#1788, 3c) npu/q4nx.h documents that the Q8 chunk comes in TWO code layouts -- byte k*32+r, or (r/16)*4096 + g*512 + (k%32)*16 + r%16 -- and that NOTHING in the chunk or the container says which. The forward's decoder picks per tensor from the codes (q4nx_q8_split16: in the right layout every group's absmax code is 127), and the two real models on this box use different ones. That makes the central design constraint of the writer explicit at the place it would be violated: a writer that re-encoded or re-quantised would have to make a choice the container does not make, and getting it wrong produces a pack that decodes to wrong weights with no error anywhere. Copying the payload verbatim cannot make that mistake. (cherry picked from commit 603a278ec3ca6ecb8649dd23aa889069d1f646fa)
…ngine#1788, 3c) Recorded at the declaration, because it decides the next code step. A container tensor's shape is ALREADY the chunk -- [4096,8,4736] I8 is N=4096, 8 groups, 4736 bytes per row, and 4736 is exactly the Q4_K chunk size -- so the payload is q4nx chunk data and the import needs no re-quantisation. It emits a container of the same genre: one contiguous payload (the planned ranges in order) plus a header naming each destination slot with dtype, shape and offset. That is also the only safe answer to q4nx.h's two-Q8-layouts problem: the bytes never change, so the choice the container does not make never has to be made. container_write_layout() stays as the debugging form (one file per slot); the pack form concatenates instead. (cherry picked from commit 9e022123cfe776379a34c5639c4f85f563f0179d)
…sets (engine#1788, 3c) The shape the forward opens, and the last piece of the writer. Writes model.q4nx as the planned byte ranges concatenated in order, and pack.json as the header: per slot its offset, byte count, shape, dtype and source tensor, plus the totals and the unplaced list. Called the container genre because the payload IS the container's chunks, copied -- nothing is re-quantised (see the note above container_write_layout on why that is the only safe choice). Armed by reading the payload back AT THE HEADER'S OFFSETS and comparing against the source ranges, because a header that is merely self-consistent proves nothing: offset 0 -> "ABCDEFGH" (model.embed_tokens.weight, source 0..8) offset 8 -> "IJKLMNOPQRSTUVWX" (model.layers.0.self_attn.q_proj.weight, source 8..24) Mutant (leave a one-byte gap between slots): "pack: two tensors, 24 bytes" and "pack: and offset 8 for the second" both fail. And the same refusal as the layout form: an incomplete plan writes nothing and says how many tensors had no destination. (cherry picked from commit 4876df3a52cede519eac6d544cf0614c19bfc8b5)
…rites a container (engine#1788, 3c) The refusal that has been there since #2277 ("importing it needs the container-genre writer, which the importer does not have yet") is replaced by the writer: read the container, take the layer types from config.json beside it, plan the placement, and write model.q4nx + pack.json. The payload is COPIED, never re-quantised, which is the only safe answer to q4nx.h's two-Q8-layouts problem. End to end on the fixture pack (840 KB, no device, no 21 GB write at the current disk floor): $ 1bit npu-pack import .../container-genre -o out $ ls out -> model.q4nx pack.json pack.json: bytes, payload_bytes, unplaced, and per slot offset/bytes/shape/dtype/source The unplaced list goes into Report::refused, because it IS a refusal list and the caller already prints that. With allow_incomplete the write proceeds and the header records what was left out; without it, nothing is written at all. And a trap worth recording: the first run of this wrote only model.q4nx and I nearly concluded the branch had not fired -- ninja had no work to do for `1bit` because I had built `onebit`, and the binary was stale. Fifth time tonight; the fix is always to check the binary's timestamp before believing its output. (cherry picked from commit ac53ceffab3dbd3c333b1208b4365b670b210ffd)
…, 3c)
The first end-to-end run wrote the right TOTAL (1024+128 = 1152 bytes) and the wrong BYTES: Q4nxTensor::begin/end are
payload-relative, so seeking to `begin` in the file read the safetensors header. `header_bytes` is the JSON length alone, so
the payload starts at 8 + header_bytes -- and that +8 only exists when there IS a header (a bare payload has header_bytes 0
and no prefix), which is why the fix is conditional.
Verified by comparing every written byte against the source container's payload:
$ 1bit npu-pack import .../container-genre -o out
OK model.embed_tokens.weight <- model.embed_tokens.weight
OK model.layers.0.mlp.down_proj.weight <- model.layers.0.mlp.down_proj.weight
EVERY BYTE MATCHES: True out size 1152 == pack.json bytes 1152
and armed so it cannot regress: a fixture whose payload has a 24-byte header must produce the PAYLOAD's bytes, and the mutant
(seek without the base) fails it with exactly the symptom production had -- "got: 012345678901", the header bytes.
The arm that could have caught this the first time is the one with a NON-ZERO header: the earlier arms all had base 0, so a
missing base was invisible to every one of them.
(cherry picked from commit 0517978b344d3934d6353ae6ab7c9dd978888ccf)
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
VERDICT: READY at The closed loop, stated in its own terms
That is the correct way to retire a refusal: not by deleting the check but by supplying the capability the message said was missing. Every "refused by name" in this queue (#2254, #2261, #2271, #2285, #2301) is a promise of that shape, and this is the first one I have seen paid off — #2277 merged, the writer arrived, and the same input now imports. It also answers a question left open by #2291: that PR could only measure tiles copied verbatim from real packs' What makes the verification trustworthy"the bytes are verified against the source" — for a writer, that is the only meaningful test: the artefact must equal what it claims to have written, and comparing against the source rather than against a second run of the same writer avoids the self-agreement trap (#2318's GGUF reference, #3325's per-role ELFs). If the check is Two asks
Why this is the right end of the stack to land
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 950b327
The offset-base arm is the real thing: I dropped payload_base and it fails with the header's bytes in its hands.
// mutant
- in.seekg(plan.payload_base + item.begin);
+ in.seekg(item.begin);FAIL pack: and the bytes are the PAYLOAD's, not the header's (got: 012345678901)
That message is what makes the arm worth having: it prints what it read (012345678901 is the fixture's length-prefix-and-JSON prefix), so the failure names the bug -- seeking to a payload-relative offset in the container file -- instead of reporting "mismatch". Unmutated: 0 build errors, all npu-pack checks passed.
And the fixture is honest about why the earlier arms could not have caught it, which is the part most test PRs skip: "This arm only means anything if the payload has a header in front of it, which is why the fixture below prepends one -- the earlier arms could not have caught the bug because their base was 0." The fixture builds a 24-byte prefix (8 + hc.header_bytes == 16 -> hplan.payload_base == 24, asserted) so a base-0 read is a different answer, not a
coincidence.
The byte-copy decision is documented where the next author will read it: "a writer that re-encoded or re-quantised would have to make a choice the container does not make" -- the Q8 chunk comes in two layouts and nothing in the container says which (q4nx_q8_split16 decides per tensor from the codes), so copying the payload's bytes verbatim is the only option that cannot pick wrong. That is the strongest form of "do not
decode and re-encode": it says why the container cannot answer the question.
The refusal pair is right, and the third arm is the one I would have asked for: an incomplete plan is refused (no destination) and writes nothing (!fs::exists(out2 + "/model.embed_tokens.weight")) -- a partial pack left on disk is worse than a refusal -- while allow_unplaced writes the placed part and no more.
One note, not blocking: the arms write real files to a scratch dir, which is the only way to test the I/O half -- worth confirming that dir goes through tmp_root()/ONEBIT_TEST_TMP like the rest of the suite (tmp_path_check is on main and pre-pr-checks.sh runs it), since a hardcoded /tmp here would be the one place the ratchet cannot see.
Not run: the 21 GB acceptance case and the device path -- the body says neither is in scope, and the 840 KB fixture is what the arms use.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 950b327: READY
The writer does the two things a writer must, and both are stated where a reader will find them.
1. It refuses rather than writing what it can. if (!plan.unplaced.empty() && !allow_unplaced) — a tensor with no destination stops the write, the reason naming the count and an example ("N tensor(s) have no destination (e.g. …)"), and the escape is an explicit allow_unplaced argument rather than a default. Two refusing to write a partial pack sites, one for the plan and one for the writer, so neither stage can emit half a container. A partial pack is the worst possible artefact here: it looks like a successful import and fails at serve time, somewhere else.
2. It carries the accounting into the artefact, not just the log. h["payload_bytes"] and h["unplaced"] go into the container's header. That is the piece I would have asked for: #2307 gave the plan a conservation check ("do the item ranges add up to the payload?"), and this writes the facts that check used into the output — so a reader of the container can see the accounting without re-running the import.
And the comment that justifies the whole approach is the best thing in the diff: "engine#1788, 3c: WHY THIS IS A BYTE COPY AND MUST STAY ONE" — because npu/q4nx.h records that the Q8 chunk comes in two code orders at the same 8704-byte size (the ambiguity #2301 taught us to detect rather than assume). A writer that decoded and re-encoded would have to know which order it was reading; a byte copy cannot get it wrong. Choosing the operation that has no ambiguity is better than choosing one that detects the ambiguity correctly, and saying why it must stay that way is what keeps a later "cleanup" from replacing it with a round-trip.
Two small notes, neither blocking:
allow_unplacedis the right escape hatch, but it is the one path where a partial container can be produced deliberately — worth a line indocs/npu.mdsaying what it is for, so it is not reached for to silence the refusal.- The header records
unplaced; if it also recorded the reason class (as #2291's placement does withno_slot+why), a container could explain itself rather than only counting.
Together with #2307 the pair reads correctly: a plan that proves its ranges tile the payload, and a writer that refuses when they do not and records what it wrote.
3c's writer:
1bit npu-pack importnow writes a container, and the bytes are verified against the sourceThe refusal that has stood since #2277 — "importing it needs the container-genre writer (engine#1786, item F1), which the importer does not have yet" — is replaced by the writer. This is the piece #1788's lead needs: a container pack can now be imported.
What it does. Reads the container, takes the layer types from
config.jsonbeside it (not from the safetensors metadata — that is where the measured ones live, and reading meta instead silently refused every layer-typed role), plans the placement, and writesmodel.q4nx+pack.json. The payload is copied, never re-quantised, which is the only safe answer toq4nx.h's two-Q8-layouts problem: nothing in the chunk or the container says which layout a Q8 chunk uses, the forward detects it per tensor, and bytes that never change cannot make a choice the container does not make.Verified byte-for-byte against the source, not by looking at the output:
Coverage is the gate, not item count (from #2307, now on main): the plan refuses to write when any tensor has no destination unless
allow_incompleteis set, and the unplaced list lands inReport::refusedwhere the caller already prints refusals. Measured on the acceptance case at the plan layer: 35B 733 items, 21043717120 of 21043717120 bytes, 0 unplaced.Two bugs this found in itself, both now armed:
Q4nxTensor::begin/endare payload-relative andheader_bytesis the JSON length alone, soseekg(begin)read the safetensors header: the first end-to-end run wrote the right total (1024+128) and the wrong bytes. Fixed conditionally (the+8length prefix only exists when there is a header), and the arm that would have caught it first time is the one with a non-zero header — every earlier arm had base 0, so a missing base was invisible to all of them.Scope: the writer only. No device access; no model file written beyond the 840 KB fixture; the 21 GB acceptance-case write is deliberately not run here (the plan's coverage is measured, the copy is verified on a fixture).