[ROCm][CI] Add More AITER quantization/MoE kernel tests (#49375)

Signed-off-by: Micah Williamson <micah.williamson@amd.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>

Micah Williamson committed Aug 5, 2026 at 16:27 UTC 4282fe52d5353084a5773e924a508e9346dc2718
5 files changed +2335 -2
.buildkite/test-amd.yaml
+20 -2
@@ -1613,8 +1613,12 @@ steps:
1613 source_file_dependencies:
1614 - csrc/quantization/cutlass_w8a8/moe/
1615 - csrc/moe/
1616 + - csrc/rocm/
1617 - tests/kernels/moe
1618 + - tests/kernels/utils.py
1619 - vllm/model_executor/layers/fused_moe/
1620 + - vllm/model_executor/layers/quantization/mxfp4.py
1621 + - vllm/model_executor/layers/quantization/utils/quant_utils.py
1622 - vllm/distributed/device_communicators/
1623 - vllm/envs.py
1624 - vllm/config
@@ -1634,12 +1638,17 @@ steps:
1638 working_dir: "/vllm-workspace/tests"
1639 source_file_dependencies:
1640 - csrc/quantization/
1641 + - csrc/rocm/
1642 - vllm/model_executor/layers/quantization
1643 - tests/kernels/quantization
1639 - - tests/kernels/quantization/test_rocm_skinny_gemms.py
1644 + - tests/kernels/quant_utils.py
1645 + - tests/kernels/utils.py
1646 - vllm/_aiter_ops.py
1647 + - vllm/_custom_ops.py
1648 + - vllm/envs.py
1649 - vllm/platforms/rocm.py
1650 - vllm/model_executor/kernels/
1651 + - vllm/v1/attention/backends/rocm_aiter_fa.py
1652 commands:
1653 - pytest -v -s kernels/quantization --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
1654
@@ -3406,8 +3415,12 @@ steps:
3415 source_file_dependencies:
3416 - csrc/quantization/cutlass_w8a8/moe/
3417 - csrc/moe/
3418 + - csrc/rocm/
3419 - tests/kernels/moe
3420 + - tests/kernels/utils.py
3421 - vllm/model_executor/layers/fused_moe/
3422 + - vllm/model_executor/layers/quantization/mxfp4.py
3423 + - vllm/model_executor/layers/quantization/utils/quant_utils.py
3424 - vllm/distributed/device_communicators/
3425 - vllm/envs.py
3426 - vllm/config
@@ -3426,12 +3439,17 @@ steps:
3439 working_dir: "/vllm-workspace/tests"
3440 source_file_dependencies:
3441 - csrc/quantization/
3442 + - csrc/rocm/
3443 - vllm/model_executor/layers/quantization
3444 - tests/kernels/quantization
3431 - - tests/kernels/quantization/test_rocm_skinny_gemms.py
3445 + - tests/kernels/quant_utils.py
3446 + - tests/kernels/utils.py
3447 - vllm/_aiter_ops.py
3448 + - vllm/_custom_ops.py
3449 + - vllm/envs.py
3450 - vllm/platforms/rocm.py
3451 - vllm/model_executor/kernels/
3452 + - vllm/v1/attention/backends/rocm_aiter_fa.py
3453 commands:
3454 - pytest -v -s kernels/quantization --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
3455
tests/kernels/moe/test_rocm_aiter_moe.py new
+1079
@@ -0,0 +1,1079 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Kernel tests for ROCm AITER fused MoE.
4 +
5 +This file owns the ROCm-specific fused-MoE custom-op path:
6 +- custom-op registration and fake-tensor support
7 +- enablement gating for ``VLLM_ROCM_USE_AITER_MOE`` and shared experts
8 +- BF16 accuracy for the fused MoE kernel on representative shapes
9 +- gfx950-only AITER MXFP4 W4A16 MoE support, accuracy, and determinism
10 +- MoE-facing FP8 group-quant activation quality
11 +- deterministic routing and representative gfx942 / gfx950 coverage
12 +
13 +Generic fused-MoE backend selection and non-ROCm kernel coverage live in the
14 +generic MoE test files under ``tests/kernels/moe``.
15 +
16 +Raw ROCm AITER helper-op coverage such as ``group_fp8_quant`` and
17 +``per_tensor_quant`` lives in ``tests/kernels/core/test_rocm_aiter_ops.py``.
18 +This file only keeps the MoE-shaped integration angle for those helpers.
19 +"""
20 +
21 +import importlib
22 +import math
23 +import warnings
24 +from typing import Any, NamedTuple
25 +
26 +import pytest
27 +import torch
28 +import torch.nn.functional as F
29 +
30 +from tests.kernels.utils import _assert_deterministic
31 +from vllm.platforms import current_platform
32 +from vllm.platforms.rocm import on_gfx942, on_gfx950
33 +from vllm.utils.torch_utils import set_random_seed
34 +
35 +pytestmark = pytest.mark.skipif(
36 + not current_platform.is_rocm(), reason="ROCm-specific tests"
37 +)
38 +
39 +
40 +# Helpers -----------------------------------------------------------------
41 +
42 +
43 +def _reload_envs():
44 + import vllm.envs as envs
45 +
46 + return importlib.reload(envs)
47 +
48 +
49 +@pytest.fixture(autouse=True)
50 +def _restore_rocm_env_state():
51 + """Restore global env + AITER flag state after every test."""
52 + yield
53 + _reload_envs()
54 + from vllm._aiter_ops import rocm_aiter_ops
55 +
56 + rocm_aiter_ops.refresh_env_variables()
57 +
58 +
59 +def _assert_aiter_supported() -> None:
60 + from vllm._aiter_ops import is_aiter_found_and_supported
61 +
62 + assert is_aiter_found_and_supported(), (
63 + "aiter is required on supported ROCm hardware for this test"
64 + )
65 +
66 +
67 +def _format_observed_rate(count: int, total: int) -> str:
68 + return f"{count / total:.4%} ({count}/{total})"
69 +
70 +
71 +def _format_allowed_rate(rate: float, total: int) -> str:
72 + allowed_count = int(rate * total)
73 + return f"{rate:.4%} (<= {allowed_count}/{total})"
74 +
75 +
76 +def _quantile(values: torch.Tensor, q: float) -> float:
77 + if values.numel() == 0:
78 + return 0.0
79 + return torch.quantile(values, q).item()
80 +
81 +
82 +def _assert_close_budget(
83 + actual: torch.Tensor,
84 + expected: torch.Tensor,
85 + *,
86 + label: str,
87 + atol: float,
88 + rtol: float = 0.0,
89 + pass_rate: float = 0.99999,
90 + max_violation_factor: float = 3.0,
91 +) -> None:
92 + actual_f = actual.detach().float().flatten()
93 + expected_f = expected.detach().float().flatten()
94 + abs_diff = (actual_f - expected_f).abs()
95 + allowed = atol + rtol * expected_f.abs()
96 +
97 + total = abs_diff.numel()
98 + within = abs_diff <= allowed
99 + passed = int(within.sum().item())
100 + failed = total - passed
101 + allowed_fail_rate = 1.0 - pass_rate
102 +
103 + max_abs = abs_diff.max().item()
104 + mean_abs = abs_diff.mean().item()
105 + p99_abs = _quantile(abs_diff, 0.99)
106 + p999_abs = _quantile(abs_diff, 0.999)
107 + worst_ratio = (abs_diff / allowed.clamp_min(1e-12)).max().item()
108 + max_atol = max_violation_factor * atol
109 + above_max_count = int((abs_diff > max_atol).sum().item())
110 +
111 + msg = (
112 + "[rocm_aiter_moe] "
113 + f"{label}: "
114 + f"pass={passed / total:.4%} ({passed}/{total}) "
115 + f"fail={_format_observed_rate(failed, total)} "
116 + f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
117 + f"atol={atol:g} "
118 + f"rtol={rtol:g} "
119 + f"abs>{max_atol:g}={_format_observed_rate(above_max_count, total)} "
120 + f"allowed_above_max={_format_allowed_rate(0.0, total)} "
121 + f"max_abs={max_abs:.6g} "
122 + f"mean_abs={mean_abs:.6g} "
123 + f"p99_abs={p99_abs:.6g} "
124 + f"p999_abs={p999_abs:.6g} "
125 + f"worst_ratio={worst_ratio:.6g}"
126 + )
127 + print(msg)
128 + if failed > 0:
129 + warnings.warn(msg, stacklevel=2)
130 +
131 + assert passed / total >= pass_rate, msg
132 + assert max_abs <= max_atol, msg
133 + assert mean_abs <= atol * 0.25, msg
134 +
135 +
136 +def _assert_group_quant_quality(
137 + actual: torch.Tensor,
138 + expected: torch.Tensor,
139 + *,
140 + label: str,
141 + preferred_rel: float = 0.06,
142 + pass_rate: float = 0.9999,
143 + max_rel: float = 0.55,
144 + max_fail_rate: float = 0.000005,
145 + mean_limit: float = 0.03,
146 +) -> None:
147 + rel = (
148 + (actual.float() - expected.float()).abs()
149 + / expected.float().abs().clamp_min(1e-5)
150 + ).flatten()
151 + total = rel.numel()
152 + within_preferred_count = int((rel <= preferred_rel).sum().item())
153 + fail_count = total - within_preferred_count
154 + allowed_fail_rate = 1.0 - pass_rate
155 + above_max_count = int((rel > max_rel).sum().item())
156 + mean_rel = rel.mean().item()
157 + max_rel_err = rel.max().item()
158 + p99 = _quantile(rel, 0.99)
159 + p999 = _quantile(rel, 0.999)
160 +
161 + msg = (
162 + "[rocm_aiter_moe] "
163 + f"{label}: "
164 + f"rel<={preferred_rel:g} pass={within_preferred_count / total:.4%} "
165 + f"({within_preferred_count}/{total}) "
166 + f"fail={_format_observed_rate(fail_count, total)} "
167 + f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
168 + f"rel>{max_rel:g}={_format_observed_rate(above_max_count, total)} "
169 + f"allowed_above_max={_format_allowed_rate(max_fail_rate, total)} "
170 + f"mean_rel={mean_rel:.6g} "
171 + f"max_rel={max_rel_err:.6g} "
172 + f"p99={p99:.6g} "
173 + f"p999={p999:.6g}"
174 + )
175 + print(msg)
176 + if fail_count > 0:
177 + warnings.warn(msg, stacklevel=2)
178 +
179 + assert within_preferred_count / total >= pass_rate, msg
180 + assert above_max_count / total <= max_fail_rate, msg
181 + assert mean_rel < mean_limit, msg
182 +
183 +
184 +def ref_moe_forward(
185 + hidden_states: torch.Tensor,
186 + w1: torch.Tensor,
187 + w2: torch.Tensor,
188 + topk_weights: torch.Tensor,
189 + topk_ids: torch.Tensor,
190 + activation: str = "silu",
191 +) -> torch.Tensor:
192 + """Float32 mask-based MoE reference for the ROCm fused-MoE kernel."""
193 + num_tokens, hidden_dim = hidden_states.shape
194 + num_experts = w1.shape[0]
195 + intermediate_dim = w1.shape[1] // 2
196 + topk = topk_ids.shape[1]
197 + device = hidden_states.device
198 +
199 + hidden_states_f = hidden_states.float()
200 + w1_f = w1.float()
201 + w2_f = w2.float()
202 +
203 + expanded = hidden_states_f.view(num_tokens, 1, hidden_dim).expand(
204 + num_tokens, topk, hidden_dim
205 + )
206 + expanded = expanded.reshape(num_tokens * topk, hidden_dim)
207 + output = torch.zeros(
208 + num_tokens * topk, hidden_dim, dtype=torch.float32, device=device
209 + )
210 + flat_topk_ids = topk_ids.view(-1).long()
211 +
212 + for expert_idx in range(num_experts):
213 + expert_mask = flat_topk_ids == expert_idx
214 + if expert_mask.sum() == 0:
215 + continue
216 + gate_up = expanded[expert_mask] @ w1_f[expert_idx].T
217 + gate = gate_up[:, :intermediate_dim]
218 + up = gate_up[:, intermediate_dim:]
219 + if activation == "silu":
220 + act = F.silu(gate) * up
221 + elif activation == "gelu":
222 + act = F.gelu(gate) * up
223 + else:
224 + raise ValueError(f"Unknown activation: {activation}")
225 + output[expert_mask] = act @ w2_f[expert_idx].T
226 +
227 + output = output.view(num_tokens, topk, hidden_dim)
228 + weights = topk_weights.float().view(num_tokens, topk, 1)
229 + return (output * weights).sum(dim=1)
230 +
231 +
232 +class AiterMxfp4MoeCase(NamedTuple):
233 + hidden_states: torch.Tensor
234 + w1_kernel: torch.Tensor
235 + w2_kernel: torch.Tensor
236 + w1_ref: torch.Tensor
237 + w2_ref: torch.Tensor
238 + topk_weights: torch.Tensor
239 + topk_ids: torch.Tensor
240 + moe_config: Any
241 + quant_config: Any
242 +
243 +
244 +def _make_topk_ids(
245 + num_tokens: int,
246 + num_experts: int,
247 + topk: int,
248 + *,
249 + device: str = "cuda",
250 +) -> torch.Tensor:
251 + """Generate distinct expert IDs per token with the same top-k shape as
252 + production routing."""
253 + router_logits = torch.randn(num_tokens, num_experts, device=device)
254 + _, topk_ids = torch.topk(torch.softmax(router_logits, dim=-1), k=topk, dim=-1)
255 + return topk_ids.to(torch.int32)
256 +
257 +
258 +def _shuffle_moe_weights(
259 + w1: torch.Tensor,
260 + w2: torch.Tensor,
261 +) -> tuple[torch.Tensor, torch.Tensor]:
262 + """Shuffle MoE weights into the AITER CK layout used in production."""
263 + from vllm._aiter_ops import rocm_aiter_ops
264 +
265 + w1_shuffled, w2_shuffled = rocm_aiter_ops.shuffle_weights(w1, w2)
266 + w1_shuffled.is_shuffled = True
267 + w2_shuffled.is_shuffled = True
268 + return w1_shuffled, w2_shuffled
269 +
270 +
271 +def _make_moe_case(
272 + *,
273 + num_tokens: int,
274 + hidden_dim: int,
275 + intermediate_dim: int,
276 + num_experts: int,
277 + topk: int,
278 + seed: int,
279 +) -> dict[str, torch.Tensor]:
280 + torch.set_default_device("cuda")
281 + set_random_seed(seed)
282 +
283 + hidden_states = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16)
284 + w1 = torch.randn(
285 + num_experts,
286 + intermediate_dim * 2,
287 + hidden_dim,
288 + dtype=torch.bfloat16,
289 + ) / math.sqrt(hidden_dim)
290 + w2 = torch.randn(
291 + num_experts,
292 + hidden_dim,
293 + intermediate_dim,
294 + dtype=torch.bfloat16,
295 + ) / math.sqrt(intermediate_dim)
296 + topk_weights = torch.rand(num_tokens, topk, dtype=torch.float32)
297 + topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
298 + topk_ids = _make_topk_ids(num_tokens, num_experts, topk)
299 + return {
300 + "hidden_states": hidden_states,
301 + "w1": w1,
302 + "w2": w2,
303 + "topk_weights": topk_weights,
304 + "topk_ids": topk_ids,
305 + }
306 +
307 +
308 +def _interleave_gate_up_rows(t: torch.Tensor) -> torch.Tensor:
309 + """Reorder contiguous ``[gate; up]`` rows into gpt-oss interleaved order."""
310 + e, two_i = t.shape[0], t.shape[1]
311 + i, rest = two_i // 2, t.shape[2:]
312 + perm = (0, 2, 1, *range(3, 3 + len(rest)))
313 + return t.view(e, 2, i, *rest).permute(*perm).contiguous().view(e, two_i, *rest)
314 +
315 +
316 +def _make_aiter_mxfp4_moe_case(
317 + *,
318 + num_tokens: int,
319 + hidden_dim: int,
320 + intermediate_dim: int,
321 + num_experts: int,
322 + topk: int,
323 + seed: int,
324 +) -> AiterMxfp4MoeCase:
325 + from triton_kernels.numerics_details.mxfp import (
326 + downcast_to_mxfp,
327 + upcast_from_mxfp,
328 + )
329 +
330 + from tests.kernels.moe.utils import make_dummy_moe_config
331 + from vllm.model_executor.layers.fused_moe.oracle.mxfp4 import (
332 + Mxfp4MoeBackend,
333 + convert_gpt_oss_weight_to_mxfp4_moe_kernel_format,
334 + make_mxfp4_moe_quant_config,
335 + )
336 +
337 + torch.set_default_device("cuda")
338 + set_random_seed(seed)
339 +
340 + hidden_states = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16)
341 + w1 = torch.randn(
342 + num_experts,
343 + intermediate_dim * 2,
344 + hidden_dim,
345 + dtype=torch.bfloat16,
346 + ) / math.sqrt(hidden_dim)
347 + w2 = torch.randn(
348 + num_experts,
349 + hidden_dim,
350 + intermediate_dim,
351 + dtype=torch.bfloat16,
352 + ) / math.sqrt(intermediate_dim)
353 + topk_weights = torch.rand(num_tokens, topk, dtype=torch.float32)
354 + topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
355 + topk_ids = _make_topk_ids(num_tokens, num_experts, topk)
356 +
357 + w1_q, w1_scale = downcast_to_mxfp(w1, torch.uint8, axis=-1)
358 + w2_q, w2_scale = downcast_to_mxfp(w2, torch.uint8, axis=-1)
359 +
360 + w1_ref = upcast_from_mxfp(w1_q, w1_scale, torch.bfloat16, axis=-1)
361 + w2_ref = upcast_from_mxfp(w2_q, w2_scale, torch.bfloat16, axis=-1)
362 +
363 + (
364 + w1_kernel,
365 + w2_kernel,
366 + w1_scale_kernel,
367 + w2_scale_kernel,
368 + _,
369 + _,
370 + ) = convert_gpt_oss_weight_to_mxfp4_moe_kernel_format(
371 + mxfp4_backend=Mxfp4MoeBackend.AITER,
372 + layer=torch.nn.Module(),
373 + # w13 rows must be gpt-oss-interleaved; the converter de-interleaves them
374 + # back to the contiguous gate/up blocks that ``w1_ref`` / ``ref_moe_forward``
375 + # use. w2 has no gate/up split, so it stays as-is.
376 + w13_weight=_interleave_gate_up_rows(w1_q),
377 + w2_weight=w2_q.clone(),
378 + w13_weight_scale=_interleave_gate_up_rows(w1_scale),
379 + w2_weight_scale=w2_scale.clone(),
380 + )
381 +
382 + moe_config = make_dummy_moe_config(
383 + num_experts=num_experts,
384 + experts_per_token=topk,
385 + hidden_dim=hidden_dim,
386 + intermediate_size=intermediate_dim,
387 + in_dtype=torch.bfloat16,
388 + )
389 + quant_config = make_mxfp4_moe_quant_config(
390 + Mxfp4MoeBackend.AITER,
391 + w1_scale=w1_scale_kernel,
392 + w2_scale=w2_scale_kernel,
393 + )
394 +
395 + return AiterMxfp4MoeCase(
396 + hidden_states=hidden_states,
397 + w1_kernel=w1_kernel,
398 + w2_kernel=w2_kernel,
399 + w1_ref=w1_ref,
400 + w2_ref=w2_ref,
401 + topk_weights=topk_weights,
402 + topk_ids=topk_ids,
403 + moe_config=moe_config,
404 + quant_config=quant_config,
405 + )
406 +
407 +
408 +def _run_fused_moe(
409 + hidden_states: torch.Tensor,
410 + w1: torch.Tensor,
411 + w2: torch.Tensor,
412 + topk_weights: torch.Tensor,
413 + topk_ids: torch.Tensor,
414 + *,
415 + activation_method: int,
416 + quant_method: int,
417 +) -> torch.Tensor:
418 + w1_shuffled, w2_shuffled = _shuffle_moe_weights(w1, w2)
419 + return torch.ops.vllm.rocm_aiter_fused_moe(
420 + hidden_states,
421 + w1_shuffled,
422 + w2_shuffled,
423 + topk_weights,
424 + topk_ids,
425 + expert_mask=None,
426 + activation_method=activation_method,
427 + quant_method=quant_method,
428 + doweight_stage1=False,
429 + )
430 +
431 +
432 +# Custom op tests ---------------------------------------------------------
433 +
434 +
435 +def test_aiter_fused_moe_custom_op_registered():
436 + """The main fused-MoE custom op should stay registered for runtime use."""
437 + _assert_aiter_supported()
438 + import vllm._aiter_ops as aiter_ops # noqa: F401
439 +
440 + assert hasattr(torch.ops.vllm, "rocm_aiter_fused_moe")
441 + assert callable(torch.ops.vllm.rocm_aiter_fused_moe)
442 +
443 +
444 +def test_aiter_asm_moe_tkw1_custom_op_registered():
445 + """The tkw1 custom op should stay registered for FP8 apply-router-weight
446 + paths."""
447 + _assert_aiter_supported()
448 + import vllm._aiter_ops as aiter_ops # noqa: F401
449 +
450 + assert hasattr(torch.ops.vllm, "rocm_aiter_asm_moe_tkw1")
451 + assert callable(torch.ops.vllm.rocm_aiter_asm_moe_tkw1)
452 +
453 +
454 +def test_aiter_fused_moe_fake_tensor_support():
455 + """The fused-MoE op should preserve fake-tensor compatibility for
456 + torch.compile-style tracing."""
457 + _assert_aiter_supported()
458 + import vllm._aiter_ops # noqa: F401
459 +
460 + num_tokens = 16
461 + hidden_dim = 1024
462 + intermediate_dim = 2048
463 + num_experts = 8
464 + topk = 2
465 +
466 + hidden_states = torch.randn(
467 + num_tokens, hidden_dim, dtype=torch.bfloat16, device="cuda"
468 + )
469 + w1 = torch.randn(
470 + num_experts,
471 + intermediate_dim * 2,
472 + hidden_dim,
473 + dtype=torch.bfloat16,
474 + device="cuda",
475 + )
476 + w2 = torch.randn(
477 + num_experts,
478 + hidden_dim,
479 + intermediate_dim,
480 + dtype=torch.bfloat16,
481 + device="cuda",
482 + )
483 + topk_weights = torch.rand(num_tokens, topk, dtype=torch.float32, device="cuda")
484 + topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
485 + topk_ids = _make_topk_ids(num_tokens, num_experts, topk, device="cuda")
486 +
487 + torch.library.opcheck(
488 + torch.ops.vllm.rocm_aiter_fused_moe,
489 + (hidden_states, w1, w2, topk_weights, topk_ids),
490 + kwargs={
491 + "expert_mask": None,
492 + "activation_method": 0,
493 + "quant_method": 0,
494 + "doweight_stage1": False,
495 + },
496 + test_utils=("test_faketensor",),
497 + )
498 +
499 +
500 +# Env gating tests --------------------------------------------------------
501 +
502 +
503 +@pytest.mark.parametrize(
504 + ("use_aiter", "use_moe", "expected"),
505 + [
506 + (True, True, True),
507 + (True, False, False),
508 + (False, True, False),
509 + (False, False, False),
510 + ],
511 +)
512 +def test_aiter_moe_enablement_follows_env(
513 + use_aiter: bool,
514 + use_moe: bool,
515 + expected: bool,
516 + monkeypatch: pytest.MonkeyPatch,
517 +):
518 + """The fused-MoE gate should depend only on the main AITER toggle and the
519 + MoE-specific toggle."""
520 + from vllm._aiter_ops import rocm_aiter_ops
521 +
522 + _assert_aiter_supported()
523 +
524 + with monkeypatch.context() as mp:
525 + mp.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0")
526 + mp.setenv("VLLM_ROCM_USE_AITER_MOE", "1" if use_moe else "0")
527 + _reload_envs()
528 + rocm_aiter_ops.refresh_env_variables()
529 +
530 + assert rocm_aiter_ops.is_fused_moe_enabled() is expected
531 +
532 +
533 +@pytest.mark.parametrize(
534 + ("use_aiter", "use_moe", "use_shared", "expected"),
535 + [
536 + (True, True, True, True),
537 + (True, True, False, False),
538 + (True, False, True, False),
539 + (False, True, True, False),
540 + ],
541 +)
542 +def test_aiter_moe_shared_experts_enablement_follows_env(
543 + use_aiter: bool,
544 + use_moe: bool,
545 + use_shared: bool,
546 + expected: bool,
547 + monkeypatch: pytest.MonkeyPatch,
548 +):
549 + """Shared-expert fusion should only be enabled when the fused-MoE path is
550 + enabled too."""
551 + from vllm._aiter_ops import rocm_aiter_ops
552 +
553 + _assert_aiter_supported()
554 +
555 + with monkeypatch.context() as mp:
556 + mp.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0")
557 + mp.setenv("VLLM_ROCM_USE_AITER_MOE", "1" if use_moe else "0")
558 + mp.setenv(
559 + "VLLM_ROCM_USE_AITER_FUSION_SHARED_EXPERTS",
560 + "1" if use_shared else "0",
561 + )
562 + _reload_envs()
563 + rocm_aiter_ops.refresh_env_variables()
564 +
565 + assert rocm_aiter_ops.is_fusion_moe_shared_experts_enabled() is expected
566 +
567 +
568 +@pytest.mark.parametrize("moe_padding", [True, False])
569 +def test_aiter_moe_padding_env_var(
570 + moe_padding: bool,
571 + monkeypatch: pytest.MonkeyPatch,
572 +):
573 + """The ROCm MoE padding env var should keep its exact parse contract."""
574 + with monkeypatch.context() as mp:
575 + mp.setenv("VLLM_ROCM_MOE_PADDING", "1" if moe_padding else "0")
576 + envs = _reload_envs()
577 + assert envs.VLLM_ROCM_MOE_PADDING is moe_padding
578 +
579 +
580 +# Enum tests --------------------------------------------------------------
581 +
582 +
583 +def test_quant_method_enum_values():
584 + """The AITER quant-method bridge enum should keep its wire values."""
585 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import QuantMethod
586 +
587 + assert QuantMethod.NO == 0
588 + assert QuantMethod.PER_TENSOR == 1
589 + assert QuantMethod.PER_TOKEN == 2
590 + assert QuantMethod.BLOCK_1X32 == 3
591 + assert QuantMethod.BLOCK_1X128 == 4
592 + assert QuantMethod.BLOCK_128x128 == 5
593 +
594 +
595 +def test_activation_method_enum_values():
596 + """The AITER activation-method bridge enum should keep its wire values."""
597 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
598 + ActivationMethod,
599 + )
600 +
601 + assert ActivationMethod.SILU == 0
602 + assert ActivationMethod.GELU == 1
603 +
604 +
605 +# MXFP4 kernel tests ------------------------------------------------------
606 +
607 +
608 +def test_aiter_mxfp4_quant_scheme_support_matches_gfx950():
609 + """AITER MXFP4 MoE support should stay gfx950-only."""
610 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
611 + AiterExperts,
612 + )
613 + from vllm.model_executor.layers.quantization.utils.quant_utils import (
614 + kMxfp4Static,
615 + )
616 +
617 + assert AiterExperts._supports_quant_scheme(kMxfp4Static, None) is on_gfx950()
618 +
619 +
620 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
621 +def test_aiter_fused_moe_mi350_mxfp4_w4a16_accuracy():
622 + """The gfx950 AITER MXFP4 W4A16 MoE path should match the dequantized
623 + MXFP4 reference."""
624 + from vllm.model_executor.layers.fused_moe.activation import MoEActivation
625 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
626 + rocm_aiter_fused_experts,
627 + )
628 +
629 + _assert_aiter_supported()
630 + case = _make_aiter_mxfp4_moe_case(
631 + num_tokens=32,
632 + hidden_dim=512,
633 + intermediate_dim=1024,
634 + num_experts=4,
635 + topk=2,
636 + seed=11,
637 + )
638 + ref_out = ref_moe_forward(
639 + case.hidden_states,
640 + case.w1_ref,
641 + case.w2_ref,
642 + case.topk_weights,
643 + case.topk_ids,
644 + )
645 + out = rocm_aiter_fused_experts(
646 + hidden_states=case.hidden_states,
647 + w1=case.w1_kernel,
648 + w2=case.w2_kernel,
649 + topk_weights=case.topk_weights,
650 + topk_ids=case.topk_ids,
651 + activation=MoEActivation.SWIGLUOAI,
652 + quant_config=case.quant_config,
653 + moe_config=case.moe_config,
654 + expert_map=None,
655 + )
656 +
657 + assert out.shape == case.hidden_states.shape
658 + _assert_close_budget(
659 + out.float(),
660 + ref_out.float(),
661 + label="mi350_mxfp4_w4a16_accuracy",
662 + atol=0.1,
663 + rtol=0.1,
664 + pass_rate=0.99,
665 + max_violation_factor=2.0,
666 + )
667 +
668 +
669 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
670 +def test_aiter_fused_moe_mi350_mxfp4_w4a16_determinism():
671 + """The gfx950 AITER MXFP4 W4A16 MoE path should stay bitwise
672 + deterministic."""
673 + from vllm.model_executor.layers.fused_moe.activation import MoEActivation
674 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
675 + rocm_aiter_fused_experts,
676 + )
677 +
678 + _assert_aiter_supported()
679 + case = _make_aiter_mxfp4_moe_case(
680 + num_tokens=8,
681 + hidden_dim=512,
682 + intermediate_dim=1024,
683 + num_experts=4,
684 + topk=2,
685 + seed=13,
686 + )
687 +
688 + def run_mxfp4_moe():
689 + return rocm_aiter_fused_experts(
690 + hidden_states=case.hidden_states,
691 + w1=case.w1_kernel,
692 + w2=case.w2_kernel,
693 + topk_weights=case.topk_weights,
694 + topk_ids=case.topk_ids,
695 + activation=MoEActivation.SWIGLUOAI,
696 + quant_config=case.quant_config,
697 + moe_config=case.moe_config,
698 + expert_map=None,
699 + )
700 +
701 + _assert_deterministic(run_mxfp4_moe, n_runs=4)
702 +
703 +
704 +# FP8 group-quant tests ---------------------------------------------------
705 +
706 +
707 +@pytest.mark.skipif(
708 + not (on_gfx942() or on_gfx950()),
709 + reason="gfx942/gfx950 ROCm only",
710 +)
711 +@pytest.mark.parametrize("num_tokens,hidden_dim", [(16, 2048), (64, 4096), (128, 8192)])
712 +def test_aiter_moe_group_fp8_quant_reconstructs_hidden_states(
713 + num_tokens: int,
714 + hidden_dim: int,
715 +):
716 + """The MoE-facing FP8 group-quant helper should reconstruct hidden states
717 + within the expected FP8 error budget.
718 +
719 + The raw op's shape and standalone roundtrip contracts are covered in
720 + ``tests/kernels/core/test_rocm_aiter_ops.py``. This test keeps the
721 + representative hidden-state sizes that the fused-MoE path actually cares
722 + about.
723 + """
724 + from vllm._aiter_ops import rocm_aiter_ops
725 +
726 + _assert_aiter_supported()
727 + torch.set_default_device("cuda")
728 + set_random_seed(1)
729 +
730 + group_size = 128
731 + hidden_states = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16)
732 + x_fp8, scales = rocm_aiter_ops.group_fp8_quant(hidden_states, group_size)
733 +
734 + assert x_fp8.shape == hidden_states.shape
735 + assert scales.shape == (num_tokens, (hidden_dim + group_size - 1) // group_size)
736 + assert scales.dtype == torch.float32
737 +
738 + scales_expanded = scales.repeat_interleave(group_size, dim=1)[:, :hidden_dim]
739 + dequantized = x_fp8.float() * scales_expanded
740 +
741 + _assert_group_quant_quality(
742 + dequantized,
743 + hidden_states,
744 + label=f"group_fp8_quant shape=({num_tokens}, {hidden_dim})",
745 + )
746 +
747 +
748 +# Kernel accuracy tests ---------------------------------------------------
749 +
750 +
751 +@pytest.mark.parametrize(
752 + "num_tokens,hidden_dim,intermediate_dim",
753 + [
754 + (16, 512, 1024),
755 + (128, 2048, 4096),
756 + (2048, 4096, 11008),
757 + ],
758 +)
759 +def test_aiter_fused_moe_bf16_accuracy(
760 + num_tokens: int,
761 + hidden_dim: int,
762 + intermediate_dim: int,
763 +):
764 + """The ROCm AITER fused-MoE BF16 path should match the float32 reference
765 + on representative shapes."""
766 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
767 + ActivationMethod,
768 + QuantMethod,
769 + )
770 +
771 + _assert_aiter_supported()
772 + case = _make_moe_case(
773 + num_tokens=num_tokens,
774 + hidden_dim=hidden_dim,
775 + intermediate_dim=intermediate_dim,
776 + num_experts=8,
777 + topk=2,
778 + seed=0,
779 + )
780 + ref_out = ref_moe_forward(
781 + case["hidden_states"],
782 + case["w1"],
783 + case["w2"],
784 + case["topk_weights"],
785 + case["topk_ids"],
786 + activation="silu",
787 + )
788 + out = _run_fused_moe(
789 + case["hidden_states"],
790 + case["w1"],
791 + case["w2"],
792 + case["topk_weights"],
793 + case["topk_ids"],
794 + activation_method=int(ActivationMethod.SILU),
795 + quant_method=int(QuantMethod.NO),
796 + )
797 +
798 + assert out.shape == (num_tokens, hidden_dim)
799 + _assert_close_budget(
800 + out.float(),
801 + ref_out,
802 + label=f"bf16_accuracy shape=({num_tokens}, {hidden_dim}, {intermediate_dim})",
803 + atol=0.05,
804 + rtol=0.0,
805 + )
806 +
807 +
808 +def test_aiter_fused_moe_gelu_accuracy():
809 + """The GELU activation variant should stay aligned with the float32
810 + reference."""
811 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
812 + ActivationMethod,
813 + QuantMethod,
814 + )
815 +
816 + _assert_aiter_supported()
817 + case = _make_moe_case(
818 + num_tokens=32,
819 + hidden_dim=512,
820 + intermediate_dim=1024,
821 + num_experts=4,
822 + topk=2,
823 + seed=42,
824 + )
825 + ref_out = ref_moe_forward(
826 + case["hidden_states"],
827 + case["w1"],
828 + case["w2"],
829 + case["topk_weights"],
830 + case["topk_ids"],
831 + activation="gelu",
832 + )
833 + out = _run_fused_moe(
834 + case["hidden_states"],
835 + case["w1"],
836 + case["w2"],
837 + case["topk_weights"],
838 + case["topk_ids"],
839 + activation_method=int(ActivationMethod.GELU),
840 + quant_method=int(QuantMethod.NO),
841 + )
842 +
843 + assert out.shape == case["hidden_states"].shape
844 + _assert_close_budget(
845 + out.float(),
846 + ref_out,
847 + label="gelu_accuracy",
848 + atol=0.05,
849 + rtol=0.0,
850 + )
851 +
852 +
853 +def test_aiter_fused_moe_determinism():
854 + """The BF16 fused-MoE kernel should stay bitwise deterministic for the
855 + same inputs."""
856 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
857 + ActivationMethod,
858 + QuantMethod,
859 + )
860 +
861 + _assert_aiter_supported()
862 + case = _make_moe_case(
863 + num_tokens=8,
864 + hidden_dim=256,
865 + intermediate_dim=512,
866 + num_experts=4,
867 + topk=2,
868 + seed=2,
869 + )
870 +
871 + def run_moe():
872 + return _run_fused_moe(
873 + case["hidden_states"],
874 + case["w1"],
875 + case["w2"],
876 + case["topk_weights"],
877 + case["topk_ids"],
878 + activation_method=int(ActivationMethod.SILU),
879 + quant_method=int(QuantMethod.NO),
880 + )
881 +
882 + _assert_deterministic(run_moe, n_runs=4)
883 +
884 +
885 +# Routed end-to-end tests -------------------------------------------------
886 +
887 +
888 +@pytest.mark.parametrize(
889 + "num_tokens,num_experts,topk",
890 + [
891 + (1, 8, 2),
892 + (16, 8, 2),
893 + (64, 16, 4),
894 + ],
895 +)
896 +def test_aiter_fused_moe_end_to_end(
897 + num_tokens: int,
898 + num_experts: int,
899 + topk: int,
900 +):
901 + """The full router-logits to top-k to fused-MoE path should stay accurate."""
902 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
903 + ActivationMethod,
904 + QuantMethod,
905 + )
906 +
907 + _assert_aiter_supported()
908 + torch.set_default_device("cuda")
909 + set_random_seed(7)
910 +
911 + hidden_dim = 512
912 + intermediate_dim = 1024
913 + hidden_states = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16)
914 + w1 = torch.randn(
915 + num_experts,
916 + intermediate_dim * 2,
917 + hidden_dim,
918 + dtype=torch.bfloat16,
919 + ) / math.sqrt(hidden_dim)
920 + w2 = torch.randn(
921 + num_experts,
922 + hidden_dim,
923 + intermediate_dim,
924 + dtype=torch.bfloat16,
925 + ) / math.sqrt(intermediate_dim)
926 +
927 + router_logits = torch.randn(num_tokens, num_experts, device="cuda")
928 + router_probs = torch.softmax(router_logits, dim=-1)
929 + topk_weights, topk_ids = torch.topk(router_probs, k=topk, dim=-1)
930 + topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
931 + topk_weights = topk_weights.float()
932 + topk_ids = topk_ids.to(torch.int32)
933 +
934 + ref_out = ref_moe_forward(hidden_states, w1, w2, topk_weights, topk_ids)
935 + out = _run_fused_moe(
936 + hidden_states,
937 + w1,
938 + w2,
939 + topk_weights,
940 + topk_ids,
941 + activation_method=int(ActivationMethod.SILU),
942 + quant_method=int(QuantMethod.NO),
943 + )
944 +
945 + assert out.shape == (num_tokens, hidden_dim)
946 + assert out.dtype == torch.bfloat16
947 + _assert_close_budget(
948 + out.float(),
949 + ref_out,
950 + label=f"end_to_end tokens={num_tokens} experts={num_experts} topk={topk}",
951 + atol=0.05,
952 + rtol=0.0,
953 + )
954 +
955 +
956 +# Arch-specific tests -----------------------------------------------------
957 +
958 +
959 +@pytest.mark.skipif(
960 + not (on_gfx942() or on_gfx950()),
961 + reason="gfx942/gfx950 ROCm only",
962 +)
963 +def test_aiter_fused_moe_mi3xx_bf16_accuracy():
964 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
965 + ActivationMethod,
966 + QuantMethod,
967 + )
968 +
969 + _assert_aiter_supported()
970 + case = _make_moe_case(
971 + num_tokens=64,
972 + hidden_dim=4096,
973 + intermediate_dim=11008,
974 + num_experts=8,
975 + topk=2,
976 + seed=42,
977 + )
978 + ref_out = ref_moe_forward(
979 + case["hidden_states"],
980 + case["w1"],
981 + case["w2"],
982 + case["topk_weights"],
983 + case["topk_ids"],
984 + )
985 + out = _run_fused_moe(
986 + case["hidden_states"],
987 + case["w1"],
988 + case["w2"],
989 + case["topk_weights"],
990 + case["topk_ids"],
991 + activation_method=int(ActivationMethod.SILU),
992 + quant_method=int(QuantMethod.NO),
993 + )
994 +
995 + _assert_close_budget(
996 + out.float(),
997 + ref_out,
998 + label=(f"mi3xx_bf16_accuracy arch={'gfx942' if on_gfx942() else 'gfx950'}"),
999 + atol=0.05,
1000 + rtol=0.0,
1001 + )
1002 +
1003 +
1004 +@pytest.mark.skipif(
1005 + not (on_gfx942() or on_gfx950()),
1006 + reason="gfx942/gfx950 ROCm only",
1007 +)
1008 +def test_aiter_fused_moe_mi3xx_fp8_accuracy():
1009 + """The MI3xx FP8 per-tensor MoE path should stay within the measured FP8
1010 + error budget."""
1011 + from tests.kernels.moe.utils import make_test_weights
1012 + from vllm._aiter_ops import rocm_aiter_ops
1013 + from vllm.model_executor.layers.fused_moe.experts.rocm_aiter_moe import (
1014 + ActivationMethod,
1015 + QuantMethod,
1016 + )
1017 +
1018 + _assert_aiter_supported()
1019 + torch.set_default_device("cuda")
1020 + set_random_seed(99)
1021 +
1022 + num_tokens = 32
1023 + hidden_dim = 512
1024 + intermediate_dim = 1024
1025 + num_experts = 4
1026 + topk = 2
1027 +
1028 + hidden_states = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16) / 10
1029 + fp8_dtype = current_platform.fp8_dtype()
1030 + (w1_bf16, w1_fp8, w1_scale, _), (w2_bf16, w2_fp8, w2_scale, _) = make_test_weights(
1031 + num_experts,
1032 + intermediate_dim * 2,
1033 + hidden_dim,
1034 + torch.bfloat16,
1035 + torch.float8_e4m3fn,
1036 + per_out_ch_quant=False,
1037 + )
1038 + _, a1_scale = rocm_aiter_ops.per_tensor_quant(hidden_states, fp8_dtype)
1039 + router_logits = torch.randn(num_tokens, num_experts, device="cuda")
1040 + router_probs = torch.softmax(router_logits, dim=-1)
1041 + topk_weights, topk_ids = torch.topk(router_probs, k=topk, dim=-1)
1042 + topk_weights = topk_weights / topk_weights.sum(dim=-1, keepdim=True)
1043 + topk_weights = topk_weights.float()
1044 + topk_ids = topk_ids.to(torch.int32)
1045 +
1046 + ref_out = ref_moe_forward(
1047 + hidden_states,
1048 + w1_bf16,
1049 + w2_bf16,
1050 + topk_weights,
1051 + topk_ids,
1052 + )
1053 +
1054 + w1_shuffled, w2_shuffled = _shuffle_moe_weights(w1_fp8, w2_fp8)
1055 + out = torch.ops.vllm.rocm_aiter_fused_moe(
1056 + hidden_states,
1057 + w1_shuffled,
1058 + w2_shuffled,
1059 + topk_weights,
1060 + topk_ids,
1061 + expert_mask=None,
1062 + activation_method=int(ActivationMethod.SILU),
1063 + quant_method=int(QuantMethod.PER_TENSOR),
1064 + doweight_stage1=False,
1065 + w1_scale=w1_scale,
1066 + w2_scale=w2_scale,
1067 + a1_scale=a1_scale,
1068 + )
1069 +
1070 + assert out.shape == hidden_states.shape
1071 + _assert_close_budget(
1072 + out.float(),
1073 + ref_out,
1074 + label=(f"mi3xx_fp8_accuracy arch={'gfx942' if on_gfx942() else 'gfx950'}"),
1075 + atol=0.02,
1076 + rtol=0.0,
1077 + pass_rate=1.0,
1078 + max_violation_factor=1.5,
1079 + )
tests/kernels/quantization/test_rocm_fp8.py new
+541
@@ -0,0 +1,541 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""ROCm-specific tests for FP8 quantization and kernels."""
4 +
5 +import importlib
6 +import math
7 +import warnings
8 +
9 +import pytest
10 +import torch
11 +
12 +import vllm._custom_ops as ops
13 +from tests.kernels.quant_utils import (
14 + ref_dynamic_per_tensor_fp8_quant,
15 + ref_dynamic_per_token_quant,
16 +)
17 +from tests.kernels.utils import _assert_deterministic
18 +from vllm.platforms import current_platform
19 +from vllm.utils.torch_utils import set_random_seed
20 +
21 +pytestmark = pytest.mark.skipif(
22 + not current_platform.is_rocm(), reason="ROCm-specific tests"
23 +)
24 +
25 +DEVICE = "cuda"
26 +FP8_DTYPE = current_platform.fp8_dtype()
27 +fp8_only = pytest.mark.skipif(
28 + not current_platform.supports_fp8(), reason="ROCm FP8 only"
29 +)
30 +
31 +ENV_DEFAULTS = {
32 + "VLLM_ROCM_FP8_PADDING": True,
33 + "VLLM_ROCM_USE_AITER_FP8BMM": True,
34 + "VLLM_ROCM_FP8_MFMA_PAGE_ATTN": False,
35 +}
36 +
37 +SCALED_MM_CASES = [
38 + pytest.param((1, 128, 128), 0.05, 0.15, 0.95, id="1x128x128"),
39 + pytest.param((4, 256, 512), 0.10, 0.30, 0.96, id="4x256x512"),
40 + pytest.param((8, 512, 1024), 0.20, 0.50, 0.99, id="8x512x1024"),
41 +]
42 +
43 +
44 +def _reload_envs():
45 + import vllm.envs as envs
46 +
47 + return importlib.reload(envs)
48 +
49 +
50 +@pytest.fixture(autouse=True)
51 +def _restore_rocm_env_state():
52 + """Restore global env + AITER flag state after every test.
53 +
54 + Tests here reload ``vllm.envs`` (a module singleton) and refresh the
55 + cached ``rocm_aiter_ops`` env flags. Restoring in a fixture teardown
56 + guarantees cleanup even when an assertion fails mid-test, preventing
57 + state from leaking into other tests in the same worker.
58 + """
59 + yield
60 + _reload_envs()
61 + from vllm._aiter_ops import rocm_aiter_ops
62 +
63 + rocm_aiter_ops.refresh_env_variables()
64 +
65 +
66 +def _quantile(flat: torch.Tensor, q: float) -> float:
67 + if flat.numel() == 1:
68 + return flat.item()
69 + return torch.quantile(flat, q).item()
70 +
71 +
72 +def _format_observed_rate(count: int, total: int) -> str:
73 + return f"{count / total:.4%} ({count}/{total})"
74 +
75 +
76 +def _format_allowed_rate(rate: float, total: int) -> str:
77 + allowed_count = math.floor(rate * total)
78 + return f"{rate:.4%} (<= {allowed_count}/{total})"
79 +
80 +
81 +def _assert_abs_error_budget(
82 + actual: torch.Tensor,
83 + expected: torch.Tensor,
84 + *,
85 + label: str,
86 + tight_atol: float,
87 + max_atol: float,
88 + pass_rate: float,
89 +) -> None:
90 + diff = (actual.float() - expected.float()).abs().flatten()
91 + total = diff.numel()
92 + within_tight_count = int((diff <= tight_atol).sum().item())
93 + fail_count = total - within_tight_count
94 + above_max_count = int((diff > max_atol).sum().item())
95 + within_tight = within_tight_count / total
96 + allowed_fail_rate = 1.0 - pass_rate
97 + above_max = above_max_count / total
98 + max_fail_rate = 0.0
99 + max_diff = diff.max().item()
100 + mean_diff = diff.mean().item()
101 + p99 = _quantile(diff, 0.99)
102 + p999 = _quantile(diff, 0.999)
103 + msg = (
104 + f"[rocm_fp8] {label}: abs<={tight_atol} pass={within_tight:.4%} "
105 + f"({within_tight_count}/{total}) "
106 + f"fail={_format_observed_rate(fail_count, total)} "
107 + f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
108 + f"abs>{max_atol}={_format_observed_rate(above_max_count, total)} "
109 + f"allowed_above_max={_format_allowed_rate(max_fail_rate, total)} "
110 + f"max_diff={max_diff:.6g} mean_diff={mean_diff:.6g} "
111 + f"p99={p99:.6g} p999={p999:.6g}"
112 + )
113 + print(msg)
114 + if within_tight < 1.0:
115 + warnings.warn(msg, stacklevel=2)
116 + assert within_tight >= pass_rate, msg
117 + assert above_max <= max_fail_rate, msg
118 +
119 +
120 +def _assert_rel_error_budget(
121 + actual: torch.Tensor,
122 + expected: torch.Tensor,
123 + *,
124 + label: str,
125 + tight_rtol: float,
126 + max_rtol: float,
127 + pass_rate: float,
128 + max_fail_rate: float,
129 +) -> None:
130 + rel = (
131 + (actual.float() - expected.float()).abs()
132 + / expected.float().abs().clamp_min(1e-5)
133 + ).flatten()
134 + total = rel.numel()
135 + within_tight_count = int((rel <= tight_rtol).sum().item())
136 + fail_count = total - within_tight_count
137 + above_max_count = int((rel > max_rtol).sum().item())
138 + within_tight = within_tight_count / total
139 + allowed_fail_rate = 1.0 - pass_rate
140 + above_max = above_max_count / total
141 + max_rel = rel.max().item()
142 + mean_rel = rel.mean().item()
143 + p99 = _quantile(rel, 0.99)
144 + p999 = _quantile(rel, 0.999)
145 + msg = (
146 + f"[rocm_fp8] {label}: rel<={tight_rtol} pass={within_tight:.4%} "
147 + f"({within_tight_count}/{total}) "
148 + f"fail={_format_observed_rate(fail_count, total)} "
149 + f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
150 + f"rel>{max_rtol}={_format_observed_rate(above_max_count, total)} "
151 + f"allowed_above_max={_format_allowed_rate(max_fail_rate, total)} "
152 + f"max_rel={max_rel:.6g} mean_rel={mean_rel:.6g} "
153 + f"p99={p99:.6g} p999={p999:.6g}"
154 + )
155 + print(msg)
156 + if within_tight < 1.0:
157 + warnings.warn(msg, stacklevel=2)
158 + assert within_tight >= pass_rate, msg
159 + assert above_max <= max_fail_rate, msg
160 +
161 +
162 +def _ref_paged_attention(
163 + query: torch.Tensor,
164 + key_cache: torch.Tensor,
165 + value_cache: torch.Tensor,
166 + *,
167 + query_lens: list[int],
168 + kv_lens: list[int],
169 + block_tables: torch.Tensor,
170 + scale: float,
171 +) -> torch.Tensor:
172 + block_tables_np = block_tables.cpu().numpy()
173 + _, block_size, num_kv_heads, head_size = key_cache.shape
174 +
175 + outputs = []
176 + start_idx = 0
177 + for i, query_len in enumerate(query_lens):
178 + kv_len = kv_lens[i]
179 + q = query[start_idx : start_idx + query_len] * scale
180 +
181 + num_kv_blocks = (kv_len + block_size - 1) // block_size
182 + block_indices = block_tables_np[i, :num_kv_blocks]
183 +
184 + k = key_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
185 + v = value_cache[block_indices].view(-1, num_kv_heads, head_size)[:kv_len]
186 +
187 + if q.shape[1] != k.shape[1]:
188 + repeat = q.shape[1] // k.shape[1]
189 + k = torch.repeat_interleave(k, repeat, dim=1)
190 + v = torch.repeat_interleave(v, repeat, dim=1)
191 +
192 + attn = torch.einsum("qhd,khd->hqk", q, k).float()
193 + mask = torch.triu(
194 + torch.ones(query_len, kv_len, device=q.device),
195 + diagonal=kv_len - query_len + 1,
196 + ).bool()
197 + attn.masked_fill_(mask, float("-inf"))
198 + attn = torch.softmax(attn, dim=-1).to(v.dtype)
199 + outputs.append(torch.einsum("hqk,khd->qhd", attn, v))
200 + start_idx += query_len
201 +
202 + return torch.cat(outputs, dim=0)
203 +
204 +
205 +def test_rocm_fp8_env_defaults(monkeypatch):
206 + with monkeypatch.context() as mp:
207 + for env_name in ENV_DEFAULTS:
208 + mp.delenv(env_name, raising=False)
209 + envs = _reload_envs()
210 + for env_name, expected in ENV_DEFAULTS.items():
211 + assert getattr(envs, env_name) is expected
212 +
213 +
214 +@pytest.mark.parametrize(
215 + ("env_name", "raw_value", "expected"),
216 + [
217 + ("VLLM_ROCM_FP8_PADDING", "0", False),
218 + ("VLLM_ROCM_FP8_PADDING", "1", True),
219 + ("VLLM_ROCM_USE_AITER_FP8BMM", "0", False),
220 + ("VLLM_ROCM_USE_AITER_FP8BMM", "1", True),
221 + ("VLLM_ROCM_FP8_MFMA_PAGE_ATTN", "0", False),
222 + ("VLLM_ROCM_FP8_MFMA_PAGE_ATTN", "1", True),
223 + ],
224 +)
225 +def test_rocm_fp8_env_overrides(env_name, raw_value, expected, monkeypatch):
226 + with monkeypatch.context() as mp:
227 + mp.setenv(env_name, raw_value)
228 + envs = _reload_envs()
229 + assert getattr(envs, env_name) is expected
230 +
231 +
232 +@fp8_only
233 +def test_maybe_pad_fp8_weight_respects_env(monkeypatch):
234 + from vllm.model_executor.layers.quantization.utils.fp8_utils import (
235 + _maybe_pad_fp8_weight,
236 + )
237 +
238 + weight = torch.randn(64, 512, device=DEVICE).to(FP8_DTYPE)
239 +
240 + with monkeypatch.context() as mp:
241 + mp.setenv("VLLM_ROCM_FP8_PADDING", "1")
242 + _reload_envs()
243 + padded = _maybe_pad_fp8_weight(weight)
244 + assert padded.shape == weight.shape
245 + assert padded.dtype == weight.dtype
246 + assert padded.data_ptr() != weight.data_ptr()
247 + torch.testing.assert_close(padded.float(), weight.float())
248 +
249 + with monkeypatch.context() as mp:
250 + mp.setenv("VLLM_ROCM_FP8_PADDING", "0")
251 + _reload_envs()
252 + unpadded = _maybe_pad_fp8_weight(weight)
253 + assert unpadded.data_ptr() == weight.data_ptr()
254 +
255 +
256 +@fp8_only
257 +@pytest.mark.parametrize(
258 + ("use_aiter", "fp8bmm_enabled", "expected"),
259 + [
260 + (True, True, True),
261 + (True, False, False),
262 + (False, True, False),
263 + ],
264 +)
265 +def test_aiter_fp8bmm_enabled_api_respects_env(
266 + use_aiter, fp8bmm_enabled, expected, monkeypatch
267 +):
268 + from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
269 +
270 + assert is_aiter_found_and_supported()
271 +
272 + with monkeypatch.context() as mp:
273 + mp.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0")
274 + mp.setenv("VLLM_ROCM_USE_AITER_FP8BMM", "1" if fp8bmm_enabled else "0")
275 + _reload_envs()
276 + rocm_aiter_ops.refresh_env_variables()
277 + assert rocm_aiter_ops.is_fp8bmm_enabled() is expected
278 +
279 +
280 +@fp8_only
281 +@pytest.mark.parametrize(
282 + ("shape", "tight_atol", "max_atol", "pass_rate"), SCALED_MM_CASES
283 +)
284 +def test_fp8_scaled_mm_matches_dequantized_reference(
285 + shape, tight_atol, max_atol, pass_rate
286 +):
287 + torch.manual_seed(0)
288 +
289 + batch, m_dim, k_dim = shape
290 + n_dim = k_dim
291 + a = torch.randn(batch * m_dim, k_dim, device=DEVICE)
292 + b = torch.randn(n_dim, k_dim, device=DEVICE)
293 +
294 + a_fp8, scale_a = ref_dynamic_per_tensor_fp8_quant(a)
295 + b_fp8, scale_b = ref_dynamic_per_tensor_fp8_quant(b)
296 + out = torch._scaled_mm(
297 + a_fp8,
298 + b_fp8.t(),
299 + out_dtype=torch.bfloat16,
300 + scale_a=scale_a,
301 + scale_b=scale_b,
302 + )
303 +
304 + a_dq = a_fp8.float() * scale_a.float()
305 + b_dq = b_fp8.float() * scale_b.float()
306 + ref = torch.mm(a_dq, b_dq.t())
307 +
308 + assert out.shape == (batch * m_dim, n_dim)
309 + assert out.dtype == torch.bfloat16
310 + assert not torch.isnan(out).any()
311 +
312 + _assert_abs_error_budget(
313 + out.float(),
314 + ref,
315 + label=f"scaled_mm shape={shape}",
316 + tight_atol=tight_atol,
317 + max_atol=max_atol,
318 + pass_rate=pass_rate,
319 + )
320 +
321 +
322 +@fp8_only
323 +def test_fp8_scaled_mm_determinism():
324 + torch.manual_seed(5)
325 +
326 + m_dim, k_dim, n_dim = 64, 128, 128
327 + a = torch.randn(m_dim, k_dim, device=DEVICE)
328 + b = torch.randn(n_dim, k_dim, device=DEVICE)
329 + a_fp8, scale_a = ref_dynamic_per_tensor_fp8_quant(a)
330 + b_fp8, scale_b = ref_dynamic_per_tensor_fp8_quant(b)
331 +
332 + def run_gemm():
333 + return torch._scaled_mm(
334 + a_fp8,
335 + b_fp8.t(),
336 + out_dtype=torch.bfloat16,
337 + scale_a=scale_a,
338 + scale_b=scale_b,
339 + )
340 +
341 + _assert_deterministic(run_gemm, n_runs=4)
342 +
343 +
344 +@fp8_only
345 +def test_aiter_fp8_kv_attention_matches_bf16_reference():
346 + import aiter
347 +
348 + from vllm._aiter_ops import is_aiter_found_and_supported
349 + from vllm.v1.attention.backends.rocm_aiter_fa import cp_mha_gather_cache
350 +
351 + assert is_aiter_found_and_supported()
352 +
353 + set_random_seed(10)
354 +
355 + num_q_heads, num_kv_heads = 8, 8
356 + head_size = 128
357 + query_len, kv_len = 4, 128
358 + block_size = 16
359 + num_blocks = 2048
360 + scale = head_size**-0.5
361 +
362 + query = torch.randn(
363 + query_len, num_q_heads, head_size, dtype=torch.bfloat16, device=DEVICE
364 + )
365 + key_cache_fp8 = torch.clamp(
366 + torch.randn(num_blocks, block_size, num_kv_heads, head_size, device=DEVICE),
367 + min=-1.0,
368 + max=1.0,
369 + ).to(FP8_DTYPE)
370 + value_cache_fp8 = torch.clamp(
371 + torch.randn(num_blocks, block_size, num_kv_heads, head_size, device=DEVICE),
372 + min=-1.0,
373 + max=1.0,
374 + ).to(FP8_DTYPE)
375 +
376 + cu_query_lens = torch.tensor([0, query_len], dtype=torch.int32, device=DEVICE)
377 + cu_seq_lens = torch.tensor([0, kv_len], dtype=torch.int32, device=DEVICE)
378 + block_tables = torch.randint(
379 + 0,
380 + num_blocks,
381 + (1, (kv_len + block_size - 1) // block_size),
382 + dtype=torch.int32,
383 + device=DEVICE,
384 + )
385 + token_to_batch = torch.zeros(kv_len, dtype=torch.int32, device=DEVICE)
386 + seq_starts = torch.zeros(1, dtype=torch.int32, device=DEVICE)
387 + gathered_key = torch.empty(
388 + kv_len, num_kv_heads, head_size, dtype=torch.bfloat16, device=DEVICE
389 + )
390 + gathered_value = torch.empty_like(gathered_key)
391 +
392 + cp_mha_gather_cache(
393 + key_cache=key_cache_fp8,
394 + value_cache=value_cache_fp8,
395 + key=gathered_key,
396 + value=gathered_value,
397 + block_tables=block_tables,
398 + k_scales=torch.ones(1, dtype=torch.float32, device=DEVICE),
399 + v_scales=torch.ones(1, dtype=torch.float32, device=DEVICE),
400 + cu_seqlens_kv=cu_seq_lens,
401 + token_to_batch=token_to_batch,
402 + seq_starts=seq_starts,
403 + dequant=True,
404 + kv_cache_layout="NHD",
405 + total_tokens=kv_len,
406 + )
407 +
408 + output = torch.empty_like(query)
409 + aiter.flash_attn_varlen_func(
410 + q=query,
411 + k=gathered_key,
412 + v=gathered_value,
413 + cu_seqlens_q=cu_query_lens,
414 + cu_seqlens_k=cu_seq_lens,
415 + max_seqlen_q=query_len,
416 + max_seqlen_k=kv_len,
417 + min_seqlen_q=1,
418 + dropout_p=0.0,
419 + softmax_scale=scale,
420 + causal=True,
421 + window_size=(-1, -1),
422 + alibi_slopes=None,
423 + return_lse=False,
424 + out=output,
425 + )
426 +
427 + ref = _ref_paged_attention(
428 + query=query,
429 + key_cache=key_cache_fp8.to(torch.bfloat16),
430 + value_cache=value_cache_fp8.to(torch.bfloat16),
431 + query_lens=[query_len],
432 + kv_lens=[kv_len],
433 + block_tables=block_tables,
434 + scale=scale,
435 + )
436 +
437 + _assert_abs_error_budget(
438 + output.float(),
439 + ref.float(),
440 + label="aiter_fp8_kv_attention",
441 + tight_atol=0.15,
442 + max_atol=0.25,
443 + pass_rate=0.98,
444 + )
445 +
446 +
447 +@fp8_only
448 +@pytest.mark.parametrize("shape", [(128, 256), (512, 1024), (1024, 4096)])
449 +def test_fp8_per_tensor_quant_matches_reference(shape):
450 + torch.manual_seed(0)
451 +
452 + x = torch.randn(*shape, dtype=torch.bfloat16, device=DEVICE)
453 +
454 + out, scale = ops.scaled_fp8_quant(x)
455 + ref_out, ref_scale = ref_dynamic_per_tensor_fp8_quant(x)
456 +
457 + assert out.shape == x.shape
458 + assert out.dtype == FP8_DTYPE
459 +
460 + torch.testing.assert_close(scale, ref_scale, rtol=1e-3, atol=1e-4)
461 + code_match_rate = (
462 + (out.view(torch.uint8) == ref_out.view(torch.uint8)).float().mean().item()
463 + )
464 + assert code_match_rate >= 0.999, (
465 + f"FP8 codes match reference only {code_match_rate:.4%} (< 99.9%)"
466 + )
467 +
468 + dequant = out.float() * scale.float()
469 + _assert_rel_error_budget(
470 + dequant,
471 + x.float(),
472 + label=f"per_tensor_quant shape={shape}",
473 + tight_rtol=0.06,
474 + max_rtol=0.60,
475 + pass_rate=0.99,
476 + max_fail_rate=1e-4,
477 + )
478 +
479 +
480 +@fp8_only
481 +def test_aiter_group_fp8_quant_roundtrip():
482 + from aiter import dtypes
483 +
484 + from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
485 +
486 + assert is_aiter_found_and_supported()
487 +
488 + torch.manual_seed(0)
489 +
490 + m_dim, n_dim = 64, 4096
491 + group_size = 128
492 + x = torch.randn(m_dim, n_dim, dtype=torch.bfloat16, device=DEVICE)
493 +
494 + out, scales = rocm_aiter_ops.group_fp8_quant(x, group_size)
495 + assert out.dtype == dtypes.fp8
496 + assert scales.dtype == torch.float32
497 +
498 + scales_expanded = scales.repeat_interleave(group_size, dim=1)[:, :n_dim]
499 + dequant = out.float() * scales_expanded
500 + _assert_rel_error_budget(
501 + dequant,
502 + x.float(),
503 + label="aiter_group_fp8_quant",
504 + tight_rtol=0.06,
505 + max_rtol=0.30,
506 + pass_rate=0.99,
507 + max_fail_rate=1e-4,
508 + )
509 +
510 +
511 +@fp8_only
512 +def test_fp8_per_token_quant_matches_reference():
513 + torch.manual_seed(0)
514 +
515 + x = torch.randn(32, 4096, dtype=torch.bfloat16, device=DEVICE)
516 +
517 + out, scale = ops.scaled_fp8_quant(x, scale=None, use_per_token_if_dynamic=True)
518 + ref_out, ref_scale = ref_dynamic_per_token_quant(x, FP8_DTYPE)
519 +
520 + assert out.shape == x.shape
521 + assert out.dtype == FP8_DTYPE
522 + assert scale.shape == (x.shape[0], 1)
523 +
524 + torch.testing.assert_close(scale, ref_scale, rtol=1e-3, atol=1e-4)
525 + code_match_rate = (
526 + (out.view(torch.uint8) == ref_out.view(torch.uint8)).float().mean().item()
527 + )
528 + assert code_match_rate >= 0.999, (
529 + f"FP8 codes match reference only {code_match_rate:.4%} (< 99.9%)"
530 + )
531 +
532 + dequant = out.float() * scale.float()
533 + _assert_rel_error_budget(
534 + dequant,
535 + x.float(),
536 + label="per_token_quant",
537 + tight_rtol=0.06,
538 + max_rtol=0.30,
539 + pass_rate=0.99,
540 + max_fail_rate=1e-4,
541 + )
tests/kernels/quantization/test_rocm_mxfp4.py new
+638
@@ -0,0 +1,638 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""ROCm AITER FP4 and MXFP4 tests.
4 +
5 +This file keeps the ROCm-specific FP4 coverage:
6 +- ROCm AITER FP4 env and enablement gates
7 +- large-shape MXFP4 wrapper checks through vLLM's public helper op
8 +- AITER Triton MXFP4 quant format and determinism
9 +- FP4 GEMM, preshuffled-scale, hardware-quant, and skinny decode
10 + paths
11 +
12 +Generic MXFP4 wrapper/reference checks already live in
13 +``tests/kernels/quantization/test_quark.py``.
14 +"""
15 +
16 +import importlib
17 +import warnings
18 +
19 +import pytest
20 +import torch
21 +
22 +from tests.kernels.utils import _assert_accurate, _assert_deterministic
23 +from tests.quantization.reference_mxfp4 import dq_mxfp4_torch, qdq_mxfp4_torch
24 +from vllm.platforms import current_platform
25 +from vllm.platforms.rocm import on_gfx950
26 +
27 +pytestmark = pytest.mark.skipif(
28 + not current_platform.is_rocm(), reason="ROCm-specific tests"
29 +)
30 +
31 +LLAMA_SHAPES = [
32 + (64, 4096),
33 + (64, 11008),
34 + (32, 14336),
35 +]
36 +GEMM_ATOL = 0.5
37 +GEMM_RTOL = 0.0
38 +GEMM_MAX_VIOLATION_FACTOR = 2.0
39 +A4W4_GEMM_PASS_RATES = {
40 + (64, 128, 64): 0.9999,
41 + (128, 256, 128): 0.9999,
42 + (128, 4096, 4096): 0.9999,
43 + (256, 4096, 11008): 0.9999,
44 + (64, 8192, 28672): 0.99765,
45 +}
46 +SKINNY_GEMM_PASS_RATES = {
47 + (4096, 4096): 0.9999,
48 + (4096, 11008): 0.99235,
49 + (8192, 8192): 0.99765,
50 +}
51 +PRESHUFFLED_SHAPES = [
52 + (64, 4096, 8192),
53 + (32, 8192, 8192),
54 +]
55 +
56 +
57 +def _reload_envs():
58 + import vllm.envs as envs
59 +
60 + return importlib.reload(envs)
61 +
62 +
63 +@pytest.fixture(autouse=True)
64 +def _restore_rocm_env_state():
65 + """Restore global env + AITER flag state after every test."""
66 + yield
67 + _reload_envs()
68 + from vllm._aiter_ops import rocm_aiter_ops
69 +
70 + rocm_aiter_ops.refresh_env_variables()
71 +
72 +
73 +def _assert_aiter_supported() -> None:
74 + from vllm._aiter_ops import is_aiter_found_and_supported
75 +
76 + assert is_aiter_found_and_supported(), (
77 + "aiter is required on supported ROCm hardware for this test"
78 + )
79 +
80 +
81 +def _format_observed_rate(count: int, total: int) -> str:
82 + return f"{count / total:.4%} ({count}/{total})"
83 +
84 +
85 +def _format_allowed_rate(rate: float, total: int) -> str:
86 + allowed_count = int(rate * total)
87 + return f"{rate:.4%} (<= {allowed_count}/{total})"
88 +
89 +
90 +def _print_close_stats(
91 + label: str,
92 + actual: torch.Tensor,
93 + expected: torch.Tensor,
94 + *,
95 + atol: float,
96 + rtol: float,
97 + pass_rate: float = 1.0,
98 + max_atol: float | None = None,
99 +) -> None:
100 + abs_diff = (actual - expected).abs().float().flatten()
101 + expected_abs = expected.abs().float().flatten()
102 + allowed = atol + rtol * expected_abs
103 + within = abs_diff <= allowed
104 +
105 + total = abs_diff.numel()
106 + passed = int(within.sum().item())
107 + failed = total - passed
108 + allowed_fail_rate = 1.0 - pass_rate
109 +
110 + max_abs = abs_diff.max().item()
111 + mean_abs = abs_diff.mean().item()
112 + p99_abs = torch.quantile(abs_diff, 0.99).item()
113 + p999_abs = torch.quantile(abs_diff, 0.999).item()
114 + worst_ratio = (abs_diff / allowed.clamp_min(1e-12)).max().item()
115 +
116 + msg = (
117 + "[rocm_aiter_fp4] "
118 + f"{label}: "
119 + f"pass={passed / total:.4%} ({passed}/{total}) "
120 + f"fail={_format_observed_rate(failed, total)} "
121 + f"allowed_fail={_format_allowed_rate(allowed_fail_rate, total)} "
122 + f"atol={atol:g} "
123 + f"rtol={rtol:g} "
124 + )
125 + if max_atol is not None:
126 + above_max_count = int((abs_diff > max_atol).sum().item())
127 + msg += (
128 + f"abs>{max_atol:g}={_format_observed_rate(above_max_count, total)} "
129 + f"allowed_above_max={_format_allowed_rate(0.0, total)} "
130 + )
131 + msg += (
132 + f"max_abs={max_abs:.6g} "
133 + f"mean_abs={mean_abs:.6g} "
134 + f"p99_abs={p99_abs:.6g} "
135 + f"p999_abs={p999_abs:.6g} "
136 + f"worst_ratio={worst_ratio:.6g}"
137 + )
138 + print(msg)
139 + if failed > 0:
140 + warnings.warn(msg, stacklevel=2)
141 +
142 +
143 +# Env and enablement tests ------------------------------------------------
144 +
145 +
146 +def test_fp4_env_defaults():
147 + """ROCm FP4 env defaults should stay stable for the AITER gates."""
148 + import vllm.envs as envs
149 +
150 + assert envs.VLLM_ROCM_USE_AITER_FP4_ASM_GEMM is False
151 + assert envs.VLLM_ROCM_USE_AITER_FP4BMM is True
152 +
153 +
154 +@pytest.mark.parametrize(
155 + (
156 + "use_aiter",
157 + "use_fp4_asm_gemm",
158 + "use_fp4bmm",
159 + ),
160 + [
161 + (True, True, True),
162 + (True, True, False),
163 + (True, False, True),
164 + (False, True, True),
165 + ],
166 +)
167 +def test_rocm_aiter_fp4_enablement_follows_env_and_arch(
168 + use_aiter,
169 + use_fp4_asm_gemm,
170 + use_fp4bmm,
171 + monkeypatch,
172 +):
173 + """The ROCm FP4 AITER gates should depend only on the env toggles and the
174 + gfx950 hardware check.
175 +
176 + Expected values are derived from the *actual* platform rather than a patched
177 + arch (gfx950 -> MI355, gfx942 -> MI300). A failure in this group therefore
178 + points straight at the hardware it ran on instead of a mocked value.
179 + """
180 + from vllm._aiter_ops import rocm_aiter_ops
181 +
182 + _assert_aiter_supported()
183 +
184 + on_gfx950_value = on_gfx950()
185 + expected_asm_gemm = use_aiter and use_fp4_asm_gemm and on_gfx950_value
186 + expected_fp4bmm = use_aiter and use_fp4bmm and on_gfx950_value
187 +
188 + with monkeypatch.context() as mp:
189 + mp.setenv("VLLM_ROCM_USE_AITER", "1" if use_aiter else "0")
190 + mp.setenv(
191 + "VLLM_ROCM_USE_AITER_FP4_ASM_GEMM",
192 + "1" if use_fp4_asm_gemm else "0",
193 + )
194 + mp.setenv("VLLM_ROCM_USE_AITER_FP4BMM", "1" if use_fp4bmm else "0")
195 + _reload_envs()
196 + rocm_aiter_ops.refresh_env_variables()
197 +
198 + assert (
199 + rocm_aiter_ops.is_asm_fp4_gemm_dynamic_quant_enabled() is expected_asm_gemm
200 + )
201 + assert rocm_aiter_ops.is_fp4bmm_enabled() is expected_fp4bmm
202 +
203 +
204 +# Large-shape MXFP4 wrapper tests -----------------------------------------
205 +
206 +
207 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
208 +@pytest.mark.parametrize("shape", LLAMA_SHAPES)
209 +def test_vllm_quant_dequant_mxfp4_matches_reference_on_large_shapes(shape, dtype):
210 + """The public vLLM MXFP4 QDQ helper should stay exact against the torch
211 + reference even on larger Llama-like shapes."""
212 + from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
213 + quant_dequant_mxfp4,
214 + )
215 +
216 + torch.set_default_device("cuda")
217 + torch.manual_seed(0)
218 + rows, cols = shape
219 + x = torch.randn(rows, cols, dtype=dtype)
220 + out = quant_dequant_mxfp4(x)
221 + ref = qdq_mxfp4_torch(x, "even")
222 +
223 + assert out.shape == x.shape
224 + assert out.dtype == dtype
225 + assert torch.isfinite(out).all()
226 + torch.testing.assert_close(out, ref, atol=0.0, rtol=0.0)
227 +
228 +
229 +def test_vllm_quant_dequant_mxfp4_is_deterministic():
230 + """The public vLLM MXFP4 QDQ helper should stay bitwise deterministic."""
231 + from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
232 + quant_dequant_mxfp4,
233 + )
234 +
235 + torch.set_default_device("cuda")
236 + torch.manual_seed(3)
237 +
238 + x = torch.randn(64, 128, dtype=torch.bfloat16)
239 +
240 + _assert_deterministic(quant_dequant_mxfp4, x, n_runs=4)
241 +
242 +
243 +# Triton MXFP4 quant tests ------------------------------------------------
244 +
245 +
246 +def test_aiter_dynamic_mxfp4_quant_output_format():
247 + """dynamic_mxfp4_quant returns packed uint8 FP4 values and E8M0 uint8 scales.
248 +
249 + OCP MXFP4: block_size=32, 2 FP4 E2M1 values packed per byte.
250 + Scale shape: (M, K // 32); one E8M0 exponent byte per 32-element block.
251 + """
252 + _assert_aiter_supported()
253 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
254 +
255 + torch.set_default_device("cuda")
256 + M, K = 64, 256
257 + x = torch.randn(M, K, dtype=torch.bfloat16)
258 +
259 + x_fp4, x_scale = dynamic_mxfp4_quant(x)
260 +
261 + # FP4 values packed 2-per-byte; shape (M, K // 2)
262 + assert x_fp4.shape == (M, K // 2), (
263 + f"Expected fp4 shape ({M}, {K // 2}), got {x_fp4.shape}"
264 + )
265 + assert x_fp4.dtype == torch.uint8
266 +
267 + # One E8M0 scale byte per 32-element block; shape (M, K // 32)
268 + assert x_scale.shape == (M, K // 32), (
269 + f"Expected scale shape ({M}, {K // 32}), got {x_scale.shape}"
270 + )
271 + assert x_scale.dtype == torch.uint8
272 +
273 +
274 +@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float16])
275 +@pytest.mark.parametrize(
276 + "shape",
277 + [
278 + (128, 4096), # Llama-7B hidden
279 + (256, 11008), # Llama-7B FFN
280 + (64, 14336), # Llama-70B FFN
281 + (512, 8192), # Llama-70B hidden
282 + (32, 28672), # DeepSeek-style large FFN
283 + ],
284 +)
285 +def test_aiter_dynamic_mxfp4_quant_llama_shapes(shape, dtype):
286 + """MXFP4 quantization output format is correct for Llama-class weight shapes.
287 +
288 + Targets B200 parity: NVIDIA nvfp4 tests parametrize over Llama shapes
289 + (7168, 14336, 28672...). This covers gfx950 MXFP4 quant format validation.
290 + """
291 + _assert_aiter_supported()
292 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
293 +
294 + torch.set_default_device("cuda")
295 + M, K = shape
296 + x = torch.randn(M, K, dtype=dtype)
297 +
298 + x_fp4, x_scale = dynamic_mxfp4_quant(x)
299 +
300 + assert x_fp4.shape == (M, K // 2)
301 + assert x_fp4.dtype == torch.uint8
302 + assert x_scale.shape == (M, K // 32)
303 + assert x_scale.dtype == torch.uint8
304 + assert not torch.any(torch.isnan(x_fp4.float()))
305 +
306 +
307 +def test_aiter_dynamic_mxfp4_quant_determinism():
308 + """dynamic_mxfp4_quant is bitwise deterministic across repeated runs."""
309 + _assert_aiter_supported()
310 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
311 +
312 + torch.set_default_device("cuda")
313 + torch.manual_seed(7)
314 + x = torch.randn(128, 256, dtype=torch.bfloat16)
315 +
316 + _assert_deterministic(dynamic_mxfp4_quant, x, n_runs=4)
317 +
318 +
319 +# gfx950 hardware FP4 GEMM tests ------------------------------------------
320 +
321 +
322 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
323 +@pytest.mark.parametrize(
324 + "shape",
325 + [
326 + (64, 128, 64), # small square-ish
327 + (128, 256, 128), # medium
328 + (128, 4096, 4096), # Llama-7B hidden square
329 + (256, 4096, 11008), # Llama-7B FFN
330 + (64, 8192, 28672), # Llama-70B FFN
331 + ],
332 +)
333 +def test_aiter_fp4_gemm_a4w4_accuracy(shape):
334 + """AITER A4W4 FP4 GEMM output is close to matmul on dequantized weights.
335 +
336 + Tests gfx950 parity with B200 test_nvfp4_scaled_mm.py: E2M1 x E2M1 GEMM
337 + with block-scaled quantization, multiple Llama-class shapes.
338 + Requires gfx950 hardware; gracefully skips elsewhere.
339 + """
340 + atol = GEMM_ATOL
341 + rtol = GEMM_RTOL
342 + pass_rate = A4W4_GEMM_PASS_RATES[shape]
343 + _assert_aiter_supported()
344 + from aiter.ops.triton.gemm_afp4wfp4 import gemm_afp4wfp4
345 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
346 +
347 + from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
348 + quant_dequant_mxfp4,
349 + )
350 +
351 + torch.set_default_device("cuda")
352 + torch.manual_seed(0)
353 + M, K, N = shape
354 +
355 + A = torch.randn(M, K, dtype=torch.bfloat16)
356 + B = torch.randn(N, K, dtype=torch.bfloat16) # weight stored as (N, K)
357 +
358 + # Quantize
359 + A_fp4, A_scale = dynamic_mxfp4_quant(A)
360 + B_fp4, B_scale = dynamic_mxfp4_quant(B)
361 +
362 + # FP4 GEMM computes A_fp4 (M, K) @ B_fp4.T (K, N) into (M, N).
363 + out = gemm_afp4wfp4(A_fp4, B_fp4, A_scale, B_scale)
364 +
365 + assert out.shape == (M, N), f"Expected ({M}, {N}), got {out.shape}"
366 + assert out.dtype == torch.bfloat16
367 + assert not torch.any(torch.isnan(out))
368 +
369 + # Reference: matmul on dequantized FP4 inputs (quark roundtrip)
370 + A_dq = quant_dequant_mxfp4(A)
371 + B_dq = quant_dequant_mxfp4(B)
372 + ref = torch.matmul(A_dq.float(), B_dq.t().float())
373 +
374 + # FP4 has only 3 mantissa bits; large K amplifies the accumulation error.
375 + # These budgets were measured on gfx950 and keep p99/p999 drift visible
376 + # without pretending the kernel is close to BF16 matmul everywhere.
377 + _print_close_stats(
378 + f"a4w4_gemm shape={shape}",
379 + out.float(),
380 + ref,
381 + atol=atol,
382 + rtol=rtol,
383 + pass_rate=pass_rate,
384 + max_atol=GEMM_MAX_VIOLATION_FACTOR * atol,
385 + )
386 + _assert_accurate(
387 + out.float(),
388 + ref,
389 + atol=atol,
390 + rtol=rtol,
391 + pass_rate=pass_rate,
392 + max_violation_factor=GEMM_MAX_VIOLATION_FACTOR,
393 + )
394 +
395 +
396 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
397 +@pytest.mark.parametrize(
398 + "shape",
399 + PRESHUFFLED_SHAPES,
400 +)
401 +def test_aiter_fp4_gemm_preshuffled_tuned_shapes(shape):
402 + """The preshuffled FP4 GEMM path should run on tuned shapes with the same
403 + packed inputs vLLM uses in production.
404 +
405 + This kernel only advertises a narrow tuned `(N, K)` set on gfx950. The
406 + important contract here is that those tuned shapes execute with the
407 + production-style shuffled weights and stay deterministic.
408 + """
409 + _assert_aiter_supported()
410 + from aiter import per_1x32_f4_quant_hip
411 + from aiter.ops.shuffle import shuffle_weight
412 + from aiter.ops.triton.gemm_afp4wfp4 import (
413 + gemm_afp4wfp4_preshuffled_weight_scales,
414 + )
415 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
416 +
417 + from vllm._aiter_ops import rocm_aiter_ops
418 +
419 + torch.set_default_device("cuda")
420 + torch.manual_seed(1)
421 + M, K, N = shape
422 +
423 + assert M <= 64
424 + assert rocm_aiter_ops.is_triton_gemm_afp4wfp4_presh_ws_tuned(N, K)
425 +
426 + A = torch.randn(M, K, dtype=torch.bfloat16)
427 + B = torch.randn(N, K, dtype=torch.bfloat16)
428 +
429 + B_fp4, B_scale = dynamic_mxfp4_quant(B)
430 + A_q, A_s = per_1x32_f4_quant_hip(A, shuffle=M >= 32)
431 +
432 + scale_rows, scale_cols = B_scale.shape
433 + B_scale = (
434 + B_scale.view(scale_rows // 32, 2, 16, scale_cols // 8, 2, 4, 1)
435 + .permute(0, 3, 5, 2, 4, 1, 6)
436 + .contiguous()
437 + .view(scale_rows, scale_cols)
438 + )
439 + B_fp4 = shuffle_weight(B_fp4, layout=(16, 16))
440 +
441 + if M >= 32:
442 + A_s = A_s.contiguous().view(torch.uint8).reshape(A_s.shape[0] // 32, -1)
443 + else:
444 + A_s = A_s[:M, ...].contiguous().view(torch.uint8)
445 + B_scale = B_scale.contiguous().view(torch.uint8).reshape(B_scale.shape[0] // 32, -1)
446 +
447 + def run_preshuffled() -> torch.Tensor:
448 + y = torch.empty(M, N, device="cuda", dtype=torch.bfloat16)
449 + return gemm_afp4wfp4_preshuffled_weight_scales(
450 + A_q.contiguous().view(torch.uint8),
451 + B_fp4.contiguous().view(torch.uint8).reshape(B_fp4.shape[0] // 16, -1),
452 + A_s,
453 + B_scale,
454 + torch.bfloat16,
455 + y,
456 + )
457 +
458 + out = run_preshuffled()
459 + assert out.shape == (M, N)
460 + assert out.dtype == torch.bfloat16
461 + assert torch.isfinite(out).all()
462 +
463 + _assert_deterministic(run_preshuffled, n_runs=3)
464 +
465 +
466 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
467 +def test_aiter_fp4_gemm_a4w4_determinism():
468 + """AITER FP4 A4W4 GEMM is bitwise deterministic across repeated runs.
469 +
470 + Targets parity with B200 FP4 determinism requirements.
471 + Requires gfx950.
472 + """
473 + _assert_aiter_supported()
474 + from aiter.ops.triton.gemm_afp4wfp4 import gemm_afp4wfp4
475 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
476 +
477 + torch.set_default_device("cuda")
478 + torch.manual_seed(2)
479 +
480 + M, K, N = 128, 256, 128
481 + A = torch.randn(M, K, dtype=torch.bfloat16)
482 + B = torch.randn(N, K, dtype=torch.bfloat16)
483 +
484 + A_fp4, A_scale = dynamic_mxfp4_quant(A)
485 + B_fp4, B_scale = dynamic_mxfp4_quant(B)
486 +
487 + def run_gemm():
488 + return gemm_afp4wfp4(A_fp4, B_fp4, A_scale, B_scale)
489 +
490 + _assert_deterministic(run_gemm, n_runs=4)
491 +
492 +
493 +# gfx950 hardware FP4 dynamic quantization --------------------------------
494 +
495 +
496 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
497 +@pytest.mark.parametrize(
498 + "shape",
499 + [
500 + (128, 256),
501 + (256, 4096),
502 + (64, 14336),
503 + ],
504 +)
505 +def test_aiter_hardware_fp4_dynamic_quant_format(shape):
506 + """aiter hardware FP4 dynamic quant produces correct output format.
507 +
508 + Tests gfx950 hardware-accelerated FP4 quantization (OCP MXFP4 E2M1).
509 + Parity with B200 scaled_fp4_quant: block_size=32, packed uint8 output.
510 + Requires gfx950.
511 + """
512 + _assert_aiter_supported()
513 +
514 + from aiter import dynamic_per_group_scaled_quant_fp4
515 +
516 + torch.set_default_device("cuda")
517 + M, K = shape
518 + group_size = 32
519 +
520 + x = torch.randn(M, K, dtype=torch.bfloat16)
521 + out_fp4 = torch.empty(M, K // 2, dtype=torch.uint8)
522 + scales = torch.empty(M, K // group_size, dtype=torch.uint8)
523 +
524 + dynamic_per_group_scaled_quant_fp4(out_fp4, x, scales, group_size)
525 +
526 + assert out_fp4.shape == (M, K // 2), (
527 + f"Shape {shape}: expected fp4 ({M}, {K // 2}), got {out_fp4.shape}"
528 + )
529 + assert scales.shape == (M, K // group_size), (
530 + f"Shape {shape}: expected scale ({M}, {K // group_size}), got {scales.shape}"
531 + )
532 +
533 +
534 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
535 +def test_aiter_hardware_fp4_quant_vs_triton():
536 + """Hardware FP4 quant should dequantize back to the same MXFP4 values as
537 + the Triton path.
538 +
539 + The packed bytes are not identical on gfx950, but with
540 + ``shuffle_scale=False`` the dequantized results match tightly.
541 + """
542 + _assert_aiter_supported()
543 +
544 + from aiter import dynamic_per_group_scaled_quant_fp4
545 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
546 +
547 + torch.set_default_device("cuda")
548 + torch.manual_seed(5)
549 +
550 + M, K = 128, 256
551 + group_size = 32
552 + x = torch.randn(M, K, dtype=torch.bfloat16)
553 +
554 + # Hardware path
555 + out_hw = torch.empty(M, K // 2, dtype=torch.uint8)
556 + scales_hw = torch.empty(M, K // group_size, dtype=torch.uint8)
557 + dynamic_per_group_scaled_quant_fp4(
558 + out_hw, x, scales_hw, group_size, shuffle_scale=False
559 + )
560 +
561 + # Triton path
562 + out_triton, scales_triton = dynamic_mxfp4_quant(x)
563 +
564 + assert out_hw.shape == out_triton.shape
565 + assert scales_hw.shape == scales_triton.shape
566 +
567 + dq_hw = dq_mxfp4_torch(out_hw, scales_hw, torch.bfloat16)
568 + dq_triton = dq_mxfp4_torch(out_triton, scales_triton, torch.bfloat16)
569 +
570 + _print_close_stats(
571 + "hardware_quant_vs_triton",
572 + dq_hw.float(),
573 + dq_triton.float(),
574 + atol=0.25,
575 + rtol=0.0,
576 + )
577 + torch.testing.assert_close(dq_hw, dq_triton, atol=0.25, rtol=0.0)
578 +
579 +
580 +# ROCm skinny GEMM FP4 tests ----------------------------------------------
581 +
582 +
583 +@pytest.mark.skipif(not on_gfx950(), reason="gfx950 ROCm only")
584 +@pytest.mark.parametrize("M", [1, 2, 4, 8]) # decode / skinny batch sizes
585 +@pytest.mark.parametrize("N, K", [(4096, 4096), (4096, 11008), (8192, 8192)])
586 +def test_aiter_fp4_gemm_skinny_shapes(M, N, K):
587 + """FP4 GEMM accuracy for skinny (small-M) shapes (decode phase).
588 +
589 + Skinny GEMMs (M=1..8) are the bottleneck in decode phase.
590 + Tests parity with B200 nvfp4_scaled_mm tests at decode batch sizes.
591 + Requires gfx950.
592 + """
593 + atol = GEMM_ATOL
594 + rtol = GEMM_RTOL
595 + pass_rate = SKINNY_GEMM_PASS_RATES[(N, K)]
596 + _assert_aiter_supported()
597 + from aiter.ops.triton.gemm_afp4wfp4 import gemm_afp4wfp4
598 + from aiter.ops.triton.quant import dynamic_mxfp4_quant
599 +
600 + from vllm.model_executor.layers.quantization.utils.mxfp4_utils import (
601 + quant_dequant_mxfp4,
602 + )
603 +
604 + torch.set_default_device("cuda")
605 + torch.manual_seed(0)
606 +
607 + A = torch.randn(M, K, dtype=torch.bfloat16)
608 + B = torch.randn(N, K, dtype=torch.bfloat16)
609 +
610 + A_fp4, A_scale = dynamic_mxfp4_quant(A)
611 + B_fp4, B_scale = dynamic_mxfp4_quant(B)
612 +
613 + out = gemm_afp4wfp4(A_fp4, B_fp4, A_scale, B_scale)
614 +
615 + assert out.shape == (M, N)
616 + assert not torch.any(torch.isnan(out))
617 +
618 + A_dq = quant_dequant_mxfp4(A)
619 + B_dq = quant_dequant_mxfp4(B)
620 + ref = torch.matmul(A_dq.float(), B_dq.t().float())
621 +
622 + _print_close_stats(
623 + f"skinny_gemm M={M} N={N} K={K}",
624 + out.float(),
625 + ref,
626 + atol=atol,
627 + rtol=rtol,
628 + pass_rate=pass_rate,
629 + max_atol=GEMM_MAX_VIOLATION_FACTOR * atol,
630 + )
631 + _assert_accurate(
632 + out.float(),
633 + ref,
634 + atol=atol,
635 + rtol=rtol,
636 + pass_rate=pass_rate,
637 + max_violation_factor=GEMM_MAX_VIOLATION_FACTOR,
638 + )
tests/kernels/utils.py
+57
@@ -36,6 +36,63 @@ ALL_OPCHECK_TEST_UTILS: tuple[str, ...] = (
36 )
37
38
39 +def _assert_accurate(
40 + actual: torch.Tensor,
41 + expected: torch.Tensor,
42 + atol: float,
43 + rtol: float = 0.0,
44 + pass_rate: float = 0.99999,
45 + max_violation_factor: float = 3.0,
46 +) -> None:
47 + """Check numeric accuracy with pass-rate, max-error, and mean-error bounds."""
48 + a = actual.detach().float().flatten()
49 + e = expected.detach().float().flatten()
50 +
51 + abs_err = (a - e).abs()
52 + tol = atol + rtol * e.abs()
53 +
54 + rate = (abs_err <= tol).float().mean().item()
55 + assert rate >= pass_rate, (
56 + f"Accuracy pass rate {rate:.6f} < {pass_rate} (atol={atol}, rtol={rtol})"
57 + )
58 +
59 + max_err = abs_err.max().item()
60 + assert max_err <= max_violation_factor * atol, (
61 + f"Max absolute error {max_err:.6f} exceeds {max_violation_factor} * atol={atol}"
62 + )
63 +
64 + mean_err = abs_err.mean().item()
65 + assert mean_err <= atol * 0.25, (
66 + f"Mean absolute error {mean_err:.6f} >= atol * 0.25 = {atol * 0.25:.6f}"
67 + )
68 +
69 +
70 +def _assert_deterministic(
71 + fn,
72 + *args,
73 + n_runs: int = 4,
74 + **kwargs,
75 +) -> None:
76 + """Verify that repeated calls produce bitwise-identical tensor outputs."""
77 +
78 + def _collect(result: Any) -> list[torch.Tensor]:
79 + if isinstance(result, torch.Tensor):
80 + return [result.detach().clone()]
81 + if isinstance(result, (tuple, list)):
82 + return [t.detach().clone() for t in result if isinstance(t, torch.Tensor)]
83 + raise TypeError(f"Unexpected return type {type(result)}")
84 +
85 + reference = _collect(fn(*args, **kwargs))
86 +
87 + for run in range(1, n_runs):
88 + outputs = _collect(fn(*args, **kwargs))
89 + for idx, (ref, out) in enumerate(zip(reference, outputs)):
90 + assert torch.equal(ref, out), (
91 + f"Run {run}: output[{idx}] differs from run 0 "
92 + f"(max diff = {(out.float() - ref.float()).abs().max().item():.2e})"
93 + )
94 +
95 +
96 class QKVInputs(NamedTuple):
97 """
98 Data structure for representing unpacked attention inputs,