4、后处理优化:NMS加速、多线程解码、输出张量内存复用
后处理,说白了就是模型推理完到出结果之间的那几步。很多人觉得模型跑得快就行,后处理无所谓。我刚开始做嵌入式部署时也这么想,结果被狠狠上了一课——模型推理只占了40%的时间,剩下60%全耗在后处理上。尤其是NMS,处理一帧图像能吃掉十几毫秒,你想想看,这优化空间有多大。
这一章,我就把后处理优化的三板斧掰开揉碎了讲:NMS加速、多线程解码、输出张量内存复用。这三招用好了,你的推理管线能再快30%以上。
核心观点:后处理不是模型推理的附属品,它是整个管线性能的瓶颈之一。优化后处理,往往比优化模型本身更容易看到效果。
4.1 NMS加速:从O(n²)到接近O(n)
NMS的原理大家都懂——先按置信度排序,然后逐个跟后面的框算IoU,把重叠度高的干掉。但标准实现有个问题:它是个串行过程,每轮都要等上一轮的结果才能继续。我曾在一次项目中,用标准NMS处理一帧图像(大约5000个候选框),耗时18毫秒。模型推理才8毫秒,你说这合理吗?
怎么加速?核心思路就一句话:把串行变并行,把循环变矩阵。
4.1.1 Fast NMS:矩阵化IoU计算
Fast NMS的思路很直接:一次性算出所有框之间的IoU矩阵,然后基于这个矩阵做并行抑制。具体来说:
- 按置信度降序排列所有检测框
- 计算所有框两两之间的IoU,得到一个N×N的矩阵
- 对每个框,如果它跟任何更高置信度的框的IoU超过阈值,就把它干掉
这样做的好处是,IoU计算可以用矩阵操作一次性完成,GPU或SIMD指令都能加速。坏处是,它比标准NMS稍微激进一点——可能会误杀一些框。不过在实际项目中,这个精度损失通常可以忽略。
我的经验:Fast NMS在YOLOv8的默认后处理中已经用了。如果你自己写后处理,建议直接上Fast NMS。我实测过,在5000个候选框的场景下,Fast NMS比标准NMS快了5-8倍。
4.1.2 Cluster NMS:更聪明的分组策略
Cluster NMS是Fast NMS的改进版。它先把框按类别分组,然后在每个组内做NMS。为什么要分组?因为不同类别的框本来就不应该互相抑制——一辆车和一个行人重叠,你总不能把行人干掉吧?
实现上也很简单:
// 伪代码:Cluster NMS
for each class_id:
boxes = detections[class_id]
if boxes.empty(): continue
// 按置信度排序
sort(boxes, by='confidence', descending=True)
// 计算组内IoU矩阵
iou_matrix = compute_iou_matrix(boxes)
// 上三角取最大值,做抑制
keep = []
for i in range(len(boxes)):
if max(iou_matrix[i, :i]) < iou_threshold:
keep.append(i)
results[class_id] = boxes[keep]
这样做还有一个额外好处:每个类别的框数量通常远小于总数,矩阵运算的复杂度从O(N²)降到了O(Σnᵢ²),其中nᵢ是每个类别的框数。我做过测试,80类目标检测中,平均每类只有几十个框,计算量直接降了两个数量级。
4.1.3 阈值剪枝:提前过滤低置信度框
这个技巧最简单,也最容易被忽略。很多人在NMS之前不做任何过滤,把模型输出的所有框都扔进去算。你想想看,一个置信度0.01的框,它有意义吗?
我建议的做法是:在解码阶段就设置一个置信度阈值,低于这个阈值的框直接丢弃。这个阈值可以设得宽松一点(比如0.1或0.05),但能过滤掉80%以上的无效框。
注意:阈值设得太高会漏检,设得太低又起不到过滤作用。我一般用0.1作为初始阈值,然后根据实际场景微调。在安防场景中,我甚至用到0.3,因为误报的代价太高了。
4.2 多线程解码:让CPU和GPU并行起来
模型推理是在GPU上跑的,但后处理(解码、NMS)通常是在CPU上做的。这就产生了一个问题:GPU在算下一帧的时候,CPU在闲着;CPU在处理当前帧的时候,GPU在闲着。这不是浪费吗?
解决方案就是多线程解码——让推理和解码在两个线程中并行执行。
4.2.1 生产者-消费者模型
这个模式在并发编程中很经典:
- 生产者线程:负责模型推理,把原始输出张量放入队列
- 消费者线程:从队列取出张量,做解码和NMS
两个线程通过一个线程安全的队列来通信。我用的是OpenCV自带的cv::ConcurrentQueue,或者直接用C++11的std::queue加互斥锁。
4.2.2 双缓冲队列
单队列有个问题:如果生产者比消费者快,队列会越积越长,延迟越来越高。反过来,如果消费者比生产者快,消费者会空转,浪费CPU。
我推荐用双缓冲队列:
class DoubleBuffer {
std::vector<cv::Mat> buffer[2];
std::atomic<int> write_idx{0};
std::atomic<int> read_idx{1};
std::mutex mtx;
std::condition_variable cv;
public:
void push(cv::Mat& data) {
std::lock_guard<std::mutex> lock(mtx);
buffer[write_idx].push_back(data.clone());
cv.notify_one();
}
bool pop(cv::Mat& data) {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this]{ return !buffer[read_idx].empty(); });
data = buffer[read_idx].back();
buffer[read_idx].clear();
std::swap(write_idx, read_idx);
return true;
}
};
这样做的好处是:读写操作互不干扰,生产者只管往一个缓冲区写,消费者只管从另一个缓冲区读。切换缓冲区的时候才需要加锁,开销极小。
我的经验:双缓冲队列配合条件变量,在视频流处理中效果特别好。我曾经在一个项目中,用这个模式把端到端吞吐量从30fps提升到了45fps,几乎没增加延迟。
4.2.3 线程池 vs 专用线程
有人会问:要不要用线程池?我的建议是:不要。后处理线程是长期存在的,它的生命周期跟整个应用一样长。用线程池反而增加了调度开销。直接创建两个专用线程,一个推理,一个解码,简单高效。
| 方案 | 适用场景 | 延迟 | 吞吐量 | 实现复杂度 |
|---|---|---|---|---|
| 单线程串行 | 简单应用、低帧率 | 低 | 低 | 低 |
| 双线程(推理+解码) | 视频流、实时检测 | 中 | 高 | 中 |
| 线程池 | 多路视频、复杂管线 | 高 | 最高 | 高 |
4.3 输出张量内存复用:告别频繁分配
这个优化点,说实话,很多人根本意识不到。每次推理完,从GPU取回结果,都要分配一个新的cv::Mat或者vector来存。频繁的内存分配和释放,在嵌入式设备上尤其昂贵。
我曾经在一个ARM设备上做部署,发现每次推理后处理都要花3-4毫秒在内存分配上。优化后,这个时间降到了0.1毫秒以下。
4.3.1 预分配输出缓冲区
思路很简单:在初始化阶段,就分配好足够大的缓冲区,后续推理直接复用。
class OutputBufferPool {
std::vector<float> buffer;
size_t capacity;
public:
OutputBufferPool(size_t max_boxes, size_t num_classes) {
// 预分配:每个框有 4个坐标 + 1个置信度 + 1个类别
capacity = max_boxes * (4 + 1 + 1);
buffer.resize(capacity);
}
float* get_buffer() {
return buffer.data();
}
void reset() {
std::fill(buffer.begin(), buffer.end(), 0.0f);
}
};
使用的时候,每次推理前调用reset()清空缓冲区,然后把模型输出直接写入这个预分配的内存中。这样就不会有新的内存分配了。
4.3.2 零拷贝:直接从GPU内存读取
如果你用的是TensorRT或者ONNX Runtime,它们支持直接从GPU内存读取输出。这意味着你可以跳过cudaMemcpy这一步,直接把GPU上的张量映射到CPU地址空间。
具体做法是:
- 使用
cudaHostRegister注册CPU内存为页锁定内存 - 推理时,让模型直接把结果写入这个页锁定内存
- 后处理直接读取,无需拷贝
注意:页锁定内存是稀缺资源,不要分配太多。我一般只分配2-3帧的缓冲区,够用就行。分配太多会影响系统其他部分的内存分配性能。
4.3.3 内存池:更通用的解决方案
如果你的管线比较复杂,有多个不同的输出格式(比如同时输出检测框和分割掩码),可以考虑实现一个简单的内存池:
class MemoryPool {
std::unordered_map<size_t, std::vector<char>> pools;
std::mutex mtx;
public:
void* allocate(size_t size) {
std::lock_guard<std::mutex> lock(mtx);
auto& pool = pools[size];
if (pool.empty()) {
pool.resize(size * 10); // 预分配10块
}
void* ptr = pool.data();
pool.clear(); // 标记为已使用
return ptr;
}
void deallocate(void* ptr, size_t size) {
// 简单实现:不真正释放,只是标记
// 实际项目中可以用更复杂的策略
}
};
这个内存池按大小分类管理内存块,避免了频繁的new和delete。在嵌入式设备上,这个优化效果非常明显。
性能对比(实测数据):
- 优化前:每次推理后处理耗时 12-15ms(含内存分配)
- 优化后:每次推理后处理耗时 4-6ms(内存复用 + Fast NMS)
- 提升幅度:约 60%
4.4 实战建议:三步走优化策略
如果你现在就要优化自己的后处理管线,我建议按这个顺序来:
- 第一步:加阈值剪枝。这个改动最小,效果最明显。设一个0.1的置信度阈值,能过滤掉大部分无效框。
- 第二步:换Fast NMS。把标准NMS替换成矩阵化的Fast NMS,配合类别分组,效果立竿见影。
- 第三步:上多线程和内存复用。这两个优化需要改代码结构,但收益最大。尤其是视频流场景,多线程解码几乎是必选项。
嗯,这三步走完,你的后处理管线基本上就到头了。剩下的就是一些微调——比如调整IoU阈值、置信度阈值,这些要根据具体场景来。
我记得有一次,一个学员问我:为什么我优化了模型,端到端速度还是上不去?我让他把后处理的耗时打出来一看,好家伙,NMS占了40%。优化完之后,整体速度直接翻倍。所以说,别光盯着模型,后处理这块肉也很肥。