FP32 router GEMV optimization (#48335)

FP32 router GEMV optimization (#48335) Signed-off-by: peiyuanz <peiyuanz@inferact.ai> Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai> Co-authored-by: peiyuanz <peiyuanz@inferact.ai> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: zhouzhou <zhouzhou@zhouzhoudeMacBook-Pro.local>

Jee Jee Li committed Jul 11, 2026 at 21:07 UTC 19069bcbd5be607a9c502e007951d8325d6a340e
4 files changed +284 -67
csrc/libtorch_stable/fp32_router_gemm.cu
+179 -56
@@ -1,13 +1,18 @@
1 // SPDX-License-Identifier: Apache-2.0
2 // SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 //
4 -// Router GEMM: activation(T) x weight(fp32) -> fp32, H=3072, E=256, M<=32.
4 +// Router GEMM: activation(T) x weight(fp32) -> fp32, M<=32, for the
5 +// supported (E, H) pairs listed at the bottom of this file.
6 // Supports bf16 or fp32 activation; weight is always fp32.
7 // Adapted from dsv3_router_gemm_float_out.cu.
8 +// (E=256, H=6144) bf16 uses a B300-tuned wide-block geometry; see
9 +// invokeFp32RouterGemm.
10
11 #include <cuda_bf16.h>
12 #include <cuda_runtime.h>
13
14 +#include <type_traits>
15 +
16 // ---------------------------------------------------------------------------
17 // Load helpers
18 // ---------------------------------------------------------------------------
@@ -73,94 +78,113 @@ __device__ __forceinline__ void load_activation<__nv_bfloat16, 8>(
78 // InputT : type of activation (float or __nv_bfloat16)
79 // Weight is always fp32; output is always fp32.
80 // VPT = 16 / sizeof(InputT): 4 for fp32, 8 for bf16
76 -template <typename InputT, int kBlockSize, int kNumTokens, int kNumExperts,
77 - int kHiddenDim>
78 -__global__ __launch_bounds__(128, 1) void fp32_router_gemm_kernel(
79 - float* out, InputT const* mat_a, float const* mat_b) {
81 +// Each block computes kEPB expert columns; wider blocks / kEPB > 1 are
82 +// selected per (shape, M) in invokeFp32RouterGemm (B300-tuned, see below).
83 +// kTGroups > 1 splits the tokens across groups of kBlockSize threads within
84 +// the block: all groups scan the same weight K-slices (group 0 misses to
85 +// DRAM, later groups hit L1) so weight traffic stays 1x, while per-thread
86 +// accumulator registers drop by kTGroups (at M=16 the 32 fp32 accumulators
87 +// push the kernel to 128 regs/thread and 1 block/SM).
88 +template <typename InputT, int kBlockSize, int kNumTokens, int kEPB,
89 + int kNumExperts, int kHiddenDim, int kTGroups = 1>
90 +__global__ __launch_bounds__(
91 + kBlockSize* kTGroups, 1) void fp32_router_gemm_kernel(float* out,
92 + InputT const* mat_a,
93 + float const* mat_b) {
94 constexpr int VPT = 16 / sizeof(InputT);
95 constexpr int k_elems_per_k_iteration = VPT * kBlockSize;
96 constexpr int k_iterations = kHiddenDim / k_elems_per_k_iteration;
97 + static_assert(kHiddenDim % k_elems_per_k_iteration == 0);
98 + static_assert(kNumTokens % kTGroups == 0);
99 constexpr int kWarpSize = 32;
84 - constexpr int kNumWarps = kBlockSize / kWarpSize;
100 + constexpr int kNumWarps = kBlockSize / kWarpSize; // per token group
101 + constexpr int kMG = kNumTokens / kTGroups; // tokens per group
102
86 - int const n_idx = blockIdx.x;
87 - int const tid = threadIdx.x;
103 + int const e_base = blockIdx.x * kEPB;
104 + int const tid = threadIdx.x % kBlockSize;
105 + int const m0 = (threadIdx.x / kBlockSize) * kMG;
106 int const warpId = tid / kWarpSize;
107 int const laneId = tid % kWarpSize;
108
91 - float acc[kNumTokens] = {};
92 - __shared__ float sm_reduction[kNumTokens][kNumWarps];
93 -
94 - float const* b_col = mat_b + n_idx * kHiddenDim;
95 -
96 - int k_bases[k_iterations];
97 -#pragma unroll
98 - for (int ki = 0; ki < k_iterations; ki++) {
99 - k_bases[ki] = ki * k_elems_per_k_iteration + tid * VPT;
100 - }
109 + float acc[kMG][kEPB] = {};
110 + __shared__ float sm_reduction[kNumTokens][kEPB][kNumWarps];
111
112 #if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
113 cudaGridDependencySynchronize();
114 + // Fire the PDL trigger right after our own wait instead of at kernel end:
115 + // a gridsync-ing consumer is unaffected (its wait always targets full grid
116 + // completion), while a consumer that reads none of our outputs (e.g. the
117 + // NVFP4 activation quant, which reads the same hidden_states) can launch
118 + // now and fully overlap this kernel's body.
119 + cudaTriggerProgrammaticLaunchCompletion();
120 #endif
121
122 +#pragma unroll
123 for (int ki = 0; ki < k_iterations; ki++) {
107 - int const k_base = k_bases[ki];
124 + int const k_base = ki * k_elems_per_k_iteration + tid * VPT;
125
109 - float b_float[VPT];
110 - load_weight<VPT>(b_col + k_base, b_float);
126 + float b_float[kEPB][VPT];
127 +#pragma unroll
128 + for (int e = 0; e < kEPB; e++) {
129 + load_weight<VPT>(mat_b + (e_base + e) * kHiddenDim + k_base, b_float[e]);
130 + }
131
132 #pragma unroll
113 - for (int m_idx = 0; m_idx < kNumTokens; m_idx++) {
133 + for (int m_idx = 0; m_idx < kMG; m_idx++) {
134 float a_float[VPT];
115 - load_activation<InputT, VPT>(mat_a + m_idx * kHiddenDim + k_base,
116 - a_float);
135 + load_activation<InputT, VPT>(
136 + mat_a + (size_t)(m0 + m_idx) * kHiddenDim + k_base, a_float);
137 +#pragma unroll
138 + for (int e = 0; e < kEPB; e++) {
139 #pragma unroll
118 - for (int k = 0; k < VPT; k++) {
119 - acc[m_idx] += a_float[k] * b_float[k];
140 + for (int k = 0; k < VPT; k++) {
141 + acc[m_idx][e] += a_float[k] * b_float[e][k];
142 + }
143 }
144 }
145 }
146
147 // Warp-level butterfly reduction
148 #pragma unroll
126 - for (int m = 0; m < kNumTokens; m++) {
127 - float sum = acc[m];
128 - sum += __shfl_xor_sync(0xffffffff, sum, 16);
129 - sum += __shfl_xor_sync(0xffffffff, sum, 8);
130 - sum += __shfl_xor_sync(0xffffffff, sum, 4);
131 - sum += __shfl_xor_sync(0xffffffff, sum, 2);
132 - sum += __shfl_xor_sync(0xffffffff, sum, 1);
133 - if (laneId == 0) sm_reduction[m][warpId] = sum;
149 + for (int m = 0; m < kMG; m++) {
150 +#pragma unroll
151 + for (int e = 0; e < kEPB; e++) {
152 + float sum = acc[m][e];
153 + sum += __shfl_xor_sync(0xffffffff, sum, 16);
154 + sum += __shfl_xor_sync(0xffffffff, sum, 8);
155 + sum += __shfl_xor_sync(0xffffffff, sum, 4);
156 + sum += __shfl_xor_sync(0xffffffff, sum, 2);
157 + sum += __shfl_xor_sync(0xffffffff, sum, 1);
158 + if (laneId == 0) sm_reduction[m0 + m][e][warpId] = sum;
159 + }
160 }
161
162 __syncthreads();
163
138 - if (tid == 0) {
139 -#pragma unroll
140 - for (int m = 0; m < kNumTokens; m++) {
141 - float final_sum = 0.0f;
164 + // Parallel finalize: one thread per (m, e) output.
165 + for (int idx = threadIdx.x; idx < kNumTokens * kEPB;
166 + idx += kBlockSize * kTGroups) {
167 + int const m = idx / kEPB;
168 + int const e = idx % kEPB;
169 + float final_sum = 0.0f;
170 #pragma unroll
143 - for (int w = 0; w < kNumWarps; w++) final_sum += sm_reduction[m][w];
144 - out[m * kNumExperts + n_idx] = final_sum;
145 - }
171 + for (int w = 0; w < kNumWarps; w++) final_sum += sm_reduction[m][e][w];
172 + out[m * kNumExperts + e_base + e] = final_sum;
173 }
147 -
148 -#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
149 - cudaTriggerProgrammaticLaunchCompletion();
150 -#endif
174 }
175
176 // ---------------------------------------------------------------------------
177 // Launcher
178 // ---------------------------------------------------------------------------
179
157 -template <typename InputT, int kNumTokens, int kNumExperts, int kHiddenDim>
158 -void invokeFp32RouterGemm(float* output, InputT const* mat_a,
159 - float const* mat_b, cudaStream_t stream) {
160 - constexpr int kBlockSize = 128;
180 +template <typename InputT, int kBlockSize, int kEPB, int kNumTokens,
181 + int kNumExperts, int kHiddenDim, int kTGroups = 1>
182 +static void launchFp32RouterGemm(float* output, InputT const* mat_a,
183 + float const* mat_b, cudaStream_t stream) {
184 + static_assert(kNumExperts % kEPB == 0);
185 cudaLaunchConfig_t config;
162 - config.gridDim = kNumExperts;
163 - config.blockDim = kBlockSize;
186 + config.gridDim = kNumExperts / kEPB;
187 + config.blockDim = kBlockSize * kTGroups;
188 config.dynamicSmemBytes = 0;
189 config.stream = stream;
190 cudaLaunchAttribute attrs[1];
@@ -168,15 +192,112 @@ void invokeFp32RouterGemm(float* output, InputT const* mat_a,
192 attrs[0].val.programmaticStreamSerializationAllowed = 1;
193 config.numAttrs = 1;
194 config.attrs = attrs;
171 - cudaLaunchKernelEx(&config,
172 - fp32_router_gemm_kernel<InputT, kBlockSize, kNumTokens,
173 - kNumExperts, kHiddenDim>,
174 - output, mat_a, mat_b);
195 + cudaLaunchKernelEx(
196 + &config,
197 + fp32_router_gemm_kernel<InputT, kBlockSize, kNumTokens, kEPB, kNumExperts,
198 + kHiddenDim, kTGroups>,
199 + output, mat_a, mat_b);
200 +}
201 +
202 +static bool isBlackwellFamily() {
203 + static int sm = []() {
204 + int dev = 0, major = 0, minor = 0;
205 + cudaGetDevice(&dev);
206 + cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev);
207 + cudaDeviceGetAttribute(&minor, cudaDevAttrComputeCapabilityMinor, dev);
208 + return major * 10 + minor;
209 + }();
210 + return sm >= 100;
211 +}
212 +
213 +template <typename InputT, int kNumTokens, int kNumExperts, int kHiddenDim>
214 +void invokeFp32RouterGemm(float* output, InputT const* mat_a,
215 + float const* mat_b, cudaStream_t stream) {
216 + // Geometry tuned on B300 per supported shape, bf16 activation, under a
217 + // production-fidelity harness (CUDA-graph replay, per-layer cold weights).
218 + // GLM-5.2 (E=256, H=6144):
219 + // M <= 4 : BS=768, EPB=1 (2.7us vs cast+cuBLAS 8.1us at M=1)
220 + // M in [5, 15]
221 + // or odd : BS=384, EPB=2 (crossover vs BS=768 measured in (4, 8))
222 + // M >= 16, even : BS=192, EPB=2, 2 token groups (M=16 4.79us vs 5.04,
223 + // M=24 5.71 vs 6.38, M=32 6.79 vs 7.72; M=12 loses at
224 + // 0.97x, so the boundary is 16).
225 + // Only enabled on the Blackwell family where it was validated; Hopper and
226 + // other shapes / fp32 activation keep the legacy geometry.
227 + if constexpr (std::is_same_v<InputT, __nv_bfloat16> && kNumExperts == 256 &&
228 + kHiddenDim == 6144) {
229 + if (!isBlackwellFamily()) {
230 + launchFp32RouterGemm<InputT, 128, 1, kNumTokens, kNumExperts, kHiddenDim>(
231 + output, mat_a, mat_b, stream);
232 + return;
233 + }
234 + if constexpr (kNumTokens <= 4) {
235 + launchFp32RouterGemm<InputT, 768, 1, kNumTokens, kNumExperts, kHiddenDim>(
236 + output, mat_a, mat_b, stream);
237 + } else if constexpr (kNumTokens >= 16 && kNumTokens % 2 == 0) {
238 + launchFp32RouterGemm<InputT, 192, 2, kNumTokens, kNumExperts, kHiddenDim,
239 + 2>(output, mat_a, mat_b, stream);
240 + } else {
241 + launchFp32RouterGemm<InputT, 384, 2, kNumTokens, kNumExperts, kHiddenDim>(
242 + output, mat_a, mat_b, stream);
243 + }
244 + } else if constexpr (std::is_same_v<InputT, __nv_bfloat16> &&
245 + kNumExperts == 128 && kHiddenDim == 6144) {
246 + // MiniMax-M3. Legacy 128/1 only fills 128 blocks and pays the same
247 + // accumulator register cliffs; B300 sweep:
248 + // even M in [6, 10] : BS=384, EPB=1, 2 token groups (1.26-1.43x)
249 + // even M >= 12 : BS=192, EPB=1, 2 token groups (1.59-1.66x at
250 + // M >= 18; re-measured on B300+B200: 192 also wins
251 + // M=12/14 by 5-11%% on both, ties 384 at 16)
252 + // M <= 5 / odd : BS=384, EPB=1 (1.03-1.19x)
253 + if (!isBlackwellFamily()) {
254 + launchFp32RouterGemm<InputT, 128, 1, kNumTokens, kNumExperts, kHiddenDim>(
255 + output, mat_a, mat_b, stream);
256 + return;
257 + }
258 + if constexpr (kNumTokens >= 12 && kNumTokens % 2 == 0) {
259 + launchFp32RouterGemm<InputT, 192, 1, kNumTokens, kNumExperts, kHiddenDim,
260 + 2>(output, mat_a, mat_b, stream);
261 + } else if constexpr (kNumTokens >= 6 && kNumTokens % 2 == 0) {
262 + launchFp32RouterGemm<InputT, 384, 1, kNumTokens, kNumExperts, kHiddenDim,
263 + 2>(output, mat_a, mat_b, stream);
264 + } else {
265 + launchFp32RouterGemm<InputT, 384, 1, kNumTokens, kNumExperts, kHiddenDim>(
266 + output, mat_a, mat_b, stream);
267 + }
268 + } else if constexpr (std::is_same_v<InputT, __nv_bfloat16> &&
269 + kNumExperts == 256 && kHiddenDim == 3072) {
270 + // MiniMax-M2/M2.5. The 3.1MB weight is latency-floor bound at small M
271 + // (legacy already optimal); token groups win only at even M >= 8
272 + // (1.05-1.17x). EPB crossover measured between 12 and 16.
273 + if (!isBlackwellFamily()) {
274 + launchFp32RouterGemm<InputT, 128, 1, kNumTokens, kNumExperts, kHiddenDim>(
275 + output, mat_a, mat_b, stream);
276 + return;
277 + }
278 + if constexpr (kNumTokens >= 14 && kNumTokens % 2 == 0) {
279 + // M=14 originally measured 0.91x and stayed on legacy; two fresh
280 + // sweeps (B300 dev1 + B200) both put 192/2/tg2 ahead by 3.5-4%%.
281 + launchFp32RouterGemm<InputT, 192, 2, kNumTokens, kNumExperts, kHiddenDim,
282 + 2>(output, mat_a, mat_b, stream);
283 + } else if constexpr (kNumTokens >= 8 && kNumTokens <= 12 &&
284 + kNumTokens % 2 == 0) {
285 + launchFp32RouterGemm<InputT, 192, 1, kNumTokens, kNumExperts, kHiddenDim,
286 + 2>(output, mat_a, mat_b, stream);
287 + } else {
288 + launchFp32RouterGemm<InputT, 128, 1, kNumTokens, kNumExperts, kHiddenDim>(
289 + output, mat_a, mat_b, stream);
290 + }
291 + } else {
292 + launchFp32RouterGemm<InputT, 128, 1, kNumTokens, kNumExperts, kHiddenDim>(
293 + output, mat_a, mat_b, stream);
294 + }
295 }
296
297 // ---------------------------------------------------------------------------
298 // Explicit instantiations: M=1..32, for both input types, for the supported
179 -// (E, H) pairs: (256, 3072) [MiniMax-M2/M2.5] and (128, 6144) [MiniMax-M3].
299 +// (E, H) pairs: (256, 3072) [MiniMax-M2/M2.5], (128, 6144) [MiniMax-M3]
300 +// and (256, 6144) [GLM-5.2].
301 // ---------------------------------------------------------------------------
302
303 #define INSTANTIATE(T, M, E, H) \
@@ -221,6 +342,8 @@ INSTANTIATE_ALL(float, 256, 3072)
342 INSTANTIATE_ALL(__nv_bfloat16, 256, 3072)
343 INSTANTIATE_ALL(float, 128, 6144)
344 INSTANTIATE_ALL(__nv_bfloat16, 128, 6144)
345 +INSTANTIATE_ALL(float, 256, 6144)
346 +INSTANTIATE_ALL(__nv_bfloat16, 256, 6144)
347
348 #undef INSTANTIATE_ALL
349 #undef INSTANTIATE
csrc/libtorch_stable/fp32_router_gemm_entry.cu
+8 -3
@@ -25,10 +25,12 @@ inline int getSMVersion() {
25 static constexpr int FP32_MAX_TOKENS = 32;
26
27 // Supported (hidden_dim, num_experts) pairs (must match the instantiations in
28 -// fp32_router_gemm.cu): (3072, 256) for MiniMax-M2/M2.5, (6144, 128) for M3.
28 +// fp32_router_gemm.cu): (3072, 256) for MiniMax-M2/M2.5, (6144, 128) for M3,
29 +// (6144, 256) for GLM-5.2.
30 static inline bool fp32_router_gemm_supported(int hidden_dim, int num_experts) {
31 return (hidden_dim == 3072 && num_experts == 256) ||
31 - (hidden_dim == 6144 && num_experts == 128);
32 + (hidden_dim == 6144 && num_experts == 128) ||
33 + (hidden_dim == 6144 && num_experts == 256);
34 }
35
36 // Forward declarations — 4 template params must match fp32_router_gemm.cu
@@ -77,6 +79,9 @@ void dispatchFp32RouterGemm(int num_experts, int hidden_dim, int num_tokens,
79 } else if (num_experts == 128 && hidden_dim == 6144) {
80 Fp32LoopUnroller<InputT, 128, 6144, 1, FP32_MAX_TOKENS>::unroll(
81 num_tokens, output, mat_a, mat_b, stream);
82 + } else if (num_experts == 256 && hidden_dim == 6144) {
83 + Fp32LoopUnroller<InputT, 256, 6144, 1, FP32_MAX_TOKENS>::unroll(
84 + num_tokens, output, mat_a, mat_b, stream);
85 } else {
86 throw std::invalid_argument(
87 "fp32_router_gemm: unsupported (hidden_dim, num_experts) pair");
@@ -111,7 +116,7 @@ void fp32_router_gemm(
116 STD_TORCH_CHECK(
117 fp32_router_gemm_supported(hidden_dim, num_experts),
118 "fp32_router_gemm: supported (hidden_dim, num_experts) pairs are "
114 - "(3072, 256) and (6144, 128)");
119 + "(3072, 256), (6144, 128) and (6144, 256)");
120 STD_TORCH_CHECK(num_tokens <= FP32_MAX_TOKENS,
121 "fp32_router_gemm: num_tokens must be in [0, 32]");
122 STD_TORCH_CHECK(
tests/kernels/test_fp32_router_gemm.py
+94 -5
@@ -3,9 +3,13 @@
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
6 + (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3,
7 + (6144, 256) -> GLM-5.2
8
8 -Correctness baseline: torch.matmul in float64.
9 +Correctness baseline: F.linear in float32. Every M in [1, 32] is covered so
10 +all tuned geometries (wide-block, experts-per-block, token-group; boundaries
11 +at M=4/5, odd/even, M=15/16) are exercised on Blackwell, and the legacy
12 +128/1 geometry everywhere else.
13 """
14
15 import pytest
@@ -14,7 +18,8 @@ import torch
18 from vllm._custom_ops import fp32_router_gemm
19
20 # (hidden_size, num_experts)
17 -SHAPES = [(3072, 256), (6144, 128)]
21 +SHAPES = [(3072, 256), (6144, 128), (6144, 256)]
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
@@ -34,7 +39,7 @@ def _ref(mat_a: torch.Tensor, mat_b: torch.Tensor) -> torch.Tensor:
39
40
41 @pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
37 -@pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 32])
42 +@pytest.mark.parametrize("num_tokens", ALL_M)
43 def test_fp32_activation(num_tokens: int, hidden_dim: int, num_experts: int):
44 """fp32 activation → fp32 output should match reference closely."""
45 _requires_sm90()
@@ -52,7 +57,7 @@ def test_fp32_activation(num_tokens: int, hidden_dim: int, num_experts: int):
57
58
59 @pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
55 -@pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 32])
60 +@pytest.mark.parametrize("num_tokens", ALL_M)
61 def test_bf16_activation(num_tokens: int, hidden_dim: int, num_experts: int):
62 """bf16 activation → fp32 output should match reference within bf16 error."""
63 _requires_sm90()
@@ -82,3 +87,87 @@ def test_output_shape_and_dtype(hidden_dim: int, num_experts: int):
87 assert out.shape == (4, num_experts)
88 assert out.dtype == torch.float32
89 assert out.device.type == "cuda"
90 +
91 +
92 +@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
93 +@pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 24, 32])
94 +def test_topk_routing_consistency(num_tokens: int, hidden_dim: int, num_experts: int):
95 + """The gate feeds top-k expert selection: the kernel's top-8 must match
96 + an fp64 reference's top-8 per token (ties tolerated). This is the
97 + business-level correctness of the router — numeric error only matters
98 + if it flips the argsort."""
99 + _requires_sm90()
100 + top_k = 8
101 + device = torch.device("cuda")
102 + for seed in range(5):
103 + torch.manual_seed(1000 + seed)
104 + mat_a = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16, device=device)
105 + mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
106 + out = fp32_router_gemm(mat_a, mat_b)
107 + ref = mat_a.double() @ mat_b.double().t()
108 + kernel_idx = out.topk(top_k, dim=-1).indices
109 + ref_vals, ref_idx = ref.topk(top_k, dim=-1)
110 + for t in range(num_tokens):
111 + got = set(kernel_idx[t].tolist())
112 + want = set(ref_idx[t].tolist())
113 + if got == want:
114 + continue
115 + # Tolerate genuine near-ties around the k-th value only.
116 + kth = ref_vals[t, -1].item()
117 + for e in got.symmetric_difference(want):
118 + gap = abs(ref[t, e].item() - kth)
119 + assert gap < 1e-3, (
120 + f"top-{top_k} mismatch beyond tie tolerance: token {t}, "
121 + f"expert {e}, gap {gap:.3e}"
122 + )
123 +
124 +
125 +def test_zero_tokens_returns_empty():
126 + """M=0 is a graceful no-op returning an empty [0, E] tensor."""
127 + _requires_sm90()
128 + device = torch.device("cuda")
129 + hidden_dim, num_experts = SHAPES[0]
130 + mat_a = torch.empty(0, hidden_dim, dtype=torch.float32, device=device)
131 + mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
132 + out = fp32_router_gemm(mat_a, mat_b)
133 + assert out.shape == (0, num_experts)
134 + assert out.dtype == torch.float32
135 +
136 +
137 +def test_rejects_invalid_inputs():
138 + """The entry must fail loudly, never compute silently wrong results."""
139 + _requires_sm90()
140 + device = torch.device("cuda")
141 + hidden_dim, num_experts = SHAPES[0]
142 + mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
143 +
144 + # num_tokens > 32 (beyond the instantiated range)
145 + with pytest.raises(Exception, match="num_tokens"):
146 + fp32_router_gemm(
147 + torch.randn(33, hidden_dim, dtype=torch.float32, device=device), mat_b
148 + )
149 +
150 + # unsupported (hidden_dim, num_experts) pair
151 + with pytest.raises(Exception, match="supported"):
152 + fp32_router_gemm(
153 + torch.randn(4, 1024, dtype=torch.float32, device=device),
154 + torch.randn(64, 1024, dtype=torch.float32, device=device),
155 + )
156 +
157 + # non-contiguous activation (a column-slice view)
158 + wide = torch.randn(4, hidden_dim * 2, dtype=torch.float32, device=device)
159 + with pytest.raises(Exception, match="contiguous"):
160 + fp32_router_gemm(wide[:, :hidden_dim], mat_b)
161 +
162 + # wrong weight dtype (bf16 weight is not a supported layout)
163 + with pytest.raises(Exception, match="float32"):
164 + fp32_router_gemm(
165 + torch.randn(4, hidden_dim, dtype=torch.float32, device=device),
166 + mat_b.to(torch.bfloat16),
167 + )
168 +
169 + # fp16 activation (only fp32 / bf16 are accepted)
170 + with pytest.raises(Exception, match="float32 or bfloat16"):
171 + fp32_router_gemm(
172 + torch.randn(4, hidden_dim, dtype=torch.float16, device=device), mat_b
173 + )
vllm/model_executor/layers/fused_moe/router/gate_linear.py
+3 -3
@@ -15,8 +15,8 @@ class GateLinear(ReplicatedLinear):
15 """MoE gate linear layer with multi-tier GEMM dispatch:
16
17 1. DSV3 specialized kernel (SM90+, M<=16, H=7168 E=256/384, H=6144 E=256)
18 - 2. fp32 specialized kernel (SM90+, bf16/fp32 in, fp32 out,
19 - M<=32, H=3072, E=256)
18 + 2. fp32 specialized kernel (SM90+, bf16/fp32 in, fp32 out, M<=32,
19 + (H, E) in {(3072, 256), (6144, 128), (6144, 256)})
20 3. cuBLAS bf16×bf16→fp32 (SM90+ + bf16 weight + fp32 out_dtype)
21 4. F.linear via ReplicatedLinear (ultimate fallback)
22
@@ -36,7 +36,7 @@ class GateLinear(ReplicatedLinear):
36
37 # (hidden_size, num_experts) pairs with an instantiated fp32 kernel:
38 # (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3
39 - FP32_SUPPORTED_SHAPES = {(3072, 256), (6144, 128)}
39 + FP32_SUPPORTED_SHAPES = {(3072, 256), (6144, 128), (6144, 256)}
40 FP32_MAX_TOKENS = 32
41
42 def __init__(