[Feature][LoRA][MoE] Expert LoRA for FlashInfer TRTLLM MXFP4 experts (#61979)

Signed-off-by: aoshen524 <aoshen524@gmail.com>
Co-authored-by: aoshen524 <aoshen524@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aoshen02
2026-10-17 20:17:56 +08:00
committed by GitHub
co-authored by aoshen524 Claude Opus 5.5
parent 4e964b2623
commit 837e87e1c5
5 changed files with 445 additions and 10 deletions
@@ -1,8 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""LoRA-aware FlashInfer TRT-LLM MoE experts (BF16).
"""LoRA-aware FlashInfer TRT-LLM MoE experts (BF16, MXFP4 x BF16).
Reuses the routed API + ``gemm1_lora_delta`` path from FlashInfer PR #3153:
Reuses the routed API + ``gemm1_lora_delta`` path from FlashInfer PR #3153
(BF16) and PR #3987 (FP4 block-scale):
* The W13 (gate_up) LoRA delta is passed directly as ``gemm1_lora_delta`` to
the routed kernel, which fuses it into FC1 before SwiGLU (BiasType::Mn).
@@ -13,11 +14,12 @@ Reuses the routed API + ``gemm1_lora_delta`` path from FlashInfer PR #3153:
Constraints (matching the PR support matrix; final gating lives in the oracle):
* SM100+ (Blackwell), gated SwiGLU, shuffled weights only;
* BF16 only;
* BF16 weights, or MXFP4 weights with BF16 activations;
* routing must be computed outside the MoE (the Modular path satisfies this).
"""
import functools
from abc import abstractmethod
import torch
@@ -37,9 +39,17 @@ from vllm.model_executor.layers.fused_moe.experts.lora_experts_mixin import (
from vllm.model_executor.layers.fused_moe.experts.trtllm_bf16_moe import (
view_as_block_major_k,
)
from vllm.model_executor.layers.fused_moe.experts.trtllm_mxfp4_moe import (
TrtLlmMxfp4ExpertsBase,
)
from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
TopKWeightAndReduceNoOP,
)
from vllm.model_executor.layers.fused_moe.utils import fi_moe_largest_bucket
from vllm.model_executor.layers.quantization.utils.quant_utils import (
QuantKey,
kMxfp4Static,
)
from vllm.platforms import current_platform
from vllm.triton_utils import tl, triton
from vllm.utils.flashinfer import has_flashinfer_trtllm_fused_moe
@@ -224,6 +234,15 @@ class _TrtLlmLoRAExpertsBase(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
"""
raise NotImplementedError
def prepare_gemm1_lora_delta(
self,
gemm1_lora_delta: torch.Tensor,
lora_context: MoELoRAContext,
topk_ids: torch.Tensor,
) -> torch.Tensor:
"""Hook to rewrite the W13 delta before it is handed to the kernel."""
return gemm1_lora_delta
def apply(
self,
output: torch.Tensor,
@@ -247,7 +266,8 @@ class _TrtLlmLoRAExpertsBase(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
num_tokens = hidden_states.size(0)
top_k = self.topk
intermediate_size = self.intermediate_size_per_partition
K = output.size(1)
# LoRA buffers use the padded hidden size; `output` may be narrower.
K = self.hidden_dim
# ---- Base-model fast path ----
# When no token in the batch selects a LoRA adapter, skip the LoRA machinery
@@ -288,8 +308,7 @@ class _TrtLlmLoRAExpertsBase(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
w13_meta = (None, None, None, None)
# zeros (not empty): under EP the punica expand kernel only writes
# slots whose expert is local to this rank; non-local (token, top_k)
# slots must stay 0 so they contribute no bias when fed to flashinfer.
# slots whose expert is local to this rank; the kernel skips the rest.
gemm1_lora_delta = torch.zeros(
num_tokens,
top_k,
@@ -326,6 +345,9 @@ class _TrtLlmLoRAExpertsBase(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
add_inputs=False,
swap_w13_slices=True,
)
gemm1_lora_delta = self.prepare_gemm1_lora_delta(
gemm1_lora_delta, lora_context, topk_ids
)
# ---- 2) Call the routed flashinfer kernel ----
ret = self.invoke_routed_moe(
@@ -466,7 +488,9 @@ class _TrtLlmLoRAExpertsBase(LoRAExpertsMixin, mk.FusedMoEExpertsModular):
(``expert_weights`` in expanded order, ``idx_map < 0`` dropped), scale by
``scale``, and add the already-weighted ``w2_delta`` reduced over top_k.
"""
K = gemm2_permuted.size(1)
# Inputs may carry hidden padding that `output` does not.
K = output.size(1)
assert gemm2_permuted.size(1) >= K and w2_delta.size(2) >= K
BLOCK_K = 512
grid = (num_tokens, triton.cdiv(K, BLOCK_K))
_finalize_lora_kernel[grid](
@@ -550,3 +574,158 @@ class TrtLlmBf16LoRAExperts(_TrtLlmLoRAExpertsBase):
return list(ret)
# do_finalize=True finalized directly into `output`.
return [output]
# MXFP4 weights x BF16 activations trtllm MoE + LoRA
class TrtLlmMxfp4LoRAExperts(_TrtLlmLoRAExpertsBase, TrtLlmMxfp4ExpertsBase):
"""MXFP4-weight, BF16-activation trtllm MoE + LoRA. Not MXFP8 activations:
the kernel then returns the FC1 activation quantized, without its scales."""
def __init__(
self,
moe_config: FusedMoEConfig,
quant_config: FusedMoEQuantConfig,
):
_TrtLlmLoRAExpertsBase.__init__(self, moe_config, quant_config)
# gemm1_alpha / gemm1_beta / gemm1_clamp_limit, hidden_dim_unpadded.
TrtLlmMxfp4ExpertsBase.__init__(self, moe_config, quant_config)
@staticmethod
def _supports_quant_scheme(
weight_key: QuantKey | None,
activation_key: QuantKey | None,
) -> bool:
return weight_key == kMxfp4Static and activation_key is None
@staticmethod
def _supports_activation(activation: MoEActivation) -> bool:
return activation in (MoEActivation.SWIGLUOAI, MoEActivation.SILU)
@staticmethod
def _supports_routing_method(routing_method, weight_key, activation_key) -> bool:
return True # routing is done outside the kernel
@staticmethod
def _supports_parallel_config(
moe_parallel_config: FusedMoEParallelConfig,
) -> bool:
# DP gathers tokens, but the no-LoRA fast path reads the local batch.
p = moe_parallel_config
return p.dp_size == 1 and not p.use_all2all_kernels and not p.enable_eplb
def workspace_shapes(
self,
M,
N,
K,
topk,
global_num_experts,
local_num_experts,
expert_tokens_meta,
activation,
):
# flashinfer manages its own workspace and drops the hidden padding.
return (0,), (0,), (M, self.hidden_dim_unpadded)
def output_hidden_dim(self, hidden_dim: int) -> int:
# FusedMoEExpertsModular precedes TrtLlmMxfp4ExpertsBase in the MRO.
return self.hidden_dim_unpadded
@functools.cached_property
def _w13_bias_up_gate_index(self) -> torch.Tensor:
"""Columns of the kernel-format FC1 bias in [up | gate] order."""
from flashinfer.fused_moe.core import get_w2_permute_indices_with_cache
# Kernel format is the (up_i, gate_i) bias permuted for the tile.
assert self.w1_bias is not None
bias = self.w1_bias.reshape(self.local_num_experts, -1)[0]
permute = get_w2_permute_indices_with_cache({}, bias.reshape(-1, 1), 128)
interleaved = torch.argsort(permute.to(bias.device))
return torch.cat([interleaved[0::2], interleaved[1::2]])
def prepare_gemm1_lora_delta(
self,
gemm1_lora_delta: torch.Tensor,
lora_context: MoELoRAContext,
topk_ids: torch.Tensor,
) -> torch.Tensor:
"""Bring the W13 delta to [up | gate] halves and fold in the FC1 bias."""
intermediate_size = gemm1_lora_delta.size(-1) // 2
# View as (I, 2) pairs of the first and second w13 half. SWIGLUOAI
# weights, and the deltas that follow them, are interleaved.
if self.moe_config.activation == MoEActivation.SWIGLUOAI:
pairs = gemm1_lora_delta.unflatten(-1, (intermediate_size, 2))
else:
pairs = gemm1_lora_delta.unflatten(-1, (2, intermediate_size)).mT
# Per-expert deltas arrive as (up, gate); fused 3D ones follow the
# checkpoint (gate, up) order.
if lora_context.w13_num_slices != 2:
pairs = pairs.flip(-1)
delta = pairs.mT.flatten(-2) # [up | gate]
# The kernel has a single FC1 bias slot, shared by the static bias and
# the per-token delta, so the static bias rides along in the delta.
if self.w1_bias is not None:
bias = self.w1_bias.reshape(self.local_num_experts, -1)
bias = bias[:, self._w13_bias_up_gate_index].float()
local_ids = topk_ids.long() - self.ep_rank * self.local_num_experts
local_ids = local_ids.clamp_(0, self.local_num_experts - 1)
delta = (delta.float() + bias[local_ids]).to(torch.bfloat16)
return delta.contiguous()
def invoke_routed_moe(
self,
*,
hidden_states: torch.Tensor,
w1: torch.Tensor,
w2: torch.Tensor,
topk_ids_and_weights: tuple[torch.Tensor, torch.Tensor],
gemm1_lora_delta: torch.Tensor | None,
global_num_experts: int,
a1q_scale: torch.Tensor | None,
output: torch.Tensor,
) -> list[torch.Tensor]:
from flashinfer import trtllm_fp4_block_scale_routed_moe
assert a1q_scale is None and hidden_states.dtype == torch.bfloat16
topk_ids, topk_weights = topk_ids_and_weights
# See TrtLlmBf16LoRAExperts.invoke_routed_moe for the return contract.
do_finalize = gemm1_lora_delta is None
ret = trtllm_fp4_block_scale_routed_moe(
topk_ids=(topk_ids.to(torch.int32), topk_weights),
routing_bias=None,
hidden_states=hidden_states,
hidden_states_scale=None,
gemm1_weights=w1,
gemm1_weights_scale=self.w1_scale,
# With a delta the bias is folded into it (prepare_gemm1_lora_delta).
gemm1_bias=self.w1_bias if do_finalize else None,
gemm1_alpha=self.gemm1_alpha,
gemm1_beta=self.gemm1_beta,
gemm1_clamp_limit=self.gemm1_clamp_limit,
gemm2_weights=w2,
gemm2_weights_scale=self.w2_scale,
gemm2_bias=self.w2_bias,
output1_scale_scalar=None,
output1_scale_gate_scalar=None,
output2_scale_scalar=None,
num_experts=global_num_experts,
top_k=self.topk,
n_group=None,
topk_group=None,
intermediate_size=self.intermediate_size_per_partition,
local_expert_offset=self.ep_rank * self.local_num_experts,
local_num_experts=self.local_num_experts,
routed_scaling_factor=None,
# Routing is already done; Renormalize is a safe default.
routing_method_type=RoutingMethodType.Renormalize,
do_finalize=do_finalize,
enable_pdl=True,
activation_type=self._flashinfer_activation_type(
self.moe_config.activation
),
output=output if do_finalize else None,
tune_max_num_tokens=fi_moe_largest_bucket(self.moe_config),
gemm1_lora_delta=gemm1_lora_delta,
)
return [output] if do_finalize else list(ret)
@@ -122,6 +122,9 @@ class TrtLlmMxfp4ExpertsBase:
p = current_platform
return p.is_cuda() and p.is_device_capability_family(100) and has_flashinfer()
def output_hidden_dim(self, hidden_dim: int) -> int:
return self.hidden_dim_unpadded
@staticmethod
def _supports_no_act_and_mul() -> bool:
return False
@@ -843,6 +843,11 @@ class FusedMoEExpertsModular(FusedMoEExperts):
"""Workspace type: The dtype to use for the workspace tensors."""
return act_dtype
def output_hidden_dim(self, hidden_dim: int) -> int:
"""Hidden size of the finalized output. Experts that drop the hidden
padding return the unpadded size."""
return hidden_dim
@abstractmethod
def workspace_shapes(
self,
@@ -1321,7 +1326,8 @@ class FusedMoEKernelModularImpl:
# low-latency kernels are always batched and can never run into
# the tensor.numel() == 0 case.
if M_full == 0:
return torch.empty_like(a1q, dtype=in_dtype)
out_dim = self.fused_experts.output_hidden_dim(a1q.size(-1))
return a1q.new_empty((*a1q.shape[:-1], out_dim), dtype=in_dtype)
workspace13, workspace2, fused_out, fused_out_is_specialized = (
self._allocate_buffers(
@@ -1505,7 +1511,12 @@ class FusedMoEKernelModularImpl:
a fused consumer.
"""
output = torch.empty_like(hidden_states)
output = hidden_states.new_empty(
(
*hidden_states.shape[:-1],
self.fused_experts.output_hidden_dim(hidden_states.size(-1)),
)
)
local_num_experts = w1.shape[0]
if global_num_experts == -1:
@@ -535,6 +535,30 @@ def _make_log_unsupported(backend: Mxfp4MoeBackend, reason: str | None) -> str:
return f"{base} since {reason}." if reason else f"{base}."
def _requested_kernel_cls(
backend: Mxfp4MoeBackend,
config: FusedMoEConfig,
) -> list[type[mk.FusedMoEExperts]]:
"""Kernel classes for an explicitly requested backend. The LoRA-aware
TRT-LLM experts are opt-in: "auto" keeps its current LoRA selection."""
candidates = backend_to_kernel_cls(backend)
if not config.is_lora_enabled or backend not in TRTLLM_BACKENDS:
return candidates
if backend == Mxfp4MoeBackend.FLASHINFER_TRTLLM_MXFP4_MXFP8:
raise ValueError(
_make_log_unsupported(
backend,
"expert LoRA is not supported with MXFP8 activations; use "
"moe_backend='flashinfer_trtllm'",
)
)
from vllm.model_executor.layers.fused_moe.experts.trtllm_lora_moe import (
TrtLlmMxfp4LoRAExperts,
)
return [TrtLlmMxfp4LoRAExperts, *candidates]
def _return_or_raise(
backend: Mxfp4MoeBackend,
config: FusedMoEConfig,
@@ -544,7 +568,7 @@ def _return_or_raise(
scope: Literal["process", "global", "local"] = "local",
) -> tuple[Mxfp4MoeBackend, type[mk.FusedMoEExperts]]:
reason: str | None = None
for k_cls in backend_to_kernel_cls(backend):
for k_cls in _requested_kernel_cls(backend, config):
supported, reason = k_cls.is_supported_config(
k_cls, config, weight_key, activation_key, activation_format
)