Repository navigation
npu: the q4_1 tile order is measured by check_q4nx_dir --against, so a foreign-order directory is refused offline (Fixes #1110) - #1275
Conversation
…ign-order dir is refused offline engine#1110's surviving hole: the repack measures the tile order (#1169/#1234) and the plan loader refuses a declared order it does not implement (#1130), but a directory that was never repacked here -- imported from a foreign converter, downloaded, copied off a board -- has no stamp and no plan, and every metadata check passes: the two orders share the chunk kind, the byte count and the tensor names. Only the weights can say which order the bytes are in. --against already decoded the first Q8 tile both ways against the source GGUF; it now does the same for q4_1 over all three orders this repo can name -- ours (npu/q4nx.cpp's), flm signed, flm unsigned -- with tools/q4nx_diff.py's own decoders imported rather than re-implemented, and refuses a container whose winner is not ours: tensor name, all three correlations, and the closest foreign reading in the message. Accepted when ours >= 0.99 and ours - best_other >= 0.5, both measured: a container packed from Q4_K_M reads +1.0000, one packed from BF16 +0.996, every foreign order |corr| < 0.05. The metadata-only path still imports no numpy and no gguf. Tests: tests/check_q4nx_tile_order_test.py builds the case the issue describes -- one known 32x256 weight encoded as a single q4_1 tile in the engine's order and again in the foreign one, each encoder round-tripping through its own decoder first (4.17e-03) while the same bytes read as 'ours' are 1.31e-01 wrong -- then requires pass/refuse, the refusal to name the tensor, the foreign reading to be identified, and the verdict to survive a rename to another tensor.
|
Head corrected for the verdict: Content is provably unchanged: Nothing else changed: the measurements in the body stand as posted, they are all host-side reads of containers that did not move ( |
|
The foreign container finished its own repack pass, and the two guards now agree on the same directory — which is the composition this PR completes. The and So on one directory there are now three rows, and they are the whole argument of this PR:
The middle row only exists if the directory was built here. The container was still left on disk by that refusing repack — which is exactly the state a foreign directory arrives in — and until this PR the third row did not exist, so a directory in that state could be served with nothing but One consistency note, since the two tools word it differently: the repack's |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 18cff3190f4e7d4867294ac2bfbb908a5f5fb1bd. Two blockers: the PR breaks a registered ctest, and its 0.99 threshold refuses the published FastFlowLM directory once the checker samples more than the first 3 tensors. The feature itself works on the case #1110 names.
(The head moved from 140045f8 to 18cff319 while I reviewed. Both are main merges only: git diff 140045f8 18cff319 -- tools tests docs/npu.md CMakeLists.txt is empty.)
What holds (reproduced here, host-only, no device)
tests/check_q4nx_tile_order_test.py0 failures. No regression inrepack_tile_order_test,npu_plan_tile_order_testortile_check_no_shared_tensor_test.q4nx_diff.py --self-testok.copyright.py --checkrc 0.- The table's rows reproduce to the digit with the default 3 samples, against
~/models/Qwen3-0.6B-Q4_K_M.gguf:~/models/npu/Qwen3-0.6B: ours +1.0000 / rel-rms 0.0054, rc 0.- The published
FastFlowLM--Qwen3-0.6B-NPU2(snapshotb5847992): ours +0.9964 / +0.9986, rc 0. /tmp/wg-foreign: metadata-only rc 0, "chunk layout verified";--againstours +0.0477 vs flm-unsigned +0.9961, rc 1, naming the tensor. #1110's hole and its closure, as claimed.
Blocker 1: tools/check_q4nx_dir.py --self-test now fails (ctest q4nx_dir_check, CMakeLists.txt:566)
$ python3 tools/check_q4nx_dir.py --self-test; echo rc=$?
self-test FAILED:
no-Q8 container did not say it compared nothing: ... chunk layout verified; no Q8 or q4_1 tile was decoded, so no layout or order was checked against the source weights
rc=1
It passes on origin/main and fails at both 76d3f661 and the head. The PR rewrote the message, but tools/check_q4nx_dir.py:522 still looks for the old substring "no Q8 tile was decoded". The body's "check_q4nx_dir.py --self-test ok" does not hold at any commit of this PR. Also, the no-Q8 fixture has no q4_1 either, so the self-test should cover a container with q4_1 tiles too.
Blocker 2: the threshold refuses a known-good published directory
The same FLM -NPU2 snapshot, checked over every q4_1 tensor instead of the first 3 (--q8-samples 100000, 196 tensors):
rc=1
lowest ours corrs: +0.9890 +0.9945 +0.9958
FAIL model.layers.2.mlp.down_proj.weight: the q4_1 tiles are NOT in the order npu/q4nx.cpp decodes
(ours corr +0.9890 rel-rms 0.1477, best of the three is ours at corr +0.9890) ...
That directory is the PR's own non-regression row, and the fleet serves it. The verdict "NOT in the engine's order, best is ours" contradicts itself. ours - others there is ~0.95, so the order is unambiguous. ours >= 0.99 is a proximity cut on quantisation error (rel-rms 0.148 on an outlier-heavy down_proj), which is exactly the thing the body says the correlation criterion avoids. The comment's "both conditions are wide" does not hold: BF16-sourced tensors sit at 0.996, which leaves 0.006 of headroom.
Suggested fix: decide the order on the margin (ours is the argmax, and ours - best_other >= 0.5), plus a floor that only rules out "no reading matches", e.g. ours >= 0.9. Report the rel-rms. Then add the full-sample FLM run, or at least the layers.2.mlp.down_proj row, to the body as the non-regression evidence.
Minor, not blocking: with the default of 3 samples (dict order), a container that is foreign in only some tensors (the body notes the foreign order varies tensor to tensor) can pass. Consider sampling across layers rather than the first N.
Scope against #1110 is right. Offline detection is the hole the last comment names, and load-time refusal of unstamped directories is correctly left to an owner decision.
|
Addendum to the CHANGES at |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at ef3a4304fc7c448095cce923b81b38370bdfa788. Main merges only since 18cff319: git diff 18cff319 ef3a4304 -- tools/check_q4nx_dir.py tests/check_q4nx_tile_order_test.py is empty (the files that did change came from #1290/#1293 on main). Both blockers from the CHANGES at 18cff319 therefore stand at this head, re-checked here:
tools/check_q4nx_dir.py --self-test(ctestq4nx_dir_check) still exits rc 1: line 522 looks for the old "no Q8 tile was decoded" message.- The
ours >= 0.99floor is unchanged. It refuses the published FLM Qwen3-0.6B-NPU2 directory atlayers.2.mlp.down_proj(+0.9890) on a full sweep.
A READY on a later merge-only head should answer these two points, not just re-check that the diff is identical.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at fefd593ec55690995676473cecd9d1c42aa8eec2. Main merges only since ef3a4304: git diff ef3a4304 fefd593e -- tools/check_q4nx_dir.py tests/check_q4nx_tile_order_test.py docs/npu.md is empty. Both blockers from the CHANGES at 18cff319 stand, re-checked at this head: --self-test rc 1 (line 522 still looks for the old message), and the ours >= 0.99 floor still refuses the published FLM -NPU2 layers.2.mlp.down_proj (+0.9890).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at c0c3d1387828c415e4b34d5282d07d76d0d0d0d8. Main merges only since fefd593e: patch-id 7706dff41ceb is identical at both heads. Both blockers stand, re-checked here: --self-test rc 1 (line 522 still looks for the old message), and the ours >= 0.99 floor still refuses the published FLM -NPU2 layers.2.mlp.down_proj (+0.9890).
PR Reviewer Guide 🔍(Review updated until commit 7ad6f6a)Here are some key observations to aid the review process:
✅ Resolved findingstools/check_q4nx_dir.py:378-384Import of q4nx_diff The |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at baf0a0e850045a34292734b2d146ca628b810b02. Main merges only (#1286 and #1294 came in): no non-merge commit of its own, and the PR diff is still 4 files, +306/−12. The patch-id moved only because #1286 shifted the diff context in tools/check_q4nx_dir.py. Both blockers stand, re-checked at this head: --self-test rc 1 (line 527 still looks for "no Q8 tile was decoded"), and ours >= 0.99 (line 428) still refuses the published FLM -NPU2 layers.2.mlp.down_proj (+0.9890).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 92fea95cf6b8f1b360bcdc8ccf517ac267d5e20e. Main merges only since baf0a0e8: no non-merge commit of its own, and the diff is still +306/−12. Both blockers from 18cff319 stand (--self-test rc 1; the ours >= 0.99 floor).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at 92fea95cf6b8f1b360bcdc8ccf517ac267d5e20e. Main merges only since baf0a0e8: no non-merge commit of its own, and the diff is still +306/−12. Both blockers from 18cff319 stand (--self-test rc 1; the ours >= 0.99 floor).
…nd make the self-test follow the message
Review blockers, both real.
1. `--self-test` exited rc 1 at every commit of this branch: the summary line was reworded
("no Q8 or q4_1 tile was decoded ...") and the assertion at the old line 522 still looked for
"no Q8 tile was decoded". My own run had printed that failure and I quoted "self-test ok" from
its tail without reading its exit code -- the exact claim-before-check mistake this lane keeps
writing down. Fixed, and the self-test now also covers the q4_1 selector and the sampler
(pure python: a 5120-B I8 tensor is q4_1, 4736/8704 are not; spread() reaches both ends).
2. `ours >= 0.99` was a proximity cut on quantization error, and it refused the PR's own
non-regression row: on a full sweep of the published FastFlowLM Qwen3-0.6B-NPU2 directory the
outlier-heavy `layers.2.mlp.down_proj` reads corr +0.9890 (rel-rms 0.1477) with a margin of
~0.95 over every foreign reading -- unmistakably the engine's order -- yet the check refused it
and then said "NOT in the order, best is ours", which contradicts itself.
The order is now decided on the MARGIN -- ours wins and beats the runner-up by >= 0.5 -- with a
0.9 floor that only rules out "no reading matches at all". Measured: correct containers lead by
>= 0.95, foreign ones by <= 0.05 (the d1d5232 container: ours +0.0477 vs flm-unsigned +0.9961).
The refusal now says which of the three things happened instead of one message for all:
no reading matches (wrong GGUF or an unnameable order), a foreign order it can name, or an
ambiguous margin (a partly foreign container).
Also, per the review's non-blocking note: samples are spread evenly across the table instead of the
first N in table order, so a foreign order that varies tensor to tensor is not missed by sampling one
layer.
Tests: tests/check_q4nx_tile_order_test.py grows to 14 checks. The new ones are the regression the
floor caused (a correct container at corr +0.9827 must pass -- inside the band the old floor refused)
and the wrong-source case (must say "no reading of these tiles matches", never claim a foreign order
it cannot identify). Negative control: restoring `ours >= 0.99` makes exactly the new regression
check fail and nothing else, so the test bites on this bug and not on a coincidence.
|
Persistent review updated to latest commit a8ecc98 |
|
Both blockers fixed at Blocker 1 —
|
| directory | ours corr, min … max |
verdict |
|---|---|---|
| published FastFlowLM Qwen3-0.6B-NPU2 | +0.9890 … +0.9992 | rc 0, 0 FAIL — including layers.2.mlp.down_proj at +0.9890 (rel-rms 0.1477), the tensor you flagged |
/tmp/wg-foreign (FLM_Q4NX_Converter @ d1d5232) |
-0.0856 … +0.1067 | rc 1, 196 of 196 refused |
So relaxing the floor did not weaken the check — it broadened it: the old code sampled three tensors, this refuses all 196 of the foreign container.
The refusal no longer says one thing for three different situations. It now reports which happened:
- no reading matches (best below 0.9) — wrong GGUF, or an order this repo cannot name. Before, this case printed "NOT in the order, best is ours", the same self-contradiction you flagged, with all three correlations ≈ 0;
- a foreign order it can name —
ours +0.0477vsflm-unsigned +0.9961; - an ambiguous margin —
oursleads but by < 0.5 (a partly foreign container), refused and said so.
Test (14 checks, 0 failures) and a negative control
New checks: a correct container at corr +0.9827 must pass — inside the band the old floor refused — and a container checked against the wrong source must say "no reading of these tiles matches". Negative control, run rather than claimed: restoring ours >= 0.99 in a copy makes exactly that +0.9827 check fail and nothing else.
Also taken (your non-blocking minor): samples are spread evenly across the table instead of the first N in table order; the default three now land on lm_head (skipped — absent from the GGUF), layers.20 and layers.9.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at a8ecc98fe3e2a66583da0c827b4f4161e3d803fd. Both blockers from my CHANGES (first at 18cff319, re-posted through 92fea95c) are fixed by a8ecc98f, measured on all 196 q4_1 tensors of every directory involved. One wording nit, not blocking.
Blocker 1, the self-test: tools/check_q4nx_dir.py --self-test → self-test passed, rc 0. The check now looks for the current "no Q8 or q4_1 tile was decoded" message, and the self-test gains a q4_1-selector case and a spread() case. check_q4nx_tile_order_test.py, repack_tile_order_test.py and tile_check_no_shared_tensor_test.py all report 0 failures. copyright.py --check rc 0.
Blocker 2, the 0.99 floor: the order is now decided on the margin (ours − best_other ≥ 0.5), with a 0.9 floor that only means "no reading matches". Full sweeps, --against ~/models/Qwen3-0.6B-Q4_K_M.gguf --q8-samples 100000:
| directory | q4_1 tensors | rc | refused | min ours |
|---|---|---|---|---|
published FLM Qwen3-0.6B-NPU2 (snapshot b5847992) |
196 | 0 | 0 (was 1: layers.2.mlp.down_proj +0.9890) |
+0.9890 |
~/models/npu/Qwen3-0.6B (ours, from Q4_K_M) |
196 | 0 | 0 | +0.9955 |
/tmp/wg-foreign (the #1110 converter) |
196 | 1 | 196 | −0.0856 |
/tmp/wg-foreign, default 3 samples (now spread() across the table) |
2 compared | 1 | 2 |
So the published directory the fleet serves is no longer falsely refused, and the foreign one is still refused on every tensor. That is the #1110 hole closed without a false positive. spread() also answers my minor point about sampling the first N in table order.
Nit, not blocking: on the foreign container, 168 of the 196 refusals use the new branch no reading of these tiles matches the source weights … Qwen3-0.6B-Q4_K_M.gguf is probably not the GGUF this container was packed from, and 28 say NOT in the order npu/q4nx.cpp decodes. In #1110's own case the GGUF is the source, and the tiles are in an order that varies tensor to tensor (the body says so). An operator reading the first message would go looking for a different GGUF. If the "or the tiles are in an order this repo cannot name" clause led, or the run-level summary said "not the engine's order" whenever any tensor proved foreign, the common reading would be the right one.
|
Persistent review updated to latest commit 7ad6f6a |
…he container once (#1319) cl-review's nit on #1275: on a genuinely foreign container, 168 of 196 refusals said "<gguf> is probably not the GGUF this container was packed from" -- the wrong diagnosis in exactly the case #1110 is about, where the source GGUF is right and the tiles are foreign. It is right, and the cause is structural: a foreign converter's order matches cleanly on some tensors and partially or not at all on others (measured: `flm-unsigned` at +0.9961 on one `down_proj`, +0.49 on a `gate_proj`), so a per-tensor line cannot know whether the tiles or the GGUF is at fault. The per-tensor row now states only what that tensor showed ("no reading of these tiles matches the source weights, best is X at corr Y"), and the cause is decided once, after the samples, as a single `container:` line: * a foreign order was identified on k of n samples -> "the q4_1 tiles are in <order> order, not the engine's (identified on k of n sampled tensors)"; * nothing identified and nothing accepted -> "no reading of any sampled tile matched <gguf> -- check that it is the GGUF this container was packed from; if it is, the tiles are in an order this repo cannot name"; * some read as ours and others did not -> "partly foreign or partly corrupt container". Measured on the container from `amd-oss/FLM_Q4NX_Converter` @ d1d5232, 30 samples: 25 per-tensor "no reading" rows, 4 named foreign rows, and ONE container line -- "in flm-unsigned order, not the engine's (identified on 4 of 29 sampled tensors)". Before, those 25 each blamed the GGUF. The wrong-GGUF case still gets its hint, once, at container level. Tests: 16 checks, 0 failures. New assertions: the container line names the order the tiles are actually in, the wrong-source hint is one container line rather than a per-tensor refrain, and no per-tensor row guesses the cause. Negative control run: restoring the old per-tensor accusation fails exactly that last check and nothing else (my first attempt at this assertion did not bite, which is how it was found -- the control is what makes it a test rather than a wish). Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Fixes #1110.
The hole this closes
#1110says a container whoseq4_1tile order is foreign "decodes as noise with no error and no check catches it". Three guards have landed since: the repack measures the order and refuses a wrong one (#1169, extended by#1234 --verify-source),tools/npu_plan.pyreads that stamp instead of asserting a claim (#1187), andnpu/plan/plan.hrefuses a declaredtile_orderit does not implement (#1130). What survives all three is the case the issue's own last comment names: a directory that was never repacked here — imported from a foreign converter, downloaded, or copied off a board. No stamp, no plan, andtools/check_q4nx_dir.pycould not see it at all, because the two orders share every metadata property: same chunk kind (5120 Bq4_1), same byte count, same tensor names.Only the weights can answer it, and the tool already had the machinery:
--against <source.gguf>decoded the first Q8 tile both ways and correlated it against the GGUF the container was packed from. This PR extends that toq4_1, over all three orders this repo can name.What changed
tools/check_q4nx_dir.py:--againstnow also decodes the first tile of each sampledq4_1tensor underours(whatnpu/q4nx.cppdecodes),flmsigned andflmunsigned, correlating each against the source block. The container is accepted whenourswins on the margin —oursis the best reading and beats the runner-up by>= 0.5, with a0.9floor that only rules out "no reading matches at all" (a floor near 1.0 is a proximity cut on quantization error, and it refused a known-good published directory — see the review history) — and otherwise refused with a message naming the tensor, all three correlations and the closest foreign reading. The decoders aretools/q4nx_diff.py's own (decode_ours/decode_flm), imported, not re-implemented, so the checker and the repack cannot drift apart.q8_ground_truth()→source_block(): the same top-left 32×256 block is the ground truth for the first tile of either chunk kind (a converter that pads a grid pads right/bottom, so tile 0 starts at the weight's origin).--q8-samplesnow means samples per chunk kind, and they are spread evenly across the table instead of the first N in table order, so an order that varies tensor to tensor is not missed by sampling one layer; the--againsthelp and the module docstring say what the new row answers.sys.modulesstill exits 0 and prints its normal line.docs/npu.md: the "Q4NX chunks" section said the checker "cannot tell the two Q8 layouts apart, because the container does not record which one it holds" — true, and still true of metadata; the paragraph now says what--againstcloses and with what separation.tests/check_q4nx_tile_order_test.py(new, registered as ctestcheck_q4nx_tile_order): the fixture is the case itself. One known 32×256 weight, encoded as a singleq4_1tile in the engine's order and again in the foreign order, then read back with the decoders the checker uses.Measured (this box, host-side, no device, no NPU ticket — c31 gates are running)
~/models/npu/Qwen3-0.6B— ourgguf_to_q4nx.py, fromQ4_K_Mours+1.0000 (rel-rms 0.0054);flm+0.0067;flm-unsigned+0.0170~/lb/onebp-import-20261105/zr1-v3— this lane's.1bpimport (#1216), sourced from BF16ours+0.996 (rel-rms 0.089); others ≤ +0.033~/stage-c26/hf/models--FastFlowLM--Qwen3-0.6B-NPU2/…— a real published FastFlowLM directory, all 196q4_1tensors (--q8-samples 100000)ours+0.9890 … +0.9992, 0 failures/tmp/wg-foreign— the converter from the issue,~/amd-oss/FLM_Q4NX_Converter@d1d5232, run throughscripts/repack_gguf.py; all 196q4_1tensors--against:oursbetween -0.0856 and +0.1067 on every tensor, 196 of 196 refused (e.g.down_proj:ours +0.0477vsflm-unsigned +0.9961)The last row is the one this PR exists for, and it is worth reading twice: the same directory that the metadata check passes is the one the weights row refuses. That is
#1110in one line — the old check is not wrong about what it measures, it answers a question that cannot distinguish the two orders.One refinement of the earlier record, measured here: the issue's comment says this converter's
Q4_K-sourced tensors "match none of the three" readings. On this containerflm-unsigneddoes match some of them (+0.9961ondown_proj) and only partially matches others (+0.4945,+0.4459,+0.4511), so the honest statement is that the container is not in the engine's order — which is all the refusal needs — while which foreign order it is in varies tensor to tensor. The verdict does not depend on resolving that.What those rows decide, deliberately:
ours >= 0.99; a full sweep of the published FastFlowLM directory then refusedlayers.2.mlp.down_projat+0.9890(rel-rms 0.1477) — outlier-heavy, packed from a different source, and unmistakably in the engine's order with a margin of ~0.95 over every foreign reading. Correlation separates orders (~0.95 vs ~0.05); it does not measure quantization quality. So the order is decided on the margin, rel-rms is reported for the reader, and the test pins a correct container at+0.9827passing.-NPU2directory — the exact case this issue says is unmeasured — is in the engine's order and passes throughout (+0.9890 … +0.9992, 0 failures), so this does not start refusing the directories the fleet already serves.oursbetween-0.0856and+0.1067), so relaxing the floor did not weaken the check — it made it broader, because the old code looked at three tensors and this run looks at all of them.What this does not do
Refusing an unstamped directory at load is the product decision the issue's last comment parks on, and this PR does not make it:
npu/plan/plan.hstill defaults an absentcontainer.tile_ordertoengine-1, so an unstamped directory still loads. What changes is that the offline check can now name such a directory as foreign instead of passing it. If the owner wants load-time refusal, that is a one-line change against a documented back-compat rule and should be its own PR.Testing
tests/check_q4nx_tile_order_test.py— 0 failure(s), 14 checks: both encoders round-trip through their own decoders (max|err| 4.17e-03) while the same bytes read asoursare1.31e-01wrong; an engine-order container passes and the row carries the correlation; a foreign-order container is refused, the refusal names the tensor, and the foreign reading is identified; the refusal survives renaming the tile to another tensor that does have ground truth; a container sharing no tensor with the GGUF is "nothing was verified", not a pass; a correct container atcorr +0.9827passes (the band the old floor refused — this is the regression test for blocker 2); and a container checked against the wrong source says "no reading of these tiles matches" instead of claiming a foreign order it cannot identify.ours >= 0.99in a copy makes exactly the new+0.9827check fail and nothing else, so the test bites on this bug rather than on a coincidence.tools/check_q4nx_dir.py --self-testrc 0. It wasrc 1at every earlier commit of this branch — the summary line was reworded and the old assertion still looked for the old sentence — and the body's earlier "--self-testok" did not hold. The self-test now also pins theq4_1selector and the sampler in pure python.tools/copyright.py --checkrc 0 (new file carries the full Apache notice).tests/repack_tile_order_test.py0 failures,tests/npu_plan_tile_order_test.py0 failures,tests/tile_check_no_shared_tensor_test.py0 failures,tools/q4nx_diff.py --self-testok,tools/check_q4nx_dir.py --self-testok.