3. 显存池核心设计:预分配策略、缓存复用机制、碎片整理算法
好,咱们直接进入正题。显存池这东西,说白了就是给大模型训练和推理时用的「内存管家」。你想想看,每次申请显存、释放显存,如果都直接跟CUDA打交道,那效率得多低?而且频繁分配释放,显存碎片会越来越严重,最后明明还有几十GB空闲,就是分配不出一个连续块——我当年第一次遇到这问题,差点把服务器砸了。
所以,显存池的核心设计,我总结为三个关键点:预分配策略、缓存复用机制、碎片整理算法。这三板斧砍下去,显存利用率能提升30%以上,推理延迟也能降不少。
3.1 预分配策略:别等用的时候再找我要
预分配,顾名思义,就是提前把显存「占上」。但怎么占、占多少,这里面有讲究。
我个人习惯的做法是:按模型层的大小和数量,一次性申请一整块连续显存。比如一个7B的LLaMA模型,每层参数大约多少MB,总共多少层,再加上激活值、KV Cache的峰值需求,算出一个上限值,然后一次性分配。
核心原则:预分配不是越多越好,而是「够用+余量」。余量一般留10%-20%,应对临时性的显存抖动。
具体策略我分三种:
- 静态预分配:模型加载时就分配好全部显存。适合推理场景,简单粗暴,零碎片。缺点是如果模型换得勤,浪费严重。
- 动态预分配:按需分配,但每次分配时多申请一个「chunk size」的倍数。比如每次至少分配256MB,不够再追加。我在项目中遇到过,如果chunk size设得太小(比如64MB),分配次数会暴增,反而拖慢速度。
- 混合策略:先静态分配一个基础池(比如覆盖模型参数和固定激活值),再动态分配一个弹性池(处理KV Cache等动态变化的部分)。这是目前工业界的主流做法。
// 伪代码示例:混合预分配策略
class MemoryPool {
size_t base_pool_size; // 静态池大小
size_t elastic_chunk; // 弹性池chunk大小
void init() {
// 静态池:一次性分配
base_pool = cudaMalloc(base_pool_size);
// 弹性池:先分配一个chunk,后续按需追加
elastic_pool = cudaMalloc(elastic_chunk);
}
void* allocate(size_t size) {
if (size <= base_pool_remaining) {
return base_pool + offset;
} else {
// 从弹性池分配,自动对齐到chunk边界
return allocate_from_elastic(size);
}
}
};
小技巧:预分配时记得做内存对齐。我一般按256字节对齐,虽然多占一点点空间,但能避免很多奇怪的性能问题。尤其是Tensor Core对对齐要求很敏感。
3.2 缓存复用机制:用完别扔,洗洗还能用
缓存复用,说白了就是「显存回收站」。一个tensor用完了,不急着释放,先放回池子里。下次有相同大小的请求,直接拿过来用。
这里的关键是如何高效地匹配「请求大小」和「缓存块大小」。我见过最蠢的做法是线性遍历所有空闲块——显存块一多,性能直接崩掉。
我推荐的做法是哈希表+红黑树的组合:
- 用哈希表按大小索引:key是块大小,value是空闲块链表。这样分配时O(1)就能找到合适大小的块。
- 用红黑树按地址索引:用于合并相邻空闲块。释放时,检查前后块是否空闲,如果是,就合并成一个更大的块。
嗯,这里要注意:缓存复用不是万能的。如果请求大小变化很频繁,缓存命中率会很低。我曾经在一个多租户推理服务上踩过坑——不同模型、不同batch size,请求大小千奇百怪,缓存池里全是「用不上」的块。后来我加了一个大小规整策略:把所有请求大小向上取整到2的幂次(比如17MB -> 32MB),虽然浪费了一点空间,但缓存命中率从40%飙升到85%。
避坑指南:我曾经在缓存复用里忘了处理「跨流同步」的问题。两个CUDA stream同时读写同一个缓存块,结果数据错乱,模型输出全是NaN。后来我强制要求:缓存块在被复用前,必须等所有关联的stream都执行完。这个坑,你们一定要记住。
3.3 碎片整理算法:给显存做个「碎片整理」
碎片整理,这是显存池里最头疼的部分。你想想看,频繁分配释放,显存就像被狗啃过一样,到处都是小洞。明明总空闲空间够,就是分配不出一块连续的。
碎片整理的核心思路是:移动数据,合并空闲块。但移动数据本身有开销,而且移动过程中不能影响正在进行的计算。
我常用的三种算法:
| 算法 | 原理 | 适用场景 | 缺点 |
|---|---|---|---|
| 紧缩(Compaction) | 把所有已分配块移到一端,空闲块合并到另一端 | 推理场景,显存使用模式稳定 | 移动开销大,需要暂停所有分配 |
| 分代回收(Generational) | 按存活时间分代,短命对象优先回收 | 训练场景,中间激活值频繁创建销毁 | 实现复杂,需要标记存活对象 |
| 伙伴系统(Buddy System) | 按2的幂次分割和合并块 | 通用场景,实现简单 | 内部碎片最多浪费50% |
我个人最常用的是伙伴系统+延迟紧缩的组合。伙伴系统负责日常的分配释放,当碎片率达到阈值(比如30%)时,触发一次紧缩操作。
为什么会选择延迟紧缩?因为碎片整理不能太频繁。我一般设置一个碎片率阈值和一个时间窗口:只有碎片率超过阈值,且距离上次整理超过一定时间(比如5秒),才触发整理。这样既控制了碎片,又避免了频繁整理带来的性能抖动。
警告:碎片整理期间,一定要保证所有CUDA kernel都执行完毕。否则你移动数据时,GPU还在读那块显存,结果就是cudaErrorIllegalAddress。我建议在整理前先调用cudaDeviceSynchronize(),虽然会损失一点吞吐,但安全第一。
3.4 整体架构:一张图看懂
下面我用一张SVG图,把这三个核心设计串起来。你一看就明白它们是怎么协同工作的。
这张图把三个核心模块串起来了。预分配是「地基」,缓存复用是「日常运营」,碎片整理是「定期大扫除」。三者缺一不可。
我的经验:刚开始做显存池时,我总想着把三个模块都做到极致。后来发现,80%的性能提升来自预分配和缓存复用,碎片整理只是锦上添花。所以建议你们先做好前两个,碎片整理可以慢慢优化。
好了,这一章的内容就到这里。预分配、缓存复用、碎片整理,这三板斧你掌握了吗?下一章我们聊聊显存池在具体框架(比如vLLM、DeepSpeed)中的落地实现,到时候会有更多实战代码。