Prhub

#7291 [ckpt] feat: Node-local multi-sender broadcast in NCCL checkpoint engine

原始 PR 作者 parinayc20 合并时间 2026-08-14 12:40 文件变更 4 提交数 2 评论 3 代码增减 +167 / -30

执行摘要

NCCL 引擎新增多发送者中继模式,权重同步提速约 3 倍

7167 报告了 NCCL 检查点引擎权重同步带宽受限的问题:With a single sender, broadcast bandwidth is capped by the one NIC reachable from the source GPU (~25 GB/s on a GH200 node with 4 NICs and ~100 GB/s bisection)。权重同步是每个训练步都会执行的临界路径,PR 的思路是让广播组成员包含 rank 0 的 NVLink 对等卡作为中继,使 NCCL's broadcast tree fan-out points it can reach over NVLink and push on from over their own NICs。PR body 还明确排除其他节点的 actor:they would have to pull a full copy over the fabric to contribute nothing,并说明 #7263 是同一问题的竞争方案、#7107 是本 PR 的前置依赖。

值得精读。核心看点:用 Ray 节点 id 作为 NVLink 可达性代理的拓扑决策、中继本地推导分块边界以避免集合通信死锁的设计、以及 root 等待时机与模式耦合的细节。建议重点关注 _multi_sender_ranksbuild_topology 的成员判定逻辑、split_weight_chunks(meta_only=True) 的复用方案,以及 review 中关于"边界分叉会导致广播永久挂起"的讨论。合并后建议跟进两件事:修正 multi_sender 默认值与 docstring、PR 描述不一致的问题;补充多节点与死锁回归测试。

讨论亮点

两条核心讨论均来自 reviewer wuxibin89,最终均已解决:

  • verl/checkpoint_engine/base.py 上针对新增的 get_weight_chunks_size 提出:Can we reuse split_weight_chunks? If divergence happened, the broadcast may hang forever. 作者 parinayc20 采纳并回复:Yes, makes sense. I added a meta_only parameter to the split_weight_chunks function rather. 最终以 meta_only 参数取代独立 helper,保证中继与 root 共享同一套分块逻辑。
  • nccl_checkpoint_engine.py 第 130 行附近提问:Should we enable multi_sender by default? 第二个提交将默认值设为 True,但类 docstring 与 PR 描述仍写"默认关闭",形成文档与实现不一致。
    其余 review 状态为 APPROVED,无遗留未解决线程。

实现拆解

  1. 元数据协议扩展verl/checkpoint_engine/nccl_checkpoint_engine.pyMasterMetadata 新增必填字段 multi_sender;新增 WorkerMetadata(node_id, master) 数据类,prepare() 的返回类型由 MasterMetadata 改为 WorkerMetadata,并新增静态方法 get_node_id() 通过 ray.get_runtime_context().get_node_id() 上报 Ray 节点 id(仅 rank 0 填充 master 字段)。由于 build_topology 是 driver 侧 classmethod,无法自行查询 worker 的放置位置,必须依赖这份上报的元数据做拓扑决策。
  2. 拓扑决策build_topology()master.multi_sender 分派到 _single_sender_ranks()(沿用上游 [0] + [-1] * (n - 1) 行为)或 _multi_sender_ranks()(rank 0 加同节点所有 actor,其余为 -1,组内 rank 连续编号)。随后计算 num_sendersworld_size = num_senders + rollout_world_size,发送者占据低 rank,并把 num_senders 随 kwargs 下发到每个 worker;当 rank 0 所在节点没有其他 actor 时自动退化为单发送者。
  3. 广播组初始化与中继分发init_process_group() 新增 num_senders 参数,rank < num_senders 为发送/中继方,rank >= num_senders 为消费方;顺带修复一处泄漏——原先所有 rank > 0 的 worker 都订阅 zmq bucket 元数据 topic,现在仅真正的消费方订阅。BroadcastOperation._run() 在非 root 端按 metadata["length"] 裁剪 bucket,依赖 #7107 的 bucket 尺寸修复。中继路径 _relay_weights() 与 root 走同一条分块逻辑(见第 4 点),逐 bucket 发起 NCCL broadcast 后丢弃负载。
  4. 分块边界一致性与死锁规避:中继端的分块边界必须本地推导而非从 wire 读取——产生每个 weight 本身是 actor 组内的集合通信,若在 wire 上阻塞会与 rank 0 的 gathers 死锁。为此 verl/checkpoint_engine/base.pysplit_weight_chunks() 新增 meta_only: bool = False 参数,复用同一分块逻辑产出边界而不物化 buffer(此前独立实现的 get_weight_chunks_size 被删除)。同时 root 的等待时机改为模式相关:单发送者保持原有 fill 与 send 重叠(入队下一个广播前等待),multi_sender 下 bucket 填满即等待,确保 root 与中继在广播组与 actor 组上的集合通信顺序一致,防止死锁。
  5. 配套适配与测试verl/checkpoint_engine/delta_checkpoint_engine.pyprepare() 适配新的 WorkerMetadata 返回类型,并强制 multi_sender=False(该引擎只有 rank 0 广播、无中继路径);tests/checkpoint_engine/test_correctness_on_gpu.pytest_nccl_checkpoint_engine 增加 multi_sender=[False, True] 参数化,覆盖单节点正确性(含 rebuild_group 两种取值)。
