之前几篇把 RK3588 上跑 YOLOv5s 的路数基本捋顺了:模型导出 ONNX、用 RKNN-Toolkit2 转成 rknn、再通过 C API 把推理搬进 C++ 侧。当时我在板子上打点,NPU 单帧推理 FP16 模型,640×640 输入大概在 28ms 到 45ms 之间,说实话已经很够看了。真正让我停下来的是接入 USB 摄像头做实时检测那会儿:预览帧率死活上不了 20FPS,单帧从采集到显示要吃掉 60ms 以上。我不信邪,把采集、预处理、推理、后处理逐段加时间戳,结果发现推理确实没拖后腿,倒是从 RKNN 输出的 raw tensor 到最终画出若干个目标框,这一小段后处理经常要 30ms 以上,目标一多甚至直接飙到 80ms。
问题就出在 NMS 上。更准确地说,是整个后处理链路里那颗不起眼但极其拖沓的瓶颈。这篇就把我完整优化过程拆开讲:从 Python 验证基线,到朴素 C++ NMS,再到最终在 RK3588 的 CPU 上把后处理从 358ms 压到 1ms 以内,加速 359 倍。整个过程没有用到什么神秘黑科技,全是工程上常见的招数,只是很多人习惯在 PC 上写推理代码,一换到 ARM 板子就忽略了这些细节。
1. 模型推理 28ms,端到端却掉到 20FPS:NMS 这个黑洞
先解释一下 NMS 为什么在后处理里这么扎眼。YOLOv5s 在 640×640 输入下,一共输出 25200 个候选框。这个数字是怎么来的?三个检测层分别负责 80×80、40×40、20×20 的网格,每个网格点上有 3 个 anchor,加起来就是 80×80×3 + 40×40×3 + 20×20×3 = 19200 + 4800 + 1200 = 25200。
每个候选框对应一个 85 维向量:前 5 维是中心坐标 cx、cy、宽 w、高 h 和 objectness 置信度,后 80 维是 COCO 数据集的类别置信度。也就是说,RKNN 从 NPU 里吐出来的不是干净的目标框,而是一张 25200×85 的稠密预测表。我们得自己做解码、置信度筛选、类别判定,最后还要用 NMS 把重复包围同一目标的框去掉。
很多人对 NMS 的理解停留在“选最高分,删掉重叠的框”这个层面,实际实现起来才头疼。因为它本质是一个二层循环:先按置信度排序,再从最高分开始,逐个和剩余框计算 IoU,高于阈值就丢弃。25200 个候选框摆在那,就算置信度过滤后只剩几百个,如果你在 Python 里用列表推导加循环去算,每一帧的耗时都会被放大到肉眼可见的程度。
还有个特殊情况:RKNN 在 NPU 上对 NMS 这类算子支持很有限。你可以试图像在 PyTorch 里那样把 NMS 塞进模型尾部再导出,但转成 rknn 之后,它要么不支持,要么变成 CPU 回退算子,性能比自己在外部写更差。所以我从一开始就决定:RKNN 只管输出原始分数,后处理全部放在 CPU 侧用 C++ 自己写。
这里插一个经验:检测部署的优化顺序,永远先是工具链能跑通,再是单帧性能,最后才是多线程流水线。不要在第一步就去搞 NEON 和 4 核并行,否则后面调试数据对不对都会非常痛苦。我之所以能被 NMS 卡住,恰恰是因为前几步都比较顺,掉以轻心了。
2. 先跑对的朴素 C++ NMS:双循环、全排序、每框都算 IoU
既然要自己写,第一版我完全没做性能设计,目的只有一个:把流程跑对,验证自动后处理结果和 Python 实现一致。
2.1 数据结构:直接沿用 85 维块还是转成紧凑结构
我第一版用的是一个带齐了所有字段的 RectangleBox:
struct RawBox { float x1, y1, x2, y2; float obj_conf; int cls_id; float cls_conf; };然后在 C++ 里把 25200 个候选先全部解析一遍,只要是 obj_conf 大于某个极低阈值(比如 0.05)的,就转成 RawBox 丢进 vector。这个阈值不能设得太高,否则后面调 NMS 阈值时没有回旋余地。第一版我图省事,直接对 25200 行全量做转换,不筛选,先把数据形态搞清楚。
2.2 朴素算法:排序加类内抑制
标准 NMS 流程分三步:按分数降序排序,取当前最高分的框,剔除掉所有和它同类别且 IoU 超阈值的框,然后继续处理下一个。
float intersection_over_union(const RawBox& a, const RawBox& b) { float xx1 = std::max(a.x1, b.x1); float yy1 = std::max(a.y1, b.y1); float xx2 = std::min(a.x2, b.x2); float yy2 = std::min(a.y2, b.y2); float w = std::max(0.0f, xx2 - xx1); float h = std::max(0.0f, yy2 - yy1); float inter = w * h; float area_a = (a.x2 - a.x1) * (a.y2 - a.y1); float area_b = (b.x2 - b.x1) * (b.y2 - b.y1); float uni = area_a + area_b - inter + 1e-6f; return inter / uni; } std::vector<RawBox> nms_baseline(std::vector<RawBox>& boxes, float iou_thr) { std::sort(boxes.begin(), boxes.end(), [](const RawBox& a, const RawBox& b) { return a.obj_conf * a.cls_conf > b.obj_conf * b.cls_conf; }); std::vector<RawBox> keep; for (size_t i = 0; i < boxes.size(); ++i) { if (boxes[i].cls_conf < 0.0f) continue; keep.push_back(boxes[i]); for (size_t j = i + 1; j < boxes.size(); ++j) { if (boxes[j].cls_conf < 0.0f) continue; if (boxes[i].cls_id != boxes[j].cls_id) continue; if (intersection_over_union(boxes[i], boxes[j]) > iou_thr) { boxes[j].cls_conf = -1.0f; } } } return keep; }这里我把被抑制的框的 cls_conf 改成负数作为哨兵。实际工程里我更喜欢用独立的 dead flag,避免浮点极端情况下出现误判。第一版能用,但效率不行。我在 RK3588 的 A76 大核上测,这个版本平均要 82ms。框架简单,但性能根本没法用于实时视频。
2.3 这一版慢在哪
慢的原因有三层。第一,std::sort 对全量候选排序,候选数量还是太大,哪怕客观上看 25200 并不算多,但排序本身只是其中一小部分。第二,NMS 的双层循环是 O(M²),M 是所有进入排序的框数,第一版我把低分框也放进来,M 经常是几千甚至上万。第三,RawBox 结构体内存不太紧凑,每算一次 IoU 都要读取两组坐标,特别在 M 大的时候,cache 命中率非常差。
所以要提速,不能简单去优化 IoU 这几个乘法,而是要让进入 NMS 循环的候选数量急剧减少。方向其实就那么几个:过滤前置、排序范围缩小、内存布局归一。
3. 真正拉开差距的是这四招:过滤前置、部分排序、紧凑结构、NEON
我把后面做的优化归纳成四条。如果只记住一条,那就是第一招:千万不要让无效框进入后续循环。
3.1 置信度过滤前置,让脏数据根本进不了 NMS
YOLOv5s 推理输出的 25200 个框里,绝大多数都是背景。测试一张普通的室内办公场景,置信度超过 0.25 的框通常只有几十个,稍微拥挤的街景也就两三百个。所以第一版最蠢的地方就是把这几千个低分框全保留下来,还让它们参与排序和 NMS 循环。
我在解析阶段直接把 85 维向量的 objectness 和类别最大分数乘起来,得到 final_score:
float obj_conf = raw[4]; float class_max = 0.0f; int class_id = -1; for (int c = 5; c < 85; ++c) { if (raw[c] > class_max) { class_max = raw[c]; class_id = c - 5; } } float final_score = obj_conf * class_max; if (final_score < conf_thr) continue;这一步在解码阶段就做,后面的 vector 只保存 final_score 达标的框。我把 conf_thr 默认调到 0.25。实际效果非常明显:候选数量从几千上万掉到几十到几百,NMS 循环的规模直接缩小两个数量级。第一版 82ms,改完这个立刻降到 9ms 左右。
有人会担心阈值设太高漏检。我的看法是,部署场景下很少需要输出低于 0.2 置信度的目标。如果你真的需要低灵敏度,可以先用 0.1,但一定要配合更精准的 NMS 阈值去调,不然误检会爆炸。
3.2 部分排序替代全排序:前 K 个就够用
NMS 需要从最高分的框开始处理,但其实我们根本不关心排名 300 名以后的框是什么状态。在真实目标检测图里,一帧输出超过 100 个有效目标已经非常极端了。所以我用 std::partial_sort 替代 std::sort,只保证前 K 个元素是正确降序,后面的元素不需要有序。
int K = std::min<int>(kTopK, (int)boxes.size()); std::partial_sort(boxes.begin(), boxes.begin() + K, boxes.end(), [](const DecodedBox& a, const DecodedBox& b) { return a.score > b.score; });这里我把 kTopK 设成 100,对常规检测完全够用。partial_sort 的时间复杂度是 O(K * logN),比全排序 O(N * logN) 便宜得多。这个优化单独看收益不算特别大,但在候选数量已经变小的基础上,它把排序这块又压缩了不小比例。
3.3 数据结构从 AOS 走向紧凑对齐,减少缓存反复读
第一版的 RawBox 里有 float 和 int 混排,编译器为了对齐会插入 padding,结构体实际占用经常比直觉大不少。我后来把它改成只留下 NMS 真正需要的字段:
struct DecodedBox { float x1, y1, x2, y2; float score; int cls; };这看起来只是删掉了 obj_conf 和 cls_conf 两个字段,实际上好处很大:对象内存变小,缓存里能装下更多候选框。特别是 IoU 双循环要反复读其他框的坐标时,紧凑结构能明显减少 cache miss。
如果你还想更激进,可以把坐标剥离成 SoA(数组结构体)形式,也就是 x 数组、y 数组、x2 数组、y2 数组分开存储。但对 NMS 这种小循环,SoA 的收益通常没有传说中那么大,反而让代码维护变得困难。我的习惯是先用 AoS 紧凑结构,性能不够再换 SoA。
3.4 NEON 用在该用的地方:分类打分循环,而不是 IoU 双循环
RK3588 的 CPU 是 4 个 Cortex-A76 大核加 4 个 Cortex-A55 小核,A76 核心支持完整的 ARMv8.2-A 指令集,NEON 是标准能力。我在最开始的方案里试过对 IoU 计算强行做 NEON,结果收益很小。原因是 IoU 计算是一连串有依赖的标量运算,先把两框相交区域的 w、h 求出来,再求交集面积,最后做除法。这个依赖链很难通过并行四条 lane 去加速,编译器自动向量化往往也拿它没办法。
真正适合 NEON 的是前面那个对 80 个类别分数求最大值的循环。一次可以加载 4 个 float,用 vmaxq_f32 把四个 lane 里的最大值合并出来:
#include <arm_neon.h> float max_class_score_neon(const float* raw) { float32x4_t cur_max = vdupq_n_f32(0.0f); for (int c = 5; c <= 81; c += 4) { float32x4_t scores = vld1q_f32(raw + c); cur_max = vmaxq_f32(cur_max, scores); } float max_arr[4]; vst1q_f32(max_arr, cur_max); float m0 = max_arr[0] > max_arr[1] ? max_arr[0] : max_arr[1]; float m1 = max_arr[2] > max_arr[3] ? max_arr[2] : max_arr[3]; return m0 > m1 ? m0 : m1; }这段代码只是示意。实际你还需要把对应的类别下标也找出来,我一般是同时维护一个 index 的 NEON 版本,用 vbslq 根据比较结果把整数下标选出来。这一部分对总耗时的贡献比 IoU 优化大得多,因为它在 25200 个候选框上都要跑一遍。
编译参数同样关键。我最终使用的编译命令是:
aarch64-linux-gnu-g++ -O3 -march=armv8.2-a -mtune=cortex-a76 -std=c++17 -fno-exceptions -fno-rtti-march=armv8.2-a让编译器可以放心生成 NEON 指令;-mtune=cortex-a76按大核流水线调度。这里注意,如果你用 32 位 ARM 交叉编译,才需要 -mfpu=neon,RK3588 是 64 位系统,不需要额外指定。
4. 从 358ms 到 1ms 的完整基准:加速比是怎么一步步攒出来的
优化做完,得拿数据说话。我的测试环境是 RK3588 板卡,Linux 系统,用的是 4 个 A76 大核中的单核跑后处理。输入是 640×640 的 FP16 RKNN 模型,测试视频是 200 帧混合场景(室内、街景、人车混行各占一部分)。测量方法很简单:用 std::chrono::high_resolution_clock 包住完整后处理函数,包括从 RKNN 输出指针到拿到最终目标框列表。先预热 10 帧,再统计 100 帧的平均耗时。
完整结果看这张表:
| 实现阶段 | 主要特征 | 平均耗时 | 相对 Python 基线 | |---|---:|---:| | Python 基线 | NumPy 解码加循环 NMS | 358 ms | 1x | | C++ 朴素版 | 全量候选、全排序、完整 NMS | 82 ms | 约 4.4x | | C++ 过滤前置 | 解码时按置信度截断 | 9.2 ms | 约 39x | | C++ 部分排序 + 紧凑结构 | 只保留 topK、减少缓存抖动 | 4.1 ms | 约 87x | | C++ NEON + 编译优化 | 类别打分向量化、A76 调度优化 | 1.0 ms | 约 359x |
注意一个细节:这里说的耗时是整个后处理链路,不是只有 NMS 双循环本身。如果你把范围严格限定在 IoU 遍历上,绝对时间会更短,但部署时我们看的还是从推理输出到目标框总体花了多少毫秒,所以我没有把解码、置信度打分的部分拆出去单独算。
从 4.1ms 到 1ms,这个最后阶段的优化主要来自两部分:一是把类别最大分数的循环改成 NEON,二是把解码循环里能提前算的除法、反量化、坐标换算全部前置。实际跑下来,解析打分这一块从 0.8ms 降到 0.45ms,NMS 本身从 0.3ms 降到 0.2ms,整体就压进了 1ms。
顺带说一下,如果你用的 RKNN 模型是 int8 量化,后处理前要先反量化,这个开销不小。我在另一个 int8 版本里测得单纯反量化加解析就要 1.5ms。所以如果部署追求极致延迟,FP16 后处理更友好,int8 的优势在 NPU 推理速度而不是 CPU 后处理。
5. 部署级 NMS 的边界条件和踩坑记录
加速到 1ms 不代表结束。把 NMS 真正用到视频流里,还会遇到一堆 Python demo 里根本不会暴露的问题。
5.1 IoU 计算里的空框和越界问题
解码时中心坐标加上宽高算出来的 x1、y1、x2、y2,有可能超过图像边界,甚至有 w 或 h 为负的情况。如果不做夹取,IoU 计算里可能算出负的交集面积,导致整帧结果异常。我在 decode 阶段统一做一次 clamp:
float x1 = clamp(cx - w * 0.5f, 0.0f, img_w); float y1 = clamp(cy - h * 0.5f, 0.0f, img_h); float x2 = clamp(cx + w * 0.5f, 0.0f, img_w); float y2 = clamp(cy + h * 0.5f, 0.0f, img_h);NMS 的 IoU 计算里也要对 w、h 做小于 0 就置 0 的处理,避免出现负面积干扰排序。这不是理论问题,我第一版在复杂街景上跑,偶尔就会出现一个角坐标越界的框把另一个正常框给抑制掉的现象。
5.2 类间抑制还是类内抑制,取决于业务
标准 YOLOv5 后处理通常按类别分别做 NMS,也就是两个不同类别的框即使 IoU 很高,也不会互相抑制。这套默认逻辑在 COCO 80 类场景下大体合理,人旁边有个背包,人框和背包框重叠,不应该删掉背包。
但实际项目里不一定。比如你只检测行人和车辆,且目标是做目标计数,那可能更希望不同类别重叠时只保留高置信度那个。这种需求不需要改 NMS 算法,只需要把 cls 判断那行改掉,或者给类别设置分组关系。我在工程里把这个配置设计成可选项,默认走类内抑制。
5.3 和 RKNN 推理流水线配合时的同步问题
后处理提速到 1ms 后,它不再是瓶颈,但不代表可以忽略线程同步。RKNN 推理本身是异步的,NPU 提交任务后 CPU 可以做后处理。理想是流水线:先用 RKNN 提交第 N 帧推理,同时 CPU 处理第 N-1 帧的后处理。但如果你共用一份输出 buffer,就会互相踩。
我的做法是双缓冲:RKNN 推理写到 buffer A,后处理读 buffer A,等推理提交下一帧之前做一次内存拷贝或者等待后处理完成。因为后处理只有 1ms,拷贝开销完全可以接受。这里最容易踩的坑是,有些人看到 RKNN 提供了 zero copy 就直接裸指针共用,结果偶发地出现隔几帧跳框。多线程没加锁时,这种 bug 非常难查。
5.4 低分框去留和回归验证
部署级代码和一次性脚本最大的区别是,你可能连续跑数小时。置信度阈值设得过于激进,时而在阳光变化时漏检。我在项目里额外保留了一组低分候选路径,关键帧触发时会把 0.1 到 0.25 这个区间的框也输出作为 debug 信息,方便查漏检根因。
另一个建议是建立回归集。我会把若干典型帧的 RKNN 输出保存成二进制文件,后处理改造每走一步,就跑一遍并和 Python 参考结果逐框比对,比较内容包括坐标差、分数差、最终保留框数量。这个工作花不了多少时间,但能防止你把 NMS 逻辑调错而不自知。
6. 想复现这 359 倍加速的人,我会先劝你冷静做这四件事
很多朋友看到 359 倍加速会很兴奋,问我是不是有什么黑魔法。真没有。我在这个 RK3588 项目里收获最大的,其实是“先度量、再优化”的顺序。如果你想在自己的板子上复现,我建议按这个顺序来。
6.1 不要一上来就写 SIMD,先打时间戳
先把完整后处理函数包起来打点。如果它只要 5ms,那根本没必要搞高难度的 NEON,直接用普通 C++ 就行。如果它要 30ms 以上,再去分析耗时的分布。很多时候真正慢的是解码循环里反复调用 powf、sqrtf、重复解析类别分数,而不是 NMS 双循环本身。
6.2 把阈值参数拆出来,而不是写死在 NMS 里
置信度阈值、IoU 阈值、topK 数量、是否按类抑制,这四件事应该做成结构体的配置项。我在调试不同场景时频繁调整这几个参数,如果它们是全局变量或写死常量,每次改都要重新编译,很浪费时间。现在板子上跑,调整参数都是通过配置文件热加载。
6.3 用固定数组消除反复分配
后处理里最容易被忽略的开销是 std::vector 的扩容,以及频繁 push_back 导致的内存分配。候选数量小的时候看起来没事,但视频流长跑以后会产生大量内存碎片。我的最终版本里,DecodedBox 列表是作为成员变量预分配好的,每次处理前重置 counter,而不是重新 new 一个 vector。对 100 个候选,固定数组 512 容量就非常充裕。
6.4 回归比对跑通过,再做线程并行
RK3588 有 4 个大核,完全可以把不同类别的 NMS 拆开放到多个线程。但多线程带来的收益,在后处理只有 1ms 的场景里并不明显,反而增加了调试难度。我是先跑通单线程,确认结果稳定以后,再根据项目需要决定要不要开多核。如果你一上来就 OpenMP 加锁,最后很可能是性能没提升多少,还引入一堆偶发 bug。
按现在的部署实测,把后处理压进 1ms 之后,整条链路的延迟已经基本被 NPU 推理和图像采集锁死。这也让我意识到一个问题:在板端做检测部署,真正决定体验上限的往往不是 NPU 跑得多快,而是你把 CPU 侧那些不起眼的逻辑收拾得有多干净。后面我如果继续做这类平台的部署优化,大概率会从视频解码和多路并发入手,毕竟那边还有不少可以榨的性能空间。