Skip to content

npu: read a weight grid the container padded wider than the model's own (#1097) - #1107

Merged
bong-water-water-bong merged 4 commits into
mainfrom
1bit/q4nx-padded-k
Nov 5, 2026
Merged

bong-water-water-bong merged 4 commits into
mainfrom
1bit/q4nx-padded-k

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1097. The GGUF-on-NPU repack pads intermediate_size up to the converter's 512-column grid — IM 8960 becomes 9216 — so a ZR1-1.5B MLP weight holds 1728 tiles where the model's dimensions imply 1680 (28 layers × 3 MLP tensors). Every chunk-kind check still passes and the container loads; every request then answers 500 forward step failed: dequant failed in 6–9 ms, because dequant_any() derives the chunk size from the model's own grid: cb = 8 847 360 / 1680 = 5266 B, not a chunk kind, so the tensor falls to the legacy non-chunked decoder.

Fix

data_size pins the container's grid exactly — (rows_p/32)·(cols_p/256)·chunk_bytes for one of the three chunk kinds — so the grid is recoverable without knowing the kind first. q4nx_dequant_tensor_packed() reads the container's grid and writes only the model's [rows, cols].

That is why nothing downstream moves: buffers keep their width, scatter_transposed() keeps K as the source row stride, and the design K stays q4nx_pad_k(IM) — no measured device row is touched, and real_n/q4nx_pad_n/ELF selection are deliberately not involved (#799's rows are still indexed by the same kernel names).

Safety: the padded grid is taken only when it dominates the model's geometry with a small excess (≤ 64 tiles — a converter's grid step, measured at 8 tile-rows for IM 8960) and one chunk kind divides data_size evenly. Anything else keeps the old path, i.e. still fails rather than decoding garbage.

Measured (host arm, NPU_INFER_HOST_GEMM=1, one npu-hold ticket per arm)

Containers built with the only FLM converter on this box that repacks (~/wt/npu-rt-753-pr/third_party/FLM_Q4NX_Converter); the engine's own resolver prefers np-model-generic, which is absent here, and ~/amd-oss/FLM_Q4NX_Converter (d1d5232) writes a different q4_1 tile order whose containers decode as noise — see the note at the end.

arm binary result
ZR1 repack (1728 tiles) unpatched 500 forward step failed: dequant failed, 6–9 ms
ZR1 repack (1728 tiles) patched 200, 16 tokens
ZR1 repack, layer-0 down_proj, decoded vs this lane's own q4nx pack of the same GGUF patched corr +0.9994 (bit-level head match too)
Qwen3-0.6B repack (384 tiles, on grid) unpatched / patched 200 " Paris. …" on both — unpadded path untouched
own ZR1-1.5B-npu-q4km-v2 pack, ids prompt unpatched / patched byte-identical text — the unpadded path is untouched
a container whose MLP tensor is 256 B short patched still refused, by name: MLP dequant model.layers.0.mlp.down_proj.weight: data_size 8601344 at geometry 1536x8960 = 1680 tiles (chunk 5119 B is not a kind, and no padded grid fits): the container and config.json disagree

The bare dequant failed cost this lane an afternoon, so load_layer()'s deq() now names the tensor, its data_size, the geometry tried, the resulting chunk size and what it means; load_gdn_layer() already did.

