Add FlashInfer SM90 cutlass MXFP4 MoE backend (W4A16) for GPT-OSS + DeepSeek-V4 (#24816)

Co-authored-by: luoyuan.luo <luoyuan.luo@antgroup.com>
This commit is contained in:
Yuan Luo
2026-05-13 14:53:18 -07:00
committed by GitHub
co-authored by luoyuan.luo
parent d6b28b4a69
commit 28758d37dd
8 changed files with 1542 additions and 3 deletions
+12
View File
@@ -243,6 +243,18 @@ class BypassedTopKOutput(NamedTuple):
def format(self) -> TopKOutputFormat:
return TopKOutputFormat.BYPASSED
def to_standard(self, layer_id: Optional[int] = None) -> "StandardTopKOutput":
"""Materialize routing tensors. Used by MoE kernels that need explicit
topk_ids / topk_weights rather than doing routing internally."""
return select_experts(
hidden_states=self.hidden_states,
router_logits=self.router_logits,
topk_config=self.topk_config,
layer_id=layer_id,
num_token_non_padded=self.num_token_non_padded,
expert_location_dispatch_info=self.expert_location_dispatch_info,
)
# -------------------------------- TopK ---------------------------------------
@@ -262,6 +262,15 @@ class Fp8Config(QuantizationConfig):
return Mxfp4MarlinMoEMethod(fp8_method, prefix=prefix)
if self.is_fp4_experts and get_moe_runner_backend().is_flashinfer_mxfp4():
# SM100 (Blackwell) -> trtllm-gen path.
# SM90 (Hopper) -> cutlass mixed-input path (FlashInfer #3084).
if is_sm90_supported() and not is_sm100_supported():
from sglang.srt.layers.quantization.mxfp4_flashinfer_cutlass_moe import (
Mxfp4FlashinferCutlassMoEMethod,
)
return Mxfp4FlashinferCutlassMoEMethod(fp8_method, prefix=prefix)
from sglang.srt.layers.quantization.mxfp4_flashinfer_trtllm_moe import (
Mxfp4FlashinferTrtllmMoEMethod,
)
+269 -1
View File
@@ -16,12 +16,18 @@
from __future__ import annotations
import os
from dataclasses import replace
from typing import TYPE_CHECKING, List, Optional
import torch
from torch.nn.parameter import Parameter
# Silence the TRT-LLM cutlass autotune trace embedded inside FlashInfer's
# cutlass_fused_moe. Its C++ logger reads TLLM_LOG_LEVEL on first kernel launch;
# setdefault preserves any explicit user override.
os.environ.setdefault("TLLM_LOG_LEVEL", "INFO")
from sglang.srt.distributed import get_tp_group
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
use_symmetric_memory,
@@ -62,7 +68,27 @@ if is_flashinfer_available():
nvfp4_block_scale_interleave,
trtllm_fp4_block_scale_moe,
)
from flashinfer.fused_moe.core import get_w2_permute_indices_with_cache
from flashinfer.fused_moe import cutlass_fused_moe as flashinfer_cutlass_fused_moe
from flashinfer.fused_moe.core import (
ActivationType,
get_w2_permute_indices_with_cache,
)
# SM90 mixed-input helpers landed in FlashInfer #3084 (post-0.6.10). Older
# versions don't ship them; gate at import so unrelated code paths still load.
try:
from flashinfer.fused_moe import (
interleave_moe_scales_for_sm90_mixed_gemm,
interleave_moe_weights_for_sm90_mixed_gemm,
)
_FI_HAS_SM90_CUTLASS_MXFP4 = True
except ImportError:
interleave_moe_scales_for_sm90_mixed_gemm = None
interleave_moe_weights_for_sm90_mixed_gemm = None
_FI_HAS_SM90_CUTLASS_MXFP4 = False
else:
_FI_HAS_SM90_CUTLASS_MXFP4 = False
_flashinfer_mxfp4_permute_indices_cache: dict[torch.Size, torch.Tensor] = {}
_flashinfer_mxfp4_permute_indices_device_cache: dict[
@@ -318,6 +344,28 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
self.flashinfer_mxfp4_moe_precision = (
get_global_server_args().flashinfer_mxfp4_moe_precision
)
# When `flashinfer_mxfp4` is enabled, dispatch to one of two FlashInfer
# entry points depending on the GPU:
# - SM100 (Blackwell) -> trtllm_fp4_block_scale_moe (existing)
# - SM90 (Hopper) -> cutlass_fused_moe(use_w4_group_scaling=True)
# (FlashInfer PR #3084, post-0.6.10)
self._fi_kernel: Optional[str] = None
if self.use_flashinfer:
if is_sm100_supported():
self._fi_kernel = "trtllm_sm100"
elif is_sm90_supported():
if not _FI_HAS_SM90_CUTLASS_MXFP4:
raise RuntimeError(
"moe_runner_backend=flashinfer_mxfp4 on SM90 requires the "
"interleave_moe_{weights,scales}_for_sm90_mixed_gemm helpers "
"from FlashInfer PR #3084 (>= 0.6.11). Upgrade flashinfer-python "
"or pick a different backend (e.g. marlin / triton_kernel)."
)
self._fi_kernel = "cutlass_sm90"
else:
raise NotImplementedError(
"moe_runner_backend=flashinfer_mxfp4 requires SM90 or SM100."
)
def create_weights(
self,
@@ -349,6 +397,26 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
intermediate_size_per_partition_after_pad = round_up(
intermediate_size_per_partition, triton_kernels_padding_alignment
)
elif self._fi_kernel == "cutlass_sm90":
# cutlass mixed-input GEMM contraction dim K must be % 128 == 0
# (interleave factor for MXFP4 group_size=32 is 4). The kernel
# also expects ``fc1_expert_weights`` in halved ``[up; gate]``
# layout, which means the padding boundary must fall on the
# gate / up split.
#
# The mxfp4 weight loader (FusedMoE.weight_loader fast path) does
# a NAIVE copy of HF's ``[2*intermediate_size, hidden_packed]``
# tensor into the buffer's ``[:dim1, :dim2]`` slice. Padding the
# buffer here would push the gate/up boundary, so HF's "up"
# rows would land in the buffer's "gate" half and vice versa.
# Marlin sidesteps this by not padding; we do the same and
# rebuild a properly-padded buffer in
# ``_process_weights_for_sm90_cutlass`` after the load completes.
self._padded_intermediate = round_up(intermediate_size_per_partition, 128)
self._padded_hidden = round_up(hidden_size, 128)
# create_weights below uses the *unpadded* sizes so the loader's
# naive-copy fast path is correct.
intermediate_size_per_partition_after_pad = intermediate_size_per_partition
elif _use_aiter:
intermediate_size_per_partition_after_pad = round_up(
@@ -438,6 +506,9 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
set_weight_attrs(w2_weight_bias, extra_weight_attrs)
def process_weights_after_loading(self, layer):
if self._fi_kernel == "cutlass_sm90":
self._process_weights_for_sm90_cutlass(layer)
return
if self.use_flashinfer:
# TODO: these values are hardcoded for now, we need to get them from the model
layer.gemm1_alpha = Parameter(
@@ -736,6 +807,133 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
layer.w2_weight = Parameter(w2_weight.data, requires_grad=False)
torch.cuda.empty_cache()
def _process_weights_for_sm90_cutlass(self, layer):
"""De-interleave + pad + halving-swap + byte-interleave MXFP4 weights
for FlashInfer's SM90 ``cutlass_fused_moe(use_w4_group_scaling=True)``
path (PR #3084).
The cutlass kernel needs (a) K (contraction dim) % 128 == 0, and (b)
``fc1_expert_weights`` in halved ``[up; gate]`` order -- the
``compute_with_experts`` reference in FlashInfer's
``test_trtllm_cutlass_fused_moe.py`` splits
``w3, w1 = chunk(W, 2, dim=0)`` and uses w3 as up, w1 as gate.
GPT-OSS's HF layout is *interleaved* ``[g_0, u_0, g_1, u_1, ..., g_{N-1}, u_{N-1}]``
(each pair occupies two adjacent rows). The mxfp4 weight loader does
a naive copy, so our unpadded buffer is interleaved post-load. We
de-interleave (even rows -> gate, odd rows -> up), pad each half from
N_un to N_pad, concatenate as halved ``[up; gate]``, and then run
FlashInfer's byte / scale interleave helpers.
"""
sf_block_size = 32 # MXFP4 group size
# Sizes from the unpadded loaded buffers.
N_un = layer.w13_weight.shape[1] // 2 # intermediate (unpadded)
K_un = (
layer.w13_weight.shape[2] * 2
) # hidden (unpadded, *2 because packed 4-bit)
N_pad = self._padded_intermediate
K_pad = self._padded_hidden
# Use the local expert count (matches the existing buffer allocation in
# create_weights) so the SM90 cutlass path remains correct under
# Expert Parallelism. `self.num_experts` is the *global* count.
E = layer.num_local_experts
device = layer.w13_weight.device
bias_dtype = layer.w13_weight_bias.dtype
# ---- De-interleave + pad w13 weight/scale/bias to halved [up; gate]
# Even rows of HF = gate, odd rows = up. After splitting we pad each
# half along its row dim (N) from N_un to N_pad with zeros, and along
# its last dim (K) from K_un (or K_un / sf_block_size) to K_pad.
def _stack_up_gate_w13(unpadded_w13, last_pad, last_un):
# unpadded_w13: [E, 2*N_un, last_un]
# Returns: [E, 2*N_pad, last_pad] in [up_padded; gate_padded] order.
gate_rows = unpadded_w13[:, 0::2, :] # [E, N_un, last_un]
up_rows = unpadded_w13[:, 1::2, :] # [E, N_un, last_un]
out = torch.zeros(
E, 2 * N_pad, last_pad, dtype=unpadded_w13.dtype, device=device
)
# First half: up (with row + col padding zeros).
out[:, :N_un, :last_un] = up_rows
# Second half: gate.
out[:, N_pad : N_pad + N_un, :last_un] = gate_rows
return out
w13_padded = _stack_up_gate_w13(
layer.w13_weight.data.view(torch.uint8), K_pad // 2, K_un // 2
)
w13_scale_padded = _stack_up_gate_w13(
layer.w13_weight_scale.data,
K_pad // sf_block_size,
K_un // sf_block_size,
)
# Bias: same de-interleave on dim=-1.
w13_bias_gate = layer.w13_weight_bias.data[:, 0::2] # [E, N_un]
w13_bias_up = layer.w13_weight_bias.data[:, 1::2] # [E, N_un]
w13_bias_padded = torch.zeros(E, 2 * N_pad, dtype=bias_dtype, device=device)
w13_bias_padded[:, :N_un] = w13_bias_up
w13_bias_padded[:, N_pad : N_pad + N_un] = w13_bias_gate
def _pad_w2_3d(unpadded, last_pad, last_un):
out = torch.zeros(E, K_pad, last_pad, dtype=unpadded.dtype, device=device)
out[:, :K_un, :last_un] = unpadded[:, :K_un, :]
return out
# ---- w2 (no halving, just pad to [E, K_pad, N_pad/2]) ----------------
w2_padded = _pad_w2_3d(
layer.w2_weight.data.view(torch.uint8), N_pad // 2, N_un // 2
)
w2_scale_padded = _pad_w2_3d(
layer.w2_weight_scale.data,
N_pad // sf_block_size,
N_un // sf_block_size,
)
w2_bias_padded = torch.zeros(E, K_pad, dtype=bias_dtype, device=device)
w2_bias_padded[:, :K_un] = layer.w2_weight_bias.data
# ---- Per-expert SwiGLU scalars (GPT-OSS defaults) ------------------
layer.swiglu_alpha = Parameter(
torch.full((E,), 1.702, dtype=torch.float32, device=device),
requires_grad=False,
)
layer.swiglu_beta = Parameter(
torch.full((E,), 1.0, dtype=torch.float32, device=device),
requires_grad=False,
)
layer.swiglu_limit = Parameter(
torch.full((E,), 7.0, dtype=torch.float32, device=device),
requires_grad=False,
)
# ---- FlashInfer SM90 byte / scale interleave -----------------------
# The padded buffers above are contiguous by construction (allocated
# via torch.zeros + slice assignment), so we feed them straight in.
layer.w13_weight = Parameter(
interleave_moe_weights_for_sm90_mixed_gemm(w13_padded, "fp4"),
requires_grad=False,
)
layer.w2_weight = Parameter(
interleave_moe_weights_for_sm90_mixed_gemm(w2_padded, "fp4"),
requires_grad=False,
)
layer.w13_weight_scale = Parameter(
interleave_moe_scales_for_sm90_mixed_gemm(
w13_scale_padded, group_size=sf_block_size
),
requires_grad=False,
)
layer.w2_weight_scale = Parameter(
interleave_moe_scales_for_sm90_mixed_gemm(
w2_scale_padded, group_size=sf_block_size
),
requires_grad=False,
)
layer.w13_weight_bias = Parameter(w13_bias_padded, requires_grad=False)
layer.w2_weight_bias = Parameter(w2_bias_padded, requires_grad=False)
torch.cuda.empty_cache()
def create_moe_runner(
self, layer: torch.nn.Module, moe_runner_config: MoeRunnerConfig
):
@@ -761,6 +959,74 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
# TODO(cwan): refactor other backends
pass
def _apply_sm90_cutlass(self, layer, x, topk_output):
"""SM90 (Hopper) MXFP4 x BF16 MoE via FlashInfer's cutlass mixed-input
path (PR #3084). The fused kernel does GEMM1 + SwiGLU + GEMM2 in one
call; weights/scales were pre-interleaved at load time."""
from sglang.srt.layers.moe.token_dispatcher import StandardCombineInput
from sglang.srt.layers.moe.topk import TopKOutputChecker
# Under ``--moe-runner-backend flashinfer_mxfp4`` the SGLang TopK layer
# emits BypassedTopKOutput by default (the SM100 trtllm-gen kernel does
# routing internally). The cutlass kernel needs explicit topk_ids /
# topk_weights, so materialize them here when bypassed.
if TopKOutputChecker.format_is_bypassed(topk_output):
topk_output = topk_output.to_standard()
topk_weights, topk_ids = topk_output.topk_weights, topk_output.topk_ids
# Pad input hidden dim to the (already-padded) loaded weight width.
origin_hidden = x.shape[-1]
padded_hidden = self._padded_hidden
if padded_hidden != origin_hidden:
x = torch.nn.functional.pad(
x,
(0, padded_hidden - origin_hidden),
mode="constant",
value=0.0,
)
output_dtype = torch.bfloat16
# Output is allocated at padded width (kernel writes padded_hidden
# columns), then trimmed back to origin_hidden before returning.
with use_symmetric_memory(
get_tp_group(), disabled=not is_allocation_symmetric()
):
out_padded = torch.empty(
x.shape[0], padded_hidden, dtype=output_dtype, device=x.device
)
flashinfer_cutlass_fused_moe(
input=x,
token_selected_experts=topk_ids.to(torch.int),
token_final_scales=topk_weights,
fc1_expert_weights=layer.w13_weight, # uint8 [E, 2*N, K/2] interleaved
fc2_expert_weights=layer.w2_weight, # uint8 [E, K, N/2] interleaved
output_dtype=output_dtype,
quant_scales=[
layer.w13_weight_scale.view(torch.int32),
layer.w2_weight_scale.view(torch.int32),
],
fc1_expert_biases=layer.w13_weight_bias, # bf16 [E, 2*N]
fc2_expert_biases=layer.w2_weight_bias, # bf16 [E, K]
swiglu_alpha=layer.swiglu_alpha,
swiglu_beta=layer.swiglu_beta,
swiglu_limit=layer.swiglu_limit,
tp_size=layer.moe_tp_size,
tp_rank=layer.moe_tp_rank,
ep_size=layer.moe_ep_size,
ep_rank=layer.moe_ep_rank,
use_w4_group_scaling=True,
activation_type=ActivationType.Swiglu,
tune_max_num_tokens=next_power_of_2(x.shape[0]),
output=out_padded,
)
if padded_hidden != origin_hidden:
out = out_padded[:, :origin_hidden].contiguous()
else:
out = out_padded
return StandardCombineInput(hidden_states=out)
def apply(
self,
layer: torch.nn.Module,
@@ -773,6 +1039,8 @@ class Mxfp4MoEMethod(FusedMoEMethodBase):
x = dispatch_output.hidden_states
topk_output = dispatch_output.topk_output
if self._fi_kernel == "cutlass_sm90":
return self._apply_sm90_cutlass(layer, x, topk_output)
if self.use_flashinfer:
# When bf16 mode is enabled, we don't need to quantize the input,
# TRT-LLM automatically handles quantization in the kernel implementation and pipelines it with GEMM operations,
@@ -0,0 +1,263 @@
"""DeepSeek-V4 MXFP4 expert backend backed by FlashInfer's SM90 cutlass
mixed-input MoE GEMM (FlashInfer PR #3084).
Sibling of :class:`Mxfp4MarlinMoEMethod` and :class:`Mxfp4FlashinferTrtllmMoEMethod`.
Wired into :func:`Fp8MoEConfig.get_quant_method` when
``is_fp4_experts=True`` and ``--moe-runner-backend flashinfer_mxfp4`` is
selected on a Hopper (SM90) device. SM100 still routes to
:class:`Mxfp4FlashinferTrtllmMoEMethod` (trtllm-gen).
Performance trade-off vs Marlin (kernel-level on H100, GPT-OSS-like body):
- decode (M <= 64) : Marlin +12-15 %
- tie (M ~= 256)
- prefill (M >= 1024) : FlashInfer +24-36 %
PD-disaggregated prefill workers are the natural fit; decode workers should
keep the Marlin default.
"""
from __future__ import annotations
import logging
import os
from typing import TYPE_CHECKING
import torch
from torch.nn import Module
from torch.nn.parameter import Parameter
from sglang.srt.distributed import get_tp_group
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
use_symmetric_memory,
)
from sglang.srt.layers.dp_attention import is_allocation_symmetric
from sglang.srt.layers.moe.token_dispatcher.standard import StandardCombineInput
from sglang.srt.layers.moe.topk import TopKOutputChecker
from sglang.srt.utils import is_flashinfer_available, log_info_on_rank0
from sglang.srt.utils.common import next_power_of_2
# Silence the TRT-LLM cutlass autotune trace embedded inside FlashInfer's
# cutlass_fused_moe. Its C++ logger reads TLLM_LOG_LEVEL on first kernel launch;
# setdefault preserves any explicit user override.
os.environ.setdefault("TLLM_LOG_LEVEL", "INFO")
if is_flashinfer_available():
from flashinfer.fused_moe import cutlass_fused_moe as flashinfer_cutlass_fused_moe
from flashinfer.fused_moe.core import ActivationType
try:
from flashinfer.fused_moe import (
interleave_moe_scales_for_sm90_mixed_gemm,
interleave_moe_weights_for_sm90_mixed_gemm,
)
_FI_HAS_SM90_CUTLASS_MXFP4 = True
except ImportError:
interleave_moe_scales_for_sm90_mixed_gemm = None
interleave_moe_weights_for_sm90_mixed_gemm = None
_FI_HAS_SM90_CUTLASS_MXFP4 = False
else:
_FI_HAS_SM90_CUTLASS_MXFP4 = False
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from sglang.srt.layers.moe.token_dispatcher import CombineInput, DispatchOutput
# MXFP4 group/block size (E8M0 scale per 32 fp4 weights).
_GROUP_SIZE = 32
class Mxfp4FlashinferCutlassMoEMethod:
"""DeepSeek-V4 W4A16 MXFP4 MoE via FlashInfer's SM90 mixed-input cutlass
grouped GEMM. The fused kernel does GEMM1 + clamped SwiGLU + GEMM2 in one
call after a one-shot weight/scale interleave at load time."""
def __init__(self, fp8_method, prefix: str):
if not _FI_HAS_SM90_CUTLASS_MXFP4:
raise RuntimeError(
"Mxfp4FlashinferCutlassMoEMethod requires FlashInfer >= 0.6.11 "
"(PR #3084 SM90 mixed-input helpers). Older builds lack "
"interleave_moe_{weights,scales}_for_sm90_mixed_gemm; "
"either upgrade flashinfer-python or fall back to "
"--moe-runner-backend marlin."
)
self._fp8 = fp8_method
self.prefix = prefix
self._swiglu_alpha_tensor: torch.Tensor | None = None
self._swiglu_beta_tensor: torch.Tensor | None = None
self._swiglu_limit_tensor: torch.Tensor | None = None
# --- Lifecycle ---------------------------------------------------------
def create_weights(
self,
layer: Module,
num_experts: int,
hidden_size: int,
intermediate_size_per_partition: int,
params_dtype,
**extra_weight_attrs,
):
# SM90 mixed-input GEMM: contraction dim K must be a multiple of 128
# (interleave factor = 128 / group_size = 4). For DSv4 (hidden=7168,
# inter=2048) both are already multiples of 128; we assert rather than
# silently pad here, since padding the FP8-base buffers in-place would
# require deeper changes.
if hidden_size % 128 != 0 or intermediate_size_per_partition % 128 != 0:
raise ValueError(
"Mxfp4FlashinferCutlassMoEMethod requires hidden_size and "
"intermediate_size_per_partition to be multiples of 128 "
f"(got hidden={hidden_size}, "
f"intermediate={intermediate_size_per_partition})."
)
# Raw weight shapes match what the fp8 base method allocates for fp4
# experts (uint8 4-bit packed weights, fp32 E8M0 scales). Delegate.
self._fp8.create_weights(
layer,
num_experts,
hidden_size,
intermediate_size_per_partition,
params_dtype,
**extra_weight_attrs,
)
def create_moe_runner(self, layer: Module, moe_runner_config) -> None:
self.moe_runner_config = moe_runner_config
# DSv4 uses standard SwiGLU plus a config-driven activation clamp.
# We pass all three (alpha, beta, limit) as explicit per-expert tensors
# rather than mixing tensors with None: the cutlass SwiGLU kernel
# branches on whether each is None, and partial-None inputs land in
# less-tested code paths. ``alpha=1.0``, ``beta=0.0`` reproduce plain
# ``silu(gate) * up``; ``limit`` enforces the activation clamp the
# checkpoint was trained with.
swiglu_limit = getattr(moe_runner_config, "swiglu_limit", None)
if swiglu_limit is not None:
E = layer.num_local_experts
device = layer.w13_weight.device
self._swiglu_alpha_tensor = torch.ones(
E, dtype=torch.float32, device=device
)
self._swiglu_beta_tensor = torch.zeros(
E, dtype=torch.float32, device=device
)
self._swiglu_limit_tensor = torch.full(
(E,), float(swiglu_limit), dtype=torch.float32, device=device
)
else:
self._swiglu_alpha_tensor = None
self._swiglu_beta_tensor = None
self._swiglu_limit_tensor = None
def process_weights_after_loading(self, layer: Module) -> None:
from sglang.srt.layers.quantization.utils import reorder_w1w3_to_w3w1
# Run the fp8 base hook first (ROCm normalization, mxfp8 requant, ...).
self._fp8.process_weights_after_loading(layer)
if getattr(layer, "_mega_moe_weights_built", False):
return
# cutlass_fused_moe expects fc1 in [w3; w1] = [up; gate] order, just
# like the trtllm-gen path. The HF / FP8 loader emits [w1; w3].
w13, w13_s = reorder_w1w3_to_w3w1(
layer.w13_weight.data, layer.w13_weight_scale_inv.data
)
layer.w13_weight = Parameter(w13, requires_grad=False)
layer.w13_weight_scale_inv = Parameter(w13_s, requires_grad=False)
log_info_on_rank0(
logger,
f"Preparing DSv4 MXFP4 experts for FlashInfer SM90 cutlass "
f"(layer: {self.prefix})...",
)
# FP8 base stores scales as fp32 numerical values (= 2**e). The
# FlashInfer SM90 helper reads raw E8M0 bytes (uint8 with the
# exponent + 127 bias). Cast through float8_e8m0fnu to extract the
# raw byte without losing the exponent.
w13_scale_u8 = (
layer.w13_weight_scale_inv.data.to(torch.float8_e8m0fnu)
.view(torch.uint8)
.contiguous()
)
w2_scale_u8 = (
layer.w2_weight_scale_inv.data.to(torch.float8_e8m0fnu)
.view(torch.uint8)
.contiguous()
)
# C++ byte interleave on packed 4-bit weights.
w13_il = interleave_moe_weights_for_sm90_mixed_gemm(
layer.w13_weight.data.view(torch.uint8).contiguous(), "fp4"
)
w2_il = interleave_moe_weights_for_sm90_mixed_gemm(
layer.w2_weight.data.view(torch.uint8).contiguous(), "fp4"
)
# Pure-PyTorch reshape+permute on E8M0 block scales.
w13_s_il = interleave_moe_scales_for_sm90_mixed_gemm(
w13_scale_u8, group_size=_GROUP_SIZE
)
w2_s_il = interleave_moe_scales_for_sm90_mixed_gemm(
w2_scale_u8, group_size=_GROUP_SIZE
)
layer.w13_weight = Parameter(w13_il, requires_grad=False)
layer.w2_weight = Parameter(w2_il, requires_grad=False)
layer.w13_weight_scale_inv = Parameter(w13_s_il, requires_grad=False)
layer.w2_weight_scale_inv = Parameter(w2_s_il, requires_grad=False)
layer._dsv4_mxfp4_backend = "flashinfer_cutlass_sm90"
torch.cuda.empty_cache()
# --- Forward -----------------------------------------------------------
def apply(
self,
layer: Module,
dispatch_output: "DispatchOutput",
) -> "CombineInput":
topk_output = dispatch_output.topk_output
if not TopKOutputChecker.format_is_standard(topk_output):
raise ValueError(f"Unsupported topk output format: {topk_output.format}")
x = dispatch_output.hidden_states
topk_weights = topk_output.topk_weights
topk_ids = topk_output.topk_ids
output_dtype = torch.bfloat16
with use_symmetric_memory(
get_tp_group(), disabled=not is_allocation_symmetric()
):
out = torch.empty(
x.shape[0], x.shape[-1], dtype=output_dtype, device=x.device
)
flashinfer_cutlass_fused_moe(
input=x,
token_selected_experts=topk_ids.to(torch.int),
token_final_scales=topk_weights,
fc1_expert_weights=layer.w13_weight,
fc2_expert_weights=layer.w2_weight,
output_dtype=output_dtype,
quant_scales=[
layer.w13_weight_scale_inv.view(torch.int32),
layer.w2_weight_scale_inv.view(torch.int32),
],
fc1_expert_biases=None, # DSv4 has no MoE expert bias.
fc2_expert_biases=None,
swiglu_alpha=self._swiglu_alpha_tensor, # ones: standard SiLU gate
swiglu_beta=self._swiglu_beta_tensor, # zeros: standard up
swiglu_limit=self._swiglu_limit_tensor,
tp_size=layer.moe_tp_size,
tp_rank=layer.moe_tp_rank,
ep_size=layer.moe_ep_size,
ep_rank=layer.moe_ep_rank,
use_w4_group_scaling=True,
activation_type=ActivationType.Swiglu,
tune_max_num_tokens=next_power_of_2(x.shape[0]),
output=out,
)
return StandardCombineInput(hidden_states=out)
@@ -445,12 +445,20 @@ def maybe_fuse_routed_scale_and_shared_add(
# alpha=scale)`. With no shared output, the missing scale is applied
# in-place. Otherwise `routed` is already scale-final and we just add
# `shared` (or pass through if there is none).
from sglang.srt.layers.quantization.mxfp4_flashinfer_cutlass_moe import (
Mxfp4FlashinferCutlassMoEMethod,
)
from sglang.srt.layers.quantization.mxfp4_marlin_moe import (
Mxfp4MarlinMoEMethod,
)
fused = isinstance(
experts.quant_method, (Mxfp4FlashinferTrtllmMoEMethod, Mxfp4MarlinMoEMethod)
experts.quant_method,
(
Mxfp4FlashinferTrtllmMoEMethod,
Mxfp4FlashinferCutlassMoEMethod,
Mxfp4MarlinMoEMethod,
),
)
if fused:
if shared is not None:
@@ -0,0 +1,366 @@
"""Benchmark MXFP4 MoE kernels on H100/H200: SGLang Marlin vs FlashInfer cutlass.
Compares per-call latency of:
* Marlin path : ``fused_marlin_moe(...)`` after Marlin weight repack
* FlashInfer : ``cutlass_fused_moe(use_w4_group_scaling=True, ...)``
(PR #3084's SM90 mixed-input path)
Both run on the same random MXFP4 weights/scales (semantics differ slightly --
Marlin uses a scalar swiglu clamp + no bias, FlashInfer fuses per-expert
SwiGLU with bias -- so the timing comparison reports kernel cost for
*equivalent compute volume*, not bit-exact numerics).
Run on H100/H200:
cd /sgl-workspace/sglang_dev3 && \\
PYTHONPATH=python:/sgl-workspace/flashinfer FLASHINFER_DISABLE_VERSION_CHECK=1 \\
python python/sglang/test/bench_mxfp4_sm90_kernels.py
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from typing import Callable, List, Tuple
import torch
from flashinfer.autotuner import autotune
# ---- FlashInfer ----
from flashinfer.fused_moe import (
cutlass_fused_moe,
interleave_moe_scales_for_sm90_mixed_gemm,
interleave_moe_weights_for_sm90_mixed_gemm,
)
from flashinfer.fused_moe.core import ActivationType
# ---- SGLang Marlin ----
from sglang.jit_kernel.gptq_marlin_repack import gptq_marlin_repack
from sglang.srt.layers.moe.fused_moe_triton.fused_marlin_moe import fused_marlin_moe
from sglang.srt.layers.quantization.marlin_utils import (
marlin_make_workspace,
marlin_permute_scales,
)
from sglang.srt.layers.quantization.marlin_utils_fp4 import mxfp4_marlin_process_scales
GROUP_SIZE = 32
@dataclass
class Shape:
tokens: int
hidden: int
inter: int
num_experts: int
top_k: int
def label(self) -> str:
return (
f"m={self.tokens:>4} h={self.hidden} i={self.inter} "
f"E={self.num_experts} k={self.top_k}"
)
# Sweep tokens at a fixed GPT-OSS-like body (hidden=4096, inter=2048, E=256,
# topk=6 -- matches PR #3084's headline shape so the small-batch numbers stay
# directly comparable). Token range covers decode (4-256) and prefill chunks
# (1024-8192).
_BODY = dict(hidden=4096, inter=2048, num_experts=256, top_k=6)
DEFAULT_SHAPES: List[Shape] = [
Shape(tokens=4, **_BODY),
Shape(tokens=16, **_BODY),
Shape(tokens=64, **_BODY),
Shape(tokens=256, **_BODY),
Shape(tokens=1024, **_BODY),
Shape(tokens=2048, **_BODY),
Shape(tokens=4096, **_BODY),
Shape(tokens=8192, **_BODY),
]
def _make_random_mxfp4(shape: Shape, seed: int = 0):
g = torch.Generator(device="cuda").manual_seed(seed)
e = shape.num_experts
n = shape.inter
k = shape.hidden
w13 = torch.randint(
0, 256, (e, 2 * n, k // 2), dtype=torch.uint8, device="cuda", generator=g
)
w2 = torch.randint(
0, 256, (e, k, n // 2), dtype=torch.uint8, device="cuda", generator=g
)
# Narrow E8M0 band so dequant magnitudes stay sane.
w13_s = torch.randint(
125,
130,
(e, 2 * n, k // GROUP_SIZE),
dtype=torch.uint8,
device="cuda",
generator=g,
)
w2_s = torch.randint(
125,
130,
(e, k, n // GROUP_SIZE),
dtype=torch.uint8,
device="cuda",
generator=g,
)
w13_b = (
torch.randn(e, 2 * n, dtype=torch.float32, device="cuda", generator=g).to(
torch.bfloat16
)
* 0.01
)
w2_b = (
torch.randn(e, k, dtype=torch.float32, device="cuda", generator=g).to(
torch.bfloat16
)
* 0.01
)
return w13, w2, w13_s, w2_s, w13_b, w2_b
def _make_topk(shape: Shape, seed: int = 1):
g = torch.Generator(device="cuda").manual_seed(seed)
logits = torch.randn(
shape.tokens,
shape.num_experts,
dtype=torch.float32,
device="cuda",
generator=g,
)
weights, ids = torch.topk(torch.softmax(logits, dim=-1), shape.top_k, dim=-1)
weights = weights / weights.sum(dim=-1, keepdim=True)
return logits, weights.to(torch.float32), ids.to(torch.int32)
# ---------------------------------------------------------------------------
# FlashInfer cutlass path
# ---------------------------------------------------------------------------
def build_flashinfer_inputs(shape: Shape, w13, w2, w13_s, w2_s, w13_b, w2_b):
w13_il = interleave_moe_weights_for_sm90_mixed_gemm(w13, "fp4")
w2_il = interleave_moe_weights_for_sm90_mixed_gemm(w2, "fp4")
w13_s_il = interleave_moe_scales_for_sm90_mixed_gemm(w13_s, group_size=GROUP_SIZE)
w2_s_il = interleave_moe_scales_for_sm90_mixed_gemm(w2_s, group_size=GROUP_SIZE)
e = shape.num_experts
swiglu_alpha = torch.full((e,), 1.702, dtype=torch.float32, device="cuda")
swiglu_beta = torch.full((e,), 1.0, dtype=torch.float32, device="cuda")
swiglu_limit = torch.full((e,), 7.0, dtype=torch.float32, device="cuda")
return {
"w13": w13_il,
"w2": w2_il,
"quant_scales": [w13_s_il.view(torch.int32), w2_s_il.view(torch.int32)],
"w13_b": w13_b,
"w2_b": w2_b,
"swiglu_alpha": swiglu_alpha,
"swiglu_beta": swiglu_beta,
"swiglu_limit": swiglu_limit,
}
def make_flashinfer_runner(
shape: Shape, prep, x, topk_w, topk_i, autotuned: bool, with_bias: bool = True
):
out = torch.empty(shape.tokens, shape.hidden, dtype=torch.bfloat16, device="cuda")
fc1_b = prep["w13_b"] if with_bias else None
fc2_b = prep["w2_b"] if with_bias else None
def _call():
cutlass_fused_moe(
input=x,
token_selected_experts=topk_i,
token_final_scales=topk_w,
fc1_expert_weights=prep["w13"],
fc2_expert_weights=prep["w2"],
output_dtype=torch.bfloat16,
quant_scales=prep["quant_scales"],
fc1_expert_biases=fc1_b,
fc2_expert_biases=fc2_b,
swiglu_alpha=prep["swiglu_alpha"],
swiglu_beta=prep["swiglu_beta"],
swiglu_limit=prep["swiglu_limit"],
use_w4_group_scaling=True,
activation_type=ActivationType.Swiglu,
output=out,
)
if autotuned:
# Populate FlashInfer's tactic cache once before timing.
with autotune(True):
_call()
return _call
# ---------------------------------------------------------------------------
# SGLang Marlin path
# ---------------------------------------------------------------------------
def build_marlin_inputs(shape: Shape, w13, w2, w13_s, w2_s):
"""Repack MXFP4 weights into Marlin layout. Mirrors
``prepare_moe_mxfp4_layer_for_marlin`` but does not require a layer object."""
e = shape.num_experts
n = shape.inter
k = shape.hidden
device = w13.device
perm = torch.empty(0, dtype=torch.int, device=device)
def _repack(weight, size_n, size_k):
out_list = []
for i in range(e):
qweight = weight[i].view(torch.int32).T.contiguous()
out_list.append(
gptq_marlin_repack(
b_q_weight=qweight,
perm=perm,
size_k=size_k,
size_n=size_n,
num_bits=4,
)
)
return torch.stack(out_list)
def _scales_for(scales, size_n, size_k):
out_list = []
# Reinterpret uint8 E8M0 byte as float8_e8m0fnu, then to bf16 numerical.
scales_bf16 = scales.view(torch.float8_e8m0fnu).to(torch.bfloat16)
for i in range(e):
s = scales_bf16[i].T.contiguous()
ms = marlin_permute_scales(
s=s, size_k=size_k, size_n=size_n, group_size=GROUP_SIZE
)
out_list.append(mxfp4_marlin_process_scales(ms, input_dtype=torch.bfloat16))
return torch.stack(out_list)
w13_marlin = _repack(w13, size_n=2 * n, size_k=k)
w2_marlin = _repack(w2, size_n=k, size_k=n)
w13_s_marlin = _scales_for(w13_s, size_n=2 * n, size_k=k)
w2_s_marlin = _scales_for(w2_s, size_n=k, size_k=n)
workspace = marlin_make_workspace(device, 4)
return {
"w13": w13_marlin,
"w2": w2_marlin,
"w13_s": w13_s_marlin,
"w2_s": w2_s_marlin,
"workspace": workspace,
}
def make_marlin_runner(shape: Shape, prep, x_bf16, router_logits, topk_w, topk_i):
def _call():
fused_marlin_moe(
hidden_states=x_bf16,
w1=prep["w13"],
w2=prep["w2"],
w1_scale=prep["w13_s"],
w2_scale=prep["w2_s"],
gating_output=router_logits,
topk_weights=topk_w,
topk_ids=topk_i,
workspace=prep["workspace"],
num_bits=4,
is_k_full=True,
inplace=False,
clamp_limit=7.0,
)
return _call
# ---------------------------------------------------------------------------
# Timing harness
# ---------------------------------------------------------------------------
def time_call(fn: Callable, warmup: int = 5, iters: int = 30) -> Tuple[float, float]:
"""Returns (median_ms, min_ms) across ``iters`` calls after ``warmup``."""
for _ in range(warmup):
fn()
torch.cuda.synchronize()
starts = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
ends = [torch.cuda.Event(enable_timing=True) for _ in range(iters)]
for s, e in zip(starts, ends):
s.record()
fn()
e.record()
torch.cuda.synchronize()
times = sorted(s.elapsed_time(e) for s, e in zip(starts, ends))
return times[len(times) // 2], times[0]
def run_one_shape(shape: Shape, run_marlin: bool):
print(f"\n=== {shape.label()} ===")
w13, w2, w13_s, w2_s, w13_b, w2_b = _make_random_mxfp4(shape, seed=0)
router_logits, topk_w, topk_i = _make_topk(shape, seed=1)
x = (
torch.randn(shape.tokens, shape.hidden, dtype=torch.bfloat16, device="cuda")
* 0.1
)
# FlashInfer cutlass (autotune ON, with bias).
fi_prep = build_flashinfer_inputs(shape, w13, w2, w13_s, w2_s, w13_b, w2_b)
fi_at_call = make_flashinfer_runner(
shape, fi_prep, x, topk_w, topk_i, autotuned=True, with_bias=True
)
fi_at_med, fi_at_min = time_call(fi_at_call)
print(
f" FlashInfer cutlass (autotune): median={fi_at_med:.3f} ms "
f"min={fi_at_min:.3f} ms"
)
# FlashInfer cutlass (autotune ON, no bias) -- isolate bias epilogue cost.
fi_at_nb_call = make_flashinfer_runner(
shape, fi_prep, x, topk_w, topk_i, autotuned=True, with_bias=False
)
fi_at_nb_med, fi_at_nb_min = time_call(fi_at_nb_call)
print(
f" FlashInfer cutlass (AT, no-bias): median={fi_at_nb_med:.3f} ms "
f"min={fi_at_nb_min:.3f} ms "
f"(bias overhead = {fi_at_med - fi_at_nb_med:+.3f} ms / "
f"{(fi_at_med / fi_at_nb_med - 1) * 100:+.1f}%)"
)
fi_med = fi_at_med # alias for downstream speedup print
# Marlin
if run_marlin:
try:
ml_prep = build_marlin_inputs(shape, w13, w2, w13_s, w2_s)
ml_call = make_marlin_runner(
shape, ml_prep, x, router_logits, topk_w, topk_i
)
ml_med, ml_min = time_call(ml_call)
print(
f" SGLang Marlin: median={ml_med:.3f} ms "
f"min={ml_min:.3f} ms"
)
print(f" speedup (Marlin / FI autotune): {ml_med / fi_at_med:.2f}x")
print(f" speedup (Marlin / FI AT no-bias): {ml_med / fi_at_nb_med:.2f}x")
except Exception as exc: # pylint: disable=broad-except
print(f" SGLang Marlin: SKIPPED ({type(exc).__name__}: {exc})")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--no-marlin", action="store_true", help="Skip Marlin path.")
args = parser.parse_args()
if not torch.cuda.is_available():
raise SystemExit("CUDA required.")
cap = torch.cuda.get_device_capability()
if cap[0] != 9:
print(f"WARNING: device cap {cap} is not SM90; SM90-specific kernel may fail.")
print(f"Device: {torch.cuda.get_device_name()} (cap {cap[0]}.{cap[1]})")
for shape in DEFAULT_SHAPES:
run_one_shape(shape, run_marlin=not args.no_marlin)
if __name__ == "__main__":
main()