[CPU] Add FP8 W8A16 linear support (#41186)

Signed-off-by: yuwenzho <[email protected]>
This commit is contained in:
Yuwen Zhou
2026-05-06 07:05:27 +00:00
committed by GitHub
parent b53c507bc9
commit 809b98e5b7
9 changed files with 331 additions and 3 deletions
+4 -2
View File
@@ -14,13 +14,15 @@ steps:
- tests/kernels/moe/test_cpu_fused_moe.py
- tests/kernels/test_onednn.py
- tests/kernels/test_awq_int4_to_int8.py
- tests/kernels/quantization/test_cpu_fp8_scaled_mm.py
commands:
- |
bash .buildkite/scripts/hardware_ci/run-cpu-test.sh 20m "
bash .buildkite/scripts/hardware_ci/run-cpu-test.sh 30m "
pytest -x -v -s tests/kernels/attention/test_cpu_attn.py
pytest -x -v -s tests/kernels/moe/test_cpu_fused_moe.py
pytest -x -v -s tests/kernels/test_onednn.py
pytest -x -v -s tests/kernels/test_awq_int4_to_int8.py"
pytest -x -v -s tests/kernels/test_awq_int4_to_int8.py
pytest -x -v -s tests/kernels/quantization/test_cpu_fp8_scaled_mm.py"
- label: CPU-Compatibility Tests
depends_on: []
+1 -1
View File
@@ -447,7 +447,7 @@ INSTANTIATE_TINYGEMM_TEMPLATE(at::BFloat16);
INSTANTIATE_TINYGEMM_TEMPLATE(at::Half);
at::Tensor fp8_scaled_mm_cpu(at::Tensor& mat1, at::Tensor& mat2, at::Tensor& scales2,
std::vector<int64_t> block_size, std::optional<at::Tensor>& bias,
std::vector<int64_t> block_size, const std::optional<at::Tensor>& bias,
at::ScalarType out_dtype, bool is_vnni) {
RECORD_FUNCTION("sgl-kernel::fp8_scaled_mm_cpu", std::vector<c10::IValue>({mat1, mat2, scales2, block_size, bias}));
+14
View File
@@ -77,6 +77,13 @@ at::Tensor int8_scaled_mm_with_quant(at::Tensor& mat1, at::Tensor& mat2,
const std::optional<at::Tensor>& bias,
at::ScalarType out_dtype, bool is_vnni);
// Adapted from sglang: FP8 W8A16 kernel
at::Tensor fp8_scaled_mm_cpu(at::Tensor& mat1, at::Tensor& mat2,
at::Tensor& scales2,
std::vector<int64_t> block_size,
const std::optional<at::Tensor>& bias,
at::ScalarType out_dtype, bool is_vnni);
// Adapted from sglang: INT4 W4A8 kernels
std::tuple<at::Tensor, at::Tensor, at::Tensor> convert_weight_packed_scale_zp(
at::Tensor qweight, at::Tensor qzeros, at::Tensor scales);
@@ -376,6 +383,13 @@ TORCH_LIBRARY_EXPAND(TORCH_EXTENSION_NAME, ops) {
"int4_scaled_mm_cpu(Tensor(a0!) x, Tensor(a1!) w, Tensor(a2!) w_zeros, "
"Tensor(a3!) w_scales, Tensor? bias) -> Tensor");
ops.impl("int4_scaled_mm_cpu", torch::kCPU, &int4_scaled_mm_cpu);
// Adapted from sglang: FP8 W8A16 kernel
ops.def(
"fp8_scaled_mm_cpu(Tensor(a0!) mat1, Tensor(a1!) mat2, Tensor(a2!) "
"scales2, SymInt[] block_size, Tensor? bias, ScalarType out_dtype, "
"bool is_vnni) -> Tensor");
ops.impl("fp8_scaled_mm_cpu", torch::kCPU, &fp8_scaled_mm_cpu);
#endif
// CPU attention kernels
@@ -0,0 +1,162 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for CPU FP8 W8A16 block-scaled GEMM kernel (fp8_scaled_mm_cpu).
Run `pytest tests/kernels/quantization/test_cpu_fp8_scaled_mm.py -v`.
"""
import pytest
import torch
from vllm import _custom_ops as ops
from vllm.platforms import current_platform
if not current_platform.is_cpu():
pytest.skip("skipping CPU-only tests", allow_module_level=True)
if not ops._supports_cpu_fp8_w8a16:
pytest.skip("fp8_scaled_mm_cpu op not available", allow_module_level=True)
BLOCK_SIZE = [128, 128]
def cdiv(a: int, b: int) -> int:
return -(a // -b)
def quantize_weight_block_fp8(
weight: torch.Tensor,
block_size: list[int],
) -> tuple[torch.Tensor, torch.Tensor]:
"""Quantize weight [N, K] to FP8 with block scales.
Returns:
fp8_weight: [N, K] float8_e4m3fn
scales: [n_tiles, k_tiles] float32
"""
N, K = weight.shape
block_n, block_k = block_size
fp8_max = torch.finfo(torch.float8_e4m3fn).max
n_tiles = cdiv(N, block_n)
k_tiles = cdiv(K, block_k)
# Pad for even blocking
pad_N = (block_n - (N % block_n)) % block_n
pad_K = (block_k - (K % block_k)) % block_k
if pad_N > 0 or pad_K > 0:
weight = torch.nn.functional.pad(weight, (0, pad_K, 0, pad_N))
# Reshape into blocks
w_blocks = weight.view(n_tiles, block_n, k_tiles, block_k)
w_blocks = w_blocks.permute(0, 2, 1, 3).contiguous()
# Per-block scale
abs_max = w_blocks.abs().amax(dim=(-2, -1), keepdim=True)
scales = abs_max / fp8_max
scales = torch.where(scales == 0, torch.ones_like(scales), scales)
# Quantize
q_fp8 = (w_blocks / scales).clamp(-fp8_max, fp8_max).to(torch.float8_e4m3fn)
# Reshape back
fp8_weight = (
q_fp8.permute(0, 2, 1, 3)
.contiguous()
.view(N + pad_N, K + pad_K)[:N, :K]
.contiguous()
)
scales = scales.view(n_tiles, k_tiles)
return fp8_weight, scales
def dequant_weight_block_fp8(
fp8_weight: torch.Tensor,
scales: torch.Tensor,
block_size: list[int],
out_dtype: torch.dtype,
) -> torch.Tensor:
"""Dequantize FP8 weight back to float for reference computation."""
N, K = fp8_weight.shape
block_n, block_k = block_size
n_tiles, k_tiles = scales.shape
pad_N = (block_n - (N % block_n)) % block_n
pad_K = (block_k - (K % block_k)) % block_k
if pad_N > 0 or pad_K > 0:
fp8_padded = torch.nn.functional.pad(fp8_weight.float(), (0, pad_K, 0, pad_N))
else:
fp8_padded = fp8_weight.float()
w_blocks = fp8_padded.view(n_tiles, block_n, k_tiles, block_k)
w_blocks = w_blocks.permute(0, 2, 1, 3).contiguous()
dq = w_blocks * scales.view(n_tiles, k_tiles, 1, 1)
dq = dq.permute(0, 2, 1, 3).contiguous().view(N + pad_N, K + pad_K)
return dq[:N, :K].to(out_dtype)
def ref_fp8_block_scaled_mm(
x: torch.Tensor,
fp8_weight: torch.Tensor,
scales: torch.Tensor,
block_size: list[int],
bias: torch.Tensor | None,
out_dtype: torch.dtype,
) -> torch.Tensor:
"""Reference: dequant FP8→float32, matmul in float32, cast to out_dtype."""
w_dq = dequant_weight_block_fp8(fp8_weight, scales, block_size, torch.float32)
out = torch.mm(x.float(), w_dq.t())
if bias is not None:
out = out + bias.float()
return out.to(out_dtype)
# ---------------------------------------------------------------------------
# Test parameters
# ---------------------------------------------------------------------------
M_SIZES = [1, 4, 16, 64, 128]
# (N, K) — weight shape is [N, K], output has N columns.
NK_SIZES = [
(128, 256),
(256, 512),
(512, 1024),
(1024, 2048),
(5120, 5120),
(17408, 5120),
(5120, 17408),
]
@pytest.mark.parametrize("M", M_SIZES)
@pytest.mark.parametrize("N,K", NK_SIZES)
@pytest.mark.parametrize("use_bias", [False, True])
def test_cpu_fp8_scaled_mm(M: int, N: int, K: int, use_bias: bool):
"""fp8_scaled_mm_cpu correctness against float reference."""
torch.manual_seed(42)
out_dtype = torch.bfloat16
block_size = BLOCK_SIZE
x = torch.randn(M, K, dtype=out_dtype) / (K**0.5)
w_f32 = torch.randn(N, K, dtype=torch.float32) / (K**0.5)
fp8_weight, scales = quantize_weight_block_fp8(w_f32, block_size)
bias = torch.randn(N, dtype=torch.float32) * 0.1 if use_bias else None
ref_out = ref_fp8_block_scaled_mm(
x, fp8_weight, scales, block_size, bias, out_dtype
)
packed_weight = torch.ops._C.convert_weight_packed(fp8_weight)
kernel_out = ops.fp8_scaled_mm_cpu(
x,
packed_weight,
scales,
block_size,
bias,
out_dtype,
True,
)
assert kernel_out.dtype == out_dtype
torch.testing.assert_close(kernel_out, ref_out, rtol=0.02, atol=0.01)
+1
View File
@@ -13,6 +13,7 @@ MODELS = [
"Qwen/Qwen1.5-0.5B-Chat-GPTQ-Int4", # without g_idx
"RedHatAI/Qwen3-1.7B-quantized.w4a16", # with zp
"OPEA/Qwen2.5-0.5B-Instruct-int4-sym-inc",
"Qwen/Qwen3-0.6B-FP8", # FP8 W8A16 block-quantized
]
DTYPE = ["bfloat16"]
+33
View File
@@ -3219,6 +3219,39 @@ if hasattr(torch.ops._C, "int4_scaled_mm_cpu"):
_supports_cpu_w4a8_int8 = bool(hasattr(torch.ops._C, "convert_weight_packed_scale_zp"))
if hasattr(torch.ops._C, "fp8_scaled_mm_cpu"):
@register_fake("_C::fp8_scaled_mm_cpu")
def fp8_scaled_mm_cpu_fake(
mat1: torch.Tensor,
mat2: torch.Tensor,
scales2: torch.Tensor,
block_size: list[int],
bias: torch.Tensor | None,
out_dtype: torch.dtype,
is_vnni: bool,
) -> torch.Tensor:
M = mat1.size(0)
N = mat2.size(0)
return torch.empty((M, N), dtype=out_dtype, device=mat1.device)
_supports_cpu_fp8_w8a16 = bool(hasattr(torch.ops._C, "fp8_scaled_mm_cpu"))
def fp8_scaled_mm_cpu(
mat1: torch.Tensor,
mat2: torch.Tensor,
scales2: torch.Tensor,
block_size: list[int],
bias: torch.Tensor | None,
out_dtype: torch.dtype,
is_vnni: bool,
) -> torch.Tensor:
return torch.ops._C.fp8_scaled_mm_cpu(
mat1, mat2, scales2, block_size, bias, out_dtype, is_vnni
)
class CPUDNNLGEMMHandler:
def __init__(self) -> None:
@@ -110,6 +110,7 @@ from vllm.model_executor.kernels.linear.scaled_mm.aiter import (
AiterPreshuffledPerTokenFp8ScaledMMLinearKernel,
)
from vllm.model_executor.kernels.linear.scaled_mm.cpu import (
CPUFp8BlockScaledMMKernel,
CPUInt8ScaledMMLinearKernel,
)
from vllm.model_executor.kernels.linear.scaled_mm.cutlass import (
@@ -199,6 +200,9 @@ _POSSIBLE_FP8_BLOCK_KERNELS: dict[
AiterFp8BlockScaledMMKernel,
TritonFp8BlockScaledMMKernel,
],
PlatformEnum.CPU: [
CPUFp8BlockScaledMMKernel,
],
}
_POSSIBLE_WFP8A16_KERNELS: dict[PlatformEnum, list[type[FP8ScaledMMLinearKernel]]] = {
@@ -8,6 +8,7 @@ from vllm.model_executor.kernels.linear.scaled_mm.BlockScaledMMLinearKernel impo
Fp8BlockScaledMMLinearKernel,
)
from vllm.model_executor.kernels.linear.scaled_mm.cpu import (
CPUFp8BlockScaledMMKernel,
CPUInt8ScaledMMLinearKernel,
)
from vllm.model_executor.kernels.linear.scaled_mm.cutlass import (
@@ -58,4 +59,5 @@ __all__ = [
"ROCmFP8ScaledMMLinearKernel",
"TritonInt8ScaledMMLinearKernel",
"Fp8BlockScaledMMLinearKernel",
"CPUFp8BlockScaledMMKernel",
]
@@ -14,6 +14,10 @@ from vllm.model_executor.layers.utils import check_cpu_sgl_kernel
from vllm.platforms import current_platform
from vllm.platforms.interface import CpuArchEnum
from .BlockScaledMMLinearKernel import (
Fp8BlockScaledMMLinearKernel,
FP8ScaledMMLinearLayerConfig,
)
from .ScaledMMLinearKernel import (
Int8ScaledMMLinearKernel,
Int8ScaledMMLinearLayerConfig,
@@ -215,3 +219,109 @@ class CPUInt8ScaledMMLinearKernel(Int8ScaledMMLinearKernel):
x.dtype,
True,
)
class CPUFp8BlockScaledMMKernel(Fp8BlockScaledMMLinearKernel):
"""FP8 W8A16 block-quantized GEMM via AMX BRGEMM on CPU."""
# Input stays BF16 — no FP8 activation quantization.
apply_input_quant = False
@classmethod
def is_supported(
cls, compute_capability: int | None = None
) -> tuple[bool, str | None]:
if not current_platform.is_cpu():
return False, "requires CPU platform."
if not torch.cpu._is_amx_tile_supported():
return False, "requires AMX tile support (Sapphire Rapids or newer)."
if not ops._supports_cpu_fp8_w8a16:
return False, "fp8_scaled_mm_cpu op not available."
return True, None
@classmethod
def can_implement(
cls, config: FP8ScaledMMLinearLayerConfig
) -> tuple[bool, str | None]:
# Validate weight block shape
weight_gs = config.weight_quant_key.scale.group_shape
if weight_gs.col <= 0 or weight_gs.col != 128:
return False, (
"CPU FP8 kernel requires K-dimension block size of 128, "
f"got {weight_gs.col}."
)
if weight_gs.row <= 0 or weight_gs.row % 32 != 0:
return False, (
"CPU FP8 kernel requires N-dimension block size to be "
f"a positive multiple of 32, got {weight_gs.row}."
)
if config.out_dtype not in (torch.bfloat16, torch.float32):
return False, "Only bfloat16/float32 output dtype supported."
return True, None
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
# Skip the base class process (FP8 padding / fnuz normalization)
# which is GPU-oriented. Instead, VNNI-prepack weights for AMX.
params = self._get_layer_params(layer)
packed_weight = torch.ops._C.convert_weight_packed(params.weight)
replace_parameter(
layer,
params.WEIGHT,
torch.nn.Parameter(packed_weight, requires_grad=False),
)
# Re-wrap scale as a plain Parameter so the kernel can read it
# without weight-loader metadata interfering.
scale_attr = (
params.WEIGHT_SCALE_INV
if params.weight_scale_inv is not None
else params.WEIGHT_SCALE
)
weight_scale = (
params.weight_scale_inv
if params.weight_scale_inv is not None
else params.weight_scale
)
assert weight_scale is not None
replace_parameter(
layer,
scale_attr,
torch.nn.Parameter(weight_scale.data, requires_grad=False),
)
def apply_weights(
self,
layer: torch.nn.Module,
x: torch.Tensor,
bias: torch.Tensor | None = None,
**kwargs,
) -> torch.Tensor:
params = self._get_layer_params(layer)
weight_scale = (
params.weight_scale_inv
if params.weight_scale_inv is not None
else params.weight_scale
)
x_2d = x.reshape(-1, x.shape[-1]) if x.dim() > 2 else x
out = torch.ops._C.fp8_scaled_mm_cpu(
x_2d,
params.weight,
weight_scale,
list(self.weight_group_shape),
bias,
x.dtype,
True, # is_vnni (weight already prepacked)
)
return out.reshape(x.shape[:-1] + (out.size(-1),)) if x.dim() > 2 else out
def apply_block_scaled_mm(
self,
A: torch.Tensor,
B: torch.Tensor,
As: torch.Tensor,
Bs: torch.Tensor,
) -> torch.Tensor:
raise NotImplementedError(
"CPUFp8BlockScaledMMKernel overrides apply_weights directly."
)