3、PyTorch显存管理机制:缓存分配器、cudaMalloc与显存池的真相

聊到显存溢出,很多人第一反应就是“我模型太大了”。但说实话,很多时候问题出在PyTorch怎么管显存,而不是你用了多少。我见过不少项目,模型明明不大,显存却莫名其妙爆了。今天我们就来扒一扒PyTorch的显存管理机制,看看它到底在背后干了什么。

3.1 PyTorch的缓存分配器:一个“小气”的管家

PyTorch不会每次需要显存都去找CUDA要。它自己养了一个“缓存分配器”(Caching Allocator)。这个分配器的工作方式,说白了就是:先借一大块,用完了不还,留着下次用。

为什么会这样?因为cudaMalloc这个操作很贵。每次调用cudaMalloc,都要和驱动层打交道,开销不小。PyTorch为了省事,干脆一次性多申请一些,放到自己的“显存池”里。你每次创建Tensor,它就从池子里划一块给你。你释放Tensor,它也不真还给系统,而是放回池子。

核心逻辑:PyTorch的显存管理 = 预分配 + 缓存复用 + 延迟释放

我个人习惯用torch.cuda.memory_summary()来查看当前分配器的状态。这个命令能告诉你:当前池子里有多少块显存、每块多大、哪些是活跃的、哪些是缓存的。

import torch

# 查看当前显存分配器的详细状态
print(torch.cuda.memory_summary())

# 输出示例(简化):
# Allocated: 2.5 GB
# Cached: 4.0 GB
# 这里 cached 就是池子里还没还给系统的部分

你看,Allocated是你真正在用的,Cached是池子里躺着的。两者差的那1.5GB,就是PyTorch“扣着不还”的部分。很多新手看到nvidia-smi里显存占用很高,就以为模型太大,其实可能只是缓存没清。

3.2 cudaMalloc与cudaFree:底层的那点事

PyTorch的缓存分配器底层,最终还是调用了CUDA的cudaMalloc和cudaFree。但调用时机和你想的不太一样。

  • cudaMalloc:只在分配器“第一次”需要扩大池子时调用。之后池子够用,就不再调用了。
  • cudaFree:几乎不主动调用。除非你显式调用torch.cuda.empty_cache(),或者池子实在太大需要收缩。

我在项目中遇到过一个问题:训练循环里每次迭代都创建了一些临时Tensor,按理说迭代结束就释放了。但显存占用却一路攀升,直到OOM。排查后发现,是因为这些Tensor大小不一,分配器找不到合适的缓存块,只好不断cudaMalloc新块。旧的块又因为大小不匹配,一直躺在池子里不被复用。

避坑指南:我曾经因为Tensor形状频繁变化,导致分配器碎片化严重。后来统一了输入尺寸,显存占用直接降了30%。

3.3 显存池机制:大池子与小池子

PyTorch的缓存分配器内部维护了多个“池子”。每个池子负责管理不同大小的显存块。分配器会尽量从最匹配的池子里找块给你。如果找不到,就分裂大块或者合并小块。

这个机制有点像内存池的伙伴算法。但有个坑:池子里的块一旦被分配出去,即使你释放了Tensor,这个块也不会立即合并回大块。它只是被标记为“空闲”,但位置和大小不变。这就导致了碎片化。

你想想看,如果你反复分配不同大小的Tensor,池子里就会充满各种大小的“空洞”。下次要一个大块时,虽然总空闲空间够,但没有连续的大块,分配器就只能再去cudaMalloc。这就是显存占用虚高的常见原因。

注意:显存池的碎片化是隐形的杀手。它不会直接报错,但会让你的显存利用率越来越低。最终OOM时,你甚至不知道为什么。

3.4 torch.cuda.empty_cache()的真相

很多人把torch.cuda.empty_cache()当成“救命稻草”。每次显存不够就调一下。但它的真实作用,可能和你想的不一样。

它不会释放你正在使用的Tensor。它只释放缓存分配器池子里那些“空闲”的块。说白了,就是把PyTorch扣着不还的那部分,还给CUDA。

import torch

# 创建一个Tensor
x = torch.randn(1000, 1000).cuda()

# 释放Tensor
del x

# 此时显存占用可能没变,因为池子里还缓存着这块空间
print(torch.cuda.memory_allocated())  # 0
print(torch.cuda.memory_cached())     # 4 MB (缓存还在)

# 调用empty_cache
torch.cuda.empty_cache()

# 现在缓存也被清掉了
print(torch.cuda.memory_cached())     # 0

嗯,这里要注意:empty_cache()不是银弹。频繁调用它反而会降低性能,因为每次清空后,下次分配又要重新cudaMalloc。我建议只在以下场景使用:

  • 训练结束后,释放显存给其他进程用
  • 调试时,想确认当前真正占用的显存
  • 切换模型或任务时,清理环境

真相总结:torch.cuda.empty_cache() 清的是“缓存”,不是“分配”。它解决的是显存占用虚高的问题,不是显存不够用的问题。

3.5 一张图看懂PyTorch显存管理

下面我用一张流程图,把整个机制串起来。你一看就明白。

PyTorch显存管理流程图 用户创建Tensor 缓存池有空闲块? 是 从缓存池分配 否 调用cudaMalloc申请 Tensor使用中 释放Tensor (del) 缓存池 (Caching Allocator) empty_cache() 说明: 绿色箭头:命中缓存,快速分配 红色箭头:未命中,触发cudaMalloc 橙色虚线:empty_cache() 清空空闲缓存

这张图把整个流程说清楚了。你创建Tensor时,分配器先看池子里有没有合适的空闲块。有就直接用,没有就去cudaMalloc。用完释放后,Tensor回到池子,但没还给系统。只有你调empty_cache(),池子里的空闲块才会被真正释放。

3.6 实战建议:怎么用好这个机制

知道了这些,你就能有针对性地优化显存了。我总结几条实战经验:

  • 尽量复用Tensor:不要频繁创建和销毁。用torch.zeros_like()或者原地操作(inplace=True)来复用显存。
  • 统一Tensor尺寸:避免形状频繁变化,减少碎片化。我习惯在数据加载时就pad到固定尺寸。
  • 慎用empty_cache():只在需要释放显存给其他进程时调用。训练循环里不要加,否则会拖慢速度。
  • 监控分配器状态:用torch.cuda.memory_summary()定期检查,看cached和allocated的差距。差距过大说明缓存太多。

一个小技巧:我习惯在训练脚本开头设置torch.cuda.set_per_process_memory_fraction(0.9),限制PyTorch最多用90%的显存。这样即使有缓存,也不会把显存占满导致其他进程OOM。

好了,PyTorch的显存管理机制就讲到这里。你理解了缓存分配器、cudaMalloc、显存池和empty_cache的真相,以后遇到显存问题,至少知道该从哪里下手排查了。

想要更多资料,联系可可