3、内存分配器原理:cudaMalloc 背后发生了什么?Buddy System 与 Slab Allocator
很多同学写 CUDA 程序,上来就是 cudaMalloc,用完了 cudaFree,觉得挺简单。但你真的想过没有——你调这一行 API 的时候,GPU 那头到底发生了什么?
我早年做一次大规模训练任务时,就吃过这个亏。程序跑着跑着突然报 out of memory,可我明明算过显存够用啊。后来一查,是内存碎片把显存吃掉了。嗯,从那天起,我就决定把内存分配器这块彻底搞明白。
3.1 从 malloc 到 cudaMalloc:用户态到驱动态
先说说最直观的感受。你在 CPU 上调 malloc,是 glibc 在用户态帮你管理堆内存。但 cudaMalloc 不一样,它要跨过 PCIe 或 NVLink,跟 GPU 驱动打交道。
调用链路大概是这样的:
- 你的代码调
cudaMalloc(&ptr, size) - CUDA Runtime API 收到请求,打包成命令
- 通过驱动层(CUDA Driver API)下发到 GPU 驱动
- GPU 驱动调用内核态的内存管理模块
- 最终在显存里划出一块区域,返回地址
整个过程涉及两次上下文切换(用户态→内核态→用户态),还有一次 PCIe 事务。所以 cudaMalloc 其实挺重的,我建议你在性能敏感路径上尽量少调它。
核心要点:cudaMalloc 不是简单的堆分配,它走的是完整的驱动栈。每次分配都有不小的开销。
3.2 Buddy System:大块内存的分配策略
GPU 驱动拿到分配请求后,怎么管理显存呢?最经典的算法就是 Buddy System(伙伴系统)。
说白了,它的思路特别朴素:
- 把显存看成一大块连续空间,大小是 2 的幂次
- 要分配时,不断对半切,直到切出刚好够用的块
- 释放时,检查相邻的块(buddy)是否空闲,是的话就合并回去
举个例子。假设显存是 256MB,你要 32MB:
初始: [256MB]
第一次切: [128MB] [128MB]
第二次切: [64MB] [64MB] [128MB]
第三次切: [32MB] [32MB] [64MB] [128MB]
↑ 给你这个
释放的时候,如果它的 buddy(旁边的 32MB)也是空闲的,就合并成 64MB。再往上合并,直到不能合并为止。
我在项目中遇到过一个问题:频繁分配释放不同大小的显存,导致大量 2MB、4MB 的小碎片散落在各处。虽然总空闲空间够,但最大的连续块已经不够分配一个大 tensor 了。这就是典型的 外部碎片。
避坑指南:我曾经在一个推理服务里,每来一个请求就 cudaMalloc 一次,跑了一天后显存碎片化严重,新请求直接 OOM。后来改成内存池 + 预分配,问题就解决了。
3.3 Slab Allocator:小对象的救星
Buddy System 有个明显的短板——它只处理 2 的幂次大小。如果你要 33MB,它得给你 64MB 的块,浪费了将近一半。更别提那些几百字节的小分配了。
这时候 Slab Allocator 就登场了。它最早是 Solaris 内核里用的,后来 Linux 也学了去。GPU 驱动里也有类似的实现。
Slab 的思路是:
- 提前把一块大内存(比如 2MB)切成固定大小的小对象
- 每个小对象大小相同,比如 64 字节、128 字节、256 字节……
- 分配时直接从对应大小的 slab 里取一个,释放时放回去
这样做的好处很明显:
- 没有内部碎片:每个对象大小刚好
- 分配极快:O(1) 复杂度,比 buddy 的 O(log N) 快多了
- 缓存友好:同大小的对象在物理上连续,访问局部性好
个人习惯:我在写自定义内存池时,会结合两种策略。大块(>1MB)走 buddy,小块走 slab。这样既避免了外部碎片,又保证了小分配的效率。
3.4 两者如何协作?一张图说清楚
下面这张 SVG 图展示了 GPU 内存分配器的整体架构:
你看,整个流程很清晰。用户调 cudaMalloc,驱动层收到请求后,根据 size 大小走不同的分配路径。大块走 buddy,小块走 slab。两者最终都在显存上操作。
3.5 实际项目中的选择
说了这么多理论,咱们聊聊实战。你在写 CUDA 程序时,怎么利用这些知识?
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| 大 tensor(>1MB) | cudaMalloc 直接分配 | buddy 系统处理大块效率高,碎片少 |
| 小 buffer(<1MB) | 自定义 slab 池 | 避免 buddy 的内部碎片和分配开销 |
| 频繁分配释放 | 内存池 + 预分配 | 减少 cudaMalloc/cudaFree 的驱动调用次数 |
| 混合大小 | buddy + slab 两层架构 | 兼顾大块和小块的效率 |
一个小技巧:你可以用 cudaMemGetInfo 监控显存使用情况。如果 free 很大但 cudaMalloc 失败,十有八九是碎片问题。这时候跑一次 cudaDeviceReset 能清掉所有分配,但代价是丢失所有上下文。
3.6 总结一下
cudaMalloc 背后不是简单的堆分配,而是一套精心设计的内存管理系统。Buddy System 负责大块内存的分配与合并,Slab Allocator 负责小对象的快速分配。两者配合,才让 GPU 显存用起来既高效又灵活。
我个人习惯是:在项目初期就用内存池把显存管起来,而不是到处撒 cudaMalloc。这样后期调优时能省很多事。你想想看,与其等碎片问题爆发了再救火,不如一开始就做好规划。
嗯,这一章就到这里。记住一句话:理解分配器,才能用好显存。
想要更多资料,联系可可