深度学习显存机制:张量存储、梯度、优化器状态
显存到底被谁吃掉了?很多人以为只有模型参数占显存,其实远不止。我刚开始做训练优化时,也这么认为。结果一跑大模型,OOM 了,才明白水有多深。今天咱们就把这件事彻底拆清楚。
显存里的东西,按生命周期可以分成三类:持久驻留的模型参数和优化器状态,每次迭代创建并释放的前向激活缓存,还有反向传播时临时出现的梯度。另外还有输入数据、临时工作区等等。下面我一个个说。
1. 张量存储:模型参数与前向激活
模型参数是最直观的。假设你有一个 7B 参数的模型,每个参数用 FP32 存——那就是 7B × 4 bytes = 28 GB。换成 FP16 或者 BF16,直接减半到 14 GB。所以混精度训练能把模型塞进更小的显存里。
但真正的大头往往不是参数,而是前向激活缓存。反向传播需要用到每一层的中间输出,这些输出要一直保留到该层梯度算完才能释放。我见过一个 12 层 Transformer,激活缓存的大小是参数量的 2~3 倍。如果你用超长序列,激活甚至能吃掉 80% 的显存。
举个例子:一个 LLaMA-7B 在 batch size 1、序列长度 2048 时,激活占用约 16 GB,参数 + 梯度 + 优化器状态才 28 GB。所以有时候你以为调大 batch size 没问题,其实激活先爆了。
经验之谈:我个人的习惯是,在调大 batch size 之前,先用一个小 batch 跑一遍,打印出 torch.cuda.memory_allocated() 和 torch.cuda.max_memory_allocated() 的差,能快速看到激活缓存的上限。
查看当前显存分配的 Python 代码(PyTorch 环境下):
import torch
print(f"当前分配: {torch.cuda.memory_allocated() / 1e9:.2f} GB")
print(f"最大分配: {torch.cuda.max_memory_allocated() / 1e9:.2f} GB")
print(f"缓存总量: {torch.cuda.memory_reserved() / 1e9:.2f} GB")
2. 梯度:动态分配,及时清理
梯度是在反向传播过程中产生的,形状和对应参数一样。所以如果参数是 28 GB,梯度也是 28 GB(FP32)。不过梯度不用一直保留——每个参数梯度算完后,就可以被覆盖或释放。正常训练中,梯度和激活释放的时机是交错的,峰值显存通常出现在反向传播的中后期。
我曾经犯过一个错:为了方便,把所有梯度都 retain 下来做梯度剪裁,结果显存直接翻倍。后来改用 in-place 的 clip_grad_norm_,这问题就没了。记住:不需要的梯度别留着。
还有一个点:梯度是否显式占用显存?是的。在 PyTorch 里,只要设置了 requires_grad=True,张量就会有一块独立的梯度存储空间(默认是 None,只有调了 backward 才会分配)。如果你用 torch.no_grad() 包住某些计算,就不会为那些输出分配梯度空间。这个技巧在推理或特征提取时很实用。
3. 优化器状态:被忽视的显存黑洞
优化器需要保存额外的状态才能更新参数。不同的优化器,额外开销不一样。我整理了一个表格,方便你看清楚:
| 优化器 | 每个参数额外状态 | 对 FP32 参数的大致额外倍率 |
|---|---|---|
| SGD (无动量) | 无 | 0x |
| SGD (带动量) | 动量缓冲区(与参数同大小) | 1x |
| Adam / AdamW | 一阶动量 + 二阶动量(各一份) | 2x(如果用 FP32) 1x(如果用 FP16 混合精度) |
| Adafactor | 对每个矩阵只存行列统计量,非全量 | 约 0.1x ~ 0.5x |
看到没?用 AdamW 训练一个 7B 模型,参数 28 GB,优化器状态 56 GB,加上梯度 28 GB,总共已经 112 GB 了!这还没算激活。所以我自己做预训练时,要么用 Adafactor 节约显存,要么用 BF16 优化器状态(比如 bitsandbytes 的 8-bit Adam)。
坑我跳过:有一次我偷懒用了 FP16 的 Adam,但忘了把参数也转成 FP16,结果 optimizer.step() 内部做了类型转换,显存瞬间翻倍,直接 OOM。记得保持参数和优化器状态的 dtype 一致,或者用专门的混合精度封装。
4. 一张图看清显存构成
下面这张 SVG 把整个显存占用拆解成几个主要部分,箭头表示数据流和依赖关系:
5. 实战:如何计算你的工作集大小
知道了这些组件,我们就能估算一个训练 step 的显存峰值。假设参数用 FP16,激活用 FP16,梯度用 FP16,优化器动量用 FP16:
- 模型参数量:N(每个参数 2 bytes)→ 2N
- 激活缓存:根据模型架构和序列长度,通常是参数的 2~4 倍(经验值)
- 梯度:和参数大小一样 → 2N
- 优化器状态:Adam 额外 2×2N = 4N(一阶 + 二阶,每个 2 bytes)
- 输入数据 + 临时缓冲区:约 1~2 GB
所以总显存 ≈ 2N (参数) + (2~4)×2N (激活) + 2N (梯度) + 4N (优化器) + 1~2 GB。如果 N=7B,那就是 2×7 + 3×14 + 2×7 + 4×7 + 2 = 14 + 42 + 14 + 28 + 2 = 100 GB。一块 A100 80G 装不下,得用两张或换优化器。
我的做法:先用上面公式粗算,然后写个脚本跑一个小 batch 并打印峰值显存(torch.cuda.max_memory_allocated())。如果峰值比我算的还高,那就查查是否有多余的梯度保留或者缓存没清。
最后强调一个很容易忽略的点:框架的缓存分配器(比如 PyTorch 的 caching allocator)会多预留一部分显存,避免频繁 cudaMalloc。你看到的 memory_allocated 是实际使用的,而 memory_reserved 是总占用的。调 batch size 时,盯着 max_memory_reserved 会更准。
坑我踩过:有一次我手动调了 torch.cuda.empty_cache(),以为能释放显存,结果反而导致下一 step 重新分配更慢,训练变慢了。后来就不瞎调了——让分配器自己管理就好。