Prhub

#7376 [megatron] fix: per-name, mapper-aware .base_layer strip in resolve_weight_name

原始 PR 作者 HollowMan6 合并时间 2026-08-12 20:43 文件变更 3 提交数 2 评论 0 代码增减 +85 / -15

执行摘要

修复混合 LoRA 模型 .base_layer 权重名解析崩溃

Qwen3.5-VL LoRA 训练在 Megatron→vLLM 权重同步时崩溃。PR body 说明:vLLM 0.26.0 中 _HAS_LORA_LOAD_WEIGHTS = False,LoRA 线性层(如 ReplicatedLinearWithLoRA)没有自己的 load_weights,vLLM 的 AutoWeightsLoader 会递归进真实的 base_layer 子层。原实现用模型级 any(".base_layer." in n ...) 决定是否剥离后缀,混合模型里语言层的 .base_layer. 参数让视觉 merger.linear_fc1 的伪 .base_layer. 没有被剥离,导致 AutoWeightsLoader 找不到参数而崩溃。

值得精读。它展示了一个典型的“模型级判断”在混合结构下失效的案例,改为“per-name + mapper-aware”的判断是良好的设计取舍。PR body 对两个连续 bug 的根因分析(vLLM 0.26.0 的 loader 递归行为、Bridge 导出前缀与活命名空间差异)质量很高,能帮助理解 verl 的权重同步链路。新增测试精确覆盖了回归场景,可作为类似边界条件的测试范本。

讨论亮点

该 PR 无人工 review 评论(Copilot 因配额未评审,wuxibin89 直接批准)。最有价值的内容来自 PR body 对两个连续 bug 的根因复盘:先是模型级→per-name 的改动,随后发现 naive per-name 在 Bridge 导出前缀下误剥 LoRA 语言层后缀,于是让 parent_has_base_layer 走 mapper-aware 的 _exists()。这个两段式修复过程展示了边界条件如何一步步被暴露和收敛。

实现拆解

  1. 定位根因:在 verl/utils/vllm/utils.pyresolve_weight_name 中,原代码用 any(".base_layer." in n for n in model_weight_names) 做模型级判断。混合 LoRA 模型(如 Qwen3.5-VL)中语言层确实有 .base_layer. 参数,但视觉 merger.linear_fc1 是普通 ColumnParallelLinear,没有 base_layer 子层,因此模型级判断错误地保留伪后缀,触发 AutoWeightsLoader 崩溃。
  2. 改为 per-name 判断:用 name.partition(".base_layer.") 取出 parent,再通过 _exists(parent + ".base_layer.weight")model_weight_names 前缀匹配,判断当前名字的 parent 是否真有 base_layer 子层;仅在没有子层或新版 vLLM(_HAS_LORA_LOAD_WEIGHTS=True)时剥离后缀。
  3. 让判断感知 mapper:vLLM 的 hf_to_vllm_mapper 会在 load_weights 时把 Bridge 导出前缀(model.language_model.)重写为活命名空间(language_model.model.)。parent_has_base_layer 改走 _exists() 后,自然复用 mapper 的前缀/子串重写,避免把 LoRA 语言层的真实后缀误剥。
  4. 修正配套变量:verl/workers/engine_workers.pyupdate_weights 中 base sync 分支原来把 get_per_tensor_param(..., base_sync_done=False) 的返回值仍命名为 peft_config,掩盖了外层同名变量;改为 _base_peft_config,避免 LoRA 配置被静默覆盖。
  5. 测试配套:在 tests/utils/test_vllm_weight_name_normalization_on_cpu.py 新增 test_mixed_model_vision_merger_strips_on_released_vllm(混合模型下 merger 剥离、LoRA 语言层保留)和 test_mixed_model_with_prefix_rewriting_mapper(带前缀重写 mapper 时保留语言层 .base_layer.、剥离 merger 后缀),两个都是 CPU 单测,不需要 GPU。
文件 模块 状态 重要度
verl/utils/vllm/utils.py 权重名解析 modified 6.22
tests/utils/test_vllm_weight_name_normalization_on_cpu.py 单测覆盖 modified 5.94
verl/workers/engine_workers.py 权重同步 modified 4.31

关键符号

resolve_weight_name _exists update_weights test_mixed_model_vision_merger_strips_on_released_vllm test_mixed_model_with_prefix_rewriting_mapper

关键源码片段

verl/utils/vllm/utils.py core-logic

核心修复点,将 `.base_layer.` 剥离的判断从模型级改为 per-name,并利用 `_exists` 的 mapper 感知能力处理 Bridge 前缀差异。

# verl/utils/vllm/utils.py —— resolve_weight_name 中的核心 strip 逻辑(head 版本)
def resolve_weight_name(model, name, names):
    # ... 前置初始化 model_weight_names、mapper、packed 等 ...
