Skip to content
21 changes: 13 additions & 8 deletions tensorrt_llm/_torch/moe/fused_moe/MOE_DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Adaptive quantize/dispatch order (gated by comm.supports_post_quant_dispatch()):
Pre-quant flow: comm.dispatch() → quantize_input() (send raw, quantize locally)
```

EPLB hooks fire only at the first/last chunk of the first/last `repeat_idx`. Multi-stream chunk overlap is enabled when `not enable_alltoall and aux_stream is not None`.
EPLB hooks fire only at the first/last chunk of the first/last `repeat_idx`. Multi-stream chunk overlap is enabled when `not enable_alltoall and aux_stream is not None`, unless the backend declares `input_requirement.run_moe_reuses_buffers`: its `run_moe` returns a view of a buffer the next call overwrites and keeps scratch across calls (the B12x leaves, whose `B12xMoEWrapper` output buffer is also shared across layers). `ExternalCommMoEScheduler` then runs its chunks on one stream and copies every chunk but the last out before the next chunk runs — the trailing `torch.cat` copies the last one. `FusedCommMoEScheduler` copies no chunk output at all, so it refuses a backend that declares the field rather than aliasing its chunks.

`unfinalized_combine_fn` is a constructor argument (`create_moe(..., unfinalized_combine_fn=...)`) for a model that needs a per-`(token, expert)` transform applied to each expert output *before* the top-k sum. Once registered it runs on **every** `do_finalize=False` forward, with or without a comm strategy, so the forward's return type (dense tensor vs. unfinalized triple) depends only on static config. Under a comm strategy the scheduler runs it on the dispatched rows and the combine reduces already-finalized partials — equal to finalizing after the combine, because each term depends on one row alone and non-local slots contribute exact zeros. That reordering is only sound for a combine that is an unweighted row sum over a dense per-token tensor; `DeepEPLowLatency.combine` is not one (it consumes expert-major rows and applies the routing weights itself), which is what `Communication.supports_finalize_before_combine()` declares. `ConfigurableMoE.validate_backend` rejects at construction: a backend without `supports_unfinalized_output()`, a comm without `supports_finalize_before_combine()`, and `apply_router_weight_on_input`. With no hook registered, `do_finalize=False` plus a comm strategy stays a named runtime refusal instead of a crash inside the comm layer.

Expand Down Expand Up @@ -227,7 +227,7 @@ Still on old path (standalone, with embedded communication):
| `fused_moe_deepgemm.py` | `DeepgemmCudaFp8BlockScalesImpl` (aliased as `DeepGemmFusedMoE`) | SM100/SM103/SM107 | FP8 Block Scales on Blackwell/Rubin | `EXTERNAL_COMM` |
| `cutedsl/dense_gemm.py` (+ the `fused_moe_densegemm.py` alias module) | `TrtllmCutedslDenseGemmNvfp4Impl` (aliased as `DenseGEMMFusedMoE`) | SM100/SM103 | NVFP4 min-latency; CuTe DSL dense GEMM packs all experts into one matrix (vs Cutlass per-expert scatter), efficient for small token counts | `EXTERNAL_COMM` |
| `cutedsl/grouped_gemm/` (+ the `fused_moe_cute_dsl.py` alias module) | the abstract `CuteDslFusedMoEBase` (aliased as `CuteDslFusedMoE`) and its two leaves, `TrtllmCutedslGroupedGemmNvfp4Impl` (`trtllm.cutedsl.grouped_gemm.nvfp4`) and `TrtllmCutedslGroupedGemmBf16Impl` (`trtllm.cutedsl.grouped_gemm.none`) | SM100/SM103/SM107; BF16 SM107 only | High throughput NVFP4, generally faster than Cutlass; unquantized BF16 SwiGLU on Rubin | `EXTERNAL_COMM` |
| `fused_moe_cute_dsl_b12x.py` | `CuteDslB12xNvfp4FusedMoE` and `CuteDslB12xW4a16Nvfp4FusedMoE` (shared mixin `CuteDslB12xFusedMoEBase`, aliased as `CuteDslB12xFusedMoE`) | SM120/SM121 | NVFP4 (CUTLASS prefill, FlashInfer b12x decode) and W4A16 NVFP4 (b12x for every call), best on RTX PRO 6000 and DGX Spark. They head the `CUTEDSL` family, so they win on SM120/121 when flashinfer is present and yield to the grouped-GEMM leaves otherwise. Single-GPU-shaped topology only: with no dispatch/combine kernel they reject `ep_size > 1` and attention-DP | `EXTERNAL_COMM` |
| `fused_moe_cute_dsl_b12x.py` | `CuteDslB12xNvfp4FusedMoE` and `CuteDslB12xW4a16Nvfp4FusedMoE` (shared mixin `CuteDslB12xFusedMoEBase`, aliased as `CuteDslB12xFusedMoE`) | SM120/SM121 | NVFP4 (CUTLASS prefill, FlashInfer b12x decode) and W4A16 NVFP4 (b12x for every call), best on RTX PRO 6000 and DGX Spark. They head the `CUTEDSL` family, so they win on SM120/121 when flashinfer is present and yield to the grouped-GEMM leaves otherwise. BF16 activations only (`B12xMoEWrapper` reads and writes bfloat16), so a float16 layer falls back to Cutlass. The NVFP4 leaf also declines `force_dynamic_quantization` (`DYNAMIC_QUANT_UNSUPPORTED`): b12x quantizes decode activations against load-time scales, so such a layer falls back to Cutlass too. Single-GPU-shaped topology only: with no dispatch/combine kernel they reject `ep_size > 1` and attention-DP | `EXTERNAL_COMM` |
| `cutedsl/fused_fc12.py` (+ the `fused_moe_cute_dsl_fc12.py` alias module) | `TrtllmCutedslFusedFc12Nvfp4Impl` (`trtllm.cutedsl.fused_fc12.nvfp4`, aliased as `CuteDslFc12FusedMoE`) | SM107 (Rubin) | NVFP4 fused FC1+FC2 in one persistent kernel (`cute_dsl_nvfp4_fc12_fused_rubin`) with a trace-time SwiGLU or SiTU epilogue, keeping the FC1→FC2 intermediate on-chip; shares `TrtllmCutedslGroupedGemmNvfp4Impl`'s NVFP4 weight layout and autotune runner; reachable through the `CUTEDSL_FC12` family or a pinned `impl_id`; needs `MoEDep.CUTEDSL_RUBIN`; non-uGPU | `EXTERNAL_COMM` |
| `mega_moe/mega_moe_deepgemm.py` | `DeepgemmCudaW4a8Mxfp4Mxfp8Impl` (aliased as `MegaMoEDeepGemm`) | SM100/SM103/SM107 | W4A8_MXFP4_MXFP8 via DeepGEMM `fp8_fp4_mega_moe` fused dispatch+GEMM+act+GEMM+combine kernel; requires `hidden_size % 512 == 0` | `FUSED_COMM` |
| `mega_moe/mega_moe_cute_dsl.py` | `TrtllmCutedslMegaMoeNvfp4Impl` (aliased as `MegaMoECuteDsl`) | SM100/SM103/SM107 | NVFP4 fused dispatch+FC1+act+FC2+combine; internally selects one of its per-architecture kernel variants. Requires CUDA 13 Cutlass DSL and a symmetric-memory provider. Uses uniform activation constants and applies routing weights before FC2 quantization for DeepSeek-V4, after FC2 for other models | `FUSED_COMM` |
Expand Down Expand Up @@ -561,7 +561,10 @@ implementations' rejections only (the fallback candidates are asked after
them): the highest-priority one that declined for a reason other than the
layer's format or the SM. On SM100 the SM120-only b12x leaves lead the `CUTEDSL`
family, so without the SM exclusion their `SM_UNSUPPORTED` would stand in for
the CuteDSL grouped-GEMM leaf's rejection.
the CuteDSL grouped-GEMM leaf's rejection. The same exclusion means a leaf that
serves the format but not a deployment setting must not answer
`QUANT_UNSUPPORTED`, or the report blames another candidate: the B12x NVFP4
leaf declines `force_dynamic_quantization` as `DYNAMIC_QUANT_UNSUPPORTED`.

Two things fail hard, and they fail in different places. An unknown or retired
backend literal raises `ValueError` before any candidate is considered, so there
Expand Down Expand Up @@ -639,7 +642,7 @@ Each backend's `can_implement(p, d)` classmethod declares what it supports. Sour
| FP8 QDQ | Y (SM89+) | N | N | N | N | N | N | Y (SM90) | N | Y |
| FP8 Block Scales | Y (SM90, SM120) | Y (SM100/103/107) | Y (SM100/103/107) | N | N‡ | N | N | N | N | Y |
| NVFP4 | Y (SM100/103/107/120/121) | Y (SM100/103/107) | N | Y (SM100/103) | Y (SM100/103/107/120/121)¶ | N | Y (SM100/103/107, cu13 cutlass-dsl + symmetric-memory provider; per-expert alpha/norm_const + SwiGLU clamp) | N | Y (SM89-SM99) | Y |
| W4A16 NVFP4 | Y (SM80+, dequant-on-the-fly) | N | N | N | Y (SM120/121 via `CuteDslB12xW4a16Nvfp4FusedMoE`, needs flashinfer) | N | N | N | Y (SM89-SM99, BF16) | Y |
| W4A16 NVFP4 | Y (SM80+, dequant-on-the-fly) | N | N | N | Y (SM120/121, BF16, via `CuteDslB12xW4a16Nvfp4FusedMoE`, needs flashinfer) | N | N | N | Y (SM89-SM99, BF16) | Y |
| W4A8 NVFP4 FP8 | N | Y (SM100/103) | N | N | N | N | N | N | N | N |
| W4A16 MXFP4 | Y (SM90) | Y (SM100/103/107) | N | N | N | N | N | Y (SM90) | N | N |
| W4A8 MXFP4 FP8 | Y (SM100/103/107) | Y (SM100/103/107) | N | N | N | N | N | Y (SM90) | N | N |
Expand Down Expand Up @@ -689,8 +692,9 @@ two run different kernels: `W4A16NVFP4CutlassFusedMoEMethod` dequantizes the FP4
weights into the activation dtype each forward and then calls the unquantized
kernel, so it inherits that path's `SM80+` floor instead of needing NVFP4
tensor cores. This is what makes Cutlass the landing spot when a W4A16 NVFP4
layer finds no specialized backend — the B12x W4A16 leaf without flashinfer,
or any SM outside Marlin's 89-99 and B12x's 120/121.
layer finds no specialized backend — the B12x W4A16 leaf without flashinfer
or with float16 activations, or any SM outside Marlin's 89-99 and B12x's
120/121.

‡ No CuteDSL leaf publishes `fp8_block_scales`; the DeepGEMM and TRTLLM-Gen
leaves own the format on SM100/103/107 and Cutlass on SM90/SM120. Because a
Expand Down Expand Up @@ -780,8 +784,9 @@ the formats whose leaf sets `requires_oss_cutlass_build` (`MXFP8`,
`W4A8_MXFP4_MXFP8`, `W4A16_MXFP4`, `W4A8_AWQ`), dynamic FC2 scaling on the leaf
that sets `uses_dynamic_fc2_scale` (`NVFP4`), post-SiLU clamping, and
routed-expert LoRA. The B12x NVFP4 leaf runs the same gate for the prefill
chunks it routes through CUTLASS. W4A8 AWQ uses per-expert activation-scale
buffers and is unavailable in the legacy build. `CutlassFusedMoEBase` exposes
chunks it routes through CUTLASS; it declines dynamic quantization before
that gate on any build, because its decode route cannot honour it. W4A8 AWQ
uses per-expert activation-scale buffers and is unavailable in the legacy build. `CutlassFusedMoEBase` exposes
`supports_alltoall` before communication selection; it is False only for a
leaf that issues the op on a legacy build. Unsupported auto-selection
falls back to AllGather/ReduceScatter, and
Expand Down
52 changes: 31 additions & 21 deletions tensorrt_llm/_torch/moe/fused_moe/cutlass/lora.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,36 @@ def raise_moe_lora_multichunk_unsupported(num_chunks: int) -> None:
)


def moe_lora_active(lora_params: dict | None, layer_idx: int | None) -> bool:
"""Return True when ``lora_params`` carries routed-expert MoE LoRA tensors
for layer ``layer_idx``.

``lora_params`` describes every adapter of the forward, so a backend that
cannot fuse MoE LoRA asks this rather than refusing any ``lora_params``.
"""
if not lora_params or layer_idx is None:
return False
# CUDA-graph slot-indexed mode carries MoE LoRA in cuda_graph_params
# rather than a per-layer eager dict (mirrors _extract_moe_lora_tensors),
# so consult the graph layer map to keep the multi-chunk guard effective
# during capture/replay.
if lora_params.get("use_cuda_graph_mode", False):
cuda_graph_params = lora_params.get("cuda_graph_params")
if cuda_graph_params is None:
return False
layer_module2key = getattr(cuda_graph_params, "layer_module2key", {})
return any(
(layer_idx, int(LoraModuleType.from_string(name))) in layer_module2key
for name in MOE_LORA_MODULE_NAMES
)
layer_params = lora_params.get(layer_idx, {})
if not layer_params:
return False
return any(
int(LoraModuleType.from_string(name)) in layer_params for name in MOE_LORA_MODULE_NAMES
)


class CutlassMoELoraMixin:
"""The LoRA half of a CUTLASS leaf that supports routed-expert LoRA.

Expand Down Expand Up @@ -200,27 +230,7 @@ def _moe_lora_active(self, lora_params: dict | None) -> bool:
"""Return True when lora_params carries routed-expert MoE LoRA tensors
for this layer, meaning run_moe would fuse a LoRA delta.
"""
if not lora_params or self.layer_idx is None:
return False
# CUDA-graph slot-indexed mode carries MoE LoRA in cuda_graph_params
# rather than a per-layer eager dict (mirrors _extract_moe_lora_tensors),
# so consult the graph layer map to keep the multi-chunk guard effective
# during capture/replay.
if lora_params.get("use_cuda_graph_mode", False):
cuda_graph_params = lora_params.get("cuda_graph_params")
if cuda_graph_params is None:
return False
layer_module2key = getattr(cuda_graph_params, "layer_module2key", {})
return any(
(self.layer_idx, int(LoraModuleType.from_string(name))) in layer_module2key
for name in MOE_LORA_MODULE_NAMES
)
layer_params = lora_params.get(self.layer_idx, {})
if not layer_params:
return False
return any(
int(LoraModuleType.from_string(name)) in layer_params for name in MOE_LORA_MODULE_NAMES
)
return moe_lora_active(lora_params, self.layer_idx)

@staticmethod
def _empty_kernel_slot_dict() -> dict[str, torch.Tensor | None]:
Expand Down
Loading
Loading