3. PyTorch 显存管理机制:cudaMalloc、内存分配器缓存、torch.cuda.memory_summary()
搞深度学习的人,十个里有八个被OOM折磨过。模型一改大,batch一调高,啪,OutOfMemoryError。
很多人第一反应是「显存不够了,换卡」。其实很多时候,是你不了解PyTorch怎么管显存的。你想想看,每次你创建一个Tensor,底层发生了什么?申请一次显存真的要调用一次cudaMalloc吗?
我个人习惯是,先搞清楚分配器怎么工作的,再动手优化。这一章,我们就把它掰开揉碎了讲。
3.1 从 cudaMalloc 说起 —— 慢,是真的慢
CUDA的cudaMalloc是个底层API。它给GPU分配物理显存。但是这个调用非常慢。
我做过一个粗略测试:调用一万次cudaMalloc分配4KB的小块,总耗时接近几百毫秒。你可能觉得几百毫秒还好,但训练时每个step里可能触发几十次小分配,积少成多就卡住了。
更麻烦的是cudaFree。它不仅仅是释放,还会触发CUDA context的同步。这就像你还在高速上跑呢,突然踩了脚刹车。
为什么慢?因为cudaMalloc/cudaFree需要和GPU驱动通信,涉及用户态到内核态的切换,还有全局锁。这个开销远大于CPU上的malloc/free。
所以PyTorch不傻。它不直接拿cudaMalloc给小张量用。它用了另一套机制。
3.2 PyTorch 的内存分配器缓存 —— 先留着,别急着还
PyTorch的CUDA分配器,本质上就是一个大管家。它从GPU拿一大块显存,切成小块,谁要用就借,谁不用就收回库里,但不还给操作系统。
这种方式,专业术语叫「缓存式分配」(caching allocator)。
核心思想很简单:
- 分配时:从已缓存的内存块里找一个大小合适的,直接复用。找不到,才调用cudaMalloc向GPU申请新块。
- 释放时:不调用cudaFree,把内存块放回缓存池,标记为可用。
嗯,这里很关键。这样做的好处是,避免了频繁的cudaMalloc/cudaFree,大大加快了分配速度。我见过一些优化后的场景,分配速度能提升上百倍。
但副作用也明显——PyTorch占着的显存,不会释放给其他进程。你以为显存占用很高,其实里面有很多是「空闲但缓存」的块。
PyTorch的分配器还管理着两种缓存模式,我整理了一张表格:
| 模式 | 描述 | 适用场景 |
|---|---|---|
| native | 默认分配器,块大小按2的幂次对齐,合并相邻空闲块 | 通用训练/推理,大多数情况 |
| cudaMallocAsync | 使用CUDA 11.2+的流序分配,更细粒度 | 多流并发、重叠计算与通信的场景 |
我建议你,如果只跑单卡单流训练,就用默认的native分配器,别折腾。多流推理时,再考虑cudaMallocAsync。
注意,设置cudaMallocAsync需要两行代码:
torch.cuda.memory._set_allocator_settings('backend:cudaMallocAsync')
os.environ['PYTORCH_CUDA_ALLOC_CONF'] = 'backend:cudaMallocAsync'
改了之后重启Python进程才生效。
3.3 核心 API 与配置控制
PyTorch提供了一组环境变量和控制接口,帮你调整分配器的行为。
先说最常用的PYTORCH_CUDA_ALLOC_CONF。格式是逗号分隔的key:value对:
export PYTORCH_CUDA_ALLOC_CONF="max_split_size_mb:64,expandable_segments:True"
几个重要参数:
- max_split_size_mb:当一个缓存块太大时,是否允许分割。设置小一点,可以减少碎片,但可能增加allocator查找时间。默认是无限大,也就是不做分割限制。
- expandable_segments:允许分配器从CUDA申请更大的虚拟地址区间,而不是每次只申请一个物理块。对减少地址空间碎片很有帮助。
- roundup_power2_divisions:控制大小对齐策略。对防止小碎片有用。
我在项目中遇到过一个问题:多卡训练时,某张卡显存突然涨了1GB却查不出原因。后来用 expandable_segments:True 就稳定了。
注意!expandable_segments 需要 CUDA 11.4+ 和较新的 PyTorch 版本。老版本不兼容,会直接报错。
另外,你也可以在代码里通过 torch.cuda.memory 模块,手动控制一些行为:
# 清空缓存池中未使用的块,释放回CUDA(但不是给OS)
torch.cuda.empty_cache()
# 设置PyTorch分配器的split阈值
torch.cuda.memory.set_per_process_memory_fraction(0.8)
要我说,empty_cache() 这玩意儿很迷惑。你以为它会释放显存,其实它只是把缓存池里的空闲块还给cudaMalloc,并没有彻底消失。你跑nvidia-smi看,显存占用可能一点都不变。
3.4 使用 torch.cuda.memory_summary() 诊断显存
这是我最常用的诊断工具,没有之一。
torch.cuda.memory_summary() 会打印一份完整的显存使用报告,包含:
- 已分配、已缓存、空闲块的数量和大小
- 大块内存的详细地址信息
- 分配器内部状态(如分割次数、合并次数)
- 段内碎片化程度
调用方式极其简单:
print(torch.cuda.memory_summary())
输出内容长什么样?我截取一段典型示例:
| segment size | segment count | allocated size | allocated count |
|--------------|---------------|----------------|-----------------|
| 16384 | 12 | 8192 | 8 |
| 32768 | 8 | 16384 | 5 |
| 65536 | 4 | 32768 | 3 |
| 262144 | 2 | 131072 | 2 |
| 2097152 | 3 | 1048576 | 2 |
Allocated Memory: 1568000 bytes
Cache Hits: 1203
Cache Misses: 27
Segment Splits: 5
Segment Merges: 3
怎么看?
- Cache Hits / Cache Misses:命中率高说明分配器工作良好,否则你可能需要调整分配器参数。
- Segment Splits / Merges:分割太多说明请求的大小太乱,碎片化严重。
- Allocated vs Cached:两者差距大,说明有很多空闲缓存没释放。但这不一定是坏事。
我记得有一次训练一个BERT-large模型,发现cache hits只有75%,大量misses导致频繁cudaMalloc。我加上 max_split_size_mb:64 之后,hits提升到98%,训练速度也快了5%。
3.5 实战:一张图看懂显存管理流程
下面这张图,把上面说的内容串起来了。你可以跟着它走一遍完整的分配与释放流程。
我个人建议你把它截图存下来,遇到OOM时对着排查。
3.6 避坑指南与实用建议
说了这么多,给你几个实用建议。
1. 不要滥用 empty_cache()
我曾经鬼迷心窍,在每个step后都调一次 torch.cuda.empty_cache()。结果训练慢了30%,因为腾出缓存后,下一个step又得重新分配。除非你想在换模型前释放一点显存,否则别碰它。
2. 碎片化是隐形杀手
如果你发现显存占用不高但OOM,十有八九是碎片化。解决方案:
- 设置
max_split_size_mb:128限制分割 - 启用
expandable_segments:True - 尽量统一Tensor的大小,比如把batch size做成2的幂次
3. 多进程显存不共享
Distributed Data Parallel (DDP) 时每个进程独立分配显存,互不影响。但如果你开太多worker,单张卡上会叠加多个进程的分配器,显存占用可能翻倍。
4. 善用 memory_summary() 打日志
我习惯在每个epoch结束时输出一次 memory_summary(),记录到日志文件。这样一旦训练过程显存溢出,我可以回头排查是哪个epoch的分配行为异常。
小技巧:把 memory_summary 输出定向到文件,方便对比不同配置下的分配器表现。
with open('mem_log.txt', 'a') as f:
f.write(torch.cuda.memory_summary())
3.7 总结
说到底,PyTorch的显存管理就三件事:
- cudaMalloc —— 慢,所以能不用就别用
- 缓存分配器 —— 用空间换时间,将空闲块暂存复用
- memory_summary —— 你的X光机,一眼看穿显存在干嘛
理解这个机制之后,你就能回答很多常见问题了:
- 为什么显存没释放?—— 因为分配器缓存着。
- 为什么内存占用稳步增长?—— 可能是碎片化,也可能是缓存块没重用到。
- 为什么调了empty_cache没效果?—— 它不释放物理显存,只清空闲块。
我最后说一句:不要盲目崇拜「显存占用越低越好」。一个高效的分配器,占用略高但运行飞快,比一个低占用但频繁分配卡顿的系统,要好得多。你该关注的指标是 Cache Hits / Misses 比例和训练吞吐,而不是nvidia-smi上那个数字。