文件 模块 状态 重要度
verl/checkpoint_engine/nccl_checkpoint_engine.py 检查点引擎 modified 8.74
verl/checkpoint_engine/base.py 检查点引擎 modified 5.56
verl/checkpoint_engine/delta_checkpoint_engine.py 检查点引擎 modified 6.13
tests/checkpoint_engine/test_correctness_on_gpu.py GPU 测试 modified 3.87

关键符号

NCCLCheckpointEngine.prepare get_node_id _single_sender_ranks _multi_sender_ranks build_topology init_process_group _relay_weights BroadcastOperation._run split_weight_chunks DeltaShardedCheckpointEngine.prepare

关键源码片段

verl/checkpoint_engine/nccl_checkpoint_engine.py core-logic

本 PR 的核心实现文件:新增 WorkerMetadata 元数据协议、multi_sender 拓扑分派、中继路径与 num_senders 分组逻辑,全部权重同步加速与死锁规避都在此落地。

# verl/checkpoint_engine/nccl_checkpoint_engine.py
# multi_sender 拓扑决策与元数据上报的核心逻辑@staticmethod
def get_node_id() -> str:
    # 用 Ray 节点 id 代理 NVLink 可达性:同节点 GPU 必然 NVLink 互通,
    # 且一个节点不会跨越多个 NVLink 域,因此按节点匹配只会少选中继、不会多选。
    return ray.get_runtime_context().get_node_id()def prepare(self) -> WorkerMetadata:
    # master 侧用 cupy 缓冲,规避 `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True`
    # 下的显存注册错误;其余 worker 使用 torch CUDA 缓冲。
    if self.is_master:
        self.send_buf = cp.zeros(self.bucket_size, dtype=cp.uint8)
        self.recv_buf = cp.zeros(self.bucket_size, dtype=cp.uint8)
    else:
        self.send_buf = torch.zeros(self.bucket_size, dtype=torch.uint8, device="cuda")
        self.recv_buf = torch.zeros(self.bucket_size, dtype=torch.uint8, device="cuda")
​
    # 只有 rank 0 填写 master 端点;每个 worker 都上报 node_id,
    # 供 driver 侧 build_topology 做放置决策(driver 无法自行查询 worker 位置)。
    master = (
        MasterMetadata(zmq_ip=self.ip, zmq_port=self.listen_port, multi_sender=self.multi_sender)
        if self.is_master
        else None
    )
    return WorkerMetadata(node_id=self.get_node_id(), master=master)@staticmethod
