main
py 497 lines 16.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for CPU FP8 scaled-mm GEMM kernels.
4
5 Covers:
6 * fp8_scaled_mm_cpu (W8A16)
7 * fp8_scaled_mm_with_quant (W8A8)
8
9 Run `pytest tests/kernels/quantization/test_cpu_fp8_scaled_mm.py -v`.
10 """
11
12 import pytest
13 import torch
14
15 from vllm import _custom_ops as ops
16 from vllm.model_executor.kernels.linear.scaled_mm.cpu import (
17 CPUFp8PerTensorScaledMMLinearKernel,
18 )
19 from vllm.model_executor.kernels.linear.scaled_mm.ScaledMMLinearKernel import (
20 FP8ScaledMMLinearLayerConfig,
21 )
22 from vllm.model_executor.layers.quantization.utils.quant_utils import (
23 kFp8StaticTensorSym,
24 )
25 from vllm.platforms import current_platform
26
27 if not current_platform.is_cpu():
28 pytest.skip("skipping CPU-only tests", allow_module_level=True)
29
30 requires_cpu_fp8_w8a16 = pytest.mark.skipif(
31 not ops._supports_cpu_fp8_w8a16, reason="fp8_scaled_mm_cpu op not available"
32 )
33 requires_cpu_fp8_w8a8 = pytest.mark.skipif(
34 not ops.cpu_has_amx_fp8(),
35 reason="requires native AMX-FP8 hardware",
36 )
37
38 if not torch.cpu._is_amx_tile_supported():
39 pytest.skip("requires AMX tile support", allow_module_level=True)
40
41 FP8_MAX = torch.finfo(torch.float8_e4m3fn).max
42 BLOCK_SIZE = [128, 128]
43 GROUP_SIZE = 128
44
45
46 def cdiv(a: int, b: int) -> int:
47 return -(a // -b)
48
49
50 # ---------------------------------------------------------------------------
51 # W8A16 helpers
52 # ---------------------------------------------------------------------------
53
54
55 def quantize_weight_block_fp8(
56 weight: torch.Tensor,
57 block_size: list[int],
58 ) -> tuple[torch.Tensor, torch.Tensor]:
59 """Quantize weight [N, K] to FP8 with block scales.
60
61 Returns:
62 fp8_weight: [N, K] float8_e4m3fn
63 scales: [n_tiles, k_tiles] float32
64
65 """
66 N, K = weight.shape
67 block_n, block_k = block_size
68 fp8_max = torch.finfo(torch.float8_e4m3fn).max
69
70 n_tiles = cdiv(N, block_n)
71 k_tiles = cdiv(K, block_k)
72
73 # Pad for even blocking
74 pad_N = (block_n - (N % block_n)) % block_n
75 pad_K = (block_k - (K % block_k)) % block_k
76 if pad_N > 0 or pad_K > 0:
77 weight = torch.nn.functional.pad(weight, (0, pad_K, 0, pad_N))
78
79 # Reshape into blocks
80 w_blocks = weight.view(n_tiles, block_n, k_tiles, block_k)
81 w_blocks = w_blocks.permute(0, 2, 1, 3).contiguous()
82
83 # Per-block scale
84 abs_max = w_blocks.abs().amax(dim=(-2, -1), keepdim=True)
85 scales = abs_max / fp8_max
86 scales = torch.where(scales == 0, torch.ones_like(scales), scales)
87
88 # Quantize
89 q_fp8 = (w_blocks / scales).clamp(-fp8_max, fp8_max).to(torch.float8_e4m3fn)
90
91 # Reshape back
92 fp8_weight = (
93 q_fp8.permute(0, 2, 1, 3)
94 .contiguous()
95 .view(N + pad_N, K + pad_K)[:N, :K]
96 .contiguous()
97 )
98
99 scales = scales.view(n_tiles, k_tiles)
100 return fp8_weight, scales
101
102
103 def dequant_weight_block_fp8(
104 fp8_weight: torch.Tensor,
105 scales: torch.Tensor,
106 block_size: list[int],
107 out_dtype: torch.dtype,
108 ) -> torch.Tensor:
109 """Dequantize FP8 weight back to float for reference computation."""
110 N, K = fp8_weight.shape
111 block_n, block_k = block_size
112 n_tiles, k_tiles = scales.shape
113
114 pad_N = (block_n - (N % block_n)) % block_n
115 pad_K = (block_k - (K % block_k)) % block_k
116 if pad_N > 0 or pad_K > 0:
117 fp8_padded = torch.nn.functional.pad(fp8_weight.float(), (0, pad_K, 0, pad_N))
118 else:
119 fp8_padded = fp8_weight.float()
120
121 w_blocks = fp8_padded.view(n_tiles, block_n, k_tiles, block_k)
122 w_blocks = w_blocks.permute(0, 2, 1, 3).contiguous()
123 dq = w_blocks * scales.view(n_tiles, k_tiles, 1, 1)
124 dq = dq.permute(0, 2, 1, 3).contiguous().view(N + pad_N, K + pad_K)
125 return dq[:N, :K].to(out_dtype)
126
127
128 def ref_fp8_block_scaled_mm(
129 x: torch.Tensor,
130 fp8_weight: torch.Tensor,
131 scales: torch.Tensor,
132 block_size: list[int],
133 bias: torch.Tensor | None,
134 out_dtype: torch.dtype,
135 ) -> torch.Tensor:
136 """Reference: dequant FP8→float32, matmul in float32, cast to out_dtype."""
137 w_dq = dequant_weight_block_fp8(fp8_weight, scales, block_size, torch.float32)
138 out = torch.mm(x.float(), w_dq.t())
139 if bias is not None:
140 out = out + bias.float()
141 return out.to(out_dtype)
142
143
144 # ---------------------------------------------------------------------------
145 # W8A16 test: fp8_scaled_mm_cpu
146 # ---------------------------------------------------------------------------
147 M_SIZES = [1, 4, 16, 64, 128]
148 # (N, K) — weight shape is [N, K], output has N columns.
149 NK_SIZES = [
150 (128, 256),
151 (256, 512),
152 (512, 1024),
153 (1024, 2048),
154 (5120, 5120),
155 (17408, 5120),
156 (5120, 17408),
157 ]
158
159
160 @requires_cpu_fp8_w8a16
161 @pytest.mark.parametrize("M", M_SIZES)
162 @pytest.mark.parametrize("N,K", NK_SIZES)
163 @pytest.mark.parametrize("use_bias", [False, True])
164 def test_cpu_fp8_scaled_mm(M: int, N: int, K: int, use_bias: bool):
165 """fp8_scaled_mm_cpu correctness against float reference."""
166 torch.manual_seed(42)
167 out_dtype = torch.bfloat16
168 block_size = BLOCK_SIZE
169
170 x = torch.randn(M, K, dtype=out_dtype) / (K**0.5)
171 w_f32 = torch.randn(N, K, dtype=torch.float32) / (K**0.5)
172 fp8_weight, scales = quantize_weight_block_fp8(w_f32, block_size)
173
174 bias = torch.randn(N, dtype=torch.float32) * 0.1 if use_bias else None
175
176 ref_out = ref_fp8_block_scaled_mm(
177 x, fp8_weight, scales, block_size, bias, out_dtype
178 )
179
180 packed_weight = torch.ops._C.convert_weight_packed(fp8_weight)
181 kernel_out = ops.fp8_scaled_mm_cpu(
182 x,
183 packed_weight,
184 scales,
185 block_size,
186 bias,
187 out_dtype,
188 True,
189 )
190
191 assert kernel_out.dtype == out_dtype
192 torch.testing.assert_close(kernel_out, ref_out, rtol=0.02, atol=0.01)
193
194
195 def quantize_weight_per_tensor_fp8(
196 weight: torch.Tensor,
197 ) -> tuple[torch.Tensor, torch.Tensor]:
198 """Quantize weight [N, K] to FP8 with a single per-tensor scale."""
199 fp8_max = torch.finfo(torch.float8_e4m3fn).max
200 scale = weight.abs().amax() / fp8_max
201 scale = torch.where(scale == 0, torch.ones_like(scale), scale)
202 q = (weight / scale).clamp(-fp8_max, fp8_max).to(torch.float8_e4m3fn)
203 return q, scale
204
205
206 def ref_fp8_per_tensor_scaled_mm(
207 x: torch.Tensor,
208 fp8_weight: torch.Tensor,
209 scale: torch.Tensor,
210 bias: torch.Tensor | None,
211 out_dtype: torch.dtype,
212 ) -> torch.Tensor:
213 w_dq = fp8_weight.float() * scale
214 out = torch.mm(x.float(), w_dq.t())
215 if bias is not None:
216 out = out + bias.float()
217 return out.to(out_dtype)
218
219
220 # ---------------------------------------------------------------------------
221 # W8A8 helpers
222 # ---------------------------------------------------------------------------
223
224
225 def quantize_weight_per_tensor(
226 weight: torch.Tensor,
227 ) -> tuple[torch.Tensor, torch.Tensor]:
228 """Quantize weight [N, K] to FP8 with a single per-tensor scale, broadcast
229 to [N, 1] (the shape float8_linear_prepack_cpu expects)."""
230 N = weight.shape[0]
231 abs_max = weight.abs().max()
232 scale = (abs_max / FP8_MAX).clamp(min=1e-7)
233 q = (weight.float() / scale).clamp(-FP8_MAX, FP8_MAX).to(torch.float8_e4m3fn)
234 return q, scale.expand(N).contiguous().view(N, 1)
235
236
237 def quantize_weight_per_channel(
238 weight: torch.Tensor,
239 ) -> tuple[torch.Tensor, torch.Tensor]:
240 """Quantize weight [N, K] to FP8 with per-channel (per-row) scales [N, 1]."""
241 abs_max = weight.abs().amax(dim=1, keepdim=True)
242 scale = (abs_max / FP8_MAX).clamp(min=1e-7)
243 q = (weight.float() / scale).clamp(-FP8_MAX, FP8_MAX).to(torch.float8_e4m3fn)
244 return q, scale.float()
245
246
247 def quantize_weight_per_group(
248 weight: torch.Tensor,
249 group_size: int = GROUP_SIZE,
250 ) -> tuple[torch.Tensor, torch.Tensor]:
251 """Quantize weight [N, K] to FP8 with per-group (128-block) scales [N, G]."""
252 N, K = weight.shape
253 G = K // group_size
254 w_groups = weight.view(N, G, group_size)
255 abs_max = w_groups.abs().amax(dim=2, keepdim=True)
256 scale = (abs_max / FP8_MAX).clamp(min=1e-7)
257 q = (w_groups.float() / scale).clamp(-FP8_MAX, FP8_MAX).to(torch.float8_e4m3fn)
258 return q.view(N, K), scale.squeeze(2).float()
259
260
261 def quantize_act_per_token(x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
262 """Reference per-token (row) dynamic FP8 quantization of activations."""
263 x_f = x.float()
264 scale = (x_f.abs().amax(dim=1, keepdim=True) / FP8_MAX).clamp(min=1e-7)
265 scaled = (x_f / scale).clamp(-FP8_MAX, FP8_MAX)
266 # Round via fp16 to match the AVX10.2 kernel path.
267 q = scaled.half().to(torch.float8_e4m3fn)
268 return q, scale
269
270
271 def reference_static_fp8_linear(
272 x: torch.Tensor, # BF16 [M, K]
273 weight_fp8: torch.Tensor, # FP8 [N, K]
274 weight_scale: torch.Tensor, # float32 [N, 1]
275 act_scale: torch.Tensor, # float32 scalar
276 bias: torch.Tensor | None,
277 out_dtype: torch.dtype,
278 ) -> torch.Tensor:
279 """Static per-tensor: quantize/dequant x with act_scale, dequant weight, matmul."""
280 x_scaled = (x.float() / act_scale.item()).clamp(-FP8_MAX, FP8_MAX)
281 # Round via fp16 to match the AVX10.2 kernel path.
282 x_q = x_scaled.half().to(torch.float8_e4m3fn)
283 x_dq = x_q.float() * act_scale.item()
284 w_dq = weight_fp8.float() * weight_scale.view(-1, 1)
285 out = torch.mm(x_dq, w_dq.t())
286 if bias is not None:
287 out = out + bias.float()
288 return out.to(out_dtype)
289
290
291 NK_SIZES_PER_TENSOR = [
292 (32, 64),
293 (5120, 5120),
294 ]
295
296
297 @pytest.mark.parametrize("M", [1, 64])
298 @pytest.mark.parametrize("N,K", NK_SIZES_PER_TENSOR)
299 @pytest.mark.parametrize("use_bias", [False, True])
300 def test_cpu_fp8_per_tensor_scaled_mm_kernel(
301 M: int, N: int, K: int, use_bias: bool, default_vllm_config
302 ):
303 """CPUFp8PerTensorScaledMMLinearKernel correctness against float reference.
304
305 Exercises the full kernel class (not just the raw op), including the
306 weight-orientation fixup: Fp8LinearMethod stores `layer.weight` as
307 [K, N] (torch._scaled_mm convention) before calling
308 process_weights_after_loading, so the kernel must transpose back to
309 [N, K] before VNNI-packing.
310 """
311 torch.manual_seed(0)
312 out_dtype = torch.bfloat16
313
314 x = torch.randn(M, K, dtype=out_dtype) / (K**0.5)
315 w_f32 = torch.randn(N, K, dtype=torch.float32) / (K**0.5)
316 fp8_weight, scale = quantize_weight_per_tensor_fp8(w_f32)
317 bias = torch.randn(N, dtype=torch.float32) * 0.1 if use_bias else None
318
319 ref_out = ref_fp8_per_tensor_scaled_mm(x, fp8_weight, scale, bias, out_dtype)
320
321 config = FP8ScaledMMLinearLayerConfig(
322 weight_quant_key=kFp8StaticTensorSym,
323 activation_quant_key=kFp8StaticTensorSym,
324 weight_shape=(N, K),
325 input_dtype=out_dtype,
326 out_dtype=out_dtype,
327 )
328 kernel = CPUFp8PerTensorScaledMMLinearKernel(
329 config,
330 layer_param_names=["weight", "weight_scale", "input_scale", "input_scale_ub"],
331 )
332
333 layer = torch.nn.Module()
334 # Fp8LinearMethod stores the weight transposed to [K, N] before calling
335 # process_weights_after_loading.
336 layer.register_parameter(
337 "weight", torch.nn.Parameter(fp8_weight.t().contiguous(), requires_grad=False)
338 )
339 layer.register_parameter(
340 "weight_scale", torch.nn.Parameter(scale.clone(), requires_grad=False)
341 )
342 kernel.process_weights_after_loading(layer)
343
344 kernel_out = kernel.apply_weights(layer, x, bias)
345
346 assert kernel_out.dtype == out_dtype
347 torch.testing.assert_close(kernel_out, ref_out, rtol=0.02, atol=0.01)
348
349
350 @pytest.mark.parametrize("n", [16, 48])
351 def test_cpu_fp8_per_tensor_kernel_rejects_non_multiple_of_32_n(n: int):
352 """The AMX tinygemm kernel tiles N in chunks of 32 and cannot handle a
353 remainder tile smaller than that, so can_implement must reject any N
354 that isn't a multiple of 32 rather than let it crash the process.
355 """
356 config = FP8ScaledMMLinearLayerConfig(
357 weight_quant_key=kFp8StaticTensorSym,
358 activation_quant_key=kFp8StaticTensorSym,
359 weight_shape=(n, 64),
360 input_dtype=torch.bfloat16,
361 out_dtype=torch.bfloat16,
362 )
363 supported, _ = CPUFp8PerTensorScaledMMLinearKernel.can_implement(config)
364 assert not supported
365
366
367 def reference_dynamic_fp8_linear(
368 x: torch.Tensor, # BF16 [M, K]
369 weight_fp8: torch.Tensor, # FP8 [N, K]
370 weight_scale: torch.Tensor, # float32 [N, G]
371 bias: torch.Tensor | None,
372 out_dtype: torch.dtype,
373 ) -> torch.Tensor:
374 """Dynamic per-token act x per-group weight: quantize x per-token,
375 dequant weight per-group, matmul."""
376 N, K = weight_fp8.shape
377 G = weight_scale.shape[1]
378 group_size = K // G
379 x_q, x_scale = quantize_act_per_token(x)
380 x_dq = x_q.float() * x_scale
381 w_dq = (
382 weight_fp8.float().view(N, G, group_size) * weight_scale.view(N, G, 1)
383 ).view(N, K)
384 out = torch.mm(x_dq, w_dq.t())
385 if bias is not None:
386 out = out + bias.float()
387 return out.to(out_dtype)
388
389
390 # ---------------------------------------------------------------------------
391 # W8A8 tests: fp8_scaled_mm_with_quant
392 # ---------------------------------------------------------------------------
393 W8A8_M_SIZES = [1, 4, 16, 64]
394 W8A8_NK_SIZES = [(128, 256), (256, 512), (64, 128)]
395
396
397 @requires_cpu_fp8_w8a8
398 @pytest.mark.parametrize("static_activation", [True, False])
399 @pytest.mark.parametrize("M", W8A8_M_SIZES)
400 @pytest.mark.parametrize("N,K", W8A8_NK_SIZES)
401 @pytest.mark.parametrize("use_bias", [False, True])
402 def test_cpu_fp8_w8a8_per_tensor(
403 M: int, N: int, K: int, use_bias: bool, static_activation: bool
404 ):
405 """CPUFP8W8A8ScaledMMLinearKernel with per-tensor weight scale."""
406 torch.manual_seed(42)
407 out_dtype = torch.bfloat16
408
409 x = torch.randn(M, K, dtype=torch.bfloat16)
410 weight = torch.randn(N, K, dtype=torch.bfloat16)
411 bias = torch.randn(N, dtype=torch.float32) if use_bias else None
412
413 w_fp8, w_scale = quantize_weight_per_tensor(weight)
414 packed_weight, packed_scale = torch.ops._C.float8_linear_prepack_cpu(w_fp8, w_scale)
415
416 if static_activation:
417 act_scale = (x.float().abs().max() / FP8_MAX).clamp(min=1e-7).view(1)
418 kernel_out = ops.fp8_scaled_mm_with_quant(
419 x, act_scale, False, packed_weight, packed_scale, bias, out_dtype
420 )
421 ref_out = reference_static_fp8_linear(
422 x, w_fp8, w_scale, act_scale, bias, out_dtype
423 )
424 atol = 0.05
425 else:
426 kernel_out = ops.fp8_scaled_mm_with_quant(
427 x, None, True, packed_weight, packed_scale, bias, out_dtype
428 )
429 ref_out = reference_dynamic_fp8_linear(x, w_fp8, w_scale, bias, out_dtype)
430 atol = 0.05
431
432 assert kernel_out.dtype == out_dtype
433 torch.testing.assert_close(kernel_out, ref_out, rtol=0.05, atol=atol)
434
435
436 @requires_cpu_fp8_w8a8
437 @pytest.mark.parametrize("static_activation", [True, False])
438 @pytest.mark.parametrize("M", W8A8_M_SIZES)
439 @pytest.mark.parametrize("N,K", W8A8_NK_SIZES)
440 @pytest.mark.parametrize("use_bias", [False, True])
441 def test_cpu_fp8_w8a8_per_channel(
442 M: int, N: int, K: int, use_bias: bool, static_activation: bool
443 ):
444 """CPUFP8W8A8ScaledMMLinearKernel with per-channel weight scale."""
445 torch.manual_seed(42)
446 out_dtype = torch.bfloat16
447
448 x = torch.randn(M, K, dtype=torch.bfloat16)
449 weight = torch.randn(N, K, dtype=torch.bfloat16)
450 bias = torch.randn(N, dtype=torch.float32) if use_bias else None
451
452 w_fp8, w_scale = quantize_weight_per_channel(weight)
453 packed_weight, packed_scale = torch.ops._C.float8_linear_prepack_cpu(w_fp8, w_scale)
454
455 if static_activation:
456 act_scale = (x.float().abs().max() / FP8_MAX).clamp(min=1e-7).view(1)
457 kernel_out = ops.fp8_scaled_mm_with_quant(
458 x, act_scale, False, packed_weight, packed_scale, bias, out_dtype
459 )
460 ref_out = reference_static_fp8_linear(
461 x, w_fp8, w_scale, act_scale, bias, out_dtype
462 )
463 atol = 0.05
464 else:
465 kernel_out = ops.fp8_scaled_mm_with_quant(
466 x, None, True, packed_weight, packed_scale, bias, out_dtype
467 )
468 ref_out = reference_dynamic_fp8_linear(x, w_fp8, w_scale, bias, out_dtype)
469 atol = 0.05
470
471 assert kernel_out.dtype == out_dtype
472 torch.testing.assert_close(kernel_out, ref_out, rtol=0.05, atol=atol)
473
474
475 @requires_cpu_fp8_w8a8
476 @pytest.mark.parametrize("M", W8A8_M_SIZES)
477 @pytest.mark.parametrize("N,K", W8A8_NK_SIZES)
478 @pytest.mark.parametrize("use_bias", [False, True])
479 def test_cpu_fp8_w8a8_per_group(M: int, N: int, K: int, use_bias: bool):
480 """CPUFp8W8A8BlockScaledMMKernel: per-group (128-block) weight scale +
481 dynamic per-token activation scale."""
482 torch.manual_seed(42)
483 out_dtype = torch.bfloat16
484
485 x = torch.randn(M, K, dtype=torch.bfloat16)
486 weight = torch.randn(N, K, dtype=torch.bfloat16)
487 bias = torch.randn(N, dtype=torch.float32) if use_bias else None
488
489 w_fp8, w_scale = quantize_weight_per_group(weight)
490 packed_weight, packed_scale = torch.ops._C.float8_linear_prepack_cpu(w_fp8, w_scale)
491 kernel_out = ops.fp8_scaled_mm_with_quant(
492 x, None, True, packed_weight, packed_scale, bias, out_dtype
493 )
494 ref_out = reference_dynamic_fp8_linear(x, w_fp8, w_scale, bias, out_dtype)
495
496 assert kernel_out.dtype == out_dtype
497 torch.testing.assert_close(kernel_out, ref_out, rtol=0.05, atol=0.05)