核心数据结构:空闲链表、伙伴系统、Slab分配器

好,咱们直接切入正题。显存池的核心,说白了就是怎么管好这块“地”。你想想看,GPU要跑几千个线程,每个线程都可能随时申请、释放显存。如果管理不好,碎片化、性能抖动、甚至OOM(显存溢出)就会找上门来。

我个人习惯把显存管理拆成三个层次:空闲链表解决“快速分配”,伙伴系统解决“大块连续”,Slab分配器解决“小对象复用”。这三个东西不是互斥的,而是配合使用的。我在项目中遇到过,只用伙伴系统,小对象分配效率惨不忍睹;只用Slab,大块内存又没法处理。所以,得组合拳。

核心观点:显存池的三大数据结构,分别对应不同粒度的内存管理需求。空闲链表负责“快”,伙伴系统负责“整”,Slab负责“省”。三者协同,才能支撑起GPU的高并发场景。

1. 空闲链表:最朴素,但最实用

空闲链表,说白了就是一个链表,把空闲的内存块串起来。每个节点记录起始地址和大小。分配时从头遍历,找到合适的块就切出去;释放时插回去,顺便合并相邻的空闲块。

嗯,这里要注意:链表的遍历效率是O(n)。如果空闲块数量上千,每次分配都遍历一遍,性能就崩了。我在早期的一个项目中,就吃过这个亏——显存池里空闲块太多,分配延迟从微秒级飙到了毫秒级。

怎么优化?我建议用多级空闲链表。按大小分桶:比如2KB、4KB、8KB、16KB……每个桶里只挂相同大小的块。分配时直接定位到对应桶,取第一个节点就行。释放时也简单,插回对应桶。这样分配复杂度降到了O(1)。

// 多级空闲链表示意
struct free_list {
    struct list_head buckets[32]; // 2^0 到 2^31 大小
    spinlock_t lock;
};

void *alloc_from_list(struct free_list *fl, size_t size) {
    int idx = fls(size); // 找到最高位
    struct list_head *bucket = &fl->buckets[idx];
    if (list_empty(bucket)) {
        // 向上查找更大的桶
        for (int i = idx + 1; i < 32; i++) {
            if (!list_empty(&fl->buckets[i])) {
                // 从大桶里切一块出来
                // ...
            }
        }
    }
    // 取第一个节点
    struct free_block *block = list_first_entry(bucket, ...);
    list_del(&block->node);
    return block->addr;
}

避坑指南:我曾经在释放时忘记合并相邻块,结果空闲链表里全是小碎片。后来加了个“合并检查”:释放时检查前后地址是否连续,连续就合并。这个操作虽然增加了一点开销,但避免了碎片化灾难。

2. 伙伴系统:解决大块连续内存

伙伴系统,是Linux内核里经典的物理内存管理算法。它的核心思想是:把内存按2的幂次分成块。比如1MB、2MB、4MB……每个块都有一个“伙伴”——大小相同、地址相邻的另一个块。

分配时,如果找不到合适大小的块,就向上找更大的块,然后不断对半切。释放时,检查伙伴是否空闲,如果是,就合并成更大的块。这样能有效避免外部碎片。

我为什么要在显存池里用伙伴系统?因为GPU经常需要大块连续显存,比如纹理、缓冲区、帧缓冲。这些动辄几十MB甚至几百MB,用空闲链表管理,碎片化会非常严重。伙伴系统通过“分裂-合并”机制,天然保证了连续性。

块大小 伙伴地址关系 典型用途
1MB 0x0 和 0x100000 小纹理
2MB 0x0 和 0x200000 中等缓冲区
4MB 0x0 和 0x400000 帧缓冲
8MB 0x0 和 0x800000 大纹理

不过,伙伴系统也有缺点:内部碎片。比如你申请3MB,伙伴系统会给你4MB的块,浪费了1MB。所以,它不适合小对象分配。

注意:伙伴系统的合并操作需要加锁。在高并发场景下,锁竞争会成为瓶颈。我建议用per-CPU缓存来缓解:每个CPU核心先缓存一些空闲块,减少全局锁的访问频率。

3. Slab分配器:小对象的救星

Slab分配器,最早是Solaris内核里提出的,后来被Linux借鉴。它的核心思想是:为每种固定大小的对象,维护一个专属的缓存池。比如,专门给“4字节”对象一个池,给“16字节”对象一个池。

每个池里,内存被分成若干个“slab”(可以理解为一个页面或一组页面)。每个slab里又分成若干个“对象槽”。分配时直接从slab里取一个槽,释放时把槽标记为空。整个过程不需要遍历,也不需要合并,速度极快。

我在显存池里用Slab,主要是为了处理频繁分配释放的小对象,比如描述符、句柄、小结构体。这些对象通常只有几十字节,如果用伙伴系统,内部碎片会浪费大量显存。

// Slab分配器示意
struct slab_cache {
    size_t obj_size;       // 对象大小
    int obj_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) {
    // 先从partial slab里取
    if (!list_empty(&cache->slabs_partial)) {
        struct slab *s = list_first_entry(&cache->slabs_partial, ...);
        void *obj = get_free_obj(s);
        if (s->free_count == 0) {
            // 这个slab满了,移到full链表
            list_move(&s->node, &cache->slabs_full);
        }
        return obj;
    }
    // 没有partial slab,从free slab里取
    if (!list_empty(&cache->slabs_free)) {
        struct slab *s = list_first_entry(&cache->slabs_free, ...);
        // 初始化slab,分配对象
        // ...
    }
    // 都没有,申请新slab(从伙伴系统或空闲链表拿)
    // ...
}

个人经验:Slab分配器的“着色”技术很有意思。为了利用CPU缓存行,每个slab里的对象起始地址可以错开,避免多个对象映射到同一个缓存行。我在项目中试过,着色后性能提升了约15%。

三者如何协同?

你可能会问:这三个东西怎么配合?我画了一张图来说明。

显存池三层架构 空闲链表 快速分配/释放 多级桶优化 伙伴系统 大块连续内存 分裂-合并机制 Slab分配器 小对象复用 固定大小缓存 物理显存(GPU DRAM) 协同工作流程 1. 小对象(< 1KB)→ Slab分配器 2. 中等对象(1KB ~ 1MB)→ 空闲链表 3. 大对象(> 1MB)→ 伙伴系统

实际工作中,我一般这样分层:

  • 小对象(< 1KB):走Slab分配器。速度快,无碎片。
  • 中等对象(1KB ~ 1MB):走空闲链表。多级桶优化后,分配效率很高。
  • 大对象(> 1MB):走伙伴系统。保证连续性,避免碎片。

三个层次之间可以互相借用。比如Slab分配器需要新的slab页面时,会从空闲链表或伙伴系统申请。空闲链表如果找不到合适大小的块,也可以向伙伴系统“借”一块大的,然后切小。

总结一下:空闲链表是“快刀”,伙伴系统是“重锤”,Slab分配器是“绣花针”。三者各司其职,才能让显存池在高并发、大压力下依然稳如磐石。我在多个GPU项目中验证过这套架构,效果不错。

嗯,今天就聊到这儿。这些数据结构看起来简单,但真正落地时,细节很多。比如锁的粒度、缓存行的对齐、NUMA感知……这些我们后面再细说。

可可精品资料库