[LoRA] Initial EP support for LoRA (#40867)

Signed-off-by: Jee Jee Li <pandaleefree@gmail.com>
Signed-off-by: Jee Jee Li <jeejeelee@inferact.ai>
This commit is contained in:
Jee Jee Li
2026-05-09 00:31:23 -07:00
committed by GitHub
parent d6563d693c
commit ecd0b60aad
13 changed files with 209 additions and 73 deletions
+1
View File
@@ -129,6 +129,7 @@ def test_gpt_oss_lora_tp2(
tensor_parallel_size=2,
gpu_memory_utilization=0.8,
fully_sharded_loras=fully_sharded_loras,
enable_expert_parallel=not fully_sharded_loras,
compilation_config=vllm.config.CompilationConfig( # Avoid OOM
cudagraph_specialize_lora=False,
),
+8 -6
View File
@@ -5,6 +5,8 @@
# NOTE To avoid overloading the CI pipeline, this test script will not
# be triggered on CI and is primarily intended for local testing and verification.
import pytest
import vllm
from vllm.lora.request import LoRARequest
@@ -82,15 +84,15 @@ def test_qwen3moe_lora(qwen3moe_lora_files):
@multi_gpu_test(num_gpus=2)
def test_qwen3moe_lora_tp2(qwen3moe_lora_files):
@pytest.mark.parametrize("ep", [False, True])
def test_qwen3moe_lora_tp2(ep, qwen3moe_lora_files):
llm = vllm.LLM(
MODEL_PATH,
max_model_len=1024,
enable_lora=True,
max_loras=4,
enforce_eager=True,
trust_remote_code=True,
enable_chunked_prefill=True,
enable_expert_parallel=ep,
tensor_parallel_size=2,
)
@@ -99,15 +101,15 @@ def test_qwen3moe_lora_tp2(qwen3moe_lora_files):
@multi_gpu_test(num_gpus=4)
def test_qwen3moe_lora_tp4(qwen3moe_lora_files):
@pytest.mark.parametrize("ep", [False, True])
def test_qwen3moe_lora_tp4(ep, qwen3moe_lora_files):
llm = vllm.LLM(
MODEL_PATH,
max_model_len=1024,
enable_lora=True,
max_loras=4,
enforce_eager=True,
trust_remote_code=True,
enable_chunked_prefill=True,
enable_expert_parallel=ep,
tensor_parallel_size=4,
)
+39 -15
View File
@@ -7,10 +7,6 @@ from transformers import PretrainedConfig
from vllm import envs
from vllm.config.lora import LoRAConfig
from vllm.distributed.parallel_state import (
get_tensor_model_parallel_rank,
get_tensor_model_parallel_world_size,
)
from vllm.distributed.utils import divide
from vllm.lora.layers.base import BaseLayerWithLoRA
from vllm.model_executor.layers.fused_moe import FusedMoE
@@ -30,15 +26,12 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
def __init__(self, base_layer: FusedMoE) -> None:
super().__init__()
self.base_layer = base_layer
assert not self.base_layer.use_ep, (
"EP support for Fused MoE LoRA is not implemented yet."
)
assert not self.base_layer.quant_method.is_monolithic, (
"Monolithic kernels are not supported for Fused MoE LoRA."
)
self.tp_size = get_tensor_model_parallel_world_size()
self.tp_rank = get_tensor_model_parallel_rank()
self._ep_check()
# Use the MoE-aware TP rank/size: when EP is active, FusedMoE collapses
# moe_parallel_config.tp_size to 1 (experts are sharded across the
# TP group instead).
self.tp_size = self.base_layer.tp_size
self.tp_rank = self.base_layer.tp_rank
self.device = _get_lora_device(base_layer)
# For non-gated MoE (is_act_and_mul=False), only 1 slice is needed
# since there's only up_proj (w1), not gate_proj + up_proj (w1 + w3)
@@ -65,7 +58,7 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
"For quantized MoE, mix LoRAExpertsMixin into the experts class "
"and consume self._lora_context in apply()."
)
self._fused_experts = moe_kernel.fused_experts
self._moe_kernel = moe_kernel
self.base_layer._replace_quant_method(
FusedMoEModularMethod(self.base_layer.quant_method, moe_kernel)
)
@@ -150,6 +143,26 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
),
)
def _ep_check(self):
if self.base_layer.use_ep:
moe_config = self.base_layer.moe_config
all2all_backend = moe_config.moe_parallel_config.all2all_backend
assert all2all_backend == "allgather_reducescatter", (
"Fused MoE LoRA with EP currently only supports "
f"all2all_backend='allgather_reducescatter', got '{all2all_backend}'."
)
assert not moe_config.moe_parallel_config.is_sequence_parallel
def _verify_ep_fs(self, lora_config: LoRAConfig):
# EP and fully_sharded LoRA both partition along the same TP group —
# EP on the expert dim, fully_sharded on the LoRA rank dim — with
# mutually contradictory assumptions about which rank holds which
# expert's rank-shard.
assert not (self.base_layer.use_ep and lora_config.fully_sharded_loras), (
"Fused MoE LoRA does not support enable_expert_parallel=True "
"together with fully_sharded_loras=True. Disable one of them."
)
def create_lora_weights(
self,
max_loras: int,
@@ -157,6 +170,8 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
model_config: PretrainedConfig | None = None,
) -> None:
"""Initializes lora matrices."""
self._verify_ep_fs(lora_config)
self.max_loras = lora_config.max_loras
self.fully_sharded = lora_config.fully_sharded_loras
@@ -282,6 +297,10 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
w1_lora_a, w2_lora_a, w3_lora_a = lora_a
w1_lora_b, w2_lora_b, w3_lora_b = lora_b
# EP slicing is done once at add time in
# LoRAModelManager._slice_moe_lora_ep, so by here the cached
# tensors already match the local-expert dim of the stacked buffers.
assert (
num_experts
== w1_lora_a.shape[0]
@@ -326,7 +345,11 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
def set_mapping(self, punica_wrapper):
super().set_mapping(punica_wrapper)
self._fused_experts.set_lora_context(self._build_lora_context())
lora_context = self._build_lora_context()
self._moe_kernel.fused_experts.set_lora_context(lora_context)
prepare_finalize = self._moe_kernel.prepare_finalize
if hasattr(prepare_finalize, "set_lora_context"):
prepare_finalize.set_lora_context(lora_context)
def forward(self, *args, **kwargs):
return self.base_layer.forward(*args, **kwargs)
@@ -400,6 +423,7 @@ class FusedMoE3DWithLoRA(FusedMoEWithLoRA):
"""Initializes lora matrices."""
assert isinstance(model_config, PretrainedConfig)
self._verify_ep_fs(lora_config)
self._base_model = model_config.architectures[0]
self.max_loras = lora_config.max_loras
self.fully_sharded = lora_config.fully_sharded_loras
+63 -9
View File
@@ -14,6 +14,7 @@ from vllm.logger import init_logger
from vllm.lora.layers import (
BaseLayerWithLoRA,
FusedMoE3DWithLoRA,
FusedMoEWithLoRA,
LoRAMapping,
LoRAMappingType,
)
@@ -562,6 +563,10 @@ class LoRAModelManager:
else:
parts = module_name.split(".")
replacements = self.packed_modules_mapping[parts[-1]]
if module.__class__.__name__ == "FusedMoEWithLoRA":
replacements = replacements[
: len(module.lora_a_stacked) // self.lora_slots
]
subloras: list[LoRALayerWeights | None] = []
for i, r in enumerate(replacements):
lora = LoRALayerWeights.create_dummy_lora_weights(
@@ -716,6 +721,8 @@ class LoRAModelManager:
for module_name, module in self.modules.items():
if isinstance(module, FusedMoE3DWithLoRA):
self._stack_moe_lora_weights(lora_model, module, module_name)
elif isinstance(module, FusedMoEWithLoRA):
self._slice_moe_lora_ep(lora_model, module, module_name)
first_lora: LoRALayerWeights = next(iter(lora_model.loras.values()))
assert first_lora.lora_a is not None
@@ -762,23 +769,33 @@ class LoRAModelManager:
assert gate_up_proj_lora is not None
assert down_proj_lora is not None
if self._is_3d_moe_model:
num_experts = module.w13_lora_a_stacked[0].shape[1]
local_num_experts = module.w13_lora_a_stacked[0].shape[1]
# The checkpoint holds weights for all global experts, but
# each EP rank owns only local_num_experts. Reshape against
# the adapter's actual expert count, then slice this rank's
# owned expert range before it gets copied into the local
# stacked buffer. For non-EP (local == global) this is a
# no-op slice.
global_num_experts = module.base_layer.global_num_experts
ep_rank = module.base_layer.ep_rank
expert_start = ep_rank * local_num_experts
expert_end = expert_start + local_num_experts
# (num_experts,rank,input_size)
gate_up_proj_lora.lora_a = gate_up_proj_lora.lora_a.reshape(
num_experts, -1, gate_up_proj_lora.lora_a.shape[-1]
)
global_num_experts, -1, gate_up_proj_lora.lora_a.shape[-1]
)[expert_start:expert_end].contiguous()
down_proj_lora.lora_a = down_proj_lora.lora_a.reshape(
num_experts, -1, down_proj_lora.lora_a.shape[-1]
)
global_num_experts, -1, down_proj_lora.lora_a.shape[-1]
)[expert_start:expert_end].contiguous()
# (output_size,rank,num_experts)
gate_up_proj_lora.lora_b = gate_up_proj_lora.lora_b.reshape(
gate_up_proj_lora.lora_b.shape[0], -1, num_experts
)
gate_up_proj_lora.lora_b.shape[0], -1, global_num_experts
)[..., expert_start:expert_end]
down_proj_lora.lora_b = down_proj_lora.lora_b.reshape(
down_proj_lora.lora_b.shape[0], -1, num_experts
)
down_proj_lora.lora_b.shape[0], -1, global_num_experts
)[..., expert_start:expert_end]
# (num_experts,output_size,rank)
gate_up_proj_lora.lora_b = gate_up_proj_lora.lora_b.permute(
@@ -828,6 +845,43 @@ class LoRAModelManager:
module_lora.lora_a = lora_a
module_lora.lora_b = lora_b
def _slice_moe_lora_ep(
self,
lora_model: LoRAModel,
module: FusedMoEWithLoRA,
module_name: str,
) -> None:
"""Slice the cached LoRA tensors down to this rank's local experts.
The 2D MoE checkpoint enters as a list of per-(w1/w2/w3) tensors of
shape (num_experts, rank, in) / (num_experts, out, rank). When EP
is active each rank only owns local_num_experts; without this slice
the CPU LoRAModel keeps the full global weight and set_lora has to
re-slice on every activation.
"""
if not module.base_layer.use_ep:
return
module_lora = self._get_lora_layer_weights(lora_model, module_name)
if module_lora is None or not isinstance(module_lora.lora_a, list):
return
local_num_experts = module.base_layer.local_num_experts
global_num_experts = module.base_layer.global_num_experts
ep_rank = module.base_layer.ep_rank
expert_start = ep_rank * local_num_experts
expert_end = expert_start + local_num_experts
new_lora_a: list[torch.Tensor | None] = []
new_lora_b: list[torch.Tensor | None] = []
for a, b in zip(module_lora.lora_a, module_lora.lora_b):
if a is not None and b is not None and a.shape[0] == global_num_experts:
a = a[expert_start:expert_end].contiguous()
b = b[expert_start:expert_end].contiguous()
new_lora_a.append(a)
new_lora_b.append(b)
module_lora.lora_a = new_lora_a
module_lora.lora_b = new_lora_b
def _get_lora_layer_weights(
self, lora_model: LoRAModel, module_name: str
) -> LoRALayerWeights | None:
+5
View File
@@ -514,6 +514,7 @@ class PunicaWrapperBase(PunicaWrapperABC):
num_slices: int,
fully_sharded: bool,
use_tuned_config: bool,
token_lora_mapping: torch.Tensor | None = None,
) -> tuple[
torch.Tensor | None,
torch.Tensor | None,
@@ -522,6 +523,10 @@ class PunicaWrapperBase(PunicaWrapperABC):
]:
"""Apply w13 LoRA to y (intermediate_cache1) in-place before activation.
When `token_lora_mapping` is provided it overrides the punica_wrapper's
global mapping — used by EP+LoRA to pass the per-rank-local mapping
after all-to-all dispatch.
Returns (sorted_token_ids_lora, expert_ids_lora,
num_tokens_post_padded_lora, token_lora_mapping)
for reuse by add_lora_w2.
+40 -12
View File
@@ -335,25 +335,49 @@ class PunicaWrapperGPU(PunicaWrapperBase):
expert_map: torch.Tensor | None = None,
pad_sorted_ids: bool = False,
naive_block_assignment: bool = False,
token_lora_mapping: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Aligns tokens and experts into block-sized chunks for LoRA-based
mixture-of-experts (MoE) execution.
When `token_lora_mapping` is provided, it overrides the global mapping
read from `self.token_mapping_meta`. This is how EP+LoRA injects the
per-rank-local token→LoRA map after all-to-all dispatch.
"""
(token_lora_mapping, _, _, _, lora_ids, _, _) = (
self.token_mapping_meta.meta_args(
num_tokens, self.lora_config.specialize_active_lora
)
(
token_lora_mapping_meta,
_,
_,
_,
lora_ids,
_,
_,
) = self.token_mapping_meta.meta_args(
num_tokens, self.lora_config.specialize_active_lora
)
if token_lora_mapping is None:
token_lora_mapping = token_lora_mapping_meta
# Under EP the caller passes local_num_experts but topk_ids carries
# GLOBAL expert indices. The CUDA kernel uses num_experts to size
# its bucketing table; with EP we must size by global_num_experts
# so global topk_ids don't overflow. expert_map inside the kernel
# then translates global→local so the output expert_ids are local
# (mirrors the non-LoRA moe_align_block_size behavior).
kernel_num_experts = (
expert_map.numel() if expert_map is not None else num_experts
)
if naive_block_assignment:
expert_ids = topk_ids.reshape(-1)
sorted_ids = None
num_tokens_post_pad = None
else:
max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
max_num_tokens_padded = topk_ids.numel() + kernel_num_experts * (
block_size - 1
)
if pad_sorted_ids:
max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
if topk_ids.numel() < num_experts:
if topk_ids.numel() < kernel_num_experts:
max_num_tokens_padded = topk_ids.numel() * block_size
sorted_ids = torch.empty(
(max_loras * max_num_tokens_padded,),
@@ -361,9 +385,12 @@ class PunicaWrapperGPU(PunicaWrapperBase):
device=topk_ids.device,
)
max_num_m_blocks = triton.cdiv(max_num_tokens_padded, block_size)
# Expert ids must be set default to -1 to prevent a blank block
expert_ids = torch.empty(
# Expert ids are initialized to -1 so unused (lora, expert)
# slots don't drive the LoRA Triton kernel into the wrong bucket.
# The kernel overwrites only active slots.
expert_ids = torch.full(
(max_loras * max_num_m_blocks,),
-1,
dtype=torch.int32,
device=topk_ids.device,
)
@@ -374,7 +401,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
ops.moe_lora_align_block_size(
topk_ids,
token_lora_mapping,
num_experts,
kernel_num_experts,
block_size,
max_loras,
max_num_tokens_padded,
@@ -384,11 +411,10 @@ class PunicaWrapperGPU(PunicaWrapperBase):
num_tokens_post_pad,
adapter_enabled,
lora_ids,
expert_map,
)
if expert_map is not None:
expert_ids = expert_map[expert_ids]
return None, sorted_ids, expert_ids, num_tokens_post_pad
return token_lora_mapping, sorted_ids, expert_ids, num_tokens_post_pad
def add_lora_fused_moe(
self,
@@ -480,6 +506,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
num_slices: int,
fully_sharded: bool,
use_tuned_config: bool,
token_lora_mapping: torch.Tensor | None = None,
) -> tuple[
torch.Tensor | None,
torch.Tensor | None,
@@ -558,6 +585,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
adapter_enabled,
expert_map,
naive_block_assignment=naive_block_assignment,
token_lora_mapping=token_lora_mapping,
)
_sorted = sorted_token_ids_lora
+1
View File
@@ -461,6 +461,7 @@ class PunicaWrapperXPU(PunicaWrapperBase):
num_slices: int,
fully_sharded: bool,
use_tuned_config: bool,
token_lora_mapping: torch.Tensor | None = None,
) -> tuple[
torch.Tensor | None,
torch.Tensor | None,
@@ -42,3 +42,10 @@ class MoELoRAContext:
# Whether VLLM_TUNED_CONFIG_FOLDER is set; selects get_lora_op_configs vs
# try_get_optimal_moe_lora_config for Triton kernel tile configs.
use_tuned_config: bool
# Per-rank token→LoRA mapping after EP dispatch. Set by
# FusedMoEPrepareAndFinalizeModular.prepare() when EP+LoRA is active, read
# by LoRAExpertsMixin helpers in place of punica_wrapper's global mapping.
# None means no dispatch happened (non-EP path), in which case callers
# fall back to punica_wrapper.token_mapping_meta.
local_token_lora_mapping: torch.Tensor | None = None
@@ -70,6 +70,7 @@ class LoRAExpertsMixin:
lora_context.w13_num_slices,
lora_context.fully_sharded,
lora_context.use_tuned_config,
token_lora_mapping=lora_context.local_token_lora_mapping,
)
def apply_w2_lora(
@@ -79,9 +79,6 @@ def select_int8_moe_backend(
Note: Shape-specific fallbacks may still occur at runtime.
"""
if config.is_lora_enabled:
return Int8MoeBackend.TRITON, backend_to_kernel_cls(Int8MoeBackend.TRITON)[0]
AVAILABLE_BACKENDS = _get_priority_backends(config)
activation_format = (
@@ -301,28 +301,6 @@ def select_mxfp4_moe_backend(
"""
# If activation_key is explicitly provided (e.g., W4A8), use it
requested_activation_key = activation_key
device_capability = current_platform.get_device_capability()
triton_kernels_supported = (
has_triton_kernels()
and device_capability is not None
and (9, 0) <= device_capability < (11, 0)
)
# LoRA: separate experts backend path
if config.is_lora_enabled:
if not current_platform.is_cuda():
# ROCm: Triton mxfp4 LoRA hits GPU memory faults due to
# triton_kernels.tensor.Tensor / HIP read-only page issues
# during weight swizzle and LoRA forward. Needs work from
# the triton_kernels/aiter side.
raise NotImplementedError("Mxfp4 LoRA is currently only supported on CUDA.")
if envs.VLLM_MXFP4_USE_MARLIN is False and triton_kernels_supported:
logger.info_once("Using Triton backend for mxfp4 lora")
return Mxfp4MoeBackend.TRITON_UNFUSED, backend_to_kernel_cls(
Mxfp4MoeBackend.TRITON_UNFUSED
)[0]
logger.info_once("Using Marlin backend for mxfp4 lora")
return Mxfp4MoeBackend.MARLIN, backend_to_kernel_cls(Mxfp4MoeBackend.MARLIN)[0]
activation_format = (
mk.FusedMoEActivationFormat.BatchedExperts
@@ -61,8 +61,6 @@ def select_mxfp8_moe_backend(
Returns:
A tuple of (fp8_backend, experts_cls).
"""
if config.is_lora_enabled:
raise NotImplementedError("LoRA is not supported for MXFP8 MoE.")
runner_backend = config.moe_backend
if runner_backend != "auto":
@@ -84,6 +84,14 @@ class MoEPrepareAndFinalizeNaiveDPEPModular(mk.FusedMoEPrepareAndFinalizeModular
super().__init__()
self.is_sequence_parallel = is_sequence_parallel
self._num_dispatchers = num_dispatchers
# Set by FusedMoEWithLoRA.set_mapping() when LoRA is active. When
# present, prepare() dispatches the per-token LoRA mapping alongside
# hidden_states and writes the gathered result back to the context so
# experts can use the per-rank-local mapping.
self._lora_context = None
def set_lora_context(self, ctx) -> None:
self._lora_context = ctx
@property
def activation_format(self) -> mk.FusedMoEActivationFormat:
@@ -124,22 +132,54 @@ class MoEPrepareAndFinalizeNaiveDPEPModular(mk.FusedMoEPrepareAndFinalizeModular
a1q, scales = _quantize_and_setup_dispatch(a1, quant_config, defer_input_quant)
# When LoRA is active, dispatch the per-token LoRA id along with
# hidden_states so every rank receives the correct mapping for the
# tokens it ends up processing. The punica_wrapper stores indices as
# int64 but the moe_lora_align_block_size kernel expects int32, so
# pull the pre-cast view from token_mapping_meta.
lora_ctx = self._lora_context
local_token_lora_mapping = None
if lora_ctx is not None:
local_token_lora_mapping = (
lora_ctx.punica_wrapper.token_mapping_meta.token_lora_mapping[
: a1.shape[0]
]
)
extra_tensors: list[torch.Tensor] | None = None
if scales is not None:
extra_tensors = list(scales)
if local_token_lora_mapping is not None:
if extra_tensors is None:
extra_tensors = []
extra_tensors.append(local_token_lora_mapping)
res = get_ep_group().dispatch(
a1q,
topk_weights,
topk_ids,
is_sequence_parallel=self.is_sequence_parallel,
extra_tensors=scales,
extra_tensors=extra_tensors,
)
if scales is None:
if extra_tensors is None:
assert len(res) == 3
a1q, topk_weights, topk_ids = res
a1q_scale = None
else:
assert len(res) == 4
a1q, topk_weights, topk_ids, scales = res
a1q_scale = _unwrap_scale_and_prepare_for_moe(scales, quant_config)
a1q, topk_weights, topk_ids, gathered_extras = res
gathered_extras = list(gathered_extras)
if local_token_lora_mapping is not None:
dispatched_lora_mapping = gathered_extras.pop()
assert lora_ctx is not None
lora_ctx.local_token_lora_mapping = dispatched_lora_mapping
if scales is not None:
a1q_scale = _unwrap_scale_and_prepare_for_moe(
gathered_extras, quant_config
)
else:
a1q_scale = None
return a1q, a1q_scale, None, topk_ids, topk_weights