Prhub

#7223 [megatron] feat: PP/VPP support for the steady delta export

原始 PR 作者 gxlvera 合并时间 2026-08-03 14:50 文件变更 3 提交数 2 评论 1 代码增减 +310 / -33

执行摘要

Megatron 稳态 delta 导出新增 PP/VPP 支持

PR body 明确说明这是对 #7060 的后续工作:此前 Megatron 稳态 delta 导出被 PP=1 断言限制,base 版代码的断言信息也写明 "the seed full sync supports PP already, the steady relay is roadmapped"——seed 全量同步早已支持 PP,但稳态增量同步在 PP 布局下被拦截。核心诉求是让 PP/VPP 布局的 Megatron 训练也能享受 delta_sharded 引擎的稳态增量权重同步,避免回退到全量 NCCL 同步(235B 上全量同步需 220.5-236.8s,delta 稳态仅 97.5-111.1s)。

值得精读。这是 delta_sharded 引擎演进的关键一步——它展示了如何不加 relay 就复用 bridge 全局参数目录实现 PP 导出,其中 _exchange_slot_tables 按目录行键控的契约设计、contributes 去重位、以及 placeholder 行的 zero-count 锁步机制都值得学习。建议重点阅读 build_export_index 的三态路由、_pp_differential 的独立参照链设计,以及第二个 commit 对 ep_size>1 归错问题的根因分析。

讨论亮点

PR 本身没有技术 review 评论,wuxibin89 直接 APPROVED;唯一的 issue 评论来自 gemini-code-assist bot,内容是 Gemini Code Assist 停服通知,与代码无关。真正的技术交锋记录在第二个 commit 的修复说明中:235B TP4×PP8×EP4 幂等扫描失败,违规者全部是专家权重。作者定位到先于 PP 就存在的归错根因——"the engine's batched gather merges entries BY SLOT POSITION within each directory row and rank 0 names the merged pieces from ITS OWN slot list",而 mcore expert 路径此前只缓存各自 ep-local 专家名,违反位置合并契约;最终以 row-keyed union slot tables 修复,并新增逐行表一致性检查。PR body 的性能备注也承认:PP 形状在 7B 上较 TP2×PP1 基线增加约 1.5-4s 稳态 uw(目录行数翻倍 + 每参数 WORLD gather),预期在大规模下摊薄,并注明若 235B 结果不支持该预期,后续会做 owner-subgroup + rank0 hybrid gather group。

实现拆解

变更入口在 verl/workers/engine/megatron/delta_export.py 的 build_export_index,核心思路是骑乘 Megatron-Bridge 已有的全局参数目录,而非自行枚举。拆解如下:

  1. 解除 PP=1 断言,复用 bridge 全局参数目录:build_export_index 删掉 pp_world == 1 的 assert,改为读取真实 pp_world。get_conversion_tasks 原本就会跨 pp rank allgather 参数名、确定性排序、去重 tied embeddings,并对其他 stage 拥有的参数留下 param_weight=None 占位——这正是 PP>1 锁步对齐的基础。McoreParamExport.param 变为 Optional[torch.Tensor],并新增 slots 字段存放全局 slot 表。
  2. PP>1 路由改为 WORLD group 全参合并:每个参数统一挂到 dist.group.WORLD,因为 wire master(global rank 0)必然参与每个 gather,无论参数归哪个 stage,天然不需要 relay 跳转;owner-stage rank 贡献自己的分片,dp/cp 副本通过 ShardSpec(contributes=False) 去重。param=None 的占位任务变成 full_shape=(0,)、contributes=False 的 zero-count 锁步行,与 HSDP 副本既有的锁步机制一致。PP=1 分支保持原路由(tp 组 / etp×ep 联合组 / 复制型 rank-0 直读),行为逐字节不变。
  3. 新增 _exchange_slot_tables 一次性全局交换:在 index 构建末尾按目录行(而非参数名)键控做 slot table union,owner rank 先探测一次 zero delta 完成预 seed,非 owner rank 从第一次同步就产出尺寸正确的零计数条目。这修复了 235B 验证暴露的 ep_size>1 专家归错:此前各 rank 只缓存自己的 ep-local 专家名,违反引擎 batched gather 按 slot 位置合并的契约。
  4. 引擎侧配套:transformer_impl.py 的 _mcore_export_index 把 self._delta_slot_cache 传入 build_export_index(顺序调整保证 cache 先于 index 建立);get_per_tensor_param_shard 对 param is None 的行产出空张量 shard,使跨 stage 占位行能流向 delta 引擎的锁步路径。
  5. 测试与验证配套:test_mcore_probe_differential.py 新增 _pp_differential 生产路径差分 oracle(支持 PP_SIZE/EP_SIZE 环境变量、新增 qwen2_tied tied-embedding 变体),并与 bridge.export_hf_weights 这条独立链做 bitwise 对比;同时顺带修复 transformers 5.6.0 将小型 NemotronH checkpoint 序列化为 backbone.embedding.weight(单数)而 bridge 映射使用 backbone.embeddings.weight 的键名归一问题。
