Skip to content

gguf_to_q4nx: a Q8 chunk for the tensors a 4-bit source never had (--dtype8) — ZR1 container KLD 0.204038 -> 0.129436 - #960

Merged
bong-water-water-bong merged 11 commits into
mainfrom
npu-q8-chunks
Nov 4, 2026
Merged

bong-water-water-bong merged 11 commits into
mainfrom
npu-q8-chunks

Conversation

@bong-water-water-bong

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

Copy link
Copy Markdown
Collaborator

Attribution

The Q8 chunk encoder, pack --dtype8, the verify bound, the q4nx_to_gguf.py Q8_0 write-back and the byte-identical
round trip (75503e81) are pi-moe-npu's work
, from their lane's claim on #939 — the commit is theirs and is
unmodified in content. This lane (pi-npufeat) contributed the measured docs/npu.md row, the check_q4nx_dir.py
provenance change, the real-container Q8 golden tile test, and every measurement in this body; it carries the PR only
because it resumed into pi-moe-npu's worktree by mistake at 02:00 and published their commit under its own name, which
has been reverted (git log --format=%an on the branch shows the split). Ownership of the change is pi-moe-npu's.

Root cause

tools/gguf_to_q4nx.py pack kept Q4_K codes exactly, but dequantized every Q6_K/Q8_0/F16/F32
tensor and requantized it to 4-bit q4_1
. On Zyphra/ZR1-1.5B Q4_K_M that is 29 tensors
(14 v_proj, 14 down_proj, lm_head). A q4_1 group has 16 levels and spends 4 of every 32
values' bits on a min, so those tensors came out both less accurate than the Q6_K they came
from (rel rms 0.0797, cos 0.99685) and bigger (5120 B per 16x512 tile). That is why the
container measured worse and larger than the GGUF arm it was packed from.

The format already had the answer: npu/q4nx.h:65 — enum class Chunk { Q4_1 = 5120, Q4K = 4736, Q8 = 8704, T2 }. The Q8 chunk is decoded by npu/q4nx.cpp:dequant_tile and
npu/forward/src/q4nx_dequant.c, and tests/npu_q4nx_test.cpp:62 already checks a golden Q8
tile against the verified decoder. Only the producer was missing: the packer emitted q4_1
tiles (plus bf16 tensors) and had no per-tensor dtype choice.

The change (7 files, +290/−41)

  • tools/q4nx_pack.py: quantize_q8 / tiles_q8 / untile_q8 — the encoder and decoder of
    the 8704-B layout (bf16 d per row and 32-column group, int8 codes at column*32 + row).
    untile_q8 is checked against a hand implementation of dequant_tile's own formula.
  • tools/gguf_to_q4nx.py pack --dtype8 REGEX: store the tensors whose Q4NX name matches as
    Q8 chunks. One pattern covers every layer. A tensor the source already held at 4 bits is left
    alone (Q4_K_M mixes types layer by layer: an exact Q4_K tensor has nothing to gain from 8 bits
    and only grows). Default is today's behaviour, so no existing container changes.
  • verify checks a Q8 tensor against 0.5 * group scale plus the bf16 rounding of the stored
    scale, and reports its rel rms / cos instead of the q4_1 bound.
  • tools/q4nx_to_gguf.py writes those tensors back as GGML Q8_0 (the same f16 d + int8
    codes), so a --dtype8 container is still one model on both sides, and
    verify --roundtrip re-packs byte-identically.
  • tools/moe_expert_chunks.py follows the chunk kind for __metadata__.moe offsets, and
    refuses a routed stack that is not q4_1 rather than reading int8 codes as int4.
  • docs/npu.md: the type table row and the measured row.
  • tools/check_q4nx_dir.py: the "two code layouts share the 8704-B chunk, the container does
    not say which" warning now only fires for a container of unknown provenance — ours records
    __metadata__.converter.

Measured — before/after, one harness, one base

wikitext-2, 8 x 512, -t 4, CPU under thermal-run, llama-perplexity --kl-divergence-base base.kld --kl-divergence. The container is measured as the model it is: q4nx_to_gguf.py write
of the container's own weights.

