3、显存分配痛点:cudaMalloc的延迟问题、显存碎片化、频繁分配释放的开销
好,咱们今天聊点实在的。
你写CUDA代码的时候,是不是经常这么干?
float *d_data;
cudaMalloc(&d_data, N * sizeof(float));
// ... 做点计算 ...
cudaFree(d_data);
看起来挺干净,对吧?
但我要告诉你,这套写法在工程里就是个坑。而且是大坑。
我自己刚入行那会儿,就被这个坑摔得鼻青脸肿。有一次跑一个图像分割模型,显存才8GB,模型本身也就占6GB,结果跑着跑着就OOM了。我当时就纳闷:剩下的2GB去哪了?
后来一查,全是碎片。
3.1 cudaMalloc的延迟问题
先说说延迟。
cudaMalloc 这个函数,看着简单,背后做的事儿可不少。它需要和驱动层交互,去申请一块物理连续的显存页。这个过程涉及到PCIe通信、页表更新、TLB刷新……
你想想看,一次 cudaMalloc 调用,少说几十微秒,多则几百微秒。如果你在推理循环里每帧都调一次,那延迟就上去了。
我做过一个测试:
| 操作 | 延迟(微秒) |
|---|---|
| cudaMalloc (1MB) | ~45 μs |
| cudaMalloc (100MB) | ~120 μs |
| cudaFree (1MB) | ~30 μs |
| 普通memcpy (1MB) | ~5 μs |
看到了吗?一次分配的开销,比一次数据拷贝还大。这还只是1MB。如果你分配大块显存,延迟更夸张。
核心结论:cudaMalloc 不是轻量级操作。它是个重量级系统调用。频繁调用,性能直接崩。
3.2 显存碎片化
这个更隐蔽。
显存碎片化,说白了就是:你的显存明明还有空闲,但就是分配不出连续的大块内存。
为什么会这样?
我举个例子。你分配了三个缓冲区:A(100MB)、B(200MB)、C(100MB)。然后你把B释放了。这时候显存里就有两个100MB的空闲块,中间夹着一个200MB的已分配块。你想再分配一个150MB的缓冲区?对不起,没有连续空间,分配失败。
这就是外部碎片。
还有一种叫内部碎片。比如你分配了1024字节,但实际只用了100字节。剩下的924字节就浪费了。虽然单次浪费不多,但架不住次数多啊。
避坑指南:我曾经在一个视频处理项目里,因为频繁分配释放不同尺寸的临时缓冲区,导致显存碎片率高达40%。最后不得不把所有缓冲区都预先分配好,才解决问题。
碎片化的后果是什么?
- OOM(显存溢出)——明明总量够,但分配不出来
- 分配延迟增加——驱动需要花时间做碎片整理
- 性能抖动——有时候快,有时候慢,没法预测
3.3 频繁分配释放的开销
这个其实前面已经提到了,但我想单独拎出来说。
频繁分配释放,不仅仅是延迟问题,还有更严重的后果:
- CPU-GPU同步开销:每次
cudaMalloc和cudaFree都会触发隐式同步。你想想看,GPU正在跑kernel,你这边一个cudaFree下去,它得等GPU跑完才能释放。这期间CPU就卡住了。 - 驱动层锁竞争:多线程环境下,多个线程同时调
cudaMalloc,驱动内部有锁,会导致线程阻塞。我见过一个项目,8个线程同时分配显存,性能直接降到单线程水平。 - 内存带宽浪费:分配和释放本身不占带宽,但频繁分配会导致页表频繁更新,TLB miss率上升,间接影响访存性能。
注意:在实时性要求高的场景(比如自动驾驶、直播推流),频繁分配释放是绝对不能接受的。一次分配延迟抖动,可能导致整个pipeline掉帧。
3.4 一张图看懂显存分配痛点
下面这张图,是我自己画的。它把这三个痛点串起来了。
嗯,这张图很直白。三个痛点最终都指向同一个方向——我们需要一个显存池。
3.5 我的经验总结
做了这么多年GPU开发,我总结了几条铁律:
- 能不分配就不分配:能复用就复用,别图省事每次都 new/delete
- 要分配就一次分配够:预分配一个大池子,后面从池子里取
- 分配策略要匹配业务:图像处理用固定大小池子,训练用动态扩容池子
一个小技巧:我习惯在程序启动时,用 cudaMalloc 一次性分配一个大的显存池(比如总显存的80%),然后自己实现一个内存分配器。这样后续所有分配都在用户态完成,零系统调用,零延迟抖动。
好了,这一章就聊到这儿。下一章我们开始动手,写一个最简单的显存池出来。
想要更多资料,联系可可