| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """Correctness tests for the gfx950 low-M fp32 router GEMM.""" |
| 4 | |
| 5 | import pytest |
| 6 | import torch |
| 7 | |
| 8 | from vllm.model_executor.layers.fused_moe.router.gate_linear import ( |
| 9 | fp32_router_gemm_dispatch_impl, |
| 10 | ) |
| 11 | from vllm.model_executor.layers.fused_moe.router.rocm_fp32_router_gemm import ( |
| 12 | rocm_fp32_router_gemm, |
| 13 | ) |
| 14 | from vllm.platforms import current_platform |
| 15 | |
| 16 | SHAPES = [ |
| 17 | (3072, 256), |
| 18 | (4096, 8), |
| 19 | (4096, 192), |
| 20 | (6144, 128), |
| 21 | (6144, 256), |
| 22 | ] |
| 23 | MAX_TOKENS = 32 |
| 24 | ATOL = 5e-4 |
| 25 | RTOL = 0.0 |
| 26 | |
| 27 | |
| 28 | def _on_gfx950() -> bool: |
| 29 | if not current_platform.is_rocm(): |
| 30 | return False |
| 31 | try: |
| 32 | from vllm.platforms.rocm import on_gfx950 |
| 33 | |
| 34 | return on_gfx950() |
| 35 | except Exception: |
| 36 | return False |
| 37 | |
| 38 | |
| 39 | pytestmark = pytest.mark.skipif( |
| 40 | not _on_gfx950(), reason="rocm_fp32_router_gemm requires ROCm gfx950" |
| 41 | ) |
| 42 | |
| 43 | |
| 44 | def _reference(x: torch.Tensor, weight: torch.Tensor) -> torch.Tensor: |
| 45 | return torch.nn.functional.linear(x.float(), weight) |
| 46 | |
| 47 | |
| 48 | @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float32]) |
| 49 | @pytest.mark.parametrize(("hidden_size", "num_experts"), SHAPES) |
| 50 | @pytest.mark.parametrize("num_tokens", range(MAX_TOKENS + 1)) |
| 51 | @torch.inference_mode() |
| 52 | def test_rocm_fp32_router_gemm_matches_reference( |
| 53 | num_tokens: int, |
| 54 | hidden_size: int, |
| 55 | num_experts: int, |
| 56 | dtype: torch.dtype, |
| 57 | ) -> None: |
| 58 | torch.manual_seed(41 + num_tokens + hidden_size + num_experts) |
| 59 | device = torch.device("cuda") |
| 60 | x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device) |
| 61 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 62 | |
| 63 | output = rocm_fp32_router_gemm(x, weight) |
| 64 | expected = _reference(x, weight) |
| 65 | |
| 66 | assert output.shape == (num_tokens, num_experts) |
| 67 | assert output.dtype == torch.float32 |
| 68 | assert output.device == x.device |
| 69 | torch.testing.assert_close(output, expected, atol=ATOL, rtol=RTOL) |
| 70 | |
| 71 | |
| 72 | @pytest.mark.parametrize("num_tokens", [1, 4, 16, 24, 32]) |
| 73 | @pytest.mark.parametrize(("hidden_size", "num_experts"), SHAPES) |
| 74 | @torch.inference_mode() |
| 75 | def test_rocm_fp32_router_gemm_preserves_topk( |
| 76 | num_tokens: int, hidden_size: int, num_experts: int |
| 77 | ) -> None: |
| 78 | torch.manual_seed(1000 + num_tokens + hidden_size + num_experts) |
| 79 | device = torch.device("cuda") |
| 80 | x = torch.randn(num_tokens, hidden_size, dtype=torch.bfloat16, device=device) |
| 81 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 82 | |
| 83 | top_k = 2 if (hidden_size, num_experts) == (4096, 8) else 8 |
| 84 | actual = rocm_fp32_router_gemm(x, weight).topk(top_k, dim=-1).indices |
| 85 | reference = _reference(x, weight) |
| 86 | reference_values, expected = reference.topk(top_k, dim=-1) |
| 87 | for token in range(num_tokens): |
| 88 | actual_set = set(actual[token].tolist()) |
| 89 | expected_set = set(expected[token].tolist()) |
| 90 | if actual_set == expected_set: |
| 91 | continue |
| 92 | kth_value = reference_values[token, -1].item() |
| 93 | for expert in actual_set.symmetric_difference(expected_set): |
| 94 | gap = abs(reference[token, expert].item() - kth_value) |
| 95 | assert gap < 1e-3 |
| 96 | |
| 97 | |
| 98 | @torch.inference_mode() |
| 99 | def test_rocm_fp32_router_gemm_rejects_invalid_inputs() -> None: |
| 100 | device = torch.device("cuda") |
| 101 | hidden_size, num_experts = 6144, 128 |
| 102 | x = torch.randn(4, hidden_size, dtype=torch.bfloat16, device=device) |
| 103 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 104 | |
| 105 | with pytest.raises(ValueError, match="num_tokens"): |
| 106 | rocm_fp32_router_gemm( |
| 107 | torch.randn( |
| 108 | MAX_TOKENS + 1, |
| 109 | hidden_size, |
| 110 | dtype=torch.bfloat16, |
| 111 | device=device, |
| 112 | ), |
| 113 | weight, |
| 114 | ) |
| 115 | |
| 116 | with pytest.raises(ValueError, match="shape"): |
| 117 | rocm_fp32_router_gemm(x[:, :2048].contiguous(), weight[:, :2048].contiguous()) |
| 118 | |
| 119 | with pytest.raises(ValueError, match="contiguous"): |
| 120 | wide_x = torch.randn(4, hidden_size * 2, dtype=torch.bfloat16, device=device) |
| 121 | rocm_fp32_router_gemm(wide_x[:, ::2], weight) |
| 122 | |
| 123 | with pytest.raises(ValueError, match="float32"): |
| 124 | rocm_fp32_router_gemm(x, weight.to(torch.bfloat16)) |
| 125 | |
| 126 | with pytest.raises(ValueError, match="bfloat16 or float32"): |
| 127 | rocm_fp32_router_gemm(x.to(torch.float16), weight) |
| 128 | |
| 129 | |
| 130 | @torch.inference_mode() |
| 131 | def test_rocm_fp32_router_gemm_cuda_graph_observes_input_mutation() -> None: |
| 132 | torch.manual_seed(2026) |
| 133 | device = torch.device("cuda") |
| 134 | hidden_size, num_experts = 6144, 128 |
| 135 | static_x = torch.randn(16, hidden_size, dtype=torch.bfloat16, device=device) |
| 136 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 137 | |
| 138 | rocm_fp32_router_gemm(static_x, weight) |
| 139 | torch.accelerator.synchronize() |
| 140 | |
| 141 | graph = torch.cuda.CUDAGraph() |
| 142 | with torch.cuda.graph(graph): |
| 143 | captured_output = rocm_fp32_router_gemm(static_x, weight) |
| 144 | torch.accelerator.synchronize() |
| 145 | |
| 146 | first_input = torch.randn_like(static_x) |
| 147 | static_x.copy_(first_input) |
| 148 | graph.replay() |
| 149 | torch.accelerator.synchronize() |
| 150 | first_output = captured_output.clone() |
| 151 | |
| 152 | second_input = torch.randn_like(static_x) |
| 153 | static_x.copy_(second_input) |
| 154 | graph.replay() |
| 155 | torch.accelerator.synchronize() |
| 156 | second_output = captured_output.clone() |
| 157 | |
| 158 | torch.testing.assert_close( |
| 159 | first_output, _reference(first_input, weight), atol=ATOL, rtol=RTOL |
| 160 | ) |
| 161 | torch.testing.assert_close( |
| 162 | second_output, _reference(second_input, weight), atol=ATOL, rtol=RTOL |
| 163 | ) |
| 164 | assert not torch.equal(first_output, second_output) |
| 165 | |
| 166 | |
| 167 | @pytest.mark.parametrize("num_tokens", [4, 32, 33]) |
| 168 | @pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float32]) |
| 169 | @torch.inference_mode() |
| 170 | def test_rocm_fp32_router_gemm_custom_op_dispatch( |
| 171 | num_tokens: int, dtype: torch.dtype |
| 172 | ) -> None: |
| 173 | torch.manual_seed(3100 + num_tokens) |
| 174 | device = torch.device("cuda") |
| 175 | hidden_size, num_experts = 6144, 128 |
| 176 | x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device) |
| 177 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 178 | |
| 179 | output = torch.ops.vllm.fp32_router_gemm_dispatch(x, weight, False) |
| 180 | direct_output = fp32_router_gemm_dispatch_impl(x, weight, False) |
| 181 | |
| 182 | torch.testing.assert_close(output, direct_output, atol=ATOL, rtol=RTOL) |
| 183 | torch.testing.assert_close(output, _reference(x, weight), atol=ATOL, rtol=RTOL) |
| 184 | |
| 185 | |
| 186 | @torch.inference_mode() |
| 187 | def test_rocm_fp32_router_gemm_dispatch_accepts_noncontiguous_input( |
| 188 | monkeypatch: pytest.MonkeyPatch, |
| 189 | ) -> None: |
| 190 | torch.manual_seed(4100) |
| 191 | device = torch.device("cuda") |
| 192 | hidden_size, num_experts = 6144, 128 |
| 193 | wide_x = torch.randn(4, hidden_size * 2, dtype=torch.bfloat16, device=device) |
| 194 | x = wide_x[:, ::2] |
| 195 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 196 | assert not x.is_contiguous() |
| 197 | |
| 198 | expected = _reference(x, weight) |
| 199 | |
| 200 | def fail_fallback(*args, **kwargs): |
| 201 | raise AssertionError("noncontiguous low-M input used the linear fallback") |
| 202 | |
| 203 | monkeypatch.setattr(torch.nn.functional, "linear", fail_fallback) |
| 204 | output = torch.ops.vllm.fp32_router_gemm_dispatch(x, weight, False) |
| 205 | |
| 206 | torch.testing.assert_close(output, expected, atol=ATOL, rtol=RTOL) |
| 207 | |
| 208 | |
| 209 | @torch.inference_mode() |
| 210 | def test_rocm_fp32_router_gemm_dynamic_compile_dispatch() -> None: |
| 211 | torch.manual_seed(5100) |
| 212 | device = torch.device("cuda") |
| 213 | hidden_size, num_experts = 6144, 128 |
| 214 | weight = torch.randn(num_experts, hidden_size, dtype=torch.float32, device=device) |
| 215 | |
| 216 | def dispatch(x: torch.Tensor) -> torch.Tensor: |
| 217 | return torch.ops.vllm.fp32_router_gemm_dispatch(x, weight, False) |
| 218 | |
| 219 | compiled_dispatch = torch.compile(dispatch, dynamic=True, fullgraph=True) |
| 220 | for num_tokens in (4, 16, 33, 5, 32): |
| 221 | x = torch.randn( |
| 222 | num_tokens, |
| 223 | hidden_size, |
| 224 | dtype=torch.bfloat16, |
| 225 | device=device, |
| 226 | ) |
| 227 | output = compiled_dispatch(x) |
| 228 | torch.testing.assert_close(output, _reference(x, weight), atol=ATOL, rtol=RTOL) |