核心数据结构:空闲链表、伙伴系统、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%。
三者如何协同?
你可能会问:这三个东西怎么配合?我画了一张图来说明。
实际工作中,我一般这样分层:
- 小对象(< 1KB):走Slab分配器。速度快,无碎片。
- 中等对象(1KB ~ 1MB):走空闲链表。多级桶优化后,分配效率很高。
- 大对象(> 1MB):走伙伴系统。保证连续性,避免碎片。
三个层次之间可以互相借用。比如Slab分配器需要新的slab页面时,会从空闲链表或伙伴系统申请。空闲链表如果找不到合适大小的块,也可以向伙伴系统“借”一块大的,然后切小。
总结一下:空闲链表是“快刀”,伙伴系统是“重锤”,Slab分配器是“绣花针”。三者各司其职,才能让显存池在高并发、大压力下依然稳如磐石。我在多个GPU项目中验证过这套架构,效果不错。
嗯,今天就聊到这儿。这些数据结构看起来简单,但真正落地时,细节很多。比如锁的粒度、缓存行的对齐、NUMA感知……这些我们后面再细说。