3、内存分配器原理:系统调用mmap与brk的代价,内存池(Memory Pool)的设计哲学

3.1 系统调用的真实代价

聊内存分配,绕不开两个系统调用:brkmmap。很多人觉得它们就是「申请内存」而已,没什么大不了的。但我在项目中吃过亏,才真正明白它们的代价有多大。

先说说 brk。它本质上是移动程序的数据段边界。你调用 malloc(1024),底层可能就调用了 brk,把堆顶往上推一点。听起来很轻量对吧?

但问题来了:brk 是串行的。它操作的是整个进程的堆空间,一次只能有一个线程调用。在高并发场景下,这就是一个隐形的全局锁。我曾经在一个推理服务里,发现 8 个线程同时分配小内存,性能直接腰斩。查了半天,罪魁祸首就是 brk 的竞争。

再说 mmap。它更重。每次调用都要经过:

  • 查找虚拟地址空间中的空闲区域
  • 建立页表映射
  • 分配物理页(可能还要清零)
  • 更新内核的 VMA 结构

这一套下来,少说几百个 CPU 周期。而且 mmap 分配的内存,释放时还要调用 munmap,又是一次完整的系统调用。你想想看,如果模型推理时每处理一个请求都来一次 mmap,延迟能不高吗?

核心结论:系统调用不是免费的。每次用户态到内核态的切换,TLB 刷新、上下文保存恢复,这些开销在模型部署这种毫秒级延迟敏感的场景下,完全不可接受。

3.2 内存池的设计哲学

既然系统调用这么贵,那怎么办?答案就是:一次申请,多次复用。这就是内存池的核心思想。

我习惯把内存池比作「批发和零售」。系统调用是批发商,你每次找它拿货,它都要走一遍流程。而内存池是零售商,它一次性从批发商那里拿一大块,然后拆成小份给你。你用完还回来,它还能再卖给别人。

内存池的设计,说白了要解决三个问题:

  1. 分配策略:怎么从池子里拿内存?
  2. 回收策略:用完了怎么还回去?
  3. 碎片管理:怎么避免越用越碎?

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 内存池在模型部署中的实战

说了这么多理论,咱们看看实际怎么用。在模型部署中,内存池主要用在三个地方:

  1. 张量内存管理:模型推理时,中间激活张量频繁创建和销毁。用内存池可以避免反复 malloc/free。
  2. 请求级内存:每个推理请求需要临时存储。用内存池可以复用这些内存,减少系统调用。
  3. 设备内存(GPU/NPU):显存分配更贵,更需要内存池。很多框架的显存分配器本质上就是内存池。

避坑指南:我曾经在一个项目中,给每个线程都创建了独立的内存池。结果发现内存占用暴涨,因为每个池都要预留最大可能的内存。后来改成「全局池 + 线程本地缓存」的模式,既保证了性能,又控制了内存总量。这个模式其实就是 tcmalloc 的核心思想。

3.5 总结

内存分配器的选择,本质上是在「分配速度」和「内存利用率」之间做权衡。

  • 系统调用(brk/mmap)是最后的手段,能不用就不用
  • 固定大小池最快,但只适合单一大小
  • 伙伴系统灵活,但有内部碎片
  • Slab 分配器是工程上的最佳实践,兼顾了速度和利用率

嗯,最后说一句:没有银弹。具体用哪种,得看你的模型特点。如果你的模型张量大小很固定,固定大小池就够了。如果大小变化很大,那 slab 分配器更合适。关键是要理解每种方案的代价,然后做出选择。