def _multi_sender_ranks(actor_wg_world_size: int, metadata: list[WorkerMetadata]) -> list[int]:
    # rank 0 与其同节点 actor 一起加入广播组:对等卡经 NVLink 收下 bucket 后,
    # 再各自从自己的 NIC 转发出去,使单次广播同时驱动多张网卡。
    source_node = metadata[0].node_id
    # NCCL 组内 rank 必须连续,所以按遍历顺序为幸存者连续编号。
    ranks, next_rank = [], 0
    for i in range(actor_wg_world_size):
        if i == 0 or metadata[i].node_id == source_node:
            ranks.append(next_rank)
            next_rank += 1
        else:
            # 其它节点的 actor 需要从 fabric 拉整份数据却帮不上忙,直接排除(rank -1)。
            ranks.append(-1)
    return ranks@classmethod
def build_topology(cls, actor_wg_world_size: int, rollout_world_size: int, metadata: list[WorkerMetadata]):
    master = metadata[0].master
    assert master is not None, "actor rank 0 must be the checkpoint engine master"
​
    if master.multi_sender:
        actor_ranks = cls._multi_sender_ranks(actor_wg_world_size, metadata)
    else:
        actor_ranks = cls._single_sender_ranks(actor_wg_world_size)
​
    # rank 0 所在节点没有其它 actor 时,multi_sender 自动退化为单发送者。
    num_senders = sum(rank >= 0 for rank in actor_ranks)
    world_size = num_senders + rollout_world_size
    logger.info(
        f"build_topology: {num_senders} of {actor_wg_world_size} actor workers send, world_size {world_size}"
    )
​
    # 发送 / 中继方占据低 rank,消费方从 num_senders 开始编号;
    # num_senders 随 kwargs 下发到每个 worker,init_process_group 据此分组。
    actor_wg_kwargs = {
        "rank": actor_ranks,
        "world_size": [world_size] * actor_wg_world_size,
        "master_metadata": [master] * actor_wg_world_size,
        "num_senders": [num_senders] * actor_wg_world_size,
    }
    rollout_kwargs = {
        "rank": list(range(num_senders, world_size)),
        "world_size": [world_size] * rollout_world_size,
        "master_metadata": [master] * rollout_world_size,
        "num_senders": [num_senders] * rollout_world_size,
    }
    return actor_wg_kwargs, rollout_kwargs
verl/checkpoint_engine/base.py core-logic

split_weight_chunks 新增 meta_only 参数,是中继与 root 共享分块边界、避免广播永久挂起的 key 修复点,也是 review 讨论的落点。

# verl/checkpoint_engine/base.py
# meta_only 模式:中继与 root 必须用同一套分块逻辑推导边界,
# 否则边界分叉会让 NCCL 广播永久挂起(review 中 wuxibin89 指出的风险)。async def split_weight_chunks(
    weights: Generator[tuple[str, torch.Tensor], None, None],
    bucket_size: int,
    meta_only: bool = False,
) -> AsyncGenerator[tuple[TensorMeta, torch.Tensor | None], None]:
    """Split the weight into chunks.    Args:
        weights: The weights generator.
        bucket_size: Max bucket size in bytes.
        meta_only: 只产出边界元数据、不携带 buffer,供 relay 复用同一分块逻辑。    Yields:
        A tuple of the weight chunk metadata and the buffer.
    """
    async for name, weight in ensure_async_iterator(weights):
        buffer = weight.view(-1).view(torch.uint8)
        chunk_offset = 0
        while chunk_offset < weight.nbytes:
            chunk_size = min(bucket_size, weight.nbytes - chunk_offset)
            tensor_meta = TensorMeta(
                name=name,
                shape=weight.shape,
                dtype=weight.dtype,
                chunk_offset=chunk_offset,
                chunk_size=chunk_size,
                offset=None,
            )
            # 分块边界必须在本地推导而非从 wire 读取:产生每个 weight 本身是
            # actor 组内集合通信,若在 wire 上阻塞会与 rank 0 的 gathers 死锁。
            yield (tensor_meta, None if meta_only else buffer[chunk_offset : chunk_offset + chunk_size])
            chunk_offset += chunk_size

