main
py 175 lines 6.99 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for fp32_router_gemm kernel: activation×weight→fp32.
4
5 Supported (hidden_size, num_experts) pairs:
6 (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3
7
8 Correctness baseline: F.linear in float32. Every M in [1, 32] is covered so
9 all tuned geometries (wide-block, experts-per-block, token-group; boundaries
10 at M=4/5, odd/even, M=15/16) are exercised on Blackwell, and the legacy
11 128/1 geometry everywhere else.
12 """
13
14 import pytest
15 import torch
16
17 from vllm._custom_ops import fp32_router_gemm
18 from vllm.platforms import current_platform
19
20 # (hidden_size, num_experts)
21 SHAPES = [(3072, 256), (6144, 128)]
22 ALL_M = list(range(1, 33))
23 # Absolute tolerance for fp32 kernel vs float64 reference
24 ATOL_FP32 = 2e-4
25 ATOL_BF16 = 2e-2 # bf16 activation has lower precision
26
27
28 def _requires_sm90():
29 # ROCm reports a CUDA-like device capability (gfx950 -> (9, 5)), which would
30 # pass the SM90 check below for a kernel that is only built for CUDA.
31 if not current_platform.is_cuda():
32 pytest.skip("fp32_router_gemm is built for CUDA only")
33 major, minor = torch.cuda.get_device_capability()
34 if major * 10 + minor < 90:
35 pytest.skip(f"fp32_router_gemm requires SM90+, got SM{major}{minor}")
36
37
38 def _ref(mat_a: torch.Tensor, mat_b: torch.Tensor) -> torch.Tensor:
39 """Reference: F.linear in float32 on GPU."""
40 return torch.nn.functional.linear(mat_a.float(), mat_b.float())
41
42
43 @pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
44 @pytest.mark.parametrize("num_tokens", ALL_M)
45 def test_fp32_activation(num_tokens: int, hidden_dim: int, num_experts: int):
46 """fp32 activation → fp32 output should match reference closely."""
47 _requires_sm90()
48 torch.manual_seed(42)
49 device = torch.device("cuda")
50 mat_a = torch.randn(num_tokens, hidden_dim, dtype=torch.float32, device=device)
51 mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
52
53 out = fp32_router_gemm(mat_a, mat_b)
54 ref = _ref(mat_a, mat_b)
55
56 assert out.shape == (num_tokens, num_experts)
57 assert out.dtype == torch.float32
58 torch.testing.assert_close(out, ref, atol=ATOL_FP32, rtol=0)
59
60
61 @pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
62 @pytest.mark.parametrize("num_tokens", ALL_M)
63 def test_bf16_activation(num_tokens: int, hidden_dim: int, num_experts: int):
64 """bf16 activation → fp32 output should match reference within bf16 error."""
65 _requires_sm90()
66 torch.manual_seed(42)
67 device = torch.device("cuda")
68 mat_a_bf16 = torch.randn(
69 num_tokens, hidden_dim, dtype=torch.bfloat16, device=device
70 )
71 mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
72
73 out = fp32_router_gemm(mat_a_bf16, mat_b)
74 ref = _ref(mat_a_bf16, mat_b).to(device)
75
76 assert out.shape == (num_tokens, num_experts)
77 assert out.dtype == torch.float32
78 torch.testing.assert_close(out, ref, atol=ATOL_BF16, rtol=0)
79
80
81 @pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
82 def test_output_shape_and_dtype(hidden_dim: int, num_experts: int):
83 """Basic shape and dtype checks."""
84 _requires_sm90()
85 device = torch.device("cuda")
86 mat_a = torch.randn(4, hidden_dim, dtype=torch.float32, device=device)
87 mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
88 out = fp32_router_gemm(mat_a, mat_b)
89 assert out.shape == (4, num_experts)
90 assert out.dtype == torch.float32
91 assert out.device.type == "cuda"
92
93
94 @pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
95 @pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 24, 32])
96 def test_topk_routing_consistency(num_tokens: int, hidden_dim: int, num_experts: int):
97 """The gate feeds top-k expert selection: the kernel's top-8 must match
98 an fp64 reference's top-8 per token (ties tolerated). This is the
99 business-level correctness of the router — numeric error only matters
100 if it flips the argsort."""
101 _requires_sm90()
102 top_k = 8
103 device = torch.device("cuda")
104 for seed in range(5):
105 torch.manual_seed(1000 + seed)
106 mat_a = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16, device=device)
107 mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
108 out = fp32_router_gemm(mat_a, mat_b)
109 ref = mat_a.double() @ mat_b.double().t()
110 kernel_idx = out.topk(top_k, dim=-1).indices
111 ref_vals, ref_idx = ref.topk(top_k, dim=-1)
112 for t in range(num_tokens):
113 got = set(kernel_idx[t].tolist())
114 want = set(ref_idx[t].tolist())
115 if got == want:
116 continue
117 # Tolerate genuine near-ties around the k-th value only.
118 kth = ref_vals[t, -1].item()
119 for e in got.symmetric_difference(want):
120 gap = abs(ref[t, e].item() - kth)
121 assert gap < 1e-3, (
122 f"top-{top_k} mismatch beyond tie tolerance: token {t}, "
123 f"expert {e}, gap {gap:.3e}"
124 )
125
126
127 def test_zero_tokens_returns_empty():
128 """M=0 is a graceful no-op returning an empty [0, E] tensor."""
129 _requires_sm90()
130 device = torch.device("cuda")
131 hidden_dim, num_experts = SHAPES[0]
132 mat_a = torch.empty(0, hidden_dim, dtype=torch.float32, device=device)
133 mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
134 out = fp32_router_gemm(mat_a, mat_b)
135 assert out.shape == (0, num_experts)
136 assert out.dtype == torch.float32
137
138
139 def test_rejects_invalid_inputs():
140 """The entry must fail loudly, never compute silently wrong results."""
141 _requires_sm90()
142 device = torch.device("cuda")
143 hidden_dim, num_experts = SHAPES[0]
144 mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
145
146 # num_tokens > 32 (beyond the instantiated range)
147 with pytest.raises(Exception, match="num_tokens"):
148 fp32_router_gemm(
149 torch.randn(33, hidden_dim, dtype=torch.float32, device=device), mat_b
150 )
151
152 # unsupported (hidden_dim, num_experts) pair
153 with pytest.raises(Exception, match="supported"):
154 fp32_router_gemm(
155 torch.randn(4, 1024, dtype=torch.float32, device=device),
156 torch.randn(64, 1024, dtype=torch.float32, device=device),
157 )
158
159 # non-contiguous activation (a column-slice view)
160 wide = torch.randn(4, hidden_dim * 2, dtype=torch.float32, device=device)
161 with pytest.raises(Exception, match="contiguous"):
162 fp32_router_gemm(wide[:, :hidden_dim], mat_b)
163
164 # wrong weight dtype (bf16 weight is not a supported layout)
165 with pytest.raises(Exception, match="float32"):
166 fp32_router_gemm(
167 torch.randn(4, hidden_dim, dtype=torch.float32, device=device),
168 mat_b.to(torch.bfloat16),
169 )
170
171 # fp16 activation (only fp32 / bf16 are accepted)
172 with pytest.raises(Exception, match="float32 or bfloat16"):
173 fp32_router_gemm(
174 torch.randn(4, hidden_dim, dtype=torch.float16, device=device), mat_b
175 )