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时性能会急剧下降。所以,页表设计要在「灵活性」和「性能」之间找平衡。
四、知识体系总览
下面这张图展示了这三个机制的关系:
五、总结
伙伴系统、SLAB分配器、页表映射,这三者是内存管理的「三驾马车」。伙伴系统管大块,SLAB管小块,页表管映射。它们各司其职,又相互配合。
我个人觉得,理解这三者的关系比记住它们的细节更重要。你想想看,如果你只知道伙伴系统怎么切分,却不知道它和SLAB怎么配合,那在实际项目里还是会出问题。
嗯,今天就聊到这里。记住,内存分配没有银弹,每种机制都有它的适用场景。选对了工具,事半功倍;选错了,事倍功半。
一句话总结:伙伴系统是「粗粒度」分配器,SLAB是「细粒度」分配器,页表是「翻译官」。三者配合,才能构建高效、低碎片的内存管理系统。