Prhub

#6512 [fsdp, model] feat: per-unit LoRA summon, FSDP1/2 compatibility, and strip-modules support

原始 PR 作者 qinganrice 合并时间 2026-06-18 11:37 文件变更 3 提交数 4 评论 16 代码增减 +110 / -37

执行摘要

重构 FSDP+LoRA 分层召唤,降低显存并修复死锁

为了支持大规模多阶段模型 (如 Qwen3-Omni-30B-A3B Thinker) 的 RL 微调,需要降低 FSDP 下 LoRA 参数收集的显存峰值,并解决 NCCL 死锁和 FSDP state_dict hooks 崩溃等问题。PR body 明确指出:'All changes are model-agnostic and do not introduce any dependency on vllm-omni'。

该 PR 值得所有深入使用 FSDP+LoRA 训练的用户精读,尤其是 layered_summon_lora_params 的设计思路和 wrap 策略防护逻辑。建议结合具体模型进行回归测试,验证显存与训练稳定性。对于重复 all-gather 的风险,可在升级后仍保持监控,必要时在内部 fork 中进一步优化。

讨论亮点
  • 嵌套 FSDP 单元重复收集问题:gemini-code-assist[bot] 指出 layered_summon_lora_params 在遍历父 FSDP 单元时会递归收集子单元参数,导致重复 all-gather 和 OOM 风险。作者回复已修复,但最终代码仍保留 submodule.named_parameters(recurse=True) 模式,重复问题是否完全解决需要进一步验证。
  • state_dict hooks 冲突:该 bot 建议在 collect_lora_params 中显式构造 state_dict 传参,避免 get_peft_model_state_dict 默认调用 state_dict() 导致 hooks 冲突。SamitHuang 认为 'not critical, but cheap and safer',但最终代码未明确修改该部分。
  • 插件加载机制:早期提交添加了自定义插件加载器,但 wuxibin89 指出 verl 已存在 VERL_USE_EXTERNAL_MODULES hook,重复实现导致后续移除,最终 PR 未包含插件相关改动。
  • import 位置与注释风格:SamitHuang 要求将 import 移至文件头部并简化注释,作者最终遵循了建议。

实现拆解

步骤1:重写 layered_summon_lora_params

  • 文件verl/utils/fsdp_utils.py
  • 关键变更:移除旧的 __prefix_submodules 和硬编码前缀;改为遍历整个模块树的 named_modules(),对每个 FSDP 单元 (通过 fsdp_version 检测) 依次调用 summon_full_paramsDTensor.full_tensor()。峰值显存从整个模型降为最大 FSDP 单元。
  • FSDP1/FSDP2 兼容:通过 fsdp_version() 判断是否需使用 summon_full_params 上下文;对 FSDP2 的 DTensor 参数直接调用 full_tensor()
  • 跳过退化单元:过滤掉不包含 lora_* 可训练参数的 FSDP 单元,避免 state_dict() 崩溃。

步骤2:调整 LoRA wrap 策略

  • 文件verl/utils/fsdp_utils.py
  • 问题:同时使用 lambda 策略和基于大小的策略会创建嵌套 FSDP 单元,在变长输入 use_remove_padding 下 all-gather 顺序不一致,导致 NCCL 死锁。
  • 修复:当 min_num_params > 0 时禁用 lambda 策略,避免混合策略冲突。

步骤3:支持模块剥离与 dtype 转换

  • 文件verl/workers/engine/fsdp/transformer_impl.py
  • _verl_strip_modules:在模型加载后,删除 module._verl_strip_modules 中列出的子模块 (如 talker、code2wav),使其不占用 GPU 内存。
  • LoRA dtype 转换:在 _build_lora_module 中,将 fp32 的 LoRA 适配器参数转为与基模型一致的 bf16,避免 FSDP flatten group 内的 dtype 不匹配错误。