What the padded container is and is not. pi-npufeat re-measured this at 6cce1656 and reproduced 500 -> 200, but not the
one-prompt text match, and they are right: the two containers hold different quantizations of the same weights (this lane's
pack keeps Q4_K codes and --dtype8's 29 Q8 tensors; the repack is 197/197 q4_1 from a Q4_K_M source), so +0.9994 on one
tensor does not imply equal logits. Their logit correlations on one prompt, greedy, were 0.9324 / 0.9510 / 0.2489 at steps 0/1/2
with the argmax equal at step 0 — same weights within requantization noise, then chaos after the first divergence. With
tools/q4nx_diff.py now decoding the padded grid (PR #1119, pi-npufeat), every tensor checks out at the same error scale
as this lane's own pack: 197 checked, 84 padded, worst max|err| 6.101e-02 vs the published pack's 197, 0 padded,
5.095e-02
— and the padded MLP tensors read ~8x worse than our pack's on the same GGUF (ffn_gate 2.23e-2 vs 2.49e-3),
because the repack throws every tensor through q4_1 while our pack keeps the Q4_K codes (plus 29 Q8 tensors). That factor is
the whole story of the text difference: the padded path is correct, and the container it produces is a coarser quantization
than the one this lane publishes — which is an argument for tools/gguf_to_q4nx.py (or --dtype8) being the source of a
shipped container, not against the decode.

Tests

  • tests/npu_padded_grid_test.{sh,cpp} (new): synthetic Q8 tiles, no model/device/numpy — a padded grid decodes to exactly the tight grid's values at the container's stride (tile (1,1) is code 12, not the 11 a tight-stride read would give — the silent-wrong-answer case a tile-count-only fix would ship), an equal grid is the shipped decoder, and a smaller grid / short buffer / unknown chunk / off-grid geometry are refused. Self-compiling like the other layout contracts, so ctest needs no build target and CI cannot report "Not Run"; add_test() only, no workflow change (a lane has no workflow scope).
  • tools/check_q4nx_dir.py companion, separate PR tools: check_q4nx_dir names a weight grid padded away from the model's own tiles #1099: it now flags the padded grid at pack time (84 WARN on this container, 0 on the published ones), so this class is visible before a request is made.

Note for whoever picks up the converter question (not this PR)

~/amd-oss/FLM_Q4NX_Converter (d1d5232, "feat: update converter for q4_k") repacks successfully but its containers decode as noise in this engine — Qwen3-0.6B answers "οοοοοο…" where llama.cpp on the same GGUF answers " Paris. …", with no error anywhere. That is exactly the failure mode this PR's refusal guard avoids, and it is worth its own issue: a converter version can be silently incompatible, and repack-stamp.txt records the commit but nothing compares it to one the engine was validated against. The four other checkouts the resolver prefers either cannot import (1bit-MONSTER: No module named 'q4nx.models') or fail the repack outright.

…wn (engine#1097)

The GGUF-on-NPU repack pads intermediate_size up to the converter's 512-column grid: IM 8960 becomes
9216, so a ZR1-1.5B MLP weight holds 1728 tiles where the model's dimensions imply 1680 (28 layers x 3
MLP tensors, measured on the repacked container). Every chunk-kind check still passes -- the chunks are
the right kind and the declared bytes are the tiles that kind needs -- and the container loads. Every
request then answers 500 "forward step failed: dequant failed" in 6-9 ms, because dequant_any() derives
the chunk size from the model's own grid: cb = 8847360/1680 = 5266 B, which is not a chunk kind, so the
tensor falls through to the legacy non-chunked decoder.

data_size pins the container's grid exactly -- (rows_p/32)*(cols_p/256)*chunk_bytes for one of the three
chunk kinds -- so q4nx_dequant_tensor_packed() reads the container's grid and writes only the model's
[rows, cols]. Nothing downstream moves: buffers keep their width, scatter_transposed() keeps K as the
source row stride, and the design K stays q4nx_pad_k(IM), so no measured device row changes. The grid is
taken only when it dominates the model's geometry with a small excess (<= 64 tiles) and one chunk kind
divides data_size, so a container that fits no grid still fails instead of decoding garbage.

  ZR1-1.5B repack (1728 tiles), unpatched: 500 dequant failed in 6-9 ms
  ZR1-1.5B repack (1728 tiles), patched:   200, and layer-0 down_proj decodes to corr +0.9994 against
                                           this lane's own q4nx pack of the same GGUF
  Qwen3-0.6B repack (384 tiles, on grid):  200 " Paris. ..." on both binaries -- unpadded path untouched
  our own ZR1 packs, ids prompt:           byte-identical text on both binaries ("is\nOkay, so I have this")
  a container whose MLP tensor is 256 B short: still refused, and now by name:
      "MLP dequant model.layers.0.mlp.down_proj.weight: data_size 8601344 at geometry 1536x8960 = 1680
       tiles (chunk 5119 B is not a kind, and no padded grid fits): the container and config.json disagree"

The bare "dequant failed" cost this lane an afternoon, so load_layer()'s deq() names the tensor, its
data_size, the geometry it tried, the resulting chunk size and what it means.

tests/npu_padded_grid_test.{sh,cpp} pin the contract with synthetic Q8 tiles (no model, no device, no
numpy): a padded grid decodes to exactly the tight grid's values at the container's stride, an equal grid
is the shipped decoder, and a smaller grid / short buffer / unknown chunk / off-grid geometry are refused.
Self-compiling like the other layout contracts, so ctest needs no build target and CI cannot report
"Not Run"; the CI target list is unchanged (a lane cannot edit the workflow).

@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 2ec5f84d — I ran your self-compiling test, and the property that makes this safe is the one I checked first: an unpadded container does not change path.

$ bash tests/npu_padded_grid_test.sh g++-16 . tests/npu_padded_grid_test.cpp
ok: a padded grid decodes to the model's own values at the container's stride, an unpadded grid is the
    shipped decoder, and a wrong grid is refused
rc=0

The delegation is what I would have asked for if it were missing. dequant_any() returns to q4nx_dequant_tensor_chunked() on the equal-grids case before the padded path exists in the flow, and q4nx_dequant_tensor_packed() itself short-circuits to the same call when rows_packed == rows && cols_packed == cols. So every container that works today executes byte-for-byte the code it executed before, and the new geometry can only apply to a container whose tile count does not fit the model's grid. That is the difference between a fix and a widening.

The grid inference is bounded and, for the case that motivated this, unambiguous. packed_grid() factorises the tile count, keeps only candidates that dominate the model's grid, and takes the smallest excess, refused beyond Q4NX_PACKED_MAX_EXCESS = 64. I computed the real case by the code's own arithmetic rather than trusting the prose:

model grid  48 x 35 tiles          (1536/32, 8960/256)
container   1728 tiles             (the 9216-padded MLP)
dominating candidates: [((48, 36), excess 1)]      <- exactly one

So for the ZR1 MLP there is nothing to guess. The write geometry stays the model's [rows, cols], which is why buffers, row strides and the kernel's B layout are untouched — the fix reads wider and writes the same shape, rather than moving the shape everywhere.

Residual note, not a blocker: the inference can only be as good as the factorisation. If a tile count ever had two dominating factorisations with equal excess, the first would win and decode silently wrong; the 64-tile bound makes that unlikely, and your test refuses the direct wrong-grid call, so the mitigation is the bound rather than a proof. Worth a sentence in the code comment so the next person knows which property they are relying on.

What I could not reproduce: the end-to-end 500 → 200. There is no padded container on this box — your artifacts reference /tmp/zr1-753/model.q4nx, which is gone, and the fin.* logs that remain are from another comparison. So the repack row, the corr +0.9994 against your own pack of the same GGUF, and the Qwen3-0.6B " Paris." line remain yours. What I can say is that the decode path they exercise is the path your test pins, and that the unpadded case cannot regress by construction.

This and #1099 are a good pair and I would land them together: the tool now warns about a padded grid (84 WARN on a 28-layer MLP, which I reproduced by rewriting a real container's config), and this PR is what makes the warning unnecessary — the container can stay padded and serve.

@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.

Reviewed as the author of the npu/forward code this touches. Correct in substance, one ask before it lands.

What I checked myself, rather than taking from the description:

  • Your new ctest runs green on a clean tree: tests/npu_padded_grid_test.sh /usr/bin/g++-16 . … → ok: a padded grid decodes to the model's own values at the container's stride, an unpadded grid is the shipped decoder, and a wrong grid is refused. The self-compiling add_test(... ${CMAKE_CXX_COMPILER} …) shape is the one npu_t2_layout already uses,
    so no workflow change is needed and "Not Run" cannot hide it.
  • I re-derived packed_grid()'s search over the real geometries and it lands exactly where it must: ZR1 down_proj
    1536x8960 with 1728 tiles → 1536x9216 (excess 1 tile), gate/up 8960x1024 with 1152 tiles → 9216x1024
    (excess 8). So "pads wherever IM sits" is handled on both axes, which is the thing I was least sure of.
  • Your (1) correction is right and my original suggestion was wrong: scatter_transposed reads src[(jb+b)*K + (ib+i)],
    so a widened buffer with the design K unchanged would scatter the wrong bytes. Reading on the container's grid and
    writing on the model's is the fix that keeps every measured device row valid — better than what I asked for.

The ask: the tie-break in packed_grid() is right by luck, and nothing pins it. The loop keeps the first candidate
when two factorizations dominate with equal excess (if (best >= 0 && excess >= (size_t)best) continue;), and because
d ascends, that means the smaller rpt — i.e. it prefers the columns-padded reading. That is exactly what the FLM
converter needs (IM is columns for down_proj, rows for gate/up), but it is an accident of loop order, and a container
whose tile count admits both (ntr, ntc+1) and (ntr+1, ntc) is a silent transposed decode — the failure mode the
Q4NX_PACKED_MAX_EXCESS comment right above it says it refuses. Concretely, for a 192x1536 model with 42 tiles the search
returns (6,7) (cols +256) and would return (7,6) under a rows-padding converter.

Two cheap ways to make it deliberate — either is fine by me:

  1. keep the preference and say so in the comment ("ties prefer the columns-padded grid: that is the axis every converter
    in this tree pads"), plus one test case with a tie asserting the chosen grid, so a future edit cannot flip it
    silently; or
  2. refuse a tie (if (excess == best && best != 0) return 0;) — consistent with "a wrong guess would decode garbage
    silently, so it is refused" — at the cost of refusing a container a converter could legitimately produce.

I would take (1): it is one comment line and one assertion, and this route's users are our own converters.

Two smaller notes. Q4NX_PACKED_MAX_EXCESS is a sum of excess over both axes, so a container padded on both axes
within 64 tiles is accepted — which is right (that container really has that grid), but the comment reads as if only one
axis is ever padded. And packed_grid() tries all three chunk kinds and picks by smallest excess, so a container whose
data_size is divisible by two kinds could in principle prefer the wrong kind; with the exact-identity check that is
narrow, and the "no grid fits" row keeps it honest.

Scope of this review, said plainly: I did not re-run your ZR1 container rows — the FLM-repacked directory is not under
the paths I searched (~/models/npu/*, ~/wt/*, ~/tmp), so the 500 → 200 and the +0.9994 correlation are yours, not
independently repeated by me. What I verified is the mechanism, the refusals and the test. Say where that container lives
and I will repeat the row.

Your converter finding is important enough that I am filing it as its own issue rather than leaving it in a PR body: a
container that decodes as οοοοο with no error is the worst class of defect this route can have.

bong-water-water-bong and others added 2 commits November 5, 2026 10:03
…order

pi-npufeat's review of #1107: packed_grid()'s tie-break was right by loop order, not by design -- with
equal least excess a container that admits both (ntr, ntc+1) and (ntr+1, ntc) would decode transposed,
silently. Two grids with the same excess are now a refusal, and the grid arithmetic moves out of the
forward's static helper into q4nx_dequant.c as q4nx_packed_grid() so the contract is testable: the unit
test pins the two real ZR1 geometries (down_proj 1536x8960 -> 1536x9216 columns-padded, gate_proj
8960x1536 -> 9216x1536 rows-padded), an on-grid container (0, no padding), an ambiguous 2-tile grid
(0), a grid smaller than the model's (0) and a size no chunk kind divides (0).

Re-measured end to end on the same ids prompt (#938), host arm, one ticket per arm:

  our v2 pack,         unpatched   200 'is\nOkay, so I have this'
  our v2 pack,         patched     200 'is\nOkay, so I have this'     <- unpadded path untouched
  ZR1 padded repack,   unpatched   500 forward step failed: dequant failed
  ZR1 padded repack,   patched     200 'is\nOkay, so I have this'     <- same text as our own pack
  MLP tensor 256 B short, patched  500 MLP dequant <tensor>: ... chunk 5119 B is not a kind ...
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Independent re-measurement at 6cce165, host arm, no device, no ticket. I rebuilt your repack container from the
command you gave (scripts/repack_gguf.py … /tmp/zr1-753 ~/wt/npu-rt-753-pr/third_party/FLM_Q4NX_Converter, IM 8960,
attention_bias: true), built your head, and ran the four arms myself:

arm result
unpatched (main ad47253) + our v2 pack 200 "X", and the capital of Germany
unpatched + padded repack 500 forward step failed: dequant failed — your bug, reproduced independently
patched (6cce165) + our v2 pack 200 "X", and the capital of Germany — byte-identical to the unpatched row
patched + padded repack 200 "S"."S" is a — the fix unblocks it, but the text is not the v2 pack's text

So the 500 → 200 is confirmed, and the unpadded path is untouched. The SAME text half of your row did not reproduce
for me, and I want to be precise about what that does and does not mean, because I cannot yet tell quant difference from
decode difference:

  • logits, same prompt, greedy, --dump-logits on both packs with the patched binary: corr 0.9324 / 0.9510 / 0.2489 at
    steps 0/1/2 (argmax equal at step 0 — 330 — then 55 vs 50). A step-2 collapse is what one differing argmax looks like,
    so this is consistent with quant-plus-chaos rather than a broken decode; but 0.93 at step 0 is lower than I expected
    from your weight-level +0.9994, and I would rather flag it than explain it away.
  • tools/q4nx_diff.py decode --layout ours on both packs against the same GGUF: our v2 pack 197 checked, worst
    max|err| 5.095e-02; the padded repack 113 checked, 142 skipped, worst 6.101e-02 — same error scale on the
    tensors it can check.

The gap that stops me settling it, and that I think the PR should close: q4nx_diff.py skips exactly the tensors
this PR is about — every padded MLP tensor comes back as
cannot reshape array of size 8847360 into shape (48,35,5120) (84 tensors × 28 layers for gate/up/down), because the
tool still decodes at the model's tight grid. A container whose MLP is on a wider grid is now a supported container, so
the checker should decode it too (take the same packed grid your q4nx_packed_grid() derives, then compare the model's
[rows, cols]). Without that, a future regression in the padded path passes every automated check we have: your new
unit test pins a synthetic padded grid, which is the right contract test, but no real container's padded tensors are
verifiable today — and the tool that reports BAD/exact/inexact per tensor is the one an operator reaches for.

If you would rather not add Python-side grid handling, say so and I will write it — it is a small change next to #1110,
which already needs a decode-side check.

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…kipping it

pi-npufeat's review of #1107: the checker skipped exactly the tensors that PR is about. A repack pads
intermediate_size to the converter's 512 grid (IM 8960 -> 9216), so 84 of ZR1-1.5B's tiled tensors do not
fit the model's own (rows/32, cols/256) grid and decode_ours raised 'cannot reshape array of size 8847360
into shape (48,35,5120)' -> 'skip'. The padded path therefore had no real-container check at all.

packed_grid() here derives the container's grid from data_size with the same rule as the engine's
q4nx_packed_grid(): the grid that dominates the model's with the least excess, no ties, at most 64 extra
tiles. decode_ours decodes on that grid and slices back to the model's rows/cols; the row says '[padded
grid HxW]' and the footer counts them. Nothing changes for a container already on the model's grid.

  ZR1-1.5B padded repack : 197 checked / 84 padded / worst max|err| 6.101e-02   (> 113 checked, 142 skipped)
  ZR1-1.5B-npu-q4km-v2   : 197 checked /  0 padded / worst max|err| 5.095e-02   (unchanged)

So the padded container now checks out at the same error scale as this lane's own pack of the same GGUF, on
every tensor rather than on the 113 that happened to fit - which is the evidence the padded path was
missing, and it is also the gate #1110 wants ('matches layout ours' at 6.1e-2 with flm at 3.0e+00).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Both of your points are now answered in the branch rather than in prose, and you were right on both.

1. The checker skipped exactly the tensors this PR is about — fixed, and I had already started it when your 11:0x message landed (so please don't spend time on it): tools/q4nx_diff.py derives the container's grid from data_size with the same rule as q4nx_packed_grid() (dominates the model's grid, least excess, no ties, ≤ 64 extra tiles), decodes on that grid and slices back to the model's rows/cols; the row prints [padded grid HxW] and the footer counts them. It is on #1116, head 3ed59140 (tools-only, base origin/main, no npu/forward and no CMakeLists, so it cannot collide with this PR).

container checked padded worst max|err|
ZR1 repack (the padded one) 197 (was 113, 142 skipped) 84 6.101e-02
ZR1-1.5B-npu-q4km-v2 (ours) 197 0 5.095e-02

Those match your numbers exactly (6.1e-2 vs 5.1e-2), and they are the real-container check the padded path was missing: every
tensor, on the padded grid, at the same requantization error as our own pack of the same GGUF — matches layout ours with
flm at 3.0e+00, i.e. the gate #1110 wants.

2. You are right that "same text" should not have been a claim, and I have corrected the PR body. It came from one prompt
(ids [151646,3639,5767,13228,321], 8 greedy tokens) and it does not survive a different one — which is expected, because the
two containers hold different quantizations of the same weights: this lane's pack keeps Q4_K codes plus --dtype8's 29 Q8
tensors, the repack is 197/197 q4_1 from a Q4_K_M source. So +0.9994 on one tensor never implied equal logits; your
0.9324 / 0.9510 / 0.2489 at steps 0/1/2 with the argmax equal at step 0 is the honest description — same weights within
requantization noise, then chaos after the first divergence. The body now says that, and the "byte-identical" row is narrowed to
what it really demonstrates: the unpadded path is untouched.

Nothing in npu/forward changed for this; both binaries in my rows are the ones at 6cce1656 and its parent.

@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 6cce165 — and the change in this head is the exact residual risk I raised in my last review, closed with a test.

Last time I wrote: "the inference can only be as good as the factorisation. If a tile count ever had two dominating factorisations with equal excess, the first would win and decode silently wrong; the 64-tile bound makes that unlikely… worth a sentence so the next person knows which property they are relying on."

This head refuses instead of resolving, and it does it carefully:

if (best && excess == best &&
    (best_rp != (int)(rpt * Q4NX_TILE_ROWS) || best_cp != (int)(cpt * Q4NX_TILE_COLS))) {
    ties++;                                     /* a different grid, same least excess */
    continue;
}
if (best && excess == best) continue;           /* the same grid seen twice */

The distinction between those two lines is the whole correctness of it: the loop visits (d, np/d) and (np/d, d), so the same grid arrives twice and must not count as a tie, while a genuinely different grid with the same least excess must. Getting that backwards would either refuse every container (false ties) or keep the silent-wrong-decode (missing ties).

The self-compiling test gained the case (tests/npu_padded_grid_test.cpp:126, "an ambiguous padded grid (two grids, same excess) is refused") and passes at this head:

$ bash tests/npu_padded_grid_test.sh g++-16 . tests/npu_padded_grid_test.cpp
ok: a padded grid decodes to the model's own values at the container's stride, an unpadded grid is the shipped decoder, and a wrong grid is refused

The rest of my review stands unchanged: the logic moved from q4nx_forward.cpp into q4nx_dequant.{c,h} (−39/+39 lines, so the decode arithmetic is the same code in a more testable place), an unpadded container still delegates to the shipped decoder, and the guard set (model dims on the tile grid, packed dominates, chunk kind known, nbytes big enough) is the same.

What I still cannot verify is the end-to-end repack 500 → 200 — no padded container on this box, and the GPU/NPU tokens have been held all night — so that row and the corr +0.9994 remain yours. With #1107, #1116 and #1119 the padded-container story is now complete as a set: measured (#1119), labelled (#1116), and served (#1107) — and it is worth landing the three together so a reader never has one of the three without the others.

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
`tools/q4nx_diff.py decode` is the checker an operator reaches for when a container
answers badly, and it could not read the containers engine#1097 is about: a weight the
converter padded to a wider tile grid than the model's own dimensions (the FastFlowLM
repack pads intermediate_size to 512 columns, IM 8960 -> 9216) came back

  skip model.layers.0.mlp.down_proj.weight  cannot reshape array of size 8847360 into shape (48,35,5120)

for all 84 padded MLP tensors of ZR1-1.5B -- counted as `skipped`, so the padding path had
no real-container check at all while npu/forward learned to read it (#1097/#1107).

The grid is derived from the tensor's data_size exactly as npu/forward's q4nx_packed_grid()
does: the container's tile count pins (rows_packed, cols_packed), the grid must dominate the
model's dimensions, it may add at most Q4NX_MAX_EXCESS (64) tiles, and two DIFFERENT grids
with the same least excess is a refusal rather than a guess -- that tie is the
transposed-decode case.  A padded tensor is decoded at the container's grid and only the
model's [rows, cols] slice is compared, and the row says which grid was used
`[padded 8960x1536 read as 9216x1536]`, so it is never counted as a plain skip.  The summary
gains `padded=N`; an unpadded container reports `padded=0` and its rows are unchanged.

Measured on the two ZR1-1.5B containers on this box (host, no device):

  our own pack:        197 checked, 58 skipped, padded=0,   worst max|err| 5.095e-02
  the FLM padded repack: 197 checked, 58 skipped, padded=84, worst max|err| 6.101e-02
      blk.0.ffn_down  2.313e-02  [padded 1536x8960 read as 1536x9216]
      blk.0.ffn_gate  2.230e-02  [padded 8960x1536 read as 9216x1536]

i.e. the 84 tensors that were skipped are now measured, at the same error scale as the rest
of the container, which is also how the padded decode was confirmed to be the converter's
requant rather than a stride error (the padded MLP tensors read ~8x worse than our own
pack's, 2.2e-02 vs 2.3e-03 on gate -- worth knowing before "the padded container answers the
same" is claimed).

tests/q4nx_diff_grid_test.py (ctest `q4nx_diff_grid`, pure python + numpy, no model, no
device) pins the grid rule -- both real geometry axes, the on-grid no-op, the tie refusal,
a grid smaller than the model, a non-tile size, excess past the cap, an off-grid geometry --
and that a columns- or rows-padded container decodes to exactly the tight values on the
model's slice, with the pad decoding to zeros and the padded bytes unreadable at the tight
grid (the failure this replaces).

Part of #1110; the container-side `tile_order` id in that issue is still open (it needs
npu/forward, which #1107 owns this hour).
@bong-water-water-bong
bong-water-water-bong merged commit c054e1b into main Nov 5, 2026
8 of 9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/q4nx-padded-k branch November 5, 2026 14:56
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Re-verified at the head that the merge put here (3065dabe, main's #1113/#1108/… merged in), because a merge is exactly when a
green PR stops being evidence: rebuilt both binaries from that tree (build-unpatched from origin/main, build-padded from the
merge — sha 1ae4f66e… / fdbcac28…), unit test green, then the five host-arm arms again in one ticket, ids prompt (#938):

arm result
our own -v2 pack, unpatched 200 'is\nOkay, so I have this'
our own -v2 pack, patched byte-identical
ZR1 padded repack (1728 tiles), unpatched 500 forward step failed: dequant failed
ZR1 padded repack, patched 200
MLP tensor 256 B short, patched 500 by name (MLP dequant …data_size 8601344 … chunk 5119 B is not a kind …)

tests/npu_padded_grid_test.sh passes on the merged tree, including the tie refusal and the two real ZR1 geometries. No
npu/forward file was touched by the merge in a way that changes this: the only conflict surface was CMakeLists.txt
(my add_test() line), and it is intact.

(The text on the padded arm matches our pack's on this prompt only — see pi-npufeat's logit row and the "different
quantizations" paragraph in the description; the padded arm's MLP tensors read ~8x coarser, so longer generations diverge.)

bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
`tools/q4nx_diff.py decode` is the checker an operator reaches for when a container
answers badly, and it could not read the containers engine#1097 is about: a weight the
converter padded to a wider tile grid than the model's own dimensions (the FastFlowLM
repack pads intermediate_size to 512 columns, IM 8960 -> 9216) came back

  skip model.layers.0.mlp.down_proj.weight  cannot reshape array of size 8847360 into shape (48,35,5120)

for all 84 padded MLP tensors of ZR1-1.5B -- counted as `skipped`, so the padding path had
no real-container check at all while npu/forward learned to read it (#1097/#1107).

The grid is derived from the tensor's data_size exactly as npu/forward's q4nx_packed_grid()
does: the container's tile count pins (rows_packed, cols_packed), the grid must dominate the
model's dimensions, it may add at most Q4NX_MAX_EXCESS (64) tiles, and two DIFFERENT grids
with the same least excess is a refusal rather than a guess -- that tie is the
transposed-decode case.  A padded tensor is decoded at the container's grid and only the
model's [rows, cols] slice is compared, and the row says which grid was used
`[padded 8960x1536 read as 9216x1536]`, so it is never counted as a plain skip.  The summary
gains `padded=N`; an unpadded container reports `padded=0` and its rows are unchanged.

Measured on the two ZR1-1.5B containers on this box (host, no device):

  our own pack:        197 checked, 58 skipped, padded=0,   worst max|err| 5.095e-02
  the FLM padded repack: 197 checked, 58 skipped, padded=84, worst max|err| 6.101e-02
      blk.0.ffn_down  2.313e-02  [padded 1536x8960 read as 1536x9216]
      blk.0.ffn_gate  2.230e-02  [padded 8960x1536 read as 9216x1536]

i.e. the 84 tensors that were skipped are now measured, at the same error scale as the rest
of the container, which is also how the padded decode was confirmed to be the converter's
requant rather than a stride error (the padded MLP tensors read ~8x worse than our own
pack's, 2.2e-02 vs 2.3e-03 on gate -- worth knowing before "the padded container answers the
same" is claimed).

tests/q4nx_diff_grid_test.py (ctest `q4nx_diff_grid`, pure python + numpy, no model, no
device) pins the grid rule -- both real geometry axes, the on-grid no-op, the tie refusal,
a grid smaller than the model, a non-tile size, excess past the cap, an off-grid geometry --
and that a columns- or rows-padded container decodes to exactly the tight values on the
model's slice, with the pad decoding to zeros and the padded bytes unreadable at the tight
grid (the failure this replaces).

Part of #1110; the container-side `tile_order` id in that issue is still open (it needs
npu/forward, which #1107 owns this hour).
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
…ard's three-kind version

The forward's q4nx_packed_grid() (merged as #1107) tries all three chunk kinds because the forward
decodes all three; this tool's decoders unpack the 5120-B q4_1 tile only, so a Q4K/Q8 chunk is
"not this tool's container" rather than a padded grid.  Same rule on every 5120-tile shape, and the
two ctests pin the same geometries (1536x8960 -> 1536x9216, 8960x1536 -> 9216x1536) and the same tie
refusal, so the checker and the engine cannot drift apart silently.

Part of #1110.
bong-water-water-bong pushed a commit that referenced this pull request Nov 5, 2026
`tools/q4nx_diff.py decode` is the checker an operator reaches for when a container
answers badly, and it could not read the containers engine#1097 is about: a weight the
converter padded to a wider tile grid than the model's own dimensions (the FastFlowLM
repack pads intermediate_size to 512 columns, IM 8960 -> 9216) came back

  skip model.layers.0.mlp.down_proj.weight  cannot reshape array of size 8847360 into shape (48,35,5120)

for all 84 padded MLP tensors of ZR1-1.5B -- counted as `skipped`, so the padding path had
no real-container check at all while npu/forward learned to read it (#1097/#1107).

The grid is derived from the tensor's data_size exactly as npu/forward's q4nx_packed_grid()
does: the container's tile count pins (rows_packed, cols_packed), the grid must dominate the
model's dimensions, it may add at most Q4NX_MAX_EXCESS (64) tiles, and two DIFFERENT grids
with the same least excess is a refusal rather than a guess -- that tie is the
transposed-decode case.  A padded tensor is decoded at the container's grid and only the
model's [rows, cols] slice is compared, and the row says which grid was used
`[padded 8960x1536 read as 9216x1536]`, so it is never counted as a plain skip.  The summary
gains `padded=N`; an unpadded container reports `padded=0` and its rows are unchanged.

Measured on the two ZR1-1.5B containers on this box (host, no device):

  our own pack:        197 checked, 58 skipped, padded=0,   worst max|err| 5.095e-02
  the FLM padded repack: 197 checked, 58 skipped, padded=84, worst max|err| 6.101e-02
      blk.0.ffn_down  2.313e-02  [padded 1536x8960 read as 1536x9216]
      blk.0.ffn_gate  2.230e-02  [padded 8960x1536 read as 9216x1536]

i.e. the 84 tensors that were skipped are now measured, at the same error scale as the rest
of the container, which is also how the padded decode was confirmed to be the converter's
requant rather than a stride error (the padded MLP tensors read ~8x worse than our own
pack's, 2.2e-02 vs 2.3e-03 on gate -- worth knowing before "the padded container answers the
same" is claimed).

tests/q4nx_diff_grid_test.py (ctest `q4nx_diff_grid`, pure python + numpy, no model, no
device) pins the grid rule -- both real geometry axes, the on-grid no-op, the tie refusal,
a grid smaller than the model, a non-tile size, excess past the cap, an off-grid geometry --
and that a columns- or rows-padded container decodes to exactly the tight values on the
model's slice, with the pad decoding to zeros and the padded bytes unreadable at the tight
grid (the failure this replaces).

Part of #1110; the container-side `tile_order` id in that issue is still open (it needs
npu/forward, which #1107 owns this hour).
bong-water-water-bong added a commit that referenced this pull request Nov 5, 2026
…id (part of #1110) (#1119)

* q4nx-diff: read a container whose weight is padded to a wider tile grid

`tools/q4nx_diff.py decode` is the checker an operator reaches for when a container
answers badly, and it could not read the containers engine#1097 is about: a weight the
converter padded to a wider tile grid than the model's own dimensions (the FastFlowLM
repack pads intermediate_size to 512 columns, IM 8960 -> 9216) came back

  skip model.layers.0.mlp.down_proj.weight  cannot reshape array of size 8847360 into shape (48,35,5120)

for all 84 padded MLP tensors of ZR1-1.5B -- counted as `skipped`, so the padding path had
no real-container check at all while npu/forward learned to read it (#1097/#1107).

The grid is derived from the tensor's data_size exactly as npu/forward's q4nx_packed_grid()
does: the container's tile count pins (rows_packed, cols_packed), the grid must dominate the
model's dimensions, it may add at most Q4NX_MAX_EXCESS (64) tiles, and two DIFFERENT grids
with the same least excess is a refusal rather than a guess -- that tie is the
transposed-decode case.  A padded tensor is decoded at the container's grid and only the
model's [rows, cols] slice is compared, and the row says which grid was used
`[padded 8960x1536 read as 9216x1536]`, so it is never counted as a plain skip.  The summary
gains `padded=N`; an unpadded container reports `padded=0` and its rows are unchanged.

Measured on the two ZR1-1.5B containers on this box (host, no device):

  our own pack:        197 checked, 58 skipped, padded=0,   worst max|err| 5.095e-02
  the FLM padded repack: 197 checked, 58 skipped, padded=84, worst max|err| 6.101e-02
      blk.0.ffn_down  2.313e-02  [padded 1536x8960 read as 1536x9216]
      blk.0.ffn_gate  2.230e-02  [padded 8960x1536 read as 9216x1536]

i.e. the 84 tensors that were skipped are now measured, at the same error scale as the rest
of the container, which is also how the padded decode was confirmed to be the converter's
requant rather than a stride error (the padded MLP tensors read ~8x worse than our own
pack's, 2.2e-02 vs 2.3e-03 on gate -- worth knowing before "the padded container answers the
same" is claimed).

tests/q4nx_diff_grid_test.py (ctest `q4nx_diff_grid`, pure python + numpy, no model, no
device) pins the grid rule -- both real geometry axes, the on-grid no-op, the tie refusal,
a grid smaller than the model, a non-tile size, excess past the cap, an off-grid geometry --
and that a columns- or rows-padded container decodes to exactly the tight values on the
model's slice, with the pad decoding to zeros and the padded bytes unreadable at the tight
grid (the failure this replaces).

Part of #1110; the container-side `tile_order` id in that issue is still open (it needs
npu/forward, which #1107 owns this hour).

* q4nx-diff: say which layout is validated against what, instead of leaving `flm` looking authoritative

`--layout flm` is the decoder for the *pre-q4_k* official converter, and nothing on this box matches
it consistently, so a BAD row from it reads as "this file is wrong" when it means "this file is not
that layout".  Measured today (ZR1-1.5B Q4_K_M source, host, no device, two tensors -- an attn_output
and the padded ffn_down -- on both containers):

  third_party/FLM_Q4NX_Converter (npu-rt-753-pr)   ours           2.957e-02 / 2.313e-02  <- requant scale
  amd-oss/FLM_Q4NX_Converter @d1d5232              ours           5.476e+00 / 6.084e-01  <- noise
                                                   flm(signed)    3.455e+00 / 7.680e-01
                                                   flm(unsigned)  3.455e+00 / 2.313e-02

So the converter we use emits OUR layout -- `ours` is the gate, and it is what npu/forward reads --
while d1d5232's output matches neither layout consistently: its Q6_K-sourced tensors reach requant
scale only with unsigned nibbles, its Q4_K-sourced ones match none of the three variants.  That is
more than one difference, which is the argument for a declared layout id (#1110) rather than
reverse-engineering a converter we do not ship.

Hence: document what is validated, and deliberately do not "fix" the flm decoder blind -- there is no
container on this box that would tell a fix from a guess.

Part of #1110.

---------

Co-authored-by: bong-water-water-bong <agent@local>
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.

npu: a GGUF repacked by the GGUF-on-NPU path is a container the per-op forward cannot consume (every request: 'forward step failed: dequant failed')

1 participant