☰
PyTorch CUDA out of memory 排查指南:从报错到显存优化实战
2026/10/15 9:32:19 网站建设 项目流程

只要用 PyTorch 跑过深度学习训练,大概率见过这条报错:

RuntimeError: CUDA out of memory. Tried to allocate 2.00 GiB (GPU 0; 11.82 GiB total capacity; 10.91 GiB already allocated; 0.86 GiB free; 11.23 GiB reserved in total by PyTorch)

我第一次看到时第一反应是“显存不够,赶紧换大卡”。后来踩的坑多了才发现,单纯换卡是成本最高、收益最差的做法。这篇文章我会围绕 CUDA out of memory 的报错信息、定位思路、应急止血手段、训练流程优化和常见排障实操展开,把我这几年实际排查的经验完整总结一遍。刚入门的新手可以顺着步骤走,老手也可以看看有没有遗漏的冷门细节。

1. 先看崩溃现场:RuntimeError 到底在抱怨什么

1.1 报错里那一串数字都是什么意思

很多人瞄一眼报错,只看到 “out of memory” 就急着改 batch size,其实这段报错信息里藏着关键线索。以刚才那段报错为例:

  • Tried to allocate 2.00 GiB:当前这个算子想向显存申请 2GB 空间。
  • 11.82 GiB total capacity:这张卡总共有大概 12GB 显存。
  • 10.91 GiB already allocated:已经有接近 11GB 被 PyTorch 真正分配并正在使用。
  • 0.86 GiB free:驱动报告还剩 0.86GB 空闲。
  • 11.23 GiB reserved in total by PyTorch:PyTorch 的缓存分配器从驱动手里一共保留了 11.23GB,即便其中有未使用的部分,也不会立刻还给系统。

很多人卡在“明明 free 还有 0.86GB,为什么申请 2GB 会失败”。这是因为 PyTorch 为了追求性能,用了一整层“显存缓存分配器”:它从驱动申请一大块显存放进自己的池子,算子释放显存时先回池子,并不真正归还系统。因此 free 的值参考意义不大,reserved 才是 PyTorch 认为“自己可以用”的总量。当剩余空间小于请求值时,报错就出现了。

1.2 OOM 不等于“绝对不够用”

有一种非常常见的情况:GPU 总显存 24GB,free 显示 23.4GB,却还是 OOM。这看起来完全不合逻辑,实际原因有两个。

第一是显存碎片。显存类似于连续内存,训练时模型、优化器状态、激活值各自申请大小不同的块,反复分配释放后会在显存里留下很多“小空隙”。单个空隙都不大,但加起来就是无法满足一次大块连续申请,于是触发了 OOM。

第二是缓存膨胀。PyTorch 的训练过程中,某些张量(尤其是中间激活值)会保留很久,导致 reserved 总量越来越大。虽然算子释放了一些内存回缓存池,但池子没有整理出足够大的连续块,同样会导致分配失败。

这就是为什么我在排查 OOM 时,从来不只看那一行报错,而是接着往下做诊断。

2. 别急着改代码,先定位显存去了哪里

2.1 用 PyTorch 自带的 memory_summary 查看分配明细

PyTorch 提供了一套非常实用的显存查询接口,我在异常处理块里最喜欢挂上torch.cuda.memory_summary(),它能把当前设备上的分配情况全部打出来。

import torch try: outputs = model(**batch) except RuntimeError as e: if "CUDA out of memory" in str(e): print(torch.cuda.memory_summary(device="cuda")) raise

输出内容比较多,核心重点看几行:

  • Current usage:当前真正分配的显存量。
  • Peak usage:运行到此刻的显存峰值。
  • Allocated:张量实际占用的字节数。
  • Reserved:缓存分配器从驱动手里保留但未必在用的字节数。

Peak usage 是非常关键的指标。如果当前值不高但峰值很高,说明训练过程中出现过巨大的临时张量,问题集中在某个算子或某个阶段;如果当前值和峰值都在高位,说明模型本身或优化器状态确实把显存吃满了,这时候再缩减 batch size 的收益就很有限。

2.2 配合驱动工具和系统监控一起看

只靠 PyTorch 内部信息有时不够,尤其是当你怀疑残留进程占用显存时。我习惯在另一个终端跑一条命令,实时刷新 GPU 状态:

watch -n 1 nvidia-smi

重点看每个 GPU 上的进程 PID和显存占用。如果同一台机器出现两个遗留 Python 进程,各自占了几 GB 显存,第一件事不是调代码,而是确认老任务是死进程后手动清理:

kill -9 残留PID

尤其在一台多人共用的训练服务器上,很多 OOM 其实是别人的进程占着显存,跟自己的代码一点关系都没有。

