[Bugfix] Handle padded routes in CUTLASS MoE permutations (#54747)

Signed-off-by: Kevin Luu <51931015+khluu@users.noreply.github.com> Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Yongye Zhu <zyy1102000@gmail.com>

Kevin H. Luu committed Sep 1, 2026 at 19:08 UTC 46c8a161f40cb8fbae0b9fd00552c9d2589fb8dc
6 files changed +216 -21
csrc/libtorch_stable/moe/moe_permute_unpermute_op.cu
+14 -7
@@ -212,23 +212,30 @@ __global__ void shuffleInputRowsKernel(const T* input,
212 int64_t num_src_rows,
213 int64_t num_dst_rows, int64_t num_cols) {
214 int64_t dest_row_idx = blockIdx.x;
215 - int64_t const source_row_idx = dst2src_map[dest_row_idx];
216 -
215 if (blockIdx.x < num_dst_rows) {
216 + int64_t const source_row_idx = dst2src_map[dest_row_idx];
217 // Load 128-bits per thread
218 constexpr int64_t ELEM_PER_THREAD = 128 / sizeof(T) / 8;
219 using DataElem = cutlass::Array<T, ELEM_PER_THREAD>;
220
222 - // Duplicate and permute rows
223 - auto const* source_row_ptr =
224 - reinterpret_cast<DataElem const*>(input + source_row_idx * num_cols);
221 auto* dest_row_ptr =
222 reinterpret_cast<DataElem*>(output + dest_row_idx * num_cols);
227 -
223 int64_t const start_offset = threadIdx.x;
224 int64_t const stride = blockDim.x;
225 int64_t const num_elems_in_col = num_cols / ELEM_PER_THREAD;
226
227 + if (source_row_idx < 0 || source_row_idx >= num_src_rows) {
228 + DataElem const zero{};
229 + for (int elem_index = start_offset; elem_index < num_elems_in_col;
230 + elem_index += stride) {
231 + dest_row_ptr[elem_index] = zero;
232 + }
233 + return;
234 + }
235 +
236 + // Duplicate and permute rows
237 + auto const* source_row_ptr =
238 + reinterpret_cast<DataElem const*>(input + source_row_idx * num_cols);
239 for (int elem_index = start_offset; elem_index < num_elems_in_col;
240 elem_index += stride) {
241 dest_row_ptr[elem_index] = source_row_ptr[elem_index];
@@ -322,4 +329,4 @@ STABLE_TORCH_LIBRARY_IMPL(_moe_C, CUDA, m) {
329 m.impl("moe_permute", TORCH_BOX(&moe_permute));
330 m.impl("moe_permute_with_scratch", TORCH_BOX(&moe_permute_with_scratch));
331 m.impl("moe_unpermute", TORCH_BOX(&moe_unpermute));
325 -}
\ No newline at end of file
332 +}
csrc/libtorch_stable/quantization/fp4/mxfp4_experts_quant.cu
+7 -2
@@ -76,9 +76,12 @@ __global__ void __launch_bounds__(512, VLLM_BLOCKS_PER_SM(512))
76
77 int tid = blockIdx.x * blockDim.x + threadIdx.x;
78 int colsPerRow = numCols / CVT_FP4_ELTS_PER_THREAD;
79 + int32_t const numValidRows =
80 + min(numRows,
81 + static_cast<int32_t>(__ldca(&input_offset_by_experts[n_experts])));
82 int inColsPerRow = FUSE_SILU_MUL ? colsPerRow * 2 : colsPerRow;
83
81 - for (int globalIdx = tid; globalIdx < numRows * colsPerRow;
84 + for (int globalIdx = tid; globalIdx < numValidRows * colsPerRow;
85 globalIdx += gridDim.x * blockDim.x) {
86 int rowIdx = globalIdx / colsPerRow;
87 int colIdx = globalIdx % colsPerRow;
@@ -193,9 +196,11 @@ __global__ void __launch_bounds__(1024, VLLM_BLOCKS_PER_SM(1024))
196
197 int tid = blockIdx.x * blockDim.x + threadIdx.x;
198 int colsPerRow = numCols / CVT_FP4_ELTS_PER_THREAD;
199 + int32_t const numValidRows =
200 + min(numRows, static_cast<int32_t>(shared_input_offsets[n_experts]));
201 int inColsPerRow = FUSE_SILU_MUL ? colsPerRow * 2 : colsPerRow;
202
198 - for (int globalIdx = tid; globalIdx < numRows * colsPerRow;
203 + for (int globalIdx = tid; globalIdx < numValidRows * colsPerRow;
204 globalIdx += gridDim.x * blockDim.x) {
205 int rowIdx = globalIdx / colsPerRow;
206 int colIdx = globalIdx % colsPerRow;
csrc/libtorch_stable/quantization/fp4/nvfp4_experts_quant.cu
+7 -2
@@ -53,11 +53,14 @@ __global__ void __launch_bounds__(512, VLLM_BLOCKS_PER_SM(512))
53
54 int tid = blockIdx.x * blockDim.x + threadIdx.x;
55 int colsPerRow = numCols / CVT_FP4_ELTS_PER_THREAD;
56 + int32_t const numValidRows =
57 + min(numRows,
58 + static_cast<int32_t>(__ldca(&input_offset_by_experts[n_experts])));
59 // When fusing SiLU+Mul, input has gate || up layout (doubled width)
60 int inColsPerRow = FUSE_SILU_MUL ? colsPerRow * 2 : colsPerRow;
61
62 // Each global thread processes one element
60 - for (int globalIdx = tid; globalIdx < numRows * colsPerRow;
63 + for (int globalIdx = tid; globalIdx < numValidRows * colsPerRow;
64 globalIdx += gridDim.x * blockDim.x) {
65 // Calculate which row and column this global thread should process
66 int rowIdx = globalIdx / colsPerRow;
@@ -186,11 +189,13 @@ __global__ void __launch_bounds__(1024, VLLM_BLOCKS_PER_SM(1024))
189
190 int tid = blockIdx.x * blockDim.x + threadIdx.x;
191 int colsPerRow = numCols / CVT_FP4_ELTS_PER_THREAD;
192 + int32_t const numValidRows =
193 + min(numRows, static_cast<int32_t>(shared_input_offsets[n_experts]));
194 // When fusing SiLU+Mul, input has gate || up layout (doubled width)
195 int inColsPerRow = FUSE_SILU_MUL ? colsPerRow * 2 : colsPerRow;
196
197 // Each global thread processes one element
193 - for (int globalIdx = tid; globalIdx < numRows * colsPerRow;
198 + for (int globalIdx = tid; globalIdx < numValidRows * colsPerRow;
199 globalIdx += gridDim.x * blockDim.x) {
200 // Calculate which row and column this global thread should process
201 int rowIdx = globalIdx / colsPerRow;
csrc/libtorch_stable/quantization/w8a8/cutlass/moe/moe_data.cu
+10 -10
@@ -62,6 +62,7 @@ __global__ void compute_expert_offsets(
62 tot_offset += swap_ab ? problem_sizes1[i * 3 + 1] : problem_sizes1[i * 3];
63 expert_offsets[i + 1] = tot_offset;
64 }
65 + atomic_buffer[num_experts] = tot_offset;
66 }
67
68 __global__ void compute_expert_blockscale_offsets(
@@ -81,28 +82,28 @@ __global__ void compute_expert_blockscale_offsets(
82 tot_offset_round += (cur_offset + (128 - 1)) / 128 * 128;
83 blockscale_offsets[i + 1] = tot_offset_round;
84 }
85 + atomic_buffer[num_experts] = tot_offset;
86 }
87
88 __global__ void compute_arg_sorts(const int32_t* __restrict__ topk_ids,
87 - const int32_t* __restrict__ expert_offsets,
89 int32_t* input_permutation,
90 int32_t* output_permutation,
91 int32_t* atomic_buffer, const int topk_length,
92 const int topk) {
93 int const blk_expert_id = blockIdx.x;
94 int const num_experts = gridDim.x;
94 - int32_t const num_tokens = expert_offsets[num_experts];
95
96 for (int i = threadIdx.x; i < topk_length; i += THREADS_PER_EXPERT) {
97 int const expert_id = topk_ids[i];
98 - if (expert_id == -1 && blockIdx.x == 0) {
98 + if ((expert_id < 0 || expert_id >= num_experts) && blockIdx.x == 0) {
99 + int const start = atomicAdd(&atomic_buffer[num_experts], 1);
100 + input_permutation[start] = -1;
101 // output_permutation is used to re-order the moe outputs. It is
102 // used as c2 = c2[c_map], where c2 is a torch.tensor that is the
101 - // output of the cutlass kernels and c_map is the output_permutation.
102 - // c2 is initialized to zeros, therefore by setting the output_permutation
103 - // to num_tokens, we are guaranteed to fill the moe outputs to zero
104 - // for "invalid" topk_ids.
105 - output_permutation[i] = num_tokens;
103 + // output of the cutlass kernels and c_map is the output_permutation. Use
104 + // out-of-range sentinels for invalid routes; shuffle_rows converts them
105 + // to zero rows.
106 + output_permutation[i] = topk_length;
107 } else if (expert_id == blk_expert_id) {
108 int start = atomicAdd(&atomic_buffer[expert_id], 1);
109 input_permutation[start] = i / topk;
@@ -246,7 +247,7 @@ void get_cutlass_moe_mm_data_caller(
247 const torch::stable::accelerator::DeviceGuard device_guard(device.index());
248 auto stream = get_current_cuda_stream(device.index());
249 torch::stable::Tensor atomic_buffer = torch::stable::new_zeros(
249 - topk_ids, {num_experts}, torch::headeronly::ScalarType::Int);
250 + topk_ids, {num_experts + 1}, torch::headeronly::ScalarType::Int);
251
252 int num_threads = min(THREADS_PER_EXPERT, topk_ids.numel());
253
@@ -275,7 +276,6 @@ void get_cutlass_moe_mm_data_caller(
276 }
277 compute_arg_sorts<<<num_experts, num_threads, 0, stream>>>(
278 static_cast<const int32_t*>(topk_ids.data_ptr()),
278 - static_cast<const int32_t*>(expert_offsets.data_ptr()),
279 static_cast<int32_t*>(input_permutation.data_ptr()),
280 static_cast<int32_t*>(output_permutation.data_ptr()),
281 static_cast<int32_t*>(atomic_buffer.data_ptr()), topk_ids.numel(),
tests/kernels/moe/test_cutlass_moe.py
+163
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import copy
4 import dataclasses
5 +from collections.abc import Callable
6 from math import prod
7
8 import pytest
@@ -33,7 +34,9 @@ from vllm.model_executor.layers.fused_moe.experts.cutlass_moe import (
34 CutlassExpertsFp8,
35 CutlassExpertsMxfp4,
36 CutlassExpertsW4A8Fp8,
37 + run_cutlass_moe_fp4,
38 run_cutlass_moe_fp8,
39 + run_cutlass_moe_mxfp4,
40 )
41 from vllm.model_executor.layers.fused_moe.oracle import nvfp4 as nvfp4_oracle
42 from vllm.model_executor.layers.fused_moe.utils import moe_kernel_quantize_input
@@ -131,6 +134,166 @@ def test_nvfp4_clamp_allows_shared_activation_backends(
134 assert selected == expected
135
136
137 +@pytest.mark.skipif(
138 + (lambda x: x is None or not ops.cutlass_group_gemm_supported(x.to_int()))(
139 + current_platform.get_device_capability()
140 + ),
141 + reason="Grouped gemm is not supported on this GPU type.",
142 +)
143 +def test_cutlass_moe_permutation_maps_padding_to_zero():
144 + """Invalid top-k routes must not leave unsafe permutation-map entries."""
145 + num_experts = 2
146 + topk_ids = torch.tensor(
147 + [[0, -1], [1, num_experts]], device="cuda", dtype=torch.int32
148 + )
149 + num_routes = topk_ids.numel()
150 +
151 + expert_offsets = torch.empty(num_experts + 1, device="cuda", dtype=torch.int32)
152 + blockscale_offsets = torch.empty_like(expert_offsets)
153 + problem_sizes1 = torch.empty(num_experts, 3, device="cuda", dtype=torch.int32)
154 + problem_sizes2 = torch.empty_like(problem_sizes1)
155 + input_permutation = torch.empty(num_routes, device="cuda", dtype=torch.int32)
156 + output_permutation = torch.empty_like(input_permutation)
157 +
158 + ops.get_cutlass_moe_mm_data(
159 + topk_ids,
160 + expert_offsets,
161 + problem_sizes1,
162 + problem_sizes2,
163 + input_permutation,
164 + output_permutation,
165 + num_experts,
166 + 128,
167 + 128,
168 + blockscale_offsets,
169 + )
170 +
171 + input_tensor = torch.randn(2, 128, device="cuda", dtype=torch.bfloat16)
172 + permuted = ops.shuffle_rows(input_tensor, input_permutation)
173 + restored = ops.shuffle_rows(permuted, output_permutation)
174 +
175 + torch.testing.assert_close(permuted[:2], input_tensor)
176 + torch.testing.assert_close(permuted[2:], torch.zeros_like(permuted[2:]))
177 + torch.testing.assert_close(restored[0], input_tensor[0])
178 + torch.testing.assert_close(restored[1], torch.zeros_like(restored[1]))
179 + torch.testing.assert_close(restored[2], input_tensor[1])
180 + torch.testing.assert_close(restored[3], torch.zeros_like(restored[3]))
181 +
182 +
183 +@pytest.mark.parametrize("quantization", ["nvfp4", "mxfp4"])
184 +@torch.inference_mode()
185 +def test_cutlass_fp4_moe_padded_routes_do_not_change_valid_output(quantization: str):
186 + """Padded routes must not participate in either activation quantization."""
187 + experts_cls = CutlassExpertsFp4 if quantization == "nvfp4" else CutlassExpertsMxfp4
188 + if not experts_cls._supports_current_device():
189 + pytest.skip(f"CUTLASS {quantization} MoE is not supported on this GPU")
190 +
191 + set_random_seed(7)
192 + num_experts, n, k = 4, 128, 128
193 + dtype = torch.bfloat16
194 + device = torch.device("cuda")
195 + w1 = torch.full(
196 + (num_experts, 2 * n, k // 2), 0x11, device=device, dtype=torch.uint8
197 + )
198 + w2 = torch.full((num_experts, k, n // 2), 0x11, device=device, dtype=torch.uint8)
199 +
200 + runner: Callable[..., None]
201 + quant_args: tuple[torch.Tensor, ...]
202 + if quantization == "nvfp4":
203 + scale_kwargs = {
204 + "device": device,
205 + "dtype": torch.float8_e4m3fn,
206 + }
207 + w1_scale = torch.full((num_experts, 2 * n, k // 16), 1 / 16, **scale_kwargs)
208 + w2_scale = torch.full((num_experts, k, n // 16), 1 / 16, **scale_kwargs)
209 + gscale = torch.ones(num_experts, device=device, dtype=torch.float32)
210 + runner = run_cutlass_moe_fp4
211 + quant_args = (
212 + gscale,
213 + w1,
214 + w1_scale,
215 + gscale,
216 + gscale,
217 + w2,
218 + w2_scale,
219 + gscale,
220 + )
221 + else:
222 + scale_exponent = 127 - 4
223 + w1_scale = torch.full(
224 + (num_experts, 2 * n, k // 32),
225 + scale_exponent,
226 + device=device,
227 + dtype=torch.uint8,
228 + )
229 + w2_scale = torch.full(
230 + (num_experts, k, n // 32),
231 + scale_exponent,
232 + device=device,
233 + dtype=torch.uint8,
234 + )
235 + runner = run_cutlass_moe_mxfp4
236 + quant_args = (w1, w1_scale, w2, w2_scale)
237 +
238 + def run_moe(
239 + hidden_states: torch.Tensor,
240 + topk_ids: torch.Tensor,
241 + topk_weights: torch.Tensor,
242 + workspace_value: float,
243 + ) -> torch.Tensor:
244 + m, topk = topk_ids.shape
245 + output = torch.empty((m, k), device=device, dtype=dtype)
246 + workspace13 = torch.full(
247 + (m * topk, max(2 * n, k)),
248 + workspace_value,
249 + device=device,
250 + dtype=dtype,
251 + )
252 + workspace2 = torch.full(
253 + (m * topk, n), workspace_value, device=device, dtype=dtype
254 + )
255 +
256 + runner(
257 + output,
258 + hidden_states,
259 + *quant_args,
260 + topk_weights,
261 + topk_ids,
262 + MoEActivation.SILU,
263 + workspace13,
264 + workspace2,
265 + m,
266 + n,
267 + k,
268 + num_experts,
269 + device,
270 + )
271 + return output
272 +
273 + valid_input = torch.randn((1, k), device=device, dtype=dtype)
274 + expected = run_moe(
275 + valid_input,
276 + torch.zeros((1, 1), device=device, dtype=torch.int32),
277 + torch.ones((1, 1), device=device, dtype=torch.float32),
278 + workspace_value=0,
279 + )
280 + assert expected.abs().max() > 0
281 +
282 + # Force the quantizers' grid-stride loops to revisit padded tail rows.
283 + num_tokens = 4096
284 + padded_input = torch.zeros((num_tokens, k), device=device, dtype=dtype)
285 + padded_input[0] = valid_input[0]
286 + padded_ids = torch.full((num_tokens, 2), -1, device=device, dtype=torch.int32)
287 + padded_ids[0, 0] = 0
288 + padded_weights = torch.zeros((num_tokens, 2), device=device, dtype=torch.float32)
289 + padded_weights[0, 0] = 1
290 +
291 + for workspace_value in (0, 64):
292 + actual = run_moe(padded_input, padded_ids, padded_weights, workspace_value)
293 + torch.testing.assert_close(actual[0], expected[0], atol=1e-2, rtol=1e-2)
294 + torch.testing.assert_close(actual[1:], torch.zeros_like(actual[1:]))
295 +
296 +
297 @dataclasses.dataclass
298 class MOETensors:
299 a: torch.Tensor
tests/kernels/test_shuffle_rows.py
+15
@@ -103,6 +103,21 @@ def test_shuffle_rows_expansion(num_tokens: int, hidden_size: int):
103 )
104
105
106 +def test_shuffle_rows_invalid_indices_are_zeroed():
107 + """Invalid sentinel indices should produce zero rows instead of OOB reads."""
108 + if not current_platform.is_cuda():
109 + pytest.skip("shuffle_rows requires CUDA")
110 +
111 + input_tensor = torch.randn(3, 128, device="cuda", dtype=torch.bfloat16)
112 + dst2src_map = torch.tensor([-1, 1, 3], device="cuda", dtype=torch.int32)
113 +
114 + output = shuffle_rows(input_tensor, dst2src_map)
115 +
116 + torch.testing.assert_close(output[0], torch.zeros_like(output[0]))
117 + torch.testing.assert_close(output[1], input_tensor[1])
118 + torch.testing.assert_close(output[2], torch.zeros_like(output[2]))
119 +
120 +
121 @pytest.mark.parametrize("num_tokens", [16, 64])
122 @pytest.mark.parametrize("hidden_size", [128, 512])
123 def test_shuffle_rows_random_permutation(num_tokens: int, hidden_size: int):