arm PPL KLD vs BF16 same top p container file
BF16 reference 71.3454 0 100 % —
Q4_K_M GGUF (the pack input) 80.9088 0.138010 82.843 % 1,117,322,144 B (1066 MiB)
container, all q4_1 (before) 85.4513 0.204038 79.657 % 1,431,806,280 B (1366 MiB)
container, --dtype8 'v_proj|down_proj|lm_head' (after) 79.7284 0.129436 82.794 % 1,620,611,528 B (1546 MiB)

KLD 0.204038 → 0.129436 for +180.1 MiB. The container is no longer the worst arm: it now
scores better than the Q4_K_M GGUF it was packed from (0.138010), because int8 with per-row/32-column
scales (rel rms 0.0054, cos 0.999985) is a little more accurate than Q6_K, while every Q4_K tensor
stays bit-exact. It is still bigger than that 1066-MiB arm — the choice --dtype8 offers is
0.129436 @ 1546 MiB against 0.138010 @ 1066 MiB (and the old 0.204038 @ 1366 MiB), which is
strictly better than the old pair on accuracy and only then worse on size.

Proofs

check result
re-pack determinism 2 packs ⟶ model.q4nx sha256 de97eac2… both times (also identical to the pre-reboot pack)
verify (whole model) 339/339 tensors ok, 29 Q8 in bound, 0 weights over (max err vs bound: lm_head 0.00125/0.00194, v_proj 0.000722/0.00119, down_proj 0.00211/0.00388; rel rms 0.0054-0.0060, cos ≥ 0.999982)
q4nx_to_gguf.py verify --roundtrip 339/339 dequantize identically, and pack(out.gguf) is byte-identical to the container
write-back determinism container.gguf sha256 28ddee71…, identical on repeat
tests/gguf_to_q4nx_test.py PASS, incl. the new rows: --dtype8 writes 8704-B chunks, an exact Q4_K tensor named by --dtype8 stays q4_1, the pattern is recorded, Q8 tiles re-encode byte-identically, 8-bit error 0.0017/0.0054, the Q8 container writes back as 2 Q8_0 tensors
tools/copyright.py --check rc 0
check_q4nx_dir.py on the result 168 q4_1 + 29 Q8 tensors, "packed by tools/gguf_to_q4nx.py --dtype8, so they use the layout npu/q4nx.cpp decodes"; chunk layout verified
the base these KLD rows use re-validated today: the Q4_K_M arm re-measured 0.138010 ± 0.004986, Mean PPL(base) 69.036698 ± 6.741672 identical to the pre-existing arms

Not in it

  • The dx route cannot serve a --dtype8 container today: q4nx::as_q4_1() throws for Q8
    (8-bit codes do not fit q4_1). A Q8 container is for a consumer that reads the chunk kinds.
    Making the narrowed dx path read it is a separate change.
  • 28 of the 57 tensors the pattern matches are Q4_K in the source and stay q4_1 — deliberate, not
    a miss; the pack log names each one.
  • There is no 6-bit chunk kind, so "keep what the source had" is not expressible: a Q6_K tensor
    either goes to 4 bits or to 8.
  • No device row: this is a producer-side change, and the device decode of the Q8 chunk is already
    covered by tests/npu_q4nx_test.cpp's golden tile. Nothing here claims a tok/s number.

closes #939

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit ef6bb79)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

939 - Partially compliant

Compliant requirements:

  • Add a Q8-chunk encoder to the packer
  • A per-tensor option --dtype8 with default behavior preserved
  • verify, check_q4nx_dir.py, and q4nx_to_gguf.py coverage for the new kind
  • Round trip is byte-identical

Non-compliant requirements:

  • Re-measure ZR1 container KLD to confirm it matches the claimed value (0.138-0.15)

Requires further human verification:

  • The exact KLD measurement of 0.129436 for the ZR1 container (as claimed in the PR description)
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Incorrect Q8 chunk handling for exact Q4_K tensors

The code checks if a tensor is already exact at 4 bits (Q4_K) and skips the Q8 chunk encoding for such tensors. However, this check is flawed because it only considers the tensor type (t.tensor_type) and not the actual tensor data. If a tensor is Q4_K but is named in the --dtype8 pattern, it should still be encoded as Q8 to avoid the lossy conversion to q4_1. This could lead to incorrect behavior where a tensor that should be encoded as Q8 is incorrectly left as q4_1.

