[RL Infra][FlashInfer] Enable router replay output from FlashInfer monolithic MoE kernel (#44214)

Signed-off-by: Xuanyu Zhang <xuanyu.zhang@mistral.ai> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: OpenAI Codex <noreply@openai.com>

Xuanyu Zhang committed Jul 21, 2026 at 01:45 UTC 97a668152b9033caf602b37b88289f03e8faffd8
11 files changed +1099 -12
tests/kernels/moe/test_routed_experts_capture_monolithic.py new
+880
@@ -0,0 +1,880 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""End-to-end tests for routed-expert capture on the monolithic MoE path.
4 +
5 +These tests exercise the wiring that lets ``RoutedExpertsCapturer`` see the
6 +expert IDs picked by FlashInfer's fused router-and-experts kernels (the
7 +"monolithic" path). When ``set_capture_fn`` is installed on
8 +a ``FusedMoEExpertsMonolithic`` subclass that supports it, the kernel call
9 +should:
10 +
11 + * allocate an int16 ``(num_tokens, top_k)`` buffer,
12 + * pass it to FlashInfer as ``routing_replay_out``,
13 + * invoke the callback after the kernel returns.
14 +"""
15 +
16 +from __future__ import annotations
17 +
18 +from types import SimpleNamespace
19 +from unittest.mock import patch
20 +
21 +import pytest
22 +import torch
23 +
24 +from vllm.model_executor.layers.fused_moe.activation import MoEActivation
25 +from vllm.model_executor.layers.fused_moe.config import (
26 + FusedMoEConfig,
27 + FusedMoEParallelConfig,
28 + FusedMoEQuantConfig,
29 + RoutingMethodType,
30 + fp8_w8a8_moe_quant_config,
31 +)
32 +from vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe import (
33 + TrtLlmBf16ExpertsMonolithic,
34 +)
35 +from vllm.model_executor.layers.fused_moe.experts.trtllm_fp8_moe import (
36 + TrtLlmFp8ExpertsMonolithic,
37 +)
38 +from vllm.model_executor.layers.fused_moe.experts.trtllm_nvfp4_moe import (
39 + TrtLlmNvFp4ExpertsMonolithic,
40 +)
41 +from vllm.platforms import current_platform
42 +
43 +try:
44 + from vllm.utils.flashinfer import has_flashinfer_trtllm_fused_moe
45 +except ImportError:
46 + pytest.skip("flashinfer not available", allow_module_level=True)
47 +
48 +if not has_flashinfer_trtllm_fused_moe() or not current_platform.is_cuda():
49 + pytest.skip(
50 + "Requires FlashInfer TRT-LLM fused MoE on CUDA",
51 + allow_module_level=True,
52 + )
53 +
54 +if not current_platform.has_device_capability(100):
55 + pytest.skip(
56 + "TRT-LLM fused MoE kernels require SM100+",
57 + allow_module_level=True,
58 + )
59 +
60 +
61 +def _shuffle_bf16_weights_block_major_k(
62 + w: torch.Tensor, epilogue_tile_m: int = 64, block_k: int = 128
63 +) -> torch.Tensor:
64 + """Reshape ``w`` (E, M, K) into the ``BlockMajorK`` layout expected by
65 + ``trtllm_bf16_moe``: ``(E, K/block_k, M, block_k)`` after a per-expert
66 + row shuffle.
67 + """
68 + from flashinfer import shuffle_matrix_a
69 + from flashinfer.fused_moe import convert_to_block_layout
70 +
71 + num_experts = w.shape[0]
72 + shuffled = []
73 + for i in range(num_experts):
74 + t = shuffle_matrix_a(w[i].view(torch.uint8), epilogue_tile_m)
75 + shuffled.append(convert_to_block_layout(t, block_k))
76 + return torch.stack(shuffled).view(torch.bfloat16)
77 +
78 +
79 +def _make_bf16_monolithic_experts(
80 + num_experts: int,
81 + top_k: int,
82 + hidden_size: int,
83 + intermediate_size: int,
84 + routing_method: RoutingMethodType,
85 + device: torch.device,
86 +) -> tuple[TrtLlmBf16ExpertsMonolithic, torch.Tensor, torch.Tensor]:
87 + """Construct the monolithic BF16 experts plus the BlockMajorK weights
88 + expected by ``trtllm_bf16_moe``.
89 + """
90 + parallel_cfg = FusedMoEParallelConfig.make_no_parallel()
91 + moe_config = FusedMoEConfig(
92 + num_experts=num_experts,
93 + experts_per_token=top_k,
94 + hidden_dim=hidden_size,
95 + intermediate_size=intermediate_size,
96 + num_local_experts=num_experts,
97 + num_logical_experts=num_experts,
98 + moe_parallel_config=parallel_cfg,
99 + in_dtype=torch.bfloat16,
100 + activation=MoEActivation.SILU,
101 + device=device,
102 + routing_method=routing_method,
103 + max_num_tokens=max(8, 1),
104 + )
105 + quant_config = FusedMoEQuantConfig.make(
106 + quant_dtype=None,
107 + per_act_token_quant=False,
108 + per_out_ch_quant=False,
109 + block_shape=None,
110 + )
111 +
112 + experts = TrtLlmBf16ExpertsMonolithic(
113 + moe_config=moe_config, quant_config=quant_config
114 + )
115 +
116 + gemm1 = (
117 + torch.randn(
118 + num_experts,
119 + 2 * intermediate_size,
120 + hidden_size,
121 + device=device,
122 + dtype=torch.bfloat16,
123 + )
124 + * 0.1
125 + )
126 + gemm2 = (
127 + torch.randn(
128 + num_experts,
129 + hidden_size,
130 + intermediate_size,
131 + device=device,
132 + dtype=torch.bfloat16,
133 + )
134 + * 0.1
135 + )
136 + w13 = _shuffle_bf16_weights_block_major_k(gemm1)
137 + w2 = _shuffle_bf16_weights_block_major_k(gemm2)
138 + return experts, w13, w2
139 +
140 +
141 +def _run_bf16_monolithic(
142 + experts: TrtLlmBf16ExpertsMonolithic,
143 + hidden_states: torch.Tensor,
144 + w13: torch.Tensor,
145 + w2: torch.Tensor,
146 + router_logits: torch.Tensor,
147 + num_experts: int,
148 + *,
149 + n_group: int | None = None,
150 + topk_group: int | None = None,
151 + routed_scaling_factor: float | None = None,
152 + e_score_correction_bias: torch.Tensor | None = None,
153 +) -> torch.Tensor:
154 + return experts.apply(
155 + hidden_states=hidden_states,
156 + w1=w13,
157 + w2=w2,
158 + router_logits=router_logits,
159 + activation=MoEActivation.SILU,
160 + global_num_experts=num_experts,
161 + expert_map=None,
162 + a1q_scale=None,
163 + apply_router_weight_on_input=False,
164 + num_expert_group=n_group,
165 + topk_group=topk_group,
166 + e_score_correction_bias=e_score_correction_bias,
167 + routed_scaling_factor=routed_scaling_factor,
168 + )
169 +
170 +
171 +_DSV3_NUM_EXPERTS = 32
172 +_DSV3_N_GROUP = 4
173 +_DSV3_TOPK_GROUP = 2
174 +
175 +
176 +def _make_dsv3_routing_bias(num_experts: int, device: torch.device) -> torch.Tensor:
177 + return torch.randn(num_experts, device=device, dtype=torch.bfloat16)
178 +
179 +
180 +@pytest.mark.parametrize("num_tokens", [2, 7, 16])
181 +@pytest.mark.parametrize("top_k", [2, 4])
182 +def test_trtllm_bf16_monolithic_routing_replay_records_valid_experts(
183 + num_tokens: int,
184 + top_k: int,
185 +) -> None:
186 + """The capture callback should receive the int16 routed-expert IDs the
187 + kernel actually used, the values should be valid expert indices, and
188 + each token should pick ``top_k`` distinct experts."""
189 + if top_k > _DSV3_N_GROUP * _DSV3_TOPK_GROUP:
190 + pytest.skip(
191 + f"DSV3 requires top_k <= n_group * topk_group "
192 + f"({_DSV3_N_GROUP * _DSV3_TOPK_GROUP})"
193 + )
194 + torch.manual_seed(0)
195 + device = torch.device("cuda:0")
196 +
197 + num_experts = _DSV3_NUM_EXPERTS
198 + hidden_size = 1024
199 + intermediate_size = 1024
200 +
201 + experts, w13, w2 = _make_bf16_monolithic_experts(
202 + num_experts=num_experts,
203 + top_k=top_k,
204 + hidden_size=hidden_size,
205 + intermediate_size=intermediate_size,
206 + routing_method=RoutingMethodType.DeepSeekV3,
207 + device=device,
208 + )
209 +
210 + captured: list[torch.Tensor] = []
211 +
212 + def capture_fn(replay_out: torch.Tensor) -> None:
213 + captured.append(replay_out.clone())
214 +
215 + assert experts.supports_routing_replay_capture()
216 + experts.set_capture_fn(capture_fn)
217 +
218 + hidden_states = (
219 + torch.randn(num_tokens, hidden_size, device=device, dtype=torch.bfloat16) * 0.1
220 + )
221 + router_logits = torch.rand(
222 + num_tokens, num_experts, device=device, dtype=torch.float32
223 + )
224 + routing_bias = _make_dsv3_routing_bias(num_experts, device)
225 +
226 + _ = _run_bf16_monolithic(
227 + experts,
228 + hidden_states=hidden_states,
229 + w13=w13,
230 + w2=w2,
231 + router_logits=router_logits,
232 + num_experts=num_experts,
233 + n_group=_DSV3_N_GROUP,
234 + topk_group=_DSV3_TOPK_GROUP,
235 + routed_scaling_factor=1.0,
236 + e_score_correction_bias=routing_bias,
237 + )
238 +
239 + assert len(captured) == 1
240 + replay = captured[0]
241 + assert replay.dtype == torch.int16
242 + assert replay.shape == (num_tokens, top_k)
243 + assert (replay >= 0).all(), f"got out-of-range values: {replay}"
244 + assert (replay < num_experts).all(), f"got out-of-range values: {replay}"
245 +
246 + for t in range(num_tokens):
247 + unique = replay[t].unique()
248 + assert unique.numel() == top_k, (
249 + f"token {t}: expected {top_k} distinct experts, "
250 + f"got {unique.numel()} ({replay[t].tolist()})"
251 + )
252 +
253 +
254 +@pytest.mark.parametrize("num_tokens", [2, 7, 16])
255 +@pytest.mark.parametrize(
256 + "routing_method",
257 + [
258 + RoutingMethodType.Renormalize,
259 + RoutingMethodType.RenormalizeNaive,
260 + ],
261 +)
262 +def test_trtllm_bf16_monolithic_routing_replay_non_dsv3(
263 + num_tokens: int,
264 + routing_method: RoutingMethodType,
265 +) -> None:
266 + """Routing replay works for non-DeepSeekV3 routing methods too.
267 + FlashInfer's ``routing_replay_out`` is routing-method-agnostic."""
268 + torch.manual_seed(0)
269 + device = torch.device("cuda:0")
270 +
271 + num_experts = 8
272 + top_k = 2
273 + hidden_size = 1024
274 + intermediate_size = 1024
275 +
276 + experts, w13, w2 = _make_bf16_monolithic_experts(
277 + num_experts=num_experts,
278 + top_k=top_k,
279 + hidden_size=hidden_size,
280 + intermediate_size=intermediate_size,
281 + routing_method=routing_method,
282 + device=device,
283 + )
284 +
285 + captured: list[torch.Tensor] = []
286 + experts.set_capture_fn(lambda r: captured.append(r.clone()))
287 +
288 + hidden_states = (
289 + torch.randn(num_tokens, hidden_size, device=device, dtype=torch.bfloat16) * 0.1
290 + )
291 + router_logits = torch.rand(
292 + num_tokens, num_experts, device=device, dtype=torch.float32
293 + )
294 +
295 + _ = _run_bf16_monolithic(
296 + experts,
297 + hidden_states=hidden_states,
298 + w13=w13,
299 + w2=w2,
300 + router_logits=router_logits,
301 + num_experts=num_experts,
302 + )
303 +
304 + assert len(captured) == 1
305 + replay = captured[0]
306 + assert replay.dtype == torch.int16
307 + assert replay.shape == (num_tokens, top_k)
308 + assert (replay >= 0).all(), f"got out-of-range values: {replay}"
309 + assert (replay < num_experts).all(), f"got out-of-range values: {replay}"
310 + for t in range(num_tokens):
311 + unique = replay[t].unique()
312 + assert unique.numel() == top_k, (
313 + f"token {t}: expected {top_k} distinct experts, "
314 + f"got {unique.numel()} ({replay[t].tolist()})"
315 + )
316 +
317 +
318 +def test_trtllm_bf16_monolithic_capture_disabled_skips_buffer_alloc() -> None:
319 + """With no callback installed the kernel should not see a
320 + ``routing_replay_out`` tensor — verify the helper short-circuits."""
321 + torch.manual_seed(0)
322 + device = torch.device("cuda:0")
323 + experts, _, _ = _make_bf16_monolithic_experts(
324 + num_experts=_DSV3_NUM_EXPERTS,
325 + top_k=2,
326 + hidden_size=1024,
327 + intermediate_size=1024,
328 + routing_method=RoutingMethodType.DeepSeekV3,
329 + device=device,
330 + )
331 + # No callback installed.
332 + buf = experts._maybe_make_routing_replay_buffer(num_tokens=4, device=device)
333 + assert buf is None
334 +
335 + # Dispatch is also a no-op.
336 + experts._maybe_dispatch_routing_replay(buf, num_tokens=4)
337 +
338 +
339 +def test_trtllm_bf16_monolithic_supports_capture_for_all_routing() -> None:
340 + """FlashInfer's ``routing_replay_out`` is supported by all routing
341 + methods, so ``supports_routing_replay_capture`` should be True
342 + regardless of routing method."""
343 + device = torch.device("cuda:0")
344 + for routing_method in (
345 + RoutingMethodType.DeepSeekV3,
346 + RoutingMethodType.Renormalize,
347 + RoutingMethodType.RenormalizeNaive,
348 + ):
349 + experts, _, _ = _make_bf16_monolithic_experts(
350 + num_experts=_DSV3_NUM_EXPERTS,
351 + top_k=2,
352 + hidden_size=1024,
353 + intermediate_size=1024,
354 + routing_method=routing_method,
355 + device=device,
356 + )
357 + assert experts.supports_routing_replay_capture() is True, (
358 + f"{routing_method!r} should support routing replay capture"
359 + )
360 +
361 +
362 +def test_trtllm_bf16_monolithic_capture_buffer_shape_and_dtype() -> None:
363 + """When capture is installed, the allocated buffer is int16 and shaped
364 + ``(num_tokens, experts_per_token)``."""
365 + device = torch.device("cuda:0")
366 + experts, _, _ = _make_bf16_monolithic_experts(
367 + num_experts=_DSV3_NUM_EXPERTS,
368 + top_k=4,
369 + hidden_size=1024,
370 + intermediate_size=1024,
371 + routing_method=RoutingMethodType.DeepSeekV3,
372 + device=device,
373 + )
374 + experts.set_capture_fn(lambda r: None)
375 + buf = experts._maybe_make_routing_replay_buffer(num_tokens=11, device=device)
376 + assert buf is not None
377 + assert buf.dtype == torch.int16
378 + assert buf.shape[0] >= 11
379 + assert buf.shape[1] == 4
380 + assert buf.device.type == "cuda"
381 +
382 +
383 +def test_routed_experts_capturer_e2e_via_monolithic_experts() -> None:
384 + """End-to-end: bind ``RoutedExpertsCapturer.capture`` as the callback
385 + on the monolithic experts and verify the captured rows land in the
386 + capturer's device buffer at the correct layer slot.
387 +
388 + Mirrors the wiring done in ``GPUModelRunner._bind_routed_experts_capturer``
389 + for the monolithic path: a single closure is installed on the monolithic
390 + ``fused_experts`` (in addition to ``router.set_capture_fn`` on the
391 + non-monolithic path) and the capturer routes per-layer based on the
392 + closed-over ``layer_id``.
393 + """
394 + from vllm.model_executor.layers.fused_moe.routed_experts_capturer import (
395 + RoutedExpertsCapturer,
396 + )
397 +
398 + torch.manual_seed(7)
399 + device = torch.device("cuda:0")
400 + num_tokens = 4
401 + top_k = 2
402 + num_experts = _DSV3_NUM_EXPERTS
403 + hidden_size = 1024
404 + intermediate_size = 1024
405 +
406 + experts, w13, w2 = _make_bf16_monolithic_experts(
407 + num_experts=num_experts,
408 + top_k=top_k,
409 + hidden_size=hidden_size,
410 + intermediate_size=intermediate_size,
411 + routing_method=RoutingMethodType.DeepSeekV3,
412 + device=device,
413 + )
414 +
415 + num_layers = 3
416 + layer_id = 1
417 + capturer = RoutedExpertsCapturer.__new__(RoutedExpertsCapturer)
418 + capturer.dp_rank = 0
419 + capturer.tp_size = 1
420 + capturer.device_buffer = torch.full(
421 + (num_tokens + 4, num_layers, top_k),
422 + -1,
423 + dtype=torch.int32,
424 + device=device,
425 + )
426 +
427 + def capture_fn(replay_out: torch.Tensor) -> None:
428 + capturer.capture(layer_id, replay_out)
429 +
430 + experts.set_capture_fn(capture_fn)
431 +
432 + hidden_states = (
433 + torch.randn(num_tokens, hidden_size, device=device, dtype=torch.bfloat16) * 0.1
434 + )
435 + router_logits = torch.rand(
436 + num_tokens, num_experts, device=device, dtype=torch.float32
437 + )
438 + routing_bias = _make_dsv3_routing_bias(num_experts, device)
439 +
440 + # Patch get_forward_context to return a dp_metadata=None context so the
441 + # capturer takes the single-DP branch.
442 + import vllm.model_executor.layers.fused_moe.routed_experts_capturer as rec
443 +
444 + with patch.object(
445 + rec,
446 + "get_forward_context",
447 + return_value=SimpleNamespace(dp_metadata=None),
448 + ):
449 + _ = _run_bf16_monolithic(
450 + experts,
451 + hidden_states=hidden_states,
452 + w13=w13,
453 + w2=w2,
454 + router_logits=router_logits,
455 + num_experts=num_experts,
456 + n_group=_DSV3_N_GROUP,
457 + topk_group=_DSV3_TOPK_GROUP,
458 + routed_scaling_factor=1.0,
459 + e_score_correction_bias=routing_bias,
460 + )
461 +
462 + captured = capturer.device_buffer[:num_tokens, layer_id, :].cpu()
463 + # Valid expert IDs at this layer.
464 + assert (captured >= 0).all()
465 + assert (captured < num_experts).all()
466 + for t in range(num_tokens):
467 + unique = captured[t].unique()
468 + assert unique.numel() == top_k, (
469 + f"token {t}: expected {top_k} distinct experts at layer "
470 + f"{layer_id}, got {unique.numel()}"
471 + )
472 +
473 + # Other layers / trailing token rows untouched.
474 + for other_layer in range(num_layers):
475 + if other_layer == layer_id:
476 + continue
477 + assert (capturer.device_buffer[:, other_layer, :].cpu() == -1).all(), (
478 + f"layer {other_layer} should be untouched, got writes"
479 + )
480 + assert (capturer.device_buffer[num_tokens:, layer_id, :].cpu() == -1).all(), (
481 + "tail rows beyond num_tokens should remain sentinel"
482 + )
483 +
484 +
485 +# ----------------------------------------------------------------------------
486 +# FP8 block-scale (DeepSeekFp8) — vLLM's ``TrtLlmFp8ExpertsMonolithic``
487 +# ----------------------------------------------------------------------------
488 +
489 +
490 +def _make_fp8_block_scale_monolithic_experts(
491 + num_experts: int,
492 + top_k: int,
493 + hidden_size: int,
494 + intermediate_size: int,
495 + device: torch.device,
496 +) -> tuple[TrtLlmFp8ExpertsMonolithic, torch.Tensor, torch.Tensor]:
497 + """Set up ``TrtLlmFp8ExpertsMonolithic`` for the DeepSeekFp8 block-scale
498 + code path with DSV3 routing.
499 +
500 + Weights are shuffled into the BlockMajorK layout the kernel expects
501 + (same helper the vLLM weight loader uses for DeepSeek-FP8 models).
502 + """
503 + from vllm.model_executor.layers.quantization.utils.flashinfer_utils import (
504 + _shuffle_deepseek_fp8_moe_weights,
505 + )
506 +
507 + block_k = 128
508 + parallel_cfg = FusedMoEParallelConfig.make_no_parallel()
509 + moe_config = FusedMoEConfig(
510 + num_experts=num_experts,
511 + experts_per_token=top_k,
512 + hidden_dim=hidden_size,
513 + intermediate_size=intermediate_size,
514 + num_local_experts=num_experts,
515 + num_logical_experts=num_experts,
516 + moe_parallel_config=parallel_cfg,
517 + in_dtype=torch.bfloat16,
518 + activation=MoEActivation.SILU,
519 + device=device,
520 + routing_method=RoutingMethodType.DeepSeekV3,
521 + max_num_tokens=max(8, 1),
522 + )
523 +
524 + # Random fp8 weights + ones-block scales (the kernel decoder only cares
525 + # that the per-block scales are present and finite for routing/replay).
526 + gemm1 = torch.randn(
527 + num_experts, 2 * intermediate_size, hidden_size, device=device
528 + ).to(torch.float8_e4m3fn)
529 + gemm2 = torch.randn(num_experts, hidden_size, intermediate_size, device=device).to(
530 + torch.float8_e4m3fn
531 + )
532 + w13_shuffled, w2_shuffled = _shuffle_deepseek_fp8_moe_weights(gemm1, gemm2)
533 +
534 + w1_scale = torch.ones(
535 + num_experts,
536 + 2 * intermediate_size // block_k,
537 + hidden_size // block_k,
538 + device=device,
539 + dtype=torch.float32,
540 + )
541 + w2_scale = torch.ones(
542 + num_experts,
543 + hidden_size // block_k,
544 + intermediate_size // block_k,
545 + device=device,
546 + dtype=torch.float32,
547 + )
548 + quant_config = fp8_w8a8_moe_quant_config(
549 + w1_scale=w1_scale,
550 + w2_scale=w2_scale,
551 + block_shape=[block_k, block_k],
552 + per_act_token_quant=False,
553 + )
554 +
555 + experts = TrtLlmFp8ExpertsMonolithic(
556 + moe_config=moe_config, quant_config=quant_config
557 + )
558 + return experts, w13_shuffled, w2_shuffled
559 +
560 +
561 +def _run_fp8_block_scale_monolithic(
562 + experts: TrtLlmFp8ExpertsMonolithic,
563 + hidden_states_fp8: torch.Tensor,
564 + hidden_states_scale: torch.Tensor,
565 + w13: torch.Tensor,
566 + w2: torch.Tensor,
567 + router_logits: torch.Tensor,
568 + num_experts: int,
569 + routing_bias: torch.Tensor,
570 +) -> torch.Tensor:
571 + return experts.apply(
572 + hidden_states=hidden_states_fp8,
573 + w1=w13,
574 + w2=w2,
575 + router_logits=router_logits,
576 + activation=MoEActivation.SILU,
577 + global_num_experts=num_experts,
578 + expert_map=None,
579 + # The block-scale apply path reads ``a1q_scale`` and transposes it
580 + # to ``(hidden_size/128, num_tokens)`` for the kernel call.
581 + a1q_scale=hidden_states_scale,
582 + apply_router_weight_on_input=False,
583 + num_expert_group=_DSV3_N_GROUP,
584 + topk_group=_DSV3_TOPK_GROUP,
585 + e_score_correction_bias=routing_bias,
586 + routed_scaling_factor=1.0,
587 + )
588 +
589 +
590 +@pytest.mark.parametrize("num_tokens", [2, 7, 16])
591 +@pytest.mark.parametrize("top_k", [2, 4])
592 +def test_trtllm_fp8_block_scale_monolithic_routing_replay_records_valid_experts(
593 + num_tokens: int,
594 + top_k: int,
595 +) -> None:
596 + """End-to-end: ``TrtLlmFp8ExpertsMonolithic`` (DeepSeekFp8 block-scale
597 + path, DSV3 routing) captures valid expert IDs."""
598 + if top_k > _DSV3_N_GROUP * _DSV3_TOPK_GROUP:
599 + pytest.skip(
600 + f"DSV3 requires top_k <= n_group * topk_group "
601 + f"({_DSV3_N_GROUP * _DSV3_TOPK_GROUP})"
602 + )
603 + torch.manual_seed(0)
604 + device = torch.device("cuda:0")
605 +
606 + num_experts = _DSV3_NUM_EXPERTS
607 + hidden_size = 1024
608 + intermediate_size = 1024
609 +
610 + experts, w13, w2 = _make_fp8_block_scale_monolithic_experts(
611 + num_experts=num_experts,
612 + top_k=top_k,
613 + hidden_size=hidden_size,
614 + intermediate_size=intermediate_size,
615 + device=device,
616 + )
617 + assert experts.supports_routing_replay_capture()
618 +
619 + captured: list[torch.Tensor] = []
620 + experts.set_capture_fn(lambda r: captured.append(r.clone()))
621 +
622 + # Per-token / per-block hidden scales (ones is fine for the routing
623 + # path; the GEMM output isn't being asserted on).
624 + hidden_states = (
625 + torch.randn(num_tokens, hidden_size, device=device, dtype=torch.bfloat16) * 0.1
626 + ).to(torch.float8_e4m3fn)
627 + hidden_states_scale = torch.ones(
628 + num_tokens, hidden_size // 128, device=device, dtype=torch.float32
629 + )
630 + router_logits = torch.rand(
631 + num_tokens, num_experts, device=device, dtype=torch.float32
632 + )
633 + routing_bias = _make_dsv3_routing_bias(num_experts, device)
634 +
635 + _ = _run_fp8_block_scale_monolithic(
636 + experts,
637 + hidden_states_fp8=hidden_states,
638 + hidden_states_scale=hidden_states_scale,
639 + w13=w13,
640 + w2=w2,
641 + router_logits=router_logits,
642 + num_experts=num_experts,
643 + routing_bias=routing_bias,
644 + )
645 +
646 + assert len(captured) == 1
647 + replay = captured[0]
648 + assert replay.dtype == torch.int16
649 + assert replay.shape == (num_tokens, top_k)
650 + assert (replay >= 0).all(), f"got out-of-range values: {replay}"
651 + assert (replay < num_experts).all(), f"got out-of-range values: {replay}"
652 + for t in range(num_tokens):
653 + unique = replay[t].unique()
654 + assert unique.numel() == top_k, (
655 + f"token {t}: expected {top_k} distinct experts, "
656 + f"got {unique.numel()} ({replay[t].tolist()})"
657 + )
658 +
659 +
660 +# ----------------------------------------------------------------------------
661 +# NVFP4 — vLLM's ``TrtLlmNvFp4ExpertsMonolithic``
662 +# ----------------------------------------------------------------------------
663 +
664 +
665 +def _make_nvfp4_monolithic_experts(
666 + num_experts: int,
667 + top_k: int,
668 + hidden_size: int,
669 + intermediate_size: int,
670 + device: torch.device,
671 +) -> tuple[
672 + TrtLlmNvFp4ExpertsMonolithic,
673 + torch.Tensor, # w13 (packed nvfp4 uint8)
674 + torch.Tensor, # w13 block-scale (fp8)
675 + torch.Tensor, # w2 (packed nvfp4 uint8)
676 + torch.Tensor, # w2 block-scale (fp8)
677 + torch.Tensor, # input global scale (per-tensor float32)
678 +]:
679 + """Set up ``TrtLlmNvFp4ExpertsMonolithic`` with NVFP4-quantized weights
680 + and DSV3 routing.
681 +
682 + NVFP4 = per-block-of-16 fp4 with an fp8 scale, plus a per-tensor
683 + "global" scale. We follow the layout in ``test_ocp_mx_moe.py`` /
684 + ``flashinfer/tests/moe/test_trtllm_gen_routed_fused_moe.py``:
685 + * weights: uint8 (packed fp4) ``(E, M, K//2)``
686 + * weight scales: fp8 ``(E, M, K//16)``
687 + * hidden states: uint8 (packed fp4) ``(N, K//2)``
688 + * hidden state scales: fp8 ``(N, K//16)``
689 + """
690 + from flashinfer import fp4_quantize
691 +
692 + block_size = 16
693 + parallel_cfg = FusedMoEParallelConfig.make_no_parallel()
694 + moe_config = FusedMoEConfig(
695 + num_experts=num_experts,
696 + experts_per_token=top_k,
697 + hidden_dim=hidden_size,
698 + intermediate_size=intermediate_size,
699 + num_local_experts=num_experts,
700 + num_logical_experts=num_experts,
701 + moe_parallel_config=parallel_cfg,
702 + in_dtype=torch.bfloat16,
703 + activation=MoEActivation.SILU,
704 + device=device,
705 + routing_method=RoutingMethodType.DeepSeekV3,
706 + max_num_tokens=max(8, 1),
707 + )
708 +
709 + gemm1 = torch.randn(
710 + num_experts,
711 + 2 * intermediate_size,
712 + hidden_size,
713 + device=device,
714 + dtype=torch.bfloat16,
715 + )
716 + gemm2 = torch.randn(
717 + num_experts,
718 + hidden_size,
719 + intermediate_size,
720 + device=device,
721 + dtype=torch.bfloat16,
722 + )
723 + # Per-tensor weight scaling factor (used to build ``g1_alphas`` /
724 + # ``g2_alphas`` below).
725 + w_global_scale = torch.tensor(1.0, device=device)
726 + # Per-tensor input scaling factor.
727 + a_global_scale = torch.tensor(1.0, device=device)
728 +
729 + w13_q, w13_scale = fp4_quantize(
730 + gemm1,
731 + w_global_scale,
732 + block_size,
733 + sf_use_ue8m0=False,
734 + is_sf_swizzled_layout=False,
735 + )
736 + w13_scale = w13_scale.view(torch.float8_e4m3fn).reshape(
737 + num_experts, 2 * intermediate_size, hidden_size // block_size
738 + )
739 + w2_q, w2_scale = fp4_quantize(
740 + gemm2,
741 + w_global_scale,
742 + block_size,
743 + sf_use_ue8m0=False,
744 + is_sf_swizzled_layout=False,
745 + )
746 + w2_scale = w2_scale.view(torch.float8_e4m3fn).reshape(
747 + num_experts, hidden_size, intermediate_size // block_size
748 + )
749 +
750 + # NVFP4 dq scale chain: g1_alphas = w1_scale_2 * a1_scale_2,
751 + # g2_alphas = w2_scale_2 * a2_scale_2. The kernel multiplies by these.
752 + g_alphas = torch.full((num_experts,), 1.0, device=device, dtype=torch.float32)
753 + a2_gscale = torch.full((num_experts,), 1.0, device=device, dtype=torch.float32)
754 +
755 + quant_config = FusedMoEQuantConfig.make(
756 + quant_dtype="nvfp4",
757 + per_act_token_quant=False,
758 + per_out_ch_quant=False,
759 + block_shape=None,
760 + w1_scale=w13_scale,
761 + w2_scale=w2_scale,
762 + g1_alphas=g_alphas,
763 + g2_alphas=g_alphas,
764 + a1_gscale=a_global_scale,
765 + a2_gscale=a2_gscale,
766 + )
767 +
768 + experts = TrtLlmNvFp4ExpertsMonolithic(
769 + moe_config=moe_config, quant_config=quant_config
770 + )
771 + return experts, w13_q, w13_scale, w2_q, w2_scale, a_global_scale
772 +
773 +
774 +def _run_nvfp4_monolithic(
775 + experts: TrtLlmNvFp4ExpertsMonolithic,
776 + hidden_states_q: torch.Tensor,
777 + hidden_states_scale: torch.Tensor,
778 + router_logits: torch.Tensor,
779 + num_experts: int,
780 + routing_bias: torch.Tensor,
781 +) -> torch.Tensor:
782 + """The monolithic NVFP4 apply expects packed fp4 hidden states + the
783 + matching fp8 per-block scale stored in the ``a1q_scale`` slot."""
784 + # Stash the weight tensors on the experts in the locations the apply()
785 + # implementation reads from (it pulls them from quant_config / scales
786 + # already; w1/w2 come in as args).
787 + return experts.apply(
788 + hidden_states=hidden_states_q,
789 + w1=experts._w13_packed,
790 + w2=experts._w2_packed,
791 + router_logits=router_logits,
792 + activation=MoEActivation.SILU,
793 + global_num_experts=num_experts,
794 + expert_map=None,
795 + a1q_scale=hidden_states_scale,
796 + apply_router_weight_on_input=False,
797 + num_expert_group=_DSV3_N_GROUP,
798 + topk_group=_DSV3_TOPK_GROUP,
799 + e_score_correction_bias=routing_bias,
800 + routed_scaling_factor=1.0,
801 + )
802 +
803 +
804 +@pytest.mark.parametrize("num_tokens", [2, 7, 16])
805 +@pytest.mark.parametrize("top_k", [2, 4])
806 +def test_trtllm_nvfp4_monolithic_routing_replay_records_valid_experts(
807 + num_tokens: int,
808 + top_k: int,
809 +) -> None:
810 + """End-to-end: ``TrtLlmNvFp4ExpertsMonolithic`` captures valid expert IDs
811 + on the DSV3 routing path."""
812 + if top_k > _DSV3_N_GROUP * _DSV3_TOPK_GROUP:
813 + pytest.skip(
814 + f"DSV3 requires top_k <= n_group * topk_group "
815 + f"({_DSV3_N_GROUP * _DSV3_TOPK_GROUP})"
816 + )
817 + from flashinfer import fp4_quantize
818 +
819 + torch.manual_seed(0)
820 + device = torch.device("cuda:0")
821 +
822 + num_experts = _DSV3_NUM_EXPERTS
823 + hidden_size = 1024
824 + intermediate_size = 1024
825 + block_size = 16
826 +
827 + experts, w13_q, _w13_s, w2_q, _w2_s, a_gs = _make_nvfp4_monolithic_experts(
828 + num_experts=num_experts,
829 + top_k=top_k,
830 + hidden_size=hidden_size,
831 + intermediate_size=intermediate_size,
832 + device=device,
833 + )
834 + # The apply() reads w1/w2 from its args, but we keep them on the experts
835 + # for convenience of the helper.
836 + experts._w13_packed = w13_q
837 + experts._w2_packed = w2_q
838 +
839 + assert experts.supports_routing_replay_capture()
840 + captured: list[torch.Tensor] = []
841 + experts.set_capture_fn(lambda r: captured.append(r.clone()))
842 +
843 + hidden_states = (
844 + torch.randn(num_tokens, hidden_size, device=device, dtype=torch.bfloat16) * 0.1
845 + )
846 + hidden_states_q, hidden_states_scale = fp4_quantize(
847 + hidden_states,
848 + a_gs,
849 + block_size,
850 + sf_use_ue8m0=False,
851 + is_sf_swizzled_layout=False,
852 + )
853 + # The vLLM apply() does the .view(fp8_e4m3fn).reshape itself, so leave
854 + # ``hidden_states_scale`` in its native (uint8 packed) form.
855 + router_logits = torch.rand(
856 + num_tokens, num_experts, device=device, dtype=torch.float32
857 + )
858 + routing_bias = _make_dsv3_routing_bias(num_experts, device)
859 +
860 + _ = _run_nvfp4_monolithic(
861 + experts,
862 + hidden_states_q=hidden_states_q,
863 + hidden_states_scale=hidden_states_scale,
864 + router_logits=router_logits,
865 + num_experts=num_experts,
866 + routing_bias=routing_bias,
867 + )
868 +
869 + assert len(captured) == 1
870 + replay = captured[0]
871 + assert replay.dtype == torch.int16
872 + assert replay.shape == (num_tokens, top_k)
873 + assert (replay >= 0).all(), f"got out-of-range values: {replay}"
874 + assert (replay < num_experts).all(), f"got out-of-range values: {replay}"
875 + for t in range(num_tokens):
876 + unique = replay[t].unique()
877 + assert unique.numel() == top_k, (
878 + f"token {t}: expected {top_k} distinct experts, "
879 + f"got {unique.numel()} ({replay[t].tolist()})"
880 + )
tests/model_executor/test_routed_experts_capture.py
+55
@@ -66,6 +66,12 @@ def _make_router(eplb_state: EplbLayerState | None = None) -> DummyRouter:
66 )
67
68
69 +def _make_modular_routed_experts():
70 + return types.SimpleNamespace(
71 + quant_method=types.SimpleNamespace(is_monolithic=False),
72 + )
73 +
74 +
75 def test_base_router_capture_pre_eplb_mapping():
76 router = _make_router()
77 captured = []
@@ -122,6 +128,8 @@ def test_gpu_model_runner_binds_router_capture(monkeypatch):
128 def __init__(self):
129 self.layer_id = 7
130 self.router = _make_router()
131 + self.routed_experts = _make_modular_routed_experts()
132 + self._quant_method = self.routed_experts.quant_method
133
134 class DummyCapturer:
135 def __init__(self):
@@ -160,6 +168,8 @@ def test_gpu_model_runner_binding_stage(monkeypatch):
168 def __init__(self):
169 self.layer_id = 11
170 self.router = _make_router()
171 + self.routed_experts = _make_modular_routed_experts()
172 + self._quant_method = self.routed_experts.quant_method
173
174 class DummyCapturer:
175 def __init__(self):
@@ -197,6 +207,8 @@ def test_gpu_model_runner_does_not_bind_draft_router_capture(monkeypatch):
207 def __init__(self, layer_id):
208 self.layer_id = layer_id
209 self.router = _make_router()
210 + self.routed_experts = _make_modular_routed_experts()
211 + self._quant_method = self.routed_experts.quant_method
212
213 target_module = DummyFusedMoE(layer_id=7)
214 draft_module = DummyFusedMoE(layer_id=0)
@@ -222,6 +234,49 @@ def test_gpu_model_runner_does_not_bind_draft_router_capture(monkeypatch):
234 assert draft_module.router.capture_fn is None
235
236
237 +def test_gpu_model_runner_rejects_monolithic_without_replay_support(monkeypatch):
238 + from vllm.v1.worker import gpu_model_runner as gmr
239 +
240 + class DummyFusedMoE:
241 + def __init__(self):
242 + self.layer_id = 3
243 + self.router = _make_router()
244 + # Use a concrete monolithic expert and override its capability
245 + # instead of instantiating the abstract base class directly.
246 + from vllm.model_executor.layers.fused_moe.experts.cpu_moe import (
247 + CPUExpertsFp8,
248 + )
249 +
250 + fused_experts = CPUExpertsFp8.__new__(CPUExpertsFp8)
251 + self.routed_experts = types.SimpleNamespace(
252 + quant_method=types.SimpleNamespace(
253 + is_monolithic=True,
254 + moe_kernel=types.SimpleNamespace(
255 + impl=types.SimpleNamespace(fused_experts=fused_experts)
256 + ),
257 + )
258 + )
259 + self._quant_method = self.routed_experts.quant_method
260 + self._quant_method.moe_kernel.impl.fused_experts = fused_experts
261 + fused_experts.supports_routing_replay_capture = lambda: False
262 +
263 + class DummyCapturer:
264 + def capture(self, layer_id, topk_ids):
265 + pass
266 +
267 + dummy_module = DummyFusedMoE()
268 + import vllm.model_executor.layers.fused_moe.layer as fused_moe_layer
269 +
270 + monkeypatch.setattr(fused_moe_layer, "MoERunner", DummyFusedMoE)
271 +
272 + dummy_self = types.SimpleNamespace(
273 + model=types.SimpleNamespace(modules=lambda: [dummy_module])
274 + )
275 +
276 + with pytest.raises(ValueError, match="monolithic MoE kernel"):
277 + gmr.GPUModelRunner._bind_routed_experts_capturer(dummy_self, DummyCapturer())
278 +
279 +
280 def test_routed_experts_capturer_single_dp_no_metadata():
281 """dp_metadata is None: capture writes the full topk_ids rows."""
282 capturer = _capturer_with_buffer(dp_rank=0)
vllm/model_executor/layers/fused_moe/experts/trtllm_bf16_moe.py
+13 -1
@@ -34,6 +34,9 @@ class TrtLlmBf16ExpertsBase:
34 monolithic interfaces.
35 """
36
37 + def supports_routing_replay_capture(self) -> bool:
38 + return True
39 +
40 def __init__(
41 self,
42 moe_config: FusedMoEConfig,
@@ -246,7 +249,11 @@ class TrtLlmBf16ExpertsMonolithic(TrtLlmBf16ExpertsBase, mk.FusedMoEExpertsMonol
249
250 assert activation in [MoEActivation.SILU, MoEActivation.RELU2_NO_MUL]
251
249 - return flashinfer.fused_moe.trtllm_bf16_moe(
252 + routing_replay_out = self._maybe_make_routing_replay_buffer(
253 + num_tokens=hidden_states.shape[0],
254 + device=hidden_states.device,
255 + )
256 + out = flashinfer.fused_moe.trtllm_bf16_moe(
257 routing_logits=router_logits,
258 routing_bias=e_score_correction_bias,
259 hidden_states=hidden_states,
@@ -263,4 +270,9 @@ class TrtLlmBf16ExpertsMonolithic(TrtLlmBf16ExpertsBase, mk.FusedMoEExpertsMonol
270 routing_method_type=self.routing_method_type,
271 activation_type=activation_to_flashinfer_int(activation),
272 tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
273 + routing_replay_out=routing_replay_out,
274 + )
275 + self._maybe_dispatch_routing_replay(
276 + routing_replay_out, num_tokens=hidden_states.shape[0]
277 )
278 + return out
vllm/model_executor/layers/fused_moe/experts/trtllm_fp8_moe.py
+23 -1
@@ -265,6 +265,9 @@ class TrtLlmFp8ExpertsMonolithic(TrtLlmFp8ExpertsBase, mk.FusedMoEExpertsMonolit
265 Fp8 TRTLLM-Gen MoE kernels. Supports monolithic interface.
266 """
267
268 + def supports_routing_replay_capture(self) -> bool:
269 + return True
270 +
271 def __init__(
272 self,
273 moe_config: FusedMoEConfig,
@@ -403,6 +406,11 @@ class TrtLlmFp8ExpertsMonolithic(TrtLlmFp8ExpertsBase, mk.FusedMoEExpertsMonolit
406 n_group = num_expert_group or 0
407 selected_topk_group = topk_group or 0
408
409 + routing_replay_out = self._maybe_make_routing_replay_buffer(
410 + num_tokens=hidden_states.shape[0],
411 + device=hidden_states.device,
412 + )
413 +
414 kwargs = dict(
415 routing_logits=router_logits,
416 routing_bias=e_score_correction_bias,
@@ -427,11 +435,16 @@ class TrtLlmFp8ExpertsMonolithic(TrtLlmFp8ExpertsBase, mk.FusedMoEExpertsMonolit
435 use_shuffled_weight=use_shuffled_weight,
436 weight_layout=weight_layout,
437 fp8_quantization_type=fp8_quant_type,
438 + routing_replay_out=routing_replay_out,
439 tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
440 )
441 if is_mxfp8 or activation == MoEActivation.RELU2_NO_MUL:
442 kwargs["activation_type"] = activation_type
434 - return flashinfer.fused_moe.trtllm_fp8_block_scale_moe(**kwargs)
443 + result = flashinfer.fused_moe.trtllm_fp8_block_scale_moe(**kwargs)
444 + self._maybe_dispatch_routing_replay(
445 + routing_replay_out, num_tokens=hidden_states.shape[0]
446 + )
447 + return result
448
449 def _apply_per_tensor(
450 self,
@@ -464,6 +477,11 @@ class TrtLlmFp8ExpertsMonolithic(TrtLlmFp8ExpertsBase, mk.FusedMoEExpertsMonolit
477 else:
478 assert not apply_router_weight_on_input
479
480 + routing_replay_out = self._maybe_make_routing_replay_buffer(
481 + num_tokens=hidden_states.shape[0],
482 + device=hidden_states.device,
483 + )
484 +
485 out = flashinfer.fused_moe.trtllm_fp8_per_tensor_scale_moe(
486 routing_logits=router_logits,
487 routing_bias=e_score_correction_bias,
@@ -485,6 +503,10 @@ class TrtLlmFp8ExpertsMonolithic(TrtLlmFp8ExpertsBase, mk.FusedMoEExpertsMonolit
503 routing_method_type=self.routing_method_type,
504 activation_type=activation_type,
505 tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
506 + routing_replay_out=routing_replay_out,
507 + )
508 + self._maybe_dispatch_routing_replay(
509 + routing_replay_out, num_tokens=hidden_states.shape[0]
510 )
511 return out
512
vllm/model_executor/layers/fused_moe/experts/trtllm_mxfp4_moe.py
+11 -1
@@ -122,6 +122,9 @@ class TrtLlmMxfp4ExpertsMonolithic(
122 Wraps flashinfer.trtllm_fp4_block_scale_moe().
123 """
124
125 + def supports_routing_replay_capture(self) -> bool:
126 + return True
127 +
128 @staticmethod
129 def _supports_parallel_config(
130 moe_parallel_config: FusedMoEParallelConfig,
@@ -184,6 +187,10 @@ class TrtLlmMxfp4ExpertsMonolithic(
187 device=hidden_states.device,
188 )
189
190 + routing_replay_out = self._maybe_make_routing_replay_buffer(
191 + num_tokens=hidden_states.shape[0],
192 + device=hidden_states.device,
193 + )
194 trtllm_fp4_block_scale_moe(
195 routing_logits=router_logits.to(torch.bfloat16),
196 routing_bias=None,
@@ -213,8 +220,11 @@ class TrtLlmMxfp4ExpertsMonolithic(
220 do_finalize=True,
221 tune_max_num_tokens=max(self.max_capture_size, 1),
222 output=output,
223 + routing_replay_out=routing_replay_out,
224 + )
225 + self._maybe_dispatch_routing_replay(
226 + routing_replay_out, num_tokens=hidden_states.shape[0]
227 )
217 -
228 return output
229
230
vllm/model_executor/layers/fused_moe/experts/trtllm_mxint4_moe.py
+14 -1
@@ -122,6 +122,9 @@ class TrtLlmMxint4ExpertsMonolithic(mk.FusedMoEExpertsMonolithic):
122 # The kernel handles quantization internally.
123 return True
124
125 + def supports_routing_replay_capture(self) -> bool:
126 + return True
127 +
128 def apply(
129 self,
130 hidden_states: torch.Tensor,
@@ -144,7 +147,12 @@ class TrtLlmMxint4ExpertsMonolithic(mk.FusedMoEExpertsMonolithic):
147
148 assert self.w1_scale is not None
149 assert self.w2_scale is not None
147 - return flashinfer_trtllm_mxint4_moe(
150 +
151 + routing_replay_out = self._maybe_make_routing_replay_buffer(
152 + num_tokens=hidden_states.shape[0],
153 + device=hidden_states.device,
154 + )
155 + result = flashinfer_trtllm_mxint4_moe(
156 x=hidden_states,
157 router_logits=router_logits,
158 w13_weight_packed=w1,
@@ -160,4 +168,9 @@ class TrtLlmMxint4ExpertsMonolithic(mk.FusedMoEExpertsMonolithic):
168 topk_group=topk_group,
169 e_score_correction_bias=e_score_correction_bias,
170 routing_method_type=self.routing_method,
171 + routing_replay_out=routing_replay_out,
172 + )
173 + self._maybe_dispatch_routing_replay(
174 + routing_replay_out, num_tokens=hidden_states.shape[0]
175 )
176 + return result
vllm/model_executor/layers/fused_moe/experts/trtllm_nvfp4_moe.py
+13 -1
@@ -435,6 +435,9 @@ class TrtLlmNvFp4ExpertsMonolithic(
435 Monolithic version of the kernel (router + experts).
436 """
437
438 + def supports_routing_replay_capture(self) -> bool:
439 + return True
440 +
441 @staticmethod
442 def _supports_parallel_config(moe_parallel_config: FusedMoEParallelConfig) -> bool:
443 """The modular implementation should be used for the Dp/Ep or EPLB case."""
@@ -509,10 +512,14 @@ class TrtLlmNvFp4ExpertsMonolithic(
512
513 output1_scale_gate_scalar = self.quant_config.g1_alphas
514
515 + routing_replay_out = self._maybe_make_routing_replay_buffer(
516 + num_tokens=hidden_states.shape[0],
517 + device=hidden_states.device,
518 + )
519 # Invoke kernel.
520 # NOTE: Activation padding and output
521 # truncation are handled by the MoE runner's
515 - return flashinfer.fused_moe.trtllm_fp4_block_scale_moe(
522 + result = flashinfer.fused_moe.trtllm_fp4_block_scale_moe(
523 routing_logits=router_logits,
524 routing_bias=e_score_correction_bias,
525 hidden_states=hidden_states,
@@ -544,4 +551,9 @@ class TrtLlmNvFp4ExpertsMonolithic(
551 activation_type=activation_to_flashinfer_int(activation),
552 per_token_scale=per_token_scale,
553 tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
554 + routing_replay_out=routing_replay_out,
555 )[0]
556 + self._maybe_dispatch_routing_replay(
557 + routing_replay_out, num_tokens=hidden_states.shape[0]
558 + )
559 + return result
vllm/model_executor/layers/fused_moe/modular_kernel.py
+51
@@ -1000,6 +1000,57 @@ class FusedMoEExpertsMonolithic(FusedMoEExperts):
1000 def is_monolithic() -> bool:
1001 return True
1002
1003 + routing_replay_capture_fn: Callable[[torch.Tensor], None] | None = None
1004 + _routing_replay_buffer: torch.Tensor | None = None
1005 +
1006 + def supports_routing_replay_capture(self) -> bool:
1007 + """Whether this expert supports routing replay capture.
1008 +
1009 + Subclasses backed by a kernel that exposes routed expert IDs
1010 + (e.g. FlashInfer's ``routing_replay_out``) should override.
1011 + """
1012 + return False
1013 +
1014 + def set_capture_fn(
1015 + self,
1016 + capture_fn: Callable[[torch.Tensor], None] | None,
1017 + ) -> None:
1018 + self.routing_replay_capture_fn = capture_fn
1019 + if capture_fn is None:
1020 + self._routing_replay_buffer = None
1021 + return
1022 + self._routing_replay_buffer = torch.empty(
1023 + (self.moe_config.max_num_tokens, self.moe_config.experts_per_token),
1024 + dtype=torch.int16,
1025 + device=self.moe_config.device,
1026 + )
1027 +
1028 + def _maybe_make_routing_replay_buffer(
1029 + self,
1030 + num_tokens: int,
1031 + device: torch.device,
1032 + ) -> torch.Tensor | None:
1033 + if self.routing_replay_capture_fn is None:
1034 + return None
1035 + buf = self._routing_replay_buffer
1036 + assert buf is not None
1037 + if buf.shape[0] < num_tokens or buf.device != device:
1038 + raise ValueError(
1039 + "Routing replay buffer was initialized for "
1040 + f"{buf.shape[0]} tokens on {buf.device}, but the kernel "
1041 + f"received {num_tokens} tokens on {device}."
1042 + )
1043 + return buf
1044 +
1045 + def _maybe_dispatch_routing_replay(
1046 + self,
1047 + routing_replay_out: torch.Tensor | None,
1048 + num_tokens: int,
1049 + ) -> None:
1050 + if routing_replay_out is None or self.routing_replay_capture_fn is None:
1051 + return
1052 + self.routing_replay_capture_fn(routing_replay_out[:num_tokens])
1053 +
1054 def apply(
1055 self,
1056 hidden_states: torch.Tensor,
vllm/model_executor/layers/fused_moe/routed_experts_capturer.py
+11 -1
@@ -90,10 +90,20 @@ class RoutedExpertsCapturer:
90 ) -> None:
91 hf_config = vllm_config.model_config.hf_text_config
92 num_experts_per_tok = _get_num_experts_per_tok(hf_config)
93 + num_layers = hf_config.num_hidden_layers
94 + logger.info(
95 + "RoutedExpertsCapturer: allocating buffer with "
96 + "max_tokens=%d, num_layers=%d, num_experts_per_tok=%d "
97 + "(hf_config.model_type=%s)",
98 + max_num_batched_tokens,
99 + num_layers,
100 + num_experts_per_tok,
101 + getattr(hf_config, "model_type", "unknown"),
102 + )
103 self.device_buffer = torch.zeros(
104 (
105 max_num_batched_tokens,
96 - hf_config.num_hidden_layers,
106 + num_layers,
107 num_experts_per_tok,
108 ),
109 # Use int32 for the device / host transit buffers: it
vllm/model_executor/layers/quantization/utils/flashinfer_mxint4_moe.py
+2
@@ -190,6 +190,7 @@ def flashinfer_trtllm_mxint4_moe(
190 topk_group: int | None = None,
191 e_score_correction_bias: torch.Tensor | None = None,
192 routing_method_type: int | None = None,
193 + routing_replay_out: torch.Tensor | None = None,
194 ) -> torch.Tensor:
195 """
196 Apply FlashInfer TensorRT-LLM MxInt4 MoE kernel.
@@ -262,6 +263,7 @@ def flashinfer_trtllm_mxint4_moe(
263 do_finalize=True,
264 output=None,
265 tune_max_num_tokens=8192,
266 + routing_replay_out=routing_replay_out,
267 )
268 if isinstance(out, (tuple, list)):
269 out = out[0]
vllm/v1/worker/gpu_model_runner.py
+26 -6
@@ -7676,17 +7676,37 @@ class GPUModelRunner(
7676
7677 def _bind_routed_experts_capturer(self, capturer: RoutedExpertsCapturer) -> None:
7678 from vllm.model_executor.layers.fused_moe.layer import MoERunner
7679 + from vllm.model_executor.layers.fused_moe.modular_kernel import (
7680 + FusedMoEExpertsMonolithic,
7681 + )
7682 from vllm.model_executor.layers.fused_moe.router.base_router import (
7683 BaseRouter,
7684 )
7685
7686 for module in self.model.modules():
7684 - if isinstance(module, MoERunner) and isinstance(module.router, BaseRouter):
7685 - layer_id = module.layer_id
7686 -
7687 - def _capture_fn(topk_ids, _layer_id=layer_id, _capturer=capturer):
7688 - _capturer.capture(_layer_id, topk_ids)
7689 -
7687 + if not isinstance(module, MoERunner):
7688 + continue
7689 + layer_id = module.layer_id
7690 +
7691 + def _capture_fn(topk_ids, _layer_id=layer_id, _capturer=capturer):
7692 + _capturer.capture(_layer_id, topk_ids)
7693 +
7694 + quant_method = module._quant_method
7695 + moe_kernel = getattr(quant_method, "moe_kernel", None)
7696 + impl = getattr(moe_kernel, "impl", None)
7697 + fused_experts = getattr(impl, "fused_experts", None)
7698 + if quant_method.is_monolithic:
7699 + if not (
7700 + isinstance(fused_experts, FusedMoEExpertsMonolithic)
7701 + and fused_experts.supports_routing_replay_capture()
7702 + ):
7703 + raise ValueError(
7704 + "--enable-return-routed-experts is not supported with "
7705 + f"monolithic MoE kernel {type(fused_experts).__name__}; "
7706 + "routed expert IDs would be silently all-zero."
7707 + )
7708 + fused_experts.set_capture_fn(_capture_fn)
7709 + elif isinstance(module.router, BaseRouter):
7710 module.router.set_capture_fn(_capture_fn)
7711
7712 def may_add_encoder_only_layers_to_kv_cache_config(self) -> None: