4. 内存优化策略:减少内存分配与拷贝,内存池化技术,Tensor复用与In-place操作

内存优化,说白了就是跟显存和内存「抢地盘」。

我刚开始做部署的时候,经常遇到模型跑着跑着就OOM了。那时候我第一反应是「模型太大了,得剪枝」。后来才发现,很多时候不是模型大,而是内存管理太粗糙了。

你想想看,一次推理过程中,中间结果可能被反复分配和释放。每一次分配,都伴随着系统调用和内存拷贝。这些开销,在端侧设备上尤其致命。

4.1 减少内存分配与拷贝:从源头省钱

内存分配和拷贝,是性能杀手。我见过一个项目,推理时间里有30%都花在了memcpy上。这太浪费了。

核心原则:能不分配就不分配,能不拷贝就不拷贝。

具体怎么做?我总结了几条实战经验:

  • 预分配缓冲区:在模型加载阶段,就把所有需要用到的Tensor空间一次性分配好。推理过程中,只复用,不新分配。
  • 避免隐式拷贝:Python里用a = b有时候是浅拷贝,有时候是深拷贝。C++里更要小心,std::vector的赋值操作默认就是深拷贝。
  • 使用零拷贝技术:比如CUDA的cudaMemcpyAsync配合流,或者直接使用cudaMallocManaged统一内存管理。

我的习惯:在代码里显式标注哪些Tensor是「只读」的,哪些是「可复用」的。这样团队协作时,别人不会误触导致不必要的拷贝。

举个例子,NVIDIA的TensorRT里有个IExecutionContext::enqueueV2接口,它允许你传入预先分配好的输出缓冲区。这就是典型的「预分配+零拷贝」思路。

// 伪代码示例:预分配缓冲区
void* inputBuffer = cudaMalloc(inputSize);
void* outputBuffer = cudaMalloc(outputSize);

// 推理时,只传入指针,不拷贝
context->enqueueV2({inputBuffer, outputBuffer}, stream, nullptr);

4.2 内存池化技术:像管理仓库一样管理内存

内存池化,说白了就是「先囤一批,随用随取」。不用每次都去系统申请,省去了频繁malloc/free的开销。

我记得有一次优化一个视频流模型,每帧都要做预处理、推理、后处理。如果不做内存池化,每帧都会产生大量内存碎片。跑个几分钟,显存就炸了。

内存池的核心设计要点:

  • 固定大小块池:适合Tensor尺寸固定的场景。比如输入是224x224,那就分配固定大小的块。
  • 可变大小块池:适合动态shape的场景。需要配合伙伴算法或slab分配器。
  • 线程安全:多线程推理时,内存池必须加锁或用无锁队列。
池类型 适用场景 优点 缺点
固定大小池 静态shape模型 分配快,无碎片 灵活性差
可变大小池 动态shape模型 灵活,利用率高 实现复杂,可能有内部碎片
伙伴算法池 需要频繁分配/释放 合并快,外部碎片少 内部碎片约50%

我曾经踩过的坑:内存池的初始大小设得太小,导致推理过程中频繁触发池扩容。扩容本身又涉及大块内存分配和拷贝,性能反而下降了。后来我改成「按最大可能需求预分配」,虽然初始占用多了点,但推理过程稳如老狗。

4.3 Tensor复用与In-place操作:一鱼多吃

Tensor复用,就是让同一个内存块在不同阶段扮演不同角色。In-place操作,就是直接在原Tensor上修改,不产生新Tensor。

这两个技术,在深度学习框架里很常见。比如PyTorch的torch.relu_(),那个下划线就表示in-place操作。

为什么重要?因为很多算子的输出可以覆盖输入。比如ReLU、Dropout、BatchNorm的推理阶段,输出和输入尺寸完全一样。这时候做in-place,能省一半内存。

实战技巧:在模型图优化阶段,分析每个算子的「输入输出生命周期」。如果某个Tensor在后续计算中不再被引用,那它的内存就可以被下一个算子复用。

我习惯在部署框架里加一个「内存复用分析器」。它会遍历计算图,标记每个Tensor的「最后一次使用位置」。然后,把那些生命周期不重叠的Tensor,映射到同一块物理内存上。

// 伪代码:Tensor复用分析
for (auto& tensor : graph.tensors()) {
    tensor.lastUse = findLastUse(tensor);
}

// 分配内存时,复用已释放的块
MemoryBlock* allocate(Tensor* tensor) {
    for (auto& block : freeBlocks) {
        if (block.size >= tensor.size && block.lastUse < tensor.firstUse) {
            return block; // 复用!
        }
    }
    return newBlock(tensor.size); // 新分配
}

嗯,这里要注意:in-place操作不是万能的。有些算子不能做in-place,比如卷积的输入如果被覆盖,反向传播就没法算了。但在推理阶段,我们不需要反向传播,所以很多算子都可以放心做in-place。

我的建议:在部署前,先用profiler跑一遍,看看哪些Tensor的分配/释放最频繁。优先优化这些热点。别一上来就全盘重构,容易翻车。

最后说一句,内存优化是个「收益递减」的过程。前20%的优化能带来80%的效果,后面80%的优化可能只带来20%的效果。所以,适可而止,别为了省几MB内存把代码搞成一团乱麻。