[Feature] Add Checkpoint Engine as a transport between train and rollout - #1993
[Feature] Add Checkpoint Engine as a transport between train and rollout#1993PengchengShi00 wants to merge 9 commits into
Conversation
| self._set_transport() | ||
|
|
||
| def update_weights(self): | ||
| def update_weights(self, need_register: bool = True, need_update: bool = True) -> None: |
There was a problem hiding this comment.
训练侧 ep_size > 1 时,专家权重可能被错误切分并绑定到错误的 HF key。xtuner/v1/rl/weight_update/weight_iterator.py:193 只有 NCCL 会 gather train-EP tensor;新加入的 checkpoint_engine 不会走这个逻辑,应该是不对的
There was a problem hiding this comment.
是的,checkpoint-engine 应该始终要走gather_train_ep_shards
| """Register current train engine weights into Checkpoint Engine PS.""" | ||
|
|
||
| # 1. Collect named tensors from weight iterator | ||
| all_tensors = self._collect_named_tensors(weight_iterator, local_keys=self._local_checkpoint_keys) |
There was a problem hiding this comment.
这个地方有比较大的显存峰值吧?要想想这个地方会不会成为 oom 瓶颈点。ce 默认这个地方就是这么做的吗?
There was a problem hiding this comment.
是的,每张卡会有checkpoint shard的显存增加,但是这部分可以放到CPU上(但不建议这么做,注册时还会有额外的H2H 操作,到pinning memory的copy)。checkpoint shard的大小不会高于rollout weight,因此这里的显存峰值也不会高于IPC的峰值
209f777 to
5210e33
Compare
5210e33 to
f21f20b
Compare
f21f20b to
d4498f3
Compare
|
@claude review |
| return original_update_weights(*args, **kwargs) | ||
| return original_weight_update(*args, **kwargs) | ||
|
|
||
| trainer.train_controller.update_weights = update_weights_wrapper |
There was a problem hiding this comment.
Claude: [Critical] rename 漏改,这个 GPU 测试的断言会失效。
第 347 行取的是新名字 weight_update,但这里仍然把 wrapper 赋给旧名字 update_weights,而 TrainingController 在本 PR 中已经没有 update_weights 了。结果是 wrapper 永远不会被调用,self.update_weight_calls 恒为 0,test_qwen35_vl_moe_async_train_2step.py#L418 的 assertEqual(self.update_weight_calls, TOTAL_TRAIN_STEPS - 1) 必然失败。
| trainer.train_controller.update_weights = update_weights_wrapper | |
| trainer.train_controller.weight_update = update_weights_wrapper |
顺带:同类漏改还有 tests/rl/test_rl_colocate_trainer.py#L166(fake controller 仍暴露 update_weights=,因为该测试 mock 掉了 _sync_weights_and_save 才没暴露问题)和 docs/design/checkpoint-engine.md#L40-L43(新文档写的还是 update_weights(...))。
| missing = self._local_checkpoint_keys - all_tensors.keys() | ||
| if missing: | ||
| missing_mtp_keys = {key for key in missing if key.startswith("mtp.")} | ||
| missing_non_mtp_keys = missing - missing_mtp_keys | ||
| if missing_non_mtp_keys: | ||
| self.logger.error( | ||
| f"[checkpoint_engine] ParameterServer Rank={self.rank} Missing non-MTP keys: {missing_non_mtp_keys}" | ||
| ) | ||
| else: | ||
| self.logger.error( | ||
| f"[checkpoint_engine] ParameterServer Rank={self.rank} Missing MTP-only keys: {missing_mtp_keys}" | ||
| ) | ||
| shard = {k: all_tensors[k] for k in self._local_checkpoint_keys if k in all_tensors} |
There was a problem hiding this comment.
Claude: [Critical] 权重缺失只打日志、不中断,会造成静默的错误权重。
missing 非空意味着这一轮注册的 checkpoint 缺少参数,ParameterServer 仍会照常注册并推送,rollout engine 上这些参数保持上一轮(或初始化)的值 —— 训练/推理权重不一致,而且只有一行 error log。RL 里这种静默不一致极难定位,建议 fail fast,只对已知可忽略的前缀(MTP)放行:
missing_mtp = {k for k in missing if k.startswith("mtp.")}
if missing - missing_mtp:
raise RuntimeError(
f"[checkpoint_engine] rank={self.rank} missing keys: {sorted(missing - missing_mtp)}"
)
if missing_mtp:
self.logger.warning(f"[checkpoint_engine] rank={self.rank} skip MTP-only keys: {sorted(missing_mtp)}")另外两个相关问题:
- 当前
if missing_non_mtp_keys: ... else: ...在「同时缺 MTP 和非 MTP」时只打印非 MTP,MTP 那部分信息丢了。 - 反向缺失完全没有检查:
local_keys来自rollout_config.model_path的 HF index,如果 train engine 导出的某个 key 不在 index 里(tied lm_head、fused expert 命名差异等),它会被每个 rank 过滤掉,永远不会推给 rollout,且没有任何日志。建议在 filter 之前先统计一次本 rank 见到的全部 key,与 HF index 全集做差集校验:
seen_all_keys |= set(sd.keys()) # filter 之前
...
extra = seen_all_keys - hf_index_keys # 任何 rank 都不会注册的 key
if extra:
raise RuntimeError(f"train engine exports keys absent from HF index: {sorted(extra)}")There was a problem hiding this comment.
什么情况下会缺失呢?如果缺失的话,是不是下一次rollout的结果就一定不对了
|
|
||
| class CheckpointEngineAdapter: |
There was a problem hiding this comment.
Claude: [Warning] ruff lint 会失败:类定义前只有 1 个空行,E302 expected 2 blank lines, found 1。pre-commit 覆盖 xtuner/v1/,请跑一次 pre-commit run --all-files。
| class CheckpointEngineAdapter: | |
| class CheckpointEngineAdapter: |
另外从抽象上看,CheckpointEngineAdapter 目前只有一个 build_update_url() 一行拼串,且没有实现 WeightTransportAdapter Protocol(没有 before_update / after_update_all_groups),构造时又立刻做了 if adapter is None: raise 这种不可能发生的检查(transport.py:1034)。按 Deletion test:删掉它,复杂度不会散落到多个调用者,只是把一行 f-string 内联回 _make_req_func。它现在是一个纯转发的 Shallow Module,一个 Adapter 也不构成真实 Seam;建议内联,等真的出现第二个 backend(LMDeploy)时再抽。
| os.environ["NCCL_IB_HCA"] = "mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_4,mlx5_5,mlx5_6,mlx5_7" | ||
| os.environ["PS_P2P_STORE_RDMA_DEVICES"] = "mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_4,mlx5_5,mlx5_6,mlx5_7" | ||
|
|
||
| self.ps_world_size = int(os.environ.get("WORLD_SIZE", dist.get_world_size())) |
There was a problem hiding this comment.
Claude: [Warning] WORLD_SIZE 语义在本仓库里是二义的,这里读环境变量既多余又危险,并且让下面的 assert 变成死代码。
- train worker 里
WORLD_SIZE由ray_accelerator_worker.py#L245在init_process_group之前设为 train world size,所以目前等价于dist.get_world_size();但在 driver / launcher 侧WORLD_SIZE一律表示 节点数(examples/v1/scripts/run_rl.sh、各 config 里的NNODE = int(os.environ.get("WORLD_SIZE", "1")),本 PR 新增的tests/rl/test_update_weight_colocate.py:75也是这个含义)。一旦这个 transport 被在非 actor 进程里构造,ps_world_size会静默变成 nnodes,进而污染split_tensors_for_rank的分片、_get_target_update_ranks的越界校验、broadcast/p2p 的判定以及ParameterServer(world_size=...),表现为错分片或 hang 而不是清晰报错。 dist.get_world_size()作为os.environ.get的默认值是立即求值的,PG 未初始化时会先在这一行抛ValueError: Default process group has not been initialized,第 901 行那条友好的 assert 永远走不到。
| self.ps_world_size = int(os.environ.get("WORLD_SIZE", dist.get_world_size())) | |
| assert dist.is_initialized(), ( | |
| "Checkpoint Engine requires an initialized torch.distributed process group." | |
| ) | |
| self.ps_world_size = dist.get_world_size() |
(对应地把 901-903 的 assert 精简掉 ps_world_size > 0 这半个恒真条件。)
| def _collect_named_tensors(self, weight_iterator, local_keys=None): | ||
| """Collect all train weights from the iterator onto CPU.""" | ||
| named = {} | ||
| named_total_bytes = 0 | ||
| for batches in weight_iterator.iter_batch_groups(): | ||
| for batch in batches: | ||
| sd = batch.state_dict | ||
| if not sd: | ||
| continue | ||
| # batch 级快路径:完全无交集可跳过 | ||
| if local_keys is not None and sd.keys().isdisjoint(local_keys): | ||
| sd.clear() | ||
| del sd, batch | ||
| DEVICE_MODULE.empty_cache() | ||
| continue | ||
| for key, tensor in list(sd.items()): | ||
| if local_keys is not None and key not in local_keys: | ||
| sd.pop(key) | ||
| del tensor | ||
| continue | ||
| # 占显存更少,但速度慢 | ||
| # named[key] = tensor.detach().to("cpu", non_blocking=True) | ||
| # 占显存多,速度快 | ||
| named[key] = tensor | ||
| named_total_bytes += named[key].numel() * named[key].element_size() | ||
| sd.clear() | ||
| del sd, batch | ||
| DEVICE_MODULE.empty_cache() | ||
| DEVICE_MODULE.empty_cache() | ||
| if local_keys is not None: | ||
| self.logger.info( | ||
| f"[checkpoint_engine] collect matched local keys rank={self.rank} " | ||
| f"parameter server shard total={named_total_bytes / 1024**3:.3f}GiB " | ||
| ) | ||
| return named |
There was a problem hiding this comment.
Claude: [Warning] 这个函数有几处需要收拾(性能 + 可读性 + 契约):
- docstring 与实现相反:写的是 "onto CPU",实际
named[key] = tensor保留的是 GPU tensor(下面注释也写了「占显存多,速度快」)。这直接决定了 register 阶段的显存峰值,是调用者必须知道的 Interface 信息,请改成准确描述。 - 热路径上的
empty_cache():每个 batch 调 1 次、每个 batch group 再调 1 次。empty_cache()会同步并归还所有 cache block,后续分配要重新走 cudaMalloc,几十~上百个 bucket 累加下来开销可观(PR 里 CE 比 IPC 慢 2-3x,这里可能是一部分原因)。建议只在 group 边界调用,或按checkpoint_engine_sync_after_register一样做成可配置。 - 注释掉的备选实现(967-969 行)请删掉,两种策略的选择应该由
checkpoint_engine_sync_after_register这类配置表达,而不是留在代码里让人手改。 - 缺类型标注(CLAUDE.md 要求新代码必须有 type hints):
def _collect_named_tensors(self, weight_iterator: WeightIterator, local_keys: set[str] | None = None) -> dict[str, torch.Tensor]:。 - 建议加一次连续性保证:checkpoint-engine 的
_register_checkpoint内部是buffer[off:off+nbytes] = tensor.view(-1).view(dtype=torch.uint8),非 contiguous 会直接抛错。这里加tensor = tensor.contiguous()(或 assert)可以把失败点提前到 XTuner 侧,报错信息更可定位。 - 中文注释和文件里其余英文注释风格不一致,建议统一为英文。
| os.environ["NCCL_IB_HCA"] = "mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_4,mlx5_5,mlx5_6,mlx5_7" | ||
| os.environ["PS_P2P_STORE_RDMA_DEVICES"] = "mlx5_0,mlx5_1,mlx5_2,mlx5_3,mlx5_4,mlx5_5,mlx5_6,mlx5_7" |
There was a problem hiding this comment.
Claude: [Critical] 库代码里无条件覆盖集群级环境变量,会破坏用户环境并在非 Mellanox / 非 8 卡 RDMA 机器上直接失败。
两点问题:
NCCL_IB_HCA是全局 NCCL 配置,这里在dist.init_process_group()之后(transport 是在bind_rollout_weight_update里才构造的)无条件覆盖用户设置。对已建立的默认 PG 无效,但会影响 checkpoint-engine 之后新建的通信域,且静默丢弃用户显式配置。- checkpoint-engine 的
_get_rdma_devices()优先读PS_P2P_STORE_RDMA_DEVICES,取不到设备时_get_my_rdma_device会raise RuntimeError("no rdma devices found"),并且ParameterServer.__init__里只 catch 了ImportError。所以在 RDMA 设备名不是mlx5_0..7(或没有 RDMA 网卡)的机器上,这两行会把一个「能 broadcast 跑通」的场景变成构造期崩溃。
这属于把部署环境细节硬编码进 Implementation,调用者无法覆盖。建议交回给环境/配置,代码只做「未设置时不干预」:
# 不要覆盖;需要时由 RolloutConfig 显式提供
if rollout_config.checkpoint_engine_rdma_devices:
os.environ.setdefault("PS_P2P_STORE_RDMA_DEVICES", rollout_config.checkpoint_engine_rdma_devices)(同样的硬编码也出现在 tests/rl/test_update_weight_colocate.py:42-43,测试里设置是可以接受的,库里不行。)
Checkpoint Engine 权重同步
1. 背景
XTuner RL 训练需要周期性把 train engine 中的最新权重同步到 rollout engines。现有权重同步路径包括:
ParameterServer,再由ParameterServer推送给 rollout engines。Checkpoint Engine 路径的收益:
ParameterServer,再 offload train engine、onload rollout engine,最后由ParameterServer推送权重。当前实现只在共卡训练路径中启用 Checkpoint Engine。分离模式检测到
enable_checkpoint_engine=True时会打印 warning,并回退到 NCCL weight transport。2. 架构设计
共卡模式下,在 rollout config 中设置
enable_checkpoint_engine=True,可使用 Checkpoint-Engine 进行权重更新, 分离模式下即使设置enable_checkpoint_engine=True,也会回退到 NCCL transport。2.1 初始化
ParameterServer训练 worker 内部创建
ParameterServer,读取 train world size,作为ParameterServerworld size,不额外启动独立 PS 进程。auto_pg=False表示 Checkpoint Engine 复用 XTuner 已经初始化好的torch.distributed默认进程组,不在 update 后销毁该进程组。根据 HF checkpoint 的
model.safetensors.index.json计算当前 PS-rank 负责的参数 key 集合。每个 PS rank 只负责注册自己分到的参数 shard。这些 key 后续用于从WeightIterator中过滤当前 PS rank 需要注册的 tensor,避免每个 rank 都注册全量参数。2.2 更新
CheckpointEngineWeightTransport.update(...)被拆成两个可选阶段:当
need_register=True时,transport 会:weight_iterator.iter_batch_groups()收集 train engine 当前权重。ParameterServer.register_checkpoint(...)注册新 checkpoint:如果 HF index 中的某些 key 没有从 train engine 收集到,transport 会记录错误日志。MTP-only key 和非 MTP key 会分开打印,便于排查模型结构差异。
当
need_update=True时,transport 会把当前 checkpoint 推送到 rollout engines:rollout_info.active_update_targets获取当前活跃 rollout targets。update_ranks非空、不越界、不重复。ParameterServer.update(..., ranks=None),使用 Checkpoint Engine broadcast 路径。ParameterServer.update(..., ranks=active_ranks),使用 p2p update 路径。need_register和need_updateneed_register和need_update用于控制 Checkpoint Engine 更新的两个阶段。TrueFalseneed_registerneed_update典型使用方式:
need_update=False的核心用途是把注册和 rollout 更新拆成两个时刻执行。这样训练侧可以先把权重注册到ParameterServer,然后释放或切换部分资源,再让 rollout engines 加载 checkpoint,从而降低峰值显存压力。3. 共卡模式 IPC / Checkpoint Engine 速度对比
使用两种更新路径的端到端权重更新时间,包括了
rollout onload_weights、train offload时间。