news 2026/10/31 1:10:44

RK3588 YOLOv5s:C++重写NMS,后处理加速359倍

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RK3588 YOLOv5s:C++重写NMS,后处理加速359倍

跑通 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 ms1x原始逻辑,list 和 numpy 临时数组
C++ 直译142 ms6.3x逻辑逐行翻译,仍有 erase 和重复分配
+ 预分配 + 标记删除39 ms23x去掉 erase 和 vector 扩容
+ NEON 四路 IoU20 ms45xSoA 连续内存,4 路批量 IoU
+ 排序/比较器微调5.6 ms160x稳定比较器,减少分支跳转
+ 并行 IoU 矩阵2.5 ms359x4 个线程分块填充上三角 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 解码也并进来,等跑通了再把经验整理出来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 1:10:44

一键开关机芯片选型指南:四大维度避坑与实战验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:09:29

Linux thermal framework:功耗协同调控的内核中枢

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:09:24

无人机集群通信架构选型与实战:从星型到自组网的关键技术

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:09:19

机器人语音交互开发套件:多机型适配与开放接口的二次开发实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:09:01

一键开关机芯片怎么选?四个关键维度避免低功耗产品翻车

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/31 1:08:42

Delphi 13升级必看:FastReport VCL v2025.2安装与报表实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华