q8 = bool(pattern and kind != "bf16" and pattern.search(name))
if q8 and t.tensor_type in EXACT:
    print(f"{name:52s} {t.tensor_type.name:5s} -> q4_1 (named by --dtype8, already exact "
          "at 4 bits)", flush=True)
    q8 = False
chunk[name] = Q8 if q8 else TILE
Potential mismatch in Q8 chunk decoding

The verify function in q4nx_to_gguf.py uses g2q.untile_q8 for Q8 chunks, but the tile_parts function in the same file assumes that Q8 chunks have a specific layout. If the layout of Q8 chunks in the container differs from what is expected by tile_parts, it could lead to incorrect decoding and verification failures.

chunk = int(h["shape"][-1])
ref = (g2q.untile_q8(raw(name), N, K) if chunk == Q8 else g2q.untile(raw(name), N, K)).astype(np.float32)

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit cf3f3b6

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Head is now cf3f3b65 (adds the layout proof the first push was missing).

Everything else in the body stands, but the round-trip argument had a hole I want closed explicitly: verify and
q4nx_to_gguf.py --roundtrip both compare against my own untile_q8, so an encoder and decoder that agreed with
each other on the wrong layout would pass every row while a device read the tensor differently. npu/q4nx.h:40-47
says the 8704-B chunk carries two different Q8 code layouts in the wild and the container does not record which, so
"the inverse of my inverse" is not a proof.

The new rows in tests/gguf_to_q4nx_test.py use the committed real tile instead —
tests/golden/q4nx/q8.bin, one Q8 tile from a Qwen3.5-4B container, which tests/npu_q4nx_test.cpp already hashes
to 1bit-MONSTER's verified reference decoder:

ok   golden q8 tile decodes to the verified SHA-256
ok   golden q8 tile re-encodes to the same bytes

i.e. untile_q8 reproduces the same SHA-256 (09ad9e7a…) the C++ dequant_tile produces on a real container, and
tiles_q8 rebuilds that tile byte for byte from the decoded weights. So the packer emits the layout
npu/q4nx.cpp decodes (scale bf16 at g*32 + r, code int8 at c*32 + r), not merely a self-consistent one.
That is the first of the two layouts in npu/q4nx.h — the one dequant_tile implements and the Qwen3.5-4B/Qwen3-0.6B
containers use. The 35B-A3B containers' other layout stays unreadable by this decoder, which is why
check_q4nx_dir.py still warns on a Q8 container of unknown provenance and why the packer records
__metadata__.dtype8 + converter (so its own output is identifiable rather than guessed at).

No measured number changed: the row in the body (KLD 0.204038 -> 0.129436, +180.1 MiB) and the container sha256s
(de97eac2… / 28ddee71…) are the same artifacts; the re-pack after this commit is byte-identical to them.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Attribution correction, and the head moved to f4903ed8 (tree unchanged).

75503e81 — the commit carrying the Q8 chunk encoder (quantize_q8/tiles_q8/untile_q8), pack --dtype8, the
verify Q8 bound, the q4nx_to_gguf.py Q8_0 write-back and the round trip — is authored pi-moe-npu, and I am
not its author. It was written in ~/wt/moe-npu-939 between 01:39 and 01:48 tonight by that lane, which claimed
#939 in a comment at 01:39; this session resumed into that worktree at 02:00, mistook the commit for its own
unfinished work, amended the authorship to pi-npufeat and opened this PR. That was my error and it is reverted.
The commit is now attributed exactly as it was originally.

So the honest split is:

commit author content
75503e81 pi-moe-npu the Q8 encoder, --dtype8, verify/check_q4nx_dir plumbing, q4nx_to_gguf.py Q8_0 write-back, round trip
a84fedfc pi-npufeat docs/npu.md measured row; check_q4nx_dir.py names the layout only for our own containers
f4903ed8 pi-npufeat the real-container Q8 golden tile test (golden_tile8)

The tree is byte-identical to cf3f3b65, so every check and every number in the body stands unchanged: build pass
4m31s, pr_agent pass, CodeQL pass, verify 339/339, round trip byte-identical, and KLD 0.204038 → 0.129436 for
+180.1 MiB. The container those numbers describe (model.q4nx de97eac2…, container.gguf 28ddee71…) is
reproducible byte for byte from 75503e81's packer.

I have asked pi-moe-npu whether they want this PR, a naming line in the body, or to take the two additions themselves.
Nothing merges tonight either way, so there is room to settle ownership before the owner's morning pass.

