main
py 1,316 lines 41.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for CPU quantized fused MoE kernels."""
4
5 import math
6 import sys
7
8 import pytest
9 import torch
10 import torch.nn.functional as F
11
12 from vllm.platforms import current_platform
13 from vllm.utils.torch_utils import set_random_seed
14
15 if not current_platform.is_cpu():
16 pytest.skip("skipping CPU-only tests", allow_module_level=True)
17
18 import vllm._custom_ops as ops # noqa: E402
19
20 if not hasattr(torch.ops._C, "fused_experts_cpu"):
21 pytest.skip("fused_experts_cpu op not available", allow_module_level=True)
22
23
24 def _silu_and_mul(x: torch.Tensor) -> torch.Tensor:
25 d = x.shape[-1] // 2
26 return F.silu(x[..., :d]) * x[..., d:]
27
28
29 def _prepack_experts(w: torch.Tensor) -> torch.Tensor:
30 """VNNI-prepack expert weights via ``convert_weight_packed``."""
31 return torch.ops._C.convert_weight_packed(w)
32
33
34 def _deterministic_expert_routes(
35 block_sizes: tuple[int, ...],
36 ) -> tuple[torch.Tensor, torch.Tensor]:
37 """Create top-1 routes with exact per-expert block occupancies."""
38 topk_ids = torch.cat(
39 [
40 torch.full((size,), expert, dtype=torch.int32)
41 for expert, size in enumerate(block_sizes)
42 ]
43 )
44 topk_ids = topk_ids.view(-1, 1)
45 topk_weights = torch.ones(topk_ids.shape, dtype=torch.float32)
46 return topk_weights, topk_ids
47
48
49 # ===========================================================================
50 # FP8 W8A16 MoE
51 # ===========================================================================
52
53
54 BLOCK_SIZE = [128, 128] # [block_n, block_k]
55
56 _FP8_INFO = torch.finfo(torch.float8_e4m3fn)
57 FP8_SCALE = _FP8_INFO.max # 448.0
58 FACTOR_FOR_SCALE = 1e-3
59
60
61 def _block_dequant_weight(
62 weight: torch.Tensor,
63 scales: torch.Tensor,
64 block_size: list[int],
65 ) -> torch.Tensor:
66 """Block-dequantize FP8 weight [E, N, K] -> float [E, N, K]."""
67 E, N, K = weight.shape
68 block_n, block_k = block_size
69 pad_N = (block_n - N % block_n) % block_n
70 pad_K = (block_k - K % block_k) % block_k
71
72 if pad_N > 0 or pad_K > 0:
73 weight = F.pad(weight, (0, pad_K, 0, pad_N))
74
75 n_tiles = math.ceil(N / block_n)
76 k_tiles = math.ceil(K / block_k)
77
78 weight_block = (
79 weight.view(E, n_tiles, block_n, k_tiles, block_k)
80 .permute(0, 1, 3, 2, 4)
81 .float()
82 .contiguous()
83 )
84 weight_scaled = (
85 (weight_block * scales.view(E, n_tiles, k_tiles, 1, 1))
86 .permute(0, 1, 3, 2, 4)
87 .contiguous()
88 )
89 if pad_N > 0 or pad_K > 0:
90 weight_scaled = weight_scaled.view(E, N + pad_N, K + pad_K)
91 weight_scaled = weight_scaled[..., :N, :K].contiguous()
92 else:
93 weight_scaled = weight_scaled.view(E, N, K)
94 return weight_scaled
95
96
97 def ref_w8a16_block_fp8_moe(
98 a: torch.Tensor,
99 w1: torch.Tensor,
100 w2: torch.Tensor,
101 w1_s: torch.Tensor,
102 w2_s: torch.Tensor,
103 topk_weight: torch.Tensor,
104 topk_ids: torch.Tensor,
105 block_size: list[int],
106 ) -> torch.Tensor:
107 """Reference FP8 W8A16 block-scaled fused MoE in pure torch."""
108 B, D = a.shape
109 topk = topk_ids.size(1)
110
111 w1_dq = _block_dequant_weight(w1, w1_s, block_size)
112 w2_dq = _block_dequant_weight(w2, w2_s, block_size)
113
114 a_exp = a.view(B, -1, D).repeat(1, topk, 1).reshape(-1, D).float()
115 out = torch.zeros(B * topk, w2_dq.shape[1], dtype=torch.float32)
116
117 topk_weight_flat = topk_weight.view(-1)
118 topk_ids_flat = topk_ids.view(-1)
119
120 for i in range(w1_dq.shape[0]):
121 mask = topk_ids_flat == i
122 if mask.sum():
123 ic0 = torch.matmul(a_exp[mask], w1_dq[i].transpose(0, 1))
124 ic1 = _silu_and_mul(ic0)
125 out[mask] = torch.matmul(ic1, w2_dq[i].transpose(0, 1))
126
127 return (
128 (out.view(B, -1, w2_dq.shape[1]) * topk_weight_flat.view(B, -1, 1))
129 .sum(dim=1)
130 .to(a.dtype)
131 )
132
133
134 def _make_fp8_moe_weights(
135 E: int,
136 N: int,
137 K: int,
138 block_size: list[int],
139 ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
140 """Generate random FP8 MoE weights with random block scales."""
141 block_n, block_k = block_size
142
143 w1 = (
144 (torch.randn(E, 2 * N, K) * FP8_SCALE)
145 .clamp(min=-FP8_SCALE, max=FP8_SCALE)
146 .to(torch.float8_e4m3fn)
147 )
148 w2 = (
149 (torch.randn(E, K, N) * FP8_SCALE)
150 .clamp(min=-FP8_SCALE, max=FP8_SCALE)
151 .to(torch.float8_e4m3fn)
152 )
153
154 w1_s = (
155 torch.randn(E, math.ceil(2 * N / block_n), math.ceil(K / block_k))
156 * FACTOR_FOR_SCALE
157 )
158 w2_s = (
159 torch.randn(E, math.ceil(K / block_n), math.ceil(N / block_k))
160 * FACTOR_FOR_SCALE
161 )
162 return w1, w2, w1_s, w2_s
163
164
165 FP8_NUM_TOKENS = [1, 2, 64, 121]
166 FP8_MOE_CONFIGS = [
167 (256, 512, 8, 2),
168 (256, 512, 8, 4),
169 (512, 256, 8, 2),
170 (512, 256, 8, 4),
171 (512, 512, 8, 2),
172 (512, 512, 8, 4),
173 (768, 2048, 8, 2),
174 (768, 2048, 8, 4),
175 (768, 2048, 128, 8),
176 ]
177
178
179 @pytest.mark.parametrize("M", FP8_NUM_TOKENS)
180 @pytest.mark.parametrize("N,K,E,topk", FP8_MOE_CONFIGS)
181 @pytest.mark.parametrize("seed", [0])
182 def test_w8a16_block_fp8_cpu_fused_moe(M, N, K, E, topk, seed):
183 """Test fused_experts_cpu FP8 W8A16 against dequantised torch reference."""
184 set_random_seed(seed)
185
186 a = torch.randn(M, K, dtype=torch.bfloat16) / math.sqrt(K)
187 w1, w2, w1_s, w2_s = _make_fp8_moe_weights(E, N, K, BLOCK_SIZE)
188
189 score = torch.randn(M, E, dtype=torch.bfloat16)
190 score = torch.softmax(score, dim=-1, dtype=torch.float32)
191 topk_weight, topk_ids = torch.topk(score, topk)
192 topk_ids = topk_ids.to(torch.int32)
193
194 ref_out = ref_w8a16_block_fp8_moe(
195 a, w1, w2, w1_s, w2_s, topk_weight, topk_ids, BLOCK_SIZE
196 )
197
198 pw1, pw2 = _prepack_experts(w1), _prepack_experts(w2)
199
200 # Test writing into a fresh output buffer against reference
201 out = torch.empty_like(a)
202 ops.fused_experts_cpu(
203 out,
204 a.clone(),
205 pw1,
206 pw2,
207 topk_weight,
208 topk_ids,
209 ops.CPUQuantMethod.FP8_W8A16,
210 w1_s,
211 w2_s,
212 None,
213 None,
214 BLOCK_SIZE,
215 is_vnni=True,
216 )
217 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
218
219 # Test writing into the input tensor itself (aliased in-place) produces
220 # an identical result
221 a_inplace = a.clone()
222 ops.fused_experts_cpu(
223 a_inplace,
224 a_inplace,
225 pw1,
226 pw2,
227 topk_weight,
228 topk_ids,
229 ops.CPUQuantMethod.FP8_W8A16,
230 w1_s,
231 w2_s,
232 None,
233 None,
234 BLOCK_SIZE,
235 is_vnni=True,
236 )
237 torch.testing.assert_close(a_inplace, out, atol=0, rtol=0)
238
239
240 def test_w8a16_block_fp8_cpu_fused_moe_small_expert_blocks():
241 """Test FP8 BRGEMM at exact and multi-block expert boundaries."""
242 set_random_seed(0)
243 block_sizes = (4, 5, 33)
244 N, K, E = 128, 128, len(block_sizes)
245 M = sum(block_sizes)
246
247 a = torch.randn(M, K, dtype=torch.bfloat16) / math.sqrt(K)
248 w1, w2, w1_s, w2_s = _make_fp8_moe_weights(E, N, K, BLOCK_SIZE)
249 topk_weight, topk_ids = _deterministic_expert_routes(block_sizes)
250
251 ref_out = ref_w8a16_block_fp8_moe(
252 a, w1, w2, w1_s, w2_s, topk_weight, topk_ids, BLOCK_SIZE
253 )
254 pw1, pw2 = _prepack_experts(w1), _prepack_experts(w2)
255 out = torch.empty_like(a)
256 ops.fused_experts_cpu(
257 out,
258 a,
259 pw1,
260 pw2,
261 topk_weight,
262 topk_ids,
263 ops.CPUQuantMethod.FP8_W8A16,
264 w1_s,
265 w2_s,
266 None,
267 None,
268 BLOCK_SIZE,
269 is_vnni=True,
270 )
271
272 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
273
274
275 # ===========================================================================
276 # MXFP4 W4A16 MoE
277 # ===========================================================================
278
279
280 class MXFP4QuantizeUtil:
281 """MXFP4 quantization utility."""
282
283 E2M1_max = 6.0
284 E2M1_values = [0, 0.5, 1, 1.5, 2, 3, 4, 6]
285 E2M1_bounds = torch.tensor([0.25, 0.75, 1.25, 1.75, 2.5, 3.5, 5])
286 block_size = 32
287
288 @classmethod
289 def quantize(cls, input: torch.Tensor) -> tuple:
290 """Quantize BF16 tensor to MXFP4 packed uint8 format."""
291
292 def cast_fp4(x):
293 sign = torch.sign(x)
294 sign_bit = (2 - sign) // 2
295 ord_ = torch.sum(
296 (x.abs().unsqueeze(-1) - cls.E2M1_bounds.to(x.device)) > 0, dim=-1
297 )
298 fp4_val = (sign_bit * 0b1000 + ord_).to(torch.uint8)
299 return fp4_val
300
301 def fuse_uint4_to_uint8(x):
302 left_side = x[..., 0::2]
303 right_side = x[..., 1::2]
304 new_data = right_side.clone() << 4
305 new_data[..., : left_side.shape[-1]] += left_side
306 return new_data
307
308 original_shape = input.shape
309 input = input.view(-1, cls.block_size)
310 input_amax = input.abs().max(dim=-1, keepdim=True).values
311 descale = input_amax / cls.E2M1_max
312 min_value = torch.tensor(-127.0, device=descale.device)
313 e8m0_scale = torch.ceil(torch.maximum(torch.log2(descale), min_value))
314
315 input = (input / torch.exp2(e8m0_scale)).view(original_shape)
316 input_q = cast_fp4(input)
317 input_q = fuse_uint4_to_uint8(input_q)
318 e8m0_scale = (e8m0_scale + 127).to(torch.uint8)
319 return input_q, e8m0_scale
320
321 @classmethod
322 def dequantize(cls, quantized_data, dtype: torch.dtype, scale):
323 """Dequantize MXFP4 packed tensor back to float."""
324
325 def unfuse_uint8_to_uint4(x):
326 left_side = x & 0x0F
327 right_side = (x >> 4) & 0x0F
328 shape = list(x.shape)
329 shape[-1] = shape[-1] * 2
330 result = torch.zeros(shape, dtype=torch.uint8, device=x.device)
331 result[..., 0::2] = left_side
332 result[..., 1::2] = right_side
333 return result
334
335 e8m0_scale = scale
336 x_unfused = unfuse_uint8_to_uint4(quantized_data)
337 sign = 1 - 2 * ((x_unfused & 0b1000) >> 3).to(torch.float32)
338 magnitude = (x_unfused & 0b0111).to(torch.long)
339 values = torch.tensor(cls.E2M1_values, device=quantized_data.device)
340 original_shape = magnitude.shape
341 x_float = values[magnitude.reshape(-1)].reshape(original_shape)
342 x_float = sign.float() * x_float
343 x_float = x_float.reshape(-1, cls.block_size)
344 scale_factor = torch.exp2(e8m0_scale.float() - 127)
345 scale_factor = scale_factor.reshape(-1, 1)
346 x_float = x_float * scale_factor
347 return x_float.reshape(original_shape).to(dtype)
348
349
350 def _swiglu(x: torch.Tensor, alpha: float, limit: float) -> torch.Tensor:
351 """SwigLU activation used in GPT-OSS.
352 Input is interleaved: [gate_0, up_0, gate_1, up_1, ...] in last dim.
353 """
354 gate = x[..., 0::2]
355 up = x[..., 1::2]
356 gate_clamped = torch.clamp(gate, max=limit)
357 up_clamped = torch.clamp(up, min=-limit, max=limit)
358 return gate_clamped * torch.sigmoid(alpha * gate_clamped) * (up_clamped + 1)
359
360
361 def ref_mxfp4_fused_moe(
362 a: torch.Tensor,
363 w1_dq: torch.Tensor,
364 w2_dq: torch.Tensor,
365 topk_weight: torch.Tensor,
366 topk_ids: torch.Tensor,
367 topk: int,
368 ) -> torch.Tensor:
369 """Reference MXFP4 fused MoE with SiLU activation."""
370 B, D = a.shape
371 a_f = a.float()
372 out = torch.zeros(B * topk, w2_dq.shape[1], dtype=torch.float32)
373 topk_ids_flat = topk_ids.view(-1)
374
375 for i in range(w1_dq.shape[0]):
376 mask = topk_ids_flat == i
377 if mask.sum() == 0:
378 continue
379 token_indices = torch.where(mask)[0]
380 source_indices = token_indices // topk
381 ic0 = torch.matmul(a_f[source_indices], w1_dq[i].float().T)
382 ic1 = _silu_and_mul(ic0)
383 out[mask] = torch.matmul(ic1, w2_dq[i].float().T)
384
385 return (out.view(B, topk, -1) * topk_weight.unsqueeze(-1)).sum(dim=1).to(a.dtype)
386
387
388 def ref_mxfp4_fused_moe_gptoss(
389 a: torch.Tensor,
390 w1_dq: torch.Tensor,
391 w2_dq: torch.Tensor,
392 w1_bias: torch.Tensor,
393 w2_bias: torch.Tensor,
394 topk_weight: torch.Tensor,
395 topk_ids: torch.Tensor,
396 alpha: float,
397 limit: float,
398 ) -> torch.Tensor:
399 """Reference MXFP4 fused MoE with SwigLU+bias (GPT-OSS style)."""
400 B, D = a.shape
401 topk = topk_ids.shape[1]
402 a_f = a.float()
403 E = w1_dq.shape[0]
404 out = torch.zeros(B * topk, w2_dq.shape[1], dtype=torch.float32)
405 topk_ids_flat = topk_ids.view(-1)
406
407 for i in range(E):
408 mask = topk_ids_flat == i
409 if mask.sum() == 0:
410 continue
411 token_indices = torch.where(mask)[0]
412 source_indices = token_indices // topk
413 ic0 = torch.matmul(a_f[source_indices], w1_dq[i].float().T)
414 ic0 = ic0 + w1_bias[i].float()
415 ic1 = _swiglu(ic0, alpha, limit)
416 ic2 = torch.matmul(ic1, w2_dq[i].float().T)
417 ic2 = ic2 + w2_bias[i].float()
418 out[mask] = ic2
419
420 return (out.view(B, topk, -1) * topk_weight.unsqueeze(-1)).sum(dim=1).to(a.dtype)
421
422
423 def _prepack_mxfp4_experts(
424 w: torch.Tensor, w_scale: torch.Tensor
425 ) -> tuple[torch.Tensor, torch.Tensor]:
426 """VNNI-prepack MXFP4 weights and repack scales."""
427 packed_w = torch.ops._C.convert_weight_packed(w)
428 packed_s = torch.ops._C.convert_scale_packed(w_scale)
429 return packed_w, packed_s
430
431
432 MXFP4_NUM_TOKENS = [1, 2, 32, 121]
433 MXFP4_MOE_CONFIGS = [
434 (128, 128, 4, 2),
435 (256, 256, 8, 4),
436 (352, 256, 8, 4),
437 (512, 320, 8, 4),
438 ]
439
440
441 @pytest.mark.parametrize("M", MXFP4_NUM_TOKENS)
442 @pytest.mark.parametrize("N,K,E,topk", MXFP4_MOE_CONFIGS)
443 @pytest.mark.parametrize("seed", [0])
444 def test_mxfp4_cpu_fused_moe(M, N, K, E, topk, seed):
445 """Test fused_experts_mxfp4_cpu against dequantized torch reference."""
446 set_random_seed(seed)
447 dtype = torch.bfloat16
448
449 a = torch.randn(M, K, dtype=dtype) / 10
450
451 # Generate and quantize weights
452 w1_bf16 = torch.randn(E, 2 * N, K, dtype=dtype) / 10
453 w1q, w1s = MXFP4QuantizeUtil.quantize(w1_bf16)
454 w1s = w1s.reshape(E, 2 * N, K // 32)
455 w1dq = MXFP4QuantizeUtil.dequantize(w1q, dtype, w1s)
456
457 w2_bf16 = torch.randn(E, K, N, dtype=dtype) / 10
458 w2q, w2s = MXFP4QuantizeUtil.quantize(w2_bf16)
459 w2s = w2s.reshape(E, K, N // 32)
460 w2dq = MXFP4QuantizeUtil.dequantize(w2q, dtype, w2s)
461
462 # Routing
463 score = torch.randn(M, E, dtype=dtype)
464 score = torch.softmax(score, dim=-1, dtype=torch.float32)
465 topk_weight, topk_ids = torch.topk(score, topk)
466 topk_ids = topk_ids.to(torch.int32)
467
468 # Reference
469 ref_out = ref_mxfp4_fused_moe(a, w1dq, w2dq, topk_weight, topk_ids, topk)
470
471 # Pack weights for kernel
472 pw1, pw1s = _prepack_mxfp4_experts(w1q, w1s)
473 pw2, pw2s = _prepack_mxfp4_experts(w2q, w2s)
474
475 # Kernel
476 a_in = a.clone()
477 out = torch.empty_like(a_in)
478 ops.fused_experts_cpu(
479 out,
480 a_in,
481 pw1,
482 pw2,
483 topk_weight,
484 topk_ids,
485 ops.CPUQuantMethod.MXFP4,
486 pw1s, # w1_scale
487 pw2s, # w2_scale
488 None, # w1_zero
489 None, # w2_zero
490 None, # block_size
491 )
492
493 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
494
495
496 def test_mxfp4_cpu_fused_moe_small_expert_blocks():
497 """Test MXFP4 BRGEMM at exact and multi-block expert boundaries."""
498 set_random_seed(0)
499 block_sizes = (4, 5, 33)
500 N, K, E = 128, 128, len(block_sizes)
501 M = sum(block_sizes)
502 dtype = torch.bfloat16
503
504 a = torch.randn(M, K, dtype=dtype) / 10
505 w1_bf16 = torch.randn(E, 2 * N, K, dtype=dtype) / 10
506 w1q, w1s = MXFP4QuantizeUtil.quantize(w1_bf16)
507 w1s = w1s.reshape(E, 2 * N, K // 32)
508 w1dq = MXFP4QuantizeUtil.dequantize(w1q, dtype, w1s)
509
510 w2_bf16 = torch.randn(E, K, N, dtype=dtype) / 10
511 w2q, w2s = MXFP4QuantizeUtil.quantize(w2_bf16)
512 w2s = w2s.reshape(E, K, N // 32)
513 w2dq = MXFP4QuantizeUtil.dequantize(w2q, dtype, w2s)
514
515 topk_weight, topk_ids = _deterministic_expert_routes(block_sizes)
516 ref_out = ref_mxfp4_fused_moe(a, w1dq, w2dq, topk_weight, topk_ids, 1)
517
518 pw1, pw1s = _prepack_mxfp4_experts(w1q, w1s)
519 pw2, pw2s = _prepack_mxfp4_experts(w2q, w2s)
520 out = torch.empty_like(a)
521 ops.fused_experts_cpu(
522 out,
523 a,
524 pw1,
525 pw2,
526 topk_weight,
527 topk_ids,
528 ops.CPUQuantMethod.MXFP4,
529 pw1s,
530 pw2s,
531 None,
532 None,
533 None,
534 )
535
536 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
537
538
539 # Both E2M1 zero codes: 0b0000 (+0.0) and 0b1000 (-0.0), in both nibbles.
540 MXFP4_ZERO_BYTES = [0x00, 0x88, 0x08, 0x80]
541 # 0 and 255 are the ends of the E8M0 range; 127 is the identity scale.
542 MXFP4_E8M0_VALUES = [0, 1, 127, 200, 254, 255]
543
544
545 @pytest.mark.parametrize("zero_byte", MXFP4_ZERO_BYTES)
546 @pytest.mark.parametrize("e8m0", MXFP4_E8M0_VALUES)
547 def test_mxfp4_cpu_zero_codes_stay_zero(zero_byte, e8m0):
548 """A zero E2M1 code stays zero for every E8M0 exponent.
549
550 The unpack applies the block scale as an integer add on the bf16 exponent
551 field, which is exact for every value in the E2M1 codebook except the two
552 zeros: 0x0000 and 0x8000 have no exponent to shift, so adding to them
553 produces a small finite number instead of zero. Both are special-cased, and
554 this is the invariant that special case exists for.
555
556 Worth pinning separately from ``test_mxfp4_cpu_fused_moe``: there the zero
557 codes are a small fraction of random weights and a broken special case
558 would stay inside the 1e-2 tolerance for the low exponents. Here every
559 weight is a zero, so the output is exactly zero or it is not.
560 """
561 N, K, E, M = 64, 64, 2, 4
562 dtype = torch.bfloat16
563 set_random_seed(0)
564
565 a = torch.randn(M, K, dtype=dtype)
566 w1q = torch.full((E, 2 * N, K // 2), zero_byte, dtype=torch.uint8)
567 w1s = torch.full((E, 2 * N, K // 32), e8m0, dtype=torch.uint8)
568 # w2 is ordinary: the zeros have to survive the first GEMM and the
569 # activation, and a nonzero w2 is what would expose it if they did not.
570 w2_bf16 = torch.randn(E, K, N, dtype=dtype) / 10
571 w2q, w2s = MXFP4QuantizeUtil.quantize(w2_bf16)
572 w2s = w2s.reshape(E, K, N // 32)
573
574 topk_weight = torch.ones((M, 1), dtype=torch.float32)
575 topk_ids = torch.zeros((M, 1), dtype=torch.int32)
576
577 pw1, pw1s = _prepack_mxfp4_experts(w1q, w1s)
578 pw2, pw2s = _prepack_mxfp4_experts(w2q, w2s)
579 out = torch.empty_like(a)
580 ops.fused_experts_cpu(
581 out,
582 a,
583 pw1,
584 pw2,
585 topk_weight,
586 topk_ids,
587 ops.CPUQuantMethod.MXFP4,
588 pw1s,
589 pw2s,
590 None,
591 None,
592 None,
593 )
594
595 # silu(0) * 0 = 0, so the whole layer collapses to exactly zero. Not
596 # assert_close: any nonzero output here is a wrong unpack, not rounding.
597 assert torch.equal(out, torch.zeros_like(out)), (
598 f"zero code 0x{zero_byte:02x} with e8m0={e8m0} produced "
599 f"max |out| = {out.abs().max().item()}"
600 )
601
602
603 # Narrower than MXFP4_E8M0_VALUES on purpose: this test keeps nonzero weights,
604 # and 6.0 * 2**(255-127) is not representable in bf16, so the ends of the E8M0
605 # range would compare inf against inf and prove nothing. The all-zero test above
606 # is the one that can reach them.
607 MXFP4_E8M0_FINITE = [107, 127, 137]
608
609
610 @pytest.mark.parametrize("e8m0", MXFP4_E8M0_FINITE)
611 def test_mxfp4_cpu_zero_codes_mixed_with_nonzero(e8m0):
612 """Zeros and nonzeros in the same 32-element scale block.
613
614 The zero check is per lane, not per block: this fails if it is ever
615 rewritten as a whole-block branch. Uses a single shared exponent so the
616 reference is a plain power of two.
617 """
618 N, K, E, M = 64, 64, 2, 4
619 dtype = torch.bfloat16
620 set_random_seed(0)
621
622 a = torch.randn(M, K, dtype=dtype)
623 # Alternate a zero byte and a nonzero one along K, so every scale block
624 # holds both kinds.
625 pattern = torch.tensor([0x88, 0x21], dtype=torch.uint8).repeat(K // 4)
626 w1q = pattern.view(1, 1, -1).expand(E, 2 * N, K // 2).contiguous()
627 w1s = torch.full((E, 2 * N, K // 32), e8m0, dtype=torch.uint8)
628 w1dq = MXFP4QuantizeUtil.dequantize(w1q, dtype, w1s)
629
630 w2_bf16 = torch.randn(E, K, N, dtype=dtype) / 10
631 w2q, w2s = MXFP4QuantizeUtil.quantize(w2_bf16)
632 w2s = w2s.reshape(E, K, N // 32)
633 w2dq = MXFP4QuantizeUtil.dequantize(w2q, dtype, w2s)
634
635 topk_weight = torch.ones((M, 1), dtype=torch.float32)
636 topk_ids = torch.zeros((M, 1), dtype=torch.int32)
637 ref_out = ref_mxfp4_fused_moe(a, w1dq, w2dq, topk_weight, topk_ids, 1)
638
639 pw1, pw1s = _prepack_mxfp4_experts(w1q, w1s)
640 pw2, pw2s = _prepack_mxfp4_experts(w2q, w2s)
641 out = torch.empty_like(a)
642 ops.fused_experts_cpu(
643 out,
644 a,
645 pw1,
646 pw2,
647 topk_weight,
648 topk_ids,
649 ops.CPUQuantMethod.MXFP4,
650 pw1s,
651 pw2s,
652 None,
653 None,
654 None,
655 )
656
657 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
658
659
660 @pytest.mark.parametrize("M", [1, 32])
661 @pytest.mark.parametrize("N,K,E,topk", [(128, 128, 4, 2), (64, 64, 4, 2)])
662 @pytest.mark.parametrize("seed", [0])
663 def test_mxfp4_cpu_fused_moe_bias_swiglu(M, N, K, E, topk, seed):
664 """Test fused_experts_mxfp4_cpu with bias and SwigLU activation (GPT-OSS)."""
665 set_random_seed(seed)
666 dtype = torch.bfloat16
667 alpha = 1.702
668 limit = 7.0
669
670 a = torch.randn(M, K, dtype=dtype) / 10
671
672 # Generate and quantize weights
673 w1_bf16 = torch.randn(E, 2 * N, K, dtype=dtype) / 10
674 w1q, w1s = MXFP4QuantizeUtil.quantize(w1_bf16)
675 w1s = w1s.reshape(E, 2 * N, K // 32)
676 w1dq = MXFP4QuantizeUtil.dequantize(w1q, dtype, w1s)
677 w1_b = torch.randn(E, 2 * N, dtype=torch.float32) / 10
678
679 w2_bf16 = torch.randn(E, K, N, dtype=dtype) / 10
680 w2q, w2s = MXFP4QuantizeUtil.quantize(w2_bf16)
681 w2s = w2s.reshape(E, K, N // 32)
682 w2dq = MXFP4QuantizeUtil.dequantize(w2q, dtype, w2s)
683 w2_b = torch.randn(E, K, dtype=torch.float32) / 10
684
685 # Routing
686 score = torch.randn(M, E, dtype=dtype)
687 score = torch.softmax(score, dim=-1, dtype=torch.float32)
688 topk_weight, topk_ids = torch.topk(score, topk)
689 topk_ids = topk_ids.to(torch.int32)
690
691 # Reference
692 ref_out = ref_mxfp4_fused_moe_gptoss(
693 a, w1dq, w2dq, w1_b, w2_b, topk_weight, topk_ids, alpha, limit
694 )
695
696 # Pack weights for kernel
697 pw1, pw1s = _prepack_mxfp4_experts(w1q, w1s)
698 pw2, pw2s = _prepack_mxfp4_experts(w2q, w2s)
699
700 # Kernel
701 a_in = a.clone()
702 out = torch.empty_like(a_in)
703 ops.fused_experts_cpu(
704 out,
705 a_in,
706 pw1,
707 pw2,
708 topk_weight,
709 topk_ids,
710 ops.CPUQuantMethod.MXFP4,
711 pw1s, # w1_scale
712 pw2s, # w2_scale
713 None, # w1_zero
714 None, # w2_zero
715 None, # block_size
716 w1_bias=w1_b,
717 w2_bias=w2_b,
718 alpha=alpha,
719 limit=limit,
720 )
721
722 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
723
724
725 # ===========================================================================
726 # INT4 W4A16 MoE
727 # ===========================================================================
728
729
730 def _pack_int4_gptq(w_int4: torch.Tensor) -> torch.Tensor:
731 """Pack INT4 values [N, K] → [N, K//8] int32 along K dim (GPTQ format)."""
732 N, K = w_int4.shape
733 assert K % 8 == 0
734 w = w_int4.to(torch.int32)
735 w_packed = torch.zeros(N, K // 8, dtype=torch.int32)
736 for j in range(8):
737 w_packed |= (w[:, j::8] & 0xF) << (j * 4)
738 return w_packed
739
740
741 def _pack_int4_awq(w_int4: torch.Tensor) -> torch.Tensor:
742 """Pack INT4 values [..., N] → [..., N//8] int32 along last dim (AWQ format)."""
743 # AWQ packing bitshifts: indices {0,4,1,5,2,6,3,7} * 4 bits each
744 _AWQ_BITSHIFTS = [0, 16, 4, 20, 8, 24, 12, 28]
745
746 N = w_int4.shape[-1]
747 assert N % 8 == 0
748 w = w_int4.to(torch.int32)
749 w_packed = torch.zeros(*w.shape[:-1], N // 8, dtype=torch.int32)
750 for j, shift in enumerate(_AWQ_BITSHIFTS):
751 w_packed |= (w[..., j::8] & 0xF) << shift
752 return w_packed
753
754
755 def _ref_int4_moe(
756 a: torch.Tensor,
757 w1_int4: torch.Tensor,
758 w2_int4: torch.Tensor,
759 w1_zeros: torch.Tensor | None,
760 w2_zeros: torch.Tensor | None,
761 w1_s: torch.Tensor,
762 w2_s: torch.Tensor,
763 topk_weight: torch.Tensor,
764 topk_ids: torch.Tensor,
765 group_size: int,
766 ) -> torch.Tensor:
767 """Reference INT4 W4A16 group-quantized fused MoE in pure torch."""
768 B = a.shape[0]
769 topk = topk_ids.size(1)
770 K_out = a.shape[1]
771
772 out = torch.zeros(B, topk, K_out, dtype=torch.float32)
773 for b in range(B):
774 for t in range(topk):
775 eid = topk_ids[b, t].item()
776 x = a[b : b + 1].float()
777
778 # Dequantize w1: [K, 2*N], groups along K (input dim)
779 K_dim = w1_int4.shape[1]
780 w1_dq = torch.zeros(K_dim, w1_int4.shape[2], dtype=torch.float32)
781 for g in range(w1_s.shape[1]):
782 k_start = g * group_size
783 k_end = min((g + 1) * group_size, K_dim)
784 zp = w1_zeros[eid, g, :].float() if w1_zeros is not None else 8.0
785 w1_dq[k_start:k_end, :] = (
786 w1_int4[eid, k_start:k_end, :].float() - zp
787 ) * w1_s[eid, g, :].float()
788
789 ic = torch.matmul(x, w1_dq) # [1, K] @ [K, 2*N] → [1, 2*N]
790 ic = _silu_and_mul(ic) # [1, N]
791
792 # Dequantize w2: [N, K], groups along N (input dim)
793 N_dim = w2_int4.shape[1]
794 w2_dq = torch.zeros(N_dim, w2_int4.shape[2], dtype=torch.float32)
795 for g in range(w2_s.shape[1]):
796 n_start = g * group_size
797 n_end = min((g + 1) * group_size, N_dim)
798 zp = w2_zeros[eid, g, :].float() if w2_zeros is not None else 8.0
799 w2_dq[n_start:n_end, :] = (
800 w2_int4[eid, n_start:n_end, :].float() - zp
801 ) * w2_s[eid, g, :].float()
802
803 oc = torch.matmul(ic, w2_dq) # [1, N] @ [N, K] → [1, K]
804 out[b, t] = oc.squeeze(0)
805
806 return (out * topk_weight.unsqueeze(-1)).sum(dim=1).to(a.dtype)
807
808
809 def _make_int4_moe_weights(E, N, K, group_size, quant_algo):
810 """Create INT4 MoE weights in GPTQ or AWQ packed format.
811
812 Canonical layout (input × output):
813 w1_int4: [E, K, 2*N] w2_int4: [E, N, K]
814
815 GPTQ packed (pack transposed weight along input/K dim):
816 w1_packed: [E, K//8, 2*N] w2_packed: [E, N//8, K]
817 zeros: actual int4 zero points, same packing as weights
818
819 AWQ packed (pack along output/N dim):
820 w1_packed: [E, K, 2*N//8] w2_packed: [E, N, K//8]
821 zeros: actual int4 zero points, same packing as weights
822
823 Returns:
824 w1_int4, w2_int4,
825 w1_packed, w2_packed,
826 w1_zeros, w2_zeros,
827 w1_zeros_packed, w2_zeros_packed,
828 w1_s, w2_s
829
830 """
831 w1_int4 = torch.randint(0, 16, (E, K, 2 * N), dtype=torch.int32)
832 w2_int4 = torch.randint(0, 16, (E, N, K), dtype=torch.int32)
833
834 num_groups_w1 = K // group_size
835 num_groups_w2 = N // group_size
836 w1_s = (
837 torch.randn(E, num_groups_w1, 2 * N, dtype=torch.bfloat16) * 0.01
838 ).abs() + 0.001
839 w2_s = (torch.randn(E, num_groups_w2, K, dtype=torch.bfloat16) * 0.01).abs() + 0.001
840
841 if quant_algo == ops.CPUQuantAlgo.GPTQ:
842 # Pack: canonical [E, K, 2*N] → transpose [E, 2*N, K] → GPTQ pack
843 # [E, 2*N, K//8] → transpose [E, K//8, 2*N]
844 w1_t = w1_int4.transpose(1, 2).contiguous() # [E, 2*N, K]
845 w1_packed = (
846 torch.stack([_pack_int4_gptq(w1_t[e]) for e in range(E)])
847 .transpose(1, 2)
848 .contiguous()
849 ) # [E, K//8, 2*N]
850 w2_t = w2_int4.transpose(1, 2).contiguous() # [E, K, N]
851 w2_packed = (
852 torch.stack([_pack_int4_gptq(w2_t[e]) for e in range(E)])
853 .transpose(1, 2)
854 .contiguous()
855 ) # [E, N//8, K]
856 w1_zeros = w2_zeros = None
857 w1_zeros_packed = torch.full(
858 (E, num_groups_w1, 2 * N // 8), 0x77777777, dtype=torch.int32
859 )
860 w2_zeros_packed = torch.full(
861 (E, num_groups_w2, K // 8), 0x77777777, dtype=torch.int32
862 )
863 else: # AWQ
864 # Asymmetric: actual zero points, packed along output dim.
865 w1_zeros = torch.randint(1, 15, (E, num_groups_w1, 2 * N), dtype=torch.int32)
866 w2_zeros = torch.randint(1, 15, (E, num_groups_w2, K), dtype=torch.int32)
867 w1_packed = torch.stack(
868 [_pack_int4_awq(w1_int4[e]) for e in range(E)]
869 ) # [E, K, 2*N//8]
870 w2_packed = torch.stack(
871 [_pack_int4_awq(w2_int4[e]) for e in range(E)]
872 ) # [E, N, K//8]
873 w1_zeros_packed = torch.stack(
874 [_pack_int4_awq(w1_zeros[e]) for e in range(E)]
875 ) # [E, K//gs, 2*N//8]
876 w2_zeros_packed = torch.stack(
877 [_pack_int4_awq(w2_zeros[e]) for e in range(E)]
878 ) # [E, N//gs, K//8]
879
880 return (
881 w1_int4,
882 w2_int4,
883 w1_packed,
884 w2_packed,
885 w1_zeros,
886 w2_zeros,
887 w1_zeros_packed,
888 w2_zeros_packed,
889 w1_s,
890 w2_s,
891 )
892
893
894 INT4_MOE_CONFIGS = [
895 # (N, K, E, topk, group_size)
896 (256, 512, 8, 2, 128),
897 (512, 256, 8, 2, 128),
898 (512, 512, 8, 4, 128),
899 (768, 2048, 8, 2, 128),
900 ]
901
902
903 @pytest.mark.parametrize("M", [1, 2, 64, 121])
904 @pytest.mark.parametrize("N,K,E,topk,group_size", INT4_MOE_CONFIGS)
905 @pytest.mark.parametrize("quant_algo", [ops.CPUQuantAlgo.GPTQ, ops.CPUQuantAlgo.AWQ])
906 @pytest.mark.parametrize("seed", [0])
907 def test_int4_w4a16_cpu_fused_moe(M, N, K, E, topk, group_size, quant_algo, seed):
908 """Test fused_experts_cpu INT4 W4A16 for both GPTQ and AWQ quant formats."""
909 set_random_seed(seed)
910
911 a = torch.randn(M, K, dtype=torch.bfloat16) / (0.5 * K**0.5)
912 (
913 w1_int4,
914 w2_int4,
915 w1_packed,
916 w2_packed,
917 w1_zeros,
918 w2_zeros,
919 w1_zeros_packed,
920 w2_zeros_packed,
921 w1_s,
922 w2_s,
923 ) = _make_int4_moe_weights(E, N, K, group_size, quant_algo)
924
925 score = torch.randn(M, E, dtype=torch.bfloat16)
926 score = torch.softmax(score, dim=-1, dtype=torch.float32)
927 topk_weight, topk_ids = torch.topk(score, topk)
928 topk_ids = topk_ids.to(torch.int32)
929
930 ref_out = _ref_int4_moe(
931 a,
932 w1_int4,
933 w2_int4,
934 w1_zeros,
935 w2_zeros,
936 w1_s,
937 w2_s,
938 topk_weight,
939 topk_ids,
940 group_size,
941 )
942
943 from vllm.model_executor.layers.fused_moe.experts.cpu_moe import (
944 prepare_int4_moe_layer_for_cpu,
945 )
946
947 (blocked_w1, blocked_w2, blocked_s1, blocked_s2, blocked_z1, blocked_z2) = (
948 prepare_int4_moe_layer_for_cpu(
949 w1_packed,
950 w2_packed,
951 w1_s,
952 w2_s,
953 quant_algo=quant_algo,
954 w13_zeros=w1_zeros_packed,
955 w2_zeros=w2_zeros_packed,
956 )
957 )
958
959 a_in = a.clone()
960 out = torch.empty_like(a_in)
961 ops.fused_experts_cpu(
962 out,
963 a_in,
964 blocked_w1,
965 blocked_w2,
966 topk_weight,
967 topk_ids,
968 ops.CPUQuantMethod.INT4_W4A8,
969 blocked_s1,
970 blocked_s2,
971 blocked_z1,
972 blocked_z2,
973 None, # block_size
974 None, # w1_bias
975 None, # w2_bias
976 None, # alpha
977 None, # limit
978 True, # is_vnni
979 )
980 torch.testing.assert_close(ref_out.bfloat16(), out, atol=1e-2, rtol=1e-2)
981
982
983 # ===========================================================================
984 # INT8 W8A8 MoE
985 # ===========================================================================
986
987
988 def _quantize_per_channel(w):
989 """Symmetric per-channel INT8 quantisation. w: [N, K] -> (int8, scale)."""
990 amax = w.abs().amax(dim=-1, keepdim=True).clamp(min=1e-12)
991 scale = amax / 127.0
992 w_q = (w / scale).round().clamp(-128, 127).to(torch.int8)
993 return w_q, scale.float()
994
995
996 def _quantize_per_token(x):
997 """Symmetric per-token INT8 quantisation. x: [M, K] -> (int8, scale)."""
998 amax = x.abs().amax(dim=-1, keepdim=True).clamp(min=1e-12)
999 scale = amax / 127.0
1000 x_q = (x / scale).round().clamp(-128, 127).to(torch.int8)
1001 return x_q, scale.float()
1002
1003
1004 def _ref_int8_moe(a, w1, w2, w1_s, w2_s, topk_weight, topk_ids):
1005 """Reference INT8 W8A8 per-channel fused MoE in pure torch."""
1006 B, D = a.shape
1007 topk = topk_ids.size(1)
1008
1009 out = torch.zeros(B, topk, w2.shape[1], dtype=torch.float32)
1010 for b in range(B):
1011 for t in range(topk):
1012 eid = topk_ids[b, t].item()
1013
1014 x = a[b : b + 1].float()
1015 x_q, x_s = _quantize_per_token(x)
1016 ic = torch.matmul(x_q.float(), w1[eid].float().t())
1017 ic = ic * x_s * w1_s[eid].view(1, -1)
1018 ic = _silu_and_mul(ic)
1019
1020 ic_q, ic_s = _quantize_per_token(ic)
1021 oc = torch.matmul(ic_q.float(), w2[eid].float().t())
1022 oc = oc * ic_s * w2_s[eid].view(1, -1)
1023 out[b, t] = oc.squeeze(0)
1024
1025 result = (out * topk_weight.unsqueeze(-1)).sum(dim=1)
1026 return result.to(a.dtype)
1027
1028
1029 def _make_int8_moe_weights(E, N, K):
1030 factor = 1e-2
1031 w1_f = (torch.randn(E, 2 * N, K) - 0.5) * 2
1032 w2_f = (torch.randn(E, K, N) - 0.5) * 2
1033
1034 w1_q_list, w1_s_list = [], []
1035 w2_q_list, w2_s_list = [], []
1036 for e in range(E):
1037 q, s = _quantize_per_channel(w1_f[e])
1038 w1_q_list.append(q)
1039 w1_s_list.append(s)
1040 q, s = _quantize_per_channel(w2_f[e])
1041 w2_q_list.append(q)
1042 w2_s_list.append(s)
1043
1044 return (
1045 torch.stack(w1_q_list),
1046 torch.stack(w2_q_list),
1047 torch.stack(w1_s_list) * factor,
1048 torch.stack(w2_s_list) * factor,
1049 )
1050
1051
1052 INT8_NUM_TOKENS = [1, 2, 64, 121]
1053 INT8_MOE_CONFIGS = [
1054 # (N, K, E, topk)
1055 (256, 512, 8, 2),
1056 (512, 256, 8, 2),
1057 (512, 512, 8, 4),
1058 (768, 2048, 8, 2),
1059 ]
1060
1061
1062 @pytest.mark.parametrize("M", INT8_NUM_TOKENS)
1063 @pytest.mark.parametrize("N,K,E,topk", INT8_MOE_CONFIGS)
1064 @pytest.mark.parametrize("seed", [0])
1065 @pytest.mark.parametrize("is_vnni", [False, True])
1066 @pytest.mark.parametrize("inplace", [False, True])
1067 def test_int8_w8a8_cpu_fused_moe(M, N, K, E, topk, seed, is_vnni, inplace):
1068 """Test fused_experts_cpu INT8 W8A8 against torch reference.
1069
1070 ``inplace`` exercises writing into a fresh output buffer vs. aliasing
1071 the input tensor as the output buffer.
1072 """
1073 set_random_seed(seed)
1074
1075 a = torch.randn(M, K, dtype=torch.bfloat16) / (0.5 * K**0.5)
1076 w1_q, w2_q, w1_s, w2_s = _make_int8_moe_weights(E, N, K)
1077
1078 score = torch.randn(M, E, dtype=torch.bfloat16)
1079 score = torch.softmax(score, dim=-1, dtype=torch.float32)
1080 topk_weight, topk_ids = torch.topk(score, topk)
1081 topk_ids = topk_ids.to(torch.int32)
1082
1083 ref_out = _ref_int8_moe(a, w1_q, w2_q, w1_s, w2_s, topk_weight, topk_ids)
1084
1085 w1 = _prepack_experts(w1_q) if is_vnni else w1_q
1086 w2 = _prepack_experts(w2_q) if is_vnni else w2_q
1087
1088 a_in = a.clone()
1089 out = a_in if inplace else torch.empty_like(a_in)
1090 ops.fused_experts_cpu(
1091 out,
1092 a_in,
1093 w1,
1094 w2,
1095 topk_weight,
1096 topk_ids,
1097 ops.CPUQuantMethod.INT8_W8A8,
1098 w1_s,
1099 w2_s,
1100 None, # w1_zero
1101 None, # w2_zero
1102 None, # block_size
1103 None, # w1_bias
1104 None, # w2_bias
1105 None, # alpha
1106 None, # limit
1107 is_vnni,
1108 )
1109 torch.testing.assert_close(
1110 ref_out.bfloat16(),
1111 out,
1112 atol=2e-1,
1113 rtol=2e-1,
1114 )
1115
1116
1117 # ===========================================================================
1118 # FP8 W8A8 MoE
1119 # ===========================================================================
1120
1121
1122 requires_cpu_fp8_w8a8 = pytest.mark.skipif(
1123 not ops.cpu_has_amx_fp8(),
1124 reason="requires native AMX-FP8 hardware",
1125 )
1126
1127 FP8_W8A8_MAX = torch.finfo(torch.float8_e4m3fn).max
1128 FP8_W8A8_QUANT_GROUP = 128 # quantization group size for both K and N dimensions
1129
1130
1131 def _make_fp8_w8a8_weight_w13(
1132 E: int, two_n: int, K: int, group_K: int = FP8_W8A8_QUANT_GROUP
1133 ) -> tuple[torch.Tensor, torch.Tensor]:
1134 """Create w13 [E, 2N, K] FP8 with group scales [E, 2N, G] where G = K//group_K."""
1135 G = K // group_K
1136 w_list, s_list = [], []
1137 for _ in range(E):
1138 w_f32 = torch.randn(two_n, K)
1139 # Per-row, per-K-group quantization: scale [2N, G]
1140 w_re = w_f32.view(two_n, G, group_K)
1141 abs_max = w_re.abs().amax(dim=2, keepdim=True).clamp(min=1e-7) # [2N, G, 1]
1142 scale = (abs_max / FP8_W8A8_MAX).squeeze(2) # [2N, G]
1143 w_q = (
1144 (w_re / abs_max).clamp(-FP8_W8A8_MAX, FP8_W8A8_MAX).to(torch.float8_e4m3fn)
1145 )
1146 w_list.append(w_q.view(two_n, K).contiguous())
1147 s_list.append(scale.float())
1148 return torch.stack(w_list), torch.stack(s_list) # [E, 2N, K], [E, 2N, G]
1149
1150
1151 def _make_fp8_w8a8_weight_w2(
1152 E: int,
1153 K: int,
1154 N: int,
1155 group_K: int = FP8_W8A8_QUANT_GROUP,
1156 group_N: int = FP8_W8A8_QUANT_GROUP,
1157 ) -> tuple[torch.Tensor, torch.Tensor]:
1158 """Create w2 [E, K, N] FP8 with block scales [E, K//gK, N//gN]."""
1159 nK = K // group_K
1160 nN = N // group_N
1161 w_list, s_list = [], []
1162 for _ in range(E):
1163 w_f32 = torch.randn(K, N)
1164 w_re = w_f32.view(nK, group_K, nN, group_N)
1165 abs_max = w_re.abs().amax(dim=(1, 3), keepdim=True).clamp(min=1e-7)
1166 scale = (abs_max / FP8_W8A8_MAX).squeeze(1).squeeze(2) # [nK, nN]
1167 w_q = (
1168 (w_re / abs_max).clamp(-FP8_W8A8_MAX, FP8_W8A8_MAX).to(torch.float8_e4m3fn)
1169 )
1170 w_list.append(w_q.view(K, N).contiguous())
1171 s_list.append(scale.float())
1172 return torch.stack(w_list), torch.stack(s_list) # [E, K, N], [E, K//gK, N//gN]
1173
1174
1175 def _pack_fp8_w8a8_w13(
1176 w13: torch.Tensor, # [E, 2N, K] FP8
1177 w13_scale: torch.Tensor, # [E, 2N, G] float32
1178 ) -> tuple[torch.Tensor, torch.Tensor]:
1179 """Pack w13 for CPU FP8 W8A8 MoE kernel using float8_linear_prepack_cpu."""
1180 E = w13.size(0)
1181 packed_list, scale_list = [], []
1182 for i in range(E):
1183 pw, ps = torch.ops._C.float8_linear_prepack_cpu(
1184 w13[i].contiguous(), w13_scale[i].contiguous()
1185 )
1186 packed_list.append(pw)
1187 scale_list.append(ps)
1188 return torch.stack(packed_list), torch.stack(scale_list)
1189
1190
1191 def _ref_fp8_w8a8_moe(
1192 hidden_states: torch.Tensor, # BF16 [M, K_hidden] (after FP8 round-trip dequant)
1193 w13: torch.Tensor, # FP8 [E, 2N, K_hidden]
1194 w13_scale: torch.Tensor, # float32 [E, 2N, G] (G = K_hidden//group_K)
1195 w2: torch.Tensor, # FP8 [E, K_hidden, N]
1196 w2_scale: torch.Tensor, # float32 [E, K_hidden//gK, N//gN]
1197 topk_weights: torch.Tensor, # float32 [M, top_k]
1198 topk_ids: torch.Tensor, # int64 [M, top_k]
1199 N: int,
1200 group_K: int = FP8_W8A8_QUANT_GROUP,
1201 group_N: int = FP8_W8A8_QUANT_GROUP,
1202 ) -> torch.Tensor:
1203 """Reference implementation: dequantize weights then compute MoE."""
1204 M, K_hidden = hidden_states.shape
1205 top_k = topk_ids.shape[1]
1206 output = torch.zeros(M, K_hidden, dtype=torch.bfloat16)
1207
1208 for tok in range(M):
1209 x = hidden_states[tok].float() # [K_hidden]
1210 for k_idx in range(top_k):
1211 expert_id = topk_ids[tok, k_idx].item()
1212 w = topk_weights[tok, k_idx].item()
1213
1214 # Stage 1: x @ w13.T — gate and up projections
1215 w13_e = w13[expert_id].float() # [2N, K_hidden]
1216 ws_e = w13_scale[expert_id].float() # [2N, G]
1217 G = ws_e.shape[1]
1218 w13_dq = torch.zeros_like(w13_e)
1219 for g in range(G):
1220 c0, c1 = g * group_K, (g + 1) * group_K
1221 w13_dq[:, c0:c1] = w13_e[:, c0:c1] * ws_e[:, g : g + 1]
1222 gate_up = (x.unsqueeze(0) @ w13_dq.T).squeeze(0) # [2N]
1223
1224 gate = gate_up[:N]
1225 up = gate_up[N:]
1226 act = F.silu(gate) * up # [N]
1227
1228 # Stage 2: act @ w2.T — down projection
1229 w2_e = w2[expert_id].float() # [K_hidden, N]
1230 ws2_e = w2_scale[expert_id].float() # [K_hidden//gK, N//gN]
1231 nK2 = K_hidden // group_K
1232 nN2 = N // group_N
1233 w2_dq = torch.zeros_like(w2_e)
1234 for gi in range(nK2):
1235 for gj in range(nN2):
1236 r0, r1 = gi * group_K, (gi + 1) * group_K
1237 c0, c1 = gj * group_N, (gj + 1) * group_N
1238 w2_dq[r0:r1, c0:c1] = w2_e[r0:r1, c0:c1] * ws2_e[gi, gj]
1239 out_e = (act.unsqueeze(0) @ w2_dq.T).squeeze(0) # [K_hidden]
1240
1241 output[tok] += (w * out_e).to(torch.bfloat16)
1242
1243 return output
1244
1245
1246 @requires_cpu_fp8_w8a8
1247 @pytest.mark.parametrize(
1248 "E,N,K,M,top_k",
1249 [
1250 # N and K must be multiples of FP8_W8A8_QUANT_GROUP=128
1251 (4, 128, 256, 1, 2), # single token (decode phase)
1252 (4, 128, 256, 8, 2),
1253 (8, 256, 512, 16, 2),
1254 (4, 256, 512, 4, 1),
1255 ],
1256 )
1257 def test_fp8_w8a8_cpu_fused_moe(E: int, N: int, K: int, M: int, top_k: int):
1258 """Test fused_experts_cpu FP8_W8A8 shape/dtype and accuracy vs a
1259 dequantized BF16 reference."""
1260 set_random_seed(42)
1261
1262 w13, w13_scale = _make_fp8_w8a8_weight_w13(E, 2 * N, K)
1263 w2, w2_scale = _make_fp8_w8a8_weight_w2(E, K, N)
1264
1265 packed_w13, packed_w13_scale = _pack_fp8_w8a8_w13(w13, w13_scale)
1266 packed_w2 = _prepack_experts(w2)
1267
1268 hidden_states = torch.randn(M, K, dtype=torch.bfloat16) * 0.1
1269 x_fp8, x_scales = torch.ops._C.quantize_fp8e4m3_vec(hidden_states, True, None)
1270 assert x_fp8.dtype == torch.float8_e4m3fn
1271 assert x_scales.shape == (M,)
1272
1273 gen = torch.Generator().manual_seed(0)
1274 topk_weights = torch.softmax(torch.randn(M, top_k, generator=gen), dim=-1).float()
1275 topk_ids = torch.zeros(M, top_k, dtype=torch.int32)
1276 for i in range(M):
1277 topk_ids[i] = torch.randperm(E, generator=torch.Generator().manual_seed(i))[
1278 :top_k
1279 ].int()
1280
1281 output = torch.empty(M, K, dtype=torch.bfloat16)
1282 ops.fused_experts_cpu(
1283 output,
1284 x_fp8,
1285 packed_w13,
1286 packed_w2,
1287 topk_weights,
1288 topk_ids,
1289 ops.CPUQuantMethod.FP8_W8A8,
1290 packed_w13_scale, # w1_scale: [E, Nc, G, BLOCK_N]
1291 w2_scale, # w2_scale: [E, K//128, N//128]
1292 None, # w1_zero
1293 None, # w2_zero
1294 [FP8_W8A8_QUANT_GROUP, FP8_W8A8_QUANT_GROUP], # block_size
1295 None, # w1_bias
1296 None, # w2_bias
1297 None, # alpha
1298 None, # limit
1299 True, # is_vnni (weights already packed)
1300 x_scales, # a1_scale: per-token FP8 activation scales
1301 )
1302
1303 assert output.shape == (M, K)
1304 assert output.dtype == torch.bfloat16
1305 assert not torch.isnan(output).any()
1306 assert not torch.isinf(output).any()
1307
1308 x_dequant = (x_fp8.float() * x_scales.unsqueeze(1)).bfloat16()
1309 ref = _ref_fp8_w8a8_moe(
1310 x_dequant, w13, w13_scale, w2, w2_scale, topk_weights, topk_ids.long(), N
1311 )
1312 torch.testing.assert_close(output.float(), ref.float(), atol=1e-2, rtol=1e-2)
1313
1314
1315 if __name__ == "__main__":
1316 sys.exit(pytest.main([__file__, "-v"]))