A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 4, 01:15 UTC
[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>
Jee Jee Li committed
May 9, 2026 at 15:31 UTC
ecd0b60aad2f4e28dd00ababfc1402690d88cbed
13 files changed
+209
-73
tests/lora/test_gptoss_tp.py
+1
@@ -129,6 +129,7 @@ def test_gpt_oss_lora_tp2(
129
tensor_parallel_size=2,
130
gpu_memory_utilization=0.8,
131
fully_sharded_loras=fully_sharded_loras,
132
+ enable_expert_parallel=not fully_sharded_loras,
133
compilation_config=vllm.config.CompilationConfig( # Avoid OOM
134
cudagraph_specialize_lora=False,
135
),
tests/lora/test_qwen3moe_tp.py
+8
-6
@@ -5,6 +5,8 @@
5
# NOTE To avoid overloading the CI pipeline, this test script will not
6
# be triggered on CI and is primarily intended for local testing and verification.
7
8
+import pytest
9
+
10
import vllm
11
from vllm.lora.request import LoRARequest
12
@@ -82,15 +84,15 @@ def test_qwen3moe_lora(qwen3moe_lora_files):
84
85
86
@multi_gpu_test(num_gpus=2)
85
-def test_qwen3moe_lora_tp2(qwen3moe_lora_files):
87
+@pytest.mark.parametrize("ep", [False, True])
88
+def test_qwen3moe_lora_tp2(ep, qwen3moe_lora_files):
89
llm = vllm.LLM(
90
MODEL_PATH,
91
max_model_len=1024,
92
enable_lora=True,
93
max_loras=4,
91
- enforce_eager=True,
94
trust_remote_code=True,
93
- enable_chunked_prefill=True,
95
+ enable_expert_parallel=ep,
96
tensor_parallel_size=2,
97
)
98
@@ -99,15 +101,15 @@ def test_qwen3moe_lora_tp2(qwen3moe_lora_files):
101
102
103
@multi_gpu_test(num_gpus=4)
102
-def test_qwen3moe_lora_tp4(qwen3moe_lora_files):
104
+@pytest.mark.parametrize("ep", [False, True])
105
+def test_qwen3moe_lora_tp4(ep, qwen3moe_lora_files):
106
llm = vllm.LLM(
107
MODEL_PATH,
108
max_model_len=1024,
109
enable_lora=True,
110
max_loras=4,
108
- enforce_eager=True,
111
trust_remote_code=True,
110
- enable_chunked_prefill=True,
112
+ enable_expert_parallel=ep,
113
tensor_parallel_size=4,
114
)
115
vllm/lora/layers/fused_moe.py
+39
-15
@@ -7,10 +7,6 @@ from transformers import PretrainedConfig
7
8
from vllm import envs
9
from vllm.config.lora import LoRAConfig
10
-from vllm.distributed.parallel_state import (
11
- get_tensor_model_parallel_rank,
12
- get_tensor_model_parallel_world_size,
13
-)
10
from vllm.distributed.utils import divide
11
from vllm.lora.layers.base import BaseLayerWithLoRA
12
from vllm.model_executor.layers.fused_moe import FusedMoE
@@ -30,15 +26,12 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
26
def __init__(self, base_layer: FusedMoE) -> None:
27
super().__init__()
28
self.base_layer = base_layer
33
-
34
- assert not self.base_layer.use_ep, (
35
- "EP support for Fused MoE LoRA is not implemented yet."
36
- )
37
- assert not self.base_layer.quant_method.is_monolithic, (
38
- "Monolithic kernels are not supported for Fused MoE LoRA."
39
- )
40
- self.tp_size = get_tensor_model_parallel_world_size()
41
- self.tp_rank = get_tensor_model_parallel_rank()
29
+ self._ep_check()
30
+ # Use the MoE-aware TP rank/size: when EP is active, FusedMoE collapses
31
+ # moe_parallel_config.tp_size to 1 (experts are sharded across the
32
+ # TP group instead).
33
+ self.tp_size = self.base_layer.tp_size
34
+ self.tp_rank = self.base_layer.tp_rank
35
self.device = _get_lora_device(base_layer)
36
# For non-gated MoE (is_act_and_mul=False), only 1 slice is needed
37
# since there's only up_proj (w1), not gate_proj + up_proj (w1 + w3)
@@ -65,7 +58,7 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
58
"For quantized MoE, mix LoRAExpertsMixin into the experts class "
59
"and consume self._lora_context in apply()."
60
)
68
- self._fused_experts = moe_kernel.fused_experts
61
+ self._moe_kernel = moe_kernel
62
self.base_layer._replace_quant_method(
63
FusedMoEModularMethod(self.base_layer.quant_method, moe_kernel)
64
)
@@ -150,6 +143,26 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
143
),
144
)
145
146
+ def _ep_check(self):
147
+ if self.base_layer.use_ep:
148
+ moe_config = self.base_layer.moe_config
149
+ all2all_backend = moe_config.moe_parallel_config.all2all_backend
150
+ assert all2all_backend == "allgather_reducescatter", (
151
+ "Fused MoE LoRA with EP currently only supports "
152
+ f"all2all_backend='allgather_reducescatter', got '{all2all_backend}'."
153
+ )
154
+ assert not moe_config.moe_parallel_config.is_sequence_parallel
155
+
156
+ def _verify_ep_fs(self, lora_config: LoRAConfig):
157
+ # EP and fully_sharded LoRA both partition along the same TP group —
158
+ # EP on the expert dim, fully_sharded on the LoRA rank dim — with
159
+ # mutually contradictory assumptions about which rank holds which
160
+ # expert's rank-shard.
161
+ assert not (self.base_layer.use_ep and lora_config.fully_sharded_loras), (
162
+ "Fused MoE LoRA does not support enable_expert_parallel=True "
163
+ "together with fully_sharded_loras=True. Disable one of them."
164
+ )
165
+
166
def create_lora_weights(
167
self,
168
max_loras: int,
@@ -157,6 +170,8 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
170
model_config: PretrainedConfig | None = None,
171
) -> None:
172
"""Initializes lora matrices."""
173
+
174
+ self._verify_ep_fs(lora_config)
175
self.max_loras = lora_config.max_loras
176
self.fully_sharded = lora_config.fully_sharded_loras
177
@@ -282,6 +297,10 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
297
298
w1_lora_a, w2_lora_a, w3_lora_a = lora_a
299
w1_lora_b, w2_lora_b, w3_lora_b = lora_b
300
+
301
+ # EP slicing is done once at add time in
302
+ # LoRAModelManager._slice_moe_lora_ep, so by here the cached
303
+ # tensors already match the local-expert dim of the stacked buffers.
304
assert (
305
num_experts
306
== w1_lora_a.shape[0]
@@ -326,7 +345,11 @@ class FusedMoEWithLoRA(BaseLayerWithLoRA):
345
346
def set_mapping(self, punica_wrapper):
347
super().set_mapping(punica_wrapper)
329
- self._fused_experts.set_lora_context(self._build_lora_context())
348
+ lora_context = self._build_lora_context()
349
+ self._moe_kernel.fused_experts.set_lora_context(lora_context)
350
+ prepare_finalize = self._moe_kernel.prepare_finalize
351
+ if hasattr(prepare_finalize, "set_lora_context"):
352
+ prepare_finalize.set_lora_context(lora_context)
353
354
def forward(self, *args, **kwargs):
355
return self.base_layer.forward(*args, **kwargs)
@@ -400,6 +423,7 @@ class FusedMoE3DWithLoRA(FusedMoEWithLoRA):
423
"""Initializes lora matrices."""
424
425
assert isinstance(model_config, PretrainedConfig)
426
+ self._verify_ep_fs(lora_config)
427
self._base_model = model_config.architectures[0]
428
self.max_loras = lora_config.max_loras
429
self.fully_sharded = lora_config.fully_sharded_loras
vllm/lora/model_manager.py
+63
-9
@@ -14,6 +14,7 @@ from vllm.logger import init_logger
14
from vllm.lora.layers import (
15
BaseLayerWithLoRA,
16
FusedMoE3DWithLoRA,
17
+ FusedMoEWithLoRA,
18
LoRAMapping,
19
LoRAMappingType,
20
)
@@ -562,6 +563,10 @@ class LoRAModelManager:
563
else:
564
parts = module_name.split(".")
565
replacements = self.packed_modules_mapping[parts[-1]]
566
+ if module.__class__.__name__ == "FusedMoEWithLoRA":
567
+ replacements = replacements[
568
+ : len(module.lora_a_stacked) // self.lora_slots
569
+ ]
570
subloras: list[LoRALayerWeights | None] = []
571
for i, r in enumerate(replacements):
572
lora = LoRALayerWeights.create_dummy_lora_weights(
@@ -716,6 +721,8 @@ class LoRAModelManager:
721
for module_name, module in self.modules.items():
722
if isinstance(module, FusedMoE3DWithLoRA):
723
self._stack_moe_lora_weights(lora_model, module, module_name)
724
+ elif isinstance(module, FusedMoEWithLoRA):
725
+ self._slice_moe_lora_ep(lora_model, module, module_name)
726
727
first_lora: LoRALayerWeights = next(iter(lora_model.loras.values()))
728
assert first_lora.lora_a is not None
@@ -762,23 +769,33 @@ class LoRAModelManager:
769
assert gate_up_proj_lora is not None
770
assert down_proj_lora is not None
771
if self._is_3d_moe_model:
765
- num_experts = module.w13_lora_a_stacked[0].shape[1]
772
+ local_num_experts = module.w13_lora_a_stacked[0].shape[1]
773
+ # The checkpoint holds weights for all global experts, but
774
+ # each EP rank owns only local_num_experts. Reshape against
775
+ # the adapter's actual expert count, then slice this rank's
776
+ # owned expert range before it gets copied into the local
777
+ # stacked buffer. For non-EP (local == global) this is a
778
+ # no-op slice.
779
+ global_num_experts = module.base_layer.global_num_experts
780
+ ep_rank = module.base_layer.ep_rank
781
+ expert_start = ep_rank * local_num_experts
782
+ expert_end = expert_start + local_num_experts
783
784
# (num_experts,rank,input_size)
785
gate_up_proj_lora.lora_a = gate_up_proj_lora.lora_a.reshape(
769
- num_experts, -1, gate_up_proj_lora.lora_a.shape[-1]
770
- )
786
+ global_num_experts, -1, gate_up_proj_lora.lora_a.shape[-1]
787
+ )[expert_start:expert_end].contiguous()
788
down_proj_lora.lora_a = down_proj_lora.lora_a.reshape(
772
- num_experts, -1, down_proj_lora.lora_a.shape[-1]
773
- )
789
+ global_num_experts, -1, down_proj_lora.lora_a.shape[-1]
790
+ )[expert_start:expert_end].contiguous()
791
792
# (output_size,rank,num_experts)
793
gate_up_proj_lora.lora_b = gate_up_proj_lora.lora_b.reshape(
777
- gate_up_proj_lora.lora_b.shape[0], -1, num_experts
778
- )
794
+ gate_up_proj_lora.lora_b.shape[0], -1, global_num_experts
795
+ )[..., expert_start:expert_end]
796
down_proj_lora.lora_b = down_proj_lora.lora_b.reshape(
780
- down_proj_lora.lora_b.shape[0], -1, num_experts
781
- )
797
+ down_proj_lora.lora_b.shape[0], -1, global_num_experts
798
+ )[..., expert_start:expert_end]
799
800
# (num_experts,output_size,rank)
801
gate_up_proj_lora.lora_b = gate_up_proj_lora.lora_b.permute(
@@ -828,6 +845,43 @@ class LoRAModelManager:
845
module_lora.lora_a = lora_a
846
module_lora.lora_b = lora_b
847
848
+ def _slice_moe_lora_ep(
849
+ self,
850
+ lora_model: LoRAModel,
851
+ module: FusedMoEWithLoRA,
852
+ module_name: str,
853
+ ) -> None:
854
+ """Slice the cached LoRA tensors down to this rank's local experts.
855
+
856
+ The 2D MoE checkpoint enters as a list of per-(w1/w2/w3) tensors of
857
+ shape (num_experts, rank, in) / (num_experts, out, rank). When EP
858
+ is active each rank only owns local_num_experts; without this slice
859
+ the CPU LoRAModel keeps the full global weight and set_lora has to
860
+ re-slice on every activation.
861
+ """
862
+ if not module.base_layer.use_ep:
863
+ return
864
+ module_lora = self._get_lora_layer_weights(lora_model, module_name)
865
+ if module_lora is None or not isinstance(module_lora.lora_a, list):
866
+ return
867
+
868
+ local_num_experts = module.base_layer.local_num_experts
869
+ global_num_experts = module.base_layer.global_num_experts
870
+ ep_rank = module.base_layer.ep_rank
871
+ expert_start = ep_rank * local_num_experts
872
+ expert_end = expert_start + local_num_experts
873
+
874
+ new_lora_a: list[torch.Tensor | None] = []
875
+ new_lora_b: list[torch.Tensor | None] = []
876
+ for a, b in zip(module_lora.lora_a, module_lora.lora_b):
877
+ if a is not None and b is not None and a.shape[0] == global_num_experts:
878
+ a = a[expert_start:expert_end].contiguous()
879
+ b = b[expert_start:expert_end].contiguous()
880
+ new_lora_a.append(a)
881
+ new_lora_b.append(b)
882
+ module_lora.lora_a = new_lora_a
883
+ module_lora.lora_b = new_lora_b
884
+
885
def _get_lora_layer_weights(
886
self, lora_model: LoRAModel, module_name: str
887
) -> LoRALayerWeights | None:
vllm/lora/punica_wrapper/punica_base.py
+5
@@ -514,6 +514,7 @@ class PunicaWrapperBase(PunicaWrapperABC):
514
num_slices: int,
515
fully_sharded: bool,
516
use_tuned_config: bool,
517
+ token_lora_mapping: torch.Tensor | None = None,
518
) -> tuple[
519
torch.Tensor | None,
520
torch.Tensor | None,
@@ -522,6 +523,10 @@ class PunicaWrapperBase(PunicaWrapperABC):
523
]:
524
"""Apply w13 LoRA to y (intermediate_cache1) in-place before activation.
525
526
+ When `token_lora_mapping` is provided it overrides the punica_wrapper's
527
+ global mapping — used by EP+LoRA to pass the per-rank-local mapping
528
+ after all-to-all dispatch.
529
+
530
Returns (sorted_token_ids_lora, expert_ids_lora,
531
num_tokens_post_padded_lora, token_lora_mapping)
532
for reuse by add_lora_w2.
vllm/lora/punica_wrapper/punica_gpu.py
+40
-12
@@ -335,25 +335,49 @@ class PunicaWrapperGPU(PunicaWrapperBase):
335
expert_map: torch.Tensor | None = None,
336
pad_sorted_ids: bool = False,
337
naive_block_assignment: bool = False,
338
+ token_lora_mapping: torch.Tensor | None = None,
339
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
340
"""
341
Aligns tokens and experts into block-sized chunks for LoRA-based
342
mixture-of-experts (MoE) execution.
343
+
344
+ When `token_lora_mapping` is provided, it overrides the global mapping
345
+ read from `self.token_mapping_meta`. This is how EP+LoRA injects the
346
+ per-rank-local token→LoRA map after all-to-all dispatch.
347
"""
343
- (token_lora_mapping, _, _, _, lora_ids, _, _) = (
344
- self.token_mapping_meta.meta_args(
345
- num_tokens, self.lora_config.specialize_active_lora
346
- )
348
+ (
349
+ token_lora_mapping_meta,
350
+ _,
351
+ _,
352
+ _,
353
+ lora_ids,
354
+ _,
355
+ _,
356
+ ) = self.token_mapping_meta.meta_args(
357
+ num_tokens, self.lora_config.specialize_active_lora
358
+ )
359
+ if token_lora_mapping is None:
360
+ token_lora_mapping = token_lora_mapping_meta
361
+ # Under EP the caller passes local_num_experts but topk_ids carries
362
+ # GLOBAL expert indices. The CUDA kernel uses num_experts to size
363
+ # its bucketing table; with EP we must size by global_num_experts
364
+ # so global topk_ids don't overflow. expert_map inside the kernel
365
+ # then translates global→local so the output expert_ids are local
366
+ # (mirrors the non-LoRA moe_align_block_size behavior).
367
+ kernel_num_experts = (
368
+ expert_map.numel() if expert_map is not None else num_experts
369
)
370
if naive_block_assignment:
371
expert_ids = topk_ids.reshape(-1)
372
sorted_ids = None
373
num_tokens_post_pad = None
374
else:
353
- max_num_tokens_padded = topk_ids.numel() + num_experts * (block_size - 1)
375
+ max_num_tokens_padded = topk_ids.numel() + kernel_num_experts * (
376
+ block_size - 1
377
+ )
378
if pad_sorted_ids:
379
max_num_tokens_padded = round_up(max_num_tokens_padded, block_size)
356
- if topk_ids.numel() < num_experts:
380
+ if topk_ids.numel() < kernel_num_experts:
381
max_num_tokens_padded = topk_ids.numel() * block_size
382
sorted_ids = torch.empty(
383
(max_loras * max_num_tokens_padded,),
@@ -361,9 +385,12 @@ class PunicaWrapperGPU(PunicaWrapperBase):
385
device=topk_ids.device,
386
)
387
max_num_m_blocks = triton.cdiv(max_num_tokens_padded, block_size)
364
- # Expert ids must be set default to -1 to prevent a blank block
365
- expert_ids = torch.empty(
388
+ # Expert ids are initialized to -1 so unused (lora, expert)
389
+ # slots don't drive the LoRA Triton kernel into the wrong bucket.
390
+ # The kernel overwrites only active slots.
391
+ expert_ids = torch.full(
392
(max_loras * max_num_m_blocks,),
393
+ -1,
394
dtype=torch.int32,
395
device=topk_ids.device,
396
)
@@ -374,7 +401,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
401
ops.moe_lora_align_block_size(
402
topk_ids,
403
token_lora_mapping,
377
- num_experts,
404
+ kernel_num_experts,
405
block_size,
406
max_loras,
407
max_num_tokens_padded,
@@ -384,11 +411,10 @@ class PunicaWrapperGPU(PunicaWrapperBase):
411
num_tokens_post_pad,
412
adapter_enabled,
413
lora_ids,
414
+ expert_map,
415
)
388
- if expert_map is not None:
389
- expert_ids = expert_map[expert_ids]
416
391
- return None, sorted_ids, expert_ids, num_tokens_post_pad
417
+ return token_lora_mapping, sorted_ids, expert_ids, num_tokens_post_pad
418
419
def add_lora_fused_moe(
420
self,
@@ -480,6 +506,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
506
num_slices: int,
507
fully_sharded: bool,
508
use_tuned_config: bool,
509
+ token_lora_mapping: torch.Tensor | None = None,
510
) -> tuple[
511
torch.Tensor | None,
512
torch.Tensor | None,
@@ -558,6 +585,7 @@ class PunicaWrapperGPU(PunicaWrapperBase):
585
adapter_enabled,
586
expert_map,
587
naive_block_assignment=naive_block_assignment,
588
+ token_lora_mapping=token_lora_mapping,
589
)
590
591
_sorted = sorted_token_ids_lora
vllm/lora/punica_wrapper/punica_xpu.py
+1
@@ -461,6 +461,7 @@ class PunicaWrapperXPU(PunicaWrapperBase):
461
num_slices: int,
462
fully_sharded: bool,
463
use_tuned_config: bool,
464
+ token_lora_mapping: torch.Tensor | None = None,
465
) -> tuple[
466
torch.Tensor | None,
467
torch.Tensor | None,
vllm/model_executor/layers/fused_moe/lora_context.py
+7
@@ -42,3 +42,10 @@ class MoELoRAContext:
42
# Whether VLLM_TUNED_CONFIG_FOLDER is set; selects get_lora_op_configs vs
43
# try_get_optimal_moe_lora_config for Triton kernel tile configs.
44
use_tuned_config: bool
45
+
46
+ # Per-rank token→LoRA mapping after EP dispatch. Set by
47
+ # FusedMoEPrepareAndFinalizeModular.prepare() when EP+LoRA is active, read
48
+ # by LoRAExpertsMixin helpers in place of punica_wrapper's global mapping.
49
+ # None means no dispatch happened (non-EP path), in which case callers
50
+ # fall back to punica_wrapper.token_mapping_meta.
51
+ local_token_lora_mapping: torch.Tensor | None = None
vllm/model_executor/layers/fused_moe/lora_experts_mixin.py
+1
@@ -70,6 +70,7 @@ class LoRAExpertsMixin:
70
lora_context.w13_num_slices,
71
lora_context.fully_sharded,
72
lora_context.use_tuned_config,
73
+ token_lora_mapping=lora_context.local_token_lora_mapping,
74
)
75
76
def apply_w2_lora(
vllm/model_executor/layers/fused_moe/oracle/int8.py
-3
@@ -79,9 +79,6 @@ def select_int8_moe_backend(
79
Note: Shape-specific fallbacks may still occur at runtime.
80
"""
81
82
- if config.is_lora_enabled:
83
- return Int8MoeBackend.TRITON, backend_to_kernel_cls(Int8MoeBackend.TRITON)[0]
84
-
82
AVAILABLE_BACKENDS = _get_priority_backends(config)
83
84
activation_format = (
vllm/model_executor/layers/fused_moe/oracle/mxfp4.py
-22
@@ -301,28 +301,6 @@ def select_mxfp4_moe_backend(
301
"""
302
# If activation_key is explicitly provided (e.g., W4A8), use it
303
requested_activation_key = activation_key
304
- device_capability = current_platform.get_device_capability()
305
- triton_kernels_supported = (
306
- has_triton_kernels()
307
- and device_capability is not None
308
- and (9, 0) <= device_capability < (11, 0)
309
- )
310
-
311
- # LoRA: separate experts backend path
312
- if config.is_lora_enabled:
313
- if not current_platform.is_cuda():
314
- # ROCm: Triton mxfp4 LoRA hits GPU memory faults due to
315
- # triton_kernels.tensor.Tensor / HIP read-only page issues
316
- # during weight swizzle and LoRA forward. Needs work from
317
- # the triton_kernels/aiter side.
318
- raise NotImplementedError("Mxfp4 LoRA is currently only supported on CUDA.")
319
- if envs.VLLM_MXFP4_USE_MARLIN is False and triton_kernels_supported:
320
- logger.info_once("Using Triton backend for mxfp4 lora")
321
- return Mxfp4MoeBackend.TRITON_UNFUSED, backend_to_kernel_cls(
322
- Mxfp4MoeBackend.TRITON_UNFUSED
323
- )[0]
324
- logger.info_once("Using Marlin backend for mxfp4 lora")
325
- return Mxfp4MoeBackend.MARLIN, backend_to_kernel_cls(Mxfp4MoeBackend.MARLIN)[0]
304
305
activation_format = (
306
mk.FusedMoEActivationFormat.BatchedExperts
vllm/model_executor/layers/fused_moe/oracle/mxfp8.py
-2
@@ -61,8 +61,6 @@ def select_mxfp8_moe_backend(
61
Returns:
62
A tuple of (fp8_backend, experts_cls).
63
"""
64
- if config.is_lora_enabled:
65
- raise NotImplementedError("LoRA is not supported for MXFP8 MoE.")
64
65
runner_backend = config.moe_backend
66
if runner_backend != "auto":
vllm/model_executor/layers/fused_moe/prepare_finalize/naive_dp_ep.py
+44
-4
@@ -84,6 +84,14 @@ class MoEPrepareAndFinalizeNaiveDPEPModular(mk.FusedMoEPrepareAndFinalizeModular
84
super().__init__()
85
self.is_sequence_parallel = is_sequence_parallel
86
self._num_dispatchers = num_dispatchers
87
+ # Set by FusedMoEWithLoRA.set_mapping() when LoRA is active. When
88
+ # present, prepare() dispatches the per-token LoRA mapping alongside
89
+ # hidden_states and writes the gathered result back to the context so
90
+ # experts can use the per-rank-local mapping.
91
+ self._lora_context = None
92
+
93
+ def set_lora_context(self, ctx) -> None:
94
+ self._lora_context = ctx
95
96
@property
97
def activation_format(self) -> mk.FusedMoEActivationFormat:
@@ -124,22 +132,54 @@ class MoEPrepareAndFinalizeNaiveDPEPModular(mk.FusedMoEPrepareAndFinalizeModular
132
133
a1q, scales = _quantize_and_setup_dispatch(a1, quant_config, defer_input_quant)
134
135
+ # When LoRA is active, dispatch the per-token LoRA id along with
136
+ # hidden_states so every rank receives the correct mapping for the
137
+ # tokens it ends up processing. The punica_wrapper stores indices as
138
+ # int64 but the moe_lora_align_block_size kernel expects int32, so
139
+ # pull the pre-cast view from token_mapping_meta.
140
+ lora_ctx = self._lora_context
141
+ local_token_lora_mapping = None
142
+ if lora_ctx is not None:
143
+ local_token_lora_mapping = (
144
+ lora_ctx.punica_wrapper.token_mapping_meta.token_lora_mapping[
145
+ : a1.shape[0]
146
+ ]
147
+ )
148
+
149
+ extra_tensors: list[torch.Tensor] | None = None
150
+ if scales is not None:
151
+ extra_tensors = list(scales)
152
+ if local_token_lora_mapping is not None:
153
+ if extra_tensors is None:
154
+ extra_tensors = []
155
+ extra_tensors.append(local_token_lora_mapping)
156
+
157
res = get_ep_group().dispatch(
158
a1q,
159
topk_weights,
160
topk_ids,
161
is_sequence_parallel=self.is_sequence_parallel,
132
- extra_tensors=scales,
162
+ extra_tensors=extra_tensors,
163
)
164
135
- if scales is None:
165
+ if extra_tensors is None:
166
assert len(res) == 3
167
a1q, topk_weights, topk_ids = res
168
a1q_scale = None
169
else:
170
assert len(res) == 4
141
- a1q, topk_weights, topk_ids, scales = res
142
- a1q_scale = _unwrap_scale_and_prepare_for_moe(scales, quant_config)
171
+ a1q, topk_weights, topk_ids, gathered_extras = res
172
+ gathered_extras = list(gathered_extras)
173
+ if local_token_lora_mapping is not None:
174
+ dispatched_lora_mapping = gathered_extras.pop()
175
+ assert lora_ctx is not None
176
+ lora_ctx.local_token_lora_mapping = dispatched_lora_mapping
177
+ if scales is not None:
178
+ a1q_scale = _unwrap_scale_and_prepare_for_moe(
179
+ gathered_extras, quant_config
180
+ )
181
+ else:
182
+ a1q_scale = None
183
184
return a1q, a1q_scale, None, topk_ids, topk_weights
185