3、显存泄漏的修复策略

显存泄漏,说白了就是内存只进不出。你训练一个模型,显存占用像滚雪球一样越滚越大,直到OOM崩掉。我早期做项目时就被这个问题折磨过——明明模型不大,跑着跑着就爆了。后来才发现,不是模型大,是代码写得不够干净。

这一章,我把自己踩过的坑和修复策略整理出来。每个方法我都亲自验证过,希望能帮你少走弯路。

3.1 with torch.no_grad() 上下文管理

推理阶段,最常用的就是 torch.no_grad()。为什么?因为推理不需要计算梯度。如果你忘了关梯度,计算图会一直累积,显存自然就炸了。

核心原则:推理时一定要用 with torch.no_grad() 包裹。

# 错误示范:没有关闭梯度
for batch in dataloader:
    output = model(batch)  # 计算图保留,显存泄漏

# 正确做法
with torch.no_grad():
    for batch in dataloader:
        output = model(batch)  # 不保留计算图,显存安全

我个人习惯在验证和测试阶段,直接在最外层套上 torch.no_grad()。这样即使内部有复杂的操作,也不会意外保留计算图。

小技巧:如果你用 @torch.no_grad() 装饰器装饰函数,也能达到同样效果。但我更喜欢显式的 with 语句,因为作用域更清晰。

3.2 del 与 torch.cuda.empty_cache() 配合使用

有时候,即使你关了梯度,显存还是涨。为什么?因为Python的垃圾回收不是实时的。你删除了变量,但显存并没有立即释放。

这时候,del 和 torch.cuda.empty_cache() 就派上用场了。

# 手动释放显存
large_tensor = torch.randn(10000, 10000).cuda()
# ... 使用 large_tensor ...
del large_tensor  # 删除引用
torch.cuda.empty_cache()  # 清空缓存

我曾经在做一个多模型推理服务时,发现每次切换模型,显存都会涨一点。排查了半天,发现是旧模型的参数没有被显式删除。加上 del model 和 torch.cuda.empty_cache() 后,显存就稳定了。

注意:empty_cache() 不是万能的。它只释放缓存,不释放正在使用的显存。如果你还有引用指向某个张量,调用 empty_cache() 是没用的。

3.3 梯度清零的最佳实践

训练时,梯度是累积的。如果你不清零,上一轮的梯度会一直占用显存。PyTorch 提供了 optimizer.zero_grad(),但很多人用错了地方。

# 标准训练循环
for batch in dataloader:
    optimizer.zero_grad()  # 每个batch开始前清零
    output = model(batch)
    loss = criterion(output, target)
    loss.backward()
    optimizer.step()

嗯,这里要注意:zero_grad() 的位置很关键。放在 backward() 之后?不行,因为梯度还没用。放在 step() 之后?也可以,但容易漏掉。我个人习惯放在每个batch的开头,这样逻辑最清晰。

避坑指南:我曾经在分布式训练中,忘了在 loss.backward() 之前同步梯度。结果每个进程的梯度都不一样,模型直接不收敛。后来加上了 torch.distributed.all_reduce 才解决。

3.4 DataLoader 的 num_workers 与 pin_memory 正确配置

DataLoader 的配置直接影响显存。很多人觉得 num_workers 越大越好,其实不然。

参数 作用 显存影响
num_workers 数据加载的进程数 每个worker会复制数据,显存占用翻倍
pin_memory 将数据固定在内存中 加速数据传输,但占用更多内存

你想想看,如果 num_workers=8,每个worker都加载一批数据到显存,那显存占用就是8倍。我建议:

  • 小模型:num_workers 可以设大一点(4-8),因为显存充裕
  • 大模型:num_workers 设小一点(2-4),避免显存爆炸
  • pin_memory:如果显存紧张,可以关掉。虽然慢一点,但更安全
# 显存紧张时的配置
dataloader = DataLoader(
    dataset,
    batch_size=16,
    num_workers=2,  # 减少worker数
    pin_memory=False,  # 关闭固定内存
    prefetch_factor=2  # 预取因子,控制预加载量
)

个人经验:我一般先设 num_workers=4,然后观察显存变化。如果显存波动大,就降到2。如果CPU负载高,就升到8。没有固定值,得试。

3.5 多进程显存隔离

多进程训练时,显存隔离是个大问题。每个进程都会加载一份模型和数据,如果隔离不好,显存会互相干扰。

PyTorch 的 torch.multiprocessing 提供了 spawn 和 fork 两种启动方式。我强烈建议用 spawn,因为它会创建全新的进程,不会继承父进程的显存状态。

# 推荐:使用 spawn 启动多进程
import torch.multiprocessing as mp

def train(rank, world_size):
    # 每个进程独立初始化
    model = MyModel().cuda(rank)
    # ... 训练逻辑 ...

if __name__ == "__main__":
    mp.spawn(train, args=(world_size,), nprocs=world_size, join=True)

我曾经踩过一个坑:用 fork 启动多进程,结果子进程继承了父进程的CUDA上下文,显存直接翻倍。换成 spawn 后,每个进程的显存就独立了。

注意:使用 spawn 时,所有代码必须放在 if __name__ == "__main__" 里,否则会无限递归创建进程。

知识体系总览

下面这张图,概括了显存泄漏修复的完整策略。你可以把它当作检查清单,每次遇到显存问题,按图索骥就行。

显存泄漏修复策略知识体系 显存泄漏修复 torch.no_grad() 推理时关闭梯度计算 避免计算图累积 del + empty_cache() 手动删除大张量 清空CUDA缓存 梯度清零 每个batch前清零 分布式训练同步 DataLoader配置 num_workers 合理设置 pin_memory 按需开启 多进程显存隔离 使用 spawn 启动 避免 fork 继承上下文 五步检查法:推理→删除→清零→配置→隔离

这五个策略,每个都能解决一类显存问题。但实际项目中,往往是多个问题叠加。我建议你遇到显存泄漏时,按这个顺序排查:先看推理有没有关梯度,再看有没有手动释放大张量,然后检查梯度清零的位置,接着优化DataLoader配置,最后确认多进程隔离是否做好。

嗯,这一章的内容就到这里。记住,显存泄漏不是玄学,是代码习惯问题。养成好习惯,显存自然就稳了。

可可精品资料库