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 驱动打交道。

调用链路大概是这样的:

  1. 你的代码调 cudaMalloc(&ptr, size)
  2. CUDA Runtime API 收到请求,打包成命令
  3. 通过驱动层(CUDA Driver API)下发到 GPU 驱动
  4. GPU 驱动调用内核态的内存管理模块
  5. 最终在显存里划出一块区域,返回地址

整个过程涉及两次上下文切换(用户态→内核态→用户态),还有一次 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 内存分配器的整体架构:

GPU 内存分配器架构 用户代码:cudaMalloc(ptr, size) CUDA Runtime / Driver API 分配器决策:size > 阈值? 是(大块) 否(小块) Buddy System 2的幂次分配 / 合并 Slab Allocator 固定大小对象池 GPU 显存(HBM / GDDR)

你看,整个流程很清晰。用户调 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。这样后期调优时能省很多事。你想想看,与其等碎片问题爆发了再救火,不如一开始就做好规划。

嗯,这一章就到这里。记住一句话:理解分配器,才能用好显存。


想要更多资料,联系可可