2. 内存分配基础

大家好,我是你们的老朋友。今天咱们聊聊内存分配里最核心的三个东西:伙伴系统、SLAB分配器,还有页表映射。说实话,这三个东西是显存管理的基石,搞不懂它们,后面谈碎片优化就是空中楼阁。

我个人习惯把内存分配想象成「分蛋糕」。你有一整块蛋糕,怎么切、怎么分、怎么回收,才能让后面来的人都能吃上,而且不浪费?嗯,这就是我们要解决的问题。

一、伙伴系统(Buddy System)原理

伙伴系统,说白了就是「二分法切蛋糕」。我刚开始接触它时觉得挺简单,后来在项目里踩过坑才发现,细节里全是魔鬼。

1.1 核心思想

伙伴系统把内存分成2的幂次方大小的块。比如2KB、4KB、8KB……一直到最大块。分配时,如果找不到合适大小的块,就把大块一分为二,直到分出刚好够用的块。

举个例子:

  • 你申请3KB内存,系统找4KB的块给你
  • 如果没有4KB的块,就从8KB的块里切一半
  • 如果8KB也没有,就从16KB里切,以此类推

释放时呢?如果两个相邻的块都是空闲的,而且它们原本是从同一个大块切出来的(也就是「伙伴」关系),系统就把它们合并回去。这就是「伙伴」这个名字的由来。

关键点:伙伴系统最大的优点是「外碎片少」。因为所有块大小都是2的幂,合并后总能形成连续的大块。但内碎片问题明显——你申请3KB,给你4KB,浪费了1KB。

1.2 我在项目中遇到的坑

我曾经在GPU显存管理里用过伙伴系统,结果发现一个问题:显存里经常有大量的小块分配(比如几百字节的纹理描述符),伙伴系统对这些小块很不友好。每次分配都要切分、合并,开销很大。后来我加了一层SLAB分配器来兜底,才解决了这个问题。

1.3 伙伴系统的数据结构

伙伴系统通常用「空闲链表数组」来实现。每个数组元素对应一种大小的块,链表中挂着所有空闲块。

// 伪代码示例
struct free_area {
    struct list_head free_list;  // 空闲块链表
    unsigned long nr_free;       // 空闲块数量
};

struct buddy_system {
    struct free_area free_area[MAX_ORDER];  // MAX_ORDER 是最大阶数
    unsigned long total_pages;
};

分配时,从对应阶数的链表里取块。如果链表为空,就向上一阶借块,切分后把一半挂到当前阶数的链表里。

二、SLAB分配器原理

SLAB分配器,说白了就是「批量生产小零件」。伙伴系统适合分配大块内存,但如果你频繁申请释放几十字节的小对象,伙伴系统就扛不住了——每次都要切分合并,效率太低。

2.1 SLAB的设计思路

SLAB的思路很简单:预先从伙伴系统申请一大块内存(比如4KB),然后把它切成若干个大小相同的「对象槽」。每个槽放一个对象。分配时直接从槽里取,释放时把槽标记为空闲。

这样做的好处:

  • 分配和释放都是O(1)复杂度,极快
  • 没有内碎片(对象大小固定)
  • 缓存友好(同一类对象放在一起)

个人经验:我建议在显存管理里,把频繁分配的小对象(比如描述符、句柄、小纹理)都用SLAB来管理。我曾经在一个项目里,把显存里的小对象分配延迟从微秒级降到了纳秒级,效果立竿见影。

2.2 SLAB的结构

一个SLAB分配器通常包含三个部分:

  • cache(缓存):管理同一类对象,比如「4字节对象缓存」、「16字节对象缓存」
  • slab( slabs):实际的内存块,每个slab被切成多个对象槽
  • 对象描述符:记录每个对象的状态(空闲/已分配)
// SLAB分配器简化结构
struct kmem_cache {
    const char *name;           // 缓存名称
    unsigned int object_size;   // 对象大小
    unsigned int slab_size;     // 每个slab的大小
    struct list_head slabs_full;    // 已满的slab链表
    struct list_head slabs_partial; // 部分空闲的slab链表
    struct list_head slabs_free;    // 完全空闲的slab链表
};

