执行摘要
- 一句话:异步写入 JSONL 并传播 I/O 异常,释放训练循环
- 推荐动作:值得精读。展示了在不影响训练精度的前提下,将 I/O 异步化并正确管理资源与异常传播的实践。
_shutdown_dump_executor 的设计(drain 所有 future 再关闭)可作为后台任务清理的标准模式。适合关注训练性能和数据可靠性的工程师。
功能与动机
Issue #6338 指出当前同步 dump 在 main 线程执行 I/O,阻塞 rollout 步骤;I/O 异常被静默忽略;"\n".join(lines) 造成高内存峰值。预期行为是异步写入、异常尽早暴露、逐行写入降低内存。
实现拆解
-
提取静态写入方法:将原 _dump_generations 中的文件写入逻辑抽出为 @staticmethod _write_generations(不捕获 self),确保后台线程安全。涉及文件 verl/trainer/main_ppo_sync.py 和 verl/trainer/ppo/ray_trainer.py。
-
初始化线程池:新增 _init_dump_executor,创建 ThreadPoolExecutor(max_workers=1) 并清空 futures 列表。在 __init__ 中调用(两个训练器均新增此方法及调用处)。
-
异步提交与异常传播:_dump_generations 改为提交任务到 executor 并记录 future;每次调用时遍历已完成的 future,调用 .result() 重抛 I/O 异常。_dump_generations 签名不变,保持调用方透明。
-
清理与关闭:新增 _shutdown_dump_executor,drain 所有 pending 的 future(.result())后 shutdown(wait=True)。在 fit() 的三个退出点(val_only、is_last_step、数据加载器耗尽)均调用此方法,避免资源泄漏和数据丢失。
-
配套修复:
- 修复
ray_trainer.py 中 _log_rollout_data 的 bug,将 request_id 写入 reward_extra_infos_to_dump 而非 reward_extra_infos_dict。
- 在
main_ppo_sync.py 中用 hasattr(obj, "tolist") 替换 isinstance(v, np.ndarray),使 torch.Tensor 等对象也能正确序列化。
- 逐行写入替代一次性 join,降低内存峰值。
关键文件:
verl/trainer/main_ppo_sync.py(模块 训练器;类别 source;类型 core-logic;符号 _dump_generations, _write_generations, _init_dump_executor, _shutdown_dump_executor): 主要的同步 PPO 训练器,dump 逻辑完全重构为异步,影响所有使用该训练器的实验。
verl/trainer/ppo/ray_trainer.py(模块 训练器;类别 source;类型 core-logic;符号 _dump_generations, _write_generations, _init_dump_executor, _shutdown_dump_executor): Ray PPO 训练器同样受益于异步 dump,但按维护者说明即将被 main_ppo_sync.py 取代,因此修改是过渡性同步。
关键符号:_dump_generations, _write_generations, _init_dump_executor, _shutdown_dump_executor, _log_rollout_data
关键源码片段
verl/trainer/main_ppo_sync.py
主要的同步 PPO 训练器,dump 逻辑完全重构为异步,影响所有使用该训练器的实验。
# 后台写入方法(静态,不持有 self,线程安全)
@staticmethod
def _write_generations(inputs, outputs, gts, scores, reward_extra_infos_dict,
dump_path, global_steps):
"""Write generation samples as JSONL (runs in background thread)."""
os.makedirs(dump_path, exist_ok=True)
filename = os.path.join(dump_path, f"{global_steps}.jsonl")
n = len(inputs)
base_data = {
"input": inputs,
"output": outputs,
"gts": gts,
"score": scores,
"step": [global_steps] * n,
}
for k, v in reward_extra_infos_dict.items():
if len(v) == n:
base_data[k] = v
# 自定义 JSON 序列化,支持 numpy 和 torch.Tensor
def json_encode_default(obj):
if isinstance(obj, (np.integer,)):
return int(obj)
elif isinstance(obj, (np.floating,)):
return float(obj)
elif isinstance(obj, np.bool_):
return bool(obj)
elif hasattr(obj, "tolist"):
return obj.tolist()
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
# 逐行写入,避免大字符串占用峰值内存
with open(filename, "w") as f:
for i in range(n):
entry = {k: v[i] for k, v in base_data.items()}
f.write(json.dumps(entry, ensure_ascii=False,
default=json_encode_default) + "\n")
print(f"Dumped generations to {filename}")
# 异步提交方法(主线程调用)
def _dump_generations(self, inputs, outputs, gts, scores,
reward_extra_infos_dict, dump_path):
"""Dump rollout/validation samples as JSONL asynchronously."""
global_steps = self.global_steps
# 后台提交写入任务
future = self._dump_executor.submit(
self._write_generations,
inputs, outputs, gts, scores,
reward_extra_infos_dict,
dump_path,
global_steps,
)
self._dump_futures.append(future)
# 清理已完成的 future,并暴露异常
still_pending = []
for f in self._dump_futures:
if f.done():
f.result() # 若失败则在此处抛出异常
else:
still_pending.append(f)
self._dump_futures = still_pending
def _init_dump_executor(self):
"""初始化线程池和 futures 列表(在 __init__ 中调用)。"""
self._dump_executor = ThreadPoolExecutor(max_workers=1)
self._dump_futures = []
def _shutdown_dump_executor(self):
"""drain 所有 pending future,然后关闭线程池。"""
for f in self._dump_futures:
f.result() # 确保所有写操作完成且无异常
self._dump_futures.clear()
self._dump_executor.shutdown(wait=True)
评论区精华
- 资源泄漏与数据丢失风险:Bot 审查指出
_dump_executor 的关闭仅放在 is_last_step 块中,若 fit() 提前退出(val_only、异常或数据加载器耗尽)则 executor 未被关闭,可能导致资源泄漏和数据丢失。后续提交提取了 _shutdown_dump_executor 并在所有退出路径调用,消除了风险。
- 同步更新主训练器:维护者 wuxibin89 要求也在
main_ppo_sync.py 中应用相同改动,因为 ray_trainer.py 即将弃用。作者立即同步修改,确保两个训练器行为一致。
- Executor shutdown 逻辑不完整导致资源泄漏与数据丢失风险 (correctness): Jackie2049 回应:提取 _shutdown_dump_executor() 并在所有退出点调用(含 val_only、is_last_step、dataloader 耗尽);异常路径由 OS 回收作为接受。
- 要求同步更新 main_ppo_sync.py(因为 ray_trainer.py 将被弃用) (design): boundless-future 同意并推送了对应修改,两个训练器保持同步。
风险与影响
- 风险:
- 回归风险:修改了两个训练器的核心 dump 方法,但行为与原来一致(仅执行路径变为异步)。异常传播会让训练在 I/O 错误时失败,而非静默继续,可能被依赖“容错”的用户视为行为变化。
- 线程安全:
_write_generations 是静态方法,不访问实例,但 os.makedirs 和 open 是线程安全的;写操作被单线程 executor 限制,无竞态。
- 缺少单元测试:没有新增单元测试覆盖异常传播和 shutdown 逻辑,仅依赖本地集成测试和人工验证。
- 性能:每次 dump 新增一次 future 遍历和结果检查,但影响极小。
- 影响:
- 用户:训练循环不再被 dump I/O 阻塞,生成批次越大加速越明显;I/O 错误将立即导致训练失败(而非静默丢失数据),方便排查。
- 系统:后台线程写入降低内存峰值(逐行写入),适合大规模 rollout。
- 团队:统一了
ray_trainer.py 和 main_ppo_sync.py 的 dump 逻辑,便于后续维护;修复了 request_id 遗漏和序列化兼容性问题。
- 风险标记:缺少单元测试覆盖异常与关闭路径, 核心训练流程 I/O 路径变更, 异常传播策略变更可能影响依赖静默写容错的用户
关联脉络
- PR #6384 [trainer] feat: deprecate main_ppo.py warning: 该 PR 标记 RayPPOTrainer 为弃用,解释了为何本 PR 需要同时修改 ray_trainer.py 和 main_ppo_sync.py。
参与讨论