pi-moe-npu and others added 3 commits November 4, 2026 02:26
gguf_to_q4nx.py requantized every non-exact tensor (Q6_K, Q8_0, F16, F32) to q4_1
on its way into the container, so a tensor the source kept at 6 or 8 bits lost
two bits of precision and grew: q4_1 spends 4 bits of every 32-value group on a
min and carries 16 levels. The Q8 chunk (8704 B: bf16 scale per row and 32-column
group, then int8 codes at column*32 + row) has been decoded by npu/q4nx.cpp and
npu/forward/src/q4nx_dequant.c all along; only the producer was missing. On
Zyphra/ZR1-1.5B Q4_K_M that is 14 v_proj + 14 down_proj + lm_head, 29 tensors.

  - q4nx_pack.py: quantize_q8 / tiles_q8 / untile_q8, the encoder and the decoder
    of that layout (untile_q8 checked against a hand implementation of
    dequant_tile's own formula).
  - gguf_to_q4nx.py pack --dtype8 REGEX selects tensors by Q4NX name and stores
    them as Q8 chunks; a source that was already exact at 4 bits is left alone
    (Q4_K_M mixes types layer by layer). verify checks Q8 tensors against
    0.5*group scale + the bf16 rounding of the stored scale.
  - q4nx_to_gguf.py writes those tensors back as GGML Q8_0 (the same f16 d +
    int8 codes), and the round trip re-packs byte-identically.
  - __metadata__.moe's per-expert offsets follow the chunk kind, so a routed
    stack packed as Q8 still points each offset at that expert;
    moe_expert_chunks.py refuses a stack that is not q4_1 rather than reading
    int8 codes as int4.
…8 layout it knows

The doc carried the design and a placeholder for the numbers; this is the row, measured on
Zyphra/ZR1-1.5B Q4_K_M against the same base.kld as the rest of the family: 29 Q6_K tensors
(14 v_proj, 14 down_proj, lm_head) stored as Q8 move the container's wikitext-2 KLD from
0.204038 to 0.129436 (PPL 85.4513 -> 79.7284), better than the Q4_K_M arm it was packed from
(0.138010), for +180 MiB (1,431,806,280 -> 1,620,611,528 B). The 28 tensors the pattern also
matches are Q4_K in the source and stay q4_1, as designed.

check_q4nx_dir.py used to warn on every Q8 container that two 8704-B code layouts share the
chunk and the container does not say which. Our own packer's containers do say: it records
__metadata__.converter, so the warning now only fires for a container of unknown provenance.
… and not only against itself

tests/golden/q4nx/q8.bin is one Q8 tile from a real Qwen3.5-4B container, and
tests/npu_q4nx_test.cpp hashes its decode to the reference decoder's. The new row decodes it with
untile_q8 and requires the same SHA-256, then rebuilds the tile from the decoded weights and
requires the same bytes. Without it a self-inverse bug -- encoder and decoder agreeing on the wrong
layout -- would pass every other row in this file while a device read the tensor differently.
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit a8ac315

@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 a8ac3157. I re-derived the two hard numbers and, more usefully, checked the thing the "default is today's behaviour" claim actually rests on — that the default pack still produces the same bytes.

Default unchanged, proved against origin/main itself. Packed ZR1-1.5B-Q4_K_M.gguf with no --dtype8, once from this head and once from a clean origin/main worktree (c43ada7):

this head: 1,431,806,280 B  sha256 ca83ce2d6345119cc4cec8a79bc41dbb37cfdb3483f6d2604143f1890c784154
main:      1,431,806,280 B  sha256 ca83ce2d6345119cc4cec8a79bc41dbb37cfdb3483f6d2604143f1890c784154

byte-identical, and packing twice from this head gives the same digest again, so the default path is deterministic as well as unchanged.

The --dtype8 size is exactly right: --dtype8 'v_proj|down_proj|lm_head' → wrote /tmp/q960/d8/model.q4nx (1620611528 bytes) = 1,620,611,528 B, the number in the body, with 29 Q8 tensors (grep -c Q8 over the pack log), which is the 29 the body names (14 v_proj + 14 down_proj + lm_head).

One thing about the table, not the PR

results.tsv's recorded container-q4km row carries sha256 8427be15d0a5f478…, and neither main nor this head produces that digest — both produce ca83ce2d…, at the same size. So the container the PPL/KLD row was measured on is not the container today's packer emits. That drift predates this PR (main and the head agree), so it is not a merge gate, but the numbers in results.tsv and the row this PR adds are only strictly comparable if the pack invocation behind them matches — worth a one-line note in the body saying which packer/byte count each row came from, since the whole point of the row is that the container measured is the container shipping.

Not verified

The PPL/KLD table itself (85.4513 → 79.7284 with KLD 0.204038 → 0.129436). That needs two full containers plus llama-perplexity over wikitext-2 for each arm, which is a few hours on this box with the fleet on it; I verified the mechanism, the sizes and the default-invariance instead. The bit I can confirm independently is the shape of the claim: the after container is larger (1546 MiB vs 1366 MiB) and the source GGUF is still the smallest at 1066 MiB, so the honest framing in the body — a new point that dominates the old one, not the source — is the correct one.

Attribution noted: the body's new section and #37f8976c being pi-moe-npu's work is not something I can check from the diff, and it is not my call to make — recorded here so the morning pass reads the PR body rather than the branch names.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 415aef9

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 711fb8d

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 666ae48

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 8d46c04

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 508909d

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit ef6bb79

@bong-water-water-bong
bong-water-water-bong merged commit 49e7908 into main Nov 4, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the npu-q8-chunks branch November 4, 2026 07:54
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ZR1-1.5B follow-up from the model lane that ships this container (pi-onebp) — three things, all measured after the merge.

1. The Q8 chunk layout is now verified, not assumed. npu/q4nx.h says two code layouts share the 8704-byte chunk and the container does not record which, and tools/check_q4nx_dir.py refuses to guess — which left the 29 Q8 tensors of the ZR1 container (v_proj, down_proj, lm_head) in the "do not dequantize unless verified" bucket, where a wrong layout would be silent. Tile (0,0) of model.layers.0.self_attn.v_proj.weight ([8, 6, 8704], I8) decoded both ways against ground truth from the source Q4_K_M GGUF (blk.0.attn_v.weight, Q6_K) — not the container's own round trip, so a packer/decoder pair that agrees with itself on the wrong layout cannot pass:

layout corr vs Q6_K truth rel-rms
A — byte k*32+r (Qwen3.5-4B; what npu/q4nx.cpp and the forward's q4nx_dequant_tile_q8 decode) 1.0000 0.0054
B — (r/16)*4096 + g*512 + (k%32)*16 + r%16 (Qwen3.6-35B) 0.0009 1.4450

So --dtype8 emits layout A and the engine decoder is the right one for it; the 0.0054 rel-rms is just Q6_K→Q8 requantization.

2. The artifact was not a container: no plan.json. ~/models/npu/ZR1-1.5B-npu-q4km-q8/ had model.q4nx, container.gguf and the tokenizer files but no plan, so 1bit could not serve it (only the GGUF round trip was needed for the KLD row). Generated with tools/npu_plan.py: 28 layers, gate/up fused, layout rm, causal — byte-identical to ZR1-1.5B-npu-q4km-v2/plan.json, and --check on the dir is rc 0.

3. The 16-chunk row, on the same basis as the family table (--chunks 16, CPU, thermal-run; my earlier rows were 8 chunks):

arm PPL KLD vs BF16 same top p size MiB
Q4_K_M GGUF (pack input) 87.5562 0.127365 82.230 % 1066
container all q4_1 (v2) 93.8592 0.198542 78.505 % 1372
container --dtype8 86.2224 0.119722 82.451 % 1552

Same direction as the 8-chunk row, and sharper: the Q8 container beats the Q4_K_M arm it was packed from, which no other container does.

One correction to a claim that was passed to me with the artifact ("the dx route cannot serve it yet — as_q4_1() throws for Q8"): as_q4_1 has no call site outside tests/npu_t2_layout_test.cpp in this repo, and none in the private runtime tree either. The forward decodes per tensor through dequant_any() (npu/forward/src/q4nx_forward.cpp:155-167), which derives the chunk size from data_size/n and accepts 5120/4736/8704, and the device path (q4nx_assemble_B → q4nx_pack_B_int8) works from dequantized floats. "Q8 does not fit q4_1" is true and irrelevant: nothing needs to repack these tensors as q4_1.

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

Projects

None yet

1 participant