From e85124db50c15d8adbab48b5c90e10b6593fe85b Mon Sep 17 00:00:00 2001 From: bong-water-water-bong Date: Tue, 10 Nov 2026 16:27:56 -0400 Subject: [PATCH] npu-pack: a qwen35 GGUF is packed by an in-tree converter, and the GGUF converter refuses what it cannot name (Fixes #2013) tools/qwen35_to_q4nx.py is the converter the dev box's 27B container names in its own metadata; it lived only in a lane worktree. It now refuses a rotated (prism.hadamard.*) or PTQ1_0 input before writing, and its verify compares the conv1d value-band direction on bf16-rounded sums. tools/gguf_to_q4nx.py lists every tensor kind it has no name for in one refusal (a suffix-less blk.N.ssm_a was a ValueError), names the architecture and the converter that does pack it, refuses an unknown ggml tensor type by number instead of an enum traceback, and leaves no empty output directory. Co-Authored-By: Claude Fable 5.2 --- CMakeLists.txt | 5 + docs/npu.md | 30 ++ tests/gguf_to_q4nx_test.py | 78 +++++ tests/qwen35_to_q4nx_test.py | 213 +++++++++++++ tools/gguf_to_q4nx.py | 74 ++++- tools/qwen35_to_q4nx.py | 576 +++++++++++++++++++++++++++++++++++ 6 files changed, 971 insertions(+), 5 deletions(-) create mode 100755 tests/qwen35_to_q4nx_test.py create mode 100755 tools/qwen35_to_q4nx.py diff --git a/CMakeLists.txt b/CMakeLists.txt index f2af8544f..84763a7df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -322,6 +322,11 @@ add_python_test(tool_selftest_whisper_encoder_baseline ${CMAKE_SOURCE_DIR}/tools # engine#1780: the DFlash draft package's gate before a loader -- tile-grid sizes from the config, tile-header sanity and # the container's scalars against config.json, on a synthetic package with eight negative controls. Stdlib only. add_python_test(tool_selftest_dflash_draft_info ${CMAKE_SOURCE_DIR}/tools/dflash_draft_info.py --selftest) +# engine#2013: the qwen35 (gated linear attention) GGUF -> Q4NX converter. Its own selftest (tile round-trips, the +# value-head permutation), and a pack + verify of a small qwen35 GGUF the test writes itself, with the three inputs it +# has to refuse. numpy only: no gguf package, no model, no device. +add_python_test(tool_selftest_qwen35_to_q4nx ${CMAKE_SOURCE_DIR}/tools/qwen35_to_q4nx.py selftest) +add_python_test(qwen35_to_q4nx ${CMAKE_SOURCE_DIR}/tests/qwen35_to_q4nx_test.py) # engine#1777: the release row's own arm, which merged unregistered -- this sweep is what found it (engine#1820). add_test(NAME release_check_hrx_init_reason COMMAND bash ${CMAKE_SOURCE_DIR}/tests/release_check_hrx_init_reason_test.sh ${CMAKE_SOURCE_DIR}/os/mini/release-check.sh) # engine#1615: every process holding the GPU/NPU device must belong to a live gpu-hold/npu-hold ticket. Three diff --git a/docs/npu.md b/docs/npu.md index 5337e7d9c..4dd77581b 100644 --- a/docs/npu.md +++ b/docs/npu.md @@ -784,6 +784,36 @@ weights over the bound. The Q6_K tensors (half of `v_proj` and `down_proj`, and 0.079. Served on the NPU, the repacked model decoded 24/24 greedy tokens the same as a CPU reference of the repacked weights, with every step's logits at cosine 0.99995 or better. +### What the GGUF converter refuses, and the qwen35 converter + +`tools/gguf_to_q4nx.py` converts qwen3, qwen3moe and qwen2. Two other inputs are refused before +anything is written, with no output directory left behind (engine#2013): + +- **A GGUF of another architecture.** Every tensor kind without a Q4NX name is listed in one message, + with how many tensors of each there are. For `qwen35` the message points at the converter below. +- **A tensor type the installed `gguf` package does not know.** The refusal gives the number; + 143 is PTQ1_0, PrismML's ternary type, which `tools/ternary_to_q4_0.py` rewrites as Q4_0. + +`tools/qwen35_to_q4nx.py pack [--tokenizer ]` packs a **qwen35** GGUF: +layers of gated linear attention (GDN) with a full-attention layer every +`qwen35.full_attention_interval`. It writes the names the forward looks up +(`linear_attn.qkv_proj`, `self_attn.gate_proj`, `linear_attn.ssm_*`), reorders the 48 value heads +from the stored order to the grouping the forward expects, and stores `ssm_alpha`/`ssm_beta` as Q8 +tiles padded to 64 rows. It reads the GGUF itself and needs only numpy. `verify +` checks every tensor of a container against the GGUF, including the direction of the +value-head reorder. + +Its input has to be a plain qwen35 GGUF in Q4_0 or Q4_1. It refuses, by name: + +- a file that still carries `prism.hadamard.*` keys: the weights are in a rotated basis, the tool + writes no sign table, and the container would load and answer wrongly; +- PTQ1_0 tensors (ggml type 143). + +So a PrismML ternary GGUF needs two steps before this one: PTQ1_0 to Q4_0 +(`tools/ternary_to_q4_0.py`, which keeps the rotation) and the un-rotation, which is not in this +tree. The 27B container on the dev box (`__metadata__.converter = qwen35_to_q4nx.py`) was made from +an un-rotated Q4_0 GGUF. + ### Routed expert stacks A MoE GGUF keeps its routed experts as one three-dimensional tensor per projection diff --git a/tests/gguf_to_q4nx_test.py b/tests/gguf_to_q4nx_test.py index 7b4cbc036..7eb4bbc6e 100755 --- a/tests/gguf_to_q4nx_test.py +++ b/tests/gguf_to_q4nx_test.py @@ -26,6 +26,7 @@ import hashlib import os import shutil +import struct import subprocess import sys import tempfile @@ -465,6 +466,81 @@ def rope_scaling_check(gguf_path, out, kind, ok): return ok and good and not left +def other_arch_check(d, rng, ok): + """A GGUF of an architecture this converter has no names for (engine#2013). + + The first unnamed tensor used to be the whole message (`no Q4NX name for blk.0.attn_gate.weight`), a + suffix-less one (blk.N.ssm_a) was a ValueError, and the empty output directory stayed behind. + """ + tool = os.path.join(HERE, "..", "tools", "gguf_to_q4nx.py") + src, out = os.path.join(d, "gdn.gguf"), os.path.join(d, "gdn-out") + w = gguf.GGUFWriter(src, "qwen35") + w.add_block_count(1) + f = lambda *s: rng.standard_normal(s).astype(np.float32) # noqa: E731 + w.add_tensor("token_embd.weight", f(512, 256)) + w.add_tensor("output_norm.weight", f(256)) + w.add_tensor("blk.0.attn_norm.weight", f(256)) + w.add_tensor("blk.0.attn_gate.weight", f(256, 256)) + w.add_tensor("blk.0.ssm_out.weight", f(256, 256)) + w.add_tensor("blk.0.ssm_a", f(48)) + w.write_header_to_file() + w.write_kv_data_to_file() + w.write_tensors_to_file() + w.close() + checks = [] + for cmd in ("pack", "verify"): + r = subprocess.run([sys.executable, tool, cmd, src, out], capture_output=True, text=True) + msg = r.stdout + r.stderr + checks += [ + (f"{cmd}: a qwen35 GGUF exits non-zero without a traceback", r.returncode != 0 and "Traceback" not in msg), + (f"{cmd}: ... keeps the first unnamed tensor's name", "no Q4NX name for blk.0.attn_gate.weight" in msg), + (f"{cmd}: ... lists every unnamed kind, the suffix-less one too", + all(k in msg for k in ("blk.N.attn_gate.weight x1", "blk.N.ssm_out.weight x1", "blk.N.ssm_a x1"))), + (f"{cmd}: ... names the architecture and the converter that does pack it", + "'qwen35'" in msg and "tools/qwen35_to_q4nx.py" in msg), + ] + if not all(g for _, g in checks): + print(msg[-800:]) + checks.append(("no output directory was created for it", not os.path.exists(out))) + for label, good in checks: + print(f"{'ok ' if good else 'FAIL'} {label}") + ok = ok and good + return ok + + +def foreign_type_check(good_gguf, d, ok): + """A tensor type the gguf package does not know is refused by number, not as an enum ValueError (engine#2013). + + The file is a valid GGUF with one tensor's type field rewritten to 143 (PTQ1_0), which is what a PrismML + ternary GGUF looks like to the stock reader. + """ + tool = os.path.join(HERE, "..", "tools", "gguf_to_q4nx.py") + src, out = os.path.join(d, "t143.gguf"), os.path.join(d, "t143-out") + with open(good_gguf, "rb") as f: + raw = bytearray(f.read()) + name = b"blk.0.attn_q.weight" + at = raw.index(name) + len(name) + 4 + 2 * 8 # name, n_dims (u32), two u64 dims, then the type + assert struct.unpack_from("