2、显存池核心架构:内存块管理、空闲链表设计、分配器策略
好,咱们直接切入正题。显存池这东西,说白了就是给GPU提前划好一块地,然后自己当包租公。你想想看,每次调用cudaMalloc都去跟驱动打交道,那开销可不是一般的大。我早年做图像处理管线的时候,就吃过这个亏——帧率死活上不去,一查profile,全是内存分配在拖后腿。
所以今天咱们聊聊显存池的核心架构。我把它拆成三个部分:内存块怎么管、空闲链表怎么设计、分配器用什么策略。这三块搞明白了,你的显存池基本就稳了。
2.1 内存块管理:大块切小块,小块要记账
显存池的第一步,是向GPU申请一大块连续显存。比如我习惯一次性申请1GB,然后自己切。这块大内存我们叫它池底(pool base)。
然后呢?你得把它切成小块。每个小块就是一个内存块(memory block)。我见过不少新手直接拿链表一个个挂,结果碎片化严重。我的做法是:
- 块头结构:每个块前面加一个header,记录块大小、是否空闲、前后块指针。
- 对齐要求:所有块地址按256字节对齐。为什么?因为GPU的global memory访问,对齐了效率高很多。
- 最小块大小:我一般设成512字节。小于这个的请求,直接向上取整。不然碎片太多,你想想看,一个4字节的int占一个512字节的块,多浪费。
核心要点:内存块管理的关键是「快」和「省」。快是指分配和释放要O(1)或接近O(1);省是指内部碎片和外部碎片都要控制住。
这里我画了一张图,帮你理解内存块在池子里的布局:
2.2 空闲链表设计:别让查找成了瓶颈
空闲链表,就是记录哪些块是空闲的。设计得好,分配就是O(1);设计得不好,分配就是O(n),n大了直接卡死。
我见过三种主流设计,咱们一个个说:
| 链表类型 | 分配策略 | 时间复杂度 | 适用场景 |
|---|---|---|---|
| 单链表(无序) | 遍历找第一个大小合适的 | O(n) | 块数少(<100) |
| 双链表(按大小排序) | 从合适大小的位置开始找 | O(log n) ~ O(n) | 块数中等(100~1000) |
| 伙伴系统(Buddy) | 按2的幂次切分,合并也方便 | O(log n) | 块数多,且大小规律 |
我个人最常用的是双链表 + 大小桶。什么意思呢?就是把空闲块按大小分到不同的桶里。比如:
- 桶1:512B ~ 1KB
- 桶2:1KB ~ 4KB
- 桶3:4KB ~ 16KB
- 桶4:16KB ~ 64KB
- 桶5:64KB以上
分配时,先算请求大小落在哪个桶,然后从那个桶的链表里取一个。如果桶空了,就去下一个更大的桶借。这样查找基本是O(1)的。
小技巧:每个桶内部用双链表,这样释放时合并相邻空闲块特别方便。我曾经在项目里只用单链表,结果释放后碎片越来越多,最后池子废了。后来改成双链表,问题就解决了。
2.3 分配器策略:选对策略,性能翻倍
分配器策略,说白了就是「当用户要一块内存时,我从哪个空闲块给他」。策略不同,碎片率和分配速度天差地别。
我整理了几种常见策略,你感受一下:
| 策略名称 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| First Fit | 从头遍历,找到第一个够大的 | 简单,速度快 | 容易产生小碎片 |
| Best Fit | 找大小最接近的 | 碎片少 | 遍历慢,O(n) |
| Worst Fit | 找最大的块,切一块给用户 | 大块保留少 | 容易浪费空间 |
| Buddy System | 按2的幂切分,伙伴合并 | 合并快,碎片可控 | 内部碎片最多50% |
我自己的经验是:没有银弹。不同的业务场景,策略完全不同。
举个例子,我做深度学习训练框架时,tensor大小基本是固定的(比如1MB、4MB、16MB)。这时候用Buddy System就特别香——分配和释放都是O(log n),而且合并非常干净。
但做图形渲染管线时,内存请求大小变化很大,从几十字节到几兆字节都有。这时候我改用大小桶 + First Fit。每个桶内部用First Fit,因为桶内块大小差不多,First Fit和Best Fit差别不大,但First Fit更快。
注意:千万别在GPU上做复杂的链表遍历。GPU的线程是并行的,一个线程在遍历链表,其他线程可能就在改链表。我见过有人把CPU上的分配器直接搬到GPU上,结果死锁加数据竞争,调试了三天。记住:GPU分配器要尽量无锁,或者用原子操作。
2.4 避坑指南:我踩过的几个坑
最后分享几个我实际项目中遇到的坑,你遇到了可以少走弯路:
- 坑一:忘了对齐。我曾经有一个块地址没做256字节对齐,结果在Volta架构上跑,带宽直接掉了一半。后来加上对齐,性能就回来了。
- 坑二:释放时没合并相邻块。分配释放几次后,池子里全是小碎片,连一个1MB的连续块都找不到。后来我加了「释放时检查前后块是否空闲,是则合并」的逻辑。
- 坑三:多线程竞争。多个CPU线程同时调显存池的分配和释放,不加锁的话,链表指针乱飞。我后来用了一个全局的spinlock,虽然简单但有效。
- 坑四:池子太小。一开始只申请了256MB,结果业务一上来就爆了。后来改成动态扩展——池子不够时,自动再向cudaMalloc申请一块新的。
嗯,核心架构就这些。你把这些搞明白了,显存池的基本骨架就有了。剩下的就是根据你的业务场景,微调参数和策略。
一句话总结:内存块管理要「快和省」,空闲链表要「分桶+双链」,分配器策略要「看场景选」。这三板斧下去,你的显存池性能不会差。