3、PyTorch显存机制:Tensor存储原理,CUDA缓存分配器,显存碎片化问题

聊到显存优化,咱们得先搞清楚一件事:PyTorch到底是怎么管显存的?

很多同学训练到一半就OOM,第一反应就是“batch size调小点”。但说实话,这往往只是治标不治本。我见过太多项目,调小batch size后显存确实降了,但训练速度慢得让人抓狂。真正的问题,可能出在你对PyTorch显存机制的理解上。

今天我就带你把这层窗户纸捅破。咱们从三个层面来拆解:Tensor怎么存、CUDA怎么分配、碎片化怎么来的。

3.1 Tensor存储原理:你以为的“释放”可能只是假象

先问个问题:当你执行 del tensor 时,显存真的被释放了吗?

答案是:不一定。

PyTorch的Tensor存储,底层依赖的是 Storage 对象。每个Tensor背后都有一个Storage,它才是真正占显存的那块“地皮”。Tensor本身只是个“门牌号”,告诉你数据在哪、形状多大。

核心概念:

  • Tensor:视图(View),包含元数据(shape、stride、dtype)
  • Storage:实际数据块,连续内存区域
  • 引用计数:多个Tensor可以共享同一个Storage

举个例子你就明白了:

import torch

# 创建一个Tensor
a = torch.randn(1000, 1000, device='cuda')
print(a.storage().data_ptr())  # 打印Storage地址

# 创建一个视图(切片)
b = a[0:500, :]
print(b.storage().data_ptr())  # 地址相同!共享Storage

# 删除a
del a
# 此时b依然有效,因为Storage的引用计数还没归零
print(b.sum())  # 正常运行

看到了吗?del a 只是减少了引用计数,Storage还在。只有所有引用都消失,Storage才会被标记为“可回收”。

我的经验: 在项目中排查显存泄漏时,我习惯用 torch.cuda.memory_summary() 查看当前所有Tensor的分配情况。这个命令能列出每个Tensor的Storage地址和大小,帮你快速定位谁在“占着茅坑不拉屎”。

3.2 CUDA缓存分配器:PyTorch的“显存管家”

好,现在Storage被标记为可回收了。但显存真的还给系统了吗?

并没有。

PyTorch底层用的是CUDA的缓存分配器(Caching Allocator)。它的工作逻辑很简单:为了省事,把释放的显存先“囤”起来。

你想想看,如果每次分配显存都去调用 cudaMalloc,那性能得多差?系统调用是有开销的。所以PyTorch搞了个“缓存池”:

  • 分配时:先从缓存池里找大小合适的块,找不到才去问GPU要
  • 释放时:不还给GPU,而是放回缓存池,下次再用

这就是为什么你经常看到 nvidia-smi 显示的显存占用居高不下,但PyTorch内部其实已经“释放”了很多。说白了,nvidia-smi看到的是“已分配+已缓存”,不是“真正在用”。

注意: 缓存分配器有个“坑”——它倾向于分配大块内存。如果你频繁创建和销毁小Tensor,缓存池里会留下很多“碎片”,导致后续大Tensor分配失败。这就是显存碎片化的根源之一。

3.3 显存碎片化:看不见的“内存杀手”

碎片化问题,我估计很多同学都遇到过。场景是这样的:

  1. 训练刚开始,显存还剩2GB
  2. 突然要分配一个1.5GB的Tensor
  3. 结果报OOM了!

为什么会这样?因为显存被切成了很多小块,没有一块连续空间能放下1.5GB。就像你钱包里有一堆硬币,但买瓶水需要一张整钞——你有钱,但花不出去。

碎片化主要来自两个地方:

碎片类型 产生原因 典型场景
内部碎片 缓存分配器按2的幂次分配(512B、1KB、2KB...) 你申请600B,实际拿到1KB,浪费400B
外部碎片 频繁分配/释放不同大小的Tensor 动态网络结构、变长序列处理

避坑指南: 我曾经在一个NLP项目中,因为用了动态batch(每个batch的序列长度不一样),导致显存碎片化严重。训练到第100个batch时,明明总显存还剩3GB,但就是分配不出一个200MB的Tensor。后来我改成固定长度padding + 按长度排序,碎片化问题大幅缓解。

3.4 可视化:PyTorch显存分配流程

为了让你更直观地理解整个过程,我画了一张流程图:

PyTorch显存分配与释放流程 创建Tensor 检查缓存池 是否有合适块? 有 复用缓存块 没有 调用cudaMalloc 向GPU申请新显存 分配成功 Tensor使用中 引用计数归零 标记为可回收 回收到缓存池 不还给GPU 碎片化累积 创建 检查 复用 申请/碎片 使用 回收

3.5 实战建议:如何应对碎片化

知道了原理,咱们得有点“武器”才行。这里分享几个我常用的方法:

方法一:使用 torch.cuda.empty_cache()

这个命令会清空缓存池,把显存还给GPU。但注意,它只释放“缓存”,不释放“正在使用”的Tensor。我一般在每个epoch结束后调用一次,效果不错。

for epoch in range(num_epochs):
    train_one_epoch()
    torch.cuda.empty_cache()  # 清空缓存,减少碎片

方法二:设置 PYTORCH_CUDA_ALLOC_CONF

PyTorch 1.9+ 支持通过环境变量控制分配器行为。我最常用的是 max_split_size_mb:

# 限制缓存块最大分割大小,减少碎片
export PYTORCH_CUDA_ALLOC_CONF=max_split_size_mb:128

这个参数的意思是:缓存池里的块,如果大于128MB就不再分割。能有效减少外部碎片。

方法三:避免动态形状

如果你处理的是变长序列,尽量用padding固定长度。或者像我之前做的,按长度排序后分组。动态形状是碎片化的头号元凶。

嗯,关于PyTorch显存机制,今天就聊到这儿。记住一句话:显存不是不够用,而是不会用。理解了Tensor的Storage机制、缓存分配器的“囤货”逻辑、以及碎片化的成因,你就能从根源上解决OOM问题。

想要更多资料,联系可可