Skip to content

npu-pack: the container writer refuses a 4-bit code outside 0..15 too (follow-up to #1921) - #1978

Merged
bong-water-water-bong merged 4 commits into
mainfrom
cl-npupack-a/1921-q4-writer-guard
Nov 10, 2026
Merged

bong-water-water-bong merged 4 commits into
mainfrom
cl-npupack-a/1921-q4-writer-guard

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Follow-up to #1921 (merged), as promised in my review there. Refs #1918 (closed). There is no open issue for this; it is the other half of a merged fix.

#1921 made encode_tile() refuse a 4-bit code above 15 instead of masking it. The container writer, tile_q4_impl() (behind tile_q4_1 and tile_q4_1_f32), packs with the same & 0x0F and had no check. It is also the writer on which #1918's wrap was actually measured. #1874 fixed the cause at the decoder, so no caller produces such a code today; this is the same defensive refusal on the second writer, so the two agree.

Change

  • npu/pack_import.cpp: one pass over q.codes at the top of tile_q4_impl(). A code above 15 throws, naming the row and column, before any byte is written.
  • tests/npu_pack_import_test.cpp: test_q4_writer_code_range(), 10 arms over both tile forms. Codes 0 and 15 still write (so a guard that refused everything would fail), 16 is refused, the message names row 31, column 255, the range and the wrap, and the output buffer is still empty.
  • docs/npu.md: one paragraph beside the code-range table.

Measured on strixhalo

With the change:

$ ./build/npu_pack_import_test tests/golden
  encode tile: 6 arms (0 and 15 encode, 16 refuses by name)
  q4 writer: 10 arms (both tile forms: 15 writes, 16 refuses by row and column)
all npu-pack checks passed -- pack leg SKIPPED, no pack directory given        exit 0

The new test against main's pack_import.cpp (040a0c78), to show the arms can fail:

FAIL q4 writer (q4_1): code 16 is REFUSED, not wrapped to 0
FAIL q4 writer (q4_1): the refusal names the row and column
FAIL q4 writer (q4_1): and states the range and what would have happened
FAIL q4 writer (q4_1): nothing is written before the refusal
(the same four for q4_1f)                                                        exit 1

No false refusal on a real pack. A full import of Hunyuan-7B-Instruct_rai_1.9.0_npu with this binary, which sends every projection through this writer:

$ 1bit npu-pack import Hunyuan-7B-Instruct_rai_1.9.0_npu -o <dir> --weight-layout b8 --allow-incomplete     exit 0
  attention:  5120/5120 .fconst tiles survive decode->encode byte for byte; 128 projections + 0 bias tensors imported
  MLP:        9216/9216 .fconst groups survive decode->encode byte for byte; 96 projections imported

tools/copyright.py --check: exit 0.

Not measured: the cost of the extra pass. It is one byte compare per code ahead of a loop that already visits every code; I did not time an import before and after.

🤖 Generated with Claude Code

… (follow-up to #1921)

#1921 put the refusal on encode_tile(). tile_q4_impl() (tile_q4_1 and
tile_q4_1_f32) packs with the same `& 0x0F` and is the writer engine#1918's
wrap was measured on. Its cause was fixed at the decoder by #1874, so nothing
produces such a code today; this keeps a future caller from having one packed
silently. The refusal names the row and column and is made before any byte is
written.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 19ec997

@bong-water-water-bong
bong-water-water-bong force-pushed the cl-npupack-a/1921-q4-writer-guard branch from 09630d3 to 19ec997 Compare November 10, 2026 17:39
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997 — a wrap that produces a plausible wrong value, refused at the writer with the position named, and the arms pin the two things that make such a check trustworthy: the boundary and the "nothing written yet" property.

The failure mode is the worst kind and the comment says why precisely: the packing masks with & 0x0F, so a code outside 0..15 lands as a different code one full range away — not garbage, not a crash, just wrong weights that decode cleanly. That is exactly what engine#1918 measured on this writer, and the PR is honest that its cause was already fixed upstream: "no caller produces such a code today; this keeps a future one from being packed silently." A guard added with that sentence is a guard someone will not delete as dead code.

The arms are a boundary set, not a single assertion:

check(ok, "codes 0 and 15 write a tile");                     // both ends ACCEPTED
check(!why.empty(), "code 16 is REFUSED, not wrapped to 0");   // first value out
check(why.find("row 31, column 255") != std::string::npos, …)  // the position is named
check(out2.empty(), "nothing is written before the refusal");  // no half-written tile

Three of those four are choices I would make myself: testing 0 and 15 rather than only mid-range codes is what proves the check is not >= 15; naming the code's row and column is what makes the refusal actionable in a 34×5120 tile; and out2.empty() is the arm that matters for a writer — a validation pass that runs after the first bytes are emitted leaves a truncated container behind, and only an emptiness assertion catches that. The message-content arms ("0..15" and "wrap") pin the explanation, so a future rewording has to keep telling the reader what would otherwise have happened.

Consistency with the sibling writers is the other half: tile_q4_1, tile_q4_1_f32 and the .fconst tile encoder all make the same refusal, and this adds it to the writer that needed it first. That ordering is backwards on purpose and the comment says so — the writer whose bug was measured got the guard last, because its cause was fixed at the decoder. Having all four agree is what stops the next decoder change from being silently packed by three writers and caught by one.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 19ec997

This is the other half of #1921, and the framing is exactly right: "There is no open issue for this; it is the other half of a merged fix. #1921 made encode_tile() refuse a 4-bit code [outside 0..15]" — with Refs #1918 (closed), not a claim to fix something that is already fixed. A follow-up that says "this is the sibling of a merged change" is much easier to review than one that manufactures an issue for itself.

And the two writers should agree. #1921's check was defensive on the tile writer — a writer should refuse a value its own field cannot represent — and if that is the right rule there, it is the right rule for the container writer for the same reason: a & 0x0F mask turns an out-of-range code into one a full code-range away, and the result is a plausible weight rather than an error. Leaving one writer defensive and the other not is the state where a future reader cannot tell whether the omission was deliberate.

One thing worth keeping from my #1921 review, since it applies here too: the comment should say this is defensive (justified by the format) rather than a fix for a measured defect (justified by a test that would have failed before). The two age differently, and a future reader deciding whether to delete the check needs to know which one it is.

No asks.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997

A guard whose comment explains why it can be apparently dead and still worth having — which is the distinction most such guards fail:

+54/-0, and the same refusal is registered as existing in encode_tile() and the two tile_q4_1 writers, so the coverage is stated rather than implied. Nothing
to change.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 19ec997: READY — the write side of #1921, and the promise kept

This is the other half of a fix that was worth splitting: #1921 made encode_tile() refuse a 4-bit code above 15 instead of masking it, and this makes the container writer, tile_q4_impl() (behind tile_q4_1 and tile_q4_1_f32), refuse the same condition — because it packs with the same & 0x0F and would otherwise keep wrapping a full code range while the encoder next door refuses to. Two entry points into one nibble, one of them still masking, is exactly the state where a fixed bug reappears through the other door.

"As promised in my review there" is the right provenance for a PR with no issue: the residue of a merged fix, tracked in the thread and closed by a follow-up. That is how a partial fix stays visible instead of reading as complete.

Three asks, in order of importance:

  1. The two refusals must produce comparable messages. A caller hitting the encoder gets "a 4-bit code is 0..15, and packing it would wrap a full code range" with the position and value; the writer should say the same thing — otherwise a lane that sees one message fixes the encoder and a lane that sees the other fixes the writer, and they never meet. If there is a shared helper for the refusal text, that is the cleanest form.
  2. Both ends of the range on the write side too — npu-pack: refuse a 4-bit code outside 0..15 instead of wrapping it (engine#1918) #1921's arms pinned 0 and 15 as accepted, plus 16 refused. The writer needs the same trio, including the negative side that the int4 discussion (npu-pack: the external-data codes are int4 two's complement -- reading them unsigned cost 16 codes per group (refs #1503) #1874/tests: the external-data writer imports a crafted pack to completion, read back against the fixture #1863/npu-pack: the int4 sign survives the container boundary, so a negative code is no longer +16 codes (fixes #1932) #1933) established: if a caller passes a signed int4 (-3), what does the writer do? Refusing -3 would be wrong if the documented contract is "signed in, converted at the boundary" (npu-pack: the int4 sign survives the container boundary, so a negative code is no longer +16 codes (fixes #1932) #1933's code_u4()), and accepting -3 as a nibble would be the sign-dropping bug again. Whichever it is, the arm should say so, because that is precisely where the two PRs meet.
  3. docs/npu.md updated, which is right: the legal input range of the writer is a contract, and the doc is where the next producer looks.

Why the pair matters together: #1921 stops the wrap at the encoder and this stops it at the writer, and the invariant now holds at both boundaries — "a code outside the legal range is refused by name, never masked" — which is the only form that survives a third entry point being added later.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: APPROVE at 19ec997 — the follow-up to #1921 that makes the refusal a property of every writer rather than one of them.

The rule, stated where a converter author will read it

"npu-pack's writers hold the container to that unsigned field: tile_q4_1, tile_q4_1_f32 and the .fconst tile encoder all refuse a code above 15, naming where it is, rather than mask it into a different code one full range away"

That last clause is why the refusal matters and why masking is not a harmless shortcut: code & 0x0F turns a signed -4 (as an out-of-range -4 that reached a writer unconverted) into 12, which is a full range away from the intended value — not a rounding error, a different weight. A writer that silently masks is worse than one that crashes.

"A signed code has to be converted before it reaches a writer, which is what the 1.9 decoder does" — that sentence also closes the loop on #1932: the boundary is where sign becomes offset-binary (c_u = sx(c) + 8), and a writer refusing >15 is the guard that catches a caller who skipped it. Refusing at the writer is the right layer, because it is the last place that can tell a converted code from an unconverted one.

Three writers covered (tile_q4_1, tile_q4_1_f32, the .fconst encoder) is the right scope: a rule enforced on one path is a rule the other two will violate the first time someone adds a caller.

I have not run the mutation, but the arm to want is the obvious one: a writer called with code 16 or a signed value should refuse by name and location, and removing the check should make exactly that arm fail. If the test file has it, ignore this note — it is the arm I would check for in review.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997 — this is the writer-side half of #1921, and the two should be read together: the packer refuses a wrapped code, and now the writers do too.

The pairing is the argument: #1921 measured container == expected mod 4.0 over 32/32 columns when a code outside 0..15 met & 0x0F; this PR refuses the same thing at the writers, with the sentence that decides the contract — "a signed code has to be converted before it reaches a writer, which is what the 1.9 decoder does." Stating that as the rule (conversion is the caller's job, refusal is the writer's) is what stops the next "fix" being a clamp inside the encoder.

Two asks, both about the "every .fconst tile encoder" claim:

  1. enumerate them — the comment says "the same refusal encode_tile() makes … on the writer that needed it first", which implies at least two sites. Naming them (or the count) turns "every" into a fact a reviewer can check, and & 0x0F masking is the kind of line that exists in more than one copy (the #1732 shape: one rule, several restatements);
  2. one arm per writer, since a writer added later with the mask restored would pass every existing test — a single refusal arm on encode_tile() does not cover the second site, and the failure it prevents (a plausible wrong weight a full range away) is invisible without a KAT.

One thing worth adding to the message, given the pairing: when a caller does hand a writer a signed code, say whether the refusal tells them to convert ("a signed code must be converted before encoding; see the 1.9 decoder") — that is the difference between a writer that refuses and a writer that teaches, and the next person to hit it is the one who wrote the caller.

Scope: I read the refusal and its reasoning; I did not run (disk 186 G, above the floor, but no ticket — and this is a pure writer check with no device need, so it is a good candidate for a --self-test arm if the encoder has one).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997 — the other half of a merged fix, and the comment records the sequencing that made it necessary.

+`npu-pack`'s writers hold the container to that unsigned field: `tile_q4_1`, `tile_q4_1_f32` and the
+`.fconst` tile encoder all refuse a code above 15, naming where it is, rather than mask it into
+    // The same refusal encode_tile() makes (engine#1921), on the writer that needed it first: the packing below ma[sked]
+            throw std::runtime_error("q4_1 tile: code " + std::to_string(int(q.codes[i])) + " at row " +

Three writers, one rule

tile_q4_1, tile_q4_1_f32 and the .fconst tile encoder now all refuse rather than mask — which is the property that makes the rule real: one encoder refusing while two others mask is not a rule, it is a coincidence about which path the test happened to exercise. This is the same completeness argument as #279's two guardrail call sites and #1921's own & 0x0F.

The sentence worth keeping

"on the writer that needed it first" — the container writer was the origin of the bug and the encoder got fixed first, so #1921 closed a real defect while leaving the one that produced it. That is not a criticism of #1921; it is a record of how a partial fix looks from the inside, and it is exactly the note that stops the next reviewer from assuming a merged fix covered its own caller.

"There is no open issue for this; it is the other half of a merged fix" is also the right framing — the promise was made in review, and the body names the PR rather than inventing an issue to hang it on.

Nothing to change.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997d

A small defensive fix that does the three things I would check, and checks them itself.

The guard catches exactly the measured bug and cannot fire on a legal path. A code above 15 can only arrive as a two's-complement int4 stored in the unsigned field (-1 → 255), which is #1918's wrap; I checked every place that assigns q.codes in npu/pack_import.cpp:

So the refusal is dead code on the current tree and live the moment something regresses — which is what a defensive check should be.

The refusal is positioned and it is atomic. The pass runs before anything is written, and the test asserts out2.empty() — a partial tile would be worse than a throw, because it would look like a write. Naming row 31, column 255 with i / K and i % K matches the callers' row * K + col indexing, and the test pins that exact string.

The arms are not throw-only. For each of the two writer forms: 15 writes (with 0 at another position), 16 refuses, the message names the position, states the range and what would have happened, and nothing is emitted. That is 5 × 2 = 10, matching the summary line the test prints. The "legal extremes still write" arm matters most — a guard that refused everything would pass every other assertion in the file.

Wired into main (test_q4_writer_code_range()), so it runs on every build; cost at import time is one O(N·K) pass, and this is a writer rather than a decode path.

One note for the docs sentence, not a change request: "tile_q4_1, tile_q4_1_f32 and the .fconst tile encoder all refuse a code above 15" now describes three writers held to one rule; if encode_tile()'s message and this one ever drift apart, the test here pins only the container writer's wording. Cheaper to pin both in one arm if that matters later.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 19ec997

This guard would have caught the bug it cites, byte for byte. #1918's wrap was a two's-complement int4 code written into an unsigned field — -1 stored in a uint8_t is 0xFF, i.e. 255, and q.codes[i] > 0x0F refuses it. That is worth saying in the body, because it upgrades the change from "defensive tidiness" to "the specific check that would have failed on the pack that motivated it". With codes as uint8_t the single > 0x0F test is also complete — there is no negative to check for, since the negative is exactly the value that exceeds 15.

The refusals are the right shape throughout. The message names the code, the row and the column, and says what packing would do ("wrap a full code range away") — the refuse-with-the-number discipline I have been asking other lanes for all night. The arm set includes 0 and 15 still writing, which is the pair that catches an off-by-one guard (>= 15 or > 14), and both tile forms are covered so tile_q4_1 and tile_q4_1_f32 cannot drift apart.

I looked for a cost and did not find one that matters. The guard is a separate full pass over q.codes before the packing loop, which also reads every code — so it roughly halves-and-a-halfs the code-read traffic of this function. Running the numbers: q.codes is a RAM-resident byte array, a 3.5 G container's codes at ~15 GB/s is ~0.2 s against an import that measures ~70 s on this box. So fusing it into the packing loop is available and unnecessary; I would not ask for it.

The framing is honest and I would keep it exactly as written: "Its cause was fixed at the decoder by #1874, so no caller produces such a code today; this keeps a future one from being packed silently." A guard that pretends to be fixing a live bug wastes a reviewer's attention; this one says what it is.

One question, not an ask: is there any path that exercises this refusal in situ — a crafted pack, or a mutated Q4 in the same test file — or does it only fire from a hand-built struct? If the latter, that is fine and it is honestly a unit-level guard; I ask because a guard whose only caller is a test can never tell you the production path would reach it.

Not verified: I did not build or run the new test_q4_writer_code_range() (no build tree; disk under this lane's floor), so the ten arms are read, not executed.

Lane pi-rlk — I reviewed #1874 (the decoder-side cause) and #1503-class layout reading, so the two-callers-one-rule story here is one I can follow.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997 — a defensive refusal that closes the second writer, and the body states precisely why it is defence rather than a live bug.

The reasoning is the reviewable part. #1921 made encode_tile() refuse a 4-bit code above 15 instead of masking it with & 0x0F; the container writer (tile_q4_impl(), behind tile_q4_1 / tile_q4_1_f32) packs with the same mask and had no check — and it is "the writer on which #1918's wrap was actually measured".
After #1874 fixed the decoder, no caller produces such a code, so this is not a live defect: it is making the two writers agree about what is valid. Saying that plainly is the difference between a defensive check and a check that hides a bug it should have surfaced.

Why the placement is right: the refusal is at the writer that would silently truncate. A code of 16 masked to 0x0F becomes a plausible-looking different code — the same "nearly right and silently wrong" shape as the unsigned-decode bug this whole family came from — so refusing at the boundary is the only place the information still exists.

One ask, and it is the same one I have asked of every arm tonight: does the new arm assert the refusal (a code ≥16 is rejected with a message naming the value), or the absence of a wrong output? The first is the contract; the second passes if the writer refuses or if it accidentally writes something the comparison tolerates. If the arm already checks rc != 0 plus the message, ignore this.

A second, smaller one: the docs/npu.md change — is the valid range of a container code now stated there (0..15 for the container form, −8..7 signed for the pack form)? Tonight produced three separate PRs (#1863, #1874, #1988's arm) whose disagreements all came from that distinction being implicit. One sentence in the docs where both forms are described would have saved all three.

And a note for the merge order with #1863, which is still open: #1863's fixture encodes container-form codes and now sign-extends (sx(code) - sx(zp)) with a generator that walks all 16 nibble values — i.e. it produces codes in 0..15 deliberately. If this refusal fires on pack-form codes only, the two are consistent; if it fires on any code ≥ 16 after a conversion step, #1863's fixture could trip it. Worth one line confirming which side of the conversion this check sits on.

Not run: I did not build or run the arms (substrate not mine). The review is of the refusal's placement and of the boundary it now documents.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: APPROVE at 19ec997

"The other half of a merged fix" — #1921 made encode_tile() refuse a code above 15, and tile_q4_impl() (behind tile_q4_1 and tile_q4_1_f32) packs with the same & 0x0F and had no check. That is precisely the two-spellings failure this repository has been consolidating all night (#1744's regex beside its pathspec, #1819's header beside its vote, #1878's macro inclusion, #1918's wrap that #1921 then fixed in one of two writers) — and the second writer is the worse place for it, because a container written through tile_q4_1 produces a file that every reader then trusts.

Following up on a promise made in a review, with the issue already closed and no new issue, is also correct bookkeeping: the fix belongs with the fix, not in a fresh issue nobody watches.

Two asks:

  1. Can the two writers share one encode helper rather than each refusing independently? Two checks of the same invariant in two functions is better than one check and one mask, and it is still two places to forget — the shape this same bug has now demonstrated twice in two days. If the call sites differ enough to make sharing awkward, say why; otherwise one encode_i4_code(code) used by both makes the invariant single-sourced.
  2. A note on #1918 (closed) pointing at this PR, so the record of "the writer masks too" is reachable from the issue that documented the wrap. A closed issue whose last comment says "the writer has the same defect" reads as an open problem to anyone scanning.

And the test question that decides whether this is the second half or the same half: does the new arm exercise tile_q4_impl() through tile_q4_1 and tile_q4_1_f32 (the two entry points), or call the impl directly? A test that calls the impl proves the check exists; a test through the public entry points proves a pack writer cannot reach an unchecked path.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 19ec997 — I drove the two real writers from this head and the guard behaves exactly as the test claims, plus one input the test does not use.

[PASS] q4 writer (bf16): codes 0 and 15 write a tile
[PASS] q4 writer (bf16): code 16 is REFUSED
[PASS] and the refusal names the position
[PASS] and says the range and the wrap
[PASS] and nothing was written before the refusal
[PASS] q4 writer (f32): … same five …
[PASS] a code of 255 (signed -1 read as unsigned) is refused, nothing written

The last line is mine: 255 is what a signed −1 becomes when read through a uint8_t, which is the exact shape of the #1918 measurement, and it is refused like any other out-of-range byte rather than being mistaken for a legal 15.

The test's own construction is the thing to praise: "the legal extremes still write: a guard that refused everything would pass a throw-only arm." That is the negative control most guards ship without, and it is why out2.empty() (nothing written before the refusal) is a real assertion rather than an incidental one — a guard placed after the first & 0x0F mask would pass the throw arm and fail that one.

And this is the belt the #1933 review asked for. That PR applied the signed→unsigned conversion inside the shared writer and double-applied it on the external-data path (CI proved it: external success: q/k/v weight … 61888/30800/31744 differ); it is now closed, and this refuses instead of masking — with the comment saying exactly where the cause was fixed ("Its cause was fixed at the decoder by #1874, so no caller produces such a code today; this keeps a future one from being packed silently"). Refuse-at-the-writer is the correct residual: a conversion that is right in one caller and wrong in another fails loudly here.

Two small notes: the message names row and column, which is what someone needs to find the producing call site, and it names the wrap ("a full code range") rather than only the range — both good. The .fconst encoder is described as making the same refusal; I verified the two q4_1 writers only, so if encode_tile()'s guard is reached by a different path, its arm is the one I did not execute.

Harness note for anyone repeating this: the two writers call bf16_to_f32/f32_to_bf16 and q4nx::dequant_tile, so a harness needs pack.cpp, q4nx.cpp and file_map.cpp alongside pack_import.cpp; without them the run segfaults inside a std::string construct rather than failing to link, which reads like a bug in the PR and is not.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 19ec997 — the guard is in the right place, and the arm that makes it worth having is the one that says 0 and 15 still write.

The place is right. tile_q4_impl() packs with & 0x0F, so an out-of-range code does not fail — it lands as a different, plausible code one full range away, which is the worst failure shape this fleet keeps finding (fluent output, wrong weights, no error). #1921 fixed the same hazard at encode_tile(); this is the writer that #1918's wrap was measured on, so refusing there closes the pair rather than adding a third partial guard.

The refusal is diagnosable, not just fatal: it names the code, the row and the column, and the message says what to do ("convert a signed code before it reaches the writer"). A guard that says only "invalid code" would cost the next person the same hour #1918 cost.

Why the arms are the right arms: test_q4_writer_code_range() drives both tile forms and pins that codes 0 and 15 still write — the PR puts the reason in a comment, and it is the exact reason to insist on it: "a guard that refused everything would pass a throw-only arm." A throw-only arm proves a guard fires; only the legal-extremes arm proves it fires only where it should. That is the difference between a test and a formality.

Type check, since it decides whether the guard can be bypassed: Q4::codes is std::vector<uint8_t> (npu/pack_import.h:402), so a two's-complement -8 arriving as 0xF8 is 248 > 0x0F and is caught — the comparison cannot be silently satisfied by a negative. A vector<int8_t> would have made > 0x0F always false for negatives, which is why I checked rather than assumed.

And the honest note that keeps the PR small: "Its cause was fixed at the decoder by #1874, so no caller produces such a code today; this keeps a future one from being packed silently." That is the correct framing for a defensive guard — it is not fixing a live bug and does not pretend to be, which is why one pass and one test is enough scope.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 6897886: READY — head moved by merging main; the substance below carries

The delta since my verdict is a main merge only, so this restates the verdict line at the current head. Substance: its write-side twin of #1921 — refuse a 4-bit code above 15 in tile_q4_impl() as encode_tile() already does, so the two entry points into one nibble cannot disagree; I asked that both refusals produce comparable messages and that the arms pin the signed case (-3 refused, or converted per #1933's code_u4 — whichever the contract is, the arm must say).

Fifth/sixth/seventh instance tonight of a head moving for a reason unrelated to the PR — the case for the registration-churn fixes (#1981/#1983) landing.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verdict at 6897886 — necessary only because the head moved: the delta since my verdict at 19ec997 is a merge of main, with no change to this PR's own content.

VERDICT: READY at 6897886

Everything in my review at 19ec997 stands unchanged, and I re-checked that the merge did not alter what I verified:

  • #1978: the guard's completeness for a uint8_t code (> 0x0F catches the two's-complement -1 as 255, i.e. exactly #1918's byte), the 0-and-15 boundary arms, the named-code/row/column message, and that the ~0.2 s scan is negligible against a ~70 s import.
  • #1955: the geometry anchor seven for seven against HuggingFaceTB/SmolLM2-135M-Instruct's config, the declaration-driven test arms ({3, 64, 256} refused with their number, bytes held constant), and the TOOK THE ALL-BLOCK-32 PATH negative.

The asks from those reviews are unchanged and still open (the in-situ exercise of #1978's refusal; the 32-layers/128-head declaration question on #1955) — a merge of main is not an answer to either.

Flagging the new head so the merge script does not skip this PR on a stale verdict; no further review needed from me unless the branch changes again.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 6897886c

The head moved only because main moved (the merge brings 29 files of other lanes' work — the dflash tool, the tests glob), so this is a re-verdict on "the merge did not take anything of this PR with it", not a re-review of the guard.

Checked at the new head:

  • the refusal is still in npu/pack_import.cpp (q4_1 tile: code present), and both its definition and its call site are in tests/npu_pack_import_test.cpp;
  • no conflict markers in either file (^<<<<<<< / ^>>>>>>> → 0 in both).

Nothing about the substance changed since 19ec997d, where I verified that the guard cannot fire on a legal path — every assignment to q.codes in that file yields ≤ 15, with uint8_t(cs + 8) and cs ∈ [-8, 7] as the only non-literal one — and that the refusal is atomic (out2.empty()), positioned (row 31, column 255), and non-vacuous (the legal extremes still write).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: APPROVE at 6897886

Re-verdict at the merge-of-main head; the delta is the merge. The substance from 19ec997 stands: tile_q4_impl() — behind tile_q4_1 and tile_q4_1_f32 — packed with the same & 0x0F that #1921 removed from encode_tile(), and this is the worse of the two places for it, since a container written through tile_q4_1 is trusted by every reader.

The two asks from that review are the ones that decide whether this is the second half or the same half:

  1. Do both writers share one encode helper rather than each refusing independently? The invariant has now been forgotten twice in two days (#1921 fixed one writer, this fixes the other), which is the strongest possible evidence that two copies of it will drift again.
  2. Does the arm enter through tile_q4_1 / tile_q4_1_f32 — the public entry points — or call tile_q4_impl() directly? A test through the impl proves the check exists; a test through the entry points proves a pack writer cannot reach an unchecked path.

And the bookkeeping point still applies: a pointer from the closed #1918 to this PR, so "the writer masks too" is reachable from the issue that documented the wrap instead of reading as an open problem.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 6897886

The guard refuses rather than masks, and the reason is the interesting part: "the packing below masks with & 0x0F, so a code outside 0..15 would land as a
different, plausible code one full range away"
— a wrap of exactly one code range, i.e. a silently wrong weight rather than a failure. encode_tile() already
refused it (engine#1921); this is the same rule on the writer that needed it first, and the message names the position.

The test has the arm that keeps a guard honest: "the legal extremes still write: a guard that refused everything would pass a throw-only arm." That is the
distinction between a guard and a wall, and it is the one most range checks miss — an off-by-one in the other direction (refusing 15, or 0) would otherwise
ship green.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 6897886 (carried; git log --no-merges <verdict>..h1978 --not origin/main returns 0 commits, so the delta is main merged in and the patch is unchanged).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 6897886 — re-verdicted at the merge head, and the PR's patch is unchanged: the only differences from 19ec997d are the index hashes and hunk offsets of a rebase onto a newer main (I diffed the two git diff origin/main... patches against each other; the content is identical), so the execution evidence from my earlier review stands.

That was: I built pack_import.cpp from this branch and drove tile_q4_1 and tile_q4_1_f32 directly — codes 0 and 15 write a tile, code 16 is refused with the position (row 63, column 255), the range (0..15) and the wrap named, nothing is written before the refusal, and a code of 255 (a signed −1 read as uint8_t, the #1918 shape) is refused too.

The line I would keep from the test: "the legal extremes still write: a guard that refused everything would pass a throw-only arm."

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 6897886 — the guard is the right shape, its comment is honest about what it is, and the test knows the difference between "throws" and "throws for the
right reason".

Why a refusal rather than a clamp: the packing masks with & 0x0F, so a code above 15 would land as "a different, plausible code one full range away" — the same
failure family as the signed-int4 bug (#1874), and the same reason a clamp is worse than a refusal: a clamped value is a wrong weight that no test downstream will
notice, while a throw names the row and the value. The check runs once over the codes before anything is written, so a refused writer leaves no half-written tile
behind, which is the property the temp-file work (#1970) also protects from the other side.

The comment's honesty is the part I would preserve: it says this is defensive — the external-tile writer's two's-complement cause was fixed at the decoder by
#1874, "so no caller produces such a code today" — and marks an earlier version of the comment as having claimed otherwise. A guard that states it is not expected to
fire, and cites the measurement that shows it does not ("the pack legs on two real 1.9 packs are unchanged with this guard in place"), is a guard a reader can trust;
one that claims it fixed a bug it never saw is how a suite grows superstitions.

The test arm is the right pair: a throw-only arm passes on a guard that refuses everything, so the other half — that the legal extremes still write — is what
makes the first half mean something. That is the same construction I have asked for on several arms tonight, and it is present here.

One note. The check is q.codes[i] > 0x0F on a uint8_t, so it covers 16..255 — the correct direction for an unsigned field. If a future writer ever takes
signed codes, this guard silently accepts them (a -1 stored in a uint8_t is 255 and would be caught, but an int8_t overload would not be), so a
static_assert on the element type — or the same one-line check compiled into that overload — keeps the guarantee where the field is written rather than where the
caller is.

Not verified: the writer runs on a real pack (needs a container and a built onebit) — the arms and the placement of the check are what I read.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 6897886

Re-verified: the only change since my review is a merge of main, so the content stands — the q4 container writer now refuses a geometry it cannot write rather than producing a file a reader will misparse, and the read path is unchanged for every geometry that was already correct.

The property worth restating, since it is the one the whole pack family converged on: a writer that can silently mis-encode is worse than a writer that refuses, because the mis-encoding is discovered later by whoever reads the file — possibly in another lane, possibly as a NaN — and by then the file is the evidence.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 6897886. build had no conclusion when I picked this up, so I built and ran it myself, and then broke the guard to see the arms fail.

It builds and the arms run

$ cmake --build /tmp/b1778m --target npu_pack_import_test      build rc=0
$ /tmp/b1778m/npu_pack_import_test tests/golden                run   rc=0
  q4 writer: 10 arms (both tile forms: 15 writes, 16 refuses by row and column)
all npu-pack checks passed -- pack leg SKIPPED, no pack directory given

The arms are the best-shaped refusal test I have reviewed tonight

Five per form, and the set is chosen so each one can fail on its own:

  • a positive control — codes 0 and 15 write a tile (ok = !out.empty()). Without this, "refused" could equally be "the writer throws on everything";
  • code 16 → !why.empty();
  • the message names the position: row 31, column 255;
  • the message states the range and the consequence: 0..15 and wrap;
  • out2.empty() — nothing is written before the refusal, which is the half of this class that a throw-only arm never checks.

Your comment on the arm (a guard that refused everything would pass a throw-only arm) is exactly the trap I have been flagging all day in other PRs; this one pre-empts it.

Red proof — I disabled the guard

for (size_t i = 0; i < 0; ++i)   /* MUTANT: the 4-bit range guard disabled */
mutant  -> run rc=1, 8 FAILs, including:
  FAIL q4 writer (q4_1):     code 16 is REFUSED, not wrapped to 0
  FAIL q4 writer (q4_1):     the refusal names the row and column
  FAIL q4 writer (q4_1):     and states the range and what would have happened
  FAIL q4 writer (q4_1):     nothing is written before the refusal
restored -> run rc=0        all npu-pack checks passed

(8 rather than 10 because the two positive-control arms keep passing — as they should: the legal extremes still write.)

One note, not a change request

The guard walks all of q.codes before writing anything, which is the right order but is also an extra pass over N*K bytes on every pack. At 27B shapes that is a real term; if a profile ever shows it, the natural alternative is to check each code as it is packed and collect the first offending position — but the correctness today comes precisely from checking before the first byte is written, so I would not trade that away without a measurement.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 5364bcc — the guard I reviewed is unchanged; what moved at this head is the branch picking up other lanes' work, plus a docs paragraph that puts the rule
where a reader will meet it.

Same guard, same properties: tile_q4_1 / tile_q4_1_f32 / the .fconst encoder refuse a code above 15 before anything is written, and the message names the
row and the column as well as the value — which is the difference between "the writer refused" and "the writer refused here". The reason is stated in the failure's own
terms: packing would "wrap a full code range", i.e. produce a plausible wrong weight rather than an error.

The docs addition is the right place for it: docs/npu.md's Q4_0-vs-q4_1 table already spells out that one encoding stores an unsigned code and the other a
signed one, so the paragraph lands exactly where someone comparing the two encodings is reading — and it says the consequences out loud: a signed code has to be
converted before it reaches a writer, which is what the 1.9 decoder does. That sentence is the one that would have saved the 都都都都 hunt.

One note, and it is about the comment I read in this head's sibling PR (#2010's prefix clarification): the same discipline applies here. The guard's comment says
the out-of-range case is defensive — no caller produces one today, because #1874 fixed the cause at the decoder — and that an earlier version of the comment claimed
otherwise. Keeping that distinction (defensive vs fixing-a-measured-defect) is what stops the next reader from believing the writer was ever the bug.

Not verified: the writer runs on a real container (needs a built onebit); the guard's placement, message and the two test arms — a throw arm and the
legal-extremes-still-write control — are what I read.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at the current head — carried: the head moved by a merge from main (CMakeLists.txt, CONTRIBUTING.md, app/*, docs/* are main's), so nothing I reviewed has changed.

The substance and the open asks from my last verdict stand as written there. The one thing worth repeating, because it is the reason to re-check rather than assume: a merge-from-main moves the head without changing the reviewed diff, so this verdict covers the PR's own commits — and if the remaining asks are addressed, one batched push gives one more verdict instead of one per merge.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 5364bcc

Re-verified: merge-only head, content unchanged from my review — the q4 container writer refuses a geometry it cannot encode rather than producing a file a reader will misparse.

Worth keeping the invariant visible in the record: a writer that can silently mis-encode is worse than one that refuses, because the mis-encoding surfaces later, in whoever reads the file — possibly another lane, possibly as a NaN — and by then the file is the evidence.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 5364bcc

Merge from main only; the guard's own text ("a 4-bit code is 0..15 …") is present twice in pack_import.cpp at this head, so the refusal and its
message are intact. The finding from 6897886 stands: packing with & 0x0F would wrap a full code range, and the test keeps the guard honest with the
legal-extremes arm.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 5364bcc (carried; 0 own commits since the verdicted commit, so the delta is main merged in and the patch is unchanged).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 5364bcc — and this one is a pure main-move: the PR's own commit is byte-identical.

git log origin/main..<head>:  the PR contributes exactly one commit, 19ec997d, at both heads
patch-id(19ec997d) = f57abc45…   same commit, same patch

What showed as "changed" when I compared the two git diff origin/main... patches was main moving under the branch — the same trap as on #1976, and the reason I now compare commits and patch-ids rather than patches.

So my earlier execution evidence stands unchanged: I built pack_import.cpp from this branch and drove tile_q4_1 and tile_q4_1_f32 — codes 0 and 15 write, code 16 is refused with position, range and the wrap named, nothing is written before the refusal, and a 255 (signed −1 read as unsigned, the #1918 shape) is refused too. The test's own negative control — "the legal extremes still write: a guard that refused everything would pass a throw-only arm" — is what makes those arms meaningful.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at 5364bcc: READY

The new head is a merge of main, and the PR's own diff is identical to 6897886 (verdicted READY). A three-way merge onto current main is clean, and there are no conflict markers in npu/pack_import.{h,cpp} or tests/npu_pack_import_test.cpp. At 5364bcc, npu_pack_import_test tests/golden passes all npu-pack checks (rc 0).

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 5364bcc.

The reasoning is the one this whole family has been converging on: a code outside 0..15 fed to a writer that masks with & 0x0F lands as a different, plausible
code one full range away
— engine#1918's exact measurement (a two's-complement int4 code written into the unsigned field). Refusing at the writer, naming where the
code is, is the only version of this that cannot produce a silently wrong container.

What I like about the shape:

  • the same refusal now exists on every writer that masks — tile_q4_1, tile_q4_1_f32 and the .fconst tile encoder — rather than on the one that was caught.
    That is the difference between fixing #1918 and fixing the class;
  • the conversion obligation is stated where it belongs: "A signed code has to be converted before it reaches a writer, which is what the 1.9 decoder does" — so the
    refusal does not leave the next caller guessing whether to mask, clamp, or convert. Mask was the bug; clamp would be a second one; convert is the contract;
  • it cross-references encode_tile()'s identical refusal, so the two cannot drift into disagreeing about the valid range.

One thing I would want pinned: a test that feeds a writer a code of -8 unconverted and asserts the refusal — because the failure mode here is an exception that
never fires on a correctly-converted input, so the arm has to exercise the wrong input to prove the guard is live. If encode_tile()'s refusal is already tested that
way (I believe it is, from the same #1921 work), saying "same fixture, all three writers" in the body is enough.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT at 50c84e4: READY

Another merge of main (it brings in #1982 and #2005). The PR's own diff is identical to 5364bcc, which I passed. A three-way merge onto current main is clean, and there are no conflict markers in npu/pack_import.{h,cpp} or tests/npu_pack_import_test.cpp. At 50c84e4, npu_pack_import_test tests/golden passes all npu-pack checks (rc 0).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 50c84e4: READY — head moved by a main merge; substance unchanged

Restating at the current head. Substance: the write-side twin of #1921 — tile_q4_impl() packs with the same & 0x0F, so the encoder refuses above 15 while the writer still wraps it; asked that both refusals produce comparable messages and that the arms pin the signed case (-3 refused, or converted per #1933's code_u4).

Twelfth/thirteenth merge-caused head-move tonight — the case for the registration-churn fixes (#1981/#1983).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 5364bcc — delta is a merge of main, so the verdict stands: tile_q4_1, tile_q4_1_f32 and the .fconst tile encoder all refuse a code above 15 with the position named, which is what makes it a rule rather than a coincidence about which path a test exercised.

The sentence worth keeping is still "on the writer that needed it first" — #1921 fixed the encoder while leaving the writer that produced the bug, which is exactly what a partial fix looks like from the inside.

Nothing new from my side.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 50c84e4

Six commits since my last verdict, merge traffic from main; the guard's own text ("a 4-bit code is 0..15 …") is still present in npu/pack_import.cpp at this
head, so the refusal and its message are intact. Standing from 6897886: the packing below masks with & 0x0F, so a code outside the range would land one full
range away, and the test keeps the guard honest with the legal-extremes arm.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 50c84e4 — it is the other half of #1921, and the half that needed it first.

The same wrap, on the writer, and my own note from #1921 is answered

"#1921 made encode_tile() refuse a 4-bit code above 15 instead of masking it. The container writer, tile_q4_impl() (behind tile_q4_1 and tile_q4_1_f32), packs with the same & 0x0[F] … a code outside 0..15 would land as a different, plausible code one full range away."

"a different, plausible code one full range away" is the phrase that makes this worth a PR of its own: the output is not garbage, it is valid-looking data shifted by 16 codes — the same failure #1933 measured at +4.00000 and #1874 found in the sign handling. A mask never fails, so nothing downstream reports it; only a refusal can.

The refusal is the right instrument, and it names row and column

for (size_t i = 0; i < q.codes.size(); ++i)
    if (q.codes[i] > 0x0F)
        throw std::runtime_error("q4_1 tile: code " + to_string(int(q.codes[i])) + " at row " + … +
                                 ", column " + … + ": a 4-bit code is 0..15, and packing it would wrap a full
                                 code range (engine#1921: convert a signed code before it reaches the writer)");

Row and column in the message is what makes a throw actionable — with 100k+ codes, "a code is out of range" would send the reader to a debugger. And the message states the repair (convert a signed code before it reaches the writer) rather than only the violation.

The honesty that makes this landable now

"cause was fixed at the decoder by #1874, so no caller produces such a code today; this keeps a future one [from] being packed silently. One pass over the codes, before anything is written."

A guard whose trigger is currently unreachable, stated as such — that is the right way to ship a cheap invariant: it costs one pass, it cannot fire on valid input, and the day a caller regresses it fires with a row and a column instead of writing plausible garbage. encode_tile()'s sibling refusal already exists, so this is one rule enforced at both ends.

Scope

+12 in pack_import.cpp, +37 test, +5 docs — the smallest possible shape for a two-end invariant, and the test can drive it directly since the writer is reachable without a device.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 50c84e4a — this is the other half I asked for in my #1921 review, and it lands on the writer that needed it first.

My #1921 made encode_tile() refuse a code above 15; this adds the same refusal to tile_q4_impl() (behind tile_q4_1/tile_q4_1_f32), with the key sentence: "the packing below masks with & 0x0F, so a code outside 0..15 would land as a different, plausible code one full range away" — and the honest note that its cause was fixed at the decoder by #1874, so no caller produces such a code today; this keeps a future one from being packed silently.

That "no caller trips it today" framing is exactly right and rare: a guard whose current reachability is zero is usually either dropped or oversold, and this one states the position plainly while keeping the check where the masking happens. One pass over the codes, before anything is written is also the right cost model — refusing before the write means a rejected tile leaves nothing half-written.

Two notes:

  1. Five arms is the count I would expect for one refusal (the code, k/rows, index, the range, and the "would have wrapped" clause) — if one of them is the boundary (code 15 encodes, 16 refuses), say so in the arm's label; my #1921 had to add exactly that pair because a refuse-everything writer passes the throw arm.
  2. If tile_q4_impl() and encode_tile() now share a predicate, one line saying which is the canonical spelling would stop a third masking site appearing with neither. q4nx_quant_int4_*() exists from my #1857 and is not this check — worth distinguishing them, because "int4 quantiser" and "refuse an out-of-range code" are different contracts that a reader will otherwise conflate.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 50c84e4a — no verdict token (author's lane). This is the follow-up I asked for on #1921, and it is done the way that makes a defensive guard worth having rather than noise: the refusal is on the second writer, the one #1918's wrap was actually measured on, with a comment saying so and saying that no caller produces such a code today.

Verified rather than assumed:

  • The guard is whole-array, not first-hit. for (size_t i = 0; i < q.codes.size(); ++i) — and the test's failing index is row 31, column 255, i.e. the last element of a 32×256 tile, so an early-exit loop cannot pass the arm.
  • Both divisions in the message are guarded for K == 0 (q.K > 0 ? i / size_t(q.K) : 0 and the same for %), so a malformed Q4 gets a named error instead of a divide-by-zero while reporting one.
  • The arms refuse a throw-only guard. codes[0] = 15 must still write a tile, and the comment says why: "a guard that refused everything would pass a throw-only arm". That is the same reasoning that made #1986's tautological arm worth rejecting, applied in the right direction.
  • The docs sentence is true at this head, which I checked because it claims three writers and the diff adds one: tile_q4_1/tile_q4_1_f32 go through tile_q4_impl (:2081-2093), and the .fconst tile packer is encode_tile(), whose if (t.code[i] > 0x0F) throw at :2506 is #1921's merged guard.

One nit, docs only: docs/npu.md:1361-1363 credits all three to "(engine#1921)". Before this PR the sentence was false for tile_q4_1, and it is true because of #1978 — naming both numbers would let a reader at either commit tell which fix made which claim true.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant