RK3588上YOLOv5后处理提速359倍:C++重写NMS详解
发布时间:2026/11/2 3:16:12 锦皓数字建站

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 原理五分钟后回顾NMSNon-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改成 topK300 的局部排序后耗时降到 0.11ms检测指标基本没变化。这 10 倍差距就是排序策略省出来的。还有一个细节如果你的类别不止一个务必按类别分别做 topK不要全局只取 300 个框否则小目标类别很容易在剪枝阶段被整个丢掉。3.3 循环内核IoU 函数内联少写没必要的分支NMS 最内层的操作是 IoU 计算这个函数会被反复调用几十万次。如果每次调用都走普通函数编译器没法把循环间的寄存器分配和内存访问优化到极致。我建议在头文件里把它定义成 static inlinestatic 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.04aarch64。模型YOLOv5s COCO 预训练权重转 RKNNint8 量化输入 640x640。测试图片500 张来自实际数据集统一缩放后送入模型。测量工具Python 侧用 time.perf_counter_nsC 侧用 std::chrono::steady_clock测的是“模型输出张量开始到后处理结果生成完毕”的完整过程。为什么强调稳态第一次调用和后续调用差异很大cache、线程池、内存分配器都要预热。我只统计连续推理 500 张中第 101 到第 500 张的平均耗时排除前 100 张的预热噪声。不要用单次调用结果衡量性能那样测出来的数字波动很大今天 3ms 明天 8ms 都可能。Python 版本实测结果解码加 NMS 平均 69.1ms。其中解码大约 22.4msNMS 本身占 46.7ms。这个数字看起来高其实是解释器循环加 NumPy 临时数组的典型代价。4.2 C 版本的分阶段优化与实测第一版我直接照搬 Python 逻辑用 std::vector 存储解码后的框全排序加双层循环做 NMS编译开 -O2。实测平均 6.83ms。虽然已经比 Python 快了一个数量级但离“消失在推理时间里”还差得远。第二版做了三件事topK 代替全排序、area 预计算、IoU 内联。实测降到 0.96ms。收益主要来自减少了排序比较和内存访问。第三版再把解码和 NMS 的临时内存全部预分配复用同时开 -O3、-marcharmv8.2-afp16、-ffast-math加入按类别分组的线程并行。实测 0.19ms。从 6.83ms 到 0.19ms又是 36 倍左右的提升。4.3 359 倍到底由哪些部分组成我把这 359 倍拆开看阶段平均耗时相对 Python 速度Python 解码 NMS69.1ms1xC 第一版直译6.83ms10.1xC 第二版算法优化0.96ms72xC 第三版内存编译并行0.19ms363x直译 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同时保留 topK500让 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 特性都齐全。编译时用 -mcpucortex-a76 或 -marcharmv8.2-afp16 比较安全不要随手写 -marcharmv9否则容易在真机上跑出随机非法指令。另外补一句如果目标板内存紧张可以把 -static 改成只静态链接 libstdc而让 glibc 动态链接这样二进制体积会小不少。6. 踩坑记录与排查经验6.1 常见错误速查表现象可能原因解决方案检测框整体偏移解码时忘记把 cx, cy, w, h 乘上网格 stride解码严格对应三个检测头的 stride 8/16/32检测框错位但得分正常RKNN 输出维度与期望不匹配打印输出形状核对 NCHW/NHWCNMS 把所有框都滤掉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 或 RK3399CPU 核心不同同样优化获得的速度提升也会变。我建议的通用流程是先用 Python 写好正确逻辑并测试指标确认性能瓶颈确实在后处理再写 C 直译版做基准再考虑算法优化、内存优化、编译优化和并行优化。每一步都保留基准数据方便定位收益来自哪里。不要把“359 倍”当成可复制的统一结果它应该是优化思路有效性的证据。7. 最后聊几句经验之谈如果让我用一句话总结这篇的核心就是“先查账再优化”。别一上来就把整体 Python 代码改成 C也别迷信某个框架的自动加速。先用 profiler 把每一段耗时量出来确认瓶颈是 NMS 还是解码是内存拷贝还是线程调度再精准开刀。我在这个 RK3588 部署项目里的流程是Python 原型负责保证指标正确C 直译负责建立性能基线然后按照“解码 → NMS → 内存 → 编译 → 并行”的顺序逐层优化。每做一步都在同一组测试图上回归确认结果一致性。这套流程放到 RK3568、RK3566 这类 SoC 上同样成立差别只在于 CPU 核心数量和 NPU 性能标定。NMS 加速到 0.19ms 之后整条推理链路的瓶颈自然转移回了 NPU 推理本身。后处理不再是短板接下来可以安心调模型量化精度和帧率调度。如果你也在 RK3588 上做 YOLO 部署卡在后处理这一环希望这篇文章能帮你少踩几个坑。等我把整条链路完整串起来再把端到端效果和数据一起放出来给大家看。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。