2.3 避坑指南

我曾经犯过一个错误:把SLAB的对象大小设得太大(比如4KB),结果每个slab只能放一个对象,SLAB退化成了伙伴系统。记住,SLAB适合小对象,一般不超过一个页面大小。大对象还是交给伙伴系统处理。

三、页表映射机制

页表映射,说白了就是「虚拟地址到物理地址的翻译官」。GPU和CPU都有页表,但GPU的页表更复杂,因为它要处理显存和系统内存之间的映射。

3.1 为什么需要页表?

你想想看,如果没有页表,每个程序都得直接操作物理地址。那多危险?程序A不小心写到了程序B的内存,系统就崩了。页表提供了隔离和保护。

另外,页表还能解决「物理内存碎片」问题。物理内存可能很碎,但通过页表映射,虚拟地址空间看起来是连续的。这对GPU显存管理特别重要——显存物理上可能有很多空洞,但通过页表,上层应用看到的是连续的虚拟地址空间。

3.2 多级页表

现代系统都用多级页表,而不是单级页表。为什么?因为单级页表太占内存了。32位系统,4KB页面,单级页表需要2^20个页表项,每个项4字节,总共4MB。如果每个进程都有自己的页表,内存根本不够用。

多级页表就好很多:

  • 顶级页表只占一小块内存
  • 只有用到的虚拟地址范围才分配下级页表
  • 64位系统通常用4级或5级页表

GPU显存里的页表:GPU的页表通常比CPU的页表更「胖」。因为GPU要支持大页(2MB、1GB),还要支持压缩、加密等特性。我见过一个GPU的页表项是64字节,比CPU的8字节大了8倍。

3.3 页表映射的实战要点

在显存管理里,页表映射有几个关键点:

  • TLB刷新:修改页表后,必须刷新TLB(快表),否则CPU/GPU可能读到旧数据。TLB刷新开销很大,要尽量减少
  • 大页支持:用2MB或1GB的大页可以减少页表层级,降低TLB miss。我建议在显存里尽量用大页,尤其是纹理和帧缓冲这种大块数据
  • 页表缓存:GPU的页表通常放在显存里,访问速度慢。所以GPU有专门的页表缓存(类似CPU的TLB),但容量更大

注意:页表映射不是免费的。每次地址翻译都要查页表,多级页表意味着多次内存访问。虽然TLB能加速,但TLB miss时性能会急剧下降。所以,页表设计要在「灵活性」和「性能」之间找平衡。

四、知识体系总览

下面这张图展示了这三个机制的关系:

内存分配基础:三大机制关系图 伙伴系统 大块内存分配 2的幂次方切分 伙伴合并回收 SLAB分配器 小对象批量管理 对象槽复用 缓存友好设计 页表映射 虚拟→物理地址翻译 多级页表结构 TLB加速访问 提供内存块 物理页帧 虚拟地址空间 三者协作关系 1. 伙伴系统管理物理内存的「大块」分配与回收 2. SLAB分配器从伙伴系统获取大块,切分成「小块」供频繁分配使用 3. 页表映射将虚拟地址翻译到物理地址,解决碎片和隔离问题 4. 三者共同构成完整的内存分配体系,缺一不可 注:在GPU显存管理中,伙伴系统和SLAB负责物理显存管理,页表负责虚拟地址映射

五、总结

伙伴系统、SLAB分配器、页表映射,这三者是内存管理的「三驾马车」。伙伴系统管大块,SLAB管小块,页表管映射。它们各司其职,又相互配合。

我个人觉得,理解这三者的关系比记住它们的细节更重要。你想想看,如果你只知道伙伴系统怎么切分,却不知道它和SLAB怎么配合,那在实际项目里还是会出问题。

嗯,今天就聊到这里。记住,内存分配没有银弹,每种机制都有它的适用场景。选对了工具,事半功倍;选错了,事倍功半。

一句话总结:伙伴系统是「粗粒度」分配器,SLAB是「细粒度」分配器,页表是「翻译官」。三者配合,才能构建高效、低碎片的内存管理系统。