Skip to content

tools: check_q4nx_dir names a weight grid padded away from the model's own tiles - #1099

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/q4nx-geometry-check
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/q4nx-geometry-check

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

tools/check_q4nx_dir.py verifies chunk kinds and declared byte counts, and it passed a container that cannot answer a
single request. This adds the one thing it was not looking at: whether a quantized tensor's tile count matches the
geometry the model's own dimensions imply.

Root cause it catches (engine#1097)

1bit serve -m <gguf> --device npu repacks through FastFlowLM's converter, which pads intermediate_size up to its
512-column grid — 8960 → 9216 on both sides. Every chunk check still passes: the chunks are the right kind and the
declared bytes are the tiles that kind needs. What changes is the tile count, 1728 where the model's dimensions imply
1680
, and the reader derives the chunk size from the unpadded grid (q4nx_forward.cpp:180, dequant_any), so it computes
cb = 8847360 / 1680 = 5266 B — not a chunk kind — falls through to the legacy non-chunked dequantizer and every request
answers {"error":{"message":"forward step failed: dequant failed"}} in ~7 ms. Measured on a Qwen2.5-1.5B finetune:
84 anomalies = 28 layers × 3 MLP tensors, and the model loads fine, which is why the failure looked like a forward bug.

What the check does

A model directory carries its own config.json, so the tile count can be compared against it with no dequantizing: rows/
cols per weight from hidden_size, num_attention_heads, num_key_value_heads, head_dim, intermediate_size,
vocab_size; then tiles = prod(shape[:-1]) against the model's own (rows/32)·(cols/256). A mismatch names which side
was padded and to what, and says what the engine will do with it:

WARN model.layers.0.mlp.down_proj.weight: holds 1728 tiles, the model's dimensions imply 1680 - columns padded
     8960 -> 9216 (the 512-column grid); this engine derives the chunk size from the unpadded grid, so the
     tensor fails to decode (engine#1097)
  • warnings by default — the default path, its output for a well-formed container and its exit code are unchanged;
  • --strict turns them into failures, for a gate that must not pass such a container;
  • a directory with no config.json skips the check (and an unreadable one says so once);
  • only tensors it can place are checked (lm_head, q/k/v/o_proj, gate/up/down_proj); MoE stacks and anything else are
    left alone, so no false positives on containers this tool does not model.

Checks

check result
FLM repack (~/.cache/1bit/q4nx/ZR1-1.5B-Q4_K_M, IM 8960 padded to 9216) 84 WARN, rc 0; rc 1 with --strict
published ZR1-1.5B-npu-q4km-v2 (the same model, gguf_to_q4nx.py layout) 0 WARN, rc 0; rc 0 with --strict
--self-test (3 new rows: the model's own grid is quiet, a 512-padded MLP grid warns on both sides with the padding named, a non-quantized tensor is never a geometry warning) PASS
--against on the published container, unchanged path layout A corr +1.0000, success line as before
tests/gguf_to_q4nx_test.py PASS
tools/copyright.py --check rc 0

Scope

This is the gate, not the fix. The forward change is npu/forward's: q4nx_dequant_tensor_chunked() uses
cols / 256 as the tile stride (npu/forward/src/q4nx_dequant.c:101-109), so an unpadded cb would decode the wrong tiles
rather than fail cleanly — the padded width has to be threaded through the reader and reach the design's K as well
(q4nx_pack.c:141), which is a padding decision I am not making in someone else's file. Reported with the audit in
engine#1097; no closes.

…s own tiles

The FastFlowLM converter pads intermediate_size up to its 512-column grid (8960 -> 9216 on both
sides), and every chunk-kind check still passes: the chunks are valid, the declared bytes are the
tiles that kind needs. What changes is the tile count -- 1728 where the model's dimensions imply
1680 -- and this engine's reader derives the chunk size from the unpadded grid, so
q4nx_dequant_tensor_chunked() is handed cb = 8847360/1680 = 5266 B, which is not a chunk kind, and
the container loads and then fails every request with "forward step failed: dequant failed"
(engine#1097, measured on a Qwen2.5-1.5B finetune: 84 anomalies, 28 layers x 3 MLP tensors).

A directory carries its own config.json, so the tile count can be checked against it without
dequantizing anything: rows/cols per weight from hidden_size, num_attention_heads,
num_key_value_heads, head_dim, intermediate_size, vocab_size, then tiles = prod(shape[:-1]) against
the model's own (rows/32)(cols/256). A mismatch names which side was padded and to what, and says
what the engine will do with it. Warnings by default, failures under --strict; a directory without
config.json skips the check, and the default path is unchanged.

  FLM repack (8960 padded to 9216): 84 WARN, rc 0        (rc 1 with --strict)
  published ZR1-1.5B-npu-q4km-v2:    0 WARN, rc 0        (rc 0 with --strict)

--self-test grows three rows (the model's own grid is quiet; a 512-padded MLP grid warns, both
sides, with the padding named; a non-quantized tensor is never a geometry warning). Also verified:
--against on the published container still reports layout A corr +1.0000, and
tests/gguf_to_q4nx_test.py PASS. This is the gate, not the fix: the forward change is npu/forward's
(npu/forward/src/q4nx_dequant.c uses cols/256 as the tile stride, so the padded width has to be
threaded through, which is the owner's call).

@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 f76d579b — the detection reproduces on a real container, the default path is untouched, and the MoE exclusion is right by construction. One nit, and it is the same fail-open family this tool exists to prevent.

The padded-grid check, reproduced independently. The FLM repack dir is not on this box, so I made the geometry real instead of trusting the row: a symlinked copy of the published ZR1-1.5B-npu-q4km-v2 container (config hidden 1536 / IM 8960 / 28 layers) with intermediate_size rewritten to the padded 9216:

$ check_q4nx_dir.py /tmp/cqtest
WARN model.layers.0.mlp.down_proj.weight: holds 1680 tiles, the model's dimensions imply 1728 -
     columns padded 9216 -> 8960 (the 512-column grid); this engine derives the chunk size from the
     unpadded grid, so the tensor fails to decode (engine#1097)
WARN model.layers.0.mlp.gate_proj.weight: … rows padded 9216 -> 8960 …
WARN model.layers.0.mlp.up_proj.weight:   … rows padded 9216 -> 8960 …
$ check_q4nx_dir.py /tmp/cqtest --strict   ->  rc 1
84 WARN

84 is exactly the count you quoted for the FLM repack, and it is 28 layers × 3 MLP tensors — so the number is structural, not incidental. Two things I liked more than the count: the message names which side was padded, and it is right about it — rows for gate_proj/up_proj (they produce IM) and columns for down_proj (it contracts over IM). That is the difference between a warning someone can act on and one they learn to ignore.

The other claims, checked:

published ZR1-1.5B-npu-q4km-v2            0 WARN, rc 0 default and --strict
directory with no config.json             geometry check skipped, chunk-layout still verified, rc 0
--self-test                               PASS, including "a 512-padded grid is named as padded while
                                          the model's own grid is quiet"
every line removed by this PR             exactly one, inside the self-test's own summary string —
                                          so "no behaviour change on the default path" is true of the code,
                                          not just of the intent
expected_rc()                             names only lm_head + the 7 per-layer projections, so an expert
                                          stack resolves to None and is skipped: MoE untouched by construction

The nit: a config.json that exists but is unusable fails open, silently. dims_from_config() returns None on any missing key, and geometry_warnings() returns [] when it has no dims — the same branch as "no config.json at all". So a container with a partial config gets no geometry check and no mention of it:

$ cat /tmp/cqtest/config.json   # hidden_size + intermediate_size only, and it still claims the padded 9216
$ check_q4nx_dir.py /tmp/cqtest
chunk layout verified: every quantized tensor's shape names a known chunk kind
rc=0                                     # the 84 warnings above are simply gone, with no note

The tool's own thesis is that a silent skip is the disease — so I would distinguish the two cases in one line: "config.json present but the dims this check needs are missing (num_attention_heads, …): geometry not checked". Same shape as the --strict story, just for input it cannot interpret, and it is what the no-config path already prints implicitly by being a documented skip.

I did not verify --against on the published container beyond the self-test row, and I have no FLM-repacked container here to run the row you measured — the 84 comes from reproducing the geometry, not from your file.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

Preparing review...

@bong-water-water-bong
bong-water-water-bong merged commit eb3afe8 into main Nov 5, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/q4nx-geometry-check branch November 5, 2026 11:56

@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-checked at bf79db2d: the delta from f76d579b is a rebase onto the merged work (#1094's serve.cpp/unified.cpp, #1093's console-tui, #1086's moe_expert_chunks.py) — not a fix for the nit, and the nit still reproduces:

$ python3 check_q4nx_dir.py /tmp/cq2        # config.json = {hidden_size, intermediate_size: 9216}, i.e. the padded grid
  chunk layout verified: every quantized tensor's shape names a known chunk kind
  (no geometry warning, no note)   rc=0

So the tool still fails open on a config.json it cannot read the dims from, in the same silent way as "no config at all". Verdict stays READY — it does not gate the merge and the check is correct on every complete config — but the one-line distinction is worth taking while the PR is open, because the failure it guards against ("container loads, every request fails with dequant failed") is exactly what a silent skip hides.

For the record on the rebase: mergeStateStatus is UNKNOWN at this moment (GitHub still computing), so I am not treating the head as verified for merge order; the earlier blob-level review at f76d579b stands for the file's content since the tool itself is unchanged in this push.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1097 - Partially compliant

Compliant requirements:

  • The PR adds a check in tools/check_q4nx_dir.py to verify that quantized tensors' tile counts match the model's own dimensions
  • The tool now detects when a container's weight grids were padded to a different grid than the engine reads
  • The tool warns about padded weight grids and fails under --strict mode
  • The tool correctly identifies the specific tensor causing the issue (engine#1097)

Non-compliant requirements:

  • The PR does not include documentation updates to reflect the new behavior

Requires further human verification:

  • The PR does not include updated tests to verify the new functionality
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Missing Documentation Updates

The PR addresses the root cause of engine#1097 by adding a geometry check to tools/check_q4nx_dir.py. However, the documentation in docs/npu.md and the wiki that mentions that GGUFs of six architectures "answer correctly this way" on the generic per-op path should be updated to reflect that this check is now required to prevent containers from being accepted that will fail at runtime.

A model directory's `config.json` also lets this tool compare each quantized tensor's **tile count**
with the geometry the model's own dimensions imply, which catches a container whose weight grids were
padded by a converter to a different grid than this engine reads (engine#1097: the FastFlowLM converter
pads `intermediate_size` to a 512-column grid, 8960 -> 9216, while the reader derives the chunk size from
the unpadded grid, so the container loads and every request fails with "dequant failed"). Those are
warnings by default and failures under `--strict`; a directory without `config.json` skips the check.
Incomplete Self-Test Coverage

The self-test in tools/check_q4nx_dir.py includes checks for the new geometry warnings, but it only tests a limited set of cases. Specifically, it tests a single tensor with a known padding scenario, but does not cover all possible padding scenarios or edge cases that could occur in real-world usage. This could lead to missed issues in containers with more complex padding patterns.

# Geometry: a tensor on the model's own grid is quiet, one a converter padded to the 512 grid
# warns (engine#1097 - the container loads, then every request fails with "dequant failed").
dims = dims_from_config({"hidden_size": 1536, "intermediate_size": 8960, "num_attention_heads": 12,
                         "num_key_value_heads": 2, "head_dim": 128, "vocab_size": 151936})
if not dims:
    bad.append("dims_from_config returned nothing for a complete config")
else:
    ok_table = {"model.layers.0.mlp.down_proj.weight": {"dtype": "I8", "shape": [48, 35, 5120]}}
    padded = {"model.layers.0.mlp.down_proj.weight": {"dtype": "I8", "shape": [1728, 5120]},
              "model.layers.0.mlp.gate_proj.weight": {"dtype": "I8", "shape": [1728, 5120]}}
    if geometry_warnings(ok_table, dims):
        bad.append(f"a 48x35 grid is the model's own grid but warned: {geometry_warnings(ok_table, dims)}")
    gw = geometry_warnings(padded, dims)
    if len(gw) != 2:
        bad.append(f"a 512-padded MLP grid produced {len(gw)} warning(s), expected 2")
    elif "columns padded 8960 -> 9216" not in gw[0] or "rows padded 8960 -> 9216" not in gw[1]:
        bad.append(f"the padding explanation is not what the grids say: {gw}")
    if geometry_warnings({"norm.weight": {"dtype": "F32", "shape": [1536]}}, dims):
        bad.append("a non-quantized tensor produced a geometry warning")

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant