执行摘要
- 一句话:NCCL/稀疏 NCCL 后端迁移至有状态 Trainer 引擎,完成三后端统一
- 推荐动作:值得精读。核心价值在于:一是 trainer 侧并发模型(side thread 并发
update_weights 与广播 + future.done() 早期报错 + 异常时不 join 防止挂死)的工程取舍;二是 wire 参数单源化设计,让 trainer_init 通过 init 握手直接把 packed 等必须一致的参数下发 worker,从结构上消除两侧分叉;三是 _validate_patch 前置校验避免广播中途 size mismatch 卡死双方。建议与 #48042、#48981 连读,观察抽象如何在三个后端逐步落地;同时关注后续 follow-up 对文档与 worker ABC 的清理。
功能与动机
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.md、base.md、README.md)将作为独立 follow-up 一并覆盖三个后端。
实现拆解
- 抽象层扩展(base.py / factory.py):
TrainerWeightTransferEngine.__init__ 与 trainer_init 中的 source 参数改为 WeightSource | None = None,以支持不携带稳定 source 的 delta 后端;全量重同步后端(NCCL、IPC)在各自 trainer_init 中校验非空。WeightTransferTrainerFactory 在 ipc 之外新增注册 nccl 与 sparse_nccl(懒加载)。
- Dense NCCL 引擎迁移(nccl_engine.py / nccl_common.py):删除
NCCLTrainerSendWeightsArgs 与静态 NCCLWeightTransferEngine.trainer_send_weights,新增 NCCLTrainerInitInfo(含 backend = "nccl" ClassVar、master_address/master_port/world_size/rank 与 packed 系列 wire 参数)和有状态 NCCLTrainerWeightTransferEngine。trainer_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_* 到 self,receive_weights 改读 self.packed,并按 PR 2 的模式将 per-round update info 精简为仅 names/dtype_names/shapes。非 sender rank 不建端点、不发 client RPC,仅迭代 WeightSource 保持 collective 对齐(如 FSDP full_tensor())。
- Sparse NCCL 引擎迁移(sparse_nccl_engine.py):新增
SparseNCCLTrainerInitInfo(backend = "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。
- 并发与错误处理:两个 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_update 的 finalize_layerwise_reload 缺少顺序保障(main 上已存在的缺陷,本分支一并修复)。
- 示例与测试配套:
rlhf_nccl.py、rlhf_http_nccl.py、rlhf_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(模块 权重传输;类别 source;类型 core-logic;符号 NCCLTrainerInitInfo, NCCLTrainerWeightTransferEngine, trainer_send_weights, receive_weights): Dense NCCL 后端迁移核心:新增 NCCLTrainerInitInfo 与有状态 NCCLTrainerWeightTransferEngine,移除静态 NCCLTrainerSendWeightsArgs / trainer_send_weights,wire 参数移入 init info,worker 从握手学习 packed。
vllm/distributed/weight_transfer/sparse_nccl_engine.py(模块 权重传输;类别 source;类型 core-logic;符号 SparseNCCLTrainerInitInfo, SparseNCCLTrainerWeightTransferEngine, SparseWeightPatch, send_weights): Sparse NCCL 后端迁移核心:新增 SparseNCCLTrainerInitInfo 与无 source 的 SparseNCCLTrainerWeightTransferEngine,SparseWeightPatch 增加 full_shape,send_weights(patches) 走每轮 delta 广播。
vllm/distributed/weight_transfer/nccl_common.py(模块 权重传输;类别 source;类型 core-logic;符号 NCCLRendezvous, NCCLWeightTransferInitInfo, trainer_init): 新增 NCCLRendezvous Protocol 使 trainer_init 摆脱具体 init info 类型依赖;NCCLWeightTransferInitInfo 增加 packed 系列 wire 参数供握手下发。
vllm/distributed/weight_transfer/base.py(模块 权重传输;类别 source;类型 core-logic;符号 TrainerWeightTransferEngine, trainer_init): TrainerWeightTransferEngine 与 trainer_init 的 source 参数改为可选,支撑 delta 后端;文档同步描述两种引擎形态。
vllm/distributed/weight_transfer/factory.py(模块 权重传输;类别 source;类型 core-logic;符号 WeightTransferTrainerFactory, trainer_init): WeightTransferTrainerFactory 注册 nccl 与 sparse_nccl 两个新后端,完成三后端统一分发。
vllm/distributed/weight_transfer/packed_tensor.py(模块 权重传输;类别 source;类型 core-logic;符号 packed_nccl_broadcast_consumer): 修复 packed consumer 未 drain 所有 slot 的预存缺陷,保证 finish_weight_update 的 finalize 与 side stream 的 load_weights 有序。
examples/rl/rlhf_nccl_fsdp_ep.py(模块 RL 示例;类别 source;类型 dependency-wiring;符号 setup_engine, gather_and_broadcast_weights, start_vllm_server, get_gpu_ids): 最复杂的示例迁移:从 AsyncLLMEngine 内嵌转为独立 vllm serve HTTP server,每个 FSDP rank 建引擎、仅 rank 0 走线,动态 GPU 划分避免与 Ray 集群冲突。
examples/rl/rlhf_sparse_nccl.py(模块 RL 示例;类别 source;类型 dependency-wiring;符号 init_dense_engine, send_dense_weights, init_sparse_engine, send_pending_sparse_patch): 演示 dense 与 sparse 双引擎共存:dense 用 ModuleSource 全量同步,sparse 每轮 patch 直传且必须携带 full_shape。
examples/rl/rlhf_nccl.py(模块 RL 示例;类别 source;类型 core-logic;符号 init_weight_transfer, broadcast_weights): 基础 Ray 示例迁移到 trainer_init + send_weights,手工元数据收集与 start/update/finish 编排全部删除。
tests/distributed/test_weight_transfer.py(模块 测试套件;类别 test;类型 test-coverage;符号 test_registry_has_all_backends, test_nccl_trainer_init_ships_worker_init_info, test_nccl_worker_learns_wire_params_from_init_handshake, test_nccl_trainer_init_non_sender_skips_rendezvous_and_client): 新增 400+ 行 GPU-free 单测:工厂注册表、NCCL trainer_init 下发 worker init info、worker 从握手学习 wire 参数、非 sender 跳过路径、send_weights 顺序、稀疏空轮与 full_shape 校验。
关键符号: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
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
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()
评论区精华
review 评论为空(fork PR 自动审核被禁用),但提交历史与 issue 评论记录了关键讨论:
- aoshen02 在 issue 评论中提出 “We should update the docs.”。PR body 承认
docs/training/weight_transfer/nccl.md、base.md、README.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 侧以保证三个后端行为一致。
-
文档更新需求 (documentation): 文档重写将一次性覆盖全部三个后端,作为独立 follow-up 另行合入,本 PR 未包含。
- WeightSource metadata/iteration 契约一致性 (correctness): sender 现在逐对校验 metadata 与迭代结果并报出第一个偏差,契约已被状态化强制。
- packed consumer stream drain (correctness): 消费端返回前 drain 全部接收流,确保默认流上的 finalize 晚于 side stream 的 load_weights。
风险与影响
- 风险:
- 破坏性 API 迁移:
NCCLWeightTransferEngine.trainer_send_weights、NCCLTrainerSendWeightsArgs、NCCLWeightTransferEngine.trainer_init、SparseNCCLWeightTransferEngine.trainer_send_weights 全部移除,外部 RLHF/权重同步脚本必须迁移到 WeightTransferTrainerFactory,且文档尚未同步更新,迁移成本集中在 nccl_engine.py / sparse_nccl_engine.py 的使用方。
- 并发死锁风险:
send_weights 若在广播中途抛错,worker 会继续阻塞在对应 NCCL 调用,因此 finally 中 exe.shutdown(wait=False) 是有意为之;若 future.done() 的早期检查窗口错过,广播仍可能 hang。该模式同时出现在 NCCLTrainerWeightTransferEngine 与 SparseNCCLTrainerWeightTransferEngine 两处。
- wire 参数单源化:packed 系列参数从 per-round update info 移入 init info,要求 trainer 与 worker 必须同版本握手;若新旧版本混跑,
receive_weights 读 self.packed 与 trainer 编码不一致会导致数据流分裂。
- 稀疏 patch 契约:
SparseWeightPatch.full_shape 缺失即抛错;_validate_patch 与 worker 侧 _apply_patch 的校验重复,但前者在广播前拦截,确保失败留在 trainer 侧。稀疏后端仍是 TP=1/PP=1 MVP scope。
- 波及预存缺陷:
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 移除, 并发/死锁风险, 文档未同步更新, 修复波及预存缺陷
关联脉络
- PR #48042 [Feat] Trainer-side weight-transfer abstractions [1/N]: 系列第一环:引入 WeightSource / ModuleSource、VLLMWeightSyncClient、TrainerWeightTransferEngine、WeightTransferTrainerFactory,本 PR 在其上完成 NCCL 后端落地。
- PR #48981 [Feat] Stateful Trainer Send: IPC [2/N]: 系列第二环:IPC 后端端到端迁移;本 PR 沿用其 wire params 走 init info、update-info 精简、client 抽象等模式。
参与讨论