4、内存优化基础:显存与内存模型、内存碎片化问题、池化与复用策略

说到YOLO部署,很多人第一反应是「模型精度掉没掉」「推理速度快不快」。

但我得说,内存这块才是真正容易翻车的地方。你想想看,模型在GPU上跑着跑着突然OOM,或者显存占用忽高忽低,这种问题我遇到过太多次了。今天咱们就把内存优化这块地基打牢。

4.1 显存与内存模型:你得知道数据住哪儿

先搞清楚一个基本概念。YOLO部署时,数据主要在三个地方流动:CPU内存GPU显存、还有CPU与GPU之间的传输通道

我个人习惯把显存想象成一个「高速工作台」。模型推理时,权重、输入图像、中间特征图、输出结果,全得摆在这个台子上。而CPU内存呢,更像是「仓库」,负责存原始数据、预处理结果、后处理结果。

这里有个常见的误区——很多人以为显存只存模型权重。其实不是的。我举个例子,YOLOv8输入一张1080p的图像,光输入张量就要占掉大约6MB(640x640x3x4字节)。如果batch size是32,那就是192MB。再加上每一层的中间特征图,你算算看,一个中等规模的模型,显存占用轻松上GB。

显存占用核心公式:

总显存 ≈ 模型权重 + 输入张量 + 所有中间特征图 + 输出张量 + 框架开销

我在项目中遇到过最离谱的一次,是有人把batch size设成64,结果显存直接爆了。后来一查,中间特征图占了总显存的60%以上。嗯,这就是典型的「只算权重没算特征图」的坑。

4.2 内存碎片化问题:看不见的性能杀手

内存碎片化,说白了就是显存被切得七零八落,像一块被狗啃过的蛋糕。

为什么会这样?

YOLO推理过程中,会频繁地分配和释放各种大小的张量。比如前向传播时,每一层都要分配新的特征图,用完又释放。这种「频繁申请-释放」的模式,时间一长,显存里就会出现很多小空隙。

这些小空隙单独看都不大,但加起来可能占掉几百MB。更气人的是,当你需要一块连续的大显存时(比如分配一个大的输出张量),明明总剩余显存够用,但因为不连续,就是分配不出来——然后程序就OOM了。

避坑指南:

我曾经在一个边缘设备上部署YOLOv5s,跑了大概2小时后突然报OOM。排查了半天,不是内存泄漏,就是碎片化问题。重启进程后一切正常,但跑几小时又复现。最后用显存分析工具一看,碎片率高达35%。

怎么检测碎片化?我一般用两个方法:

  • nvidia-smi 看显存占用:如果显存占用持续增长但推理速度反而下降,大概率是碎片化
  • 框架自带工具:PyTorch可以用 torch.cuda.memory_summary() 查看详细分配情况

碎片化的直接后果就是推理延迟抖动。你想想看,本来一次推理只要10ms,结果因为碎片化,显存分配花了50ms,这谁受得了?

4.3 池化与复用策略:把显存当共享单车骑

解决碎片化问题,最有效的思路就是「池化与复用」。说白了,就是别用完就扔,而是回收再利用。

我常用的策略有三种:

4.3.1 显存池(Memory Pool)

显存池的原理很简单:预先申请一大块显存,然后自己管理内部的分配和回收。不再依赖框架的默认分配器。

举个例子,TensorRT就内置了显存池机制。你可以通过设置 IGpuAllocator 来接管显存管理。我自己在项目中写过一套简单的显存池,核心逻辑就这几行:

class MemoryPool:
    def __init__(self, total_size):
        self.pool = cuda.mem_alloc(total_size)
        self.free_blocks = [(0, total_size)]
        self.used_blocks = {}
    
    def alloc(self, size):
        # 从free_blocks中找到合适大小的空闲块
        for i, (offset, block_size) in enumerate(self.free_blocks):
            if block_size >= size:
                # 分配并切分
                self.free_blocks.pop(i)
                if block_size > size:
                    self.free_blocks.append((offset + size, block_size - size))
                self.used_blocks[offset] = size
                return self.pool + offset
        raise MemoryError("池子不够用了")
    
    def free(self, offset):
        # 回收并合并相邻空闲块
        size = self.used_blocks.pop(offset)
        self.free_blocks.append((offset, size))
        self._merge_blocks()  # 合并相邻块,减少碎片

个人经验:显存池的大小一般设为模型峰值显存占用的1.2倍。太小了不够用,太大了浪费。我习惯先跑一次profiling,看看峰值是多少,再定池大小。

4.3.2 张量复用(Tensor Reuse)

YOLO推理时,很多中间张量的生命周期是重叠的。比如卷积层的输入和输出,不会同时存在。这时候就可以复用显存。

具体做法是:分析计算图,找出那些「生命周期不重叠」的张量,让它们共享同一块显存。

我记得在优化YOLOv8部署时,通过张量复用,把中间特征图的显存占用从1.2GB降到了600MB。怎么做的?就是发现backbone的某些层和neck的某些层,它们的特征图不会同时被用到,那就共用一个buffer呗。

4.3.3 对象池(Object Pool)

这个主要针对后处理阶段。YOLO的后处理要生成很多检测框、置信度、类别等对象。如果每个对象都new/delete,碎片化会非常严重。

我的做法是:预先分配一个对象池,后处理时从池里取,用完放回去。

class DetectionPool:
    def __init__(self, pool_size=1000):
        self.pool = [Detection() for _ in range(pool_size)]
        self.available = list(range(pool_size))
    
    def get(self):
        if not self.available:
            # 池子不够了,动态扩容
            self._expand()
        idx = self.available.pop()
        return self.pool[idx]
    
    def release(self, det):
        # 重置对象状态
        det.reset()
        self.available.append(id(det))

三种策略对比:

策略 适用场景 效果 实现难度
显存池 整体显存管理 减少碎片化,降低分配延迟 中等
张量复用 中间特征图 大幅降低峰值显存 较高
对象池 后处理阶段 减少小对象碎片

4.4 实战建议:从哪开始优化?

如果你现在就要动手优化YOLO部署的内存,我建议按这个顺序来:

  1. 先做profiling:搞清楚显存到底花在哪了。用 torch.cuda.memory_allocated()torch.cuda.max_memory_allocated() 看峰值
  2. 检查碎片化程度:连续跑100次推理,看显存占用是否持续增长
  3. 从最简单的开始:先实现对象池,这个改动最小,效果立竿见影
  4. 再考虑张量复用:如果峰值显存还是高,分析计算图,找复用机会
  5. 最后上显存池:如果碎片化严重,或者框架默认分配器表现不佳,再自己写池

注意:不要一上来就搞显存池。我见过有人花了两周写了个完美的显存池,结果发现框架自带的分配器已经够用了。先测量,再优化,这是铁律。

好了,这一章的内容就到这。内存优化是个细活,但只要你把显存模型、碎片化问题、池化复用这三个点吃透了,后面再讲具体优化技巧时,你就能很快上手。下一章咱们聊聊「推理引擎的内存管理机制」,到时候会结合TensorRT和ONNX Runtime的实际案例来讲。