动态分配算法:伙伴系统、Slab分配器与TLSF

显存管理,说白了就是怎么把有限的显存空间安排得明明白白。我做了这么多年GPU开发,见过太多因为显存分配不当导致的性能灾难。今天咱们聊聊三种经典的动态分配算法——伙伴系统、Slab分配器和TLSF。

这三种算法各有各的脾气。有的适合大块内存,有的擅长小对象,有的追求极致实时性。你想想看,如果你的程序需要频繁分配释放小块显存,用伙伴系统就有点大炮打蚊子了。

伙伴系统(Buddy System)

伙伴系统的思路很朴素:把内存不断对半切。我刚开始学这个算法时觉得它有点笨,后来才发现它其实很优雅。

它的核心规则就两条:

  • 每次分配时,找到大小刚好够用的块,切到不能再切为止
  • 释放时,检查相邻的"伙伴"是否空闲,是就合并回去

举个例子,假设你有1MB的显存块:

// 分配256KB
1MB → 512KB + 512KB
512KB → 256KB + 256KB  // 取走一个256KB

// 释放256KB,检查伙伴是否空闲
// 如果伙伴空闲,合并成512KB
256KB + 256KB → 512KB

关键点:伙伴必须是大小相同、地址相邻、由同一个父块分裂出来的两个子块。不是随便两个相邻块就能合并。

我在项目中遇到过一个问题:频繁分配释放导致大量碎片。伙伴系统虽然能合并,但只限于伙伴之间。如果分配模式不规整,碎片率会很高。

我的经验:伙伴系统适合分配大小是2的幂次的内存块。如果你的显存分配模式比较规整,比如都是64KB、128KB这种,用伙伴系统效率很高。

Slab分配器

Slab分配器是专门为小对象设计的。我记得第一次在GPU驱动里看到它时,心想:这不就是对象池吗?

它的思路是这样的:

  • 为每种大小的对象维护一个缓存(cache)
  • 每个缓存包含多个slab(连续内存页)
  • 每个slab被划分为固定大小的对象槽

分配和释放都很快,因为不需要搜索空闲链表,直接从slab里取一个槽就行。

// Slab分配器伪代码
struct slab_cache {
    size_t object_size;    // 对象大小
    int objects_per_slab;  // 每个slab的对象数
    struct list_head slabs_full;     // 已满的slab
    struct list_head slabs_partial;  // 部分空闲的slab
    struct list_head slabs_free;     // 完全空闲的slab
};

// 分配对象
void *slab_alloc(struct slab_cache *cache) {
    // 先从部分空闲的slab里找
    // 没有的话从完全空闲的slab里找
    // 都没有就分配新的slab
}

注意:Slab分配器有个坑——内部碎片。如果对象大小不是对齐的,每个对象槽会浪费一些空间。我曾经在一个项目中,因为对象大小设置不当,浪费了将近15%的显存。

Slab分配器在Linux内核里用得很多,GPU驱动里也常见。说白了,它就是为那些频繁创建销毁的小对象量身定做的。

TLSF(Two-Level Segregated Fit)算法

TLSF是我个人比较喜欢的一个算法。它解决了伙伴系统的碎片问题和Slab分配器的灵活性不足。

它的核心思想是两级分离适配:

  • 第一级:按大小范围分桶(比如2^0~2^1, 2^1~2^2, ...)
  • 第二级:在每个桶内按精确大小分桶
  • 使用位图快速定位合适的空闲块

这样做的好处是分配时间复杂度是O(1),而且碎片率很低。

// TLSF的分配过程
void *tlsf_malloc(size_t size) {
    // 1. 计算size对应的第一级索引
    int fl = fls(size);  // 找到最高位
    
    // 2. 计算第二级索引
    int sl = (size >> (fl - SL_INDEX_COUNT_LOG2)) & (SL_INDEX_COUNT - 1);
    
    // 3. 在位图中查找空闲块
    // 4. 如果找到,分割并返回
    // 5. 如果没找到,去更大的桶里找
}

TLSF的优势:

  • 分配和释放都是O(1)时间复杂度
  • 内存碎片率低,通常小于5%
  • 支持任意大小的分配,不像伙伴系统只能2的幂次

我曾经在一个实时渲染项目里用TLSF管理显存。场景需要频繁分配释放各种大小的纹理和缓冲区,TLSF的表现非常稳定,延迟抖动很小。

三种算法对比

特性 伙伴系统 Slab分配器 TLSF
时间复杂度 O(log N) O(1) O(1)
内部碎片 高(2的幂次对齐) 中(对象对齐) 低
外部碎片 中 低 低
适用场景 大块内存,规整分配 小对象,频繁分配释放 通用,实时性要求高
实现复杂度 低 中 高

实际选型建议

嗯,这里要注意,没有银弹。选哪种算法得看你的具体场景:

  • 如果你的显存分配模式很规整,比如都是固定大小的纹理,用Slab分配器最合适
  • 如果分配大小变化很大,但实时性要求不高,伙伴系统够用了
  • 如果既要灵活性又要实时性,TLSF是首选

我的建议:在实际项目中,我通常会组合使用。比如用伙伴系统管理大块显存,然后在每个大块内部用Slab分配器管理小对象。这样既减少了碎片,又提高了分配效率。

你想想看,显存分配这件事,说白了就是在时间效率和空间效率之间找平衡。没有完美的算法,只有最适合你场景的方案。

动态分配算法对比 伙伴系统 Slab分配器 TLSF 核心思想 对半切分,伙伴合并 时间复杂度 O(log N) 碎片率 中高 适用场景 大块内存 规整分配 核心思想 对象缓存,slab复用 时间复杂度 O(1) 碎片率 中 适用场景 小对象 频繁分配释放 核心思想 两级分离,位图加速 时间复杂度 O(1) 碎片率 低 适用场景 通用 实时性要求高 选型建议 规整分配 → 伙伴系统 | 小对象频繁 → Slab | 通用实时 → TLSF

这三种算法各有千秋。我个人觉得,作为GPU开发者,至少要把TLSF搞明白。因为现在的GPU应用越来越复杂,显存分配的需求也越来越多样化。TLSF的O(1)分配和低碎片特性,让它成为很多现代显存管理器的首选。

避坑指南:我曾经在一个项目中直接用伙伴系统管理所有显存,结果发现碎片率高达30%。后来改用TLSF,碎片率降到了5%以下。所以选算法之前,一定要先分析你的分配模式。

好了,这一章就聊到这里。记住,显存分配没有银弹,理解每种算法的优缺点,才能做出最适合的选择。