* WIP OCP FP8 E4M3 support * Fix python type-checker * Materialize input_scales also for FP8 when converting * Fix conversion of https://huggingface.co/RedHatAI/Qwen3.8-27B-NVFP4 1. Per row FP8 wrongly fell to NVFP4 conversion, as NVFP4 checked on dims alone. Also check on dtype for FP8 2. Need to reshape FP8 QKV projections scales in the same way that weights are reshaped * Allow remaining FP8 tensors to be dequantized during conversion * Allow tensor-strategy for FP8-only compressed-tensor ckpts * Fix and test gguf-py behavior for FP8_E4M3 * Disallow QKV-fusion for FP8 temporarily * Fix conversion of fused qkvz & FP8 for Qwen3-Next * Avoid fusing FP8 for --fuse-gate-up-exps as we may have different ws * Emit warnings when not fusing FP8 during conversion * More FP8 conversion fixes * Add supported for strided copies to/from FP8 in CPU * remove unneeded scratch buffer from FP8 cpu cpy/dup path * add explanatory comments and TODOs * Add dedicated encoder and decoder tests for FP8 * Whitelist ggml_op_get_rows in metal instead of blacklisting required to fix failing CI, and white-listing should be more future-proof than adding FP8 to the black-list https://github.com/ggml-org/llama.cpp/actions/runs/38433916498/job/115422880633?pr=27512 * Fix more conversion bugs * Defer weight materialization during conversion * Blacklist fp8 in metal backend instead of white-list * Adjust indents * Fix lint + execute tests * Add fast CPU kernel for F8_E4M3 * Conversion fixes * Fix remaining gaps * Enable tied weights with shared scales * Make mandatory-F32 rules apply to FP8 during conversion * Fix python type checks https://github.com/ggml-org/llama.cpp/actions/runs/39765670052/job/119780379595?pr=27512 * Fix kaleidai unit-test failures https://github.com/ggml-org/llama.cpp/actions/runs/39765670125/job/119780383150 * Relax numeric precision constraints for pre-check * Leave fast-FP8 CPU on RISC-V for follow-up We already exclude arm from the fast-path * Allow output scale tensors to be located in any gguf shard * Move test_conversion_fp8.py test execution to behavior-check.yaml * Apply batched suggestions from code review Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co> * Move mixed compressed-tensor logic into dequant_fp8 + add unit-tests for it * Install all requirements also on Windows for behavior-check Fixes https://github.com/ggml-org/llama.cpp/actions/runs/40535852590/job/122300758223?pr=27512 * Unmap lazy tensors before clean-up in Windows Should fix https://github.com/ggml-org/llama.cpp/actions/runs/40539797824/job/122314782459?pr=27512 * LF line endings https://github.com/ggml-org/llama.cpp/actions/runs/40539798168/job/122314791118?pr=27512 * Extract FP8 tests from llama-arch to its own test-fixture See https://github.com/ggml-org/llama.cpp/pull/27512#discussion_r4400628221 * Update gguf-py/gguf/scripts/gguf_convert_endian.py Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co> --------- Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>
264 lines
11 KiB
Python
Executable File
264 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Test gguf.quants so that it exactly matches the C implementation of the (de)quantization
|
|
|
|
# NOTE: this is kind of a mess, but at least it worked for initially testing the Python implementations.
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from math import prod
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
import ctypes
|
|
import logging
|
|
import numpy as np
|
|
|
|
# Necessary to load the local gguf package
|
|
if "NO_LOCAL_GGUF" not in os.environ and (Path(__file__).parent.parent.parent / 'gguf-py').exists():
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
import gguf
|
|
from gguf.constants import GGMLQuantizationType
|
|
|
|
|
|
logger = logging.getLogger("test-quants")
|
|
|
|
|
|
c_float_p = ctypes.POINTER(ctypes.c_float)
|
|
|
|
|
|
class ggml_init_params(ctypes.Structure):
|
|
_fields_ = [
|
|
("mem_size", ctypes.c_size_t),
|
|
("mem_buffer", ctypes.c_void_p),
|
|
("no_alloc", ctypes.c_bool),
|
|
]
|
|
|
|
|
|
class GGMLQuants:
|
|
libggml: ctypes.CDLL
|
|
|
|
def __init__(self, libggml: Path):
|
|
self.libggml = ctypes.CDLL(str(libggml))
|
|
self.libggml.ggml_quantize_chunk.restype = ctypes.c_size_t
|
|
# enum ggml_type type,
|
|
# const float * src,
|
|
# void * dst,
|
|
# int64_t start,
|
|
# int64_t nrows,
|
|
# int64_t n_per_row,
|
|
# const float * imatrix) {
|
|
self.libggml.ggml_quantize_chunk.argtypes = (
|
|
ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_float),
|
|
ctypes.c_void_p,
|
|
ctypes.c_int64,
|
|
ctypes.c_int64,
|
|
ctypes.c_int64,
|
|
ctypes.POINTER(ctypes.c_float),
|
|
)
|
|
|
|
self.libggml.ggml_quantize_requires_imatrix.restype = ctypes.c_bool
|
|
self.libggml.ggml_quantize_requires_imatrix.argtypes = (ctypes.c_int,)
|
|
|
|
for t in (
|
|
"q4_0", "q4_1", "q5_0", "q5_1", "q8_0",
|
|
"q2_K", "q3_K", "q4_K", "q5_K", "q6_K",
|
|
"tq1_0", "tq2_0",
|
|
"mxfp4",
|
|
"nvfp4",
|
|
"q1_0", "q2_0",
|
|
"f8_e4m3",
|
|
"iq2_xxs", "iq2_xs", "iq2_s", "iq3_xxs", "iq3_s", "iq1_s", "iq1_m",
|
|
"iq4_nl", "iq4_xs",
|
|
):
|
|
dequant_func: ctypes._NamedFuncPointer = getattr(self.libggml, "dequantize_row_" + t)
|
|
dequant_func.restype = None
|
|
dequant_func.argtypes = (ctypes.c_void_p, ctypes.POINTER(ctypes.c_float), ctypes.c_int64)
|
|
|
|
self.libggml.ggml_fp16_to_fp32_row.restype = None
|
|
self.libggml.ggml_fp16_to_fp32_row.argtypes = (ctypes.POINTER(ctypes.c_uint16), ctypes.POINTER(ctypes.c_float), ctypes.c_int64)
|
|
self.libggml.ggml_bf16_to_fp32_row.restype = None
|
|
self.libggml.ggml_bf16_to_fp32_row.argtypes = (ctypes.POINTER(ctypes.c_uint16), ctypes.POINTER(ctypes.c_float), ctypes.c_int64)
|
|
|
|
self.libggml.ggml_init.argtypes = (ggml_init_params,)
|
|
|
|
self.libggml.ggml_init(ggml_init_params(1 * 1024 * 1024, 0, False))
|
|
|
|
def dequantize(self, tensor: np.ndarray, qtype: GGMLQuantizationType) -> np.ndarray:
|
|
result = np.zeros(gguf.quant_shape_from_byte_shape(tensor.shape, qtype), dtype=np.float32, order="C")
|
|
if qtype == GGMLQuantizationType.F32:
|
|
# no-op
|
|
result = tensor.view(np.float32)
|
|
elif qtype == GGMLQuantizationType.F16:
|
|
self.libggml.ggml_fp16_to_fp32_row(tensor.ctypes.data_as(ctypes.POINTER(ctypes.c_uint16)), result.ctypes.data_as(c_float_p), result.size)
|
|
elif qtype == GGMLQuantizationType.BF16:
|
|
self.libggml.ggml_bf16_to_fp32_row(tensor.ctypes.data_as(ctypes.POINTER(ctypes.c_uint16)), result.ctypes.data_as(c_float_p), result.size)
|
|
else:
|
|
lw_qname = qtype.name.lower()
|
|
if lw_qname[-1] == "k":
|
|
lw_qname = lw_qname[:-1] + "K"
|
|
dequant_func: ctypes._NamedFuncPointer = getattr(self.libggml, "dequantize_row_" + lw_qname)
|
|
dequant_func(tensor.ctypes.data_as(ctypes.c_void_p), result.ctypes.data_as(c_float_p), result.size)
|
|
return result
|
|
|
|
def quantize(self, data: np.ndarray, qtype: GGMLQuantizationType) -> np.ndarray:
|
|
result = np.zeros(gguf.quant_shape_to_byte_shape(data.shape, qtype), dtype=np.uint8, order="C")
|
|
if self.libggml.ggml_quantize_requires_imatrix(qtype.value):
|
|
# TODO: is a column-wise sum of squares appropriate?
|
|
qw = np.sum((data * data).reshape((-1, data.shape[-1])), axis=0).ctypes.data_as(c_float_p)
|
|
else:
|
|
qw = ctypes.cast(0, c_float_p)
|
|
result_size = self.libggml.ggml_quantize_chunk(qtype.value, data.ctypes.data_as(c_float_p), result.ctypes.data_as(ctypes.c_void_p), 0, prod(data.shape[:-1]), data.shape[-1], qw)
|
|
assert result.size == result_size
|
|
return result
|
|
|
|
|
|
def compare_tensors(t1: np.ndarray, t2: np.ndarray, qtype: GGMLQuantizationType) -> bool:
|
|
same = np.array_equal(t1, t2)
|
|
if same:
|
|
return True
|
|
else:
|
|
block_size, type_size = gguf.GGML_QUANT_SIZES[qtype]
|
|
if t1.dtype == np.float32:
|
|
t1 = t1.reshape((-1, block_size))
|
|
t2 = t2.reshape((-1, block_size))
|
|
else:
|
|
t1 = t1.reshape((-1, type_size))
|
|
t2 = t2.reshape((-1, type_size))
|
|
x = t1.view(np.uint8) ^ t2.view(np.uint8)
|
|
diff_bits = np.count_nonzero(np.unpackbits(x, axis=-1), axis=-1)
|
|
num_bad_blocks = np.count_nonzero(diff_bits, axis=0)
|
|
if num_bad_blocks == 0 and t1.shape == t2.shape:
|
|
logger.debug("Bits are equal, but arrays don't match, likely contains NANs")
|
|
return True
|
|
logger.debug(f"{num_bad_blocks} bad blocks ({100 * num_bad_blocks / x.shape[0]:.6f}%)")
|
|
bad_block_id = np.argmax(diff_bits, axis=0)
|
|
logger.debug(f"Worst block id: {bad_block_id}")
|
|
logger.debug(f"Sample bad block ({diff_bits[bad_block_id]} differing bits):\n{t1[bad_block_id]}\nReference:\n{t2[bad_block_id]}")
|
|
|
|
sum_diff_bits = np.sum(diff_bits)
|
|
logger.debug(f"{sum_diff_bits} bits differ ({100 * sum_diff_bits / (x.size * 8):.6f}%)")
|
|
return False
|
|
|
|
|
|
def do_test(libggml_path: Path, quick: bool = False, user_type: GGMLQuantizationType | None = None):
|
|
ggml_quants = GGMLQuants(libggml_path)
|
|
|
|
np.set_printoptions(precision=None, threshold=(4 * 256) + 1, formatter={"int": lambda n: "0x%02X" % n})
|
|
|
|
r = np.random.randn(8, 1024, 1024).astype(np.float32, copy=False)
|
|
# test zero blocks
|
|
r[0, 0, :] = 0
|
|
## Maybe test infinities? (can make NANs, not really useful in practice)
|
|
# r[0, 1, 0] = np.inf
|
|
# r[0, 2, 0] = -np.inf
|
|
# r[0, 3, 0] = np.inf
|
|
# r[0, 3, 1] = -np.inf
|
|
|
|
for qtype in ((GGMLQuantizationType.F16, *gguf.quants._type_traits.keys()) if user_type is None else (user_type,)):
|
|
has_dequantize = False
|
|
has_quantize = False
|
|
|
|
try:
|
|
gguf.dequantize(np.zeros((gguf.GGML_QUANT_SIZES[qtype][1]), dtype=np.uint8), qtype)
|
|
has_dequantize = True
|
|
except (NotImplementedError, AssertionError) as e:
|
|
if isinstance(e, AssertionError):
|
|
logger.error(f"Error with {qtype.name}: {e}")
|
|
raise e
|
|
try:
|
|
gguf.quantize(np.zeros((gguf.GGML_QUANT_SIZES[qtype][0]), dtype=np.float32), qtype)
|
|
has_quantize = True
|
|
except (NotImplementedError, AssertionError) as e:
|
|
if isinstance(e, AssertionError):
|
|
logger.error(f"Error with {qtype.name}: {e}")
|
|
raise e
|
|
|
|
if not has_dequantize and not has_quantize:
|
|
continue
|
|
|
|
logger.info(f"Testing {qtype.name}")
|
|
|
|
if qtype == GGMLQuantizationType.F8_E4M3:
|
|
encodings = np.arange(256, dtype=np.uint8)
|
|
pydq = gguf.dequantize(encodings, qtype)
|
|
ggdq = ggml_quants.dequantize(encodings, qtype)
|
|
assert pydq.dtype == np.float32
|
|
nan_mask = (encodings & 0x7F) == 0x7F
|
|
# Both 0x7F and 0xFF are NaNs; check classification, not FP32 NaN sign or payload.
|
|
np.testing.assert_array_equal(np.isnan(pydq), nan_mask)
|
|
np.testing.assert_array_equal(np.isnan(ggdq), nan_mask)
|
|
# Compare finite values by bits to also check signed zeros.
|
|
np.testing.assert_array_equal(pydq[~nan_mask].view(np.uint32), ggdq[~nan_mask].view(np.uint32))
|
|
logger.info("All 256 FP8 E4M3 encodings match C")
|
|
|
|
rc = r.copy(order="C")
|
|
|
|
pyq = None
|
|
ggq = None
|
|
|
|
if has_quantize:
|
|
logger.debug(f"Quantizing to {qtype.name} with Python")
|
|
pyq = gguf.quants.quantize(rc, qtype)
|
|
|
|
logger.debug(f"Quantizing to {qtype.name} with C")
|
|
ggq = ggml_quants.quantize(rc, qtype)
|
|
|
|
if qtype == GGMLQuantizationType.F16:
|
|
pyq = pyq.view(np.uint8)
|
|
quant_equal = compare_tensors(pyq, ggq, qtype)
|
|
|
|
if not quant_equal:
|
|
logger.error(f"Quantization to {qtype.name} does not match ❌")
|
|
else:
|
|
logger.info(f"Quantization to {qtype.name} matches exactly ✅")
|
|
|
|
if has_dequantize:
|
|
if ggq is None and not quick:
|
|
logger.debug(f"Quantizing to {qtype.name} with C")
|
|
ggq = ggml_quants.quantize(rc, qtype)
|
|
|
|
if ggq is not None:
|
|
logger.debug(f"Dequantizing from {qtype.name} with Python")
|
|
pydq = gguf.quants.dequantize(ggq, qtype)
|
|
logger.debug(f"Dequantizing from {qtype.name} with C")
|
|
ggdq = ggml_quants.dequantize(ggq, qtype)
|
|
|
|
dequant_equal = compare_tensors(pydq, ggdq, qtype)
|
|
|
|
if not dequant_equal:
|
|
logger.error(f"Dequantization from {qtype.name} does not match ❌")
|
|
else:
|
|
logger.info(f"Dequantization from {qtype.name} matches exactly ✅")
|
|
|
|
rq_shape = gguf.quants.quant_shape_to_byte_shape((8, 1024, 1024 // 2), qtype)
|
|
rq = np.random.random(rq_shape).astype(np.float16).view(np.uint8)
|
|
|
|
logger.debug(f"Dequantizing random f16 data as {qtype.name} with Python")
|
|
pydq = gguf.quants.dequantize(rq, qtype)
|
|
logger.debug(f"Dequantizing random f16 data as {qtype.name} with C")
|
|
ggdq = ggml_quants.dequantize(rq, qtype)
|
|
|
|
dequant_equal = compare_tensors(pydq, ggdq, qtype)
|
|
|
|
if not dequant_equal:
|
|
logger.error(f"Dequantization from random f16 data as {qtype.name} does not match ❌")
|
|
else:
|
|
logger.info(f"Dequantization from random f16 data as {qtype.name} matches exactly ✅")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Test Python (de)quantization against the reference C implementation")
|
|
parser.add_argument("--libggml", type=Path, default=Path(__file__).parent.parent.parent / "build" / "bin" / "libggml.so", help="The path to libggml.so")
|
|
parser.add_argument("--quick", action="store_true", help="Don't quantize with C when it's not strictly necessary")
|
|
parser.add_argument("--type", type=str, help="The quant type to test (all by default)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
|
|
do_test(args.libggml, args.quick, GGMLQuantizationType[args.type.upper()] if args.type is not None else None)
|