评论区精华

复用 split_weight_chunks 避免分块边界分叉导致广播挂起 正确性

wuxibin89 在 base.py 上针对新增的 get_weight_chunks_size 评论:"Can we reuse split_weight_chunks? If divergence happened, the broadcast may hang forever."

结论:parinayc20 采纳并回复:"Yes, makes sense. I added a meta_only parameter to the split_weight_chunks function rather." 最终以 meta_only 参数取代独立 helper,保证中继与 root 共享同一分块逻辑。 · 已解决

multi_sender 是否默认开启 设计

wuxibin89 在 nccl_checkpoint_engine.py 第 130 行附近提问:"Should we enable multi_sender by default?"

结论:第二个提交将默认值设为 True,但类 docstring 与 PR 描述仍写默认关闭,存在文档与实现不一致,属于遗留问题。 · 已解决

风险与影响

  1. 死锁与悬挂风险:中继与 root 共享 split_weight_chunks(meta_only=True) 的分块逻辑,若未来该函数被修改而 meta_only 路径与真实路径产生分叉,广播将永久挂起(reviewer 明确点出的风险);此外中继的 NCCL broadcast 与 actor 组的权重产生集合通信必须保持相同顺序,root 的等待时机是模式相关的,任何遗漏都会死锁。当前通过共享分块函数与模式相关等待规避,但缺少针对分叉的回归测试。
  2. 默认行为变更:最终代码中 multi_sender: bool = True 默认开启,而 PR body 写"Off by default; existing behavior is unchanged",类 docstring 也写"Defaults to False"。对现有 NCCL 后端用户是静默行为变更:同节点 actor 会加入广播组并产生额外 NCCL 流量(尽管不承担正确性负载)。
  3. 多节点扩展性受限:跨节点 trainer 时主要开销变为参数 gather 跨越 CXI fabric(约 25 GB/s),multi-sender 只对根节点有效,8 trainer(2 节点)配置下提速降至 2.25 倍,且只有 rank 0 所在节点贡献中继。
  4. 兼容性MasterMetadata 新增必填字段 multi_sender,外部直接构造该 dataclass 的代码会被破坏;prepare() 返回类型变化影响所有继承 NCCLCheckpointEngine 的引擎(delta 已适配);build_topology 现在显式 assert master is not None,非 rank 0 作为 master 的调用会直接失败。
  5. 测试覆盖不足test_correctness_on_gpu.py 仅单节点 GPU 正确性测试,未覆盖多节点 relay 场景、中继与 root 边界分叉的悬挂回归、以及 rebuild_groupmulti_sender 在真实多机环境下的交互。

性能影响:权重同步是每个训练步的临界路径,同节点配置下耗时降低约 2.8-2.9 倍(Qwen3.5-27B 从 5.03 s 到 1.79 s),双节点训练配置仍为 2.25 倍;rollout 侧横向扩展(4 到 12 rollout worker)无额外成本。用户影响:所有使用 backend="nccl" 的现有用户默认行为发生变化(新增同节点中继参与广播),并新增 multi_sender 配置项。系统影响:checkpoint 引擎的元数据协议升级(prepare() 返回 WorkerMetadata),zmq 订阅关系修正(只有消费方订阅 bucket 元数据),广播组 world_sizerollout_world_size + 1 变为 num_senders + rollout_world_size团队影响checkpoint_engine 子系统需要维护两套拓扑构建路径,并与 #7263 的竞争方案形成取舍对比,未来可能需要收敛。

默认行为变更 死锁与悬挂风险 文档与实现不一致 多节点测试覆盖不足

关联 Issue

#7107 [ckpt]: nccl broadcast bucket size fix

完整报告

参与讨论