Prhub

#7770 [training_utils] feat: Add automatic CUDA/NPU OOM snapshots and multi-step memory history

原始 PR 作者 ji-huazhong 合并时间 2026-09-08 14:30 文件变更 16 提交数 2 评论 0 代码增减 +412 / -29

执行摘要

为 torch_memory 分析器增加 CUDA/NPU OOM 自动快照和多步内存历史窗口。

解决在训练过程中,OOM 发生时无法获取事发现场内存状态的痛点。PR body 中提到:'helps diagnose OOMs that occur before the next scheduled snapshot'。同时,引入 memory_snapshot_num_steps 配置项,以支持分析跨步的内存增长模式,满足更精细的性能调优需求。

该 PR 是一个高质量的增强功能,解决了一个常见的痛点。代码结构清晰,异常处理完善,测试覆盖全面。建议相关开发者(尤其是进行大规模训练调优的团队)精读其设计,特别是 OOM 回调的实现和多步窗口的配置使用。

讨论亮点

PR 仅有一条 APPROVED 审核记录,无公开讨论。设计决策(如 OOM 回调中禁用同步、NPU 使用 pickle 序列化)均通过代码和 PR body 中的 'Limitations' 部分进行说明。

实现拆解

  1. OOM 观察者注册与回调:在 verl/utils/profiler/torch_memory_profile.py 中,为 TorchMemoryProfiler 新增类属性 _oom_observer_attached 和实例方法 _attach_oom_observer。在 __init__ 中,若当前 rank 被选中,则自动尝试注册一个进程本地的 OOM 观察者回调 _on_out_of_memory。该回调负责记录 OOM 详情(分配大小、设备总内存、空闲内存)、打印 Python 调用栈,并在不同步 CUDA 的情况下转储快照。
  2. 多步内存历史窗口:在 TorchMemoryToolConfig 中新增 memory_snapshot_num_steps 配置项(默认值 1,保持向后兼容)。在 TorchMemoryProfiler 中引入 _steps_in_window、_window_start_step、_window_end_step 三个状态变量,以及辅助方法 _window_sub_dir。start 和 stop 方法被修改以跟踪窗口进度,仅在累计步数达到配置值后才执行快照转储和历史重置。
  3. 底层快照转储适配:在 verl/utils/memory_utils.py 的 dump_memory_snapshot 方法中,新增 synchronize 参数以支持 OOM 回调中的非同步转储。同时,针对 NPU 设备,改用 device.memory._dump_snapshot 而非序列化原始快照,以避免启动/停止分析器带来的额外开销。
  4. 配置与文档同步更新:在多个 PPO 训练器配置文件(ppo_trainer.yaml, _generated_*.yaml)和角色配置文件(actor.yaml, critic.yaml 等)中,为 torch_memory 工具添加 memory_snapshot_num_steps 字段。同步更新了 docs/perf/verl_profiler_system.md 文档。
  5. 全面测试覆盖:在 tests/utils/test_torch_memory_profile.py 中新增 TestTorchMemoryProfiler 和 TestNpuTorchMemoryProfiler 测试类,覆盖 OOM 观察者自动注册、跨 rank 逻辑、进程单例注册、设备可用性检查、API 缺失处理、多步窗口行为以及诊断失败容错等关键场景。
文件 模块 状态 重要度
verl/utils/profiler/torch_memory_profile.py 内存分析器 modified 8.35
verl/utils/memory_utils.py 内存工具 modified 7.1
tests/utils/test_torch_memory_profile.py 内存分析器测试 added 7.99

关键符号

_attach_oom_observer _on_out_of_memory _window_sub_dir dump_memory_snapshot

关键源码片段

verl/utils/profiler/torch_memory_profile.py core-logic

核心实现文件,包含 OOM 观察者注册、回调逻辑和多步内存窗口管理。

def _attach_oom_observer(self) -> None:
    """Register one process-local callback that writes a snapshot at allocator OOM time."""
    if TorchMemoryProfiler._oom_observer_attached:
        return
