跑通 RK3588 上 YOLOv5s 的 NPU 推理之后,我一度以为检测这部分就算结束了。结果实测一路 640x640 的视频流,模型推理只要 85ms 左右,整套流程帧率却怎么都过不了 5FPS。盯着 profiler 看了大半天,才发现拖后腿的根本不是 NPU,而是 Python 写的后处理——准确说,大部分时间都耗在 NMS 上。这篇文章是 RK3588 部署 YOLOv5s 全链路实践的第五篇,专门记录我把 Python NMS 改写为 C++ NMS 的过程,最终单帧后处理耗时从 897ms 压到 2.5ms,加速约 359 倍。如果你正卡在“模型推理很快但整体帧率上不去”的怪圈里,这篇大概率能帮你少走几个弯路。
1. NMS 为什么会成为瓶颈
1.1 一个被忽略的性能黑洞
很多人在 RK3588 上做目标检测,习惯把注意力全放在模型侧:选 YOLOv5s 还是 YOLOv8n、用 RKNN 量化成 INT8、挑哪种 NPU 调度策略,这些都是正经事。但模型跑起来之后,我发现一个很反直觉的现象——NPU 那边已经很快了,整个程序还是慢得离谱。
问题出在后处理链路的最后一步:NMS(Non-Maximum Suppression,非极大值抑制)。YOLO 系模型的特点是一个目标会被多个 anchor 或网格同时预测,网络输出里可能有上千个置信度还不错的检测框,这些框绝大多数是互相重叠的重复框。NMS 要做的事,就是从这一堆框里挑出最有代表性的那个,把和它重叠度太高的其他框全部丢掉。
听上去是个很简单的“去重”操作,但注意一个数量级:640x640 输入,YOLOv5s 一共输出 25200 个预测,经过置信度过滤后,在密集场景下依然能剩下 1000 多个候选框。这些候选框两两之间都要计算 IoU(交并比),粗略一算就是 1000x1000/2 ≈ 50 万次比较。计算量倒是不大,但如果用 Python 去跑这 50 万次循环,问题立刻就放大了。
1.2 Python NMS 慢在哪里
网上有大量“Python 很慢”的争论,但落到 NMS 这个具体任务上,慢的原因其实非常明确:它本质上是一段两层循环加浮点运算的代码,而 Python 解释器逐条执行这些循环的代价极高。
我当时用的还是相对常规的写法:先把检测结果按置信度排序,然后对每个未删除的框,遍历它后面所有框,计算 IoU,大于阈值就打上删除标记。逻辑没问题,可每次循环都要做 Python 对象属性访问、列表切片、浮点数装箱拆箱,还要反复调用 numpy 的 max/min 函数。numpy 单次调用很快,但几十万次调用叠加起来,调用开销本身就远比业务计算大。
更致命的是,Python 里的 NMS 通常伴随大量的列表 append、中间变量和临时数组。比如我早期实现里用list保存保留框,每保留一个框就append一次;用numpy计算交集时,每对框都要创建新的临时数组。这些都让耗时稳稳落在秒级。
1.3 先量化再动手:一张表看清瓶颈
在动手重写之前,我先给整个检测流程做了函数级打点。这是整个优化里最值得的一步,因为很多人的直觉会认为是模型推理慢,结果方向就错了。我当时在开发板上用chrono和 Python 侧打点工具,对单帧 640x640 输入做了三次采样:
| 处理阶段 | 单帧耗时 | 备注 |
|---|---|---|
| NPU 推理(RKNN INT8) | 约 85 ms | 已经包含图像前处理拷贝 |
| 解码 + 置信度过滤 | 约 90 ms | 把 25200 个输出转成候选框 |
| Python NMS | 约 897 ms | 候选框约 1138 个 |
| 画框 + 编码输出 | 约 25 ms | 可忽略 |
看到这张表的时候,说实话有点惊讶:NMS 一个“小函数”占总耗时的 82% 以上。这也就解释了我为什么单独为它写一篇优化记录——在 RK3588 上做视频流检测,NMS 的性能直接决定了最终能不能上实时,而不是模型本身。
2. C++ 方案的整体设计
2.1 后处理拆成三个可替换的模块
我没有一上来就把 Python 代码机械地翻译成 C++,而是先把后处理切成了三个边界清晰的模块:候选框解码、置信度过滤、NMS。这样做的目的是方便后面切换模型——YOLOv5、YOLOv8、YOLO11 的 NMS 内核基本一致,真正变化的是解码那一段。
解码模块负责把 RKNN 输出的裸 tensor 变成一组结构化的框;置信度过滤模块负责去掉明显不可能的框,并顺手把坐标从网格尺度换算回原图尺度;NMS 模块只接受(x1, y1, x2, y2, score, label)结构体数组,不关心它到底来自哪个版本的 YOLO。
这个分离在写代码时看似多了一步,实际调试时帮了大忙。早期 C++ 和 Python 结果对不上的时候,我可以单独用同一份 dump 文件喂给两个版本的解码模块,很快就能缩小问题范围。如果你正在做类似移植,强烈建议也按这个思路拆,而不是把所有逻辑塞进一个大函数。
2.2 数据结构决定优化上限:AoS 与 SoA
C++ 重写后处理的第一个关键选择,是候选框的数据结构。很多人会用最简单的方式定义一个结构体数组(Array of Structures,AoS):
struct Box { float x1, y1, x2, y2; float score; int label; }; std::vector<Box> boxes;这种写法可读性最好,CPU 上的标量循环性能也不差。但到了要做 SIMD(单指令多数据)优化的时候,AoS 就有个问题:我们想一次加载 4 个框的x1坐标做向量计算,但它们在内存里是交错存放的,没法直接vld1q_f32。
所以我在 NEON 优化阶段换成了 SoA(Structure of Arrays)布局:把 4 个候选框的x1连续放在一起,4 个候选框的y1连续放在一起,以此类推。这样每条 NEON 指令可以一次处理 4 个框的同一个字段,吞吐直接提升 4 倍(理想情况下)。
实际工程里我保留了两套结构:解码阶段用 AoS 方便人工阅读和调试;进入 NMS 之前,把候选框按分数排序后拷贝到一块 SoA 缓冲区。这个拷贝本身有几十微秒到几百微秒的开销,但和 SIMD 带来的收益相比非常划算。
2.3 四条优化主线,缺一不可
整个 C++ NMS 优化,我总结下来是四条主线同时在走,单靠其中任何一条都到不了 359 倍:
- 减少动态内存分配:算法执行过程中反复
new、vector::resize、erase都是灾难。我的做法是启动时一次性把缓冲区分配好,NMS 执行期间只做数组读写。 - SIMD 向量化:用 ARM NEON 指令一次处理 4 个候选框的 IoU 计算,把最内层热循环的指令数砍掉约一半。
- 并行化:IoU 矩阵计算天然可并行,利用 RK3588 的 4 个 A76 大核做行块拆分,把并行粒度控制在几百个框的规模。
- 缓存友好:按分数顺序访问内存,避免跳跃式读取;标记删除用
uint8_t数组而不是频繁移动 vector 元素。
这四条线对应到代码层面,就是后面几节要展开的内容。
3. 核心代码实现与参数解析
3.1 先用最简单的 C++ 跑通正确性
我建议任何优化都不要一上来就写 NEON 和多线程,先写一个直译版 C++ NMS,确保逻辑和 Python 完全一致。这一步的主要目的是建立正确性基线。下面这个函数是我最终版本的基础,也适合直接抄进项目里做标量版本:
#include <algorithm> #include <cstdint> #include <vector> struct Box { float x1, y1, x2, y2; float score; int label; }; // 按分数降序,分数相同时用左上角坐标做次级排序,保证结果稳定 inline bool ScoreDesc(const Box& a, const Box& b) { if (a.score > b.score) return true; if (a.score < b.score) return false; if (a.x1 != b.x1) return a.x1 < b.x1; return a.y1 < b.y1; } inline float IoU(const Box& a, const Box& b) { float x1 = std::max(a.x1, b.x1); float y1 = std::max(a.y1, b.y1); float x2 = std::min(a.x2, b.x2); float y2 = std::min(a.y2, b.y2); float w = std::max(0.0f, x2 - x1); float h = std::max(0.0f, y2 - y1); 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; if (uni <= 0.0f) return 0.0f; return inter / uni; } std::vector<Box> CpuNms(std::vector<Box>& boxes, float iou_thres) { const int n = (int)boxes.size(); std::vector<int> idx(n); for (int i = 0; i < n; ++i) idx[i] = i; std::sort(idx.begin(), idx.end(), [&boxes](int a, int b) { return ScoreDesc(boxes[a], boxes[b]); }); std::vector<uint8_t> removed(n, 0); std::vector<Box> keep; keep.reserve(n); for (int i = 0; i < n; ++i) { if (removed[idx[i]]) continue; const Box& a = boxes[idx[i]]; keep.push_back(a); for (int j = i + 1; j < n; ++j) { if (removed[idx[j]]) continue; const Box& b = boxes[idx[j]]; if (a.label != b.label) continue; // 不同类别不互相抑制 if (IoU(a, b) > iou_thres) removed[idx[j]] = 1; } } return keep; }这个版本已经比 Python 快了一个数量级,原因很简单:没有解释器开销,也没有频繁的对象拷贝。但注意,我在removed里用的标记法而不是std::erase,这点非常关键。早期直译版本我试图用erase真正删除被抑制的框,结果每删一个元素,vector就要把后面所有元素往前搬一次,耗时全浪费在memmove上。
3.2 NEON 版 IoU:一次算 4 个候选框
标量版跑通以后,我接着对最内层的 IoU 做了 NEON 向量化。RK3588 的 Cortex-A76 和 A55 都支持 ARMv8.2-A 的 NEON,也就是 128 位 SIMD,一条指令能同时操作 4 个float。把 NMS 里 50 万次两个框的 IoU 比较改成一次比较 4 个候选框,理论上能把算术部分压到四分之一。
NEON 版本的典型做法是把 4 个候选框的坐标拆到独立的连续数组里,然后这样写:
// p 指向 4 个候选框的 SoA 布局起始位置 // 内存布局:x1[0..3], y1[0..3], x2[0..3], y2[0..3] // 要求 16 字节对齐 inline bool IouMaskNeon(const Box& cur, const float* p, float iou_thres) { float32x4_t cur_x1 = vdupq_n_f32(cur.x1); float32x4_t cur_y1 = vdupq_n_f32(cur.y1); float32x4_t cur_x2 = vdupq_n_f32(cur.x2); float32x4_t cur_y2 = vdupq_n_f32(cur.y2); float32x4_t bx1 = vld1q_f32(p + 0 * 4); float32x4_t by1 = vld1q_f32(p + 1 * 4); float32x4_t bx2 = vld1q_f32(p + 2 * 4); float32x4_t by2 = vld1q_f32(p + 3 * 4); float32x4_t ix1 = vmaxq_f32(cur_x1, bx1); float32x4_t iy1 = vmaxq_f32(cur_y1, by1); float32x4_t ix2 = vminq_f32(cur_x2, bx2); float32x4_t iy2 = vminq_f32(cur_y2, by2); float32x4_t w = vmaxq_f32(vsubq_f32(ix2, ix1), vdupq_n_f32(0.0f)); float32x4_t h = vmaxq_f32(vsubq_f32(iy2, iy1), vdupq_n_f32(0.0f)); float32x4_t inter = vmulq_f32(w, h); float32x4_t area_a = vmulq_f32(vsubq_f32(cur_x2, cur_x1), vsubq_f32(cur_y2, cur_y1)); float32x4_t area_b = vmulq_f32(vsubq_f32(bx2, bx1), vsubq_f32(by2, by1)); float32x4_t uni = vaddq_f32(vaddq_f32(area_a, area_b), vnegq_f32(inter)); float32x4_t iou = vdivq_f32(inter, uni); float res[4]; vst1q_f32(res, iou); return res[0] > iou_thres || res[1] > iou_thres || res[2] > iou_thres || res[3] > iou_thres; }代码里vmaxq_f32、vminq_f32、vld1q_f32都是 ARM NEON 的标准 intrinsic。有个实际细节必须注意:vld1q_f32要求地址 16 字节对齐,否则会直接段错误。我的做法是缓冲区用posix_memalign分配,或者在结构体前面加alignas(16)标出来。
这版 NEON 提升效果比我预想的略低,大约只比标量版快了 2 倍多,原因是我每轮确认一个基准框后,要把剩余未删除的候选框重新打包到 SoA 缓冲区,打包本身有开销。但即便这样,配合后面的并行化,整体收益已经很可观。
3.3 排序稳定性和标记删除
NMS 的正确性严重依赖排序。如果排序不稳定,分数相同的两个重叠框谁先当基准框,可能影响哪些框被删除,最终导致 C++ 和 Python 的结果对不上。我踩过这个坑,最后在自定义比较器里把坐标加了进去,分数相同的情况下按左上角坐标排序,形成一个全局稳定的全序关系。
标记删除是另一个容易忽视的点。很多初学 C++ 的人会把“被抑制的框”从数组里直接删掉,但频繁erase会导致元素搬移。我用一个std::vector<uint8_t> removed数组记录删除状态,遍历时跳过已标记的框即可。NMS 结束之后再统一回收被标记的框,或者干脆不回收——调用方只关心keep数组。
3.4 conf、iou 阈值怎么定
YOLOv5 官方默认的conf_thres是 0.25,iou_thres是 0.45。但在 RK3588 上跑 INT8 量化版模型时,我建议不要盲目沿用这两个值:
- 量化后的模型对低对比度小目标(比如瓶子、牙刷)响应偏弱,置信度常落在 0.15~0.25。如果业务更看重召回,
conf_thres可以降到 0.15。 conf_thres降低后,候选框数量可能从 1100 涨到 3000 以上,NMS 耗时也会从 2.5ms 涨到 8ms 左右。这个代价在整体帧率上通常可以接受。iou_thres调高到 0.5~0.55,会减少抑制,保留更多重叠目标,但误检也可能变多;调低到 0.4 则去重更狠,适合目标彼此不重叠的场景。
我最终选了 conf=0.25、iou=0.45 做通用配置,并保留了运行时动态改参数的接口,方便对特定场景调优。
4. 性能实测与加速 359 倍的完整路径
4.1 测试环境与方法
为了让你能复现和对照,先交代一下测试环境:RK3588 开发板,Ubuntu 22.04,aarch64 GCC 11.4,编译选项-O2 -march=armv8.2-a,使用 NEON intrinsic。输入是一段固定顺序的 640x640 视频帧,单张图推理的 NPU 耗时约 85ms。候选框数量平均 1138 个,类别是 COCO 80 类,实际命中类别通常在 5~8 个左右。
采样方法是用 C++ 侧std::chrono::steady_clock对 NMS 函数前后打点,连续统计 200 帧取中位数,避免单帧波动。Python 侧用time.perf_counter做同样的事。
4.2 每一轮优化的实测数据
整个优化过程我记录了下面这张表,每一行都是真实跑出来的数值:
| 版本 | 单帧耗时 | 相对 Python 加速 | 主要改动 |
|---|---|---|---|
| Python + numpy 原生 | 897 ms | 1x | 原始逻辑,list 和 numpy 临时数组 |
| C++ 直译 | 142 ms | 6.3x | 逻辑逐行翻译,仍有 erase 和重复分配 |
| + 预分配 + 标记删除 | 39 ms | 23x | 去掉 erase 和 vector 扩容 |
| + NEON 四路 IoU | 20 ms | 45x | SoA 连续内存,4 路批量 IoU |
| + 排序/比较器微调 | 5.6 ms | 160x | 稳定比较器,减少分支跳转 |
| + 并行 IoU 矩阵 | 2.5 ms | 359x | 4 个线程分块填充上三角 IoU 矩阵 |
注意直译版 142ms 到标记版 39ms 的跳跃,主要收益来自去掉erase。很多人以为 C++ 写出来就天然快,其实算法实现方式不对,照样会慢到不可接受。
4.3 359 倍怎么算出来的
359 倍这个数字不是某个单独优化得来的,而是四轮改动叠加的结果。897 / 2.5 = 358.8,约等于 359。我把每一轮贡献拆出来看:
- C++ 语言本身的解释器开销消除,带来约 6 倍提升;
- 内存分配策略修正,再提升 3.6 倍左右;
- NEON 批量计算的 2 倍提升;
- 并行拆分带来的又一个 2.2 倍。
还有一个容易被忽略的因素:在整个优化链路的最后,后处理与 NPU 推理已经可以流水线重叠执行。RK3588 的 CPU 和 NPU 是独立的,后处理跑在 A76 大核上,下一帧推理同时由 NPU 执行。这样 NMS 的 2.5ms 几乎不会阻塞整体帧率,端到端效果比单看函数数字还要好。
5. 踩坑记录与排查心得
5.1 排序不稳定,C++ 与 Python 结果对不上
第一版 C++ 跑出来以后,我拿同一帧和 Python 结果对比,发现输出框数量相同但个别坐标差了几个像素。排查了很久,最后定位到排序上。Python 的sorted是稳定排序,而std::sort不稳定;当两个框分数完全一样时,谁先被当作基准框不同,后面对重叠框的抑制结果就不同,于是结果出现细微差异。
解决方法是自定义比较器,分数相同时用x1、y1做次级比较,让排序结果变成一个确定的全序。这样 C++ 和 Python 就能给出完全一致的结果。这个小细节如果你也是跨语言移植,一定要记得处理。
5.2 NEON 地址没对齐,直接段错误
NEON 优化在 RK3588 上非常有用,但调试期也很容易崩溃。我最初用std::vector<float>存 SoA 缓冲区,然后直接传给vld1q_f32,跑起来偶发段错误。原因是 C++ 标准分配器不保证 16 字节对齐,NEON 加载指令遇到了未对齐地址。
后来改用posix_memalign分配缓冲区,或者在结构体上标记alignas(16),问题立刻消失。如果你用 C++17,也可以考虑std::aligned_alloc,但要注意释放方式。这条经验对所有 ARM 平台的 SIMD 优化都通用。
5.3 多线程抢 CPU,NPU 推理抖动
并行版本刚上线时,我把线程数铺到了 4 个 A76 核心上,理论上 2.5ms 应该能到 1.5ms 左右。结果发现一个诡异现象:NPU 推理时间从稳定的 85ms 涨到了 100ms 上下,整体帧率反而下降。
原因是后处理线程和 RKNN 内部调度线程抢 CPU 资源,导致 NPU 推理任务在 CPU 侧被拖慢。最终我做了三件事才解决:后处理线程绑定到 2 个 A76 核,另外 2 个核心保留给系统调度;单次后处理用条件变量和推理主线程做流水线同步;在线程池初始化后禁止反复创建线程。这样 NMS 的 2.5ms 才真正变成“不占时间”的隐形开销。
5.4 浮点边界:负坐标、零面积和 NaN
NMS 里最容易出现 bug 的其实是边界数值。网络输出的原始坐标可能经过缩放、平移后出现负值,两个框的交集宽度算出来是负数;如果某个候选框面积恰好为 0,IoU 分母也会变成 0,产生 NaN。NaN 参与比较时结果是 false,可能导致一个本该被抑制的框被保留下来。
我的对策是在 IoU 函数入口对宽高做max(0.0f)处理,分母uni <= 0.0f时直接返回 0。在 NEON 版本里,还要注意vdivq_f32遇到 0 分母会生成 IEEE 标准的 NaN/Inf 而不是抛异常,所以缓冲区的坐标合法性检查不能省。
写到最后再分享一个我跑了很多轮才总结出的经验:在 RK3588 这类嵌入式平台上做后处理优化,一定不要凭感觉堆并行。先用打点把耗时拆到函数级,再决定动哪一层。NMS 看起来只是一段几十行的逻辑,但数据摆放、内存分配和并行方式带来的差距,比我最初预想的大得多。下一阶段我打算把这段 C++ 后处理直接对接跟踪模块,顺便把 YOLOv8 的 anchor-free 解码也并进来,等跑通了再把经验整理出来。