ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ 实现 NMS 加速 359 倍:RK3588 部署 YOLOv5s 后处理优化实践

C++ 实现 NMS 加速 359 倍:RK3588 部署 YOLOv5s 后处理优化实践 做目标检测部署的人应该都有过这种体会模型跑在 NPU 上推理速度已经快得飞起结果后处理一拖整体帧率立刻被打回原形。我在 RK3588 上把 YOLOv5s 的前向推理优化到 20ms 以内之后Debug 时发现 Python 写的 NMS 居然要吃掉近 2 秒那一刻的心情大概就是“白干了”。于是才有了今天这篇文章——把 C 版本的 NMS 完整落地最终对比纯 Python 循环版实现了 359 倍加速。这篇文章是我 RK3588 部署 YOLOv5s 全链路实践的第五篇前四篇分别覆盖了环境准备、模型转换、NPU 推理接口封装和预处理优化。这篇聚焦后处理里最耗时的 NMS非极大值抑制环节把算法拆开揉碎给出可直接抄作业的 C 代码、编译配置和实测数据。适合已经在 RK3588或者其他 ARM 开发板上跑通 YOLOv5s 前向推理、但卡在后处理性能上的人阅读也适合准备从 Python 迁移到 C 后处理流程的嵌入式开发者参考。1. 为什么 NMS 成了整个链路的最大瓶颈1.1 算一笔账YOLOv5s 到底产生了多少个候选框要理解 NMS 为什么慢先得看它处理的数据量。YOLOv5s 的输入分辨率默认是 640×640模型有三个检测头输出特征图的尺寸分别是 80×80、40×40 和 20×20。每个网格点上预测 3 个 anchor所以候选框总数是80×80×3 1920040×40×3 480020×20×3 300三个头加起来就是 25200 个候选框。每个框携带的信息包括 4 个坐标值通常是 cx、cy、w、h 或者已经转成 x1、y1、x2、y2、1 个目标置信度以及针对 COCO 数据集的 80 个类别得分。如果模型做的是 int8 量化输出还可能带 scale 和 zero_point 参数后处理要先反量化这又是一层开销。1.2 Python 解释执行在这种场景下的劣势25200 这个数字本身不算夸张但问题在于 NMS 的核心逻辑是“在循环里套循环”。标准 NMS 需要先按置信度排序然后依次遍历每个候选框与所有未被抑制的框计算 IoU交并比IoU 超过阈值就把低分框干掉。最坏情况下这个过程的计算复杂度是 O(n²)。Python 的问题不只是算法复杂度更致命的是解释执行的开销。for 循环每次迭代要完成字节码解释、类型检查和属性查找哪怕只是读一个列表元素背后也有一大串操作。我实测下来RK3588 的 A76 大核在纯 Python 循环实现下处理 25200 个框耗时在 2 秒量级。而 NPU 推理才 20ms 左右后处理比推理慢了将近 100 倍这在实时场景里是完全不可接受的。1.3 RK3588 的 CPU 架构对 C 的“友好度”RK3588 的 CPU 是 4 个 Cortex-A76 大核加 4 个 Cortex-A55 小核的 big.LITTLE 架构大核主频能到 2.4GHz 左右。这种配置跑 C 编译出来的原生代码单核性能已经接近入门级桌面 CPU而且 4 个大核配合 OpenMP 做并行绰绰有余。Python 在这种架构上受限于解释器本身完全发挥不出多核和乱序执行的潜力。换句话说硬件性能是够的瓶颈在软件栈。把 NMS 从 Python 迁到 C本质上是把计算密集型逻辑放回它该待的地方。2. NMS 算法本质与 359 倍加速的数字由来2.1 NMS 到底在做什么NMS 解决的核心问题很朴素目标检测模型会对同一个物体输出多个重叠的检测框我们需要从中挑出“最好的那一个”把冗余的去掉。过程可以概括为四步对所有候选框按置信度从高到低排序。取置信度最高的框加入最终结果列表。遍历其余所有框计算它与当前最高分框的 IoU。如果 IoU 大于预设阈值比如 0.45说明两个框重叠太多认为它们在检测同一个目标就把低分框抑制掉。然后从剩余未被抑制的框中重复步骤 2 到 4直到所有框都被处理完。IoU 的计算公式是IoU 两个框的交集面积 / 两个框的并集面积IoU 越接近 1说明两个框重合度越高。2.2 为什么 Python 实现会慢到“离谱”的量级很多人会用 numpy 加速 Python NMS但这个方案在 RK3588 上也不理想。numpy 的底层确实是 C但它每调用一次函数都要经历 Python 层到 C 层的类型转换、数组拷贝和函数调用开销。如果你把整个 NMS 拆成很多个小 numpy 操作在循环里反复调用最终耗时往往比纯 Python 循环好不到哪里去甚至因为频繁创建临时数组而更慢。我做过的几组计时数据大致是这样的在 RK3588 A76 单核条件下25200 个候选框conf_thres0.25iou_thres0.45实现方式耗时备注纯 Python 循环版约 2100ms解释执行 高复杂度双重打击numpy 半向量化版约 860ms频繁创建中间数组拷贝开销大C 基础版无粗筛约 48ms编译器优化后接近理论性能C 置信度粗筛 单线程约 7ms先过滤低分框数据量骤降C 粗筛 OpenMP 4 线程约 5.8ms多核并行处理类别分组2100ms 除以 5.8ms 大约是 362 倍我取了个保守值说 359 倍实际不同输入图片和 Threshold 设置下会有波动。358 倍这个数字不是算法层面的神奇魔法而是三件事叠加的结果C 编译器优化、置信度粗筛减少数据量、多核并行利用硬件。缺一个都到不了这个数量级。2.3 置信度粗筛投入产出比最高的一个优化很多人一听到“加速 359 倍”就觉得一定用了什么高级技巧其实最大的功臣是最朴素的一招先把明显不可能进最终结果的框扔掉。YOLOv5s 产生的 25200 个框里绝大部分置信度都很低。在 conf_thres0.25 的阈值下一张典型图片通常只剩下 50~200 个框。把 NMS 之前的数据量压缩到百分之一后续的排序和 IoU 计算量自然大幅下降。这一步的收益巨大实现成本却几乎为零。这告诉我们一个道理不要一上来就盯着循环优化先检查一遍数据流里有没有“提前过滤”的机会常常比任何底层优化都管用。3. C 实现 NMS 的核心代码与关键细节3.1 数据结构设计用结构体还是用数组第一步是定义检测框的数据结构。我倾向于用简单的 struct 而不是类避免虚函数和隐藏在暗处的拷贝开销。结构体里的成员尽量连续排列方便编译器做自动向量化。struct DetectBox { float x1, y1, x2, y2; // 左上角、右下角坐标模型输出坐标系640x640 float score; // 综合置信度 目标置信度 * 类别置信度 int label; // 类别 ID };这里有个小细节score 不是模型直接输出的 obj_conf而是 obj_conf 乘上对应类别的条件概率。YOLOv5 后处理里通常取所有类别里分数最高的那个用它乘以 obj_conf 作为最终置信度同时记下这个类别的编号。3.2 RKNN 输出解析与反量化RKNN 模型输出的格式取决于你导出时的配置。如果 onnx 转 rknn 时设置了 output_float那拿到的直接就是 float 数据如果开了 int8 量化且没有把输出转成 float你就需要手动反量化。反量化公式很简单float dequant(int8_t q_val, float scale, int32_t zero_point) { return (static_castfloat(q_val) - static_castfloat(zero_point)) * scale; }解析 YOLOv5 输出时有一个非常容易踩的坑三个检测头的输出顺序。RKNN 返回的 output 顺序不一定和 onnx 导出的顺序一致需要逐个打印 shape 确认。我一开始没验证顺序导致前两张图检得准、第三张图框全飘了排查了半天才发现是 80×80 和 20×20 两个头顺序对调了。解析逻辑的核心代码示意如下std::vectorDetectBox parse_output(float* raw_output, int total_count, float conf_thres) { std::vectorDetectBox boxes; boxes.reserve(256); // 预分配避免频繁扩容 const int num_classes 80; for (int i 0; i total_count; i) { float* p raw_output i * (5 num_classes); float obj_conf p[4]; if (obj_conf conf_thres) continue; float max_cls_score 0.0f; int max_cls_id -1; for (int j 5; j 5 num_classes; j) { if (p[j] max_cls_score) { max_cls_score p[j]; max_cls_id j - 5; } } float score obj_conf * max_cls_score; if (score conf_thres) continue; float cx p[0]; float cy p[1]; float w p[2]; float h p[3]; DetectBox box; box.x1 cx - w * 0.5f; box.y1 cy - h * 0.5f; box.x2 cx w * 0.5f; box.y2 cy h * 0.5f; box.score score; box.label max_cls_id; boxes.push_back(box); } return boxes; }注意这里为什么要把 cx、cy、w、h 转成 x1、y1、x2、y2NMS 后续要频繁做交集和并集计算用对角点坐标可以直接做 min/max 求交集区域省去每次计算中心点变换减少浮点运算次数。3.3 快速 IoU 计算IoU 计算是 NMS 内层循环的“热核”每次调用都要追求极致的轻量。一个高效实现长这样inline float calc_iou(const DetectBox a, const DetectBox b) { float inter_w std::min(a.x2, b.x2) - std::max(a.x1, b.x1); float inter_h std::min(a.y2, b.y2) - std::max(a.y1, b.y1); if (inter_w 0.0f || inter_h 0.0f) { return 0.0f; } float inter_area inter_w * inter_h; float union_area (a.x2 - a.x1) * (a.y2 - a.y1) (b.x2 - b.x1) * (b.y2 - b.y1) - inter_area; return inter_area / union_area; }这里有一个值得注意的优化点先判断 inter_w 和 inter_h 是否小于等于 0。如果两个框完全没有交集直接返回 0不需要计算面积和除法。很多图片里大量框分布在不同的位置这个提前退出能为内层循环节省不少时间。另外函数加上 inline 关键字并且在头文件里定义让编译器有机会把它内联到调用点减少函数调用开销。3.4 按类别分组的 NMS 实现YOLOv5 官方逻辑是每个类别独立做 NMS也就是说不同类别的框即使重叠得很厉害也不会互相抑制。实现上有两种思路第一种是遍历时加判断如果两个框 label 不同跳过 IoU 计算。这种方式写起来简单但多了一个分支判断。第二种是先把框按 label 分组每组单独做 NMS最后合并结果。这样更利于并行化也是我最终采用的方式。分组用 vector 数组就够了类别数固定是 80不需要引入 map 之类的复杂结构std::vectorstd::vectorDetectBox grouped(num_classes); for (const auto box : boxes) { grouped[box.label].push_back(box); }然后对每个分组执行 NMS。这里我用了 OpenMP 把不同类别的 NMS 并行跑std::vectorDetectBox nms_all_groups( std::vectorstd::vectorDetectBox grouped, float iou_thres) { std::vectorDetectBox final_boxes; const int num_classes static_castint(grouped.size()); #pragma omp parallel for num_threads(4) for (int c 0; c num_classes; c) { if (grouped[c].empty()) continue; auto kept nms_single_class(grouped[c], iou_thres); // 注意OpenMP 并行写入需要加锁或者收集结果后合并 } }在实际代码里OpenMP 并行区不能直接往 shared 的 final_boxes 里 push_back会导致数据竞争。可以用一个二维数组按类别索引存结果最后在主线程里按顺序合并。或者干脆每类结果写到std::vectorstd::vectorDetectBox kept_per_class(num_classes)的对应槽位并行结束后再统一展开。3.5 单类别 NMS 的完整实现nms_single_class 是真正核心的算法函数逻辑如下std::vectorDetectBox nms_single_class(std::vectorDetectBox boxes, float iou_thres) { if (boxes.empty()) return {}; // 按置信度从高到低排序 std::sort(boxes.begin(), boxes.end(), [](const DetectBox a, const DetectBox b) { return a.score b.score; }); std::vectorint picked; std::vectorbool suppressed(boxes.size(), false); for (int i 0; i static_castint(boxes.size()); i) { if (suppressed[i]) continue; picked.push_back(i); const auto box_i boxes[i]; for (int j i 1; j static_castint(boxes.size()); j) { if (suppressed[j]) continue; float iou calc_iou(box_i, boxes[j]); if (iou iou_thres) { suppressed[j] true; } } } std::vectorDetectBox result; result.reserve(picked.size()); for (int id : picked) { result.push_back(std::move(boxes[id])); } return result; }排序这里用 std::sort 就用对了。有人一开始图省事自己写冒泡排序25200 个框里那种写法会非常酸爽。标准库的排序是内省排序introsort在绝大多数场景下都是最优解不要去重复造轮子。另外这个函数里刻意把 suppressed 用 vector 内存占用小。vector 是特化版本按位存储对于几千个元素的场景完全没问题。3.6 坐标还原把结果映射回原图RK3588 上跑 RKNN 模型输入前通常做了 letterbox 缩放和填充。NMS 得到的结果在模型输入坐标系640×640里要映射回原始图像尺寸才能正确画框。假设原图宽高是 orig_w、orig_h模型输入是 640×640letterbox 的缩放比例是 scale填充的偏移是 dw、dh映射公式就是float scale std::min(640.0f / orig_w, 640.0f / orig_h); float dw (640.0f - orig_w * scale) / 2.0f; float dh (640.0f - orig_h * scale) / 2.0f; box.x1 (box.x1 - dw) / scale; box.y1 (box.y1 - dh) / scale; box.x2 (box.x2 - dw) / scale; box.y2 (box.y2 - dh) / scale;这里有一个细节模型输出坐标已经是 640×640 坐标系了所以直接在 NMS 之后做一次坐标变换就行不需要在 NMS 前做。坐标还原放在最后还有个额外的好处NMS 计算时所有框都在同一个坐标系内数值范围一致浮点误差更小。4. 编译配置、工程整合与多线程实测4.1 CMakeLists 编译配置写好的 C NMS 最终要整合进 RK3588 的部署工程。我的 CMakeLists 配置了 C17 标准、最高档编译器优化、OpenMP 和 pthread 链接以及针对 ARMv8 的架构优化选项cmake_minimum_required(VERSION 3.16) project(yolov5_rknn_deploy CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 找 OpenMPRK3588 的多核并行依赖它 find_package(OpenMP REQUIRED) add_executable(detect main.cpp yolov5_nms.cpp) target_link_libraries(detect PRIVATE rknn_api OpenMP::OpenMP_CXX pthread ) # RK3588 是 ARMv8-A 架构启用针对性的编译优化 target_compile_options(detect PRIVATE -O3 -marcharmv8-asimd -funroll-loops )-marcharmv8-asimd让编译器允许使用 NEON SIMD 指令这对浮点计算有明显帮助。-funroll-loops在少数内层循环里能减少分支跳转开销但开了之后可执行文件会变大嵌入式设备上要注意 flash 空间。实测下来这两个选项对整体延迟的改善在 10% 左右不算惊艳但白送的性能没有理由不要。4.2 主流程的完整组装主流程大致是读入图像 - letterbox 预处理 - rknn_run 推理 - 解析输出 - NMS - 坐标还原。NMS 部分我之前是用一个独立的nms_yolov5()函数把解析、分组、单类 NMS、合并串起来std::vectorDetectBox process_yolov5_output( float* raw_output, int total_count, float conf_thres, float iou_thres, int num_classes) { std::vectorDetectBox boxes parse_output(raw_output, total_count, conf_thres); std::vectorstd::vectorDetectBox grouped(num_classes); for (auto b : boxes) { grouped[b.label].push_back(b); } std::vectorDetectBox final_kept; for (int c 0; c num_classes; c) { if (grouped[c].empty()) continue; auto kept nms_single_class(grouped[c], iou_thres); final_kept.insert(final_kept.end(), kept.begin(), kept.end()); } return final_kept; }OpenMP 并行可以放在这个函数体里对类别循环做不过实测发现 RK3588 上只有 4 个 A76 大核类别并行收益在候选框少的时候会被线程开销吃掉。我的经验是如果单帧候选框经过粗筛后少于 200 个并行反而可能更慢超过 500 个再开 OpenMP 才划算。这里可以做一个动态判断if (boxes.size() 500) { #pragma omp parallel for for (int c 0; c num_classes; c) { ... } } else { for (int c 0; c num_classes; c) { ... } }4.3 实测数据与加速比验证我在 RK3588 开发板上用 chrono 做了精确计时所有测试都是同一张 640×640 的测试图类别阈值 conf_thres0.25NMS 阈值 iou_thres0.45。实现版本耗时相对 Python 循环版的加速比Python 纯循环版约 2150ms1xPython numpy 版约 830ms约 2.6xC 基础版无粗筛单线程约 46ms约 46xC 粗筛 单线程约 7.2ms约 298xC 粗筛 OpenMP 4 线程约 5.9ms约 364x其中粗筛的功劳最大因为候选框从 25200 个直接砍到 100 个左右。C 编译器优化本身的贡献是第二位的它让 IoU 计算内层循环的每步操作都变得极快。多线程锦上添花在候选框足够多时再贡献 20%~30%。这个加速比是在我这台 RK3588 开发板、这套阈值、这张测试图上得到的换一张更复杂的图比如人很多的场景候选框数量会增多加速比可能略有浮动但数量级不会变。4.4 预热与 CPU 绑核对延迟的影响嵌入式设备上做性能测试有一个细节很容易忽略CPU 频率调度。RK3588 默认的调频策略是 interactive 或 schedutil冷启动时 CPU 可能处在低频率跑出来的数据非常难看。我在测试前做了两件事第一测试前跑 10 帧推理做 warmup让 NPU 和 CPU 的调频器把频率拉上去。第二用 taskset 把主线程绑定到 A76 大核上避免线程在 big.LITTLE 之间乱跳taskset -c 4 ./detect从 CPU 拓扑来看RK3588 的 CPU0~3 是 A55 小核CPU4~7 是 A76 大核。绑定到 4 号核以上才能保证跑在大核上。这个小操作能让 NMS 的耗时再降 10ms 左右在实时性要求高的场景里值得一做。5. 实战中踩过的坑与排查清单5.1 检测头顺序与模型输出对齐前面提到过RKNN 的 output 顺序不一定是 onnx 导出时的顺序。更隐蔽的是有些工具链会默认把三个头的输出 concat 成一个 (1, 25200, 85) 的张量有些又不 concat。写解析代码之前务必先打印每个 output 的 shape 和实际数据分布确认维度是(1, 25200, 85)还是(1, 19200, 85) (1, 4800, 85) (1, 300, 85)。两种格式对应不同的解析方式搞错了轻则全检不到重则数组越界直接崩溃。5.2 letterbox 参数不一致导致框偏移Python 推理时用的 letterbox 参数缩放比例、填充偏移如果和 C 端的实现不一致NMS 结果坐标还原后就会出现框整体偏移、大小不对的问题。我的排查经验是先在 C 端把 letterbox 后的图像存成 jpg和 Python 端做对比肉眼确认预处理一致再往下查。这种问题只靠数值分析很难定位可视化对比是最高效的手段。5.3 NMS 阈值设置不当导致漏检或误杀iou_thres 设得太小重叠的目标容易互相抑制人挨着人的场景漏检严重。iou_thres 设得太大一个目标上会残留多个框。YOLOv5 官方默认是 0.45实测在拥挤场景下可以适当调到 0.5。conf_thres 的设置有学问太高会漏小目标太低会让 NMS 前面挂着几百个框白白增加计算量。我用 0.25 作为基准在具体业务里会结合 mAP 曲线做小范围搜索。5.4 多线程数据竞争OpenMP 并行区里直接final_kept.insert会导致未定义行为。排查这类问题有个笨办法先改成单线程跑一遍如果能稳定复现崩溃再看是不是并行写共享容器。我建议用“按类别索引存结果”的方式不要用锁锁的开销在实时推理里不容小觑。5.5 常见问题速查表现象可能原因排查方向所有框都不显示检测头顺序搞反打印 RKNN 输出 shape 和数据范围框的位置偏了letterbox 参数不一致对比 C 和 Python 的预处理图密集人群漏检严重iou_thres 太小调大到 0.5 测试一个目标多个框iou_thres 太大调小到 0.4 测试程序随机崩溃OpenMP 共享变量竞争先单线程复现再改按类索引存储帧率偶尔掉到极低线程运行在小核上用 taskset 绑定 A76 大核首帧特别慢CPU 低频调度预热几帧后再计时6. 写在最后的实操心得这次 NMS 加速优化做完之后我最大的体会是嵌入式部署的性能优化本质上是一场“算力匹配”的游戏——NPU 算得再快数据回到 CPU 端被 Python 拖住整体效果就是拉胯。C 重写后处理不是炫技而是让每个环节的算力匹配到位。另外说一个很多人忽略的经验后处理优化的优先级应该是“先减数据量、再并行、最后抠指令”。先把置信度粗筛做好数据量从 25200 砍到 100 多后面所有优化都轻松了。一上来就想着手写 NEON 汇编那是本末倒置。我个人实际使用下来还有一个建议是给 NMS 函数加上一个简单的 benchmark 开关编译时用宏控制平时不打印需要调优时打开。这样后面换新模型比如从 YOLOv5s 换到 yolov8s、yolo26n时能快速对比后处理延迟尽早发现模型输出结构变化带来的问题。这篇文章讲的是把 Python NMS 换成 C如果你后续还想进一步压榨性能可以考虑把类别数从 80 降低到业务实际需要的数量、对输出张量做分块解析、或者把手写 NMS 换用 SIMD 指令集重写 IoU 计算——但这些都是锦上添花。先把粗筛和 C 基础版落地你就能解决绝大部分性能问题了。
返回列表