执行摘要
- 一句话:NCCL 引擎新增多发送者中继模式,权重同步提速约 3 倍
- 推荐动作:值得精读。核心看点:用 Ray 节点 id 作为 NVLink 可达性代理的拓扑决策、中继本地推导分块边界以避免集合通信死锁的设计、以及 root 等待时机与模式耦合的细节。建议重点关注
_multi_sender_ranks 与 build_topology 的成员判定逻辑、split_weight_chunks(meta_only=True) 的复用方案,以及 review 中关于"边界分叉会导致广播永久挂起"的讨论。合并后建议跟进两件事:修正 multi_sender 默认值与 docstring、PR 描述不一致的问题;补充多节点与死锁回归测试。
功能与动机
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 的前置依赖。
实现拆解
- 元数据协议扩展:
verl/checkpoint_engine/nccl_checkpoint_engine.py 中 MasterMetadata 新增必填字段 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 的放置位置,必须依赖这份上报的元数据做拓扑决策。
- 拓扑决策:
build_topology() 按 master.multi_sender 分派到 _single_sender_ranks()(沿用上游 [0] + [-1] * (n - 1) 行为)或 _multi_sender_ranks()(rank 0 加同节点所有 actor,其余为 -1,组内 rank 连续编号)。随后计算 num_senders 与 world_size = num_senders + rollout_world_size,发送者占据低 rank,并把 num_senders 随 kwargs 下发到每个 worker;当 rank 0 所在节点没有其他 actor 时自动退化为单发送者。
- 广播组初始化与中继分发:
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 后丢弃负载。
- 分块边界一致性与死锁规避:中继端的分块边界必须本地推导而非从 wire 读取——产生每个 weight 本身是 actor 组内的集合通信,若在 wire 上阻塞会与 rank 0 的 gathers 死锁。为此
verl/checkpoint_engine/base.py 的 split_weight_chunks() 新增 meta_only: bool = False 参数,复用同一分块逻辑产出边界而不物化 buffer(此前独立实现的 get_weight_chunks_size 被删除)。同时 root 的等待时机改为模式相关:单发送者保持原有 fill 与 send 重叠(入队下一个广播前等待),multi_sender 下 bucket 填满即等待,确保 root 与中继在广播组与 actor 组上的集合通信顺序一致,防止死锁。
- 配套适配与测试:
verl/checkpoint_engine/delta_checkpoint_engine.py 的 prepare() 适配新的 WorkerMetadata 返回类型,并强制 multi_sender=False(该引擎只有 rank 0 广播、无中继路径);tests/checkpoint_engine/test_correctness_on_gpu.py 为 test_nccl_checkpoint_engine 增加 multi_sender=[False, True] 参数化,覆盖单节点正确性(含 rebuild_group 两种取值)。
关键文件:
verl/checkpoint_engine/nccl_checkpoint_engine.py(模块 检查点引擎;类别 source;类型 core-logic;符号 WorkerMetadata, prepare, get_node_id, _single_sender_ranks): 本 PR 的核心实现文件:新增 WorkerMetadata 元数据协议、multi_sender 拓扑分派、中继路径与 num_senders 分组逻辑,全部权重同步加速与死锁规避都在此落地。
verl/checkpoint_engine/base.py(模块 检查点引擎;类别 source;类型 core-logic;符号 split_weight_chunks): split_weight_chunks 新增 meta_only 参数,是中继与 root 共享分块边界、避免广播永久挂起的 key 修复点,也是 review 讨论的落点。
verl/checkpoint_engine/delta_checkpoint_engine.py(模块 检查点引擎;类别 source;类型 core-logic;符号 prepare): prepare() 需要适配新的 WorkerMetadata 返回协议,并强制 multi_sender=False 保持单发送者语义,否则会破坏 delta_sharded 后端的拓扑构建。
tests/checkpoint_engine/test_correctness_on_gpu.py(模块 GPU 测试;类别 test;类型 test-coverage): 为 test_nccl_checkpoint_engine 增加 multi_sender=[False, True] 参数化,验证两种模式下的权重一致性,是唯一的自动化验证。
关键符号: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
本 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
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
评论区精华
两条核心讨论均来自 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,无遗留未解决线程。
-
复用 split_weight_chunks 避免分块边界分叉导致广播挂起 (correctness): parinayc20 采纳并回复:"Yes, makes sense. I added a meta_only parameter to the split_weight_chunks function rather." 最终以 meta_only 参数取代独立 helper,保证中继与 root 共享同一分块逻辑。
- multi_sender 是否默认开启 (design): 第二个提交将默认值设为 True,但类 docstring 与 PR 描述仍写默认关闭,存在文档与实现不一致,属于遗留问题。
风险与影响
- 风险:
- 死锁与悬挂风险:中继与 root 共享
split_weight_chunks(meta_only=True) 的分块逻辑,若未来该函数被修改而 meta_only 路径与真实路径产生分叉,广播将永久挂起(reviewer 明确点出的风险);此外中继的 NCCL broadcast 与 actor 组的权重产生集合通信必须保持相同顺序,root 的等待时机是模式相关的,任何遗漏都会死锁。当前通过共享分块函数与模式相关等待规避,但缺少针对分叉的回归测试。
- 默认行为变更:最终代码中
multi_sender: bool = True 默认开启,而 PR body 写"Off by default; existing behavior is unchanged",类 docstring 也写"Defaults to False"。对现有 NCCL 后端用户是静默行为变更:同节点 actor 会加入广播组并产生额外 NCCL 流量(尽管不承担正确性负载)。
- 多节点扩展性受限:跨节点 trainer 时主要开销变为参数 gather 跨越 CXI fabric(约 25 GB/s),multi-sender 只对根节点有效,8 trainer(2 节点)配置下提速降至 2.25 倍,且只有 rank 0 所在节点贡献中继。
- 兼容性:
MasterMetadata 新增必填字段 multi_sender,外部直接构造该 dataclass 的代码会被破坏;prepare() 返回类型变化影响所有继承 NCCLCheckpointEngine 的引擎(delta 已适配);build_topology 现在显式 assert master is not None,非 rank 0 作为 master 的调用会直接失败。
- 测试覆盖不足:
test_correctness_on_gpu.py 仅单节点 GPU 正确性测试,未覆盖多节点 relay 场景、中继与 root 边界分叉的悬挂回归、以及 rebuild_group 与 multi_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_size 从 rollout_world_size + 1 变为 num_senders + rollout_world_size。团队影响:checkpoint_engine 子系统需要维护两套拓扑构建路径,并与 #7263 的竞争方案形成取舍对比,未来可能需要收敛。
- 风险标记:默认行为变更, 死锁与悬挂风险, 文档与实现不一致, 多节点测试覆盖不足
关联脉络
- PR #7107 [ckpt]: nccl broadcast bucket size fix: 前置依赖:本 PR 的 BroadcastOperation 按 metadata["length"] 裁剪 bucket、中继按 root 实际长度广播,均依赖 #7107 的 bucket 尺寸修复;PR body 明确说明 "This PR is rebased on / assumes #7107"。
- PR #7263 nccl_parallel(竞争方案,标题未在提供材料中): PR body 指明 #7263 通过 S 个独立 NCCL 组分发 bucket 序列 stripe 解决同一问题(#7167),与本 PR 的单组多发送者中继互为替代方案,作者明确说明两者是 alternatives。
参与讨论