Files
llama.cpp-xrip/tests/test_conversion_fp8.py
T
Oliver SimonsandSigbjørn Skjæret 68d20b884c Quant: OCP FP8 E4M3 support (#27512)
* 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>
2026-10-29 18:14:37 +01:00

371 lines
21 KiB
Python

"""Test FP8 conversion with small synthetic checkpoints; no GPU or model downloads required.
Install dependencies and run from the repository root:
python -m pip install -r requirements/requirements-convert_hf_to_gguf.txt
python -m unittest discover -s tests -p 'test_conversion_fp8.py' -v
Use unittest discovery so the repository root is available for imports.
"""
from __future__ import annotations
import json
from pathlib import Path
import tempfile
import unittest
import numpy as np
import torch
from safetensors.torch import save_file
from conversion.base import LazyTorchTensor, gguf
from conversion.deepseek import DeepseekV2Model
from conversion.llama import LlamaModel
from conversion.nemotron import NemotronHModel
from conversion.phi import PhiMoeModel
from conversion.qwen import Qwen2MoeModel, Qwen3NextModel
class ForcedF16Model(Qwen2MoeModel):
model_arch = gguf.MODEL_ARCH.QWEN2MOE
def tensor_force_quant(self, name, new_name, bid, n_dims):
if name.endswith("q_proj.weight"):
return gguf.GGMLQuantizationType.F16
return super().tensor_force_quant(name, new_name, bid, n_dims)
class OffsetNormModel(Qwen2MoeModel):
model_arch = gguf.MODEL_ARCH.QWEN2MOE
def modify_tensors(self, data_torch, name, bid):
if name.endswith("input_layernorm.weight"):
data_torch = data_torch + 1.0
yield from super().modify_tensors(data_torch, name, bid)
class TestFP8Conversion(unittest.TestCase):
def convert(self, model_class, tensors, eager=False, check_lazy=False, scan_nvfp4=False, ftype=gguf.LlamaFileType.GUESSED, hparams_override=None, **kwargs):
with tempfile.TemporaryDirectory() as directory:
return self._convert(Path(directory), model_class, tensors, eager, check_lazy, scan_nvfp4, ftype, hparams_override, **kwargs)
def _convert(self, path, model_class, tensors, eager, check_lazy, scan_nvfp4, ftype, hparams_override, **kwargs):
hparams = {
"model_type": "qwen2_moe",
"architectures": ["Qwen2MoeForCausalLM"],
"num_hidden_layers": 1,
"hidden_size": 4,
"num_attention_heads": 1,
"num_key_value_heads": 1,
"num_experts": 2,
"qk_nope_head_dim": 2,
"v_head_dim": 2,
"quantization_config": {"quant_method": "modelopt"},
}
hparams.update(hparams_override or {})
(path / "config.json").write_text(json.dumps(hparams))
save_file(tensors, str(path / "model.safetensors"))
model = model_class(path, ftype, path / "out.gguf", hparams=hparams, eager=eager, **kwargs)
reads: list[str] = []
if check_lazy:
self.assertFalse(eager)
def track(name, gen):
tensor = gen()
def load(source):
reads.append(name)
return LazyTorchTensor.to_eager(source())
return LazyTorchTensor(meta=torch.empty(tuple(tensor.shape), dtype=tensor.dtype, device="meta"), args=(gen,), func=load)
model.model_tensors = {
name: lambda name=name, gen=gen: track(name, gen)
for name, gen in model.model_tensors.items()
}
if scan_nvfp4:
model._generate_nvfp4_tensors()
self.assertEqual(reads, [])
model.prepare_tensors()
if check_lazy:
deferred = {name for name, tensor in tensors.items() if tensor.numel() != 1 or name.endswith((".weight", ".bias"))}
self.assertFalse(deferred.intersection(reads), f"Materialized during preparation: {reads}")
for name, info in model.gguf_writer.tensors[0].items():
if name.endswith((".weight", ".bias")):
self.assertIsInstance(info.tensor, gguf.LazyNumpyTensor)
result = {
name: (info.dtype, gguf.LazyNumpyTensor.to_eager(info.tensor).copy())
for name, info in model.gguf_writer.tensors[0].items()
}
if check_lazy:
self.assertTrue(deferred.intersection(reads))
return result
def test_lazy_fp8_export(self):
name = "model.layers.0.self_attn.q_proj.weight"
weight = torch.tensor([[1., -1.], [2., -2.]]).to(torch.float8_e4m3fn)
for channel_scale in (False, True):
for scan_nvfp4 in (False, True):
with self.subTest(channel_scale=channel_scale, scan_nvfp4=scan_nvfp4):
tensors = {
name: weight,
name + "_scale": torch.full((2, 1) if channel_scale else (1,), 0.25),
}
result = self.convert(Qwen2MoeModel, tensors, check_lazy=True, scan_nvfp4=scan_nvfp4)
dtype, data = result["blk.0.attn_q.weight"]
if channel_scale:
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
np.testing.assert_array_equal(data, weight.float().numpy() * 0.25)
else:
self.assertEqual(dtype, gguf.GGMLQuantizationType.F8_E4M3)
np.testing.assert_array_equal(gguf.dequantize(data, dtype), weight.float().numpy())
np.testing.assert_array_equal(result["blk.0.attn_q.scale"][1], [0.25])
def test_mixed_compressed_fp8_block_scale(self):
name = "model.layers.0.self_attn.q_proj.weight"
scale = torch.tensor([[0.25, 0.5], [1.0, 2.0]])
tensors = {
name: torch.ones((3, 32)).to(torch.float8_e4m3fn),
name + "_scale": scale,
}
config = {
"quant_method": "compressed-tensors",
"format": "mixed-precision",
"config_groups": {
"nvfp4": {"format": "nvfp4-pack-quantized"},
"fp8": {"format": "float-quantized", "weights": {
"type": "float", "num_bits": 8, "strategy": "block", "block_structure": [2, 16],
}},
},
}
result = self.convert(Qwen2MoeModel, tensors, check_lazy=True, hparams_override={"quantization_config": config})
self.assertEqual(set(result), {"blk.0.attn_q.weight"})
dtype, data = result["blk.0.attn_q.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
expected = scale.repeat_interleave(2, 0).repeat_interleave(16, 1)[:3, :32]
np.testing.assert_array_equal(data, expected.numpy())
def test_mixed_compressed_fp8_block_layout_errors(self):
name = "model.layers.0.self_attn.q_proj.weight"
tensors = {
name: torch.ones((3, 32)).to(torch.float8_e4m3fn),
name + "_scale": torch.ones((2, 2)),
}
for configs, message in (
([], "requires a block layout"),
([{"strategy": "block"}], "requires a block layout"),
([{"strategy": "block", "block_structure": [2, 16], "group_size": 16}], "requires a block layout"),
([{"strategy": "block", "block_structure": [2, 16]}, {"strategy": "block", "block_structure": [2, 32]}], "ambiguous block layouts"),
):
with self.subTest(configs=configs):
groups: dict[str, dict[str, object]] = {"nvfp4": {"format": "nvfp4-pack-quantized"}}
for i, config in enumerate(configs):
groups[str(i)] = {"format": "float-quantized", "weights": {"type": "float", "num_bits": 8, **config}}
config = {"quant_method": "compressed-tensors", "format": "mixed-precision", "config_groups": groups}
with self.assertRaisesRegex(ValueError, message):
self.convert(Qwen2MoeModel, tensors, hparams_override={"quantization_config": config})
def test_mla_scale_fallback(self):
weight = torch.arange(16, dtype=torch.float32).reshape(4, 4).to(torch.float8_e4m3fn)
prefix = "model.layers.0.self_attn."
tensors = {}
for proj in ("q_a_proj", "q_b_proj", "kv_a_proj_with_mqa", "kv_b_proj"):
tensors[prefix + proj + ".weight"] = weight.clone()
tensors[prefix + proj + ".weight_scale"] = torch.tensor([0.25])
for eager in (False, True):
with self.subTest(eager=eager):
result = self.convert(DeepseekV2Model, tensors, eager=eager)
self.assertFalse(any(name.endswith(".scale") for name in result))
np.testing.assert_array_equal(result["blk.0.attn_k_b.weight"][1], (weight.float()[:2].T * 0.25).numpy()[None])
np.testing.assert_array_equal(result["blk.0.attn_v_b.weight"][1], (weight.float()[2:] * 0.25).numpy()[None])
def test_output_type_override(self):
name = "model.layers.0.self_attn.q_proj.weight"
weight = torch.tensor([[1., -1.], [2., -2.]]).to(torch.float8_e4m3fn)
for eager in (False, True):
for scaled in (False, True):
with self.subTest(eager=eager, scaled=scaled):
tensors = {name: weight}
if scaled:
tensors[name + "_scale"] = torch.tensor([0.25])
result = self.convert(ForcedF16Model, tensors, eager=eager, check_lazy=not eager)
dtype, data = result["blk.0.attn_q.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
self.assertNotIn("blk.0.attn_q.scale", result)
np.testing.assert_array_equal(data, weight.float().numpy() * (0.25 if scaled else 1.))
def test_fp8_bias_is_numeric_f32(self):
bias = torch.tensor([1., -1.]).to(torch.float8_e4m3fn)
result = self.convert(Qwen2MoeModel, {"model.layers.0.self_attn.q_proj.bias": bias}, check_lazy=True)
dtype, data = result["blk.0.attn_q.bias"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F32)
np.testing.assert_array_equal(data, [1., -1.])
def test_fp8_mandatory_f32(self):
for name, target, shape in (
("model.layers.0.linear_attn.conv1d.weight", "blk.0.ssm_conv1d.weight", (4, 1, 4)),
("model.layers.0.mlp.gate.weight", "blk.0.ffn_gate_inp.weight", (2, 4)),
):
for eager in (False, True):
for scaled in (False, True):
with self.subTest(name=name, eager=eager, scaled=scaled):
weight = torch.arange(int(np.prod(shape)), dtype=torch.float32).reshape(shape).to(torch.float8_e4m3fn)
tensors = {name: weight}
if scaled:
tensors[name + "_scale"] = torch.tensor([0.25])
result = self.convert(Qwen3NextModel, tensors, eager=eager, check_lazy=not eager, hparams_override={"mtp_num_hidden_layers": 1})
dtype, data = result[target]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F32)
np.testing.assert_array_equal(data, weight.float().squeeze().numpy() * (0.25 if scaled else 1.))
self.assertNotIn(target.removesuffix("weight") + "scale", result)
def test_fp8_norm_transform(self):
for shape in ((4,), (2, 2)):
with self.subTest(shape=shape):
weight = torch.ones(shape, dtype=torch.float32).to(torch.float8_e4m3fn)
result = self.convert(OffsetNormModel, {"model.layers.0.input_layernorm.weight": weight}, check_lazy=True)
dtype, data = result["blk.0.attn_norm.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F32)
np.testing.assert_array_equal(data, np.full(shape, 2.))
def test_explicit_outtypes(self):
name = "model.layers.0.self_attn.q_proj.weight"
weight = torch.ones((4, 256), dtype=torch.float32).to(torch.float8_e4m3fn)
for ftype, qtype in (
(gguf.LlamaFileType.ALL_F32, gguf.GGMLQuantizationType.F32),
(gguf.LlamaFileType.MOSTLY_F16, gguf.GGMLQuantizationType.F16),
(gguf.LlamaFileType.MOSTLY_BF16, gguf.GGMLQuantizationType.BF16),
(gguf.LlamaFileType.MOSTLY_Q8_0, gguf.GGMLQuantizationType.Q8_0),
(gguf.LlamaFileType.MOSTLY_TQ1_0, gguf.GGMLQuantizationType.TQ1_0),
(gguf.LlamaFileType.MOSTLY_TQ2_0, gguf.GGMLQuantizationType.TQ2_0),
):
for eager in (False, True):
for scaled in (False, True):
with self.subTest(ftype=ftype, eager=eager, scaled=scaled):
tensors = {name: weight}
if scaled:
tensors[name + "_scale"] = torch.tensor([0.25])
tensors[name.removesuffix("weight") + "input_scale"] = torch.tensor([0.5])
result = self.convert(Qwen2MoeModel, tensors, ftype=ftype, eager=eager, check_lazy=not eager)
self.assertEqual(set(result), {"blk.0.attn_q.weight"})
dtype, data = result["blk.0.attn_q.weight"]
self.assertEqual(dtype, qtype)
np.testing.assert_allclose(gguf.dequantize(data, dtype), np.full((4, 256), 0.25 if scaled else 1.), atol=1e-4)
def test_embedding_scale_fallback(self):
name = "model.embed_tokens.weight"
weight = torch.ones((4, 4), dtype=torch.float32).to(torch.float8_e4m3fn)
for eager in (False, True):
with self.subTest(eager=eager):
result = self.convert(Qwen2MoeModel, {name: weight, name + "_scale": torch.tensor([0.25])}, eager=eager, check_lazy=not eager)
self.assertEqual(set(result), {"token_embd.weight"})
dtype, data = result["token_embd.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
np.testing.assert_array_equal(data, np.full((4, 4), 0.25))
def test_fp8_scale_formats(self):
name = "model.layers.0.self_attn.q_proj.weight"
weight = torch.ones((4, 4), dtype=torch.float32).to(torch.float8_e4m3fn)
for suffix, config in (
("_scale_inv", {"quant_method": "fp8"}),
("_scale", {"quant_method": "compressed-tensors", "format": "float-quantized", "config_groups": {"group_0": {"weights": {"type": "float", "num_bits": 8, "strategy": "tensor"}}}}),
):
for ftype in (gguf.LlamaFileType.GUESSED, gguf.LlamaFileType.MOSTLY_F16):
with self.subTest(suffix=suffix, ftype=ftype):
result = self.convert(Qwen2MoeModel, {name: weight, name + suffix: torch.tensor([0.25])}, ftype=ftype, hparams_override={"quantization_config": config}, check_lazy=True)
dtype, data = result["blk.0.attn_q.weight"]
if ftype == gguf.LlamaFileType.GUESSED:
self.assertEqual(dtype, gguf.GGMLQuantizationType.F8_E4M3)
np.testing.assert_array_equal(result["blk.0.attn_q.scale"][1], [0.25])
else:
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
self.assertEqual(set(result), {"blk.0.attn_q.weight"})
np.testing.assert_array_equal(data, np.full((4, 4), 0.25))
def test_expert_naming(self):
for model_class, prefix, projections, hparams in (
(LlamaModel, "model.layers.0.block_sparse_moe", (("w1", "gate"), ("w3", "up"), ("w2", "down")), {"num_local_experts": 2}),
(PhiMoeModel, "model.layers.0.block_sparse_moe", (("w1", "gate"), ("w3", "up"), ("w2", "down")), {"max_position_embeddings": 16, "rope_scaling": {"original_max_position_embeddings": 16}}),
(NemotronHModel, "backbone.layers.0.mixer", (("up_proj", "up"), ("down_proj", "down")), {"num_experts_per_tok": 1, "n_routed_experts": 2, "head_dim": 4, "num_heads": 1, "n_groups": 1, "hybrid_override_pattern": "E"}),
):
for eager in (False, True):
for mixed in (False, True):
with self.subTest(model=model_class.__name__, eager=eager, mixed=mixed):
tensors = {}
for expert in (1, 0):
for proj, _ in projections:
name = f"{prefix}.experts.{expert}.{proj}.weight"
dtype = torch.float16 if mixed and expert == 1 else torch.float8_e4m3fn
tensors[name] = torch.ones((4, 4), dtype=torch.float32).to(dtype)
tensors[name + "_scale"] = torch.tensor([0.25 * (expert + 1)])
result = self.convert(model_class, tensors, hparams_override=hparams, eager=eager, check_lazy=not eager)
for _, proj in projections:
dtype, data = result[f"blk.0.ffn_{proj}_exps.weight"]
if mixed:
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
np.testing.assert_array_equal(data, np.array([np.full((4, 4), 0.25), np.full((4, 4), 0.5)]))
self.assertNotIn(f"blk.0.ffn_{proj}_exps.scale", result)
else:
self.assertEqual(dtype, gguf.GGMLQuantizationType.F8_E4M3)
np.testing.assert_array_equal(gguf.dequantize(data, dtype), np.ones((2, 4, 4)))
np.testing.assert_array_equal(result[f"blk.0.ffn_{proj}_exps.scale"][1], [0.25, 0.5])
def test_mixed_experts(self):
for eager in (False, True):
for other in ("f16", "channel", "unscaled"):
with self.subTest(eager=eager, other=other):
tensors = {}
for expert in range(2):
for proj in ("gate", "up", "down"):
name = f"model.layers.0.mlp.experts.{expert}.{proj}_proj.weight"
dtype = torch.float16 if expert == 1 and other == "f16" else torch.float8_e4m3fn
tensors[name] = torch.ones((4, 4), dtype=torch.float32).to(dtype)
if expert == 0:
tensors[name + "_scale"] = torch.tensor([0.25])
elif other == "channel":
tensors[name + "_scale"] = torch.full((4, 1), 0.5)
result = self.convert(Qwen2MoeModel, tensors, eager=eager, check_lazy=not eager)
self.assertEqual(len(result), 3)
for proj in ("gate", "up", "down"):
dtype, data = result[f"blk.0.ffn_{proj}_exps.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F16)
np.testing.assert_array_equal(data[0], np.full((4, 4), 0.25))
np.testing.assert_array_equal(data[1], np.full((4, 4), 0.5 if other == "channel" else 1.))
def test_uniform_experts_stay_fp8(self):
tensors = {}
for expert in range(2):
for proj in ("gate", "up", "down"):
name = f"model.layers.0.mlp.experts.{expert}.{proj}_proj.weight"
tensors[name] = torch.ones((4, 4), dtype=torch.float32).to(torch.float8_e4m3fn)
tensors[name + "_scale"] = torch.tensor([0.25 * (expert + 1)])
result = self.convert(Qwen2MoeModel, tensors, fuse_gate_up_exps=True, check_lazy=True)
for proj in ("gate", "up", "down"):
dtype, data = result[f"blk.0.ffn_{proj}_exps.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.F8_E4M3)
np.testing.assert_array_equal(gguf.dequantize(data, dtype), np.ones((2, 4, 4)))
np.testing.assert_array_equal(result[f"blk.0.ffn_{proj}_exps.scale"][1], [0.25, 0.5])
unscaled = {name: tensor for name, tensor in tensors.items() if name.endswith(".weight")}
result = self.convert(Qwen2MoeModel, unscaled, check_lazy=True)
self.assertEqual(len(result), 3)
for dtype, data in result.values():
self.assertEqual(dtype, gguf.GGMLQuantizationType.F8_E4M3)
np.testing.assert_array_equal(gguf.dequantize(data, dtype), np.ones((2, 4, 4)))
def test_fp8_as_q8(self):
name = "model.layers.0.self_attn.q_proj.weight"
tensors = {
name: torch.ones((4, 32), dtype=torch.float32).to(torch.float8_e4m3fn),
name + "_scale": torch.tensor([0.25]),
}
result = self.convert(Qwen2MoeModel, tensors, fp8_as_q8=True)
self.assertNotIn("blk.0.attn_q.scale", result)
dtype, data = result["blk.0.attn_q.weight"]
self.assertEqual(dtype, gguf.GGMLQuantizationType.Q8_0)
np.testing.assert_allclose(gguf.dequantize(data, dtype), np.full((4, 32), 0.25), atol=1e-4)
if __name__ == "__main__":
unittest.main()