另外还有一个容易遗漏的细节:nvidia-smi显示的进程显存是 CUDA 上下文级别,并不完全等于 PyTorch 内部张量大小。有时进程已经退出,驱动侧的释放有延迟,肉眼看到显存没回落,等一两秒再看就正常了。

2.3 用“二分法”隔离显存大户

如果项目中模型比较复杂,比如多个子网络拼接、多阶段推理,一时看不出哪里耗显存,我推荐一个笨但有效的办法——二分法。

具体做法是:从数据开始,用固定 batch size 跑 forward,每跑一层就记录torch.cuda.memory_allocated()的增量:

torch.cuda.reset_peak_memory_stats() with torch.no_grad(): feature = model.backbone(image) print(torch.cuda.max_memory_allocated())

如果 backbone 就占了 8GB,那后面不用测也知道瓶颈在哪。再继续测分类头、多尺度融合、损失函数等部分。实际执行时不需要精细到每个算子,只要按模块的 forward 入口切分,通常十几分钟就能定位出显存消耗的大户。

这套方法排查过很多次,最后发现的问题五花八门:有人是损失函数里构建了一个超大相似度矩阵,有人是后处理阶段把整个 batch 的特征图保存下来转成 numpy,也有人是真的模型太大。定位清楚再动手,效率高很多。

3. 显存不够怎么办:四招止血方案

3.1 降低 batch size,并用梯度累积补回落差

这是最直接的一招:batch size 从 32 降到 16 或 8,显存占用通常会跟着大幅下降。原因是训练时每个样本都要保留中间激活值用于反向传播,这部分内存基本和 batch size 成正比。

但很多模型对 batch size 很敏感,直接降会导致 Batch Normalization 统计不稳定,影响收敛效果。我的建议是:调低 batch size 的同时,叠加梯度累积训练。

accumulation_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): outputs = model(images) loss = criterion(outputs, labels) loss = loss / accumulation_steps # 平均梯度 loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

这里有两个重要的点:

  • 除积步数是因为分别 backward 出去的梯度会叠加,如果不归一化,等效 batch size 变大但学习率不变,可能会震荡。
  • Batch Normalization 仍然在每个 micro batch 上独立统计均值方差,不会自动跨 micro batch 累积。所以梯度累积能让“优化器眼里”的 batch size 变大,但 BN 的统计口径没变。

这种方案适合视觉分类、语义分割一类模型,尤其当你只是想把显存峰值压下去,同时又不想牺牲精度时。

3.2 开混合精度 AMP,常见且收益直接

PyTorch 官方推荐的混合精度方案是torch.cuda.amp。原理并不复杂:前向计算和反向计算时,让大部分算子使用 FP16 张量参与运算,中间激活值也是 FP16,显存占用几乎减半;同时优化器侧仍然保留 FP32 主权重,保证训练精度。

开启方式非常简单:

scaler = torch.cuda.amp.GradScaler() for epoch in range(epochs): for images, labels in train_loader: optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

很多新手会漏掉GradScaler,只用了autocast,这样容易在 FP16 梯度极小值状态下出现 underflow,损失表现为不下降。GradScaler会先在损失上放大一个倍数,反向传播时再还原梯度,有效避免精度溢出,所以这两者必须配合。

实际体验中,开启 AMP 后不仅显存下降,训练速度往往也会提升,因为部分 CUDA 算子对 FP16 有硬件加速。但要注意某些自定义损失函数里的 log、exp 操作可能在 FP16 下不稳定,建议保留这些算子在 FP32 下计算,一般只需要把对应代码从autocast区域里单独放到外面,或使用自动混合精度策略提供的白名单/黑名单。

3.3 梯度检查点:用算力换显存

你的网络层数很深、单层中间激活又非常大时,梯度检查点是一个极具性价比的方案。其核心思想是:训练时只保存部分层的中间激活值,反向传播需要某层激活值时重新 forward 一次来算出结果,也就是“重新计算”。

类比一下:正常训练是每一层做完后把中间结果随身携带,检查点模式只带几个关键节点的结果,后面要用临时数据时再顺着原路算一遍。显存占用大幅降低,代价是增加了约 30% 的前向计算时间。

PyTorch 里可以直接使用torch.utils.checkpoint:

import torch.utils.checkpoint as cp def transformer_block_forward(x, layer): return layer(x) for layer in model.layers: x = cp.checkpoint(transformer_block_forward, x, layer)

注意这里传入的layer不能是 Python 普通参数,需要是张量或可被识别为输入的内容。对于使用 Transformer 的库,通常还有更省事的方式,例如所有PreTrainedModel都自带gradient_checkpointing_enable()方法,一行即可开启。

如果你的模型是自定义 ResNet 风格,也可以只在瓶颈阶段使用 checkpoint,比如每四个 block 包一个,这样计算开销增加不大,显存能省下很多。

