显存复用策略:内存池设计、对象池模式、显存碎片整理

大家好,我是你们的GPU系统讲师。今天咱们聊一个实战中绕不开的话题——显存复用。

说实话,我见过太多同学在训练大模型时,明明显存够用,却因为分配策略不当导致OOM。或者模型跑着跑着,显存碎片越来越多,最后卡死在分配上。嗯,这些问题,其实都可以通过合理的复用策略来解决。

为什么需要显存复用?

先问大家一个问题:每次需要显存时都去cudaMalloc,用完就cudaFree,这样不好吗?

我刚开始做CUDA开发时也是这么干的。直到有一次,我在一个实时推理系统里频繁分配释放显存,结果发现系统响应越来越慢。一查,原来每次cudaMalloc都要和驱动交互,开销巨大。更糟的是,频繁分配释放会导致显存碎片化——就像硬盘碎片一样,明明总空间够,但就是分配不出一块连续的大内存。

所以,显存复用的核心目标就两个:

  • 减少分配次数——避免频繁调用cudaMalloc/cudaFree
  • 降低碎片化——让显存利用率更高

内存池(Memory Pool)设计

内存池说白了就是:提前申请一大块显存,自己管理。需要时从池子里拿,用完还回去。这样就不用每次都找驱动要了。

我个人的习惯是,把内存池设计成两级结构:

层级 说明 典型大小
一级池 固定大小块,用于小张量 1KB ~ 1MB
二级池 大块可变大小,用于大张量 1MB ~ 数GB

这里我贴一段简化版的内存池实现,大家感受一下:

class MemoryPool {
private:
    // 空闲块列表,按大小排序
    std::map<size_t, std::vector<void*>> free_blocks;
    // 已分配块记录
    std::unordered_map<void*, size_t> allocated;
    
public:
    void* allocate(size_t size) {
        // 查找合适大小的空闲块
        auto it = free_blocks.lower_bound(size);
        if (it != free_blocks.end()) {
            void* ptr = it->second.back();
            it->second.pop_back();
            if (it->second.empty()) free_blocks.erase(it);
            allocated[ptr] = size;
            return ptr;
        }
        // 没有合适的,向驱动申请
        void* ptr;
        cudaMalloc(&ptr, size);
        allocated[ptr] = size;
        return ptr;
    }
    
    void deallocate(void* ptr) {
        size_t size = allocated[ptr];
        allocated.erase(ptr);
        // 归还到空闲列表
        free_blocks[size].push_back(ptr);
    }
};
小技巧:我建议在分配时做一次大小对齐,比如按256字节对齐。这样能减少碎片,提高后续分配的命中率。

对象池模式

对象池和内存池思路类似,但更高级一点。它不只管理内存,还管理对象的生命周期。

举个例子,在Transformer推理时,每个token都要创建attention mask、position encoding等临时对象。如果每次都new一个,用完再delete,那效率太低了。

对象池的做法是:

  1. 初始化时创建一批对象
  2. 需要时从池中借出一个
  3. 用完归还,对象重置但不销毁

我曾经在一个BERT推理服务里用了对象池,QPS直接提升了30%。为什么?因为省去了反复构造和析构的开销。

template<typename T>
class ObjectPool {
    std::vector<T*> pool;
    std::vector<bool> in_use;
    
public:
    T* acquire() {
        for (size_t i = 0; i < pool.size(); i++) {
            if (!in_use[i]) {
                in_use[i] = true;
                return pool[i];
            }
        }
        // 池不够用了,扩容
        T* obj = new T();
        pool.push_back(obj);
        in_use.push_back(true);
        return obj;
    }
    
    void release(T* obj) {
        for (size_t i = 0; i < pool.size(); i++) {
            if (pool[i] == obj) {
                in_use[i] = false;
                // 重置对象状态
                obj->reset();
                return;
            }
        }
    }
};
注意:对象池不适合所有场景。如果对象状态差异很大,重置成本高,那还不如直接销毁重建。我踩过这个坑,大家引以为戒。

显存碎片整理

碎片整理是个老话题了。在CPU内存里,我们有GC、有内存压缩。但在GPU上,事情没那么简单——你不能随便移动显存里的数据,因为GPU kernel可能正在访问它。

我总结了几种常见的碎片整理策略:

  • 被动整理:在分配失败时触发,尝试合并相邻空闲块
  • 主动整理:定期检查碎片率,超过阈值就整理
  • 预防性分配:提前分配好常用大小的块,避免小碎片产生

这里我画了一张图,展示碎片整理的核心逻辑:

显存碎片整理流程 碎片化状态: 已分配 空闲 触发整理条件: • 分配失败时 • 碎片率 > 30% • 定时触发 整理后状态: 已分配(连续) 空闲 通过合并相邻空闲块,减少碎片,提高显存利用率

实际实现时,我建议采用延迟合并策略。什么意思呢?就是释放块时不立即合并,而是等碎片率达到阈值再统一处理。这样能避免频繁合并的开销。

核心要点总结:

  • 内存池:预分配+复用,减少cudaMalloc调用
  • 对象池:复用对象实例,减少构造析构开销
  • 碎片整理:合并空闲块,提高显存利用率

最后说一句,这些策略不是孤立的。我通常会在一个系统里同时使用内存池和对象池,再配合碎片整理。比如,小张量走对象池,大张量走内存池,定期检查碎片情况。这样组合下来,显存利用率能提升20%以上。

好了,这一章就到这里。记住,显存管理是个实践活,光看理论没用。建议大家在自己的项目里试试这些策略,遇到问题欢迎交流。

避坑指南:我曾经在一个项目里过度使用内存池,把所有大小都缓存起来,结果显存占用暴涨。后来才明白,池化不是万能的,要结合业务场景设置合理的上限。

可可精品资料库