​
    def _exists(candidate: str) -> bool:
        # 先直接查名字,再通过 mapper 重写后查一次。
        # 注意:vLLM 的 hf_to_vllm_mapper 会在 load_weights 时把 Bridge 导出前缀
        # (如 model.language_model.)重写为活命名空间(language_model.model.),
        # 所以这里必须按 mapper 重写后的名字判断,否则会误判 base_layer 是否存在。
        if candidate in model_weight_names:
            return True
        if mapper is not None:
            mapped = mapper.apply_list([candidate])
            mapped = mapped[0] if mapped else candidate
            if mapped != candidate and mapped in model_weight_names:
                return True
        # packed-owner 反查(q/k/v -> qkv),同样复用 mapper 重写逻辑
        if packed and '.' in candidate:
            parts = candidate.split('.')
            mi = -3 if len(parts) >= 3 and parts[-2] == 'base_layer' else -2
            if -mi <= len(parts):
                rev = {u: p for p, us in packed.items() for u in us}
                owner = rev.get(parts[mi])
                for pn in (owner,) if owner else ():
                    pp = parts.copy()
                    pp[mi] = pn
                    joined = '.'.join(pp)
                    if joined in model_weight_names:
                        return True
                    if mapper is not None:
                        mp = mapper.apply_list([joined])
                        mp = mp[0] if mp else joined
                        if mp != candidate and mp in model_weight_names:
                            return True
        return False
​
    # Strip .base_layer.:判断必须按“当前名字的 parent”,而不是按整个模型。
    # 混合 LoRA 模型(如 Qwen3.5-VL)语言层有 .base_layer. 参数,
    # 但视觉 merger.linear_fc1 是普通 ColumnParallelLinear,没有 base_layer 子层。
    if '.base_layer.' in name:
        parent, _, _ = name.partition('.base_layer.')
        # 只有当前 parent 真的有 base_layer 子层时,才在 released vLLM 保留后缀
        parent_has_base_layer = _exists(parent + '.base_layer.weight') or any(
            n.startswith(parent + '.base_layer.') for n in model_weight_names
        )
        if not parent_has_base_layer or _HAS_LORA_LOAD_WEIGHTS:
            return name.replace('.base_layer.', '.', 1)
​
    if _exists(name):
        return name
    # ... 后续 packed routed-expert 别名与 leaf-add 逻辑 ...
tests/utils/test_vllm_weight_name_normalization_on_cpu.py test-coverage

新增两个 CPU 单测,锁定混合模型与 mapper 前缀重写两个回归场景。

# tests/utils/test_vllm_weight_name_normalization_on_cpu.py —— 新增回归测试(head 版本)
def test_mixed_model_with_prefix_rewriting_mapper():
    """Qwen3.5-VL 回归场景:Bridge 导出语言层前缀为 model.language_model.,
    视觉 merger 前缀为 model.visual.;vLLM 活命名空间则为
    language_model.model. / visual.,由 hf_to_vllm_mapper 的
    orig_to_new_prefix 重写。    关键点:parent_has_base_layer 必须查询 mapper 重写后的名字,
    否则 LoRA-wrapped 语言层的真实 base_layer 在 Bridge 前缀下不可见,
    会被误剥,导致 AutoWeightsLoader 找不到 layers.0.mlp.gate.weight 而崩溃。"""
    mapper = _FakeMapper(
        {
            # 用子串替换近似 WeightsMapper 的 orig_to_new_prefix
            'model.visual.': 'visual.',
            'model.language_model.': 'language_model.model.',
        }
    )
    model = _FakeModel(
        {
            'language_model.model.layers.0.mlp.gate.base_layer.weight': torch.empty(0),
            'visual.merger.linear_fc1.weight': torch.empty(0),
            'visual.merger.linear_fc1.bias': torch.empty(0),
        },
        mapper=mapper,
    )
    worker = _make_worker(model)
​
    # 语言层叶子:Bridge 前缀与 vLLM 不同,但重写后真实存在 base_layer 子层 -> 保留后缀
    name = 'model.language_model.layers.0.mlp.gate.base_layer.weight'
    assert _resolve(worker, model, name) == name
    # merger:两种前缀下都没有 base_layer 子层 -> 剥离后缀
    assert _resolve(worker, model, 'model.visual.merger.linear_fc1.base_layer.weight') == (
        'model.visual.merger.linear_fc1.weight'
    )
    assert _resolve(worker, model, 'model.visual.merger.linear_fc1.base_layer.bias') == (
        'model.visual.merger.linear_fc1.bias'
    )

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  • 核心路径变更:resolve_weight_name 是 vLLM 权重同步的必经函数,改动会影响所有 Megatron→vLLM 权重同步路径,尤其是 LoRA 场景。
  • 边界判断依赖 _exists 与 mapper:若上游 vLLM 改变 hf_to_vllm_mapper 的重写规则或新增其他重写方式,parent + ".base_layer.weight" 可能无法命中,导致误剥或误留。当前代码里 _exists 还处理 packed-owner 反查,逻辑较复杂。
  • 测试覆盖有限:新增的两个测试是 CPU 单测,使用 _FakeModel / _FakeMapper,没有覆盖真实 Qwen3.5-VL + Megatron + vLLM 0.26.0 的端到端场景。
  • 无关改动:engine_workers.py 中的变量重命名虽小,但如果未来有分支复用 _base_peft_config,需注意其作用域。
  • 用户影响:修复 Qwen3.5-VL 等混合 LoRA 模型在 Megatron 引擎下训练时的崩溃,这类用户升级后可直接运行。
  • 系统影响:权重同步逻辑更精确,减少了不必要的 .base_layer 剥离,避免 AutoWeightsLoader 找不到参数的故障。
  • 团队影响:为后续 vLLM 版本(_HAS_LORA_LOAD_WEIGHTS 行为变化)的兼容性维护提供了更清晰的判断锚点。
  • 影响程度:中。修复面主要在权重名规范化,不改变训练算法或配置 API。
核心路径变更 缺少端到端测试 依赖 mapper 行为

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论