Prhub

#6324 [trainer] feat: async generation dump with exception propagation and streaming write

原始 PR 作者 Jackie2049 合并时间 2026-05-18 15:49 文件变更 2 提交数 9 评论 4 代码增减 +108 / -22

执行摘要

异步写入 JSONL 并传播 I/O 异常,释放训练循环

Issue #6338 指出当前同步 dump 在 main 线程执行 I/O,阻塞 rollout 步骤;I/O 异常被静默忽略;"\n".join(lines) 造成高内存峰值。预期行为是异步写入、异常尽早暴露、逐行写入降低内存。

值得精读。展示了在不影响训练精度的前提下,将 I/O 异步化并正确管理资源与异常传播的实践。_shutdown_dump_executor 的设计(drain 所有 future 再关闭)可作为后台任务清理的标准模式。适合关注训练性能和数据可靠性的工程师。

讨论亮点
  1. 资源泄漏与数据丢失风险:Bot 审查指出 _dump_executor 的关闭仅放在 is_last_step 块中,若 fit() 提前退出(val_only、异常或数据加载器耗尽)则 executor 未被关闭,可能导致资源泄漏和数据丢失。后续提交提取了 _shutdown_dump_executor 并在所有退出路径调用,消除了风险。
  2. 同步更新主训练器:维护者 wuxibin89 要求也在 main_ppo_sync.py 中应用相同改动,因为 ray_trainer.py 即将弃用。作者立即同步修改,确保两个训练器行为一致。

实现拆解

  1. 提取静态写入方法:将原 _dump_generations 中的文件写入逻辑抽出为 @staticmethod _write_generations(不捕获 self),确保后台线程安全。涉及文件 verl/trainer/main_ppo_sync.pyverl/trainer/ppo/ray_trainer.py

  2. 初始化线程池:新增 _init_dump_executor,创建 ThreadPoolExecutor(max_workers=1) 并清空 futures 列表。在 __init__ 中调用(两个训练器均新增此方法及调用处)。

  3. 异步提交与异常传播_dump_generations 改为提交任务到 executor 并记录 future;每次调用时遍历已完成的 future,调用 .result() 重抛 I/O 异常。_dump_generations 签名不变,保持调用方透明。

  4. 清理与关闭:新增 _shutdown_dump_executor,drain 所有 pending 的 future(.result())后 shutdown(wait=True)。在 fit() 的三个退出点(val_onlyis_last_step、数据加载器耗尽)均调用此方法,避免资源泄漏和数据丢失。

  5. 配套修复

    • 修复 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 训练器 modified 8.19
verl/trainer/ppo/ray_trainer.py 训练器 modified 7.9

关键符号

_dump_generations _write_generations _init_dump_executor _shutdown_dump_executor _log_rollout_data

关键源码片段

verl/trainer/main_ppo_sync.py core-logic

主要的同步 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_pendingdef _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)

评论区精华

Executor shutdown 逻辑不完整导致资源泄漏与数据丢失风险 正确性

gemini-code-assist[bot] 指出关闭逻辑仅在 is_last_step 触发,若 fit() 因 val_only 或 dataloader 耗尽提前退出,executor 未关闭,pending futures 未检查,可能泄漏线程和数据丢失。

结论:Jackie2049 回应:提取 _shutdown_dump_executor() 并在所有退出点调用(含 val_only、is_last_step、dataloader 耗尽);异常路径由 OS 回收作为接受。 · 已解决

要求同步更新 main_ppo_sync.py(因为 ray_trainer.py 将被弃用) 设计

wuxibin89 要求将相同改动应用于 main_ppo_sync.py,因为 ray_trainer.py 即将被弃用。

结论:boundless-future 同意并推送了对应修改,两个训练器保持同步。 · 已解决

风险与影响

  1. 回归风险:修改了两个训练器的核心 dump 方法,但行为与原来一致(仅执行路径变为异步)。异常传播会让训练在 I/O 错误时失败,而非静默继续,可能被依赖“容错”的用户视为行为变化。
  2. 线程安全_write_generations 是静态方法,不访问实例,但 os.makedirsopen 是线程安全的;写操作被单线程 executor 限制,无竞态。
  3. 缺少单元测试:没有新增单元测试覆盖异常传播和 shutdown 逻辑,仅依赖本地集成测试和人工验证。
  4. 性能:每次 dump 新增一次 future 遍历和结果检查,但影响极小。
  1. 用户:训练循环不再被 dump I/O 阻塞,生成批次越大加速越明显;I/O 错误将立即导致训练失败(而非静默丢失数据),方便排查。
  2. 系统:后台线程写入降低内存峰值(逐行写入),适合大规模 rollout。
  3. 团队:统一了 ray_trainer.pymain_ppo_sync.py 的 dump 逻辑,便于后续维护;修复了 request_id 遗漏和序列化兼容性问题。
缺少单元测试覆盖异常与关闭路径 核心训练流程 I/O 路径变更 异常传播策略变更可能影响依赖静默写容错的用户

关联 Issue

#6338 [PERF] Synchronous generation dump blocks training loop and silently drops I/O errors

完整报告

参与讨论