显存分配与生命周期管理:PyTorch显存分配器机制

显存管理这事儿,说白了就是跟GPU要内存、用完了再还回去。但问题在于——怎么要、怎么还、还了之后能不能再快速借出来?我刚开始做大模型训练时,经常遇到OOM(显存溢出),后来才发现,很多时候不是显存真的不够,而是分配器没用好。

PyTorch显存分配器机制

PyTorch默认用的是自己的缓存分配器(caching allocator),而不是直接调用cudaMalloc。为什么要这么设计?因为cudaMalloc这玩意儿开销太大了,每次分配都要跟驱动打交道,频繁调用会严重拖慢训练速度。

PyTorch的做法是:先一次性从GPU申请一大块显存,然后自己内部做二次分配。你调用torch.cuda.empty_cache()时,其实只是把缓存标记为可用,并没有真正还给操作系统。嗯,这里要注意——这个函数只清空缓存,不释放已分配的内存块。

核心机制:PyTorch分配器维护了一个缓存池(cache pool),里面放着之前分配过但已经释放的显存块。下次需要同样大小的显存时,直接从池子里拿,省去了cudaMalloc的开销。

# 查看当前显存使用情况
import torch

# 分配一个张量
x = torch.randn(1024, 1024).cuda()
print(f"当前分配: {torch.cuda.memory_allocated() / 1024**2:.2f} MB")
print(f"缓存大小: {torch.cuda.memory_reserved() / 1024**2:.2f} MB")

# 释放张量
del x
print(f"释放后分配: {torch.cuda.memory_allocated() / 1024**2:.2f} MB")
print(f"释放后缓存: {torch.cuda.memory_reserved() / 1024**2:.2f} MB")
# 注意:分配量归零,但缓存量不变

显存碎片化问题

碎片化是我在实际项目中踩过最大的坑。有一次训练一个BERT-large模型,明明总显存还有4GB空闲,但就是分配不出一个2GB的tensor。为什么会这样?因为显存被切成了无数个小碎片,每个碎片都不够大。

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

  • 大小不一的张量反复分配释放——比如你循环里创建不同shape的中间变量
  • 梯度检查点(checkpoint)的不当使用——保存的中间激活值大小差异太大

我的经验:训练前先跑一次warmup,让分配器把缓存池填满。这样后续训练时,分配器已经知道要分配哪些大小的块,碎片化会明显减少。

# 查看碎片化程度
def check_fragmentation():
    allocated = torch.cuda.memory_allocated()
    reserved = torch.cuda.memory_reserved()
    # 碎片率 = 1 - (已分配 / 已缓存)
    frag_ratio = 1 - allocated / max(reserved, 1)
    print(f"碎片率: {frag_ratio:.2%}")
    return frag_ratio

# 模拟碎片化场景
tensors = []
for i in range(100):
    size = (128, 128 * (i % 10 + 1))
    t = torch.randn(*size).cuda()
    if i % 3 == 0:
        del t  # 随机释放,制造碎片
    else:
        tensors.append(t)

check_fragmentation()

手动释放与缓存策略

我个人习惯在关键节点手动干预显存管理。虽然PyTorch的自动管理已经很智能了,但有些场景下,手动释放能带来质的提升。

1. 及时删除不再使用的张量

# 不好的做法:中间变量一直占用显存
def bad_forward(x):
    h1 = torch.relu(x @ w1 + b1)
    h2 = torch.relu(h1 @ w2 + b2)
    return h2 @ w3 + b3

# 好的做法:及时释放中间变量
def good_forward(x):
    h1 = torch.relu(x @ w1 + b1)
    h2 = torch.relu(h1 @ w2 + b2)
    del h1  # 显式释放
    result = h2 @ w3 + b3
    del h2
    return result

避坑指南:我曾经在循环里忘记释放中间变量,结果一个batch跑完显存涨了2GB。后来加了del和torch.cuda.empty_cache()才稳住。但注意——empty_cache不能频繁调用,否则会拖慢速度。

2. 使用上下文管理器自动释放

# 使用torch.no_grad()减少中间变量
with torch.no_grad():
    # 推理时不需要梯度,减少显存占用
    output = model(input_data)

# 自定义上下文管理器
class AutoRelease:
    def __enter__(self):
        return self
    
    def __exit__(self, *args):
        torch.cuda.empty_cache()

# 使用示例
with AutoRelease():
    # 做一些临时计算
    temp = torch.randn(10000, 10000).cuda()
    result = temp.sum()
    # 退出时自动清理缓存

3. 缓存策略对比

策略 适用场景 优点 缺点
默认缓存 常规训练 速度快,无需干预 碎片化风险
手动释放 显存紧张时 精确控制 代码侵入性强
定期清空 长训练任务 防止累积 性能损失
预分配 固定大小张量 零碎片 灵活性差

知识体系总览

下面这张图是我自己总结的显存管理核心逻辑,你一看就明白:

显存分配与生命周期管理核心流程 PyTorch分配器 缓存池(Cache Pool) 命中缓存 → 快速分配 未命中 → cudaMalloc 碎片化累积 新分配开销大 手动释放 + empty_cache 预分配 + 固定大小张量

你看这个流程,其实核心就两件事:怎么快速拿到显存和怎么避免碎片化。缓存池是PyTorch给我们的礼物,但用不好反而会变成负担。

实用技巧:训练前用torch.cuda.empty_cache()清空一次缓存,然后跑一个batch做warmup。这样后续的分配都会走缓存命中路径,速度最快。

最后说一句——显存管理不是玄学,是工程。你只要理解了分配器的缓存机制,再配合手动释放和预分配策略,OOM基本就跟你无缘了。我在多个百亿参数模型上验证过这套方法,效果很稳定。

想要更多资料,联系可可