3、碎片化根因分析:频繁申请释放小张量、动态shape导致的内存空洞、多流并发带来的分配冲突、梯度检查点与重计算的内存模式
好,咱们直接切入正题。显存碎片化这个问题,说白了就是「内存管理不当」导致的。我见过不少团队,模型能跑,但一上大batch就崩,或者训练到一半突然OOM。查来查去,根子往往就在这几个地方。
我个人习惯,遇到碎片化问题,先不急着调代码。先问自己:我的显存是怎么被「切碎」的?
3.1 频繁申请释放小张量
这是最常见的「凶手」。你想想看,PyTorch或者TensorFlow在运行时,会频繁创建和销毁临时张量。比如一个简单的加法 c = a + b,背后可能就创建了一个临时buffer。
这些小张量,大小可能只有几KB到几MB。频繁申请释放,就像在内存里反复打孔。时间一长,显存就被打成了「筛子」。
核心问题:小张量释放后留下的空洞,无法被后续的大张量申请利用。
我在项目中遇到过这样一个案例:一个BERT微调任务,显存占用只有11GB(卡是16GB),但跑着跑着就OOM了。我dump了显存快照一看,好家伙,空闲碎片加起来有3GB,但最大的连续块只有200MB。这就是典型的「小碎片累积」。
避坑指南:我曾经在代码里用torch.no_grad()包住一些中间计算,结果发现显存碎片率从15%降到了3%。原因很简单——减少了临时张量的创建。
3.2 动态shape导致的内存空洞
动态shape,说白了就是每次迭代输入数据的尺寸不一样。比如NLP里的变长序列,或者目标检测里不同尺寸的图片。
为什么会造成空洞?
假设第一次分配了一个 [4, 512, 768] 的张量,第二次变成了 [4, 256, 768]。释放掉第一个后,留下一个512大小的槽位。但下次如果来了个 [4, 384, 768],这个槽位就装不下,只能重新找更大的地方。一来二去,空洞就越来越多。
我建议,能固定shape就尽量固定。实在不行,用padding对齐到某个粒度(比如64的倍数)。
| 场景 | 碎片化风险 | 我的建议 |
|---|---|---|
| 固定shape | 低 | 优先使用 |
| 动态shape(未对齐) | 高 | 使用padding或bucket策略 |
| 动态shape(对齐到128) | 中 | 可接受,但需监控 |
3.3 多流并发带来的分配冲突
多流(Multi-stream)是CUDA里提升并行度的好手段。但成也萧何败也萧何——多流并发时,不同流上的内存分配请求会相互干扰。
举个例子:流A在分配一个 [1024, 1024] 的张量,流B同时释放了一个 [512, 512] 的张量。CUDA的内存分配器为了线程安全,可能会加锁。结果就是:流A等流B释放完,但流B释放的位置不一定适合流A。最终分配器只能从更远的地方切一块出来。
嗯,这里要注意:多流场景下,内存碎片率往往比单流高30%-50%。我做过一个实验,同样的模型,单流跑碎片率8%,双流跑直接飙到22%。
警告:不要以为多流只是「并行执行」那么简单。内存分配器的行为会变得非常不可预测。我建议在关键路径上使用cudaMallocAsync配合显存池来缓解。
3.4 梯度检查点与重计算的内存模式
梯度检查点(Gradient Checkpointing)是个好东西,它用时间换空间。但它的内存模式,其实对碎片化不太友好。
为什么?
因为检查点会「分段」保存中间激活值。前向计算时,只保留几个关键节点的张量。反向传播时,再重新计算中间的张量。这一「释放」一「重新申请」,恰好就是碎片化的温床。
我见过一个极端案例:一个50层的Transformer,用了梯度检查点后,显存峰值从24GB降到了12GB。但碎片率从5%涨到了18%。原因就是反向传播时频繁重计算,大量临时张量反复申请释放。
说白了,梯度检查点是把「空间压力」转化成了「时间压力」和「碎片压力」。用的时候要心里有数。
我的经验:如果你用了梯度检查点,建议同时开启torch.cuda.empty_cache()的定期清理,或者使用自定义的显存池。我曾经在一个项目里,把检查点间隔从每2层改成每4层,碎片率直接降了一半。
知识体系总览
下面这张图,是我自己总结的碎片化根因分析框架。你可以把它当作排查问题的「地图」。
这张图把四个根因、它们各自造成的后果,以及最终的解决方向串在了一起。你排查问题时,可以对照着看:你的场景属于哪一种?
我个人觉得,碎片化问题最坑的地方在于——它不会立刻报错,而是慢慢累积,直到某个临界点突然崩掉。所以,监控和预防比事后补救重要得多。
好了,根因分析就到这里。记住这四个方向,后面咱们讲具体怎么「治」。