3、内存分配器原理:系统调用mmap与brk的代价,内存池(Memory Pool)的设计哲学
3.1 系统调用的真实代价
聊内存分配,绕不开两个系统调用:brk 和 mmap。很多人觉得它们就是「申请内存」而已,没什么大不了的。但我在项目中吃过亏,才真正明白它们的代价有多大。
先说说 brk。它本质上是移动程序的数据段边界。你调用 malloc(1024),底层可能就调用了 brk,把堆顶往上推一点。听起来很轻量对吧?
但问题来了:brk 是串行的。它操作的是整个进程的堆空间,一次只能有一个线程调用。在高并发场景下,这就是一个隐形的全局锁。我曾经在一个推理服务里,发现 8 个线程同时分配小内存,性能直接腰斩。查了半天,罪魁祸首就是 brk 的竞争。
再说 mmap。它更重。每次调用都要经过:
- 查找虚拟地址空间中的空闲区域
- 建立页表映射
- 分配物理页(可能还要清零)
- 更新内核的 VMA 结构
这一套下来,少说几百个 CPU 周期。而且 mmap 分配的内存,释放时还要调用 munmap,又是一次完整的系统调用。你想想看,如果模型推理时每处理一个请求都来一次 mmap,延迟能不高吗?
核心结论:系统调用不是免费的。每次用户态到内核态的切换,TLB 刷新、上下文保存恢复,这些开销在模型部署这种毫秒级延迟敏感的场景下,完全不可接受。
3.2 内存池的设计哲学
既然系统调用这么贵,那怎么办?答案就是:一次申请,多次复用。这就是内存池的核心思想。
我习惯把内存池比作「批发和零售」。系统调用是批发商,你每次找它拿货,它都要走一遍流程。而内存池是零售商,它一次性从批发商那里拿一大块,然后拆成小份给你。你用完还回来,它还能再卖给别人。
内存池的设计,说白了要解决三个问题:
- 分配策略:怎么从池子里拿内存?
- 回收策略:用完了怎么还回去?
- 碎片管理:怎么避免越用越碎?
3.3 常见的内存池实现模式
3.3.1 固定大小块池(Fixed-size Pool)
这是最简单的模式。预先分配一大块内存,切成固定大小的块。每个块用链表串起来。分配时取头节点,释放时插回头部。
// 伪代码示例
struct Pool {
void* blocks; // 预分配的内存块
void* free_list; // 空闲块链表头
size_t block_size; // 每个块的大小
size_t block_count;// 块的数量
};
void* pool_alloc(Pool* pool) {
if (pool->free_list == NULL) return NULL;
void* block = pool->free_list;
pool->free_list = *(void**)block; // 取下个空闲块
return block;
}
void pool_free(Pool* pool, void* block) {
*(void**)block = pool->free_list;
pool->free_list = block;
}
这种模式适合模型中的张量内存分配。比如你的模型固定使用 4KB 的块,那用固定大小池就非常高效。分配和释放都是 O(1) 的,没有系统调用,没有锁竞争(如果每个线程有自己的池)。
我的经验:在部署 BERT 模型时,我把所有中间激活张量的大小对齐到 256 字节的倍数,然后用固定大小池管理。内存分配耗时从原来的 3μs 降到了 0.1μs 以下。效果立竿见影。
3.3.2 伙伴系统(Buddy System)
固定大小池有个局限:它只能处理一种大小。如果模型需要不同大小的内存块,怎么办?
伙伴系统就是解决方案之一。它的核心思想是:把内存按 2 的幂次分成不同大小的块。分配时,找刚好能装下请求大小的最小块。如果找不到,就分裂更大的块。释放时,检查相邻的「伙伴」是否也是空闲的,如果是就合并。
| 请求大小 | 分配块大小 | 内部碎片 |
|---|---|---|
| 100 字节 | 128 字节 | 28 字节 |
| 200 字节 | 256 字节 | 56 字节 |
| 500 字节 | 512 字节 | 12 字节 |
伙伴系统的优点是外部碎片少,合并策略简单。但缺点是内部碎片不可避免——你申请 100 字节,它给你 128 字节的块,多出来的 28 字节就浪费了。
注意:伙伴系统在频繁分配释放的场景下,分裂和合并操作本身也有开销。我曾经在一个实时推理系统中发现,伙伴系统的合并操作占了 15% 的 CPU 时间。后来改用 slab 分配器才解决。
3.3.3 Slab 分配器
Slab 分配器是 Linux 内核的经典设计。它结合了固定大小池和伙伴系统的优点。
核心思路是:
- 为每种常用大小维护一个 slab(相当于一个固定大小池)
- 每个 slab 由多个连续页面组成,切成固定大小的对象
- slab 之间用伙伴系统管理大块内存
在模型部署中,我常用 slab 分配器来管理不同大小的张量。比如:
// 简化的 slab 分配器结构
struct SlabCache {
size_t obj_size; // 对象大小
struct Slab* partial; // 部分空闲的 slab
struct Slab* full; // 已满的 slab
struct Slab* empty; // 完全空闲的 slab
};
struct Slab {
void* base; // slab 起始地址
int free_count; // 空闲对象数
int total_count; // 总对象数
unsigned long* bitmap; // 分配位图
struct Slab* next; // 链表指针
};
Slab 分配器的好处是:
- 分配和释放都是 O(1) 的
- 对象大小固定,没有内部碎片
- 通过着色(coloring)技术,可以优化 CPU 缓存利用率
3.4 内存池在模型部署中的实战
说了这么多理论,咱们看看实际怎么用。在模型部署中,内存池主要用在三个地方:
- 张量内存管理:模型推理时,中间激活张量频繁创建和销毁。用内存池可以避免反复 malloc/free。
- 请求级内存:每个推理请求需要临时存储。用内存池可以复用这些内存,减少系统调用。
- 设备内存(GPU/NPU):显存分配更贵,更需要内存池。很多框架的显存分配器本质上就是内存池。
避坑指南:我曾经在一个项目中,给每个线程都创建了独立的内存池。结果发现内存占用暴涨,因为每个池都要预留最大可能的内存。后来改成「全局池 + 线程本地缓存」的模式,既保证了性能,又控制了内存总量。这个模式其实就是 tcmalloc 的核心思想。
3.5 总结
内存分配器的选择,本质上是在「分配速度」和「内存利用率」之间做权衡。
- 系统调用(brk/mmap)是最后的手段,能不用就不用
- 固定大小池最快,但只适合单一大小
- 伙伴系统灵活,但有内部碎片
- Slab 分配器是工程上的最佳实践,兼顾了速度和利用率
嗯,最后说一句:没有银弹。具体用哪种,得看你的模型特点。如果你的模型张量大小很固定,固定大小池就够了。如果大小变化很大,那 slab 分配器更合适。关键是要理解每种方案的代价,然后做出选择。