3.4 及时释放中间变量和清空缓存

这是最容易被忽略的一招。很多人模型不大,batch size 也不高,但 OOM 出现在长时间训练的中后段。原因往往就是代码里某个循环不断把中间结果赋给同一个变量,旧的大张量没有及时释放,而 PyTorch 缓存池里的空间被越挤越碎。

我处理这种问题时的常规操作:

del large_tensor torch.cuda.empty_cache()

但要记住两个要点。第一,empty_cache()只释放 PyTorch 缓存池里“当前没在用”的块,它不会释放仍被变量引用的张量,所以一定要先del掉引用。第二,empty_cache()不是万能药,如果显存已经被当前模型和优化器状态占满,它并不会神奇地多出空间。

因此,这段代码最常用的场景是在每个 epoch 保存 checkpoint 后做一次,把验证阶段的临时显存喘一口气清理掉;或者在 OOM 异常捕获后主动释放并记录当时的峰值信息,而不是指望它从根本上解决问题。核心价值是避免“显存碎片化导致的假 OOM”,真正的大模型显存压力还得靠前面三招。

4. 从训练流程上消除“隐形”显存开销

4.1 算一笔账:模型训练到底吃多少显存

我经常遇到朋友问“为什么模型文件只有 200MB,训练却占了 6GB 显存”,这是因为训练显存消耗远不止参数体重。

一个典型训练过程显存主要由四部分构成:

  • 模型参数:模型当前参数的存储。
  • 梯度:每个参数对应一个梯度张量。
  • 优化器状态:例如 Adam 会保存一阶动量、二阶动量等额外状态。
  • 中间激活值:forward 过程中每层输出和反向传播需要的临时张量。

我用一个具体案例说明。假设某 Transformer 模型有 600M 参数,使用 FP16 混合精度:

  • 模型参数 FP16:约 600M × 2B = 1.2GB。
  • FP32 主权重梯度:约 600M × 4B = 2.4GB。
  • Adam 优化器的额外状态:一阶动量和二阶动量都是 FP32,约 600M × 4B × 2 = 4.8GB。
  • 中间激活值:取决于 batch size 和序列长度,可能从几百 MB 到几个 GB 不等。

总显存轻轻松松就是 8GB 起步,看起来自然和“模型文件只有 1GB”完全对不上。这还没算损失函数内部构造的张量,以及 DataLoader 图像预处理的临时变量。

所以当你面对 OOM 时,先按这个公式在纸上估算一次,就能知道自己优化空间到底还剩多少。如果光参数、梯度和优化器状态就已经压满显存,那调 batch size 也救不了,只能上模型并行、混合精度或换更大显存。

4.2 验证阶段也要留意计算图

一个常见且隐蔽的 OOM 场景是训练正常,但跑验证集时爆显存。很多人第一反应是模型太大,其实原因往往是验证时没有关闭梯度计算。

只要在model.eval()下没有使用torch.no_grad()或@torch.no_grad(),每次 forward 依然会保存中间激活到计算图里,为可能的 backward 做准备。验证集如果样本多、batch 设得大,保留的计算图会持续累积,直到 OOM。

正确的验证循环长这样:

model.eval() with torch.no_grad(): for images, labels in val_loader: outputs = model(images) val_loss += criterion(outputs, labels).item()

另外,验证阶段不要贪图方便把所有预测结果堆到一个 list 里,尤其当输出维度很大时(比如分割图的 logits),整个 epoch 攒下来会新增几个 GB 内存。建议在轮次内直接统计正确数或保存到磁盘,避免显存、内存“双爆”。

4.3 DataLoader 与多卡方案选择的影响

DataLoader 的pin_memory=True会把数据放到锁页内存,加快 CPU 到 GPU 的拷贝速度。这个操作主要消耗 CPU 内存,不直接占显存,但在多进程场景下,如果num_workers开太高,很容易把系统内存耗尽,系统频繁换页,傀儡进程卡住反而拖慢训练,间接影响显存释放节奏。

我实际遇到过一次:某个项目 CPU 内存只有 32GB,num_workers=32,训练到第三个 epoch 时 CPU 内存被打满,整个机器几乎失去响应,GPU 显存却一直显示正常占用。这种问题的表象不是 CUDA OOM,但它会放大训练循环内部的异常状态。合理经验是num_workers设置在核心数一半以内,并保持persistent_workers=True(PyTorch 较新版本支持)来省去频繁启停 worker 的开销。

多卡场景也有讲究。DataParallel因为会在主卡做一次输入分发和输出汇总,主卡显存往往明显高于其他卡;相比之下,DistributedDataParallel每张卡各算各的前向和反向,只同步梯度,显存均衡性更好,模型更大时优势更明显。如果发现多卡训练中某张卡率先 OOM,而其他卡还有剩余,优先确认是否误用了 DataParallel,或代码中某些损失计算、评价逻辑集中到了单卡。

