From b7635cd624082a4354cd7e551f922dd51a52a28a Mon Sep 17 00:00:00 2001 From: Circle-Cheng Date: Thu, 3 Sep 2026 19:33:23 +0800 Subject: [PATCH] feat(gguf): implement Q8_0 dequantization dequant.py declared Q8_0 in BLOCK_SHAPE, GGML_NAME and __all__ but had no dequant_q8_0, so dequantize() raised NotImplementedError on the format that the most widely distributed Gemma-4 GGUFs (unsloth's UD-*_XL dynamic quants) use on attention projections, the dense FFN and token_embd -- 237 of 658 tensors in the 26B-A4B UD-Q6_K_XL file. dequant_q8_0 follows the ggml block_q8_0 layout: fp16 scale d + 32 int8 quants, w = d*q with no offset. Tests round-trip through a reference quantizer mirroring quantize_row_q8_0 (d = max|w|/127, q = round(w/d)), bounding the error at half a quantization step, plus a hand-computed exact-values case and a dequantize() dispatch check. All pure torch / CPU. Fixes #358 --- python/freetoken/models/gguf/dequant.py | 16 ++++- tests/models/test_dequant_q8_0.py | 86 +++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/models/test_dequant_q8_0.py diff --git a/python/freetoken/models/gguf/dequant.py b/python/freetoken/models/gguf/dequant.py index 77c3ea010..08f0289b9 100644 --- a/python/freetoken/models/gguf/dequant.py +++ b/python/freetoken/models/gguf/dequant.py @@ -1,5 +1,5 @@ """GGML block-quant dequantization in pure torch (the formats this repo's GGUF -checkpoints use: Q4_0, Q6_K, plus trivial F32/F16/BF16). +checkpoints use: Q4_0, Q8_0, Q6_K, plus trivial F32/F16/BF16). This is the *reference / CPU* path, NOT the engine's hot path: GGUF weights stay packed and are dequantized inside the borrowed ggml CUDA kernels (see @@ -79,6 +79,18 @@ def dequant_q4_0(raw: torch.Tensor, out_dtype: torch.dtype) -> torch.Tensor: return ((q - 8.0) * d).reshape(-1).to(out_dtype) +def dequant_q8_0(raw: torch.Tensor, out_dtype: torch.dtype) -> torch.Tensor: + """Q8_0: per 32-elem block = fp16 scale ``d`` + 32 int8 quants; ``w = d*q``. + + Unlike Q4_0 there is no offset — ggml's quantize_row_q8_0 stores + ``q = round(w / d)`` with ``d = max|w| / 127`` per block. + """ + raw = raw.reshape(-1, 34) + d = _f16_scales(raw, 0, 2) # [N,1] + q = raw[:, 2:34].contiguous().view(torch.int8).to(torch.float32) # [N,32] + return (q * d).reshape(-1).to(out_dtype) + + def dequant_q6_k(raw: torch.Tensor, out_dtype: torch.dtype) -> torch.Tensor: """Q6_K: 256-elem super-block = 128B low nibbles + 64B high 2-bits + 16 int8 sub-scales + fp16 ``d``. Direct vectorization of ggml's two-half loop.""" @@ -117,6 +129,7 @@ def dequant_q6_k(raw: torch.Tensor, out_dtype: torch.dtype) -> torch.Tensor: _DEQUANT = { GGML_Q4_0: dequant_q4_0, + GGML_Q8_0: dequant_q8_0, GGML_Q6_K: dequant_q6_k, } @@ -148,6 +161,7 @@ def dequantize(raw: torch.Tensor, ggml_type: int, out_dtype: torch.dtype) -> tor "BLOCK_SHAPE", "row_bytes", "dequant_q4_0", + "dequant_q8_0", "dequant_q6_k", "dequantize", ] diff --git a/tests/models/test_dequant_q8_0.py b/tests/models/test_dequant_q8_0.py new file mode 100644 index 000000000..5de8dbfe6 --- /dev/null +++ b/tests/models/test_dequant_q8_0.py @@ -0,0 +1,86 @@ +"""dequant_q8_0 correctness (issue #358). + +Q8_0 layout per ggml block_q8_0: fp16 scale ``d`` + 32 int8 quants ``q``, +``w = d * q`` with no offset. The dequantizer is validated by round-tripping +through a reference quantizer that mirrors ``ggml's quantize_row_q8_0`` +(``d = max|w|/127``, ``q = round(w/d)``), which bounds the reconstruction +error to half a quantization step. +""" + +import struct + +import pytest +import torch + +from freetoken.models.gguf.dequant import ( + BLOCK_SHAPE, + GGML_Q8_0, + dequant_q8_0, + dequantize, + row_bytes, +) + + +def quantize_q8_0_reference(w: torch.Tensor) -> torch.Tensor: + """Pack a flat fp32 tensor into Q8_0 bytes exactly like ggml's quantizer.""" + assert w.numel() % 32 == 0 + blocks = w.reshape(-1, 32) + d = blocks.abs().amax(dim=1) / 127.0 + d = torch.where(d == 0, torch.ones_like(d), d) # all-zero block: scale 1, q all 0 + q = torch.round(blocks / d[:, None]).clamp(-127, 127).to(torch.int8) + out = torch.empty((blocks.shape[0], 34), dtype=torch.uint8) + out[:, 0:2] = torch.from_numpy( + struct.pack("= 2.0 * 126 / 127 + + +def test_row_bytes_q8_0(): + """The metadata table already matched ggml; keep it honest (32 elems, 34 bytes).""" + assert BLOCK_SHAPE[GGML_Q8_0] == (32, 34) + assert row_bytes(32, GGML_Q8_0) == 34 + assert row_bytes(320, GGML_Q8_0) == 340