文件 模块 状态 重要度
verl/workers/engine/megatron/delta_export.py 导出引擎 modified 8.48
tests/special_distributed/test_mcore_probe_differential.py 差分测试 modified 6.59
verl/workers/engine/megatron/transformer_impl.py 引擎实现 modified 6.15

关键符号

build_export_index _exchange_slot_tables _pp_differential _mcore_export_index get_per_tensor_param_shard

关键源码片段

tests/special_distributed/test_mcore_probe_differential.py test-coverage

新增 _pp_differential 生产路径差分 oracle,覆盖 PP/EP 组合、目录锁步、逐行 slot 表一致性与 bitwise 对比,并修复 transformers 5.6.0 键名归一。

def _pp_differential(bridge, model, rank: int, world: int) -> None:
    """PP>1 差分 oracle:跑生产导出路径(全局目录 + slot 预 seed + probe 条目),
    把"每个本地元素都变化"的全量 delta 同步结果与 bridge.export_hf_weights
    对比——后者走 bridge 自身的 PP 广播全量导出,构成独立参照链。
    逐 HF 张量 bitwise 比较,中间还做目录锁步与占位行校验。"""
    from verl.workers.engine.megatron.delta_export import build_export_index, mcore_hf_delta_entry
​
    slot_cache: dict = {}
    index = build_export_index(bridge, model, slot_cache)
​
    # 1. 目录锁步 : 所有 rank 行数必须一致 (mcore 专家参数名在 ep 维度
    # 合法不同 , 但 " 行 " 是合并单位 , 行数必须对齐 )
    names = [r.megatron_name for r in index]
    all_names: list = [None] * world
    dist.all_gather_object(all_names, names)
    failures = []
    if rank == 0 and any(len(n) != len(names) for n in all_names):
        failures.append(f"directory row counts diverge: {[len(n) for n in all_names]}")
​
    # 1b. 逐行 slot table 一致性 :batched gather 按行内 slot 位置合并 ,
    # rank 0 用自己的表命名合并结果 , 因此每行表必须全局一致
    row_tables = [r.slots for r in index]
    all_tables: list = [None] * world
    dist.all_gather_object(all_tables, row_tables)
    if rank == 0:
        for r_i, tbls in enumerate(all_tables):
            bad = [k for k in range(len(row_tables)) if tbls[k] != row_tables[k]][:3]
            if bad:
                failures.append(f"row slot tables diverge on rank {r_i} (rows {bad})")
                break
​
    # 3. 独立参照 :bridge 自带的全量导出 ( 内部走 PP broadcast, 各 rank 全量张量 )
    exported = bridge.export_hf_weights(model, cpu=True, show_progress=False)
    ref = {name: t.detach().to(torch.bfloat16).cpu() for name, t in exported}
