[Feature] Add FP4 KV Cache Design and support SM120 GPUs (#21601)

This commit is contained in:
Sam (Kesen Li)
2026-07-17 14:49:43 -07:00
committed by GitHub
parent 7fc3fb9657
commit ec6a3163b7
19 changed files with 1829 additions and 327 deletions
@@ -0,0 +1,64 @@
import unittest
from sglang.srt.utils.common import is_sm120_supported
from sglang.test.accuracy_test_runner import AccuracyTestParams
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.run_combined_tests import run_combined_tests
from sglang.test.test_utils import CustomTestCase, ModelLaunchSettings
register_cuda_ci(est_time=300, stage="extra-a", runner_config="1-gpu-small")
LLAMA8B_NVFP4_MODEL = "nvidia/Llama-3.1-8B-Instruct-NVFP4"
TP_SIZE = 1
@unittest.skipUnless(
is_sm120_supported(), "requires at least 1 SM120 GPU with CUDA 12.8+"
)
class TestLlama8BNVFP4KVCacheSM120(CustomTestCase):
"""Llama-3.1-8B-Instruct-NVFP4 with NVFP4 KV cache on SM120."""
def test_gsm8k(self):
variants = [
ModelLaunchSettings(
LLAMA8B_NVFP4_MODEL,
tp_size=TP_SIZE,
extra_args=[
"--quantization",
"modelopt_fp4",
"--fp4-gemm-backend",
"auto",
"--kv-cache-dtype",
"nvfp4",
"--prefill-attention-backend",
"flashinfer",
"--decode-attention-backend",
"trtllm_mha",
"--page-size",
"64",
"--cuda-graph-backend-prefill=disabled",
],
variant="NVFP4-GEMM+NVFP4-KV+SM120-XQA",
)
]
run_combined_tests(
models=variants,
test_name="Llama-3.1-8B-Instruct-NVFP4-KV-SM120",
accuracy_params=AccuracyTestParams(
dataset="gsm8k",
# Full GSM8K measured locally with 1319 requested / 1314 scored:
# - FP8 KV: 0.6461187214611872
# - NVFP4 KV: 0.632420091324201
# Keep the threshold 0.015 below the NVFP4 KV score.
baseline_accuracy=0.632420091324201 - 0.015,
num_examples=1319,
num_threads=200,
max_tokens=512,
api="completion",
),
)
if __name__ == "__main__":
unittest.main()
@@ -1,12 +1,16 @@
#!/usr/bin/env python3
import sys
import time
import numpy as np
import pytest
import torch
from sglang.srt.layers.quantization.kvfp4_tensor import BlockFP4KVQuantizeUtil
from sglang.srt.layers.quantization.kvfp4_tensor import FP4MXBlock16KVQuantizeUtil
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(est_time=30, stage="base-b", runner_config="1-gpu-large")
def calculate_accuracy_metrics(
@@ -28,7 +32,7 @@ def calculate_accuracy_metrics(
return {"MSE": mse, "MAE": mae, "PSNR": psnr, "Relative Error": rel_error}
def run_benchmark(m, n, k, num_runs=100) -> dict[str, dict[str, float]]:
def run_benchmark(m, n, k, num_runs=10) -> dict[str, dict[str, float]]:
"""Run FP8 vs KVFP4 quantization benchmark and return metrics."""
tensor_bf16 = torch.randn(m, n, k, dtype=torch.bfloat16, device="cuda")
@@ -52,18 +56,20 @@ def run_benchmark(m, n, k, num_runs=100) -> dict[str, dict[str, float]]:
fp8_metrics = calculate_accuracy_metrics(tensor_bf16, tensor_fp8_dequant)
# --- KVFP4 ---
tensor_fp4, scale_factors = BlockFP4KVQuantizeUtil.batched_quantize(tensor_bf16)
_ = BlockFP4KVQuantizeUtil.batched_dequantize(tensor_fp4, scale_factors)
tensor_fp4, scale_factors = FP4MXBlock16KVQuantizeUtil.batched_quantize(tensor_bf16)
_ = FP4MXBlock16KVQuantizeUtil.batched_dequantize(tensor_fp4, scale_factors)
start = time.time()
for _ in range(num_runs):
tensor_fp4, scale_factors = BlockFP4KVQuantizeUtil.batched_quantize(tensor_bf16)
tensor_fp4, scale_factors = FP4MXBlock16KVQuantizeUtil.batched_quantize(
tensor_bf16
)
torch.cuda.synchronize()
fp4_quant_time = (time.time() - start) / num_runs
start = time.time()
for _ in range(num_runs):
tensor_fp4_dequant = BlockFP4KVQuantizeUtil.batched_dequantize(
tensor_fp4_dequant = FP4MXBlock16KVQuantizeUtil.batched_dequantize(
tensor_fp4, scale_factors
)
torch.cuda.synchronize()
@@ -91,14 +97,8 @@ def run_benchmark(m, n, k, num_runs=100) -> dict[str, dict[str, float]]:
MNK_FACTORS = [
(64, 1, 576),
(512, 1, 576),
(1024, 1, 576),
(4096, 1, 576),
(2868672, 1, 576),
(64, 8, 64),
(512, 8, 64),
(1024, 8, 64),
(4096, 8, 64),
(2868672, 8, 64),
]
@@ -112,5 +112,9 @@ def test_kvfp4_quant_dequant(m, n, k):
print("FP4:", results["fp4"])
# Basic assertions to make sure metrics are reasonable
assert results["fp4"]["MSE"] < 1.0
assert results["fp8"]["MSE"] < 1.0
assert results["fp4"]["MSE"] < 0.1
assert results["fp8"]["MSE"] < 0.1
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
@@ -1,4 +1,4 @@
"""Unit tests for FP4 KV cache quantization strategy pattern — no server, no model loading."""
"""Unit tests for FP4 KV cache quantization strategy pattern - no server, no model loading."""
from sglang.test.ci.ci_register import register_cpu_ci
@@ -23,54 +23,87 @@ def skip_if_no_blackwell_nvfp4(func):
class TestKVCacheQuantRegistry(CustomTestCase):
"""Test the registry and factory function."""
def test_registry_contains_nvfp4_and_mxfp4(self):
def test_registry_contains_nvfp4_and_blockfp4(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
FP4_KV_CACHE_QUANT_REGISTRY,
KV_CACHE_QUANT_REGISTRY,
)
self.assertIn("nvfp4", FP4_KV_CACHE_QUANT_REGISTRY)
self.assertIn("blockfp4", FP4_KV_CACHE_QUANT_REGISTRY)
self.assertIn("nvfp4", KV_CACHE_QUANT_REGISTRY)
self.assertIn("fp4_mx_block16", KV_CACHE_QUANT_REGISTRY)
def test_factory_nvfp4(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
NVFP4KVMethod,
get_fp4_kv_cache_quant_method,
NVFP4KVCacheMethod,
get_kv_cache_quant_method,
)
method = get_fp4_kv_cache_quant_method(
"nvfp4", num_layers=4, device="cpu", sm_version=120
)
self.assertIsInstance(method, NVFP4KVMethod)
method = get_kv_cache_quant_method("nvfp4", num_layers=4, device="cpu")
self.assertIsInstance(method, NVFP4KVCacheMethod)
self.assertEqual(method.name, "nvfp4")
def test_factory_mxfp4(self):
def test_factory_blockfp4(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
BlockFP4KVMethod,
get_fp4_kv_cache_quant_method,
FP4MXBlock16KVCacheMethod,
get_kv_cache_quant_method,
)
method = get_fp4_kv_cache_quant_method("blockfp4")
self.assertIsInstance(method, BlockFP4KVMethod)
self.assertEqual(method.name, "blockfp4")
method = get_kv_cache_quant_method("fp4_mx_block16")
self.assertIsInstance(method, FP4MXBlock16KVCacheMethod)
self.assertEqual(method.name, "fp4_mx_block16")
def test_factory_unknown_raises(self):
def test_resolve_explicit_recipes(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
get_fp4_kv_cache_quant_method,
resolve_kv_cache_quant,
)
self.assertEqual(resolve_kv_cache_quant("nvfp4"), "nvfp4")
self.assertEqual(resolve_kv_cache_quant("fp4_mx_block16"), "fp4_mx_block16")
self.assertIsNone(resolve_kv_cache_quant("fp8_e4m3"))
def test_resolve_legacy_fp4_alias_raises(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
resolve_kv_cache_quant,
)
with self.assertRaisesRegex(ValueError, "fp4_mx_block16"):
resolve_kv_cache_quant("fp4_e2m1")
def test_model_runner_rejects_legacy_fp4_alias(self):
from types import SimpleNamespace
from sglang.srt.model_executor.model_runner import ModelRunner
runner = object.__new__(ModelRunner)
runner.server_args = SimpleNamespace(kv_cache_dtype="fp4_e2m1")
with self.assertRaisesRegex(ValueError, "fp4_mx_block16"):
runner.configure_kv_cache_dtype()
def test_resolve_mxfp4_name_raises(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
resolve_kv_cache_quant,
)
with self.assertRaises(ValueError):
get_fp4_kv_cache_quant_method("unknown_method")
resolve_kv_cache_quant("mxfp4")
def test_factory_unknown_raises(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
get_kv_cache_quant_method,
)
with self.assertRaises(ValueError):
get_kv_cache_quant_method("unknown_method")
class TestNVFP4KVMethod(CustomTestCase):
"""Test NVFP4KVMethod buffer creation and properties."""
class TestNVFP4KVCacheMethod(CustomTestCase):
"""Test NVFP4KVCacheMethod buffer creation and properties."""
def test_properties(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
NVFP4KVMethod,
NVFP4KVCacheMethod,
)
m = NVFP4KVMethod(num_layers=4, device="cpu", sm_version=120)
m = NVFP4KVCacheMethod(num_layers=4, device="cpu")
self.assertEqual(m.name, "nvfp4")
self.assertEqual(m.SCALE_BLOCK_SIZE, 16)
self.assertTrue(m.needs_dequant_workspace())
@@ -78,10 +111,10 @@ class TestNVFP4KVMethod(CustomTestCase):
def test_create_buffers_shapes(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
NVFP4KVMethod,
NVFP4KVCacheMethod,
)
m = NVFP4KVMethod(num_layers=4, device="cpu", sm_version=120)
m = NVFP4KVCacheMethod(num_layers=4, device="cpu")
size, heads, dim, layers = 64, 8, 128, 4
bufs = m.create_buffers(size, heads, dim, layers, "cpu")
@@ -101,20 +134,20 @@ class TestNVFP4KVMethod(CustomTestCase):
def test_compute_cell_size(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
NVFP4KVMethod,
NVFP4KVCacheMethod,
)
m = NVFP4KVMethod(num_layers=4, device="cpu")
m = NVFP4KVCacheMethod(num_layers=4, device="cpu")
cell = m.compute_cell_size(head_num=8, head_dim=128, num_layers=4, kv_size=1)
# FP4: 8*64*4*2 = 4096, scales: 8*8*4*2 = 512, dq: 8*128*2 = 2048
self.assertEqual(cell, 4096 + 512 + 2048)
def test_scales_init(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
NVFP4KVMethod,
NVFP4KVCacheMethod,
)
m = NVFP4KVMethod(num_layers=4, device="cpu")
m = NVFP4KVCacheMethod(num_layers=4, device="cpu")
# Default scales should be 1.0
self.assertTrue(torch.all(m.k_scales_gpu == 1.0))
self.assertTrue(torch.all(m.v_scales_gpu == 1.0))
@@ -122,13 +155,13 @@ class TestNVFP4KVMethod(CustomTestCase):
@skip_if_no_blackwell_nvfp4
def test_quantize_dequantize_roundtrip(self):
"""Test NVFP4 quantize→dequantize roundtrip on CUDA."""
"""Test NVFP4 quantize->dequantize roundtrip on CUDA."""
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
NVFP4KVMethod,
NVFP4KVCacheMethod,
)
major, minor = torch.cuda.get_device_capability()
m = NVFP4KVMethod(num_layers=1, device="cuda", sm_version=major * 10 + minor)
m = NVFP4KVCacheMethod(num_layers=1, device="cuda")
size, heads, dim = 32, 8, 128
bufs = m.create_buffers(size, heads, dim, 1, "cuda")
@@ -171,40 +204,55 @@ class TestNVFP4KVMethod(CustomTestCase):
)
class TestBlockFP4KVMethod(CustomTestCase):
"""Test BlockFP4KVMethod buffer creation and roundtrip."""
class TestFP4MXBlock16KVCacheMethod(CustomTestCase):
"""Test FP4MXBlock16KVCacheMethod buffer creation and roundtrip."""
def test_properties(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
BlockFP4KVMethod,
FP4MXBlock16KVCacheMethod,
KVCacheAttentionAccessKind,
)
m = BlockFP4KVMethod()
self.assertEqual(m.name, "blockfp4")
self.assertTrue(m.needs_dequant_workspace())
m = FP4MXBlock16KVCacheMethod()
self.assertEqual(m.name, "fp4_mx_block16")
self.assertFalse(m.needs_dequant_workspace())
self.assertTrue(m.needs_plain_kv_dequant_read())
self.assertFalse(m.needs_global_scale())
self.assertEqual(m.plain_attention_kv_dtype(), torch.bfloat16)
self.assertEqual(
m.resolve_attention_access("prefill", "triton").kind,
KVCacheAttentionAccessKind.PLAIN,
)
self.assertEqual(
m.resolve_attention_access("decode", "trtllm_mha").kind,
KVCacheAttentionAccessKind.PLAIN,
)
self.assertIsNone(m.resolve_attention_access("prefill", "flashinfer"))
self.assertIsNone(m.resolve_attention_access("decode", "flashinfer"))
def test_create_buffers_shapes(self):
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
BlockFP4KVMethod,
FP4MXBlock16KVCacheMethod,
)
m = BlockFP4KVMethod()
m = FP4MXBlock16KVCacheMethod()
size, heads, dim, layers = 64, 8, 128, 4
bufs = m.create_buffers(size, heads, dim, layers, "cpu")
self.assertEqual(len(bufs["k_buffer"]), layers)
self.assertEqual(bufs["k_buffer"][0].shape, (size, heads, dim // 2))
# MXFP4 flattens head dims for scales
# Block-16 FP4 flattens head dims for scales
self.assertEqual(bufs["k_scale_buffer"][0].shape, (size, (heads * dim) // 16))
self.assertIsNone(bufs["dq_k_buffer"])
self.assertIsNone(bufs["dq_v_buffer"])
def test_quantize_dequantize_roundtrip_cpu(self):
"""Test MXFP4 quantize→dequantize roundtrip on CPU."""
"""Test block-16 FP4 quantize->dequantize roundtrip on CPU."""
from sglang.srt.layers.quantization.fp4_kv_cache_quant_method import (
BlockFP4KVMethod,
FP4MXBlock16KVCacheMethod,
)
m = BlockFP4KVMethod()
m = FP4MXBlock16KVCacheMethod()
size, heads, dim = 32, 8, 128
bufs = m.create_buffers(size, heads, dim, 1, "cpu")
@@ -231,18 +279,22 @@ class TestBlockFP4KVMethod(CustomTestCase):
k_out, v_out = m.dequantize_prev_kv(k_fp4, k_scales, v_fp4, v_scales, 0)
self.assertEqual(k_out.shape, (4, heads, dim))
self.assertEqual(k_out.dtype, torch.float8_e4m3fn)
self.assertEqual(v_out.shape, (4, heads, dim))
self.assertEqual(k_out.dtype, torch.bfloat16)
self.assertEqual(v_out.dtype, torch.bfloat16)
class TestBlockFP4KVQuantizeUtil(CustomTestCase):
"""Test the existing MXFP4 BlockFP4KVQuantizeUtil roundtrip."""
class TestFP4MXBlock16KVQuantizeUtil(CustomTestCase):
"""Test the existing block-16 FP4 FP4MXBlock16KVQuantizeUtil roundtrip."""
def test_roundtrip_cpu(self):
from sglang.srt.layers.quantization.kvfp4_tensor import BlockFP4KVQuantizeUtil
from sglang.srt.layers.quantization.kvfp4_tensor import (
FP4MXBlock16KVQuantizeUtil,
)
x = torch.randn(4, 8, 128, dtype=torch.bfloat16)
packed, scales = BlockFP4KVQuantizeUtil.batched_quantize(x)
reconstructed = BlockFP4KVQuantizeUtil.batched_dequantize(packed, scales)
packed, scales = FP4MXBlock16KVQuantizeUtil.batched_quantize(x)
reconstructed = FP4MXBlock16KVQuantizeUtil.batched_dequantize(packed, scales)
self.assertEqual(reconstructed.shape, x.shape)
rel_error = (
@@ -251,15 +303,5 @@ class TestBlockFP4KVQuantizeUtil(CustomTestCase):
self.assertLess(rel_error, 0.5)
class TestFP4KVCacheRecipe(CustomTestCase):
"""Test enum."""
def test_enum_values(self):
from sglang.srt.layers.quantization.kvfp4_tensor import FP4KVCacheRecipe
self.assertEqual(FP4KVCacheRecipe.MXFP4.value, 1)
self.assertEqual(FP4KVCacheRecipe.NVFP4.value, 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,124 @@
# Copyright 2026 SGLang Team
# Licensed under the Apache License, Version 2.0
import types
import unittest
import torch
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
class _FakeQuantMethod:
name = "fake_quant"
def __init__(self):
self.k_scales_gpu = torch.tensor([2.0], dtype=torch.float32)
self.v_scales_gpu = torch.tensor([3.0], dtype=torch.float32)
self.store_calls = []
def dequant_workspace_dtype(self):
return torch.float32
def create_buffers(self, size, head_num, head_dim, layer_num, device):
return {
"k_buffer": [
torch.zeros(
(size, head_num, head_dim), dtype=torch.uint8, device=device
)
for _ in range(layer_num)
],
"v_buffer": [
torch.zeros(
(size, head_num, head_dim), dtype=torch.uint8, device=device
)
for _ in range(layer_num)
],
"k_scale_buffer": [
torch.zeros((size, head_num, 1), dtype=torch.uint8, device=device)
for _ in range(layer_num)
],
"v_scale_buffer": [
torch.zeros((size, head_num, 1), dtype=torch.uint8, device=device)
for _ in range(layer_num)
],
"dq_k_buffer": torch.zeros(
(size, head_num, head_dim), dtype=torch.float32, device=device
),
"dq_v_buffer": torch.zeros(
(size, head_num, head_dim), dtype=torch.float32, device=device
),
"store_dtype": torch.uint8,
}
def quantize_and_store(
self,
k_buffer,
v_buffer,
k_scale_buffer,
v_scale_buffer,
loc,
cache_k,
cache_v,
k_scale=None,
v_scale=None,
):
self.store_calls.append(
{
"loc": loc,
"k_scale": k_scale,
"v_scale": v_scale,
"k_scale_buffer": k_scale_buffer,
"v_scale_buffer": v_scale_buffer,
}
)
k_buffer[loc] = 1
v_buffer[loc] = 2
k_scale_buffer[loc] = 3
v_scale_buffer[loc] = 4
class TestQuantizedKVPool(unittest.TestCase):
def test_quant_method_owns_buffers_and_store_path(self):
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool
quant_method = _FakeQuantMethod()
pool = MHATokenToKVPool(
size=4,
page_size=1,
dtype=torch.bfloat16,
head_num=1,
head_dim=8,
layer_num=1,
device="cpu",
enable_memory_saver=False,
quant_method=quant_method,
)
self.assertTrue(pool.is_quantized_kv_cache)
self.assertIs(pool.quant_method, quant_method)
self.assertIsNotNone(pool.k_scale_buffer)
self.assertIs(pool.get_dequant_workspace()[0], pool.dq_k_buffer)
loc = torch.tensor([0, 1], dtype=torch.int64)
layer = types.SimpleNamespace(layer_id=0)
pool.set_kv_buffer(
layer,
loc,
torch.zeros((2, 1, 8), dtype=torch.bfloat16),
torch.zeros((2, 1, 8), dtype=torch.bfloat16),
)
self.assertEqual(len(quant_method.store_calls), 1)
call = quant_method.store_calls[0]
self.assertIs(call["loc"], loc)
self.assertTrue(torch.equal(call["k_scale"], quant_method.k_scales_gpu[0:1]))
self.assertTrue(torch.equal(call["v_scale"], quant_method.v_scales_gpu[0:1]))
self.assertEqual(pool.k_buffer[0][loc].unique().tolist(), [1])
self.assertEqual(pool.v_buffer[0][loc].unique().tolist(), [2])
if __name__ == "__main__":
unittest.main()
@@ -1136,7 +1136,7 @@ class TestPrefillOnlyDisableKvCache(unittest.TestCase):
- disable_radix_cache (radix cache otherwise indexes empty pool slots),
- no context-parallel attention (CP writes to the pool via set_kv_buffer),
- no HiSparse (uses a different pool family),
- kv_cache_dtype != fp4_e2m1 (FP4 pool is a separate allocation path).
- kv_cache_dtype is not nvfp4/fp4_mx_block16 (FP4 pool is a separate allocation path).
All other configurations must be rejected before model load.
"""
@@ -1186,8 +1186,10 @@ class TestPrefillOnlyDisableKvCache(unittest.TestCase):
self._validate_prefill_only_args(enable_hisparse=True)
def test_rejects_fp4_kv_cache(self):
with self.assertRaisesRegex(ValueError, "fp4_e2m1"):
self._validate_prefill_only_args(kv_cache_dtype="fp4_e2m1")
for kv_cache_dtype in ("nvfp4", "fp4_mx_block16"):
with self.subTest(kv_cache_dtype=kv_cache_dtype):
with self.assertRaisesRegex(ValueError, "nvfp4.*fp4_mx_block16"):
self._validate_prefill_only_args(kv_cache_dtype=kv_cache_dtype)
class TestSessionRadixCacheServerArgs(unittest.TestCase):