☰
RK3588上YOLOv5s后处理NMS优化:从280ms到0.78ms
2026/10/1 22:08:43 网站建设 项目流程

我前四篇把 RK3588 上跑 YOLOv5s 的模型转换、NPU 推理、性能瓶颈分析都讲得差不多了,这一篇专门聊后处理里最重量级的一环——NMS(非极大值抑制)。先说结论:我最初用 Python 写的一版 NMS,单帧处理 25200 个候选框要 280ms,比 NPU 推理还慢好几倍;经过 C++ 重写,加上缓存面积、按类别并行、NEON 向量化这几板斧,最终压到了 0.78ms,算下来正好是 359 倍加速。整个过程踩了不少坑,也有一些取舍和反思,这篇就从头到尾摊开讲,代码也会给到可以直接抄的版本。

1. 为什么 NMS 会成为整条链路的头号瓶颈

1.1 先回顾一下 YOLOv5s 在 RK3588 上的完整输出链路

我之前在系列里说过,YOLOv5s 的部署链路大体是:PyTorch 训练好的权重导出成 ONNX,再用 RKNN-Toolkit2 转成 RK3588 NPU 能直接跑的 RKNN 模型。纯推理阶段,640×640 输入的单帧大约在 30~60ms,看模型版本和 NPU 频率设置。但这个速度只覆盖到“模型输出原始张量”为止,离画框还很远。

YOLOv5s 的输出不是一张干净的检测结果表,而是三个尺度的特征图:

  • 80×80 特征图,负责小目标。
  • 40×40 特征图,负责中等目标。
  • 20×20 特征图,负责大目标。

每个特征图上的每个格子有 3 个 anchor,每个 anchor 输出一组预测:cx、cy、w、h、objectness,以及 80 个类别的概率(按 COCO 数据集算)。把三个尺度加起来:(80×80 + 40×40 + 20×20) × 3 = 25200 个候选框。也就是说,NPU 每帧吐出来的是一堆未经处理的原始预测,其中绝大部分是背景框和重叠严重的框。

从这 25200 个框变成最终画在画面上的那几个框,需要经历四步:

  1. 解码:把相对于特征图的 cx、cy、w、h 换算成原图坐标系下的 x1、y1、x2、y2。
  2. 置信度过滤:把 objectness 与类别概率相乘,低于阈值(比如 0.25)的框直接丢弃。
  3. NMS:对剩余框按类别执行非极大值抑制,把重叠框收敛成每类每目标一个框。
  4. 坐标还原:把 letterbox 后的推理图坐标映射回原始图像坐标。

NMS 是这四步里计算量最重的一步。它要对剩余框两两计算 IoU,复杂度是 O(n²)。如果过滤后还剩几百上千个框,两两组合的规模就相当可观。我最初在验证阶段用 Python 纯循环写 NMS,实测数据是:NPU 推理约 45ms,解码加过滤用 NumPy 约 15ms,NMS 用 Python 纯循环约 280ms。280ms 什么概念?一秒钟只能处理三帧多一点,直接被这个后处理环节把整体帧率拖垮了。

1.2 NMS 算法本身的原理回顾

NMS 的思路并不复杂,但对它理解得有多深,直接决定后面能做哪些优化。

核心思想:同一个目标上,模型通常会输出多个高重叠的框。算法保留其中置信度最高的那一个,把和它高度重叠的其他框全部抑制掉。标准流程是:

  1. 按置信度从高到低对所有框排序。
  2. 取出当前置信度最高的框,加入最终结果。
  3. 遍历剩余框,计算它们与这个最高框的 IoU。IoU 超过阈值的框(通常取 0.45~0.5)标记为“已抑制”。
  4. 在剩余未被抑制的框中,继续选置信度最高的框,重复执行第 2、3 步,直到处理完所有框。

IoU(Intersection over Union,交并比)是两个框交集面积与并集面积的比值:

  • 交集面积 = 交集宽 × 交集高。交集宽 = min(A.x2, B.x2) - max(A.x1, B.x1),交集高同理。
  • 如果交集宽或者高小于等于 0,直接认为交集面积为 0。
  • 并集面积 = 框A面积 + 框B面积 - 交集面积。

这个逻辑本身不复杂,问题出在 Python 的执行模型上。Python 是解释型语言,每一次 if 判断、每一次函数调用、每一次列表遍历都带有解释器开销。假设过滤后有 500 个框,需要比较的框对数量是 500×499/2 ≈ 12.5 万次,每次循环里还夹着大量浮点运算。累积起来,就是几百毫秒。

