[Optimization] Skip DP padding tokens in MoE (#46428)

Signed-off-by: Woosuk Kwon <woosuk@inferact.ai> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Woosuk Kwon committed Jun 23, 2026 at 14:54 UTC 0a3e2dbc09c8a70dfd18f728bb829bf29ffa7da6
9 files changed +154 -1
tests/models/test_deepseek_v4_mega_moe.py
+80 -1
@@ -46,7 +46,8 @@ def test_deepseek_v4_mega_moe_ue8m0_uint8_to_float():
46
47 def test_deepseek_v4_mega_moe_weight_loader_uses_ep_expert_ownership():
48 vllm_config = SimpleNamespace(
49 - scheduler_config=SimpleNamespace(max_num_batched_tokens=4)
49 + scheduler_config=SimpleNamespace(max_num_batched_tokens=4),
50 + compilation_config=SimpleNamespace(static_forward_context={}),
51 )
52 experts = DeepseekV4MegaMoEExperts(
53 vllm_config,
@@ -182,3 +183,81 @@ def test_deepseek_v4_mega_moe_fused_input_staging_is_bitwise_exact():
183 fused_topk_weights.view(torch.uint8),
184 ref_topk_weights.view(torch.uint8),
185 )
186 +
187 +
188 +@pytest.mark.skipif(
189 + not torch.cuda.is_available(),
190 + reason="DeepSeek V4 MegaMoE fused input staging requires CUDA.",
191 +)
192 +def test_deepseek_v4_mega_moe_fused_input_staging_masks_padding():
193 + from vllm.third_party.deep_gemm.utils import per_token_cast_to_fp8
194 +
195 + device = torch.device("cuda")
196 + num_tokens = 7
197 + hidden_size = 256
198 + top_k = 8
199 +
200 + generator = torch.Generator(device=device)
201 + generator.manual_seed(1)
202 + hidden_states = torch.randn(
203 + num_tokens,
204 + hidden_size,
205 + device=device,
206 + dtype=torch.bfloat16,
207 + generator=generator,
208 + )
209 + topk_ids = torch.randint(
210 + 0,
211 + 256,
212 + (num_tokens, top_k),
213 + device=device,
214 + dtype=torch.int32,
215 + generator=generator,
216 + )
217 + topk_weights = torch.randn(
218 + num_tokens,
219 + top_k,
220 + device=device,
221 + dtype=torch.float32,
222 + generator=generator,
223 + )
224 + is_padding = torch.tensor(
225 + [False, True, False, False, True, False, True],
226 + device=device,
227 + )
228 +
229 + ref_x, ref_x_sf = per_token_cast_to_fp8(
230 + hidden_states,
231 + use_ue8m0=True,
232 + gran_k=32,
233 + use_packed_ue8m0=True,
234 + )
235 + ref_topk_idx = topk_ids.to(torch.int64)
236 + ref_topk_idx[is_padding] = -1
237 + ref_topk_weights = topk_weights.clone()
238 + ref_topk_weights[is_padding] = 0.0
239 +
240 + fused_x = torch.empty_like(ref_x)
241 + fused_x_sf = torch.empty_like(ref_x_sf)
242 + fused_topk_idx = torch.empty_like(ref_topk_idx)
243 + fused_topk_weights = torch.empty_like(ref_topk_weights)
244 +
245 + prepare_megamoe_inputs(
246 + hidden_states,
247 + topk_weights,
248 + topk_ids,
249 + fused_x,
250 + fused_x_sf,
251 + fused_topk_idx,
252 + fused_topk_weights,
253 + is_padding=is_padding,
254 + )
255 + torch.accelerator.synchronize()
256 +
257 + assert torch.equal(fused_x.view(torch.uint8), ref_x.view(torch.uint8))
258 + assert torch.equal(fused_x_sf, ref_x_sf)
259 + assert torch.equal(fused_topk_idx, ref_topk_idx)
260 + assert torch.equal(
261 + fused_topk_weights.view(torch.uint8),
262 + ref_topk_weights.view(torch.uint8),
263 + )
vllm/envs.py
+6
@@ -185,6 +185,7 @@ if TYPE_CHECKING:
185 "relax",
186 ] = "relax"
187 VLLM_USE_FUSED_MOE_GROUPED_TOPK: bool = True
188 + VLLM_MOE_SKIP_PADDING: bool = False
189 VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER: bool = True
190 VLLM_USE_FLASHINFER_MOE_INT4: bool = False
191 VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR: str | None = None
@@ -1469,6 +1470,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
1470 "VLLM_USE_FUSED_MOE_GROUPED_TOPK": lambda: bool(
1471 int(os.getenv("VLLM_USE_FUSED_MOE_GROUPED_TOPK", "1"))
1472 ),
1473 + # Skip cudagraph/DP padding tokens in the MoE path by forcing their expert
1474 + # ids to -1 so the dispatch and experts drop them. Requires a MoE kernel that
1475 + # treats topk_id == -1 as a skip sentinel; off by default because not all
1476 + # kernels support it yet.
1477 + "VLLM_MOE_SKIP_PADDING": lambda: bool(int(os.getenv("VLLM_MOE_SKIP_PADDING", "0"))),
1478 # Allow use of FlashInfer FP8 block-scale GEMM for linear layers.
1479 # This uses TensorRT-LLM kernels and requires SM90+ (Hopper).
1480 "VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER": lambda: bool(
vllm/forward_context.py
+9
@@ -147,6 +147,11 @@ class ForwardContext:
147
148 ubatch_slices: UBatchSlices | None = None
149
150 + # Boolean mask over the token axis: True for padding rows that are not real
151 + # tokens. Consumers can use it to skip work for padded tokens. None when
152 + # the producer does not set it.
153 + is_padding: torch.Tensor | None = None
154 +
155 # If True, bypass the compiled model call, e.g. by using .forward() directly
156 skip_compiled: bool = False
157
@@ -211,6 +216,7 @@ def create_forward_context(
216 slot_mapping: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]] | None = None,
217 additional_kwargs: dict[str, Any] | None = None,
218 skip_compiled: bool = False,
219 + is_padding: torch.Tensor | None = None,
220 ):
221 if vllm_config.compilation_config.fast_moe_cold_start:
222 all_moe_layers = vllm_config.compilation_config.static_all_moe_layers
@@ -228,6 +234,7 @@ def create_forward_context(
234 ubatch_slices=ubatch_slices,
235 skip_compiled=skip_compiled,
236 additional_kwargs=additional_kwargs or {},
237 + is_padding=is_padding,
238 )
239
240
@@ -257,6 +264,7 @@ def set_forward_context(
264 ubatch_slices: UBatchSlices | None = None,
265 slot_mapping: dict[str, torch.Tensor] | list[dict[str, torch.Tensor]] | None = None,
266 skip_compiled: bool = False,
267 + is_padding: torch.Tensor | None = None,
268 ):
269 """A context manager that stores the current forward context,
270 can be attention metadata, etc.
@@ -316,6 +324,7 @@ def set_forward_context(
324 slot_mapping,
325 additional_kwargs,
326 skip_compiled,
327 + is_padding=is_padding,
328 )
329
330 try:
vllm/model_executor/layers/fused_moe/modular_kernel.py
+17
@@ -10,6 +10,7 @@ from typing import final
10 import torch
11
12 import vllm.envs as envs
13 +from vllm.forward_context import get_forward_context, is_forward_context_available
14 from vllm.logger import init_logger
15 from vllm.model_executor.layers.fused_moe.activation import (
16 MoEActivation,
@@ -1133,6 +1134,22 @@ class FusedMoEKernelModularImpl:
1134 The _prepare method is a wrapper around self.prepare_finalize.prepare
1135 that handles DBO and async.
1136 """
1137 + # Skip cudagraph/DP padding tokens uniformly across all a2a backends:
1138 + # forcing padded rows' expert ids to -1 makes every prepare_finalize drop
1139 + # them (not dispatched / not computed by the experts). The V2 model runner
1140 + # marks them in forward_context.is_padding; it is None for runners that do
1141 + # not populate it, leaving topk_ids unchanged.
1142 + # Gated by VLLM_MOE_SKIP_PADDING (off by default) because this requires the
1143 + # experts kernel to treat topk_id == -1 as a skip sentinel, which not all
1144 + # MoE backends support yet.
1145 + is_padding = None
1146 + if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
1147 + is_padding = get_forward_context().is_padding
1148 + if is_padding is not None:
1149 + n = topk_ids.shape[0]
1150 + # TODO: Properly support DBO (padding lives at the batch tail).
1151 + topk_ids = torch.where(is_padding[:n].unsqueeze(1), -1, topk_ids)
1152 +
1153 if not self.prepare_finalize.supports_async():
1154 # We shouldn't be running an a2a kernel that doesn't
1155 # support async prepare/finalize
vllm/models/deepseek_v4/nvidia/model.py
+10
@@ -8,6 +8,7 @@ import regex as re
8 import torch
9 import torch.nn as nn
10
11 +import vllm.envs as envs
12 from vllm.config import VllmConfig
13 from vllm.distributed import (
14 get_ep_group,
@@ -16,6 +17,7 @@ from vllm.distributed import (
17 get_tensor_model_parallel_world_size,
18 )
19 from vllm.distributed.eplb.eplb_state import EplbLayerState
20 +from vllm.forward_context import get_forward_context, is_forward_context_available
21 from vllm.model_executor.kernels.mhc.tilelang import (
22 hc_head_fused_kernel_tilelang,
23 mhc_fused_post_pre_tilelang,
@@ -442,6 +444,11 @@ class DeepseekV4MegaMoEExperts(nn.Module):
444
445 symm_buffer = self.get_symm_buffer()
446 num_tokens = hidden_states.shape[0]
447 + is_padding = None
448 + if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
449 + is_padding = get_forward_context().is_padding
450 + if is_padding is not None:
451 + is_padding = is_padding[:num_tokens]
452
453 # EPLB: map logical expert IDs to physical replicas and record load.
454 eplb_state = self.eplb_state
@@ -449,6 +456,8 @@ class DeepseekV4MegaMoEExperts(nn.Module):
456 assert eplb_state.expert_load_view is not None
457 assert eplb_state.logical_replica_count is not None
458 assert eplb_state.should_record_tensor is not None
459 + if is_padding is not None:
460 + topk_ids = torch.where(is_padding.unsqueeze(1), -1, topk_ids)
461 topk_ids = eplb_map_to_physical_and_record(
462 topk_ids=topk_ids,
463 expert_load_view=eplb_state.expert_load_view,
@@ -465,6 +474,7 @@ class DeepseekV4MegaMoEExperts(nn.Module):
474 symm_buffer.x_sf[:num_tokens],
475 symm_buffer.topk_idx[:num_tokens],
476 symm_buffer.topk_weights[:num_tokens],
477 + is_padding=is_padding,
478 )
479
480 # This method must have been already called during the weight loading phase.
vllm/models/deepseek_v4/nvidia/ops/prepare_megamoe.py
+11
@@ -19,6 +19,7 @@ def _prepare_megamoe_inputs_kernel(
19 x_sf,
20 topk_ids,
21 topk_weights,
22 + is_padding,
23 topk_idx_out,
24 topk_weights_out,
25 hidden_stride_m: tl.constexpr,
@@ -31,6 +32,7 @@ def _prepare_megamoe_inputs_kernel(
32 topk_ids_stride_k: tl.constexpr,
33 topk_weights_stride_m: tl.constexpr,
34 topk_weights_stride_k: tl.constexpr,
35 + is_padding_stride_m: tl.constexpr,
36 topk_idx_stride_m: tl.constexpr,
37 topk_idx_stride_k: tl.constexpr,
38 topk_weights_out_stride_m: tl.constexpr,
@@ -85,12 +87,16 @@ def _prepare_megamoe_inputs_kernel(
87 if k_block_id == 0:
88 topk_offsets = tl.arange(0, BLOCK_TOPK)
89 topk_mask = topk_offsets < top_k
90 + token_is_padding = False
91 + if is_padding is not None:
92 + token_is_padding = tl.load(is_padding + token_id * is_padding_stride_m)
93
94 ids = tl.load(
95 topk_ids + token_id * topk_ids_stride_m + topk_offsets * topk_ids_stride_k,
96 mask=topk_mask,
97 other=0,
98 ).to(tl.int64)
99 + ids = tl.where(token_is_padding, -1, ids)
100 tl.store(
101 topk_idx_out
102 + token_id * topk_idx_stride_m
@@ -106,6 +112,7 @@ def _prepare_megamoe_inputs_kernel(
112 mask=topk_mask,
113 other=0.0,
114 )
115 + weights = tl.where(token_is_padding, 0.0, weights)
116 tl.store(
117 topk_weights_out
118 + token_id * topk_weights_out_stride_m
@@ -123,6 +130,7 @@ def prepare_megamoe_inputs(
130 x_sf: torch.Tensor,
131 topk_idx_out: torch.Tensor,
132 topk_weights_out: torch.Tensor,
133 + is_padding: torch.Tensor | None = None,
134 ) -> None:
135 num_tokens, hidden_size = hidden_states.shape
136 if num_tokens == 0:
@@ -142,12 +150,14 @@ def prepare_megamoe_inputs(
150 block_k = 128
151 grid = (num_tokens, triton.cdiv(hidden_size, block_k))
152 block_topk = triton.next_power_of_2(top_k)
153 + padding_stride_m = is_padding.stride(0) if is_padding is not None else 0
154 _prepare_megamoe_inputs_kernel[grid](
155 hidden_states,
156 x_fp8,
157 x_sf,
158 topk_ids,
159 topk_weights,
160 + is_padding,
161 topk_idx_out,
162 topk_weights_out,
163 hidden_states.stride(0),
@@ -160,6 +170,7 @@ def prepare_megamoe_inputs(
170 topk_ids.stride(1),
171 topk_weights.stride(0),
172 topk_weights.stride(1),
173 + padding_stride_m,
174 topk_idx_out.stride(0),
175 topk_idx_out.stride(1),
176 topk_weights_out.stride(0),
vllm/v1/worker/gpu/cudagraph_utils.py
+4
@@ -471,6 +471,9 @@ class ModelCudaGraphManager(CudaGraphManager):
471 skip_attn=(desc.cg_mode == CUDAGraphMode.PIECEWISE),
472 )
473
474 + # Capture with dummy rows marked as padding.
475 + input_buffers.is_padding.fill_(True)
476 +
477 def forward_fn(cg_mode: CUDAGraphMode) -> None:
478 batch_descriptor = None
479 if cg_mode == CUDAGraphMode.PIECEWISE:
@@ -488,6 +491,7 @@ class ModelCudaGraphManager(CudaGraphManager):
491 num_tokens_across_dp=num_tokens_across_dp,
492 slot_mapping=slot_mappings,
493 batch_descriptor=batch_descriptor,
494 + is_padding=input_buffers.is_padding[:num_tokens],
495 ):
496 if cg_mode == CUDAGraphMode.PIECEWISE:
497 # PIECEWISE graph (compiled PW or breakable, chosen inside
vllm/v1/worker/gpu/input_batch.py
+7
@@ -22,6 +22,7 @@ class InputBuffers:
22
23 self.input_ids = torch.zeros(max_num_tokens, dtype=torch.int32, device=device)
24 self.positions = torch.zeros(max_num_tokens, dtype=torch.int64, device=device)
25 + self.is_padding = torch.zeros(max_num_tokens, dtype=torch.bool, device=device)
26 self.query_start_loc = torch.zeros(
27 max_num_reqs + 1, dtype=torch.int32, device=device
28 )
@@ -83,6 +84,8 @@ class InputBatch:
84 input_ids: torch.Tensor
85 # [num_tokens_after_padding]
86 positions: torch.Tensor
87 + # [num_tokens_after_padding]
88 + is_padding: torch.Tensor
89
90 # [total_num_logits]
91 logits_indices: torch.Tensor
@@ -134,6 +137,9 @@ class InputBatch:
137 input_ids = input_buffers.input_ids[:num_tokens].zero_()
138 positions = input_buffers.positions[:num_tokens].zero_()
139
140 + input_buffers.is_padding[:num_tokens].fill_(True)
141 + is_padding = input_buffers.is_padding[:num_tokens]
142 +
143 logits_indices = query_start_loc[1:] - 1
144 cu_num_logits = torch.arange(num_reqs + 1, device=device, dtype=torch.int32)
145 cu_num_logits_np = np.arange(num_reqs + 1, dtype=np.int32)
@@ -164,6 +170,7 @@ class InputBatch:
170 max_seq_len_np=None,
171 input_ids=input_ids,
172 positions=positions,
173 + is_padding=is_padding,
174 logits_indices=logits_indices,
175 cu_num_logits=cu_num_logits,
176 cu_num_logits_np=cu_num_logits_np,
vllm/v1/worker/gpu/model_runner.py
+10
@@ -27,6 +27,7 @@ import numpy as np
27 import torch
28 import torch.nn as nn
29
30 +import vllm.envs as envs
31 from vllm.compilation.counter import compilation_counter
32 from vllm.config import VllmConfig
33 from vllm.config.compilation import CUDAGraphMode
@@ -847,6 +848,13 @@ class GPUModelRunner(LoRAModelRunnerMixin):
848 num_tokens = scheduler_output.total_num_scheduled_tokens
849 num_tokens_after_padding = batch_desc.num_tokens
850 assert num_tokens > 0
851 + if envs.VLLM_MOE_SKIP_PADDING:
852 + # Mark trailing cudagraph-padding rows so kernels can skip work for
853 + # them when supported.
854 + self.input_buffers.is_padding[:num_tokens].fill_(False)
855 + self.input_buffers.is_padding[num_tokens:num_tokens_after_padding].fill_(
856 + True
857 + )
858 num_tokens_per_req = scheduler_output.num_scheduled_tokens
859 num_reqs = len(num_tokens_per_req)
860
@@ -1001,6 +1009,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1009 max_seq_len_np=max_seq_len_np,
1010 input_ids=self.input_buffers.input_ids[:num_tokens_after_padding],
1011 positions=self.input_buffers.positions[:num_tokens_after_padding],
1012 + is_padding=self.input_buffers.is_padding[:num_tokens_after_padding],
1013 logits_indices=logits_indices,
1014 cu_num_logits=cu_num_logits,
1015 cu_num_logits_np=cu_num_logits_np,
@@ -1277,6 +1286,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1286 batch_descriptor=batch_descriptor,
1287 slot_mapping=slot_mappings_by_layer,
1288 skip_compiled=skip_compiled,
1289 + is_padding=input_batch.is_padding,
1290 ):
1291 self.kv_connector.pre_forward(scheduler_output)
1292 if batch_desc.cg_mode == CUDAGraphMode.PIECEWISE: