Prhub

#50902 [rl] Stateful Trainer Send: NCCL + Sparse NCCL [3/N]

原始 PR 作者 hao-aaron 合并时间 2026-08-07 15:36 文件变更 14 提交数 4 评论 7 代码增减 +1418 / -779

执行摘要

NCCL/ 稀疏 NCCL 后端迁移至有状态 Trainer 引擎,完成三后端统一

PR body 明确说明这是 trainer 侧权重传输重构的第三次提交:前两次分别引入抽象(#48042)和完成 IPC 后端迁移(#48981),本 PR 目标是让所有已发布后端都通过 WeightTransferTrainerFactory.trainer_init(...).send_weights() 驱动 trainer 侧,并彻底移除旧静态路径。对用户而言,原示例中手工编排 init/metadata/start/update/finish 以及手写 threading.Thread 并发广播的样板代码,被折叠为单个 send_weights() 调用。Issue 评论中 aoshen02 提出 “We should update the docs.”,PR body 回应称文档重写(docs/training/weight_transfer/nccl.mdbase.mdREADME.md)将作为独立 follow-up 一并覆盖三个后端。

值得精读。核心价值在于:一是 trainer 侧并发模型(side thread 并发 update_weights 与广播 + future.done() 早期报错 + 异常时不 join 防止挂死)的工程取舍;二是 wire 参数单源化设计,让 trainer_init 通过 init 握手直接把 packed 等必须一致的参数下发 worker,从结构上消除两侧分叉;三是 _validate_patch 前置校验避免广播中途 size mismatch 卡死双方。建议与 #48042、#48981 连读,观察抽象如何在三个后端逐步落地;同时关注后续 follow-up 对文档与 worker ABC 的清理。

讨论亮点

review 评论为空(fork PR 自动审核被禁用),但提交历史与 issue 评论记录了关键讨论:

  • aoshen02 在 issue 评论中提出 “We should update the docs.”。PR body 承认 docs/training/weight_transfer/nccl.mdbase.mdREADME.md 仍记录本 PR 移除的静态 API(如同 ipc.md 仍记录 PR 2 移除的 IPCTrainerSendWeightsArgs),但 doc 重写将一次性覆盖三个后端,故作为独立 follow-up 而非半成品合入。此问题在合并时未闭合。
  • 提交 48b108b(“Harden the NCCL trainer engines”)记录了 review follow-up:WeightSource 的 metadata 与迭代契约被正式约束——worker 按 metadata() 定 buffer 大小、从迭代取字节,若 source 在两者间重排、遗漏或改变 dtype,两侧会分裂数据流导致 NCCL 挂死或模型脏数据;sender 现在逐对校验并报出第一个偏差。
  • 提交 fa48bfe(“Drain the packed consumer's streams before returning”)记录了消费端 drain 修复:这是 main 上已存在的问题,但 trainer 侧修复已在本分支完成,故同步修复 worker 侧以保证三个后端行为一致。

实现拆解

  1. 抽象层扩展(base.py / factory.py)TrainerWeightTransferEngine.__init__trainer_init 中的 source 参数改为 WeightSource | None = None,以支持不携带稳定 source 的 delta 后端;全量重同步后端(NCCL、IPC)在各自 trainer_init 中校验非空。WeightTransferTrainerFactoryipc 之外新增注册 ncclsparse_nccl(懒加载)。
  2. Dense NCCL 引擎迁移(nccl_engine.py / nccl_common.py):删除 NCCLTrainerSendWeightsArgs 与静态 NCCLWeightTransferEngine.trainer_send_weights,新增 NCCLTrainerInitInfo(含 backend = "nccl" ClassVar、master_address/master_port/world_size/rankpacked 系列 wire 参数)和有状态 NCCLTrainerWeightTransferEnginetrainer_init 在 side thread 上调用 client.init_weight_transfer_engine 下发 rank_offset=1 的 worker init info,同时本方以 rank 0 打开 trainer 端点完成 NCCL rendezvous;rank 0 持有 PyNcclCommunicator,在广播的同时并发执行推理侧 update_weights。worker 侧 NCCLWeightTransferEngine.init_transfer_engine 从握手 init info 记录 packed / packed_*selfreceive_weights 改读 self.packed,并按 PR 2 的模式将 per-round update info 精简为仅 names/dtype_names/shapes。非 sender rank 不建端点、不发 client RPC,仅迭代 WeightSource 保持 collective 对齐(如 FSDP full_tensor())。
  3. Sparse NCCL 引擎迁移(sparse_nccl_engine.py):新增 SparseNCCLTrainerInitInfobackend = "sparse_nccl",无 packed 参数)与 SparseNCCLTrainerWeightTransferEngine。稀疏 patch 每轮不同,不是稳定 WeightSource,因此引擎不接收 source,每轮 patch 经 send_weights(patches) 传入;空轮为 no-op。SparseWeightPatch 新增必填 full_shape 字段用于构造每轮 update info,_validate_patch 在发起任何 NCCL 调用前校验 int32 索引、1D 展平、长度匹配等不变量,_post_send_sync 在返回前同步当前 CUDA 流,便于调用方立即释放 patch 张量。单 rank trainer(TP=1/PP=1 MVP)范围内,非 sender 直接跳过 send_weights
  4. 并发与错误处理:两个 trainer 引擎都用单线程 ThreadPoolExecutor 执行 client.update_weights,与 NCCL 广播并发(两侧在同一批 NCCL 调用中 rendezvous);广播前做 future.done() 早期错误检查,避免请求被拒后仍阻塞在等不到 peer 的广播中。异常路径下 exe.shutdown(wait=False) 且绝不 join RPC 线程——广播已失败时 worker 仍阻塞在对应 NCCL 调用,join 会把错误变成永久挂起。packed_tensor.py 同步修复消费端 drain:packed_nccl_broadcast_consumer 原先只同步待复用 slot,receive_weights 返回时仍有 slot 在 side stream 上执行 load_weights,导致 finish_weight_updatefinalize_layerwise_reload 缺少顺序保障(main 上已存在的缺陷,本分支一并修复)。
  5. 示例与测试配套rlhf_nccl.pyrlhf_http_nccl.pyrlhf_async_new_apis.py 迁移为 trainer_init(...).send_weights() 单次调用;rlhf_nccl_fsdp_ep.py 改为独立 vllm serve HTTP server + 每个 FSDP rank 建引擎、rank 0 走线;rlhf_sparse_nccl.py 同时建 dense 与 sparse 两个引擎。测试方面,tests/distributed/test_weight_transfer.py 新增工厂注册表、init info 下发握手、worker 学习 wire 参数、非 sender 跳过、send_weights 顺序、稀疏空轮与 full_shape 校验等 GPU-free 单测(52 passed / 45 skipped on CPU-only host),并删除 test_packed_tensor.py 中针对已移除 packed 字段的过时用例。
文件 模块 状态 重要度
vllm/distributed/weight_transfer/nccl_engine.py 权重传输 modified 8.86
vllm/distributed/weight_transfer/sparse_nccl_engine.py 权重传输 modified 8.83
vllm/distributed/weight_transfer/nccl_common.py 权重传输 modified 6.65
vllm/distributed/weight_transfer/base.py 权重传输 modified 5.74
vllm/distributed/weight_transfer/factory.py 权重传输 modified 5.58
vllm/distributed/weight_transfer/packed_tensor.py 权重传输 modified 5.1
examples/rl/rlhf_nccl_fsdp_ep.py RL 示例 modified 8.49
examples/rl/rlhf_sparse_nccl.py RL 示例 modified 8.38
examples/rl/rlhf_nccl.py RL 示例 modified 7.87
tests/distributed/test_weight_transfer.py 测试套件 modified 7.52

关键符号

NCCLTrainerWeightTransferEngine.trainer_init NCCLTrainerWeightTransferEngine.send_weights SparseNCCLTrainerWeightTransferEngine.send_weights SparseNCCLTrainerWeightTransferEngine._validate_patch SparseNCCLTrainerWeightTransferEngine._post_send_sync NCCLWeightTransferEngine.init_transfer_engine NCCLWeightTransferEngine.receive_weights WeightTransferTrainerFactory.trainer_init NCCLRendezvous

关键源码片段

vllm/distributed/weight_transfer/nccl_engine.py core-logic

Dense NCCL 后端迁移核心:新增 NCCLTrainerInitInfo 与有状态 NCCLTrainerWeightTransferEngine,移除静态 NCCLTrainerSendWeightsArgs / trainer_send_weights,wire 参数移入 init info,worker 从握手学习 packed。

class NCCLTrainerWeightTransferEngine(TrainerWeightTransferEngine[NCCLTrainerInitInfo]):
    """Trainer 侧有状态 NCCL 权重传输引擎。    Rank 0 持有 NCCL communicator 并驱动完整更新轮次:
    在 side thread 上并发运行推理侧的 update_weights 与 trainer 侧广播
    (两侧在同一批 NCCL 调用中 rendezvous),随后 finish_weight_update。
    非 sender rank 不持有 communicator,只迭代 source 参与 collective。
    """
    init_info_cls = NCCLTrainerInitInfo
​
    @classmethod
    def trainer_init(
        cls,
        init_info: NCCLTrainerInitInfo,
        *,
        client: VLLMWeightSyncClient,
        source: WeightSource | None = None,
    ) -> Self:
        # 全量重同步后端必须携带稳定 WeightSource,缺失即拒绝。
        if source is None:
            raise ValueError("NCCL trainer weight transfer requires a WeightSource.")
        engine = cls(
            client=client,
            source=source,
            is_sender=init_info.is_sender,
            packed=init_info.packed,
            packed_buffer_size_bytes=init_info.packed_buffer_size_bytes,
            packed_num_buffers=init_info.packed_num_buffers,
        )
        if not engine.is_sender:
            # 非 sender trainer rank 不在传输 NCCL 组内,也不驱动推理侧;
            # 它们只在 send_weights 阶段的 trainer 侧 gather 中参与。
            return engine
​
        # 推理 worker 位于 rank_offset 1,即单个 trainer sender rank 0 之后。
        worker_init_info = NCCLWeightTransferInitInfo(
            master_address=init_info.master_address,
            master_port=init_info.master_port,
            rank_offset=1,
            world_size=init_info.world_size,
            packed=init_info.packed,
            packed_buffer_size_bytes=init_info.packed_buffer_size_bytes,
            packed_num_buffers=init_info.packed_num_buffers,
        )
​
        # 推理 worker 会阻塞在 init_weight_transfer_engine 等待 NCCL rendezvous,
        # 所以在 side thread 上先发起它,同时打开 trainer 端点(rank 0):
        # 两侧必须一起 rendezvous。
        with ThreadPoolExecutor(max_workers=1) as exe:
            future = exe.submit(
                engine.client.init_weight_transfer_engine, asdict(worker_init_info)
            )
            engine.model_update_group = open_trainer_endpoint(init_info)
            future.result() # 暴露推理侧初始化错误
        return engine
vllm/distributed/weight_transfer/sparse_nccl_engine.py core-logic

Sparse NCCL 后端迁移核心:新增 SparseNCCLTrainerInitInfo 与无 source 的 SparseNCCLTrainerWeightTransferEngine,SparseWeightPatch 增加 full_shape,send_weights(patches) 走每轮 delta 广播。

class SparseNCCLTrainerWeightTransferEngine(
    TrainerWeightTransferEngine[SparseNCCLTrainerInitInfo]
):
    """稀疏增量(delta)后端:稀疏 patch 每轮不同,不是稳定 WeightSource。    引擎不接收 source,每轮 patch 直接传给 send_weights(patches);
    空轮是 no-op。稀疏后端假设单 rank trainer(对应 TP=1 / PP=1 MVP)。
    """
​
    def send_weights(self, patches: Iterable[SparseWeightPatch] | None = None) -> None:
        """广播本轮稀疏 patch;每个 patch 必须设置 full_shape。"""
        if not self.is_sender:
            return
​
        patches = list(patches) if patches is not None else []
        if not patches:
            return
​
        shapes = []
        for patch in patches:
            # full_shape 随 update info 下发,是 trainer 引擎发送的硬性要求。
            if patch.full_shape is None:
                raise ValueError(
                    "SparseWeightPatch.full_shape must be set to send via the "
                    f"trainer engine: {patch.name}"
                )
            self._validate_patch(patch)
            shapes.append(list(patch.full_shape))
​
        update_info = SparseNCCLWeightTransferUpdateInfo(
            names=[patch.name for patch in patches],
            dtype_names=[str(patch.values.dtype).split(".")[-1] for patch in patches],
            shapes=shapes,
            num_updates_list=[patch.indices.numel() for patch in patches],
        )
​
        assert self.model_update_group is not None, (
            "trainer_init() must be called before send_weights()."
        )
        self.client.start_weight_update()
        # 推理侧 update_weights 必须与 trainer 侧广播并发:
        # 两者在同一批 NCCL 调用中 rendezvous。
        exe = ThreadPoolExecutor(max_workers=1)
        try:
            future = exe.submit(self.client.update_weights, asdict(update_info))
            # 尽早报错的尽力检查:若 update_weights 已在任何 NCCL 调用前失败
            # (例如坏请求被拒),立刻抛出,而不是在广播中等待一个
            # 永远不会出现的 peer 而挂死。
            if future.done():
                future.result()
            stream = torch.cuda.current_stream()
            for patch in patches:
                self.model_update_group.broadcast(patch.indices, src=0, stream=stream)
                self.model_update_group.broadcast(patch.values, src=0, stream=stream)
            future.result() # 暴露推理侧错误
        finally:
            # 绝不在 RPC 线程上 join:若广播已抛出异常,worker 仍阻塞在
            # 对应的 NCCL 调用中且永远不会返回,join 会把错误变成永久挂起。
            exe.shutdown(wait=False)
        self.client.finish_weight_update()
        self._post_send_sync()

评论区精华

文档更新需求 documentation

aoshen02 在 issue 评论中提出 “We should update the docs.”。PR body 承认 docs/training/weight_transfer/ 下的 nccl.md、base.md、README.md 仍记录已删除的静态 trainer_send_weights / NCCLTrainerSendWeightsArgs 路径,ipc.md 同样仍记录 PR 2 移除的 IPCTrainerSendWeightsArgs。

结论:文档重写将一次性覆盖全部三个后端,作为独立 follow-up 另行合入,本 PR 未包含。 · unresolved

WeightSource metadata/iteration 契约一致性 正确性

提交 48b108b 记录 review follow-up:worker 按 metadata() 决定 buffer 大小与 packed chunk 边界,但字节来自迭代,若 source 在两者间重排、遗漏或改变 dtype,两侧会分裂数据流,导致 NCCL 挂死或模型脏数据。

结论:sender 现在逐对校验 metadata 与迭代结果并报出第一个偏差,契约已被状态化强制。 · 已解决

packed consumer stream drain 正确性

提交 fa48bfe 指出 packed_nccl_broadcast_consumer 仅同步待复用 slot,receive_weights 返回时仍有 slot 在 side stream 上执行 load_weights,finish_weight_update 的 finalize_layerwise_reload 缺少顺序保障且 receive buffer 可能被提前释放;该问题在 main 上已存在,但 trainer 侧修复已在本分支完成。

结论:消费端返回前 drain 全部接收流,确保默认流上的 finalize 晚于 side stream 的 load_weights。 · 已解决

风险与影响

  1. 破坏性 API 迁移NCCLWeightTransferEngine.trainer_send_weightsNCCLTrainerSendWeightsArgsNCCLWeightTransferEngine.trainer_initSparseNCCLWeightTransferEngine.trainer_send_weights 全部移除,外部 RLHF/权重同步脚本必须迁移到 WeightTransferTrainerFactory,且文档尚未同步更新,迁移成本集中在 nccl_engine.py / sparse_nccl_engine.py 的使用方。
  2. 并发死锁风险send_weights 若在广播中途抛错,worker 会继续阻塞在对应 NCCL 调用,因此 finallyexe.shutdown(wait=False) 是有意为之;若 future.done() 的早期检查窗口错过,广播仍可能 hang。该模式同时出现在 NCCLTrainerWeightTransferEngineSparseNCCLTrainerWeightTransferEngine 两处。
  3. wire 参数单源化:packed 系列参数从 per-round update info 移入 init info,要求 trainer 与 worker 必须同版本握手;若新旧版本混跑,receive_weightsself.packed 与 trainer 编码不一致会导致数据流分裂。
  4. 稀疏 patch 契约SparseWeightPatch.full_shape 缺失即抛错;_validate_patch 与 worker 侧 _apply_patch 的校验重复,但前者在广播前拦截,确保失败留在 trainer 侧。稀疏后端仍是 TP=1/PP=1 MVP scope。
  5. 波及预存缺陷packed_tensor.py 的 drain 修复影响所有走 packed 路径的 worker(NCCL 与 IPC 共用),属于行为变更,需关注它是否改变已有 packed 传输的时序。

影响范围中等偏大且集中在特定使用群体:

  • 用户侧:所有使用 dense/sparse NCCL 权重同步的 RLHF 训练脚本(示例为主要入口)必须改用 WeightTransferTrainerFactory;原有手工 rendezvous + 线程编排被封装,API 面显著收窄。
  • 系统侧:三个后端(NCCL、IPC、sparse NCCL)在 trainer 侧统一为有状态引擎与单一 send_weights() 入口,后续可删除 worker ABC 中仅用于兼容的 trainer_send_weights 抽象成员;wire 参数由 trainer 单源下发,消除了两侧参数不一致的隐患。
  • 工程侧:14 个文件、+1418/-779,其中 5 个 RL 示例、2 个核心引擎、基础抽象与工厂、2 个测试文件联动;新增 400+ 行 GPU-free 单测,覆盖握手、顺序、非 sender 跳过等关键路径,降低回归风险。
  • 团队侧:3/N 系列收尾后,剩余 follow-up(文档重写、ABC 清理、Megatron WeightSource)均为可独立合入的小型变更。
核心路径变更 破坏性 API 移除 并发 / 死锁风险 文档未同步更新 修复波及预存缺陷

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论