[K3 Perf] Fuse MXFP4 top-k finalization into latent-tail, ~5% E2E latency reduction (#53152)

Signed-off-by: yewentao256 <zhyanwentao@126.com> Signed-off-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: Yongye Zhu <zyy1102000@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Wentao Ye committed Aug 21, 2026 at 13:29 UTC 7a2fdbaac44921f57b04e53d6f49ed594c2209b6
17 files changed +720 -139
benchmarks/kernels/benchmark_kimi_k3_latent_moe_tail.py
+89 -17
@@ -21,6 +21,10 @@ Examples:
21 torchrun --nproc-per-node=8 \
22 benchmarks/kernels/benchmark_kimi_k3_latent_moe_tail.py whole-tail
23
24 + torchrun --nproc-per-node=8 \
25 + benchmarks/kernels/benchmark_kimi_k3_latent_moe_tail.py whole-tail \
26 + --backend fused --routed-input both
27 +
28 For multi-node runs, launch one ``torchrun`` agent per node and use a shared
29 rendezvous endpoint.
30 """
@@ -50,6 +54,7 @@ from vllm.distributed.parallel_state import (
54 initialize_model_parallel,
55 set_custom_all_reduce,
56 )
57 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
58 from vllm.model_executor.warmup.cutedsl_warmup import cutedsl_warmup
59 from vllm.models.kimi_k3.nvidia.ops import latent_moe_tail
60 from vllm.models.kimi_k3.nvidia.ops.cute_dsl.latent_moe_tail import (
@@ -165,6 +170,18 @@ def parse_args() -> argparse.Namespace:
170 nargs="+",
171 default=[1, 5, 8, 16],
172 )
173 + whole_tail.add_argument(
174 + "--routed-input",
175 + choices=("finalized", "deferred", "both"),
176 + default="finalized",
177 + help="Select whether the fused tail performs the top-k finalize step.",
178 + )
179 + whole_tail.add_argument(
180 + "--top-k",
181 + type=int,
182 + default=8,
183 + help="Expert count used by the synthetic deferred-finalize input.",
184 + )
185 whole_tail.add_argument("--warmup-replays", type=int, default=20)
186 whole_tail.add_argument("--samples", type=int, default=51)
187 whole_tail.add_argument(
@@ -586,6 +603,36 @@ def make_inputs(
603 return routed, shared
604
605
606 +def make_deferred_input(
607 + routed: torch.Tensor,
608 + top_k: int,
609 +) -> UnfinalizedMoEOutput:
610 + num_tokens = routed.shape[0]
611 + gemm2_permuted = (
612 + routed[:, None, :]
613 + .expand(num_tokens, top_k, LATENT_SIZE)
614 + .contiguous()
615 + .view(num_tokens * top_k, LATENT_SIZE)
616 + )
617 + expert_weights = torch.zeros(
618 + num_tokens,
619 + top_k,
620 + dtype=routed.dtype,
621 + device=routed.device,
622 + )
623 + expert_weights[:, 0] = 1
624 + expanded_idx = torch.arange(
625 + num_tokens * top_k,
626 + dtype=torch.int32,
627 + device=routed.device,
628 + ).view(num_tokens, top_k)
629 + return UnfinalizedMoEOutput(
630 + gemm2_permuted=gemm2_permuted,
631 + expert_weights=expert_weights,
632 + expanded_idx_to_permuted_idx=expanded_idx,
633 + )
634 +
635 +
636 def make_reference(
637 routed: torch.Tensor,
638 shared: torch.Tensor,
@@ -628,6 +675,8 @@ def benchmark_whole_tail(args: argparse.Namespace) -> None:
675 raise ValueError("--num-tokens values must be in [1, 16]")
676 if args.warmup_replays < 0 or args.samples <= 0:
677 raise ValueError("warmup replays must be nonnegative and samples positive")
678 + if args.top_k <= 0:
679 + raise ValueError("--top-k must be positive")
680 if args.skinny_max_num_tokens is not None and any(
681 not 0 <= cutoff <= 8 for cutoff in args.skinny_max_num_tokens
682 ):
@@ -672,6 +721,11 @@ def benchmark_whole_tail(args: argparse.Namespace) -> None:
721
722 use_reference = args.backend in ("reference", "both")
723 use_fused = args.backend in ("fused", "both")
724 + routed_input_modes = (
725 + ("finalized", "deferred")
726 + if args.routed_input == "both"
727 + else (args.routed_input,)
728 + )
729 fused_ops = []
730 if use_fused:
731 production_config_for_m = fused_add_multicast_skinny_gemm.config_for_m
@@ -690,23 +744,33 @@ def benchmark_whole_tail(args: argparse.Namespace) -> None:
744 for cutoff in cutoffs:
745 latent_moe_tail._SKINNY_MAX_NUM_TOKENS = cutoff
746 latent_moe_tail.KimiK3LatentMoETailOp._instances.clear()
693 - fused_ops.append(
694 - (
695 - cutoff,
696 - latent_moe_tail.KimiK3LatentMoETailOp.initialize(
697 - hidden_size=HIDDEN_SIZE,
698 - latent_size=LATENT_SIZE,
699 - dtype=torch.bfloat16,
700 - device=device,
701 - rms_eps=RMS_EPS,
702 - ),
747 + for routed_input_mode in routed_input_modes:
748 + fused_ops.append(
749 + (
750 + cutoff,
751 + routed_input_mode,
752 + latent_moe_tail.KimiK3LatentMoETailOp.initialize(
753 + hidden_size=HIDDEN_SIZE,
754 + latent_size=LATENT_SIZE,
755 + dtype=torch.bfloat16,
756 + device=device,
757 + rms_eps=RMS_EPS,
758 + experts_per_token=(
759 + args.top_k if routed_input_mode == "deferred" else 0
760 + ),
761 + ),
762 + )
763 )
704 - )
764 cutedsl_warmup()
765
766 results = []
767 for num_tokens in args.num_tokens:
768 routed, shared = make_inputs(num_tokens, rank, device)
769 + routed_inputs: dict[str, torch.Tensor | UnfinalizedMoEOutput] = {
770 + "finalized": routed
771 + }
772 + if "deferred" in routed_input_modes:
773 + routed_inputs["deferred"] = make_deferred_input(routed, args.top_k)
774 reference = make_reference(
775 routed,
776 shared,
@@ -724,17 +788,23 @@ def benchmark_whole_tail(args: argparse.Namespace) -> None:
788 device_group=device_group,
789 cpu_group=cpu_group,
790 )
727 - for cutoff, fused_op in fused_ops:
791 + for cutoff, routed_input_mode, fused_op in fused_ops:
792 + routed_input = routed_inputs[routed_input_mode]
793
794 def fused(
730 - routed: torch.Tensor = routed,
795 + routed_input: torch.Tensor | UnfinalizedMoEOutput = routed_input,
796 shared: torch.Tensor = shared,
797 fused_op: latent_moe_tail.KimiK3LatentMoETailOp = fused_op,
798 ) -> torch.Tensor:
734 - return fused_op(routed, shared, rms_weight, up_weight)
799 + return fused_op(routed_input, shared, rms_weight, up_weight)
800
801 fused_graph, fused_output = capture_tail_graph(fused, cpu_group)
737 - fused_key = "fused" if len(fused_ops) == 1 else f"fused_skinny_max_{cutoff}"
802 + fused_key_parts = ["fused"]
803 + if routed_input_mode != "finalized" or len(routed_input_modes) > 1:
804 + fused_key_parts.append(routed_input_mode)
805 + if len(cutoffs) > 1:
806 + fused_key_parts.extend(("skinny", "max", str(cutoff)))
807 + fused_key = "_".join(fused_key_parts)
808 result[fused_key] = benchmark_tail_graph(
809 fused_graph,
810 warmup_replays=args.warmup_replays,
@@ -747,7 +817,7 @@ def benchmark_whole_tail(args: argparse.Namespace) -> None:
817 speedup = (
818 result["reference"]["median_us"] / result[fused_key]["median_us"]
819 )
750 - if len(fused_ops) == 1:
820 + if fused_key == "fused":
821 result["speedup"] = speedup
822 else:
823 result[f"{fused_key}_speedup"] = speedup
@@ -763,7 +833,9 @@ def benchmark_whole_tail(args: argparse.Namespace) -> None:
833 "cuda_version": torch.version.cuda,
834 "warmup_replays": args.warmup_replays,
835 "samples": args.samples,
766 - "skinny_max_num_tokens": [cutoff for cutoff, _ in fused_ops],
836 + "routed_input": args.routed_input,
837 + "top_k": args.top_k,
838 + "skinny_max_num_tokens": sorted({cutoff for cutoff, _, _ in fused_ops}),
839 "skinny_configs": {
840 str(num_tokens): asdict(config)
841 for num_tokens, config in skinny_configs.items()
tests/models/kimi_k3/test_latent_moe_tail.py
+152
@@ -13,6 +13,7 @@ from tests.utils import (
13 multi_process_parallel,
14 )
15 from vllm.distributed import get_tp_group
16 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
17 from vllm.model_executor.warmup.cutedsl_warmup import cutedsl_warmup
18 from vllm.models.kimi_k3.nvidia.ops.latent_moe_tail import KimiK3LatentMoETailOp
19 from vllm.platforms import current_platform
@@ -20,6 +21,57 @@ from vllm.platforms import current_platform
21 HIDDEN_SIZE = 7168
22 LATENT_SIZE = 3584
23 EPS = 0.1
24 +TOP_K = 8
25 +
26 +
27 +def _make_deferred_routed_output(
28 + num_tokens: int,
29 + device: torch.device,
30 +) -> tuple[UnfinalizedMoEOutput, torch.Tensor]:
31 + num_routes = num_tokens * TOP_K
32 + num_permuted_rows = num_routes + 7
33 + expanded_output = torch.randn(
34 + num_routes,
35 + LATENT_SIZE,
36 + device=device,
37 + dtype=torch.bfloat16,
38 + ).mul_(0.01)
39 + expert_weights = torch.rand(
40 + num_tokens,
41 + TOP_K,
42 + device=device,
43 + dtype=torch.bfloat16,
44 + )
45 + expert_weights.div_(expert_weights.sum(dim=-1, keepdim=True))
46 + expanded_idx = torch.randperm(num_permuted_rows, device=device)[:num_routes]
47 + gemm2_permuted = torch.empty(
48 + num_permuted_rows,
49 + LATENT_SIZE,
50 + device=device,
51 + dtype=torch.bfloat16,
52 + )
53 + gemm2_permuted[expanded_idx] = expanded_output
54 +
55 + finalized = torch.zeros(
56 + num_tokens,
57 + LATENT_SIZE,
58 + device=device,
59 + dtype=torch.bfloat16,
60 + )
61 + expanded_output = expanded_output.view(num_tokens, TOP_K, LATENT_SIZE)
62 + for slot in range(TOP_K):
63 + finalized.add_(expanded_output[:, slot] * expert_weights[:, slot, None])
64 +
65 + return (
66 + UnfinalizedMoEOutput(
67 + gemm2_permuted=gemm2_permuted,
68 + expert_weights=expert_weights,
69 + expanded_idx_to_permuted_idx=expanded_idx.to(torch.int32).view(
70 + num_tokens, TOP_K
71 + ),
72 + ),
73 + finalized,
74 + )
75
76
77 @ray.remote(num_gpus=1, max_calls=1)
@@ -131,6 +183,99 @@ def _run_latent_moe_tail_test(
183 )
184
185
186 +@ray.remote(num_gpus=1, max_calls=1)
187 +def _test_deferred_finalize_parity_worker(
188 + monkeypatch: pytest.MonkeyPatch,
189 + tp_size: int,
190 + pp_size: int,
191 + rank: int,
192 + distributed_init_port: str,
193 +) -> None:
194 + monkeypatch.delenv("CUDA_VISIBLE_DEVICES", raising=False)
195 + device = torch.device(f"cuda:{rank}")
196 + torch.accelerator.set_device_index(device)
197 + init_test_distributed_environment(
198 + tp_size,
199 + pp_size,
200 + rank,
201 + distributed_init_port,
202 + )
203 +
204 + torch.manual_seed(1000 + rank)
205 + rms_weight = 1 + 0.1 * torch.randn(
206 + LATENT_SIZE,
207 + device=device,
208 + dtype=torch.bfloat16,
209 + )
210 + up_weight = (
211 + torch.randn(
212 + HIDDEN_SIZE,
213 + LATENT_SIZE,
214 + device=device,
215 + dtype=torch.bfloat16,
216 + )
217 + / LATENT_SIZE**0.5
218 + )
219 + finalized_op = KimiK3LatentMoETailOp.initialize(
220 + hidden_size=HIDDEN_SIZE,
221 + latent_size=LATENT_SIZE,
222 + dtype=torch.bfloat16,
223 + device=device,
224 + rms_eps=EPS,
225 + )
226 + deferred_op = KimiK3LatentMoETailOp.initialize(
227 + hidden_size=HIDDEN_SIZE,
228 + latent_size=LATENT_SIZE,
229 + dtype=torch.bfloat16,
230 + device=device,
231 + rms_eps=EPS,
232 + experts_per_token=TOP_K,
233 + )
234 + cutedsl_warmup()
235 +
236 + for iteration, num_tokens in enumerate((1, 5, 16)):
237 + torch.manual_seed(2000 + 100 * iteration + rank)
238 + deferred_output, finalized_output = _make_deferred_routed_output(
239 + num_tokens,
240 + device,
241 + )
242 + shared_output = torch.randn(
243 + num_tokens,
244 + HIDDEN_SIZE,
245 + device=device,
246 + dtype=torch.bfloat16,
247 + )
248 +
249 + expected = finalized_op(
250 + finalized_output,
251 + shared_output,
252 + rms_weight,
253 + up_weight,
254 + )
255 + actual = deferred_op(
256 + deferred_output,
257 + shared_output,
258 + rms_weight,
259 + up_weight,
260 + )
261 +
262 + torch.testing.assert_close(actual, expected, atol=8e-2, rtol=3e-2)
263 +
264 +
265 +def _run_deferred_finalize_parity_test(
266 + monkeypatch: pytest.MonkeyPatch,
267 + tp_size: int,
268 +) -> None:
269 + if not current_platform.is_device_capability_family(100):
270 + pytest.skip("K3 latent-MoE tail fusion requires SM100")
271 + multi_process_parallel(
272 + monkeypatch,
273 + tp_size,
274 + 1,
275 + _test_deferred_finalize_parity_worker,
276 + )
277 +
278 +
279 @multi_gpu_test(num_gpus=8)
280 def test_latent_moe_tail_tp8_matches_native_path(
281 monkeypatch: pytest.MonkeyPatch,
@@ -143,3 +288,10 @@ def test_latent_moe_tail_tp16_matches_native_path(
288 monkeypatch: pytest.MonkeyPatch,
289 ) -> None:
290 _run_latent_moe_tail_test(monkeypatch, 16)
291 +
292 +
293 +@multi_gpu_test(num_gpus=8)
294 +def test_latent_moe_tail_deferred_finalize_matches_finalized(
295 + monkeypatch: pytest.MonkeyPatch,
296 +) -> None:
297 + _run_deferred_finalize_parity_test(monkeypatch, 8)
vllm/model_executor/layers/fused_moe/config.py
+21 -3
@@ -1321,6 +1321,8 @@ class FusedMoEConfig:
1321 # consumer; read through `use_deferred_moe_finalize`, which applies the
1322 # guards. Kernels without the capability ignore it. Default False.
1323 defer_moe_finalize: bool = False
1324 + # Optional consumer capacity for deferred finalize. Negative means unbounded.
1325 + defer_moe_finalize_max_num_tokens: int = -1
1326
1327 # SwiGLU clamp limit. When set, backends that do not implement the clamp
1328 # are filtered out by `FusedMoEExperts.is_supported_config` so the oracle
@@ -1447,9 +1449,25 @@ class FusedMoEConfig:
1449 ``defer_moe_finalize`` is set after construction, like
1450 ``skip_final_all_reduce``.
1451 """
1450 - # Without TP there is no all-reduce for the top-k reduction to fuse
1451 - # into, so the deferred form buys nothing.
1452 - return self.defer_moe_finalize and self.tp_size > 1
1452 + # The consumer fuses a TP all-reduce. Other parallel modes require a
1453 + # combine or reduce-scatter after the experts and cannot defer it.
1454 + return (
1455 + self.defer_moe_finalize
1456 + and self.tp_size > 1
1457 + and self.dp_size == 1
1458 + and self.ep_size == 1
1459 + and self.pcp_size == 1
1460 + and not self.is_sequence_parallel
1461 + )
1462 +
1463 + def should_defer_moe_finalize(self, num_tokens: int) -> bool:
1464 + """Return whether this invocation may defer the top-k reduction."""
1465 + max_num_tokens = self.defer_moe_finalize_max_num_tokens
1466 + return (
1467 + self.use_deferred_moe_finalize
1468 + and num_tokens > 0
1469 + and (max_num_tokens < 0 or num_tokens <= max_num_tokens)
1470 + )
1471
1472 @property
1473 def use_deepep_ht_kernels(self):
vllm/model_executor/layers/fused_moe/experts/trtllm_bf16_moe.py
+10 -15
@@ -13,6 +13,7 @@ from vllm.model_executor.layers.fused_moe.config import (
13 )
14 from vllm.model_executor.layers.fused_moe.moe_output import (
15 UnfinalizedMoEOutput,
16 + convert_flashinfer_moe_output,
17 )
18 from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
19 TopKWeightAndReduceNoOP,
@@ -278,13 +279,13 @@ class TrtLlmBf16ExpertsMonolithic(TrtLlmBf16ExpertsBase, mk.FusedMoEExpertsMonol
279 num_tokens = hidden_states.shape[0]
280 # The runner divides by the token count on the host, so an idle rank's
281 # dummy 0-token forward has to keep the finalized (empty) form.
281 - defer = self.moe_config.use_deferred_moe_finalize and num_tokens > 0
282 + defer = self.moe_config.should_defer_moe_finalize(num_tokens)
283
284 routing_replay_out = self._maybe_make_routing_replay_buffer(
285 num_tokens=num_tokens,
286 device=hidden_states.device,
287 )
287 - out = flashinfer.fused_moe.trtllm_bf16_moe(
288 + flashinfer_output = flashinfer.fused_moe.trtllm_bf16_moe(
289 routing_logits=router_logits,
290 routing_bias=e_score_correction_bias,
291 hidden_states=hidden_states,
@@ -304,17 +305,11 @@ class TrtLlmBf16ExpertsMonolithic(TrtLlmBf16ExpertsBase, mk.FusedMoEExpertsMonol
305 routing_replay_out=routing_replay_out,
306 do_finalize=not defer,
307 )
308 + routed_output = convert_flashinfer_moe_output(
309 + flashinfer_output,
310 + do_finalize=not defer,
311 + num_tokens=num_tokens,
312 + top_k=self.topk,
313 + )
314 self._maybe_dispatch_routing_replay(routing_replay_out, num_tokens=num_tokens)
308 - if defer:
309 - # flashinfer returns a flat permute map; the protocol wants
310 - # [num_tokens, top_k] so consumers can read top_k from its shape.
311 - return UnfinalizedMoEOutput(
312 - gemm2_permuted=out[0],
313 - expert_weights=out[1],
314 - expanded_idx_to_permuted_idx=out[2]
315 - .to(torch.int32)
316 - .view(num_tokens, self.topk),
317 - )
318 - # do_finalize=True yields the finalized states (a bare tensor on some
319 - # FlashInfer versions, a single-element list on others).
320 - return out[0] if isinstance(out, (list, tuple)) else out
315 + return routed_output
vllm/model_executor/layers/fused_moe/experts/trtllm_mxfp4_moe.py
+27 -14
@@ -11,6 +11,10 @@ from vllm.model_executor.layers.fused_moe.config import (
11 FusedMoEQuantConfig,
12 RoutingMethodType,
13 )
14 +from vllm.model_executor.layers.fused_moe.moe_output import (
15 + UnfinalizedMoEOutput,
16 + convert_flashinfer_moe_output,
17 +)
18 from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
19 TopKWeightAndReduceNoOP,
20 )
@@ -215,7 +219,7 @@ class TrtLlmMxfp4ExpertsMonolithic(
219 e_score_correction_bias: torch.Tensor | None = None,
220 routed_scaling_factor: float | None = None,
221 topk_group: int | None = None,
218 - ) -> torch.Tensor:
222 + ) -> torch.Tensor | UnfinalizedMoEOutput:
223 from flashinfer import trtllm_fp4_block_scale_moe
224
225 if a1q_scale is not None:
@@ -225,18 +229,22 @@ class TrtLlmMxfp4ExpertsMonolithic(
229 assert hidden_states.dtype == torch.bfloat16
230 x_quant = hidden_states
231 x_scale = None
228 - output = torch.empty(
229 - *hidden_states.shape[:-1],
230 - self.hidden_dim_unpadded,
231 - dtype=torch.bfloat16,
232 - device=hidden_states.device,
233 - )
232 + num_tokens = hidden_states.shape[0]
233 + defer = self.moe_config.should_defer_moe_finalize(num_tokens)
234 + finalized_output = None
235 + if not defer:
236 + finalized_output = torch.empty(
237 + *hidden_states.shape[:-1],
238 + self.hidden_dim_unpadded,
239 + dtype=torch.bfloat16,
240 + device=hidden_states.device,
241 + )
242
243 routing_replay_out = self._maybe_make_routing_replay_buffer(
236 - num_tokens=hidden_states.shape[0],
244 + num_tokens=num_tokens,
245 device=hidden_states.device,
246 )
239 - trtllm_fp4_block_scale_moe(
247 + flashinfer_output = trtllm_fp4_block_scale_moe(
248 routing_logits=router_logits,
249 routing_bias=e_score_correction_bias,
250 hidden_states=x_quant,
@@ -262,16 +270,21 @@ class TrtLlmMxfp4ExpertsMonolithic(
270 local_num_experts=self.local_num_experts,
271 routed_scaling_factor=routed_scaling_factor,
272 routing_method_type=self.routing_method_type,
265 - do_finalize=True,
273 + do_finalize=not defer,
274 activation_type=self._flashinfer_activation_type(activation),
275 tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
268 - output=output,
276 + output=finalized_output,
277 routing_replay_out=routing_replay_out,
278 )
271 - self._maybe_dispatch_routing_replay(
272 - routing_replay_out, num_tokens=hidden_states.shape[0]
279 + routed_output = convert_flashinfer_moe_output(
280 + flashinfer_output,
281 + do_finalize=not defer,
282 + num_tokens=num_tokens,
283 + top_k=self.topk,
284 + finalized_output=finalized_output,
285 )
274 - return output
286 + self._maybe_dispatch_routing_replay(routing_replay_out, num_tokens=num_tokens)
287 + return routed_output
288
289
290 class TrtLlmMxfp4ExpertsModular(TrtLlmMxfp4ExpertsBase, mk.FusedMoEExpertsModular):
vllm/model_executor/layers/fused_moe/experts/trtllm_nvfp4_moe.py
+10 -13
@@ -15,6 +15,7 @@ from vllm.model_executor.layers.fused_moe.config import (
15 )
16 from vllm.model_executor.layers.fused_moe.moe_output import (
17 UnfinalizedMoEOutput,
18 + convert_flashinfer_moe_output,
19 )
20 from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
21 TopKWeightAndReduceNoOP,
@@ -551,7 +552,7 @@ class TrtLlmNvFp4ExpertsMonolithic(
552 num_tokens = hidden_states.shape[0]
553 # The runner divides by the token count on the host, so an idle rank's
554 # dummy 0-token forward has to keep the finalized (empty) form.
554 - defer = self.moe_config.use_deferred_moe_finalize and num_tokens > 0
555 + defer = self.moe_config.should_defer_moe_finalize(num_tokens)
556
557 routing_replay_out = self._maybe_make_routing_replay_buffer(
558 num_tokens=num_tokens,
@@ -560,7 +561,7 @@ class TrtLlmNvFp4ExpertsMonolithic(
561 # Invoke kernel.
562 # NOTE: Activation padding and output
563 # truncation are handled by the MoE runner's
563 - result = flashinfer.fused_moe.trtllm_fp4_block_scale_moe(
564 + flashinfer_output = flashinfer.fused_moe.trtllm_fp4_block_scale_moe(
565 routing_logits=router_logits,
566 routing_bias=e_score_correction_bias,
567 hidden_states=hidden_states,
@@ -594,15 +595,11 @@ class TrtLlmNvFp4ExpertsMonolithic(
595 tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
596 routing_replay_out=routing_replay_out,
597 )
598 + routed_output = convert_flashinfer_moe_output(
599 + flashinfer_output,
600 + do_finalize=not defer,
601 + num_tokens=num_tokens,
602 + top_k=self.topk,
603 + )
604 self._maybe_dispatch_routing_replay(routing_replay_out, num_tokens=num_tokens)
598 - if defer:
599 - # flashinfer returns a flat permute map; the protocol wants
600 - # [num_tokens, top_k] so consumers can read top_k from its shape.
601 - return UnfinalizedMoEOutput(
602 - gemm2_permuted=result[0],
603 - expert_weights=result[1],
604 - expanded_idx_to_permuted_idx=result[2]
605 - .to(torch.int32)
606 - .view(num_tokens, self.topk),
607 - )
608 - return result[0]
605 + return routed_output
vllm/model_executor/layers/fused_moe/fused_moe_method_base.py
+4 -3
@@ -13,6 +13,7 @@ from vllm.model_executor.layers.fused_moe.config import (
13 FusedMoEParallelConfig,
14 FusedMoEQuantConfig,
15 )
16 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
17 from vllm.model_executor.layers.quantization.base_config import (
18 QuantizeMethodBase,
19 )
@@ -161,7 +162,7 @@ class FusedMoEMethodBase(QuantizeMethodBase):
162 shared_experts_input: Input for shared experts (if any)
163
164 Returns:
164 - Output tensor from routed experts
165 + Output tensor from routed experts.
166 """
167 raise NotImplementedError
168
@@ -171,7 +172,7 @@ class FusedMoEMethodBase(QuantizeMethodBase):
172 x: torch.Tensor,
173 router_logits: torch.Tensor,
174 input_ids: torch.Tensor | None = None,
174 - ) -> torch.Tensor:
175 + ) -> torch.Tensor | UnfinalizedMoEOutput:
176 """
177 Apply the MoE operation using monolithic kernels.
178
@@ -181,6 +182,6 @@ class FusedMoEMethodBase(QuantizeMethodBase):
182 router_logits: Router logits (routing done internally)
183
184 Returns:
184 - Output tensor from routed experts
185 + Finalized routed states or a deferred-finalize output.
186 """
187 raise NotImplementedError
vllm/model_executor/layers/fused_moe/modular_kernel.py
+21 -6
@@ -22,6 +22,7 @@ from vllm.model_executor.layers.fused_moe.config import (
22 FusedMoEQuantConfig,
23 RoutingMethodType,
24 )
25 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
26 from vllm.model_executor.layers.fused_moe.runner.shared_experts import (
27 SharedExperts,
28 SharedExpertsOrder,
@@ -425,6 +426,9 @@ class FusedMoEPrepareAndFinalizeMonolithic(FusedMoEPrepareAndFinalize):
426 described above for the monolithic case.
427 """
428
429 + def supports_deferred_moe_finalize(self) -> bool:
430 + return False
431 +
432 @abstractmethod
433 def prepare(
434 self,
@@ -1078,7 +1082,7 @@ class FusedMoEExpertsMonolithic(FusedMoEExperts):
1082 e_score_correction_bias: torch.Tensor | None = None,
1083 routed_scaling_factor: float | None = None,
1084 topk_group: int | None = None,
1081 - ) -> torch.Tensor:
1085 + ) -> torch.Tensor | UnfinalizedMoEOutput:
1086 """
1087 Same as apply(), except uses router_logits as opposed
1088 to the topk_ids and topk_weights. This is useful for kernels
@@ -1548,7 +1552,7 @@ class FusedMoEKernelMonolithicImpl:
1552 e_score_correction_bias: torch.Tensor | None = None,
1553 routed_scaling_factor: float | None = None,
1554 topk_group: int | None = None,
1551 - ) -> torch.Tensor:
1555 + ) -> torch.Tensor | UnfinalizedMoEOutput:
1556 """
1557 Same as forward(), except uses router_logits as opposed
1558 to the topk_ids and topk_weights. This is used for kernels
@@ -1579,9 +1583,14 @@ class FusedMoEKernelMonolithicImpl:
1583 topk_group=topk_group,
1584 )
1585
1582 - output = self.prepare_finalize.finalize(fused_out)
1583 -
1584 - return output
1586 + if isinstance(fused_out, UnfinalizedMoEOutput):
1587 + if not self.prepare_finalize.supports_deferred_moe_finalize():
1588 + raise RuntimeError(
1589 + f"{type(self.prepare_finalize).__name__} cannot pass through "
1590 + "a deferred MoE output."
1591 + )
1592 + return fused_out
1593 + return self.prepare_finalize.finalize(fused_out)
1594
1595
1596 @final
@@ -1664,6 +1673,12 @@ class FusedMoEKernel:
1673 """
1674 return self.prepare_finalize.output_is_reduced()
1675
1676 + def supports_deferred_moe_finalize(self) -> bool:
1677 + return (
1678 + isinstance(self.prepare_finalize, FusedMoEPrepareAndFinalizeMonolithic)
1679 + and self.prepare_finalize.supports_deferred_moe_finalize()
1680 + )
1681 +
1682 def apply_monolithic(
1683 self,
1684 hidden_states: torch.Tensor,
@@ -1679,7 +1694,7 @@ class FusedMoEKernel:
1694 e_score_correction_bias: torch.Tensor | None = None,
1695 routed_scaling_factor: float | None = None,
1696 topk_group: int | None = None,
1682 - ) -> torch.Tensor:
1697 + ) -> torch.Tensor | UnfinalizedMoEOutput:
1698 assert isinstance(self.impl, FusedMoEKernelMonolithicImpl)
1699 return self.impl.apply(
1700 hidden_states=hidden_states,
vllm/model_executor/layers/fused_moe/moe_output.py
+90
@@ -33,6 +33,96 @@ class UnfinalizedMoEOutput:
33 expanded_idx_to_permuted_idx: torch.Tensor
34
35
36 +def convert_flashinfer_moe_output(
37 + flashinfer_output: torch.Tensor | list[torch.Tensor],
38 + *,
39 + do_finalize: bool,
40 + num_tokens: int,
41 + top_k: int,
42 + finalized_output: torch.Tensor | None = None,
43 +) -> torch.Tensor | UnfinalizedMoEOutput:
44 + """Normalize the two FlashInfer TRTLLM MoE return layouts.
45 +
46 + Args:
47 + flashinfer_output: Tensor returned by the FlashInfer BF16 wrapper's
48 + legacy finalized path, or its mode-dependent tensor list.
49 + do_finalize: Whether FlashInfer ran its top-k finalize step.
50 + num_tokens: Number of input tokens.
51 + top_k: Number of routed experts per token.
52 + finalized_output: Optional destination passed to FlashInfer's ``output``
53 + argument.
54 +
55 + Returns:
56 + A finalized tensor or the structured deferred-finalize output.
57 +
58 + Raises:
59 + ValueError: If FlashInfer returns an unexpected layout.
60 + """
61 +
62 + if num_tokens < 0:
63 + raise ValueError("num_tokens must be non-negative.")
64 + if top_k <= 0:
65 + raise ValueError("top_k must be positive.")
66 +
67 + if do_finalize:
68 + if isinstance(flashinfer_output, torch.Tensor):
69 + returned_output = flashinfer_output
70 + elif len(flashinfer_output) == 1 and isinstance(
71 + flashinfer_output[0], torch.Tensor
72 + ):
73 + returned_output = flashinfer_output[0]
74 + else:
75 + raise ValueError(
76 + "Finalized FlashInfer MoE output must contain exactly one tensor."
77 + )
78 + if finalized_output is not None and (
79 + returned_output.shape != finalized_output.shape
80 + or returned_output.dtype != finalized_output.dtype
81 + or returned_output.device != finalized_output.device
82 + or returned_output.data_ptr() != finalized_output.data_ptr()
83 + ):
84 + raise ValueError(
85 + "Finalized FlashInfer MoE output must alias the provided destination."
86 + )
87 + return returned_output
88 +
89 + if finalized_output is not None:
90 + raise ValueError("Deferred FlashInfer MoE output cannot have a destination.")
91 + if isinstance(flashinfer_output, torch.Tensor) or len(flashinfer_output) != 3:
92 + raise ValueError(
93 + "Deferred FlashInfer MoE output must contain GEMM2 output, routing "
94 + "weights, and a permutation map."
95 + )
96 + gemm2_permuted, expert_weights, expanded_idx = flashinfer_output
97 + if gemm2_permuted.ndim != 2:
98 + raise ValueError("Deferred FlashInfer GEMM2 output must be rank-2.")
99 + if expanded_idx.dtype != torch.int32:
100 + raise ValueError("Deferred FlashInfer permutation map must use int32.")
101 + expected_routes = num_tokens * top_k
102 + if expert_weights.numel() != expected_routes:
103 + raise ValueError(
104 + "Deferred FlashInfer routing weights must contain "
105 + f"num_tokens * top_k ({expected_routes}) elements."
106 + )
107 + if expanded_idx.numel() != expected_routes:
108 + raise ValueError(
109 + "Deferred FlashInfer permutation map must contain "
110 + f"num_tokens * top_k ({expected_routes}) elements."
111 + )
112 + if not (gemm2_permuted.device == expert_weights.device == expanded_idx.device):
113 + raise ValueError("Deferred FlashInfer outputs must share a device.")
114 + if not all(
115 + output.is_contiguous()
116 + for output in (gemm2_permuted, expert_weights, expanded_idx)
117 + ):
118 + raise ValueError("Deferred FlashInfer outputs must be contiguous.")
119 + return UnfinalizedMoEOutput(
120 + gemm2_permuted=gemm2_permuted,
121 + expert_weights=expert_weights.view(num_tokens, top_k),
122 + expanded_idx_to_permuted_idx=expanded_idx.view(num_tokens, top_k),
123 + )
124 +
125 +
126 @dataclass
127 class MoEOutput:
128 """A MoE layer's output with its final reduction still open.
vllm/model_executor/layers/fused_moe/prepare_finalize/no_dp_ep.py
+3
@@ -98,6 +98,9 @@ class MoEPrepareAndFinalizeNoDPEPModular(mk.FusedMoEPrepareAndFinalizeModular):
98
99
100 class MoEPrepareAndFinalizeNoDPEPMonolithic(mk.FusedMoEPrepareAndFinalizeMonolithic):
101 + def supports_deferred_moe_finalize(self) -> bool:
102 + return True
103 +
104 @property
105 def activation_format(self) -> mk.FusedMoEActivationFormat:
106 return mk.FusedMoEActivationFormat.Standard
vllm/model_executor/layers/fused_moe/routed_experts.py
+4 -3
@@ -19,6 +19,7 @@ from vllm.model_executor.layers.fused_moe.expert_map_manager import (
19 from vllm.model_executor.layers.fused_moe.fused_moe_method_base import (
20 FusedMoEMethodBase,
21 )
22 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
23 from vllm.model_executor.layers.fused_moe.unquantized_fused_moe_method import (
24 UnquantizedFusedMoEMethod,
25 )
@@ -1216,7 +1217,7 @@ class RoutedExperts(PluggableLayer):
1217 shared_experts_input: Input for shared experts (if any)
1218
1219 Returns:
1219 - Output tensor from routed experts
1220 + Output tensor from routed experts.
1221 """
1222 assert not self.quant_method.is_monolithic
1223
@@ -1235,7 +1236,7 @@ class RoutedExperts(PluggableLayer):
1236 x: torch.Tensor,
1237 router_logits: torch.Tensor | None = None,
1238 input_ids: torch.Tensor | None = None,
1238 - ) -> torch.Tensor:
1239 + ) -> torch.Tensor | UnfinalizedMoEOutput:
1240 """
1241 Execute routed experts using the quantization method's apply function.
1242
@@ -1250,7 +1251,7 @@ class RoutedExperts(PluggableLayer):
1251 input_ids: input ids for DeepSeek V4
1252
1253 Returns:
1253 - Output tensor from routed experts
1254 + Finalized routed states or a deferred-finalize output.
1255 """
1256 assert self.quant_method.is_monolithic
1257
vllm/model_executor/layers/fused_moe/runner/moe_runner.py
+42 -20
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections.abc import Callable, Iterable
4 from contextlib import nullcontext
5 -from typing import TYPE_CHECKING
5 +from typing import TYPE_CHECKING, cast
6
7 import torch
8 import torch.nn.functional as F
@@ -28,6 +28,7 @@ from vllm.model_executor.layers.fused_moe.config import (
28 from vllm.model_executor.layers.fused_moe.fused_moe_method_base import (
29 FusedMoEMethodBase,
30 )
31 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
32 from vllm.model_executor.layers.fused_moe.routed_experts import (
33 RoutedExperts,
34 )
@@ -121,11 +122,14 @@ def _moe_forward(
122 hidden_dim_unpadded: int,
123 ) -> torch.Tensor:
124 layer = get_layer_from_name(_resolve_layer_name(layer_name))
124 - return layer._forward_impl(
125 - hidden_states,
126 - router_logits,
127 - shared_experts_input,
128 - input_ids,
125 + return cast(
126 + torch.Tensor,
127 + layer._forward_impl(
128 + hidden_states,
129 + router_logits,
130 + shared_experts_input,
131 + input_ids,
132 + ),
133 )
134
135
@@ -155,11 +159,14 @@ def _moe_forward_shared(
159 hidden_dim_unpadded: int,
160 ) -> tuple[torch.Tensor, torch.Tensor]:
161 layer = get_layer_from_name(_resolve_layer_name(layer_name))
158 - return layer._forward_impl(
159 - hidden_states,
160 - router_logits,
161 - shared_experts_input,
162 - input_ids,
162 + return cast(
163 + tuple[torch.Tensor, torch.Tensor],
164 + layer._forward_impl(
165 + hidden_states,
166 + router_logits,
167 + shared_experts_input,
168 + input_ids,
169 + ),
170 )
171
172
@@ -207,8 +214,10 @@ direct_register_custom_op(
214
215
216 def _unpack(
210 - result: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
211 -) -> tuple[torch.Tensor | None, torch.Tensor]:
217 + result: torch.Tensor
218 + | UnfinalizedMoEOutput
219 + | tuple[torch.Tensor, torch.Tensor | UnfinalizedMoEOutput],
220 +) -> tuple[torch.Tensor | None, torch.Tensor | UnfinalizedMoEOutput]:
221 if isinstance(result, tuple):
222 return result
223 else:
@@ -573,7 +582,7 @@ class MoERunner(MoERunnerInterface):
582 router_logits: torch.Tensor,
583 shared_experts_input: torch.Tensor | None,
584 input_ids: torch.Tensor | None = None,
576 - ) -> tuple[torch.Tensor | None, torch.Tensor]:
585 + ) -> tuple[torch.Tensor | None, torch.Tensor | UnfinalizedMoEOutput]:
586 """Run expert routing and the fused MoE kernel via the quant method.
587
588 Orchestrates shared expert execution (before/after), expert selection
@@ -733,6 +742,7 @@ class MoERunner(MoERunnerInterface):
742
743 # Extract outputs from result
744 shared_output, fused_output = _unpack(result)
745 + fused_output = cast(torch.Tensor, fused_output)
746
747 if og_hidden_dim_pre_xform is not None:
748 fused_output = fused_output[..., :og_hidden_dim_pre_xform]
@@ -808,18 +818,25 @@ class MoERunner(MoERunnerInterface):
818 def _maybe_combine(
819 self,
820 shared_output: torch.Tensor | None,
811 - hidden_states: torch.Tensor,
812 - ) -> torch.Tensor | tuple[torch.Tensor | None, torch.Tensor]:
821 + hidden_states: torch.Tensor | UnfinalizedMoEOutput,
822 + ) -> (
823 + torch.Tensor
824 + | UnfinalizedMoEOutput
825 + | tuple[torch.Tensor | None, torch.Tensor | UnfinalizedMoEOutput]
826 + ):
827 if self.do_naive_dispatch_combine:
828 hidden_states = get_ep_group().combine(
815 - hidden_states, self.moe_config.is_sequence_parallel
829 + cast(torch.Tensor, hidden_states),
830 + self.moe_config.is_sequence_parallel,
831 )
832
833 if (
834 self.moe_config.pcp_size > 1
835 and not self.moe_config.moe_parallel_config.use_all2all_kernels
836 ):
822 - hidden_states = get_pcp_group().reduce_scatter(hidden_states, dim=0)
837 + hidden_states = get_pcp_group().reduce_scatter(
838 + cast(torch.Tensor, hidden_states), dim=0
839 + )
840
841 if self.shared_experts is not None:
842 assert shared_output is not None
@@ -833,7 +850,11 @@ class MoERunner(MoERunnerInterface):
850 router_logits: torch.Tensor,
851 shared_experts_input: torch.Tensor | None,
852 input_ids: torch.Tensor | None = None,
836 - ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
853 + ) -> (
854 + torch.Tensor
855 + | UnfinalizedMoEOutput
856 + | tuple[torch.Tensor, torch.Tensor | UnfinalizedMoEOutput]
857 + ):
858 """Entry point called by the custom op to run the MoE computation.
859
860 Handles pre-dispatch setup (gate application, external shared expert
@@ -844,7 +865,8 @@ class MoERunner(MoERunnerInterface):
865 - fused MoE kernel execution
866 - shared expert computation.
867
847 - Returns a single tensor of combined fused and shared output (if present).
868 + Returns routed output, optionally paired with shared-expert output. A
869 + fused consumer may request the routed output in deferred-finalize form.
870 """
871 # TODO(bnell): this can be removed after MK migration is complete.
872 self.routed_experts._ensure_moe_quant_config_init()
vllm/model_executor/layers/quantization/mxfp4.py
+3 -2
@@ -17,6 +17,7 @@ from vllm.model_executor.layers.fused_moe import modular_kernel as mk
17 from vllm.model_executor.layers.fused_moe.config import (
18 mxfp4_w4a16_moe_quant_config,
19 )
20 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
21 from vllm.model_executor.layers.fused_moe.oracle.mxfp4 import (
22 TRITON_BACKENDS,
23 Mxfp4MoeBackend,
@@ -451,7 +452,7 @@ class GptOssMxfp4MoEMethod(FusedMoEMethodBase):
452 x: torch.Tensor,
453 router_logits: torch.Tensor,
454 input_ids: torch.Tensor | None = None,
454 - ) -> torch.Tensor:
455 + ) -> torch.Tensor | UnfinalizedMoEOutput:
456 assert self.is_monolithic
457 assert self.moe_kernel is not None
458 return self.moe_kernel.apply_monolithic(
@@ -865,7 +866,7 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
866 x: torch.Tensor,
867 router_logits: torch.Tensor,
868 input_ids: torch.Tensor | None = None,
868 - ) -> torch.Tensor:
869 + ) -> torch.Tensor | UnfinalizedMoEOutput:
870 assert self.is_monolithic
871 assert self.moe_kernel is not None
872 return self.moe_kernel.apply_monolithic(
vllm/models/kimi_k3/amd/latent_moe_runner.py
+4 -3
@@ -1,5 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from typing import cast
4 +
5 import torch
6
7 from vllm.distributed import (
@@ -7,7 +9,7 @@ from vllm.distributed import (
9 tensor_model_parallel_all_reduce,
10 )
11 from vllm.logger import init_logger
10 -from vllm.model_executor.layers.fused_moe.runner.moe_runner import MoERunner, _unpack
12 +from vllm.model_executor.layers.fused_moe.runner.moe_runner import MoERunner
13
14 logger = init_logger(__name__)
15
@@ -139,8 +141,7 @@ class ROCmLatentMoERunner(MoERunner):
141 else 0,
142 )
143
142 - shared_output, fused_output = _unpack(result)
143 - assert shared_output is not None
144 + shared_output, fused_output = cast(tuple[torch.Tensor, torch.Tensor], result)
145
146 if og_hidden_dim_pre_xform is not None:
147 fused_output = fused_output[..., :og_hidden_dim_pre_xform]
vllm/models/kimi_k3/nvidia/latent_moe_runner.py
+51 -3
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from enum import IntEnum
4 +from typing import cast
5
6 import torch
7
@@ -11,6 +12,7 @@ from vllm.distributed import (
12 tensor_model_parallel_all_reduce,
13 )
14 from vllm.logger import init_logger
15 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
16 from vllm.model_executor.layers.fused_moe.runner.moe_runner import MoERunner, _unpack
17 from vllm.model_executor.layers.layernorm import RMSNorm
18 from vllm.platforms import current_platform
@@ -103,6 +105,24 @@ class LatentMoERunner(MoERunner):
105 assert transform is not None
106 norm = transform.norm
107 assert norm is not None
108 + from vllm.model_executor.layers.fused_moe.experts.trtllm_mxfp4_moe import (
109 + TrtLlmMxfp4ExpertsMonolithic,
110 + )
111 + from vllm.model_executor.layers.fused_moe.experts.trtllm_nvfp4_moe import (
112 + TrtLlmNvFp4ExpertsMonolithic,
113 + )
114 +
115 + experts_cls = getattr(self._quant_method, "experts_cls", None)
116 + moe_kernel = self._quant_method.moe_kernel
117 + self.moe_config.defer_moe_finalize = (
118 + (
119 + experts_cls is TrtLlmMxfp4ExpertsMonolithic
120 + or experts_cls is TrtLlmNvFp4ExpertsMonolithic
121 + )
122 + and self.moe_config.hidden_dim == self.moe_config.hidden_dim_unpadded
123 + and moe_kernel is not None
124 + and moe_kernel.supports_deferred_moe_finalize()
125 + )
126 from vllm.models.kimi_k3.nvidia.ops.latent_moe_tail import (
127 KimiK3LatentMoETailOp,
128 )
@@ -110,11 +130,24 @@ class LatentMoERunner(MoERunner):
130 op = KimiK3LatentMoETailOp.initialize(
131 hidden_size=transform.up_proj.weight.shape[0],
132 latent_size=norm.weight.shape[0],
133 + experts_per_token=(
134 + self.moe_config.experts_per_token
135 + if self.moe_config.use_deferred_moe_finalize
136 + else 0
137 + ),
138 dtype=norm.weight.dtype,
139 device=norm.weight.device,
140 rms_eps=norm.variance_epsilon,
141 )
142 self._k3_latent_moe_tail_op = op
143 + self.moe_config.defer_moe_finalize_max_num_tokens = (
144 + op.contract.max_num_tokens
145 + if self.moe_config.use_deferred_moe_finalize
146 + else -1
147 + )
148 + else:
149 + self.moe_config.defer_moe_finalize = False
150 + self.moe_config.defer_moe_finalize_max_num_tokens = -1
151
152 def _get_zero_residual(
153 self,
@@ -177,7 +210,7 @@ class LatentMoERunner(MoERunner):
210
211 def _small_batch_tail(
212 self,
180 - fused_output: torch.Tensor,
213 + fused_output: torch.Tensor | UnfinalizedMoEOutput,
214 shared_output: torch.Tensor,
215 trunc_size: int | None,
216 ) -> torch.Tensor:
@@ -304,6 +337,22 @@ class LatentMoERunner(MoERunner):
337 )
338 )
339
340 + if (
341 + og_hidden_dim_pre_xform is None
342 + and self.moe_config.should_defer_moe_finalize(hidden_states.shape[0])
343 + ):
344 + unfinalized = self._forward_impl(
345 + hidden_states,
346 + router_logits,
347 + shared_experts_input,
348 + input_ids,
349 + )
350 + shared_output, fused_output = _unpack(unfinalized)
351 + assert shared_output is not None
352 + assert isinstance(fused_output, UnfinalizedMoEOutput)
353 + result = self._small_batch_tail(fused_output, shared_output, None)
354 + return self._maybe_add_zero_expert_output(result)
355 +
356 result = self._forward_entry(
357 hidden_states,
358 router_logits,
@@ -315,8 +364,7 @@ class LatentMoERunner(MoERunner):
364 else 0,
365 )
366
318 - shared_output, fused_output = _unpack(result)
319 - assert shared_output is not None
367 + shared_output, fused_output = cast(tuple[torch.Tensor, torch.Tensor], result)
368
369 if og_hidden_dim_pre_xform is not None:
370 fused_output = fused_output[..., :og_hidden_dim_pre_xform]
vllm/models/kimi_k3/nvidia/ops/cute_dsl/latent_moe_tail/allreduce_rmsnorm_reduce_scatter_early_exit.py
+160 -23
@@ -16,6 +16,8 @@ import torch.distributed as dist
16 import torch.distributed._symmetric_memory as symm_mem
17 from cutlass import BFloat16, Float32, Int32, Int64, Uint32
18
19 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
20 +
21 from .primitives import (
22 NUM_LAMPORT_BUFFERS,
23 PACKED_BYTES,
@@ -104,6 +106,7 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
106 fp32_internal: bool = False,
107 include_reduce_scatter: bool = True,
108 include_routed: bool = True,
109 + top_k: int = 0,
110 ):
111 validate_shape(
112 tp_size=tp_size,
@@ -153,6 +156,7 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
156 self.fp32_internal = fp32_internal
157 self.include_reduce_scatter = include_reduce_scatter
158 self.include_routed = include_routed
159 + self.top_k = top_k
160
161 @cute.jit
162 def __call__(
@@ -171,6 +175,8 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
175 m: Int32,
176 epsilon: Float32,
177 stream: cuda.CUstream,
178 + expert_weights: cute.Tensor,
179 + expanded_idx_to_permuted_idx: cute.Tensor,
180 ):
181 self.kernel(
182 latent_source,
@@ -186,6 +192,8 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
192 shared_peer_ptrs,
193 m,
194 epsilon,
195 + expert_weights,
196 + expanded_idx_to_permuted_idx,
197 ).launch(
198 grid=(self.token_ctas, self.cluster_ctas, self.roles),
199 block=(self.threads, 1, 1),
@@ -211,6 +219,8 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
219 shared_peer_ptrs: cute.Tensor,
220 m: Int32,
221 epsilon: Float32,
222 + expert_weights: cute.Tensor,
223 + expanded_idx_to_permuted_idx: cute.Tensor,
224 ):
225 tidx, _, _ = cute.arch.thread_idx()
226 token_cta, cta_y, role = cute.arch.block_idx()
@@ -244,6 +254,8 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
254 logical_role,
255 cluster_rank,
256 tidx,
257 + expert_weights,
258 + expanded_idx_to_permuted_idx,
259 )
260 token = token + self.token_ctas
261
@@ -269,6 +281,8 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
281 role: Int32,
282 cluster_rank: Int32,
283 tidx: Int32,
284 + expert_weights: cute.Tensor,
285 + expanded_idx_to_permuted_idx: cute.Tensor,
286 ):
287 if role == 0:
288 # ---------------- routed AllReduce + RMSNorm ----------------
@@ -292,15 +306,48 @@ class AllReduceRMSNormWithReduceScatterEarlyExit:
306 )
307 dirty_elements = Int64(dirty_index) * (Int64(bytes_per_buffer) // Int64(2))
308
295 - local_ptr = cute.make_ptr(
296 - BFloat16,
297 - (latent_source.iterator + element_offset).llvm_ptr,
298 - cute.AddressSpace.gmem,
299 - assumed_align=16,
300 - )
301 - local_packed = sanitize_negative_zero(
302 - load_global_u32x4(local_ptr, volatile=False)
303 - )
309 + if cutlass.const_expr(self.top_k > 0):
310 + # finalize topk reduction
311 + local_values = cute.make_rmem_tensor(
312 + cute.make_layout((VEC_BF16,)), BFloat16
313 + )
314 + for element in cutlass.range_constexpr(VEC_BF16):
315 + local_values[element] = BFloat16(0.0)
316 + for slot in cutlass.range_constexpr(self.top_k):
317 + permuted_idx = expanded_idx_to_permuted_idx[token, slot]
318 + if permuted_idx >= Int32(0):
319 + permuted_element = (
320 + Int64(permuted_idx) * self.latent_dim
321 + + Int64(packed_idx) * VEC_BF16
322 + )
323 + permuted_ptr = cute.make_ptr(
324 + BFloat16,
325 + (latent_source.iterator + permuted_element).llvm_ptr,
326 + cute.AddressSpace.gmem,
327 + assumed_align=16,
328 + )
329 + values = packed_u32x4_to_bf16x8(
330 + load_global_u32x4(permuted_ptr, volatile=False)
331 + )
332 + weight = expert_weights[token, slot].to(Float32)
333 + for element in cutlass.range_constexpr(VEC_BF16):
334 + scaled = (values[element].to(Float32) * weight).to(BFloat16)
335 + local_values[element] = (
336 + local_values[element].to(Float32) + scaled.to(Float32)
337 + ).to(BFloat16)
338 + local_packed = sanitize_negative_zero(
339 + bf16x8_to_packed_u32x4(local_values.load())
340 + )
341 + else:
342 + local_ptr = cute.make_ptr(
343 + BFloat16,
344 + (latent_source.iterator + element_offset).llvm_ptr,
345 + cute.AddressSpace.gmem,
346 + assumed_align=16,
347 + )
348 + local_packed = sanitize_negative_zero(
349 + load_global_u32x4(local_ptr, volatile=False)
350 + )
351 multicast_offset = (
352 Int64(current_index) * Int64(bytes_per_buffer)
353 + (
@@ -645,6 +692,8 @@ def _compile_key(
692 fp32_internal: bool,
693 include_reduce_scatter: bool,
694 include_routed: bool,
695 + *,
696 + top_k: int,
697 ):
698 return (
699 torch.accelerator.current_device_index(),
@@ -657,6 +706,7 @@ def _compile_key(
706 fp32_internal,
707 include_reduce_scatter,
708 include_routed,
709 + top_k,
710 )
711
712
@@ -673,6 +723,9 @@ def _runtime_args(
723 shared_flags: torch.Tensor,
724 shared_peer_ptrs: torch.Tensor,
725 rms_eps: float,
726 + *,
727 + expert_weights: torch.Tensor,
728 + expanded_idx_to_permuted_idx: torch.Tensor,
729 ):
730 return (
731 to_cute_dynamic_m(latent_source, mode=0, assumed_align=16),
@@ -686,9 +739,11 @@ def _runtime_args(
739 to_cute(shared_workspace, 16),
740 to_cute(shared_flags, 16),
741 to_cute(shared_peer_ptrs, 16),
689 - Int32(latent_source.shape[0]),
742 + Int32(shared_source.shape[0]),
743 Float32(rms_eps),
744 cuda.CUstream(torch.cuda.current_stream(latent_source.device).cuda_stream),
745 + to_cute_dynamic_m(expert_weights, mode=0, assumed_align=16),
746 + to_cute_dynamic_m(expanded_idx_to_permuted_idx, mode=0, assumed_align=16),
747 )
748
749
@@ -712,6 +767,7 @@ def compile_kernel(
767 fp32_internal: bool,
768 include_reduce_scatter: bool = True,
769 include_routed: bool = True,
770 + top_k: int = 0,
771 ) -> None:
772 """Compile the rank/M specialization without retaining caller tensors."""
773
@@ -725,11 +781,17 @@ def compile_kernel(
781 fp32_internal,
782 include_reduce_scatter,
783 include_routed,
784 + top_k=top_k,
785 )
786 if key in _COMPILED:
787 return
788 device = latent_output.device
732 - latent = torch.empty((max_m, latent_dim), dtype=torch.bfloat16, device=device)
789 + latent_rows = max_m * max(top_k, 1)
790 + latent = torch.empty((latent_rows, latent_dim), dtype=torch.bfloat16, device=device)
791 + expert_weights = torch.empty(
792 + (max_m, max(top_k, 1)), dtype=torch.bfloat16, device=device
793 + )
794 + expanded_idx = torch.empty((max_m, max(top_k, 1)), dtype=torch.int32, device=device)
795 gamma = torch.empty((latent_dim,), dtype=torch.bfloat16, device=device)
796 shared = torch.empty((max_m, hidden_dim), dtype=torch.bfloat16, device=device)
797 kernel = AllReduceRMSNormWithReduceScatterEarlyExit(
@@ -742,6 +804,7 @@ def compile_kernel(
804 fp32_internal=fp32_internal,
805 include_reduce_scatter=include_reduce_scatter,
806 include_routed=include_routed,
807 + top_k=top_k,
808 )
809 _COMPILED[key] = cute.compile(
810 kernel,
@@ -758,6 +821,8 @@ def compile_kernel(
821 shared_flags,
822 shared_peer_ptrs,
823 rms_eps,
824 + expert_weights=expert_weights,
825 + expanded_idx_to_permuted_idx=expanded_idx,
826 ),
827 )
828
@@ -785,6 +850,9 @@ def launch(
850 fp32_internal: bool,
851 include_reduce_scatter: bool = True,
852 include_routed: bool = True,
853 + expert_weights: torch.Tensor,
854 + expanded_idx_to_permuted_idx: torch.Tensor,
855 + top_k: int = 0,
856 ) -> None:
857 compile_kernel(
858 rank=rank,
@@ -805,6 +873,7 @@ def launch(
873 fp32_internal=fp32_internal,
874 include_reduce_scatter=include_reduce_scatter,
875 include_routed=include_routed,
876 + top_k=top_k,
877 )
878 _COMPILED[
879 _compile_key(
@@ -817,6 +886,7 @@ def launch(
886 fp32_internal,
887 include_reduce_scatter,
888 include_routed,
889 + top_k=top_k,
890 )
891 ](
892 *_runtime_args(
@@ -832,6 +902,8 @@ def launch(
902 shared_flags,
903 shared_peer_ptrs,
904 rms_eps,
905 + expert_weights=expert_weights,
906 + expanded_idx_to_permuted_idx=expanded_idx_to_permuted_idx,
907 )
908 )
909
@@ -851,6 +923,7 @@ class CollectiveKernel:
923 max_token_ctas: int,
924 rms_eps: float,
925 fp32_internal: bool,
926 + top_k: int = 0,
927 ) -> None:
928 validate_shape(
929 tp_size=tp_size,
@@ -866,8 +939,16 @@ class CollectiveKernel:
939 self.max_token_ctas = max_token_ctas
940 self.rms_eps = float(rms_eps)
941 self.fp32_internal = fp32_internal
942 + self.top_k = top_k
943 device = torch.device("cuda", torch.accelerator.current_device_index())
944
945 + self._dummy_expert_weights = torch.empty(
946 + (max_m, max(top_k, 1)), dtype=torch.bfloat16, device=device
947 + )
948 + self._dummy_expanded_idx = torch.empty(
949 + (max_m, max(top_k, 1)), dtype=torch.int32, device=device
950 + )
951 +
952 bytes_per_routed_buffer = max_m * tp_size * latent_dim * 2
953 routed_bytes = NUM_LAMPORT_BUFFERS * bytes_per_routed_buffer
954 self._routed_workspace = symm_mem.empty(
@@ -947,38 +1028,91 @@ class CollectiveKernel:
1028 shared_peer_ptrs=self._shared_peer_ptrs,
1029 rms_eps=self.rms_eps,
1030 fp32_internal=fp32_internal,
1031 + top_k=top_k,
1032 )
1033 dist.barrier(group=group, device_ids=[device.index])
1034
1035 def __call__(
1036 self,
955 - latent_source: torch.Tensor,
1037 + latent_source: torch.Tensor | UnfinalizedMoEOutput,
1038 shared_source: torch.Tensor,
1039 gamma: torch.Tensor,
1040 ) -> tuple[torch.Tensor, torch.Tensor]:
959 - if latent_source.ndim != 2 or shared_source.ndim != 2:
960 - raise ValueError("latent_source and shared_source must be rank-2")
961 - m = latent_source.shape[0]
1041 + if shared_source.ndim != 2:
1042 + raise ValueError("shared_source must be rank-2")
1043 + expected: list[tuple[torch.Tensor, tuple[int, ...], str, torch.dtype]]
1044 + if isinstance(latent_source, UnfinalizedMoEOutput):
1045 + if self.top_k <= 0:
1046 + raise ValueError("collective was not configured for top-k finalize")
1047 + gemm2_permuted = latent_source.gemm2_permuted
1048 + expert_weights = latent_source.expert_weights
1049 + expanded_idx = latent_source.expanded_idx_to_permuted_idx
1050 + m = expanded_idx.shape[0]
1051 + expected = [
1052 + (
1053 + gemm2_permuted,
1054 + (gemm2_permuted.shape[0], self.latent_dim),
1055 + "gemm2_permuted",
1056 + torch.bfloat16,
1057 + ),
1058 + (
1059 + expert_weights,
1060 + (m, self.top_k),
1061 + "expert_weights",
1062 + torch.bfloat16,
1063 + ),
1064 + (
1065 + expanded_idx,
1066 + (m, self.top_k),
1067 + "expanded_idx_to_permuted_idx",
1068 + torch.int32,
1069 + ),
1070 + ]
1071 + else:
1072 + if self.top_k > 0:
1073 + raise ValueError("top-k collective requires an unfinalized output")
1074 + if latent_source.ndim != 2:
1075 + raise ValueError("latent_source must be rank-2")
1076 + m = latent_source.shape[0]
1077 + gemm2_permuted = latent_source
1078 + expert_weights = self._dummy_expert_weights
1079 + expanded_idx = self._dummy_expanded_idx
1080 + expected = [
1081 + (
1082 + latent_source,
1083 + (m, self.latent_dim),
1084 + "latent_source",
1085 + torch.bfloat16,
1086 + ),
1087 + ]
1088 device = self._routed_workspace.device
963 - expected = (
964 - (latent_source, (m, self.latent_dim), "latent_source"),
965 - (shared_source, (m, self.hidden_dim), "shared_source"),
966 - (gamma, (self.latent_dim,), "gamma"),
1089 + expected.extend(
1090 + [
1091 + (
1092 + shared_source,
1093 + (m, self.hidden_dim),
1094 + "shared_source",
1095 + torch.bfloat16,
1096 + ),
1097 + (gamma, (self.latent_dim,), "gamma", torch.bfloat16),
1098 + ]
1099 )
968 - for tensor, shape, name in expected:
1100 + for tensor, shape, name, dtype in expected:
1101 if (
1102 tensor.shape != shape
971 - or tensor.dtype != torch.bfloat16
1103 + or tensor.dtype != dtype
1104 or tensor.device != device
1105 or not tensor.is_contiguous()
1106 ):
975 - raise ValueError(f"{name} must be contiguous CUDA BF16 {list(shape)}")
1107 + raise ValueError(
1108 + f"{name} must be contiguous CUDA {dtype} {list(shape)}"
1109 + )
1110 if not 1 <= m <= self.max_m:
1111 raise ValueError(f"runtime M={m} must be in [1, {self.max_m}]")
1112
1113 with torch.accelerator.device_index(device.index):
1114 launch(
981 - latent_source,
1115 + gemm2_permuted,
1116 gamma,
1117 self._latent_output,
1118 self._routed_workspace,
@@ -997,6 +1131,9 @@ class CollectiveKernel:
1131 max_m=self.max_m,
1132 max_token_ctas=self.max_token_ctas,
1133 fp32_internal=self.fp32_internal,
1134 + top_k=self.top_k,
1135 + expert_weights=expert_weights,
1136 + expanded_idx_to_permuted_idx=expanded_idx,
1137 )
1138 return (
1139 self._latent_output[:m],
vllm/models/kimi_k3/nvidia/ops/latent_moe_tail.py
+29 -14
@@ -9,17 +9,18 @@ import torch
9 import torch.distributed as dist
10
11 from vllm.distributed import get_tp_group
12 +from vllm.model_executor.layers.fused_moe.moe_output import UnfinalizedMoEOutput
13 from vllm.model_executor.warmup.cutedsl_warmup import (
14 CuTeDSLCompileUnit,
15 register_cutedsl_warmup_provider,
16 )
17
17 -_MAX_NUM_TOKENS = 16
18 +_MAX_NUM_TOKENS = 128
19 _SKINNY_MAX_NUM_TOKENS = 5
20 _MMA_TILER_MN = (64, 32)
21 _GEMM_CLUSTER_MN = (1, 8)
22 _B_PRIME_STAGES = 2
22 -_COLLECTIVE_TOKEN_CTAS = 8
23 +_COLLECTIVE_TOKEN_CTAS = 32
24 _LAMPORT_COPY_CTAS = 32
25 _LAMPORT_COPY_THREADS = 224
26 _SUPPORTED_TP_SIZES = (8, 16)
@@ -35,6 +36,7 @@ class KimiK3LatentMoETailContract:
36 latent_size: int
37 max_num_tokens: int
38 rms_eps: float
39 + experts_per_token: int
40
41
42 class KimiK3LatentMoETailOp:
@@ -53,6 +55,7 @@ class KimiK3LatentMoETailOp:
55 dtype: torch.dtype,
56 device: torch.device,
57 rms_eps: float,
58 + experts_per_token: int = 0,
59 ) -> tuple[KimiK3LatentMoETailContract, dist.ProcessGroup]:
60 tp = get_tp_group()
61 group = tp.device_group
@@ -72,6 +75,7 @@ class KimiK3LatentMoETailOp:
75 latent_size=latent_size,
76 max_num_tokens=_MAX_NUM_TOKENS,
77 rms_eps=float(rms_eps),
78 + experts_per_token=experts_per_token,
79 ),
80 group,
81 )
@@ -85,10 +89,12 @@ class KimiK3LatentMoETailOp:
89 dtype: torch.dtype,
90 device: torch.device,
91 rms_eps: float,
92 + experts_per_token: int = 0,
93 ) -> "KimiK3LatentMoETailOp":
94 contract, group = cls._contract_and_group(
95 hidden_size=hidden_size,
96 latent_size=latent_size,
97 + experts_per_token=experts_per_token,
98 dtype=dtype,
99 device=device,
100 rms_eps=rms_eps,
@@ -140,6 +146,7 @@ class KimiK3LatentMoETailOp:
146 max_token_ctas=_COLLECTIVE_TOKEN_CTAS,
147 rms_eps=contract.rms_eps,
148 fp32_internal=False,
149 + top_k=contract.experts_per_token,
150 )
151 self._up_projection = AdaptiveUpProjectionKernel(
152 group=group,
@@ -191,7 +198,7 @@ class KimiK3LatentMoETailOp:
198
199 def __call__(
200 self,
194 - routed_output: torch.Tensor,
201 + routed_output: torch.Tensor | UnfinalizedMoEOutput,
202 shared_output: torch.Tensor,
203 rms_weight: torch.Tensor,
204 up_weight: torch.Tensor,
@@ -202,7 +209,12 @@ class KimiK3LatentMoETailOp:
209 rms_weight,
210 up_weight,
211 )
205 - self._up_projection.ensure_compiled(routed_output.shape[0])
212 + num_tokens = (
213 + routed_output.expanded_idx_to_permuted_idx.shape[0]
214 + if isinstance(routed_output, UnfinalizedMoEOutput)
215 + else routed_output.shape[0]
216 + )
217 + self._up_projection.ensure_compiled(num_tokens)
218 latent, shared_shard = self._collective(
219 routed_output,
220 shared_output,
@@ -221,24 +233,27 @@ class KimiK3LatentMoETailOp:
233 )
234 return self._lamport_copy(
235 mailbox,
224 - m=routed_output.shape[0],
236 + m=num_tokens,
237 ).squeeze(0)
238
239 def _validate_inputs(
240 self,
229 - routed_output: torch.Tensor,
241 + routed_output: torch.Tensor | UnfinalizedMoEOutput,
242 shared_output: torch.Tensor,
243 rms_weight: torch.Tensor,
244 up_weight: torch.Tensor,
245 ) -> None:
246 contract = self.contract
235 - if routed_output.ndim != 2:
236 - raise ValueError("routed_output must be a 2D tensor.")
237 - num_tokens = routed_output.shape[0]
238 - if routed_output.shape != (num_tokens, contract.latent_size):
239 - raise ValueError(
240 - f"routed_output must have shape [M, {contract.latent_size}]."
241 - )
247 + if isinstance(routed_output, UnfinalizedMoEOutput):
248 + num_tokens = routed_output.expanded_idx_to_permuted_idx.shape[0]
249 + else:
250 + if routed_output.ndim != 2:
251 + raise ValueError("routed_output must be a 2D tensor.")
252 + num_tokens = routed_output.shape[0]
253 + if routed_output.shape != (num_tokens, contract.latent_size):
254 + raise ValueError(
255 + f"routed_output must have shape [M, {contract.latent_size}]."
256 + )
257 if shared_output.shape != (num_tokens, contract.hidden_size):
258 raise ValueError(
259 f"shared_output must have shape [M, {contract.hidden_size}]."
@@ -256,7 +271,7 @@ class KimiK3LatentMoETailOp:
271 f"{contract.max_num_tokens} tokens."
272 )
273
259 - tensors = (routed_output, shared_output, rms_weight, up_weight)
274 + tensors = (shared_output, rms_weight, up_weight)
275 if any(tensor.device != contract.device for tensor in tensors):
276 raise ValueError("All inputs must be on the contract device.")
277 if any(tensor.dtype != contract.dtype for tensor in tensors):