​
    # 限制仅在 CUDA/NPU 设备上注册 OOM 观察者
    if self._device_name not in ("cuda", "npu"):
        logger.warning("[torch_memory] automatic OOM snapshots are only available on CUDA/NPU devices")
        return
​
    try:
        if not get_torch_device().is_available():
            logger.warning("[torch_memory] automatic OOM snapshots require an available accelerator")
            return
​
        # 根据设备类型选择对应的后端模块(CUDA 或 NPU)
        if self._device_name == "npu":
            import torch_npu
            backend = torch_npu._C
        else:
            backend = torch._C
        # 动态查找 attach_observer 函数,例如 torch._C._cuda_attach_out_of_memory_observer
        attach_observer = getattr(backend, f"_{self._device_name}_attach_out_of_memory_observer", None)
        if attach_observer is None:
            logger.warning("[torch_memory] this build does not support %s OOM observers", self._device_name.upper())
            return
​
        # 注册回调,确保整个进程只注册一次
        attach_observer(self._on_out_of_memory)
        TorchMemoryProfiler._oom_observer_attached = True
        logger.info("[torch_memory] %s OOM snapshot observer attached", self._device_name.upper())
    except Exception as exc:
        logger.warning(
            "[torch_memory] failed to attach %s OOM snapshot observer: %s", self._device_name.upper(), exc
        )
​
def _on_out_of_memory(self, device: int, alloc: int, device_total: int, device_free: int) -> None:
    """Best-effort snapshot callback invoked by the CUDA/NPU allocator."""
    out_dir = self.config.save_path or "outputs/profile"
    sub_dir = f"oom_{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}"
    # NPU 可能报告的是进程内存限制,而不仅仅是物理设备总量。
    total_label = "total_or_limit" if self._device_name == "npu" else "total"
    logger.error(
        "[torch_memory] %s OOM on device %s: requested=%s %s=%s free=%s; dumping allocator snapshot",
        self._device_name.upper(), device, alloc, total_label, device_total, device_free,
    )
    logger.error("[torch_memory] Python stack at OOM:\n%s", "".join(traceback.format_stack()))
    try:
        # 在 OOM 回调中收集额外的内存分配器信息
        logger.error("[torch_memory] allocator memory at OOM: %s", get_memory_info())
    except Exception as exc:
        logger.warning(f"[torch_memory] failed to collect allocator memory at OOM: {exc}")
    try:
        # 关键:OOM 时可能无法同步设备,因此设置 synchronize=False
        self.sampler.dump_memory_snapshot(
            out_dir=out_dir, tag="torch_memory_oom", sub_dir=sub_dir, synchronize=False
        )
    except Exception as exc:
        logger.warning("[torch_memory] failed to dump %s OOM snapshot: %s", self._device_name.upper(), exc)
verl/utils/memory_utils.py core-logic

底层快照转储逻辑的适配层,为 OOM 回调提供非同步转储和 NPU 兼容性支持。

def dump_memory_snapshot(
    self, out_dir: str = "./mem_snapshots", tag: str = "snapshot", sub_dir: str = None, synchronize: bool = True
) -> Path | None:
    """
    Generates a memory snapshot and saves it as a pickle file.
​
    Args:
        out_dir: Directory for snapshot files.
        tag: Filename prefix.
        sub_dir: Subdirectory within out_dir.
        synchronize: Whether to sync the device before dumping. Disable for OOM observer.
    """
    # ... ( 路径计算逻辑不变 ) ...
​
    device = get_torch_device()
    if not device.is_available():
        logger.warning("[memory_visualize] is only available on accelerator devices.")
        return None
    try:
        if synchronize:
            device.synchronize()
        # NPU 需要特殊处理:使用 pickle 序列化,避免调用可能触发分析器的 _dump_snapshot
        if get_device_name() == "npu":
            snapshot = device.memory._snapshot()
            with path.open("wb") as f:
                pickle.dump(snapshot, f)
        else:
            device.memory._dump_snapshot(str(path))
        logger.info(f"[memory_visualize] dumped: {path}")
        return path
    except Exception as e:
        logger.warning(f"[memory_visualize] dump failed: {e}")
        return None
