1. 后处理比推理还慢,这账真的不能忍
我把 YOLOv5s 的 RKNN 模型在 RK3588 上跑通的那天晚上,说实话挺兴奋的。NPU 走 int8 推理只有 30ms 出头,这个速度对一台边缘检测设备来说已经相当能打了。但等我把整条链路接起来,Python 后端后处理的耗时直接给我浇了盆冷水:单单解码加 NMS,实测平均要吃 69ms 左右,比 NPU 推理还翻了一倍。当时整个系统跑起来,帧率上不去,CPU 占用却居高不下,最气人的是 NPU 明明闲着,活全堵在“没人看”的后处理代码上。
所以我干了一件在不少项目里看起来有点“小题大做”的事:把解码和 NMS 全部用 C++ 重写,做成一个独立的后处理模块。做完之后效果非常直观,同样一块 RK3588 板卡、同一个模型、同一批测试图,Python 后处理整段平均耗时从 69.1ms 降到了 0.19ms,算下来加速 359 倍。我并没有刻意追一个夸张数字,但这个结果让我意识到,边缘端部署项目的瓶颈往往不只在算力,更多时候在软件栈里那些被默认“没多大事”的常规操作上。
这篇文章就把这 359 倍是怎么挤出来的写清楚。如果你正在 RK3588、RK3568 这类瑞芯微平台上做 YOLO 系列模型部署,或者准备把 Python 原型改成 C++ 生产代码,那这篇内容应该能派上用场。我会从 NMS 为什么慢讲起,把 C++ 实现思路、性能分析方法、集成到流水线的细节都摊开说,最后列一下我在这个过程中踩过的坑。
2. NMS 为啥这么慢:原理清楚,Python 却有三重开销
2.1 NMS 原理五分钟后回顾
NMS,Non-Maximum Suppression,非极大值抑制。目标检测模型在同一个位置经常打出好几个重叠的候选框,比如一辆车被三个框同时框住,一个偏左、一个偏右、一个稍大。NMS 要做的就是把重叠框里得分最高的留下,把和它交并比超过阈值的框干掉,剩下的候选再重复这个过程。
标准实现分三步:先把同类别检测框按置信度从高到低排序;取最高分框“留底”;再算其余框和这个留底框的 IoU,超出阈值就抑制,没超的留到下一轮。重复执行第二步和第三步,直到处理完所有框。
逻辑虽然简单,但它本质上是个双层循环。最坏情况下有 n 个框做 NMS,复杂度接近 O(n²)。模型输出几百个候选框时还好说,可 YOLOv5s 在 640x640 输入下,三个检测头总共会给出 8400 个锚框位置的原始输出。即便经过置信度过滤后只剩几十到几百个框,在 Python 里跑双层循环加频繁的数组运算,照样能把 CPU 吃干抹净。
2.2 Python 实现里隐藏的三重开销
我在做实验原型时用了很典型的 Python 写法:先把所有框按类别分组,再用 NumPy 算 IoU 矩阵,最后循环做抑制。逻辑上没有毛病,性能却踩了三重坑。
第一重坑是解释器循环本身。Python 的 for 循环即便配合 NumPy 数组,每轮迭代也要经历字节码解释、对象引用计数增减、方法调用分派等一堆零碎操作。几个框的时候无所谓,几千个候选框加上多类别遍历,时间全耗在“循环怎么跑”而不是“循环在算什么”上了。
第二重坑是 NumPy 的隐式拷贝。我用广播特性一次性算出所有候选框的 IoU 矩阵,听着很“向量化”,但中间结果会产生一堆临时数组。x2 减 x1 生成一个数组,面积计算又是一个数组,最终 IoU 矩阵又是一个巨大的 (N, N) 块。每个临时数组都在申请内存、写缓存、再释放。NMS 一次推理调用一次,每次都是这个流程,内存分配器都被折腾得够呛。更麻烦的是,候选框数量不是固定的,NumPy 每次按实际框数生成不同尺寸的中间数组,内存申请和释放的规律被打乱,缓存命中率就更差了。
第三重坑是 GIL。如果我想让后处理线程和推理线程并行,全局解释器锁会强制同一时间只有一个 Python 线程真正执行。我第一版流水线试图一个线程跑 NPU 推理,另一个线程做后处理,结果发现 CPU 利用率始终上不了双核,排查了半天才意识到是 GIL 在暗中排队。这也是我下定决心把后处理迁到 C++ 的另一半原因:不只为快,也要能把 RK3588 的多核真正用起来。
2.3 RKNN 在 RK3588 上的输出形态,决定你怎么写解码
很多人一上来就写 NMS,结果发现数据格式对不上,白忙半天。在 RK3588 上跑 RKNN 导出的 YOLOv5s 模型,输出格式和 PyTorch 原版那个 (1, 25200, 85) 并不完全一样。RKNN 工具链转换时,通常会把三个检测头分别输出,每组张量形状可能是 (1, 3, H, W, 85),85 对应 cx, cy, w, h, objectness,再加 80 个类别得分;也可能按你配置的输出格式导出成三维张量。输出排列还会因量化方式、通道顺序变化而不同。
这意味着做 NMS 之前,必须先做一个“解码”:把每个锚框位置上的 cx, cy, w, h 换算成真实坐标 x1, y1, x2, y2,把 objectness 和类别得分合并成最终置信度,再按置信度阈值做初筛。这个解码在 Python 里用 NumPy 一步一步做,耗时其实比 NMS 还高。所以我重写时没有只换 NMS 内核,而是把从 RKNN 输出到最终检测框的那一整段后处理逻辑全部搬进了 C++。NMS 加速是一部分,解码加速是另一部分,两者加在一起才是“后处理 359 倍”这个数字的完整含义。
3. C++ NMS 设计与实现细节
3.1 数据结构:用紧凑的“框”,别用高维张量
第一件事是把数据形态从多维张量“拍扁”成一个连续结构。我定义了一个结构体:
struct Detection { float x1, y1, x2, y2; float area; float score; uint16_t cls; };所有候选框放在一个 std::vector 里。这里有个关键选择:坐标用 float,类别用 uint16_t,整个结构体 24 字节。为什么不把坐标和类别分开存成多个数组?因为 NMS 会反复随机访问每个框的坐标和得分,连续内存布局能让 CPU 缓存命中率高出很多。一条 64 字节缓存行能装下两个多检测框,访问速度完全不是一个量级。
我第一版代码为了“长得像 NumPy”,维护了五个平行数组:x1、y1、x2、y2、score、cls。结果 NMS 内层循环要跨多个数组跳来跳去,cache miss 严重。改回结构体数组 AoS 之后,同一次测试直接快了 30%。如果你打算用 NEON 做 SIMD 向量化,那可以再考虑 SoA 布局,但纯 NMS 场景 AoS 更省心。
还有一个隐藏开销:std::vector 在反复 push_back 时会触发多次内存扩容和拷贝。每帧候选框数量都不同,你总不能依赖每次推理都重新分配内存。我的做法是做一个“内存池式”的 vector:在 pipeline 初始化时 reserve 到足够容量,比如 4096 个检测框,之后每帧通过 clear() 复用同一块内存,不再触发堆分配。这个改动对延迟的影响在第二版和第三版之间体现得非常明显。
3.2 排序策略:不要盲目 std::sort 所有框
标准 NMS 第一步是对同类别框按置信度从高到低排序。很多 C++ 新手会直接对整个 vector 做 std::sort。但 YOLOv5 的输出量很大,8400 个锚框位置经过置信度过滤后也可能留下上千个框。全排序复杂度是 O(n log n),大量比较其实没有必要,因为 NMS 最终只关心最高分的一批框。
我的做法分两层。第一层在解码阶段就按置信度阈值初筛,第二层对每个类别调用 topK 版本的选择算法:选出得分最高的 K 个框,比如 300 个,再对这 K 个框排序。用 std::partial_sort 就能做到。对于 NMS 来说,靠后的低分候选框几乎不可能反转全局结果,截断掉并不影响最终输出质量。
实测下来,全排序在候选框 1000 个时耗时约 1.2ms;改成 topK=300 的局部排序后,耗时降到 0.11ms,检测指标基本没变化。这 10 倍差距就是排序策略省出来的。还有一个细节:如果你的类别不止一个,务必按类别分别做 topK,不要全局只取 300 个框,否则小目标类别很容易在剪枝阶段被整个丢掉。
3.3 循环内核:IoU 函数内联,少写没必要的分支
NMS 最内层的操作是 IoU 计算,这个函数会被反复调用几十万次。如果每次调用都走普通函数,编译器没法把循环间的寄存器分配和内存访问优化到极致。我建议在头文件里把它定义成 static inline:
static inline float box_iou(const Detection& a, const Detection& b) { float inter_w = (a.x2 < b.x2 ? a.x2 : b.x2) - (a.x1 > b.x1 ? a.x1 : b.x1); float inter_h = (a.y2 < b.y2 ? a.y2 : b.y2) - (a.y1 > b.y1 ? a.y1 : b.y1); if (inter_w <= 0.f || inter_h <= 0.f) return 0.f; float inter = inter_w * inter_h; float area_a = a.area; float area_b = b.area; return inter / (area_a + area_b - inter); }这里有个容易被忽略的细节:std::min 和 std::max 语义虽然一样,但某些编译器配置下会引入额外函数调用。直接写在条件表达式里更利于内联。另一个优化是把 area 预计算好放进结构体字段,这样 IoU 循环中省去每次重复算面积。我加上 area 字段后,整体 NMS 耗时又降了约 15%。
再聊一下分支。循环里“重叠宽度小于等于 0 直接返回 0”这种分支,在大量非重叠候选对的情况下命中率很高,值得保留。但如果你已经提前做了置信度过滤,候选框之间普遍重叠率偏高,分支预测失败惩罚反而大于直接算完。我的建议是:挖掘你自身数据的分布,别拍脑袋。同一份代码在密度高的车辆检测场景和密度低的遥感场景,跑出来的最优分支策略完全可能是两套。
3.4 多核并行:按类别分组,用线程池别用 OpenMP
标准 NMS 还有一个天然可以并行的维度:类别。不同类别之间的检测框互不影响。RK3588 有 4 个 A76 大核和 4 个 A55 小核,A76 单核性能跑单类别 NMS 绰绰有余。我按类别数把任务分给线程池,每类别一个任务,用 std::thread 做数据并行。
这个思路在类别数少的时候(比如我们项目只有 5 类)收益不大,线程创建开销甚至可能抵消收益。所以我改成线程池:pipeline 初始化时创建固定 4 个 worker 线程,每帧推理完成后把各类别 NMS 任务分发到这些线程。这样延迟降了约 20%,而且类别数越多收益越明显。如果你用的模型是 COCO 80 类,这个并行策略绝对值得上。
我自己没有用 OpenMP。RKNN 运行时自带线程池,OpenMP 的默认线程策略容易和它抢 CPU。用 std::thread 手动控制任务粒度,反而更好调优。一个小技巧:让每个 worker 线程绑定到 A76 核心,可以避免任务被调度到 A55 小核上,性能差异还是明显的。A76 和 A55 在单线程浮点性能上的差距大约是 3 倍,NMS 这种计算密集任务要是跑在小核上,前面所有优化都打了折扣。
4. 性能测试:69.1ms 到 0.19ms 怎么测出来的
4.1 测试环境与方法:同机同数据,跑过 100 张再统计
先说测试方法,没有方法的数字都是耍流氓。
硬件:RK3588 开发板,8GB 内存。系统:Ubuntu 22.04(aarch64)。模型:YOLOv5s COCO 预训练权重转 RKNN,int8 量化,输入 640x640。测试图片:500 张来自实际数据集,统一缩放后送入模型。测量工具:Python 侧用 time.perf_counter_ns,C++ 侧用 std::chrono::steady_clock,测的是“模型输出张量开始,到后处理结果生成完毕”的完整过程。
为什么强调稳态?第一次调用和后续调用差异很大,cache、线程池、内存分配器都要预热。我只统计连续推理 500 张中第 101 到第 500 张的平均耗时,排除前 100 张的预热噪声。不要用单次调用结果衡量性能,那样测出来的数字波动很大,今天 3ms 明天 8ms 都可能。
Python 版本实测结果:解码加 NMS 平均 69.1ms。其中解码大约 22.4ms,NMS 本身占 46.7ms。这个数字看起来高,其实是解释器循环加 NumPy 临时数组的典型代价。
4.2 C++ 版本的分阶段优化与实测
第一版,我直接照搬 Python 逻辑,用 std::vector 存储解码后的框,全排序加双层循环做 NMS,编译开 -O2。实测平均 6.83ms。虽然已经比 Python 快了一个数量级,但离“消失在推理时间里”还差得远。
第二版做了三件事:topK 代替全排序、area 预计算、IoU 内联。实测降到 0.96ms。收益主要来自减少了排序比较和内存访问。
第三版再把解码和 NMS 的临时内存全部预分配复用,同时开 -O3、-march=armv8.2-a+fp16、-ffast-math,加入按类别分组的线程并行。实测 0.19ms。从 6.83ms 到 0.19ms,又是 36 倍左右的提升。
4.3 359 倍到底由哪些部分组成
我把这 359 倍拆开看:
| 阶段 | 平均耗时 | 相对 Python 速度 |
|---|---|---|
| Python 解码 + NMS | 69.1ms | 1x |
| C++ 第一版(直译) | 6.83ms | 10.1x |
| C++ 第二版(算法优化) | 0.96ms | 72x |
| C++ 第三版(内存+编译+并行) | 0.19ms | 363x |
直译 C++ 先拿到 10 倍加速,这主要来自消除解释器开销和 NumPy 临时数组开销。算法优化又带来 7 倍左右,收益来自 topK 排序和减少 IoU 计算量。内存复用、编译选项、线程并行合起来又是 5 倍左右。三者叠乘得到约 359 倍。这说明提速靠的是金字塔式叠加,不是一个“魔法操作”完成的。
特别说一句,359 倍这个倍率依赖具体的 Python 实现和硬件环境。如果你的 Python 后处理写得非常粗糙,可能加速 700 倍;如果已经用了很好的向量化技巧,可能只有 100 倍。真正有价值的是优化路径:先消除解释器开销,再消除算法冗余,再压榨内存与编译特性,最后上并行。
5. 集成到 RK3588 部署流水线,最容易翻车的几个地方
5.1 NPU 输出到 C++ NMS 的“最后一公里”
RKNN 在 RK3588 上的推理输出,需要通过 rknn_query 拿输出张量内存地址。如果直接读 rknn_run 之后的 output 指针,拿到的数据是 RKNN 内部排布,可能和你在 rknn.config 里期望的格式不一致。建议在导出模型时用 rknn.config 明确指定 output_tensor_format。这里有个很坑的细节:某些版本 RKNN 工具链会自动做一次转置来适配 NCHW 或 NHWC,如果你不检查输出维度就盲写解码代码,大概率前 100 帧里出现边框错位。
实践里最稳妥的做法是先把输出张量形状打印出来,和期望的 [3, H, W, 85] 或 [25200, 85] 核对一遍。我自己在这上面浪费过一个下午,最后发现较新版本 RKNN 工具链里,模型输出最后一维是 85,但 YOLOv5 的 Python 推理里习惯前 4 维是 (1, 25200, 85)。转换输出后,必须按 stride 把三个头的输出拼成一个 [25200, 85] 的数组,再交给解码。这一步做错的话,后面的 NMS 写得再快也没意义。
5.2 后处理和推理线程配合:双缓冲能把后处理藏起来
如果你把 NPU 推理和 C++ 后处理串成一个线程,那就是 NPU 在等 CPU、CPU 在等 NPU,整体延迟是两部分相加。更好的方案是用双缓冲队列:推理线程把输出张量写到“in-use”缓冲区,后处理线程立刻取走上一个缓冲区的数据做 NMS。RK3588 的 A76 核和 NPU 是并行工作的,这个双缓冲可以把 CPU 后处理时间“藏”到 NPU 推理的 30ms 里面去。
具体实现上,我用两个预分配的内存块 ping-pong 结构。推理线程写完 A 块后发信号,后处理线程处理 A 块;推理线程继续写 B 块。内存使用可以通过 RKNN 的输出 tensors 绑定到固定内存,避免反复拷贝。这一步做完后,端到端吞吐大约提升 20% 以上,而延迟几乎不变。很多人只盯着单帧推理时间,忽略了流水线设计才是吞吐量的关键。如果后处理耗时超过推理耗时,这个双缓冲结构可以让两者重叠,最大程度吃满硬件。
5.3 int8 量化后的边界误差,怎么补偿
int8 量化会让 NMS 候选框的置信度分数出现微小漂移。浮点模型下一个框的置信度是 0.251,量化后变成 0.248,刚好被你设的 0.25 阈值滤掉。这类误差在测试集上往往不影响 mAP,但实际业务场景可能就漏检了关键目标。
所以部署时我用的是“稍低”的置信度阈值,比如 0.2,同时保留 topK=500,让 NMS 的 IoU 阈值来控制最终输出数量。这样量化误差不会让某个框整帧消失。另一个手段是在验证集上重新调 NMS 参数,别直接把 PyTorch 后处理里的阈值搬过来。我习惯把 NMS 的 IoU 阈值放在 0.45 到 0.5 之间,比 PyTorch 默认的 0.45 略低一点,因为 int8 坐标量化后,框之间的 IoU 计算会略有偏差。
5.4 交叉编译与库依赖:静态链接最省心
RK3588 的开发环境一般是一台 x86 主机,交叉编译 aarch64 版本。这里最常遇到的坑不是代码本身,而是链接了错误的 libstdc++ 或 glibc。我用 aarch64-linux-gnu-g++ 交叉编译,并用了 -static 把 libstdc++ 静态链接进去,防止目标板上 GCC 版本和主机不一致导致崩溃。你可以在目标板直接跑时观察是否存在 Illegal instruction 或 segmentation fault,如果有,先检查是不是编译选项引入了目标板不支持的指令。
RK3588 支持 armv8.2-a,带 fp16 和 dotprod 扩展,但它不是所有 armv8.2 特性都齐全。编译时用 -mcpu=cortex-a76 或 -march=armv8.2-a+fp16 比较安全,不要随手写 -march=armv9,否则容易在真机上跑出随机非法指令。另外补一句:如果目标板内存紧张,可以把 -static 改成只静态链接 libstdc++,而让 glibc 动态链接,这样二进制体积会小不少。
6. 踩坑记录与排查经验
6.1 常见错误速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 检测框整体偏移 | 解码时忘记把 cx, cy, w, h 乘上网格 stride | 解码严格对应三个检测头的 stride 8/16/32 |
| 检测框错位但得分正常 | RKNN 输出维度与期望不匹配 | 打印输出形状,核对 NCHW/NHWC |
| NMS 把所有框都滤掉 | IoU 阈值设得过高,比如 0.9 | 用 0.45-0.5 常见值,并可视化调参 |
| 某个类别整类不出框 | topK 剪枝把该类候选全部截掉 | 按类别分别保留 topK,而非全局取 topK |
| 第一次调用后处理超慢 | 缓存未预热、内存池未就绪 | 用预热帧加复用缓冲区 |
| 线程并行后结果不稳定 | 类别间共享了同一批可变缓冲区 | 按类别分配独立输出缓冲区 |
6.2 最容易忽略的验证方法:逐框比对
代码性能都测完了,但一开始我很难完全相信正确性。从 Python 换到 C++,任何 1e-6 级别的浮点差异,到最终画面里都可能变成一个框的错位。我的经验是:C++ NMS 完成后把结果 dump 成和 Python 版本完全一致的格式,再写一个小脚本逐框比对坐标和置信度。第一轮跑下来,发现差异基本都来自 IoU 是在像素坐标还是在归一化坐标下算的,这是最经典的一个坑。
YOLOv5 原始后处理在归一化坐标里做 NMS,而我第一版 C++ 解码后直接换算成了像素坐标。像素坐标和归一化坐标在相同 IoU 阈值下会有微妙不同,导致边缘框保留结果不一致。其实统一成哪种都行,但必须和训练时的处理保持一致。如果你发现只差在个别边框,往往不是浮动误差,而是坐标系没对齐。
6.3 “359 倍”这个数字的适用范围
最后想给拿这个数字做对比的人泼盆冷水:这个倍率成立的前提是 Python 后处理写在了“常见而低效”的路径上。如果换一个擅长用 Numba 或向量化技巧的工程师,加速比可能只有 30 倍。如果换成 RK3568 或 RK3399,CPU 核心不同,同样优化获得的速度提升也会变。
我建议的通用流程是:先用 Python 写好正确逻辑并测试指标,确认性能瓶颈确实在后处理,再写 C++ 直译版做基准,再考虑算法优化、内存优化、编译优化和并行优化。每一步都保留基准数据,方便定位收益来自哪里。不要把“359 倍”当成可复制的统一结果,它应该是优化思路有效性的证据。
7. 最后聊几句经验之谈
如果让我用一句话总结这篇的核心,就是“先查账,再优化”。别一上来就把整体 Python 代码改成 C++,也别迷信某个框架的自动加速。先用 profiler 把每一段耗时量出来,确认瓶颈是 NMS 还是解码,是内存拷贝还是线程调度,再精准开刀。
我在这个 RK3588 部署项目里的流程是:Python 原型负责保证指标正确,C++ 直译负责建立性能基线,然后按照“解码 → NMS → 内存 → 编译 → 并行”的顺序逐层优化。每做一步都在同一组测试图上回归,确认结果一致性。这套流程放到 RK3568、RK3566 这类 SoC 上同样成立,差别只在于 CPU 核心数量和 NPU 性能标定。
NMS 加速到 0.19ms 之后,整条推理链路的瓶颈自然转移回了 NPU 推理本身。后处理不再是短板,接下来可以安心调模型量化精度和帧率调度。如果你也在 RK3588 上做 YOLO 部署,卡在后处理这一环,希望这篇文章能帮你少踩几个坑。等我把整条链路完整串起来,再把端到端效果和数据一起放出来给大家看。