​
    all_pieces: list = [None] * world
    dist.all_gather_object(all_pieces, mine)
    n_checked = n_pass = 0
    if rank == 0:
        slot_names = {k for d in all_pieces for k in d}
        missing_ref = sorted(set(ref) - slot_names)
        if missing_ref:
            more = max(0, len(missing_ref) - 5)
            failures.append(f"HF tensors never covered by any entry: {missing_ref[:5]} (+{more})")
        for sname in sorted(slot_names):
            if sname not in ref:
                failures.append(f"entry slot {sname} absent from bridge export")
                continue
            full = ref[sname]
            merged = torch.full_like(full, float("nan")) # NaN 哨兵 : 未覆盖位必须是 NaN
            covered = torch.zeros(full.numel(), dtype=torch.int32)
            mflat = merged.view(-1)
            for d in all_pieces:
                got = d.get(sname)
                if got is None:
                    continue
                sshape, pos, val = got
                if tuple(sshape) != tuple(full.shape):
                    failures.append(f"{sname}: slot shape {sshape} != export shape {tuple(full.shape)}")
                    continue
                both = covered[pos] > 0
                if both.any() and not torch.equal(mflat[pos[both]].view(torch.int16), val[both].view(torch.int16)):
                    failures.append(f"{sname}: conflicting rank contributions")
                mflat[pos] = val
                covered[pos] += 1
            n_checked += 1
            if torch.isnan(mflat).any():
                failures.append(f"{sname}: uncovered positions remain NaN")
            elif not torch.equal(mflat.view(torch.int16), full.view(-1).view(torch.int16)):
                failures.append(f"{sname}: bitwise mismatch")
            else:
                n_pass += 1
    dist.barrier()
    dist.destroy_process_group()
    if rank == 0 and (failures or n_checked == 0):
        sys.exit(1)
verl/workers/engine/megatron/transformer_impl.py core-logic

引擎侧配套:索引构建传入 slot_cache,get_per_tensor_param_shard 对跨 stage 占位行产出空 shard。

def _mcore_export_index(self):
    """Build (once) the per-parameter delta export index: geometry specs and
    form-B probes derived from the bridge's conversion tasks."""
    index = getattr(self, "_delta_export_index", None)
    if index is None:
        from .delta_export import build_export_index
​
        assert not self.vanilla_bridge, (
            "megatron delta_sharded is built on Megatron-Bridge param mappings; "
            "the deprecated vanilla mbridge flavor is not supported"
        )
        assert self.peft_cls is None, "megatron delta_sharded does not support LoRA"
        # slot cache 必须先于 index 建立 , 因为 build_export_index 内部会
        # 通过 _exchange_slot_tables 预 seed 每行的全局 slot 表
        self._delta_slot_cache: dict = {}
        index = build_export_index(self.bridge, self.module, self._delta_slot_cache)
        self._delta_export_index = index
        self._delta_export_by_name = {rec.megatron_name: rec for rec in index}
    return index
​
def get_per_tensor_param_shard(self, **kwargs):
    """Yield each rank's *local* mcore shard ``(name, local_flat_bf16, ShardSpec)``.
    Params owned by another pipeline stage yield an empty shard
    (zero-count lockstep rows; see the index builder)."""
    load_megatron_model_to_gpu(self.module, load_grad=False)
    index = self._mcore_export_index()
​
    def _gen():
        # 跨 stage 占位行 :param 为 None, 产出空张量让 delta 引擎走锁步路径
        empty = torch.empty(0, dtype=torch.bfloat16, device=get_device_id())
        for rec in index:
            if rec.param is None:
                yield rec.megatron_name, empty, rec.spec
                continue
            local = rec.param.data
            if local.is_floating_point() and local.dtype != torch.bfloat16:
                local = local.to(torch.bfloat16)
            yield rec.megatron_name, local.reshape(-1), rec.spec
