Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/npu.md
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,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 <model.gguf> <out dir> [--tokenizer <dir>]` 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 <model.gguf>
<out dir>` 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
Expand Down
78 changes: 78 additions & 0 deletions tests/gguf_to_q4nx_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import hashlib
import os
import shutil
import struct
import subprocess
import sys
import tempfile
Expand Down Expand Up @@ -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("<I", raw, at)[0] == int(Q.Q4_0), "the type field is not where the test expects it"
struct.pack_into("<I", raw, at, 143)
with open(src, "wb") as f:
f.write(raw)
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}: ggml type 143 exits non-zero without a traceback", r.returncode != 0 and "Traceback" not in msg),
(f"{cmd}: ... names the number and what it is", "quantisation type 143" in msg and "PTQ1_0" 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 main():
ok = golden_tile()
ok = golden_tile8() and ok
Expand Down Expand Up @@ -509,6 +585,8 @@ def main():
print(f"FAIL moe-q8 {cmd[0]} exited {r.returncode}")
ok = False
ok = moe_check(os.path.join(d, "tiny-moe.gguf"), mq8, ok)
ok = other_arch_check(d, rng, ok)
ok = foreign_type_check(src, d, ok)
dense_3d_gguf(os.path.join(d, "dense3d.gguf"), rng)
ok = dense_3d_pack_check(os.path.join(d, "dense3d.gguf"), os.path.join(d, "dense3d"), ok)
ok = tokenizer_flags_check(src, out, ok)
Expand Down
213 changes: 213 additions & 0 deletions tests/qwen35_to_q4nx_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
# Copyright 2026 bong-water-water-bong
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""tools/qwen35_to_q4nx.py on a small qwen35 GGUF written here (engine#2013).

usage: tests/qwen35_to_q4nx_test.py (needs numpy; no gguf package, no model, no device)

One gated-linear-attention layer and one full-attention layer, at the 16 key groups and 48 value
heads the tool is written for and a hidden size of 256, so the file is a few megabytes. Checks:

* pack, then verify: every tensor of both layer families comes back, 0 bad tensors;
* the container has the names the forward looks up, and config.json the layer types;
* verify notices a container packed in the stored (not reordered) v-head order;
* the three inputs that would give a wrong container are refused by name and leave no
output directory: another architecture, a file that still carries prism.hadamard.* keys,
and a projection stored as PTQ1_0 (ggml type 143).
"""
import json
import os
import struct
import subprocess
import sys
import tempfile

import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
TOOL = os.path.join(HERE, "..", "tools", "qwen35_to_q4nx.py")

F32, Q4_0, PTQ1_0 = 0, 2, 143
U32, F32V, STR = 4, 6, 8
H, IM, V = 256, 512, 512 # hidden, feed-forward, vocabulary
HD_G, NKH, NVH = 128, 16, 48 # GDN head dim, key groups, value heads (the tool's constants)
HD_A, NH, NKV = 128, 2, 1 # attention head dim, heads, kv heads


def gstr(s):
b = s.encode()
return struct.pack("<Q", len(b)) + b


def write_gguf(path, kv, tensors):
"""GGUF v3: kv is [(key, type, value)], tensors is [(name, ggml dims, type, bytes)]."""
head = struct.pack("<IIQQ", 0x46554747, 3, len(tensors), len(kv))
for k, t, v in kv:
head += gstr(k) + struct.pack("<I", t)
head += gstr(v) if t == STR else struct.pack("<I" if t == U32 else "<f", v)
off, infos, blobs = 0, b"", []
for name, dims, tt, data in tensors:
infos += gstr(name) + struct.pack("<I", len(dims)) + struct.pack("<%dQ" % len(dims), *dims)
infos += struct.pack("<IQ", tt, off)
pad = (-len(data)) % 32
blobs.append(data + b"\0" * pad)
off += len(data) + pad
body = head + infos
body += b"\0" * ((-len(body)) % 32)
with open(path, "wb") as f:
f.write(body)
for b in blobs:
f.write(b)


def model(rng, arch="qwen35", extra_kv=(), qkv_type=Q4_0):
kv = [("general.architecture", STR, arch),
("qwen35.block_count", U32, 2), ("qwen35.full_attention_interval", U32, 2),
("qwen35.embedding_length", U32, H), ("qwen35.feed_forward_length", U32, IM),
("qwen35.attention.head_count", U32, NH), ("qwen35.attention.head_count_kv", U32, NKV),
("qwen35.attention.key_length", U32, HD_A), ("qwen35.context_length", U32, 4096),
("qwen35.attention.layer_norm_rms_epsilon", F32V, 1e-6), ("qwen35.rope.freq_base", F32V, 1e7),
("qwen35.ssm.state_size", U32, HD_G), ("qwen35.ssm.inner_size", U32, NVH * HD_G),
("qwen35.ssm.group_count", U32, NKH), ("qwen35.ssm.conv_kernel", U32, 4),
("tokenizer.ggml.bos_token_id", U32, 2), ("tokenizer.ggml.eos_token_id", U32, 1)]
kv += list(extra_kv)
out = []

def q4(name, K, N, tt=Q4_0):
# 32 weights per block: an fp16 scale, then 16 bytes of nibbles
blocks = np.zeros((N * K // 32, 18), np.uint8)
blocks[:, 0:2] = rng.uniform(0.01, 0.05, N * K // 32).astype("<f2").view(np.uint8).reshape(-1, 2)
blocks[:, 2:] = rng.integers(0, 256, (N * K // 32, 16), dtype=np.uint8)
data = blocks.tobytes()
if tt == PTQ1_0: # 28 bytes per 128 weights; the bytes are never decoded, only refused
data = bytes(N * K // 128 * 28)
out.append((name, (K, N), tt, data))

def f32(name, *dims):
out.append((name, dims, F32, rng.standard_normal(int(np.prod(dims))).astype("<f4").tobytes()))

cd = (2 * NKH + NVH) * HD_G # q | k | v rows of the fused projection
q4("token_embd.weight", H, V)
f32("output_norm.weight", H)
# layer 0: gated linear attention
q4("blk.0.attn_qkv.weight", H, cd, qkv_type)
q4("blk.0.attn_gate.weight", H, NVH * HD_G)
q4("blk.0.ssm_out.weight", NVH * HD_G, H)
f32("blk.0.ssm_alpha.weight", H, NVH)
f32("blk.0.ssm_beta.weight", H, NVH)
f32("blk.0.ssm_conv1d.weight", 4, cd)
f32("blk.0.ssm_norm.weight", HD_G)
f32("blk.0.ssm_a", NVH)
f32("blk.0.ssm_dt.bias", NVH)
# layer 1: full attention, q stored per head as [q | gate]
q4("blk.1.attn_q.weight", H, 2 * NH * HD_A)
q4("blk.1.attn_k.weight", H, NKV * HD_A)
q4("blk.1.attn_v.weight", H, NKV * HD_A)
q4("blk.1.attn_output.weight", NH * HD_A, H)
f32("blk.1.attn_q_norm.weight", HD_A)
f32("blk.1.attn_k_norm.weight", HD_A)
for l in (0, 1):
f32(f"blk.{l}.attn_norm.weight", H)
f32(f"blk.{l}.post_attention_norm.weight", H)
q4(f"blk.{l}.ffn_gate.weight", H, IM)
q4(f"blk.{l}.ffn_up.weight", H, IM)
q4(f"blk.{l}.ffn_down.weight", IM, H)
return kv, out


def run(*args):
r = subprocess.run([sys.executable, TOOL, *args], capture_output=True, text=True)
return r.returncode, r.stdout + r.stderr


def main():
rng = np.random.default_rng(7)
fails = 0

def check(good, label, detail=""):
nonlocal fails
print(f"{'ok ' if good else 'FAIL'} {label}")
if not good:
fails += 1
if detail:
print(detail[-1200:])

with tempfile.TemporaryDirectory() as d:
src, out = os.path.join(d, "tiny.gguf"), os.path.join(d, "q4nx")
with open(os.path.join(d, "tokenizer.json"), "w") as f:
f.write("{}")
write_gguf(src, *model(rng))

rc, log = run("pack", src, out)
check(rc == 0, "pack exits 0", log)
rc, log = run("verify", src, out)
check(rc == 0 and "0 bad tensors" in log, "verify: 0 bad tensors", log)

with open(os.path.join(out, "model.q4nx"), "rb") as f:
n = struct.unpack("<Q", f.read(8))[0]
table = json.loads(f.read(n))
want = {
"model.layers.0.linear_attn.qkv_proj.weight": [(2 * NKH + NVH) * HD_G // 32 * (H // 256), 5120],
"model.layers.0.self_attn.gate_proj.weight": [NVH * HD_G // 32 * (H // 256), 5120],
"model.layers.0.linear_attn.ssm_out_proj.weight": [H // 32 * (NVH * HD_G // 256), 5120],
"model.layers.0.linear_attn.ssm_alpha_proj.weight": [2, 1, 8704], # 48 rows padded to 64
"model.layers.0.linear_attn.ssm_beta_proj.weight": [2, 1, 8704],
"model.layers.0.linear_attn.ssm_conv1d.weight": [4 * (2 * NKH + NVH) * HD_G],
"model.layers.0.linear_attn.ssm_norm.weight": [HD_G],
"model.layers.0.linear_attn.ssm_a": [NVH],
"model.layers.0.linear_attn.ssm_dt.bias": [NVH],
"model.layers.1.self_attn.q_proj.weight": [2 * NH * HD_A // 32 * (H // 256), 5120],
"model.layers.1.self_attn.q_norm.weight": [HD_A],
"model.layers.1.post_attention_layernorm.weight": [H],
"model.embed_tokens.weight": [V // 32 * (H // 256), 5120],
"model.norm.weight": [H],
}
for name, shape in want.items():
got = table.get(name, {}).get("shape")
check(got == shape, f"{name} is in the container with shape {shape}", f"got {got}")
check("lm_head.weight" not in table, "no lm_head tensor for a GGUF without output.weight")
check(table.get("__metadata__", {}).get("converter") == "qwen35_to_q4nx.py",
"the container names its converter")
with open(os.path.join(out, "config.json")) as f:
cfg = json.load(f)
check(cfg.get("layer_types") == ["linear_attention", "full_attention"], "config.json: layer_types")
check(cfg.get("model_type") == "qwen3_5" and cfg.get("linear_num_value_heads") == NVH
and cfg.get("tie_word_embeddings") is True, "config.json: model_type, value heads, tied head")
check(os.path.exists(os.path.join(out, "tokenizer.json")), "tokenizer.json is copied from the GGUF's directory")

# verify has to notice the one thing a wrong pack gets wrong silently: the v-head order
stored = os.path.join(d, "stored")
rc, log = run("pack", src, stored, "--no-reorder")
check(rc == 0, "pack --no-reorder exits 0", log)
rc, log = run("verify", src, stored)
check(rc != 0 and "BAD" in log, "verify fails a container packed in the stored v-head order", log)

def refused(label, needle, **kw):
p, o = os.path.join(d, label + ".gguf"), os.path.join(d, label + "-out")
write_gguf(p, *model(rng, **kw))
rc, log = run("pack", p, o)
check(rc != 0 and needle in log and "Traceback" not in log, f"{label}: refused, naming {needle!r}", log)
check(not os.path.exists(o), f"{label}: no output directory is left")

refused("other-arch", "general.architecture is 'qwen3'", arch="qwen3")
refused("rotated", "prism.hadamard.axis", extra_kv=[("prism.hadamard.axis", U32, 1)])
refused("ternary", "ggml type 143", qkv_type=PTQ1_0)

print("FAILED" if fails else "qwen35_to_q4nx: ok")
return 1 if fails else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading