4、显存池概念:为什么需要显存池、显存池的核心思想(预分配+复用)、显存池 vs 动态分配

聊到显存池,我得先跟你讲个真实的故事。

几年前我接手过一个推理服务,QPS 一上来就频繁 OOM。查了半天,发现每次请求都去 cudaMalloc 分配显存,推理完再 cudaFree 释放。你想想看,高并发场景下,几百个请求同时进来,显存分配释放就像在高速公路上频繁变道——不堵车才怪。

后来我引入了显存池,问题直接解决了。今天我们就来聊聊这个东西。

为什么需要显存池?

先看动态分配的问题。每次推理都走 cudaMalloc / cudaFree,有几个致命伤:

  • 分配开销大:cudaMalloc 是同步操作,要跟驱动层交互。一次分配可能几微秒,但高并发下累积起来就是灾难。
  • 内存碎片化:频繁分配释放,显存会变得支离破碎。我见过一个服务,明明还有 4GB 显存,却分配不出一个 500MB 的 tensor。
  • OOM 风险高:动态分配时,如果多个请求同时申请大块显存,很容易撑爆。而且 cudaMalloc 失败后,恢复起来很麻烦。
  • 延迟抖动大:分配释放的耗时不稳定,导致推理延迟忽高忽低。做在线服务最怕这个。

核心痛点:动态分配就像每次做饭都去菜市场买菜——现买现做,时间全花在路上了。显存池就是你的冰箱,提前备好菜,用的时候直接拿。

显存池的核心思想:预分配 + 复用

说白了就两句话:提前申请好,用完别还。

具体怎么做?我习惯把显存池设计成三层结构:

  1. 预分配层:服务启动时,一次性申请一大块显存。比如模型需要 4GB,我直接申请 6GB,留出余量。
  2. 缓存管理层:用 free list 管理空闲块,用 busy list 跟踪已分配块。每个块记录大小、偏移量、引用计数。
  3. 复用策略层:请求来了,从 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 用另一个。减少碎片,提高命中率。

最后说一句,显存池的实现其实不复杂,但细节决定成败。你想想看,一个分配函数从几微秒优化到几十纳秒,对于高并发服务来说,就是质的飞跃。

显存池核心架构图 预分配层 服务启动时一次性申请大块显存(如 6GB) 按 256KB 对齐,划分成多个固定大小的块 缓存管理层 Free List:空闲块链表,按大小排序 Busy List:已分配块,记录引用计数 复用策略层 最佳适配:从 Free List 找大小最接近的块 分裂/合并:大块分裂、相邻空闲块合并 关键指标 分配速度 碎片率 命中率 水位线 碎片率 < 30% 水位线 < 90% 命中率 > 95%

想要更多资料,联系可可