​
    return _gen(), None

评论区精华

ep_size>1 专家权重归错根因与 row-keyed slot table 修复 正确性

235B TP4×PP8×EP4 幂等扫描失败,违规者全部是专家权重。根因先于 PP 存在:引擎的 batched gather 按目录行内 slot 位置合并条目,rank 0 用自己 slot 列表命名合并结果,因此每行 slot 表必须在 merge group 内逐 rank 相同;而 mcore expert 路径此前仅缓存各自 ep-local 专家名,ep_size>1 时违反位置合并契约,导致 slot 错位归到错误专家名下。

结论:改为按目录行(而非参数名)键控的联合 slot 表,按 rank 顺序跨 merge group union;EP oracle 矩阵(qwen3_moe EP2 × PP1/PP2/PP2+EDP2)45/45 bitwise + 逐行表一致,EP1 回归全绿。 · 已解决

PP 稳态同步的性能权衡与后续混合 gather 组 性能

PR body 的 perf note 承认:PP 形状在 7B 上比 TP2×PP1 基线增加约 1.5-4s 稳态 uw,来源是目录行数翻倍 + 每参数 WORLD gather;作者预期在大规模下摊薄,并注明若 235B 结果不支持该预期,后续将做 owner-subgroup + rank0 hybrid gather group。

结论:235B TP4×PP8×EP4 实测稳态 uw 97.5-111.1s vs 同形状 NCCL 220.5-236.8s(2.1-2.4×),收益显著;混合 gather 组明确留作 follow-up。 · 已解决

风险与影响

  1. 通信拓扑热点:PP>1 时所有参数并入 WORLD group,gather 参与方从 tp/etp×ep 子组扩到全部 rank,全局 rank 0 承担所有行的主聚合。7B 小模型上稳态 uw 增加 1.5-4s(数据来自 PR body),大模型下虽有 2.1-2.4× 收益但 payload 达 20-22GB,通信组规模对扩展性的影响仍需观察。
  2. 强依赖 bridge 全局目录语义:build_export_index 的正确性建立在 get_conversion_tasks 的确定性顺序、跨 pp allgather、tied embedding 去重和 param_weight=None 占位行为之上;Megatron-Bridge 升级若改变这些语义,可能导致目录不同 rank 分歧或占位误判。
  3. 新集合点风险:_exchange_slot_tables 在 index 构建时引入一次 allgather_object,若某 rank 的目录行数与全局不一致(例如 bridge 版本混用),会直接卡死或产生未对齐的 slot 表;测试虽覆盖了 row-count 分歧检查,但那是验证脚本而非运行时保护。
  4. 测试覆盖的分布:differential oracle 覆盖 qwen2/qwen2_tied/qwen3_moe/nemotron_h 的 PP/EP 矩阵且全绿,但 e2e 验证(7B、235B)依赖人工跑通,未进入常规 CI;_exchange_slot_tables 在 PP=1 时也执行,属于对所有现有用户路径的行为变更,回归风险集中在 slot cache 的预 seed 时序。

对用户:Megatron 后端 + PP/VPP 布局使用 delta_sharded checkpoint engine 的用户首次获得稳态增量权重同步,此前只能退化为 seed 全量同步或全量 NCCL 同步;大 MoE 模型(如 235B 官方尺寸)权重同步时间缩短约 55-60%。对系统:PP>1 下同步拓扑简化为单 WORLD 组,消除了多 stage 间 relay 的复杂度,但把聚合压力集中到全局 rank 0,小规模 PP 场景有轻微稳态开销增加。对团队:为后续 owner-subgroup + rank0 hybrid gather group 优化铺平了道路,且 differential oracle 扩展为可复用的 PP/EP 回归工具链,后续 bridge 升级可直接套用。

核心路径变更 PP>1 全参 WORLD 组 gather 通信热点 依赖 bridge 全局目录语义 新集合点 allgather_object 卡死风险

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论