5. 实操排障实录:症状对照与高频问答

5.1 一张速查表解决大部分场景

为了方便大家快速定位,我把平时遇到最多的 OOM 相关情况整理成了表格,对着症状找处理方向:

症状表现可能原因优先处理建议
训练刚开始第一步就 OOM模型本身太大、优化器状态过大或 batch 太大先看memory_summary当前值与峰值;临时用单样本 forward 验证主动学
训练到某个特定 epoch 后每隔一段时间 OOM显存碎片化或缓存池不断膨胀;验证阶段没有清缓存在 epoch 结束处del临时变量并empty_cache;检查是否存在无限累积的 list 或张量
验证阶段 OOM忘记torch.no_grad()/ 验证数据全部堆在变量里补no_grad;验证循环中按 batch 统计结果
batch size 调到很小还是 OOM参数/梯度和优化器状态已经把显存吃满用公式估算固定开销;开启混合精度或梯度检查点;换卡
多卡训练中某张卡提前爆DataParallel 负载不均;某些聚合操作在主卡执行切换到DistributedDataParallel;检查代码中是否把所有loss汇总到主卡
nvidia-smi 显示残留进程占用前一次任务崩溃后进程未退出kill -9对应 PID,或让运维清理僵尸进程

这张表我贴在工位上很长时间,基本遇到 OOM 先扫一眼,能省下不少反复试错的功夫。

5.2 高频问答实录

问:为什么我把 batch size 从 32 降到 2,显存还是不够?

答:说明显存大头已经不在激活值上,而是被模型参数、梯度和优化器状态占据。尤其使用 Adam 的模型,参数每增加一倍,显存增加的光是优化器状态就非常可观。这时候优先考虑 AMP 混合精度和梯度检查点,如果还不够,就要模型并行或者直接换更大显存的机器了。

问:torch.cuda.empty_cache()之后,为什么nvidia-smi还显示显存被占用?

答:empty_cache()释放的是 PyTorch 缓存池中“可释放”的块,CUDA runtime 未必立刻回收给驱动。尤其你还开着其他引用时,显存占用数值不会立刻变低。它更适合用来整理缓存碎片,不是释放显存的万能按钮。

问:我在torch.no_grad()下验证,为什么显存还是稳步上升?

答:确认一下是否在循环里把每个 batch 的outputs都 append 到了一个大列表中。列表持有每个 batch 的张量引用,即使没有计算图,大维度输出也会在轮次过程中越攒越多。正确做法是只累积统计量(正确数、损失和),不要保留整批张量。

问:用PYTORCH_CUDA_ALLOC_CONF能解决 OOM 吗?

答:这个环境变量可以调整 PyTorch 缓存分配器的策略。比如max_split_size_mb控制大块分割阈值,expandable_segments:True在较新版本里能降低显存碎片化。它能解决“碎片型”OOM,但无法降低模型本身的真实显存占用。我通常会在定位到碎片问题后再用它,而不是一上来就配置。

5.3 分享一个我踩过的坑

有一阵子训练图像分割模型,batch size 已经降到了 4,AMP 也开了,但每次跑到第 40 多个 batch 就稳定 OOM。用memory_summary看峰值并不高,单卡剩余显存也一直有几百 MB,按理说不该爆。

后来逐步排查才发现,我在数据预处理里顺手加了一个“把每个 batch 的原图保存到可视化列表”的调试逻辑,每隔 10 个 batch 会把列表里的图像画成网格写盘。由于保存逻辑每次都会把 GPU 上的张量.cpu()复制到内存,再生成大画布,一张拼接图就有几百 MB,内存侧被拖住,GPU 侧的显存缓存也没法及时回收,最终以一种很隐蔽的方式触发了 OOM。

这个坑让我学到两点:第一,调试用的可视化逻辑上线时有命名空间隔离,不能和训练主循环混在一起;第二,遇到反复 OOM 时,除了模型本身,还要审视训练循环外那些“不起眼”的辅助逻辑。很多时候代码里的中间张量、日志记录、可视化代码,才是压垮显存和内存的最后一根稻草。

如果你也长期被 CUDA OOM 困扰,我建议下一次先做两件事:在异常处理里打印torch.cuda.memory_summary(),同时把nvidia-smi监控打开,观察是训练哪个阶段发生崩溃。我在实际处理过的绝大多数项目中,最后发现并不是卡不够用,而是代码里多留了几个不该留的大张量,或者漏了一句no_grad()。先稳住训练流,再谈硬件升级,才能避免花了大价钱换卡却依然跑不起来的尴尬。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询