tests/utils/test_torch_memory_profile.py test-coverage

全面的单元测试文件,确保 OOM 观察者和多步窗口功能的正确性和健壮性。

class TestTorchMemoryProfiler(unittest.TestCase):
    # ... (setUp 方法 ) ...
​
    def test_oom_observer_is_automatic_and_dumps_without_sync(self):
        """验证 OOM 观察者自动注册且转储时不同步设备。"""
        profiler = TorchMemoryProfiler(rank=0, config=self._config(), tool_config=TorchMemoryToolConfig())
​
        self.attach_observer.assert_called_once() # 确认观察者已注册
        observer = self.attach_observer.call_args.args[0] # 获取注册的回调函数
        with (
            patch("verl.utils.profiler.torch_memory_profile.traceback.format_stack", return_value=["stack"]),
            patch("verl.utils.profiler.torch_memory_profile.get_memory_info", return_value={"allocated": 1234}),
            patch.object(profiler.sampler, "dump_memory_snapshot") as dump_snapshot,
        ):
            # 模拟 OOM 回调被调用
            with self.assertLogs("verl.utils.profiler.torch_memory_profile", level="ERROR"):
                observer(0, 4096, 1234, 5678)
​
        dump_snapshot.assert_called_once()
        kwargs = dump_snapshot.call_args.kwargs
        self.assertEqual(kwargs["tag"], "torch_memory_oom") # 确认快照标签
        self.assertFalse(kwargs["synchronize"]) # 关键断言:转储时不应同步设备
​
    def test_snapshot_window_keeps_history_until_the_configured_step_count(self):
        """验证多步内存历史窗口在达到配置步数前不转储。"""
        tool_config = TorchMemoryToolConfig(memory_snapshot_num_steps=2) # 配置为 2 步一个窗口
        with patch("verl.utils.profiler.torch_memory_profile.clear_memory_history"):
            profiler = TorchMemoryProfiler(rank=0, config=self._config(), tool_config=tool_config)
            with patch.object(profiler.sampler, "dump_memory_snapshot") as dump_snapshot:
                # 第一步:不应触发转储
                profiler.start(profile_step=4)
                profiler.stop()
                dump_snapshot.assert_not_called()
​
                # 第二步:达到窗口大小,应触发转储
                profiler.start(profile_step=5)
                profiler.stop()
                dump_snapshot.assert_called_once_with(
                    out_dir="/tmp/profiles", tag="torch_memory", sub_dir="steps4-5" # 窗口目录命名正确
                )

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. OOM 回调稳定性:OOM 发生时设备流可能处于错误状态,转储操作本身存在失败风险。代码通过多层 try...except 并记录警告日志来降低风险,但 synchronize=False 可能导致写入不完整的快照文件。
  2. 性能开销:注册 OOM 观察者本身开销极低。多步窗口通过延迟转储减少了 I/O 频率,性能影响可忽略。
  3. 兼容性:memory_snapshot_num_steps 默认值为 1,完全向后兼容。新功能仅在选择 torch_memory 工具时激活,对其他训练流程无影响。
  4. 测试覆盖:测试全面覆盖了主要功能路径和异常路径,降低了回归风险。
  1. 用户体验:显著降低了调试 CUDA/NPU OOM 问题的难度,提供了前所未有的事后分析能力。
  2. 系统健壮性:增强了系统在内存压力下的可观测性,有助于提前发现和修复潜在的内存问题。
  3. 开发者工作流:为性能工程师和训练工程师提供了更强大的内存分析工具链,促进了针对大规模训练任务的优化。影响范围主要限于 verl/utils/profiler 和 verl/utils/memory_utils 模块,以及使用 torch_memory 工具的配置。
OOM 回调容错依赖 设备流错误状态处理

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论