这个场景很典型:算法是对的,但语言执行效率限制了吞吐。所以接下来要做的不是改算法本身——标准 NMS 在算法层面很难再降低复杂度了——而是把执行引擎从 Python 换成 C++。这就像同样是搬砖,算法决定搬几次,语言决定每次搬砖的手续费有多高。当算法已经最优时,降低单次开销就成了最实在的优化方向。

2. 第一版 C++ 实现:先把瓶颈从“批判级”拉到“毫秒级”

2.1 环境搭建与工程组织

在 RK3588 上写 C++ 后处理,工具链很常规。我选的是板端直接编译,而不是交叉编译。RK3588 的 CPU 性能足够,板端装好 g++ 直接编,省去了交叉编译链的配置和维护成本。如果你的构建环境必须放在 PC 上,注意用 aarch64-linux-gnu-g++,并且要确保工具链的 glibc 版本和板端系统一致,否则板端跑起来会报 “version `GLIBC_XX' not found”。

编译选项是这组基础配置:

g++ -O3 -std=c++17 -march=armv8-a+simd -funroll-loops -Wall -Wextra nms.cpp test.cpp -o test -lpthread

RK3588 是 Armv8 架构,天然支持 NEON 和 SIMD 扩展,-march=armv8-a+simd只是明确告诉编译器可以用这些指令。-funroll-loops对热循环有一定帮助,但也不是万能的,后面实测时发现它对某些循环反而会造成指令缓存膨胀,具体收益要分别验证。

工程目录很朴素:

postprocess/ ├── CMakeLists.txt ├── nms.hpp ├── nms.cpp ├── test.cpp └── test_data.bin

test.cpp 里我直接从 RKNN 的 C API 拿到模型输出缓冲区,转成自定义的 Detection 结构体数组后调用 NMS。这样测试面对的是真实部署数据,而不是在 PC 上用随机数自嗨。

CMakeLists 的核心配置不到十行:

cmake_minimum_required(VERSION 3.16) project(postprocess) set(CMAKE_CXX_STANDARD 17) set(CMAKE_BUILD_TYPE Release) add_compile_options(-O3 -march=armv8-a+simd) add_executable(test test.cpp nms.cpp) target_link_libraries(test pthread)

2.2 数据结构与解码过滤的设计

Python 里用嵌套列表或者字典存检测框很方便,但到了 C++,第一步就应该设计紧凑、内存连续的数据结构。我把每个检测框定义成:

struct Detection { float x1, y1, x2, y2; // 边界框坐标 float score; // 目标置信度 int class_id; // 类别索引 };

用 struct 而不是 class,成员全部公开,避免 getter/setter 的函数调用开销。整个结构体 24 个字节,不是 16 字节或者 32 字节的对齐粒度,但实测没带来额外问题,后续 NEON 优化时也不是直接基于这个结构体做向量加载的,所以没有过度设计。

解码和置信度过滤我用 C++ 重写,并且做了一个在我看来非常关键的动作:把过滤提前到解码过程中。每次从 RKNN 输出缓冲区解析出 cx、cy、w、h、objectness 后,立即计算最终置信度,有超过阈值的才写入新的紧凑数组。这样能把 25200 个候选框快速压到通常只有几百个框,后续 O(n²) 的 NMS 输入规模大幅缩小。

这里有一个我特别想强调的经验:NMS 真正意义上的第一刀,应该砍在输入数量上,而不是死在 IoU 计算上。每多过滤掉一个框,后面的平方级比较就少一轮。过滤策略并不复杂,就两点:objectness 太低的不留,类别概率最大值太小的不留。这两步合并成一次阈值判断即可。实际场景里一帧 25200 个候选框,经过这样处理后通常只剩下 300~700 个,过滤率超过 97%。

2.3 第一版 C++ NMS 完整实现

第一版我写的是最朴素的教科书版本,目标是把功能跑通,并且和 Python 输出逐框比对一致,再谈优化。代码长这样:

#include <vector> #include <algorithm> #include <cmath> struct Detection { float x1, y1, x2, y2; float score; int class_id; }; inline float overlap(const Detection& a, const Detection& b) { float inter_w = std::min(a.x2, b.x2) - std::max(a.x1, b.x1); if (inter_w <= 0.f) return 0.f; float inter_h = std::min(a.y2, b.y2) - std::max(a.y1, b.y1); if (inter_h <= 0.f) return 0.f; float inter_area = inter_w * inter_h; float area_a = (a.x2 - a.x1) * (a.y2 - a.y1); float area_b = (b.x2 - b.x1) * (b.y2 - b.y1); return inter_area / (area_a + area_b - inter_area); } std::vector<Detection> nms_cpu(std::vector<Detection>& dets, float iou_threshold) { std::sort(dets.begin(), dets.end(), [](const Detection& a, const Detection& b) { return a.score > b.score; }); std::vector<bool> suppressed(dets.size(), false); std::vector<Detection> result; result.reserve(dets.size()); for (size_t i = 0; i < dets.size(); ++i) { if (suppressed[i]) continue; result.push_back(dets[i]); for (size_t j = i + 1; j < dets.size(); ++j) { if (suppressed[j] || dets[i].class_id != dets[j].class_id) continue; if (overlap(dets[i], dets[j]) > iou_threshold) { suppressed[j] = true; } } } return result; }

这段代码和教科书一致,但我编译运行时踩了一个坑:std::vector<bool>是位压缩实现的,每个元素只占 1 位,读写都要通过代理对象,频繁随机访问时开销比普通数组大不少。第一版用它是 3.5ms,换成std::vector<uint8_t>后立刻降到 3.3ms 左右,虽然幅度不大,但白捡的收益不要白不要。

第一版在-O3下,配合前面说的预过滤,处理一帧大约 3.5ms,对比 Python 的 280ms,已经拿到了约 80 倍加速。这个提升主要来自语言本身:无解释器开销、更紧凑的数据结构、编译器优化指令。但离 359 倍还差得远,接下来的每一毫秒都要靠更精细的优化去抠。

3. 从 80 倍到 359 倍:四轮推进式的优化实战

3.1 第一轮:排序与标记策略的再思考

标准 NMS 里,每个被选中为当前最高置信度的 i,都要遍历它后面所有的 j。当 i 对应的框被抑制时会跳过。但注意到了吗:即使跳过后面的计算,外层循环还是要继续走完整个数组,而且每次内层循环都会做一次suppressed[j]的检查。当高置信度框把大量低置信度框抑制掉后,这些检查就变成了纯开销。

我在这一轮做的是:

  1. 把std::vector<bool>统一换成std::vector<uint8_t>。
  2. 对面积做预计算缓存,避免每次 IoU 都重新算面积。
  3. 把std::max/std::min换成三目运算符。

第 1、2、3 项看起来都是小动作,但叠加效果明显。面积缓存这一点尤其重要:框的面积只和自身坐标有关,在 NMS 过程中不会变化。第一版每个 IoU 计算都会重复算两个面积,等于做了大量无用功。把面积数组提前算好,IoU 函数进去只算交集和并集,算力开销直接减少三分之一。

换成三目运算符的原因也值得展开一下:在 ARM 平台上,三目运算符会生成更紧凑的fcmp/csel指令序列,而std::max即便标记了 inline,也有可能被优化器决策成分支跳转。指令越少、分支越少,性能越好。实测这一项在-O3下有约 8% 的提升。

这轮优化做完,NMS 从 3.5ms 压到 2.6ms。虽然逐项拆开来看,每一项的收益都在 10%~15% 之间,但组合起来就是实打实的 1ms 级下降。

3.2 第二轮:IoU 计算的开销压榨

IoU 计算是整个 NMS 里被调用次数最多的热函数,大约调用 n×(n-1)/2 次。它在-O3下通常已经被内联进外层循环了,但函数体内的计算方式还大有讲究。

改造后的 IoU 长这样:

inline float overlap_area(const Detection& a, float area_a, const Detection& b, float area_b) { float inter_x1 = a.x1 > b.x1 ? a.x1 : b.x1; float inter_y1 = a.y1 > b.y1 ? a.y1 : b.y1; float inter_x2 = a.x2 < b.x2 ? a.x2 : b.x2; float inter_y2 = a.y2 < b.y2 ? a.y2 : b.y2; float inter_w = inter_x2 - inter_x1; if (inter_w <= 0.f) return 0.f; float inter_h = inter_y2 - inter_y1; if (inter_h <= 0.f) return 0.f; float inter_area = inter_w * inter_h; return inter_area / (area_a + area_b - inter_area); }

这里有两个关键点值得单独说。

第一,提前返回的顺序很重要。我先把交集宽度算出来,宽度小于等于 0 就直接返回 0,再算高度。实测真实一帧数据里,大约 70% 的框对根本没有交集。这些框对根本不需要进入后续的面积计算和除法,提前返回让它们变成了一次减法加一次比较的极轻操作。

第二,除法只有真正有交集时才算。浮点除法在 ARM 上比乘法的开销高不少,能躲就躲。很多实现会先算交集面积再算并集面积最后除一下,但如果交集本身不存在,后面全是白算。

这一轮优化后,NMS 从 2.6ms 压到 1.8ms。

3.3 第三轮:按类别分解 + 多线程并行

标准 NMS 里,类别不同的框互相之间本来就不需要抑制。这个天然的解耦条件是绝佳的并行边界。RK3588 是 8 核 CPU(4×Cortex-A76 + 4×Cortex-A55),不利用一下多核太浪费了。

我的做法是:

  1. 把过滤后的检测框按 class_id 分组到若干个小数组里。
  2. 每个线程负责一个或几个类别,独立执行 NMS。
  3. 最后把各线程的结果合并。

执行起来遇到第一个问题是:COCO 有 80 个类别,但一帧里真正出现的往往只有几个。如果直接建 80 个桶,大部分桶是空的,线程调度反而浪费。我做了个统计:先扫一遍,找出哪些类别实际有框,只为这些类别建分组。这样既省内存,又避免了空线程空转。

第二个问题是线程数量的选择。RK3588 的 4 个大核 A76 性能比 4 个小核 A55 强不少。8 线程全开时,任务会被分配到小核上,NMS 的延迟反而可能变差。实测 4 线程在大部分场景下最优:四个 A76 核各干各的类别组,A55 核闲着反而省电省热量。

第三个问题,也是我一开始翻车的地方:线程创建和销毁的开销。当检测框总数很少(比如只有 30 个)时,创建 4 个线程的代价比并行收益还大。我加了一个判断:只有当过滤后的总框数超过 300 时才启用多线程,否则直接单线程跑。这个阈值是我在自己设备上测出来的经验值,不同设备可能略有差异,建议做成常量方便调。

多线程版把 1.8ms 压到了 0.9ms,这轮是最实在的一轮。

3.4 第四轮:NEON 向量化的尝试与取舍

NEON 是 Armv8 的 SIMD 指令集,一条指令能并行处理 128 位数据,也就是 4 个 32 位浮点数。直觉上,IoU 计算里的浮点操作非常适合向量化。我没忍住写了 NEON 版本,但整个实验下来,我得承认这一轮收益没有想象中夸张,而且引入了一些工程复杂度。

NEON 可以加速的是“同一个检测框 a,同时和多个 b 框计算 IoU”的场景。用float32x4_t同时加载 4 个 b 框的 x1,一次算 4 组inter_x1 = max(a.x1, b.x1),理论上把 4 个框的 IoU 计算压缩到接近 1 个框的耗时。

数据布局必须从 AoS(Array of Structures)改成 SoA(Structure of Arrays)。也就是说,不要用std::vector<Detection>,而是把 x1、y1、x2、y2、score 分别存成 5 个连续 float 数组,这样vld1q_f32才能批量加载。

核心片段示意:

#include <arm_neon.h> float32x4_t vinter_x1 = vmaxq_f32(vdupq_n_f32(a_x1), vld1q_f32(&b_x1[i])); float32x4_t vinter_x2 = vminq_f32(vdupq_n_f32(a_x2), vld1q_f32(&b_x2[i])); float32x4_t vinter_w = vsubq_f32(vinter_x2, vinter_x1);

实际跑起来有三个问题:

  1. 提前返回的分支在向量化版本里变成了障碍。如果一个向量批里有某个通道的交集宽度小于 0,“这一批”整体不能提前返回,只能继续往下算完所有通道,最后再掩蔽。这让 NEON 版在稀疏目标场景的效率打了折扣。
  2. 边界处理繁琐。候选框数量不一定是 4 的倍数,最后剩下的 1~3 个框得单独走标量路径。
  3. 代码可维护性下降。NEON 版本到处都是vdupq、vld1q、vst1q,后续想加个 Soft-NMS 或者 DIoU-NMS 变体时,改造难度比标量版本高太多。

最终 NEON 版测出来约 0.78ms,相比多线程版 0.9ms 只快了 15%。这个收益值得保留,但考虑到维护成本,我最终在生产代码里用的是多线程标量版(0.9ms)。359 倍确实是做到了——作为实验版本。如果你对延迟极其敏感,目标就是榨干每一微秒,NEON 版可以上;否则,我更推荐标量加多线程的组合。

4. 可复用的最终代码与部署衔接细节

4.1 最终版 NMS 完整代码

这里给出我生产环境里用的版本——标量计算加多线程分组。它足够快,代码也足够干净,适合直接抄走。

头文件nms.hpp:

#ifndef NMS_HPP #define NMS_HPP #include <vector> struct Detection { float x1, y1, x2, y2; float score; int class_id; }; std::vector<Detection> nms( std::vector<Detection>& dets, float iou_threshold, int num_threads = 4); #endif

实现文件nms.cpp:

#include "nms.hpp" #include <algorithm> #include <thread> #include <atomic> namespace { inline float overlap_area(const Detection& a, float area_a, const Detection& b, float area_b) { float inter_x1 = a.x1 > b.x1 ? a.x1 : b.x1; float inter_y1 = a.y1 > b.y1 ? a.y1 : b.y1; float inter_x2 = a.x2 < b.x2 ? a.x2 : b.x2; float inter_y2 = a.y2 < b.y2 ? a.y2 : b.y2; float inter_w = inter_x2 - inter_x1; if (inter_w <= 0.f) return 0.f; float inter_h = inter_y2 - inter_y1; if (inter_h <= 0.f) return 0.f; float inter_area = inter_w * inter_h; return inter_area / (area_a + area_b - inter_area); } std::vector<Detection> nms_single_class(std::vector<Detection> dets, float iou_threshold) { std::sort(dets.begin(), dets.end(), [](const Detection& a, const Detection& b) { return a.score > b.score; }); const size_t n = dets.size(); std::vector<float> areas(n); for (size_t i = 0; i < n; ++i) { areas[i] = (dets[i].x2 - dets[i].x1) * (dets[i].y2 - dets[i].y1); } std::vector<uint8_t> active(n, 1); std::vector<Detection> result; result.reserve(n); for (size_t i = 0; i < n; ++i) { if (!active[i]) continue; result.push_back(dets[i]); for (size_t j = i + 1; j < n; ++j) { if (!active[j]) continue; if (overlap_area(dets[i], areas[i], dets[j], areas[j]) > iou_threshold) { active[j] = 0; } } } return result; } } // namespace std::vector<Detection> nms(std::vector<Detection>& dets, float iou_threshold, int num_threads) { if (dets.empty()) return {}; int max_class = -1; for (const auto& d : dets) { max_class = std::max(max_class, d.class_id); } std::vector<std::vector<Detection>> groups(max_class + 1); for (auto& d : dets) { groups[d.class_id].push_back(d); } std::vector<std::vector<Detection>> results(groups.size()); if (num_threads <= 1 || dets.size() < 300) { for (size_t c = 0; c < groups.size(); ++c) { if (groups[c].empty()) continue; results[c] = nms_single_class(groups[c], iou_threshold); } } else { std::vector<int> active_classes; for (size_t c = 0; c < groups.size(); ++c) { if (!groups[c].empty()) active_classes.push_back(static_cast<int>(c)); } unsigned int thread_count = std::min( static_cast<unsigned int>(num_threads), static_cast<unsigned int>(active_classes.size())); std::atomic<size_t> next_idx{0}; std::vector<std::thread> pool; pool.reserve(thread_count); auto worker = [&]() { while (true) { size_t idx = next_idx.fetch_add(1); if (idx >= active_classes.size()) break; int c = active_classes[idx]; results[c] = nms_single_class(groups[c], iou_threshold); } }; for (unsigned int t = 0; t < thread_count; ++t) { pool.emplace_back(worker); } for (auto& t : pool) t.join(); } size_t total = 0; for (const auto& r : results) total += r.size(); std::vector<Detection> final_result; final_result.reserve(total); for (auto& r : results) { final_result.insert(final_result.end(), r.begin(), r.end()); } return final_result; }

注意nms_single_class的参数是值传递。这是有意设计的:std::sort会原地打乱元素的顺序,而我不希望函数外部观察到这个副作用。STL 的sort不是稳定排序,如果后续模块对结果顺序敏感,值拷贝传入更安全。至于性能,每个类别分组后的数组本身就不大,拷贝开销完全可以忽略。

4.2 与 RKNN 输出的衔接方式

RKNN 的 C API 推理结束后,输出是 packed 的二进制缓冲区。以rknn_output数组为例,YOLOv5s 通常输出 3 个张量,分别对应三个尺度。读取后的解码伪代码可以这样组织:

std::vector<Detection> decode_outputs(rknn_output* outputs, float conf_threshold) { std::vector<Detection> dets; // strides: 640 / 80 = 8, 640 / 40 = 16, 640 / 20 = 32 const int strides[3] = {8, 16, 32}; const int grids[3] = {80, 40, 20}; for (int s = 0; s < 3; ++s) { int num_boxes = grids[s] * grids[s] * 3; const float* data = reinterpret_cast<const float*>(outputs[s].buf); for (int b = 0; b < num_boxes; ++b) { const float* ptr = data + b * 85; float obj = ptr[4]; if (obj < conf_threshold) continue; int best_class = -1; float best_conf = 0.f; for (int c = 0; c < 80; ++c) { float conf = ptr[5 + c]; if (conf > best_conf) { best_conf = conf; best_class = c; } } float score = obj * best_conf; if (score < conf_threshold) continue; float cx = ptr[0]; float cy = ptr[1]; float w = ptr[2]; float h = ptr[3]; // 这里需要根据你的 RKNN 转换版本来确认 // 有些转换会把 anchor 解码内嵌进模型,CX/CY/W/H 已是相对输入图的尺度 // 有些则输出的是相对特征图的归一化坐标,需要乘 stride float x1 = (cx - w * 0.5f) * strides[s]; float y1 = (cy - h * 0.5f) * strides[s]; float x2 = (cx + w * 0.5f) * strides[s]; float y2 = (cy + h * 0.5f) * strides[s]; dets.push_back({x1, y1, x2, y2, score, best_class}); } } return dets; }

这个解码函数里藏着一个容易出大问题的细节:你的 RKNN 转换脚本到底把 anchor 解码做到哪一步了。我自己的经验是,用 RKNN-Toolkit2 转换时,如果输出的张量数量是 3,说明模型输出基本保持原始 head 的结构,后处理需要传入 anchor 先验并按偏移量解码;如果转换脚本里做了额外的融合,输出可能是一个 1×25200×85 的单一张量,对应的解码逻辑又不同。最保险的方式是拿一帧已知结果的图去反推,核对解码后的坐标和 PyTorch 推理结果是否一致,再决定乘不乘 stride、要不要 exp 处理。

另外注意:解码出来的坐标是 letterbox 后 640×640 坐标系下的值。如果最终要在原始图像上画框,必须在 NMS 之后做一次反向 letterbox 映射。这个我在系列前篇讲过了,这里只提醒你别漏掉。漏掉的典型症状就是检测框偏大或偏小,位置整体平移,看起来很违和。

5. 实测数据、调试心得与避坑清单

5.1 几个版本的性能对比

我把整个优化过程的实测数据整理成了表格。测试条件统一为:RK3588 板端 g++ 11.4 直编译,-O3 -march=armv8-a+simd,输入为真实推理输出的一帧数据(过滤后 512 个框,11 个有效类别,IoU 阈值 0.45)。单帧耗时取 100 次平均值。

版本单帧耗时相对 Python 加速比关键改动
Python 纯循环 + NumPy约 280ms1×基线
C++ 第一版(vector + 未缓存面积)约 3.5ms80×语言替换 + 编译优化
缓存面积 + 三目运算符 + 提前返回约 2.6ms108×热函数瘦身
按类分组 + 4 线程并行约 0.9ms311×多核利用
NEON 向量化(SoA 布局)约 0.78ms359×SIMD 优化

有必要说明的是,359 倍是我拿“Python 最慢版本”对比“NEON 最优化版本”的端点值。生产代码其实用的是 0.9ms 的多线程版本。真实产品里 311 倍已经足够把 NMS 从瓶颈降级成可忽略项,多出来的这 0.12ms,对大多数场景没有本质差别。

还有一个容易被忽略的整体收益:解码和置信度过滤这部分,Python 版耗时约 15ms,C++ 重写后降到约 0.3ms。整个后处理链路 Python 化时约 295ms,C++ 化后约 1.1ms,这才是全链路改造的真正的效果。NMS 加速只是其中一块拼图,但确实是最亮眼的一块。

5.2 调试中的几个隐蔽坑

这几个坑我逐一踩过,网上单搜关键词很难找到完整答案,整理出来给你省点时间。

坑一:坐标没有做 letterbox 反向映射。第一次全链路整合后画框,发现目标位置总是偏一点。排查半天才想起来,解码算出来的坐标是 letterbox 后 640×640 坐标系下的值,而显示用的原图有不同的缩放比例和 padding。必须要在 NMS 之后、画框之前做逆变换。这个坑不算 NMS 本身的 bug,但最早出现的时候最容易被误判成 NMS 写错了。

坑二:vector 性能陷阱。前面已经反复提到,std::vector<bool>是特化的位压缩容器,读写通过代理对象完成。等到 NMS 内层循环高频操作它的时候,隐开销就会被放大。直接换成std::vector<uint8_t>是最省心的做法,性能提升大约 5%~10%,代码语义还更清楚。

坑三:多线程输出顺序不稳定。按类别分组并线后,合并结果的顺序取决于各线程完成的先后。不同帧之间,同一个目标的输出顺序可能变化。如果下游有跟踪、去重逻辑依赖框顺序,这会引入偶发问题。解决办法是在合并后按 score 做一次稳定排序,耗时几乎可以忽略。

坑四:NEON 版本在少框场景反而更慢。过滤后只剩 30~50 个框时,NEON 版因为数据布局变化、边界处理和掩蔽逻辑,实测比标量版慢 20%。这再次印证了一个道理:NMS 这类算法,性能优化的收益高度依赖输入规模,没有一劳永逸的方案。想上线跑,最好把你真实的分布采样出来测一轮再决定用哪种实现。

坑五:RKNN 输出缓冲区的对齐问题。RKNN 的rknn_output.buf默认对齐到 16 字节,直接reinterpret_cast成float*访问没问题。但如果你把输出拷贝到自己的数组后,内存对齐可能就不再满足 NEON 的要求,导致段错误。稳妥的做法是在解码阶段显式memcpy到对齐好的临时数组,或者去查一下你用的 RKNN 版本的对齐承诺,别踩侥幸跳过。

5.3 如何验证 C++ 版本和 Python 参考实现一致

优化之前最重要的事情:先保证 C++ 输出的框和 Python 参考实现完全一致。没有这一步,后面所有加速数据都可能建立在错误结果上。

我的验证流程是:

  1. 固定一帧输入,导出过滤后的 512 个检测框作为测试数据,存成二进制文件。
  2. 用 Python 跑一轮标准 NMS,结果存盘。
  3. 用 C++ 读同样的输入文件,跑 NMS,结果存盘。
  4. 逐框对比 x1 / y1 / x2 / y2 / score / class_id,允许浮点误差 1e-5。
  5. 特别注意:如果你 Python 参考用的是 OpenCV 的cv2.dnn.NMSBoxes,它的 IoU 阈值语义和手写版可能略有差异。统一改成手写标准 NMS 再对比。
  6. 验证通过后再开始优化。每做一轮优化都重新跑一遍对比,确保输出框集合不变。

这个流程帮我抓住了一个我差点没发现的错误:在优化 IoU 计算时,我把一个边界条件写错了,导致某个重叠框没有被抑制,最终结果多了一个框。如果不做一致性校验,这种问题在真实场景里很难肉眼发现,而且会随机出现。

调试用的小脚本长这样:

./test test_data.bin out.bin python compare.py out.bin ref.bin

compare.py 的逻辑很简单:按 score 排序后逐个框比对坐标和类别,输出不一致的索引和差值。跑通了之后再进入优化阶段,心里才有底。


我最后还想说一句关于“359 倍”这个数字的个人感受。单独看,它确实是个漂亮的结果,但它真正值得记录的原因不在于把 NMS 加速了多少倍,而在于它逼着我重新审视了整个部署链路里的每一个算子、每一块数据结构、每一条 CPU 指令。NMS 只是其中一个典型的缩影:先确认算法正确,再换执行引擎,然后沿着热路径逐层榨干开销,最后用多核和 SIMD 把剩余部分并行化。这套思路放到任何目标检测后处理里都成立。如果你也在优化类似的算子,别急着上 NEON 或者多线程,先把输入数量和热函数内部的冗余算干净,收益往往会比想象的更大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询