4、碎片产生的根源:频繁分配释放、大小不一、生命周期交错——碎片产生的三大元凶
好,咱们今天来聊聊一个让所有GPU开发者都头疼的问题——显存碎片。
你可能有过这种经历:程序跑着跑着,突然报了个“out of memory”。你一看显存占用,明明还有好几GB空闲,怎么就OOM了呢?
嗯,这大概率就是碎片在作祟。
我在做分布式训练框架的时候,就曾被这个问题折磨过整整两周。每次跑到第37个epoch必挂,排查到最后,发现是显存被切成了无数个小块,连一个完整的张量都放不下了。
那碎片到底是怎么产生的?说白了,就三个元凶:频繁分配释放、大小不一、生命周期交错。咱们一个一个拆开看。
4.1 元凶一:频繁分配释放
你想想看,如果你的程序里有个循环,每次迭代都cudaMalloc和cudaFree一次,会发生什么?
每次分配,CUDA驱动都会去翻找一块连续的空闲内存。释放的时候,这块内存就变成了一个“空洞”。
循环跑1000次,显存里就多了1000个空洞。这些空洞大小不一,位置随机。下次再分配时,可能没有一个空洞能装下你的数据。
我见过最夸张的一个项目,一个batch里调用了上万次cudaMalloc。结果显存利用率不到40%,剩下的全成了碎片。
4.2 元凶二:大小不一
如果每次分配的内存块大小都一样,那其实还好。就像切豆腐,每块都一样大,用完放回去,下次还能用。
但现实是残酷的。你的模型里有各种形状的张量:
- 权重矩阵:1024x1024
- 偏置向量:1024
- 中间激活值:64x64x256
- 梯度缓存:和权重一样大
这些大小完全不同的内存块,就像不同尺寸的积木。你把它塞进显存里,再拿出来,留下的空洞就很难被后续的分配完美填上。
举个例子:你分配了一个1MB的块,然后释放了。接着想分配一个2MB的块。如果1MB的空洞旁边没有连续的1MB空闲空间,那这个2MB的请求就只能去更远的地方找空间。
久而久之,显存里就布满了“小碎块”——每个单独看都不小,但就是凑不出一块大的。
4.3 元凶三:生命周期交错
这个最隐蔽,也最致命。
假设你有三个张量A、B、C:
- A在时间点t0分配,t2释放
- B在t1分配,t3释放
- C在t2分配,t4释放
你看,A和B的生命周期有重叠,B和C也有重叠。但A和C没有重叠。
当A在t2释放时,它留下的空间被B占着(因为B还没释放)。等B在t3释放时,A的空间已经被C或者其他东西占用了。
这就导致了一个结果:明明每个块都释放了,但空闲空间就是无法合并成连续的大块。
我把它叫做“内存版的俄罗斯方块”——每个块都在不同的时间点出现和消失,最后留下的空洞形状复杂到让人崩溃。
4.4 三大元凶的协同效应
这三个元凶很少单独出现。它们通常会一起上,形成“碎片化风暴”。
比如在动态图训练中:
- 每次前向传播都会创建新的中间张量(频繁分配)
- 不同层的输出大小不同(大小不一)
- 有些张量在反向传播后才释放,有些则要保留到优化器更新(生命周期交错)
三者叠加,显存很快就变成了一盘散沙。
下面这张图可以帮你直观理解这个过程:
你看,明明总空闲空间有300MB(空洞150MB + 尾部空闲150MB),但因为不连续,200MB的D只能挤在尾部。那个150MB的空洞,就这么浪费了。
4.5 如何判断你的程序是否被碎片困扰?
这里我分享一个实用的小技巧:
| 症状 | 表现 | 可能性 |
|---|---|---|
| 显存占用不高但OOM | nvidia-smi显示占用60%,但分配新张量时报错 | 极高 |
| 训练速度逐渐变慢 | 前几个epoch很快,后面越来越慢 | 中等 |
| cudaMalloc耗时波动大 | 有时1ms,有时100ms | 极高 |
| 程序在固定迭代次数后崩溃 | 每次都在第N次迭代后OOM | 高 |
cudaMemGetInfo 在关键节点打印显存使用情况。对比 free 和 total - used 的差值,差值越大,碎片越严重。
好了,三大元凶讲完了。总结一句话:频繁分配释放是“量”的问题,大小不一是“形”的问题,生命周期交错是“序”的问题。 三者叠加,就是显存碎片的完美风暴。
下一节,咱们聊聊怎么用cudaMallocAsync和流式分配来对抗这些元凶。嗯,那是个好东西。