步骤4:修复 monkey_patch 中 text_config 的获取

  • 文件verl/models/transformers/monkey_patch.py
  • 问题:多阶段模型 (如 Qwen3-Omni) 的 text_config 不直接暴露在 model.config.text_config,而是在嵌套结构中。
  • 修复:使用 get_text_config() 替代直接属性访问,并保留向后兼容 fallback。
文件 模块 状态 重要度
verl/utils/fsdp_utils.py FSDP 工具 modified 7.72
verl/workers/engine/fsdp/transformer_impl.py FSDP 引擎 modified 6.36
verl/models/transformers/monkey_patch.py 模型补丁 modified 5.4

关键符号

layered_summon_lora_params collect_lora_params get_wrap_policy _build_module _build_lora_module apply_monkey_patch

关键源码片段

verl/utils/fsdp_utils.py core-logic

核心改动:重写 layered_summon_lora_params、FSDP1/2 兼容、wrap 策略修复、collect_lora_params 调整

def layered_summon_lora_params(fsdp_module) -> OrderedDict:
    """
    逐个 FSDP 单元收集 LoRA 参数,避免一次收集全部模型导致显存爆炸。
    遍历所有 named_modules(),对每个 FSDP unit(依据 fsdp_version)单独召唤。
    使用 clean_prefix 去除内部 wrapping 前缀,保持与 downstream loader 兼容。
    注意:如果父单元和子单元都是 FSDP 单元,父单元的 named_parameters 会
    递归包含子单元的参数,导致重复 all-gather;作者声称已修复此问题,但
    当前实现仍存在理论上的重复收集风险(参见 review 讨论)。
    """
    lora_params = OrderedDict()
    peft_model = getattr(fsdp_module, "_fsdp_wrapped_module", fsdp_module)
​
    for name, submodule in fsdp_module.named_modules():
        # 跳过 root,因为 root summon 会收集整个模型,违背分层目的
        if name == "":
            continue
        # 只处理 FSDP 包裹的 unit(FSDP1 或 FSDP2)
        if fsdp_version(submodule) == 0:
            continue
​
        # 去掉内部的 _fsdp_wrapped_module. 前缀,使参数名与下游期望一致
        clean_prefix = name.replace("_fsdp_wrapped_module.", "")
        # 跳过容器型单元(.model / .layers),它们没有直接参数
        if clean_prefix.endswith(".model") or clean_prefix.endswith(".layers"):
            continue
​
        if fsdp_version(submodule) == 1:
            # FSDP1 必须在 summon 上下文中才能获取完整参数
            with FSDP.summon_full_params(submodule):
                params = dict(submodule.named_parameters(recurse=True))
        else: # FSDP2: 通过 DTensor.full_tensor() 获取,无需 summon
            params = {}
            for n, p in submodule.named_parameters(recurse=True):
                if isinstance(p, DTensor):
                    params[n] = p.full_tensor()
                elif isinstance(p, torch.Tensor):
                    params[n] = p # 已经 gathered
​
        # 只保留 LoRA 参数
        lora_suffix = "lora_A.default.weight"
        for n, p in params.items():
            clean_name = clean_prefix + "." + n
            clean_name = _strip_fsdp_wrapper(clean_name)
            if clean_name.endswith(lora_suffix) or "lora_" in n:
                lora_params[clean_name] = cpu_if_needed(p)
​
    return lora_params
verl/workers/engine/fsdp/transformer_impl.py core-logic

支持子模块剥离 (_verl_strip_modules) 和 LoRA dtype 自动转换

# _build_module 中的剥离逻辑 ( 位于 module 加载之后 )
if self.model_config.model_type == "language_model":
    module = auto_class.from_pretrained(...)
​
    # 从模型属性 _verl_strip_modules 读取待删除的子模块列表
    _strip_list = getattr(module, "_verl_strip_modules", [])
    for attr in _strip_list:
        if hasattr(module, attr):
            delattr(module, attr)
            logger.info(f"Stripped unused sub-module '{attr}' to reduce memory")# _build_lora_module 中的 dtype 转换逻辑
