Prhub

#40867 [LoRA] Initial EP support for LoRA

原始 PR 作者 jeejeelee 合并时间 2026-05-09 15:31 文件变更 13 提交数 54 评论 21 代码增减 +209 / -73

执行摘要

为 LoRA MoE 添加初始 Expert Parallelism 支持

之前 FusedMoEWithLoRA 直接断言不支持 EP (assert not self.base_layer.use_ep),限制了 LoRA 与专家并行的组合使用。为满足用户对 MoE 模型进行 LoRA 微调时同时利用专家并行加速的需求,需要移除该限制并实现完整的 EP+LoRA 支持。此 PR 是系列工作的前置依赖(依赖 #40338)。

本 PR 值得所有关注 MoE + LoRA 联合优化的开发者精读。核心设计亮点包括:通过 extra_tensors 机制扩展 all-to-all 调度以携带元数据;通过 local_token_lora_mapping 保留了 rank 本地的 LoRA 映射;通过断言检查避免了与 fully_sharded_loras 的冲突。review 中关于切片时机的讨论也反映了 vLLM 中数据编排的常见模式。

讨论亮点

序列并行下的 token 映射切片风险

gemini-code-assist 指出当 is_sequence_parallel 启用时,prepare 中的 token_lora_mapping[:a1.shape[0]] 假设 rank 0 处理序列开头,对其他 rank 导致错误。虽然当前 EP 配置禁止序列并行,但未来若放开需修复。

非门控 MoE 模型兼容性

gemini-code-assist 指出对于 _w13_slices=1 的非门控模型,w3_lora_a 可能为 None,在 set_lora 切片时引发 TypeError。建议增加守卫。作者未明确回复,但 PR 已合并,疑未处理。

PrepareFinalize 上下文设计

Jackmin801 认为 PrepareFinalize 不应持有整个 MoELoRAContext,只需 lora_id 以降低耦合和便于测试。作者回复当前为扩展性保留完整上下文,未来可考虑简化。

专家权重切片时机

Jackmin801 建议将 set_lora 中的切片逻辑移至 load 阶段以提前减少内存和提升效率。作者表示赞同,但未在本次 PR 中修改。

修复 replacements 越界

HollowMan6 在 create_dummy_lora 中发现并修复 FusedMoEWithLoRAreplacements 切片问题,防止索引越界。

实现拆解

  1. 解除 EP+LoRA 限制并添加兼容性检查:在 vllm/lora/layers/fused_moe.pyFusedMoEWithLoRA.__init__ 中移除 assert not self.base_layer.use_ep,改调用 _ep_check()_verify_ep_fs()_ep_check 确保 all2all_backendallgather_reducescatter 且未启用 is_sequence_parallel_verify_ep_fs 禁止与 fully_sharded_loras 同时启用,因为两者在同一 TP 组上的分片方式冲突。同时 tp_sizetp_rank 改为从 base_layer 获取,以反映 EP 场景下的视角。

  2. 统一 MoE 后端选择路径:移除 vllm/model_executor/layers/fused_moe/oracle/mxfp4.pyint8.pyunquantized.py 中针对 LoRA 的 early-return 分支,这些分支曾强制使用 Triton 或 Marlin 后端。现在 LoRA 路径由 LoRAExpertsMixinMoELoRAContext 统一处理,不再需要在后端选择阶段特殊分流,使 EP+LoRA 能通过标准的 prepare_finalize 流程。

  3. 在 EP 调度中整合 LoRA 映射:在 vllm/.../naive_dp_ep.pyMoEPrepareAndFinalizeNaiveDPEPModular 中添加 _lora_context 属性和 set_lora_context() 方法。prepare() 时从 _lora_context 获取 token_lora_mapping(即每个 token 对应的 LoRA ID),将其附加到 extra_tensors 列表中,与量化 scale 一起通过 get_ep_group().dispatch() 进行 all-to-all 调度。在收到 gathered_extras 后,弹出 dispatched_lora_mapping 并写回 lora_ctx.local_token_lora_mapping,供后续 LoRA 专家使用。

  4. 调整 LoRA 权重创建与加载以支持专家切片:在 vllm/lora/model_manager.py 中新增 _slice_moe_lora_ep() 方法,在 _stack_moe_lora_weights() 中处理 FusedMoEWithLoRA 类型的模块时调用该方法,根据 ep_ranklocal_num_experts 从全局专家权重重切片出本地部分。同时修改 create_dummy_lora()replacements 的计算,避免因 packed_modules_mapping 中出现多余映射导致的索引越界。

  5. Punica 包装器支持 per-rank LoRA 映射:在 vllm/lora/punica_wrapper/punica_gpu.pymoe_lora_align_block_size() 中添加可选的 token_lora_mapping 参数,当 EP 激活时使用该 per-rank 映射替代全局映射;调整 num_expertskernel_num_experts,支持 expert_map 进行全局到本地的专家 ID 转换。punica_base.pypunica_xpu.py 添加相应接口签名。

  6. 更新测试覆盖:修改 tests/lora/test_qwen3moe_tp.py 以验证 EP+LoRA 在多 TP 下的正确性,包括 TP=2 和 TP=4 配置。

文件 模块 状态 重要度
vllm/lora/layers/fused_moe.py LoRA 层 modified 7.62
vllm/lora/model_manager.py LoRA 管理器 modified 7.74
vllm/model_executor/layers/fused_moe/prepare_finalize/naive_dp_ep.py EP 调度 modified 7.54
vllm/lora/punica_wrapper/punica_gpu.py Punica 包装器 modified 6.68
vllm/model_executor/layers/fused_moe/lora_context.py LoRA 上下文 modified 6.02

关键符号

_ep_check _verify_ep_fs _slice_moe_lora_ep set_lora_context

关键源码片段

vllm/lora/layers/fused_moe.py core-logic

核心逻辑改动,移除 EP 断言,添加 _ep_check 和 _verify_ep_fs 验证函数,修改 set_lora 中的专家切片逻辑。

class FusedMoEWithLoRA(BaseLayerWithLoRA):
    def __init__(self, base_layer: FusedMoE) -> None:
        super().__init__()
        self.base_layer = base_layer
        # 原来 : assert not self.base_layer.use_ep
        # 改为动态检查 : 确认 all2all_backend 和禁止 sequence parallel
        self._ep_check()
        # 使用 MoE-aware 的 TP 信息(EP 时 tp_size=1)
        self.tp_size = self.base_layer.tp_size
        self.tp_rank = self.base_layer.tp_rank
        # ... 后续初始化
​
    def _ep_check(self):
        """验证 EP 配置与 LoRA 兼容。"""
        if self.base_layer.use_ep:
            moe_config = self.base_layer.moe_config
            all2all_backend = moe_config.moe_parallel_config.all2all_backend
            # 只支持 allgather_reducescatter 后端
            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 和 fully_sharded_loras 在同一 TP 组上分片冲突。"""
        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."
        )
vllm/lora/model_manager.py data-contract

管理 LoRA 权重创建与加载,新增 _slice_moe_lora_ep 方法处理 EP 下的专家切片,修改 _stack_moe_lora_weights 支持全局到本地的切片。

def _stack_moe_lora_weights(
    self, lora_model: LoRAModel, module: FusedMoE3DWithLoRA, module_name: str
) -> None:
    # ... 从 lora_model.loras 获取 gate_up_proj_lora 和 down_proj_lora
    if self._is_3d_moe_model:
        # 当使用 EP 时,adapter 携带全局专家权重,需要切片成本地专家
        local_num_experts = module.w13_lora_a_stacked[0].shape[1]
        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
​
        # lora_a: (num_experts, rank, input_size) -> 切片专家维度
        gate_up_proj_lora.lora_a = (
            gate_up_proj_lora.lora_a.reshape(
                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(
                global_num_experts, -1, down_proj_lora.lora_a.shape[-1]
            )[expert_start:expert_end].contiguous()
        )
        # lora_b: (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, 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, global_num_experts
            )[..., expert_start:expert_end]
        )
vllm/model_executor/layers/fused_moe/prepare_finalize/naive_dp_ep.py data-contract

修改 MoEPrepareAndFinalizeNaiveDPEPModular,添加 set_lora_context 和在 prepare 中传递 LoRA 映射,是 EP+LoRA 的核心调度集成点。

class MoEPrepareAndFinalizeNaiveDPEPModular(mk.FusedMoEPrepareAndFinalizeModular):
    def __init__(self, is_sequence_parallel=False, num_dispatchers=1):
        super().__init__()
        self.is_sequence_parallel = is_sequence_parallel
        self._num_dispatchers = num_dispatchers
        # 由 FusedMoEWithLoRA.set_mapping() 设置
        self._lora_context = None
​
    def set_lora_context(self, ctx) -> None:
        self._lora_context = ctx
​
    def prepare(self, a1, topk_weights, topk_ids, num_experts, expert_map,
                apply_router_weight_on_input, quant_config, defer_input_quant=False):
        # ... 量化与 scale 设置 ...
        a1q, scales = _quantize_and_setup_dispatch(a1, quant_config, defer_input_quant)
​
        # 获取每 token 的 LoRA 映射(如果存在)
        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:包含 scale 和可选的 token_lora_mapping
        extra_tensors = None
        if scales is not None:
            extra_tensors = list(scales)
        if local_token_lora_mapping is not None:
            extra_tensors = extra_tensors or []
            extra_tensors.append(local_token_lora_mapping)
​
        # 执行 all-to-all 调度,同时分发 extra_tensors
        res = get_ep_group().dispatch(
            a1q, topk_weights, topk_ids,
            is_sequence_parallel=self.is_sequence_parallel,
            extra_tensors=extra_tensors,
        )
​
        # 从结果中分离出 dispatched_lora_mapping
        if extra_tensors is None:
            a1q, topk_weights, topk_ids = res
            a1q_scale = None
        else:
            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()
                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

评论区精华

Sequence parallelism 下 token_lora_mapping 切片不正确 正确性

gemini-code-assist 指出当 is_sequence_parallel 启用时,prepare 中的 token_lora_mapping[:a1.shape[0]] 假设 rank0 处理序列开头,对其他 rank 错误。

结论:当前 EP 配置禁止 sequence parallel,因此不构成立即风险。但若未来解除限制需修复。 · deferred

非门控 MoE 模型(Nemotron-Nano)兼容性 正确性

gemini-code-assist 指出对于 _w13_slices=1 的模型,w3_lora_a/b 为 None,切片导致 TypeError。

结论:未在 PR 中修复,合并前未见后续讨论,可能需后续 PR 解决。 · unresolved

PrepareFinalize 是否应持有完整 LoRA 上下文 设计

Jackmin801 认为只传 lora_id 更简洁易测试,作者回复为扩展性保留完整上下文。

结论:当前保留完整上下文,未来可简化。 · 已解决

风险与影响

  • 序列并行兼容性风险:当未来允许 EP+LoRA 同时启用序列并行时,prepare 中的 token 映射切片逻辑不正确。当前虽已禁止,但若未来放开则需修复。
  • 非门控 MoE 模型兼容性风险set_lora 中的专家权重切片未处理 w3_lora_a/b 为 None 的情况,可能导致 TypeError。需添加条件判断。
  • 通信开销增加:在 prepare 中将 token_lora_mapping 作为 extra_tensors 参与 all-to-all 调度,增加了少量通信开销。对于大规模部署需关注。
  • 回归风险:移除 LoRA 后端选择特殊路径可能影响非 EP 场景的 LoRA 运行,但已通过 LoRAExpertsMixin 统一处理,理论上兼容。测试覆盖有限(仅 Qwen3MoE),可能对其他模型有隐藏问题。
  • 内存风险:专家权重切片在 set_lora 时进行,临时分配了切片视图,可能略微增加峰值内存。若移至 load 阶段可缓解。

用户侧:启用 enable_expert_parallel=True 的用户现可同时使用 LoRA 微调 Qwen3MoE 等模型,提升吞吐和显存效率。禁用 EP 的用户行为无变化。
系统侧:改动涉及 LoRA 调度核心链路(prepare/finalize),对不启用 EP 的推理无额外开销;启用 EP 时增加了一次 LoRA 映射的 all-to-all 传输。
团队侧:为后续支持更多模型(如 DeepSeek MoE)的 EP+LoRA 奠定了基础,重构了 LoRA 与 EP 的交互模式。

序列并行兼容风险 非门控 MoE 模型兼容风险 通信开销增加 回归风险(LoRA 后端选择) 内存风险(切片时机)

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论