在 RK3588 上部署 YOLOv5s 这个系列写到第五篇,这次要解决的是 C++ 侧 NMS 的性能问题。前四篇我依次完成了环境搭建、rknn-toolkit2 模型导出、RKNN 推理接口封装、以及输出张量的解码,跟着做下来的读者应该都会遇到一个很现实的现象:模型前向推理确实够快,但画面出来后框的刷新速度跟不上。尤其是把输出锚点数摊开看,一次 640x640 的输入会产生 25200 个候选位置,后处理里一个不讲究的 NMS 在 RK3588 的 CPU 上能跑出一百多毫秒,比 NPU 推理时间还长。
这篇记录的是我把 NMS 从纯 Python 重写为 C++,并把单帧后处理耗时从 112.3 毫秒压到 0.312 毫秒的完整链路。359 倍不是某一个操作带来的,而是数据结构、排序策略、短路计算和 SIMD 指令一层层抠出来的。如果你也需要在 RK3588 这类 arm64 设备上做实时检测,这篇可以直接当一份优化笔记抄。
1. NMS 为什么会在 RK3588 上成为“帧率刺客”
1.1 从 25200 个锚点说起
YOLOv5s 输入 640x640 时,三个检测头的输出特征图分别是 80x80、40x40、20x20。每个特征图上的每个格子都有 3 个 anchor,所以模型推理之后一共会产生:
(80 * 80 + 40 * 40 + 20 * 20) * 3 = 25200也就是 25200 组预测值。RKNN 输出给我的张量形状是 [1, 25200, 85],其中前 5 个数是 cx、cy、w、h、objectness,后面 80 个数是 COCO 类别的条件概率。理论上,每一组都对应一个“潜在的目标框”。
但事实上绝大多数检测位置都是背景,objectness 乘上类别概率之后分数非常低。只有很少一部分超过置信度阈值,能进入 NMS 阶段。以我常用的一张测试图为例,conf_thres=0.25 过滤之后剩下大概 200 到 300 个框,分布在不同类别里。
那为什么不直接对这几百个框做循环判断?问题在于很多人第一次写 NMS 时,是带着“所有框先全局排序,然后从最高分往下依次删”的思路去写的。一旦候选框数量是几百个,两两之间要计算 IoU 的次数就是 O(n^2)。也就是说,最终参与后处理的只有 1% 的候选框,但算法在前期要为这 1% 付出大量无效判断。
1.2 一份最朴素的 Python NMS 为什么能跑出 112ms
我最初为了快速验证精度,直接在 RK3588 上跑过一个纯 Python 版本。代码思路和大家见到的教程差不多:
def iou(a, b): x1 = max(a[0], b[0]) y1 = max(a[1], b[1]) x2 = min(a[2], b[2]) y2 = min(a[3], b[3]) inter = max(0.0, x2 - x1) * max(0.0, y2 - y1) union = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter return inter / union def nms_python(dets, iou_thres): dets = sorted(dets, key=lambda x: x[4], reverse=True) keep = [] while dets: best = dets.pop(0) keep.append(best) dets = [d for d in dets if iou(best, d) <= iou_thres] return keep这个版本在 RK3588 的一个 A76 大核上跑,单帧要 112.3 毫秒。速度慢不是 IoU 公式写错了,而是 CPython 的每次列表 pop、每次 lambda 排序、每次函数调用都在做大量解释和对象分配。两个 float 相乘,在 C++ 里就是一条vmul.f32指令,在 Python 里却要经过 int/float 对象拆包、属性访问、函数调用返回,三层开销叠起来完全是两个世界。
这个 112ms 并不是极端情况。如果候选框数量冲到 500 个左右,纯 Python 版本甚至会超过 150ms,因为内层列表推导[d for d in dets if ...]每删一次框都会重新生成一个列表,内存分配和旧列表回收带来的开销比想象中的还夸张。
1.3 RK3588 的 CPU 结构和优化空间
RK3588 是 4 颗 Cortex-A76 + 4 颗 Cortex-A55 的 big.LITTLE 设计。NPU 负责模型卷积,后处理几乎都会落到 A76 大核上。A76 的核心频率最高约 2.2GHz,寄存器是 128 位宽的 NEON SIMD,支持常规的vmaxq_f32、vminq_f32、vld1q_f32这类批量数据操作。
这意味着两件事:第一,只要用 C++ 重写,不再受 Python 解释器束缚,性能会有数量级提升;第二,如果把内存布局和计算逻辑再整理一遍,让数据尽量连续存放、减少分支跳转,还可以利用 NEON 一次性处理多个候选框的坐标比较,把单核性能压榨到极限。
在动手写代码之前,我先明确了一个原则:先正确,再快。很多优化手段会直接影响最终保留框的选择,比如 float 运算顺序不同可能带来微小误差,如果一开始就上黑魔法,出错后很难定位。所以我第一版 C++ 完全按 Python 逻辑翻译,之后再逐步加优化。
2. NMS 的判定逻辑:只知道“算重叠”远远不够
2.1 IoU 公式与边界条件
Non-Maximum Suppression 的核心是计算两个框的交并比。公式不复杂:
IoU = 交集面积 / (框A面积 + 框B面积 - 交集面积)但工程实现里有几个容易出错的细节。两个框如果没有交集,计算出来的inter_w或inter_h会是负数,如果不做 max(0) 处理,后面积会造成负数面积,IoU 就可能变成负值,导致该抑制的框被保留。所以我在 C++ 里写 IoU 时先做了两条快速判断:
inline float IoU(const Box &a, const Box &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 inter_w = xx2 - xx1; float inter_h = yy2 - yy1; if (inter_w <= 0.f || inter_h <= 0.f) return 0.f; float inter = inter_w * inter_h; float uni = (a.x2 - a.x1) * (a.y2 - a.y1) + (b.x2 - b.x1) * (b.y2 - b.y1) - inter; return inter / uni; }另外,某些极端情况下uni可能非常接近 0,比如两个框都是退化框。我在解码阶段就会把w <= 0 || h <= 0的候选直接丢弃,不让它进入 NMS。
2.2 类别感知 NMS 和全局 NMS 的区别
我第一次写 NMS 时用的是全局抑制,也就是不管类别,只要两个框 IoU 超过阈值就删掉低分那个。后来发现同一个位置如果同时站着行人和自行车,类别不同但框高度重合,全局 NMS 会把其中一类误删。标准做法是“类别感知 NMS”:先按 class_id 分组,再在每一个类别内部执行同一套抑制逻辑。
对应到代码层面,排序时不能只按 score 排序,要按(class_id, score)双关键字排序。这样同类别的候选框会被排在相邻区间,后面优化时只需要在区间内部做两两比较,不同类别的框天然不会互相干扰。
2.3 阈值怎么选
置信度阈值和 IoU 阈值会影响速度和召回率。我常用的组合是conf_thres=0.25、iou_thres=0.45。IoU 阈值越高,重叠的框越难被抑制,保留的框越多;置信度阈值越低,进入 NMS 的候选框越多,计算量成平方增长。
如果你做的是小目标检测,比如车辆、行人这类场景,把置信度降到 0.1 会明显提升召回,但候选框可能涨到 1000 个以上。这时候 NMS 的优化就不仅是“够用”,而是决定整条流水线能不能实时跑起来的关键。
3. 从 0 写一个能跑的 C++ NMS:正确性第一版
3.1 结构体与解码出口对接
我定义了一个很朴素的Box结构体:
struct Box { float x1, y1, x2, y2; float score; int cls; float area; };这里直接预存了area,因为 IoU 计算时框面积会被重复使用,每次现算虽然也便宜,但在几万次甚至几十万次循环里累计起来就不是小钱了。
解码函数从 RKNN 拿到的裸 buffer 里逐个锚点读取数据,流程是:
std::vector<Box> decode(float *data, int num_anchors, int num_classes, float conf_thres) { std::vector<Box> boxes; boxes.reserve(1024); for (int i = 0; i < num_anchors; ++i) { float *ptr = data + i * (5 + num_classes); float obj = ptr[4]; int cls = -1; float max_cls = 0.f; for (int c = 0; c < num_classes; ++c) { if (ptr[5 + c] > max_cls) { max_cls = ptr[5 + c]; cls = c; } } float score = obj * max_cls; if (score < conf_thres) continue; float cx = ptr[0]; float cy = ptr[1]; float w = ptr[2]; float h = ptr[3]; if (w <= 0.f || h <= 0.f) continue; boxes.push_back(Box{ std::max(0.f, cx - w * 0.5f), std::max(0.f, cy - h * 0.5f), std::min(640.f, cx + w * 0.5f), std::min(640.f, cy + h * 0.5f), score, cls, w * h }); } return boxes; }这里出现了一个部署中非常常见的坑:RKNN 输出的坐标到底有没有恢复到 640x640 尺度。我在转换模型时选择让输出保留经过 stride 还原后的坐标,所以解码后直接对应输入图像像素位置。如果你的模型输出还是 feature map 上的相对坐标,需要在解码前乘上对应的 stride,否则后面 NMS 的结果会完全错乱。
3.2 排序:按类别和分数一起排
正确性第一版里,我用std::sort对索引排序:
std::vector<int> order(boxes.size()); std::iota(order.begin(), order.end(), 0); std::sort(order.begin(), order.end(), [&](int a, int b) { if (boxes[a].cls != boxes[b].cls) return boxes[a].cls < boxes[b].cls; return boxes[a].score > boxes[b].score; });之所以排索引而不是直接给std::vector<Box>排序,是因为后面 NMS 需要一种“当前保留集合”的标记,直接让对象跟着索引但不是唯一选择;直接排序引用对象会带来大量内存拷贝。把 Box 对象留在原地,只移动 int 索引,能显著减少 cache miss。
3.3 朴素双循环 NMS:先保证结果和 Python 一致
这版我用std::vector<bool>作为抑制标记:
std::vector<int> nms(const std::vector<Box> &boxes, const std::vector<int> &order, float iou_thres) { std::vector<int> keep; keep.reserve(16); std::vector<bool> removed(boxes.size(), false); for (size_t i = 0; i < order.size(); ++i) { int idx = order[i]; if (removed[idx]) continue; keep.push_back(idx); const Box &a = boxes[idx]; for (size_t j = i + 1; j < order.size(); ++j) { int jdx = order[j]; if (removed[jdx]) continue; if (boxes[jdx].cls != a.cls) continue; if (IoU(a, boxes[jdx]) > iou_thres) { removed[jdx] = true; } } } return keep; }这个版本在候选框只有两三百个时已经能做到 1.8 毫秒左右,相对 Python 是几十倍的提升。如果模型跑在低分辨率场景,比如 320x320 输入,候选框会更少,这个版本可能已经够用。但到了 640x640 并伴随低置信度阈值时,内层循环里还要做一次类别判断和一个 IoU 函数调用,依然有优化空间。
4. 让性能飞起来:标记、剪枝、SIMD
4.1 用连续内存的标记数组替代删减容器
Python 列表pop(0)和列表推导虽然方便,但每次删除元素都涉及内存搬移。C++ 的vector::erase也一样,频繁删除中间元素会让 O(n) 变成实际上的 O(n^2) 内存操作。
所以我在所有 C++ 版本里都没有真正删除过 Box,只用removed标记跳过去。这样内存始终是连续的一块,访问顺序也接近线性,配合 CPU 的 cache 预取机制,性能表现会好很多。
4.2 面积预计算和短路 IoU
IoU 计算里,交集区域如果小于等于零,可以直接返回 0,不需要继续计算 union。这个分支看起来是一件小事,但内层循环可能被执行几十万次,每次少两次减法和一次除法,收益非常可观。
另外,因为我们在结构体里预存了area,IoU 函数里的uni计算也不再需要重新读取两端坐标去求面积,直接:
float uni = a.area + b.area - inter; return inter / uni;这些细碎优化叠加之后,朴素版从 1.8ms 降到了约 0.9ms,并没有改变任何算法逻辑。
4.3 按 class_id 分区间,删掉内层类别判断
虽然排序已经让同类框相邻,但朴素版的内层循环里依然有一条if (boxes[jdx].cls != a.cls) continue;。在候选框很少时无所谓,但候选框多时这条分支会伴随大量无意义循环。
更彻底的做法是找出每个类别在order里的起始和结束下标,然后在每个区间内部独立跑 NMS:
struct Segment { int begin; int end; }; std::vector<Segment> segs; int start = 0; for (size_t i = 1; i < order.size(); ++i) { if (boxes[order[i]].cls != boxes[order[i - 1]].cls) { segs.push_back({start, (int)i}); start = (int)i; } } segs.push_back({start, (int)order.size()}); // 每个 segment 内部单独做 np.py 逻辑 for (auto seg : segs) { for (int i = seg.begin; i < seg.end; ++i) { int idx = order[i]; if (removed[idx]) continue; keep.push_back(idx); const Box &a = boxes[idx]; for (int j = i + 1; j < seg.end; ++j) { ... } } }我只展示这个分段思路,没有贴完整的nms重复代码。分段之后,内层循环不再需要判断类别,比较次数也只会落在同一类别的候选框之间。实际操作中,4 个类别的候选框总共 240 个时,分段版本和朴素版本的耗时差距大概在 20% 到 30%。
4.4 NEON 批量 IoU:最后一层 CDN
当候选框数量在几百个时,NMS 真正的热点是内层循环里反复读取每个候选项坐标、求 min/max。A76 的 NEON 寄存器能一次处理 4 个 float,所以可以把“四个候选框与当前框 a 的 IoU”打包成一条指令组。
下面的代码不是完整版本,只展示核心向量化思路。我先假设候选框坐标被平铺成连续数组,例如bx1[j]、by1[j]、bx2[j]、by2[j]各是一段连续内存,这样vld1q_f32能一次性加载 4 个 float:
#include <arm_neon.h> inline float32x4_t IoU4(const Box &a, const float *bx1, const float *by1, const float *bx2, const float *by2, const float *bareas) { float32x4_t v_xx1 = vmaxq_f32(vdupq_n_f32(a.x1), vld1q_f32(bx1)); float32x4_t v_yy1 = vmaxq_f32(vdupq_n_f32(a.y1), vld1q_f32(by1)); float32x4_t v_xx2 = vminq_f32(vdupq_n_f32(a.x2), vld1q_f32(bx2)); float32x4_t v_yy2 = vminq_f32(vdupq_n_f32(a.y2), vld1q_f32(by2)); float32x4_t v_w = vsubq_f32(v_xx2, v_xx1); float32x4_t v_h = vsubq_f32(v_yy2, v_yy1); float32x4_t v_inter = vmulq_f32(v_w, v_h); float32x4_t v_uni = vsubq_f32(vaddq_f32(vdupq_n_f32(a.area), vld1q_f32(bareas)), v_inter); return vmulq_f32(v_inter, vrecpeq_f32(v_uni)); }vrecpeq_f32是 NEON 提供的倒数估计指令,精度有限,对不需要精确到小数点后多位的 NMS 场景是足够的。如果你不信任这个近似,也可以用vrecpsq_f32做一次 Newton-Raphson 迭代提高精度,但速度会略微下降。
我最终采用的版本,其实只在候选框数量较多时启用这段向量化代码,候选框少于 64 个时仍然走标量版本,避免 NEON 带来的对齐和调用开销超过收益。
4.5 编译参数
在 RK3588 上交叉编译时,我用的参数是:
aarch64-linux-gnu-g++ -O3 -std=c++17 -fno-rtti -fno-exceptions \ -march=armv8.2-a+fp16+dotprod -mtune=cortex-a76 \ -pipe nms.cpp -o nms_test-O3让编译器自动向量化,-mtune=cortex-a76让指令调度更贴近 A76 的流水线。需要留意的是不要在本机编译时用-march=native,因为 RK3588 和 x86 开发机的指令集完全不同,编译出的二进制会遇到非法指令。
5. 实测报告:359 倍是怎么测出来的
5.1 测试环境和测试方法
为了确保对比公平,所有版本都跑在 RK3588 开发板上,CPU 固定使用同一颗 A76 大核,通过taskset -c 4绑定核心 4。测试输入是同一张 640x640 图片的 RKNN 推理输出,也就是说 NPU 部分完全相同,只有 NMS 实现不同。
Python 版本使用 Python 3.8.15,C++ 版本使用 GCC 9.4.0。每个版本跑 100 次取平均,避免 cold page 和调度噪声。
5.2 逐版本耗时对比
我记录的数据如下:
| 版本 | 耗时 | 相对 Python 提升 |
|---|---|---|
| Python 朴素 NMS | 112.3 ms | 1x |
| Python 优化版(减少列表重建) | 68.5 ms | 1.6x |
| C++ 朴素版 | 1.82 ms | 62x |
| C++ 分段 + 预计算面积 | 0.83 ms | 135x |
| C++ 分段 + NEON 批量 IoU | 0.312 ms | 359x |
112.3 除以 0.312 约等于 359.9,考虑测量波动,我之前在技术群里汇报时写的 359 倍,实际统计口径是平均帧耗时比例。你如果在自己环境里复现,数字可能会在 350 到 370 之间浮动,但只要结构一致,这个量级的提升是可以稳定复现的。
5.3 为什么没有直接用 cv2.dnn.NMSBoxes
很多人会问:OpenCV 不是已经内置了cv2.dnn.NMSBoxes吗,为什么还要自己写?在我的部署链路里,OpenCV 内置 NMS 当然可以作为替代方案,但它有几个我不喜欢的地方:
- 我需要把 RKNN 输出转成 Python list 再传进去,这个转换本身在 Python 侧就要几十毫秒量级;
- OpenCV 的 NMS 实现是固定的接口,不方便按类别做精细控制,也不方便对坐标范围做裁剪;
- 我想在同一份 C++ 代码里做解码、阈值过滤、NMS 全流程,不希望在中间额外引入一个 Python 层的依赖。
如果你只是快速验证功能,先调用cv2.dnn.NMSBoxes完全没问题,但走到性能指标阶段,自己写一套类感知 NMS 几乎是必然选择。
5.4 结果一致性:优化之后框和原来一样吗
这是一定要验证的。我用 Python 朴素版、C++ 分段版、C++ NEON 版三份代码分别输出 keep 框的索引,对比了 100 张测试图的保留框集合。
结论是:分段 C++ 版本和 Python 基本一致,唯一可能出现差异的地方是浮点数运算顺序。Python 里算inter_w和 C++ 里算inter_w可能会在最后一位有效数字上有区别,尤其当 IoU 恰好非常接近 0.45 阈值时,保留结果可能差一个框。NEON 版的近似倒数指令也会带来类似影响。
应对方式是在阈值比较处不写>=,而是写> iou_thres。这样即使 IoU 有极微小的上下抖动,也能避免“等于阈值时被抑制”的边界问题。实测下来,100 张图的 keep 集合全部一样。
6. 部署到 RK3588 时容易踩的四个坑
6.1 不要试图把 NMS 塞进模型里
有的朋友想省事,希望模型直接输出最终框。这不现实,NPU 适合矩阵计算,不适合这种带分支和删除的迭代逻辑。RKNN 工具链里虽然也提供部分后处理支持,但灵活性非常差,一旦你的输入尺寸、类别数或者阈值变化,就要重新转换模型,调试效率很低。
我最后采用的架构非常清晰:NPU 只负责推理,CPU 端负责解码和 NMS,两者通过 RKNN 的零拷贝接口传递原始张量。这样后处理可以独立优化,也不会被 NPU 的固定输入输出束缚。
6.2 坐标尺度一定要打印出来确认
YOLOv5 的原始输出是网格坐标和归一化后的宽高,需要乘 stride 才能得到输入图像坐标。但 RKNN 导出时如果带上了部分后处理,输出坐标可能是已经在 640x640 尺度的数值。
我一开始没打印第一帧的原始坐标,直接把输出当作网络原封不动的结果去解码,导致画出来的框全跑到左上角。后来在解码前打印几个非零 box 的 cx、cy、w、h 才发现,数值范围已经是 640 尺度。这个坑基本属于“每个接手模型的人都要踩一遍”的类型,建议解码代码里临时加一行printf验证尺度,再继续往后写。
6.3 NMS 函数里的动态分配不要每帧都触发
我第一版把keep、removed、order都定义在nms()函数内部,跑起来也没觉得异常,但用perf看热点时会发现malloc和free占比不小。因为每帧都要为几百个 int 分配内存、释放内存,在实时链路里这个开销本来可以完全避免。
改进做法是给每个线程准备一个复用结构:
struct NmsWorkspace { std::vector<int> order; std::vector<bool> removed; std::vector<int> keep; std::vector<Segment> segs; }; void nmsReuse(WorkSpace &ws, const Boxes &boxes, float iou_thres) { ws.order.clear(); ws.removed.assign(boxes.size(), false); ws.keep.clear(); ... }保留容量之后,reserve 分配只发生在前几帧,后续帧的clear()不会释放内存,性能会稳定在一个更低的水平。
6.4 多线程不是第一优先级
看到 0.312ms 之后,有人会问为什么不用多线程给每个类别分配一个核。我的建议是:先看你的候选框总量。如果过滤后只有一两百个框,多线程切分本身的同步开销可能达到 0.1ms 量级,收益不明显。真正值得多线程化的,不是 NMS 本身,而是图像缩放、预处理、等待 NPU 这些更粗粒度的流程。
我在完整 pipeline 里是把解码和 NMS 放在一个独立的线程里,让它们和下一帧的 NPU 推理重叠执行。也就是说,NPU 在算第 N 帧时,CPU 同时在做第 N-1 帧的后处理。这样即使后处理是 5ms,也不会直接叠加到单帧延迟上。
最终在我的 RK3588 板子上,YOLOv5s 的 NPU 推理耗时约 47.8ms,后处理从 112.3ms 压到 0.312ms 之后,单帧总延迟从 160ms 左右降到 48ms 上下,帧率从 6fps 以下提升到可以稳定跑 20fps。如果再把图像缩放和显示刷新也改成双缓冲线程模式,还能继续往上走。
我个人的实操体会是:NMS 这类算法优化的核心,往往不是那几条 SIMD 指令,而是先想清楚哪些比较是无效的,哪些数据是重复读取的。只要把无效计算剪掉,哪怕不写一行 NEON,也能拿到十倍级别的提升。359 倍的加速听起来很夸张,但它确实是在 RK3588 这样一颗移动处理器上,从 Python 解释器到 C++ 向量化一层层换回来的真实结果。