大规模 RL 下 oom profile 实践

💡 原文中文,约3200字,阅读约需8分钟。
📝

内容提要

作者为Qwen RL大规模训练解决CPU内存泄漏难定位问题。memray全量开销太大,采样后仍因维护Python影子栈而昂贵;scalene抽中时取栈却可能因等GIL死锁。作者借鉴faulthandler,实现无需GIL的Python栈采集,接入jemalloc采样路径,周期性落盘。真实任务中开销几乎为零,因顺带换用更快的jemalloc。

🔎

延伸解读

为什么全量内存分析在RL训练中不可行

文章指出,memray等全量内存分析工具在Python调用密集的负载下,开销可能超过一倍,训练任务无法承受。即使加入采样,memray仍需维护Python影子栈,每次函数调用都push/pop,导致端到端耗时几乎不变。这说明在长跑分布式训练中,观测工具必须足够轻量,否则无法常开,而内存问题恰恰需要长期监控才能暴露。

采样后取栈的GIL死锁风险

scalene采用采样触发时再取Python栈的方式,但取栈前需通过PyGILState_Ensure()获取GIL。在malloc路径中等待GIL可能引发死锁:线程A持有native锁M后malloc被采样,等待GIL;线程B持有GIL并等待锁M。作者强调这是源码推演的风险,并非线上实际发生,但用于长期运行的RL任务,不应在malloc中埋下依赖锁顺序的隐患。

借鉴faulthandler实现无GIL栈采集

faulthandler在进程崩溃时无需GIL即可打印Python栈,它从线程局部存储获取thread state,沿解释器内部frame链爬取。作者借鉴此思路,实现了不需要GIL的Python frame采集,并接入jemalloc采样路径。最终流程为:jemalloc决定是否采样,抽中则就地抓取Python栈,释放时扣账,后台周期性dump。这避免了在malloc中等待GIL,同时保留了jemalloc原有的采样、匹配、聚合和导出机制。

实际开销与可观测性理念

在真实RL任务中,该方案端到端step耗时几乎不变甚至略降,因为LD_PRELOAD时顺带将libc allocator换成了更快的jemalloc,对冲了采样和抓栈成本。作者强调,只有不花钱的观测才配常驻,常驻的观测才等得到问题。同时,他坚持不给算法老师增加心智负担,而是通过基础设施侧的可观测性,让内存变化被看见,再结合业务语义治理。

❓

Q&A

为什么大规模 RL 训练中需要常驻的 CPU 内存 profiler?

因为 RL 规模变大后,CPU 内存问题往往在运行一段时间后才出现,而 OOM 时进程已死,现场丢失。需要能一直跟着任务跑的 profiler,周期性留快照,记录每块内存的分配栈,以便事后对比找出持续增长的分配路径。

memray 为什么不适合长期开启在 RL 训练任务中?

memray 全量记录开销太大,在 Python 调用密集的负载下会拖慢一倍多。即使加入采样过滤掉大部分内存事件,它仍需通过 CPython 的 profile 事件持续维护 Python 影子栈,这部分开销与采样无关,无法节省。

scalene 在内存采样时取 Python 栈有什么风险?

scalene 在采样触发时同步取栈,但取栈前会调用 PyGILState_Ensure() 等待 GIL。如果 malloc 发生在持有 native 锁的线程中,而另一线程持有 GIL 并等待该锁,就会形成死锁。这种风险在 malloc 中埋雷,不适合长期运行。

如何实现无需 GIL 的 Python 栈采集?

借鉴 faulthandler 的思路:从线程局部存储中获取 thread state,然后沿着解释器内部的 frame 链直接向上爬取调用栈,全程不碰 GIL。这样可以在 malloc 采样时安全地抓取 Python 栈。

最终的 memory profile 方案是如何工作的?

基于 jemalloc 的采样机制:jemalloc 决定是否采样某次分配;若抽中,就地抓取 Python 栈;内存释放时扣掉对应样本;后台周期性 prof.dump 落盘。采样、匹配、聚合、导出全用 jemalloc 原有机制,只补充了 Python 来源层。

这个 profiler 在真实 RL 任务中的开销如何?

端到端 step 耗时几乎不变,甚至略有下降。因为通过 LD_PRELOAD 将 libc allocator 换成了更快的 jemalloc,其 malloc/free 速度更快,抵消了采样、抓栈和 dump 的成本,实现了近乎零开销。

这个方案有哪些局限性?

它是 best-effort 实现,需要跟随解释器版本变化;只能看到经过 allocator 的分配,并非进程全部内存。但足以回答“内存是谁分配的”这一问题。

🏷️

标签

➡️

继续阅读