module = get_peft_model(module, LoraConfig(**lora_config))# FSDP 要求同一个 flat group 内所有参数 dtype 一致;
# 如果 LoRA 适配器默认是 fp32,需转换到基模型 dtype(通常是 bf16)
base_dtype = next((p.dtype for p in module.parameters() if not p.requires_grad), None)
if base_dtype is not None:
    mismatched = [p for p in module.parameters() if p.requires_grad and p.dtype != base_dtype]
    if mismatched:
        logger.info(f"Casting {len(mismatched)} LoRA adapter params from "
                     f"{mismatched[0].dtype} to {base_dtype}")
        for param in mismatched:
            param.data = param.data.to(base_dtype)

评论区精华

嵌套 FSDP 单元重复收集导致 OOM 性能

gemini-code-assist[bot] 指出 layered_summon_lora_params 在处理父 FSDP 单元时会通过 named_parameters(recurse=True) 递归收集子单元参数,导致重复 all-gather 和 OOM 风险。

结论:作者回复 'fixed.',但最终关键代码未显式过滤已收集的子单元参数,重复问题可能未完全解决。SamitHuang 表示同意并催促作者处理。 · 已解决

collect_lora_params 未显式传递 state_dict 可能触发 hooks 冲突 正确性

gemini-code-assist[bot] 建议在 collect_lora_params 中显式构造 state_dict 字典后传递给 get_peft_model_state_dict,避免默认调用 peft_model.state_dict() 触发的 state_dict hooks 冲突。

结论:SamitHuang 认为 'not critical, but cheap and safer',但最终代码未包含该修改。作者未回复是否采纳。 · dismissed

自定义插件加载器与已有 VERL_USE_EXTERNAL_MODULES 重复 设计

wuxibin89 指出 verl 已有 VERL_USE_EXTERNAL_MODULES 钩子用于加载外部模块,新引入的插件加载机制冗余,建议移除。

结论:作者在后续提交中移除了自定义插件加载器,回归使用现有机制。 · 已解决

代码风格:import 位置和注释简洁性 style

SamitHuang 多次要求将 import 移到文件头部、注释保持简洁。

结论:作者最终遵循了这些风格建议。 · 已解决

风险与影响

  • 重复 all-gather 风险:当前 layered_summon_lora_params 实现未显式跳过已处理的子单元,仍可能触发重复 all-gather,在大型 MoE 模型上实际存在 OOM 风险 (引用 reviewer 指出的 OOM 问题)。
  • wrap 策略行为变更:当 min_num_params > 0 时禁用 lambda 策略,现有用户若依赖 lambda+size 混合策略可能发现 wrap 粒度改变,需验证训练收敛性。
  • strip_modules 依赖模型属性_verl_strip_modules 是模型自定义属性,如果模型未设置或设置错误,不会生效或可能删除错误模块。
  • dtype 转换覆盖不全:仅对 requires_grad=True 的 params 进行 dtype 转换,若基模型部分参数不需梯度且 dtype 不一致,可能导致 FC group dtype 不匹配。
  • FSDP 版本检测fsdp_version(submodule) 函数可能无法覆盖所有 PyTorch 版本,需在 PyTorch 2.4~2.6+ 上充分测试。
  • 用户视角:所有使用 FSDP+LoRA 训练的用户将受益于显存降低和训练稳定性提升。特别是训练 30B+ MoE 或包含多阶段子模块的模型时,之前可能遇到 OOM 或死锁的任务现在可正常运行。
  • 系统视角:峰值显存下降,可支持更大模型或更大 batch size 在同一 GPU 集群上训练。
  • 团队视角:改动了 FSDP 核心工具函数,后续 FSDP 相关开发需要兼容此次引入的新模式;同时清理了若干废弃标记,降低了技术债务。
  • 影响范围:直接修改 3 个文件,但影响所有 FSDP 后端训练流程 (包括 PPO、GRPO 等)。影响程度中等偏大。
嵌套 FSDP 重复收集 wrap 策略行为变更 strip_modules 依赖模型属性 dtype 转换覆盖不全 FSDP 版本检测依赖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论