3、显存复用策略(上):对象池模式、预分配与懒分配、Tensor复用技术、显存生命周期管理

显存复用,说白了就是「省着用」。

做吞吐压测的时候,显存就是你的命根子。我见过太多团队,模型推理性能上不去,一查发现显存被反复申请释放,碎片化严重,GPU利用率低得可怜。今天咱们就聊聊怎么把这部分优化做到极致。

3.1 对象池模式(Object Pool)

对象池这个概念,其实不新鲜。但在GPU编程里,它的价值被严重低估了。

你想想看,每次cudaMalloc和cudaFree,都是跟驱动打交道。这玩意儿开销不小。我在项目中遇到过,一个简单的推理服务,每秒处理几百次请求,每次都要分配临时缓冲区。结果呢?光分配释放就占了10%以上的时间。

核心思路:提前分配好一批显存块,用的时候拿,用完还回去。不释放,只复用。

对象池的实现,我一般这么搞:

class CudaMemoryPool {
private:
    std::vector<void*> free_blocks;
    std::mutex mtx;
    size_t block_size;
    size_t pool_size;
    
public:
    CudaMemoryPool(size_t size, size_t count) 
        : block_size(size), pool_size(count) {
        for (size_t i = 0; i < count; ++i) {
            void* ptr;
            cudaMalloc(&ptr, size);
            free_blocks.push_back(ptr);
        }
    }
    
    void* acquire() {
        std::lock_guard<std::mutex> lock(mtx);
        if (free_blocks.empty()) {
            // 池子空了,可以动态扩容
            void* ptr;
            cudaMalloc(&ptr, block_size);
            return ptr;
        }
        void* ptr = free_blocks.back();
        free_blocks.pop_back();
        return ptr;
    }
    
    void release(void* ptr) {
        std::lock_guard<std::mutex> lock(mtx);
        free_blocks.push_back(ptr);
    }
    
    ~CudaMemoryPool() {
        for (auto ptr : free_blocks) {
            cudaFree(ptr);
        }
    }
};

我的经验:对象池的大小别设太死。我一般设一个初始值,比如16个块,然后允许动态增长。但设个上限,防止内存泄漏。

3.2 预分配与懒分配

这两个策略,其实是两种哲学。

预分配:启动时就把所有可能用到的显存都申请好。好处是运行时零延迟分配,坏处是——你得多占着茅坑不拉屎。

懒分配:用到了才分配。好处是省显存,坏处是第一次用的时候会卡一下。

我个人习惯是:核心路径用预分配,边缘路径用懒分配。

举个例子,做吞吐压测时,模型权重、输入输出缓冲区这些核心资源,必须预分配。而一些调试用的临时变量、日志缓冲区,懒分配就行。

注意:懒分配有个坑。cudaMalloc是同步的,但cudaMallocManaged(统一内存)是懒触发的。我曾经在项目里用统一内存做懒分配,结果第一次访问时触发了page fault,延迟直接飙到几百毫秒。嗯,这个坑我踩过。

3.3 Tensor复用技术(in-place操作)

Tensor复用,说白了就是「用完的显存别急着扔,看看能不能给下一个用」。

in-place操作是最直接的复用方式。比如ReLU、Dropout这些操作,输出可以直接覆盖输入。PyTorch里加个下划线就行:

# 普通操作
x = torch.relu(x)  # 分配了新显存

# in-place操作
torch.relu_(x)     # 直接覆盖,零额外显存

但要注意,不是所有操作都能in-place。比如BatchNorm,它需要保存输入的均值和方差用于反向传播。你要是直接覆盖了,反向传播就废了。

我做推理优化时,经常用这个技巧。推理不需要反向传播,所以几乎所有激活函数都可以in-place。显存占用直接砍半。

Tensor复用策略总结:

  • 同尺寸复用:相同shape的tensor,用对象池管理
  • in-place覆盖:激活函数、dropout等操作直接覆盖
  • 生命周期对齐:两个tensor生命周期不重叠,可以共用同一块显存

3.4 显存生命周期管理

这是最容易被忽视的部分。很多人只关注「怎么分配」,不关注「什么时候释放」。

显存生命周期管理,核心就一句话:谁分配,谁释放;谁借用,谁归还。

我画了一张图,帮你理解显存的生命周期流转:

显存生命周期流转图 分配阶段 cudaMalloc / 对象池 使用阶段 kernel执行 / 数据传输 释放/归还 cudaFree / 对象池归还 对象池复用路径 生命周期管理要点: 1. 谁分配谁释放:避免跨模块的显存管理混乱 2. 借用语义:借用方用完必须归还,不能私自释放 3. 生命周期对齐:两个tensor生命周期不重叠,可以复用同一块显存 4. 引用计数:复杂场景下用shared_ptr管理显存生命周期

实际项目中,我见过最典型的生命周期问题:

  • 悬空指针:一个模块释放了显存,另一个模块还在用
  • 内存泄漏:申请了显存,但忘记释放,或者异常路径没处理
  • 生命周期不匹配:两个tensor生命周期重叠,但被错误地复用了同一块显存

我的建议:用RAII(资源获取即初始化)思想管理显存。写一个智能指针包装类,析构时自动释放。C++里可以用自定义deleter配合shared_ptr。Python里可以用context manager(with语句)。

举个例子,C++里我经常这么写:

class CudaTensor {
    float* data_;
    size_t size_;
    bool owned_;
    
public:
    // 构造函数:分配显存
    CudaTensor(size_t size) : size_(size), owned_(true) {
        cudaMalloc(&data_, size * sizeof(float));
    }
    
    // 构造函数:借用显存(不拥有所有权)
    CudaTensor(float* data, size_t size) : data_(data), size_(size), owned_(false) {}
    
    // 析构函数:只有owned_为true时才释放
    ~CudaTensor() {
        if (owned_ && data_) {
            cudaFree(data_);
        }
    }
    
    // 禁止拷贝,允许移动
    CudaTensor(const CudaTensor&) = delete;
    CudaTensor& operator=(const CudaTensor&) = delete;
    
    CudaTensor(CudaTensor&& other) noexcept 
        : data_(other.data_), size_(other.size_), owned_(other.owned_) {
        other.data_ = nullptr;
        other.owned_ = false;
    }
};

避坑指南:我曾经在项目里用裸指针管理显存,结果一个异常抛出,析构函数没执行,显存泄漏了。后来全部改成RAII包装,再也没出过问题。记住:永远不要让裸指针离开你的控制范围。

好了,显存复用策略的上半部分就聊到这儿。对象池、预分配与懒分配、Tensor复用、生命周期管理,这四个点你吃透了,吞吐压测的显存瓶颈基本就能解决大半。

一句话总结:显存复用不是技巧,是习惯。养成「申请前先看看有没有能用的,用完了一定要还回去」的习惯,你的GPU利用率自然就上去了。

想要更多资料,联系可可