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
8 changes: 8 additions & 0 deletions docs/npu.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ thing this compaction cannot hold, and it is the thing the t2 chunk's own gate m
blocks for Bonsai-2. The n-major order also makes one `nb` (1,024 B) a single contiguous DMA run, and
the tile itself is streamed as one 2,176-B run: 2-bit must not cost contiguous-run length.

A model shipped in the **t2** form needs no pb16i image to feed this kernel: `from_t2(chunk, K, N)` takes
the container's own chunk and returns the same bytes `from_pb16i_image()` returns for the image it was
made from, by re-deriving the image with the packer's code rather than a second implementation of the
tile order. The two forms are the same weights in **different orders** (t2 is k-major within each 32-row
band, t2s is n-major within each 16x16 block: on the test fixture's 139,264 stream bytes, 135,476 differ),
so they are never interchangeable and `from_t2` is the only crossing — the test asserts both the equality
and that inequality, so a "simplification" that returns the chunk unchanged fails.

### From a GGUF

`tools/gguf_to_q4nx.py pack <model.gguf> <out dir> --tokenizer <dir>` writes a model
Expand Down
8 changes: 8 additions & 0 deletions tests/q4nx_t2s_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
2. expand back to **the pb16i image's own int4 code bytes**, block for block, on every tile;
3. decode back to the image's codes and multipliers, with the 128-K compaction losing no value;
4. be reproduced byte for byte by `stream()` (GGUF-side input) and `from_pb16i_image()` (image-side input);
4b. and be reproduced from a **t2 container chunk** by `from_t2()` — the pb16i image is not required;
5. refuse, with a named error, an image whose four 32-K multipliers inside a 128-K block are not equal;
6. refuse a stream carrying the reserved 2-bit pattern.

Expand All @@ -37,6 +38,7 @@
sys.path.insert(0, str(ROOT / "tools"))

import q4nx_pb16i # noqa: E402
import q4nx_t2 # noqa: E402
import q4nx_t2s as t2s # noqa: E402

K, N = 512, 1024 # whole phase-B tiles, 4 k-chunks x 32 columns
Expand Down Expand Up @@ -96,6 +98,12 @@ def main():
check("stream() == from_pb16i_image()", np.array_equal(st, st2))
check("the side table is the pb16i one", np.array_equal(e_ref, e_ref2) and mode == mode2)

# 4b. the container alone is enough: a t2 chunk re-streams to the same bytes, no pb16i image
chunk = q4nx_t2.pack(codes.T.copy(), dbits.T.copy().view(np.float16))
diff = int(np.count_nonzero(np.frombuffer(chunk, np.uint8) != st.reshape(-1)))
check("t2 and t2s are different byte orders", bool(diff), f"{diff} of {st.size} bytes differ")
check("from_t2(t2 chunk) == from_pb16i_image()", np.array_equal(t2s.from_t2(chunk, K, N), st))

# 5. an image whose 32-K multipliers differ inside a 128-K block is refused, not rounded
bad = img.copy()
flat = np.ascontiguousarray(bad[:, q4nx_pb16i.CODE_BYTES:]).view("<i2").reshape(tiles, 256).copy()
Expand Down
25 changes: 16 additions & 9 deletions tools/q4nx_t2.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,12 @@ def pack(codes, scales, pad=False):
return out.reshape(-1).tobytes()


def unpack(raw, K, N):
"""t2 bytes -> float32 [N, K] (the same arithmetic as npu/q4nx.cpp::dequant_tile)."""
def unpack_bits(raw, K, N):
"""t2 bytes -> (codes int8 [N][K] in {-1,0,1}, IEEE fp16 scale bits uint16 [N][K//GROUP]).

The scale stays as the bits the container stores, so a conversion back to an int16 image (see
`q4nx_t2s.from_t2`) re-derives the reference exponent instead of trusting a rounded float.
"""
if len(raw) != nbytes(K, N):
raise ValueError(f"a {N}x{K} t2 tensor is {nbytes(K, N)} B, got {len(raw)}")
rt, kt = N // ROWS, K // COLS
Expand All @@ -137,13 +141,16 @@ def unpack(raw, K, N):
bits = (packed >> (2 * s)) & 3
# the reserved pattern (3) decodes as 0, never as 2 * scale -- same rule as q4nx.cpp
quad[..., s] = np.where(bits == RESERVED, 0, bits.astype(np.int8) - 1).astype(np.int8)
codes = quad.reshape(rt * kt, ROWS, 64 * 4).transpose(0, 1, 2) # (tile, r, c) in tile order
sc = t[:, CODE_BYTES:].copy().view("<u2").reshape(rt * kt, ROWS, COLS // GROUP).astype(np.uint16)
scale = sc.view(np.float16).astype(np.float32)
codes3 = codes.reshape(rt, kt, ROWS, COLS).transpose(0, 2, 1, 3)
scale3 = scale.reshape(rt, kt, ROWS, COLS // GROUP).transpose(0, 2, 1, 3)
w = codes3.astype(np.float32) * np.repeat(scale3, GROUP, axis=3)
return w.reshape(N, K)
codes = quad.reshape(rt, kt, ROWS, COLS).transpose(0, 2, 1, 3).reshape(N, K)
sb = t[:, CODE_BYTES:].copy().view("<u2").reshape(rt, kt, ROWS, COLS // GROUP)
return codes, np.ascontiguousarray(sb.transpose(0, 2, 1, 3).reshape(N, K // GROUP))


def unpack(raw, K, N):
"""t2 bytes -> float32 [N, K] (the same arithmetic as npu/q4nx.cpp::dequant_tile)."""
codes, sb = unpack_bits(raw, K, N)
scale = sb.view(np.float16).astype(np.float32)
return codes.astype(np.float32) * np.repeat(scale, GROUP, axis=1)


def reserved_count(raw, K, N):
Expand Down
21 changes: 21 additions & 0 deletions tools/q4nx_t2s.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,17 @@
for block and the multipliers must be identical, which `tests/q4nx_t2s_test.py` checks on every tile of a
frozen fixture. Nothing here runs on the device; the kernel's own copy of the expansion is
`npu-kernels`, private.

The stream is also derivable from the container alone: `from_t2()` takes a `q4nx_t2` chunk and returns
the same bytes `from_pb16i_image()` returns for the image that chunk was made from, so a model shipped
in the t2 form needs no pb16i sidecar to feed the kernel. The two forms are the same weights in
different orders (t2 is k-major per 32-row band, this is n-major per 16x16 block) and are never
interchangeable, which is why the crossing is one function and not a byte copy.
"""
import numpy as np

import q4nx_t2

from q4nx_pb16i import CODE_BYTES as PB_CODE_BYTES, KC, NW, ROWS as PB_ROWS, TILE as PB_TILE, pack, w_bytes as pb_w_bytes

ROWS, COLS = 16, 16 # one mmul B block: k=16 x n=16
Expand Down Expand Up @@ -125,6 +133,19 @@ def from_pb16i_image(img, K, N):
return np.concatenate([area, ms_area], axis=1)


def from_t2(chunk, K, N):
"""A container's t2 chunk -> the t2s stream bytes, without the pb16i image.

Re-derives the image with the packer's own code (so the tile order and the int16 multipliers are
the packer's, not a second implementation of them) and then streams it; the result is byte-
identical to `from_pb16i_image()` on the image the chunk came from. pb16i geometry applies on
top of t2's, i.e. N must be a multiple of 1,024 as well as 32.
"""
codes, bits = q4nx_t2.unpack_bits(chunk, K, N) # [N][K] and [N][K//128], fp16 bits
img, _eref, _mode = pack(codes.T.copy(), bits.T.copy(), K, N)
return from_pb16i_image(img.reshape(-1), K, N)


def stream(codes, dbits, K, N):
"""(codes int8 [K][N] in {-1,0,1}, d fp16 bits [K/128][N]) -> (stream bytes, e_ref int16[N], mode)."""
img, e_ref, mode = pack(codes, dbits, K, N) # the pb16i image is the reference
Expand Down
Loading