4、显存池概念:为什么需要显存池、显存池的核心思想(预分配+复用)、显存池 vs 动态分配
聊到显存池,我得先跟你讲个真实的故事。
几年前我接手过一个推理服务,QPS 一上来就频繁 OOM。查了半天,发现每次请求都去 cudaMalloc 分配显存,推理完再 cudaFree 释放。你想想看,高并发场景下,几百个请求同时进来,显存分配释放就像在高速公路上频繁变道——不堵车才怪。
后来我引入了显存池,问题直接解决了。今天我们就来聊聊这个东西。
为什么需要显存池?
先看动态分配的问题。每次推理都走 cudaMalloc / cudaFree,有几个致命伤:
- 分配开销大:cudaMalloc 是同步操作,要跟驱动层交互。一次分配可能几微秒,但高并发下累积起来就是灾难。
- 内存碎片化:频繁分配释放,显存会变得支离破碎。我见过一个服务,明明还有 4GB 显存,却分配不出一个 500MB 的 tensor。
- OOM 风险高:动态分配时,如果多个请求同时申请大块显存,很容易撑爆。而且 cudaMalloc 失败后,恢复起来很麻烦。
- 延迟抖动大:分配释放的耗时不稳定,导致推理延迟忽高忽低。做在线服务最怕这个。
核心痛点:动态分配就像每次做饭都去菜市场买菜——现买现做,时间全花在路上了。显存池就是你的冰箱,提前备好菜,用的时候直接拿。
显存池的核心思想:预分配 + 复用
说白了就两句话:提前申请好,用完别还。
具体怎么做?我习惯把显存池设计成三层结构:
- 预分配层:服务启动时,一次性申请一大块显存。比如模型需要 4GB,我直接申请 6GB,留出余量。
- 缓存管理层:用 free list 管理空闲块,用 busy list 跟踪已分配块。每个块记录大小、偏移量、引用计数。
- 复用策略层:请求来了,从 free list 里找大小最合适的块。找不到就分裂大块,或者等待其他请求释放。
这里有个关键设计——块大小对齐。我一般按 256KB 对齐,避免产生太多小碎片。举个例子:
// 伪代码:显存池分配逻辑
void* PoolAlloc(size_t size) {
// 对齐到 256KB
size = AlignUp(size, 256 * 1024);
// 从 free list 找合适块
for (auto& block : free_list_) {
if (block.size >= size) {
// 如果块太大,分裂
if (block.size > size * 1.5) {
SplitBlock(block, size);
}
free_list_.erase(block);
busy_list_.insert(block);
return block.ptr;
}
}
// 没找到?从预分配区扩展
return ExtendPool(size);
}
我的经验:预分配大小建议设为模型峰值显存的 1.2~1.5 倍。太小了频繁扩展,太大了浪费。我曾经在一个 8卡 A100 的机器上,预分配 80% 的显存,剩下的留给数据加载和临时变量,效果很好。
显存池 vs 动态分配
直接上对比表,一目了然:
| 维度 | 动态分配 | 显存池 |
|---|---|---|
| 分配速度 | 微秒级(cudaMalloc 同步) | 纳秒级(指针交换) |
| 内存碎片 | 严重,长期运行后碎片化 | 可控,通过合并策略减少碎片 |
| OOM 风险 | 高,突发请求容易撑爆 | 低,预分配 + 限流保护 |
| 延迟抖动 | 大,分配耗时不稳定 | 小,分配时间几乎恒定 |
| 实现复杂度 | 简单,直接调用 API | 中等,需要管理 free list |
| 适用场景 | 单次推理、调试阶段 | 高并发在线服务 |
嗯,这里要注意——显存池不是银弹。如果你的服务 QPS 很低,或者模型经常换,动态分配反而更灵活。我见过有人为了用显存池而用显存池,结果预分配了 10GB,实际只用 2GB,白白浪费。
避坑指南:我曾经在一个多租户场景里,每个租户都独立维护一个显存池。结果显存利用率只有 40%,因为每个池都要预留余量。后来改成全局共享池 + 租户隔离策略,利用率提升到 85%。
显存池的核心数据结构
我常用的设计是这样的:
struct MemoryBlock {
void* ptr; // 显存地址
size_t size; // 块大小
size_t offset; // 在池中的偏移
int ref_count; // 引用计数
bool is_free; // 是否空闲
};
class MemoryPool {
private:
void* base_ptr_; // 预分配基地址
size_t total_size_; // 总大小
std::vector<MemoryBlock> blocks_;
std::mutex mutex_; // 线程安全
// 空闲块按大小排序,便于最佳适配
std::multiset<size_t> free_sizes_;
public:
void* Alloc(size_t size);
void Free(void* ptr);
void Defragment(); // 碎片整理
};
碎片整理是个有意思的话题。当 free list 里有很多小碎片时,我会触发一次整理——把相邻的空闲块合并成大块。这个操作开销不小,我一般放在服务低峰期做,或者当碎片率超过 30% 时触发。
我的一些实践建议
- 启动时预热:服务启动后,先跑几个 dummy 请求,让显存池把常用大小的块都分配好。这样线上请求来了直接命中。
- 监控碎片率:我习惯在监控面板上加一个「显存碎片率」指标。公式是 1 - (最大连续空闲块 / 总空闲大小)。超过 50% 就要警惕了。
- 限流保护:显存池满了怎么办?我一般设置一个水位线,超过 90% 就开始拒绝新请求,返回 503。比 OOM 优雅得多。
- 多级池化:对于不同大小的 tensor,可以分多个池。比如小 tensor(<1MB)用一个池,大 tensor 用另一个。减少碎片,提高命中率。
最后说一句,显存池的实现其实不复杂,但细节决定成败。你想想看,一个分配函数从几微秒优化到几十纳秒,对于高并发服务来说,就是质的飞跃。
想要更多资料,联系可可