2、CUDA内存池机制:CUDA Driver的内存分配策略、cudaMalloc的延迟分配、cudaFree的回收机制、内存池的默认行为
聊完显存碎片,咱们得直面一个核心问题:CUDA到底怎么管显存?
很多同学写CUDA代码,上来就是cudaMalloc一把梭。用完了再cudaFree。看起来挺干净,对吧?
但说实话,这种“裸分配”的方式,在长时间运行的服务里,简直就是灾难。我见过太多项目,跑着跑着突然out of memory,查半天发现是碎片太多,显存明明还有几百MB,就是分配不出一块连续空间。
为什么会这样?
嗯,这就要从CUDA Driver的内存分配策略说起了。
2.1 CUDA Driver的内存分配策略
CUDA Driver管理显存,本质上是一个虚拟内存系统。它把显存分成一个个页(page),默认大小是4KB或64KB(取决于架构和驱动版本)。
当你调用cudaMalloc时,Driver会做两件事:
- 分配虚拟地址空间:在进程的虚拟地址空间里,划出一段连续区域。
- 映射物理页:把物理显存页映射到这段虚拟地址上。
但这里有个关键点:这两步不是同时发生的。
核心概念:CUDA Driver采用延迟分配(Lazy Allocation)策略。也就是说,cudaMalloc返回时,只保证了虚拟地址空间可用,但物理显存页并没有真正分配。
我刚开始接触这个机制时,也觉得挺反直觉的。明明调了cudaMalloc,为什么cudaMemGetInfo看到的空闲显存没怎么变?
答案就是:物理页还没真正给你。
2.2 cudaMalloc的延迟分配
延迟分配,说白了就是“用到才给”。
当你执行cudaMalloc(&d_ptr, size)时,Driver只是:
- 在虚拟地址空间里预留了
size大小的区域 - 标记这些页为“未提交”(uncommitted)
- 返回一个虚拟地址指针
真正的物理显存分配,发生在第一次访问这些地址的时候。比如你第一次往这个指针里写数据,或者启动kernel去读它,Driver才会触发缺页中断(page fault),然后从物理显存里找空闲页,映射过来。
这样做的好处很明显:
- 节省显存:如果你分配了1GB,但只用了一小部分,剩下的物理页就不会被浪费。
- 启动更快:
cudaMalloc本身几乎不耗时,因为它只是虚拟地址操作。
但坏处也很致命:
- 碎片风险增加:因为物理页是“随用随分”,分配顺序和释放顺序可能完全错乱,导致大量外部碎片。
- 性能抖动:第一次访问时触发缺页,会有微秒级的延迟。在实时性要求高的场景里,这很要命。
避坑指南:我曾经在一个推理服务里,发现每次请求的第一次kernel调用特别慢。排查了半天,原来是cudaMalloc的延迟分配在作祟。解决方案?在初始化阶段,对分配好的显存做一次cudaMemset,强制触发物理页分配。这样运行时就不会有缺页开销了。
2.3 cudaFree的回收机制
说完分配,咱们看看释放。
cudaFree的行为,比很多人想象的要“懒”得多。
当你调用cudaFree(ptr)时,Driver会:
- 解除虚拟地址映射:把
ptr对应的虚拟地址空间标记为“空闲”。 - 回收物理页:把物理显存页归还给Driver的内部空闲池。
- 但不会归还给OS:这些物理页仍然被Driver持有,不会真正释放给操作系统。
换句话说,cudaFree之后,你用cudaMemGetInfo看到的空闲显存,可能并没有增加多少。因为Driver把那些页“藏”起来了,留着自己用。
为什么要这么做?
嗯,原因很简单:避免频繁的页分配开销。如果每次cudaFree都把物理页还给OS,下次cudaMalloc时又要重新申请,这中间的延迟和系统调用开销,谁也受不了。
注意:这种“懒回收”机制,会导致一个现象:你的进程占用的显存只增不减。即使你释放了所有显存,nvidia-smi看到的显存占用可能还是很高。这不是内存泄漏,而是Driver的缓存策略。但如果你在写长时间运行的服务,一定要小心——这种缓存可能会让其他进程申请不到显存。
2.4 内存池的默认行为
从CUDA 11.0开始,NVIDIA引入了显存池(Memory Pool)机制。说白了,就是Driver自己维护一个“显存仓库”,所有cudaMalloc和cudaFree都在这个仓库里操作。
默认情况下,每个CUDA设备都有一个默认内存池。它的行为是这样的:
| 操作 | 默认行为 |
|---|---|
| cudaMalloc | 从内存池中分配一块空闲区域。如果池中不够,Driver会向OS申请新的物理页,并加入池中。 |
| cudaFree | 将显存块归还给内存池。池会尝试合并相邻的空闲块,减少碎片。 |
| 池大小 | 默认无上限。Driver会一直持有释放的物理页,直到进程退出。 |
| 碎片整理 | 默认不主动整理。但Driver会在分配时尝试合并相邻空闲块。 |
这个默认行为,对于大多数应用来说,已经够用了。但如果你遇到以下情况,就得考虑手动调整:
- 显存占用只增不减:默认池不会把物理页还给OS,导致
nvidia-smi看到的占用持续上升。 - 碎片严重:默认的合并策略很保守,无法处理复杂的碎片场景。
- 多进程共享GPU:一个进程的池占用了大量显存,其他进程可能分配不到。
我的建议:如果你在写推理服务、数据库、或者任何长时间运行的CUDA程序,不要完全依赖默认内存池。至少要做到:
- 监控显存碎片率(可以用
cudaMemPoolGetAttribute查询) - 定期调用
cudaMemPoolTrimTo,把多余物理页归还给OS - 考虑使用
cudaMemPoolCreate创建自定义池,设置大小上限
好了,这一章的内容就到这里。CUDA内存池的默认行为,说白了就是“懒分配+懒回收+懒整理”。理解了这个“懒”字,你就能明白为什么显存碎片会越来越严重,也知道该从哪里下手去治理它。
一句话总结:CUDA内存池的默认行为,就是“三懒”——懒分配、懒回收、懒整理。理解了这个,你就掌握了治理显存碎片的第一把钥匙。