☰
RK3588上C++加速NMS:从Python到C++的359倍性能提升
2026/10/28 7:40:26 网站建设 项目流程

1. 为什么要在 RK3588 上自己写 C++ NMS

YOLOv5s 在 RK3588 上跑推理,模型本身经过 RKNN 量化后速度已经相当可观,但很多人在整条链路跑通之后会发现一个尴尬的事实:后处理阶段反而成了新的瓶颈。尤其是当输入分辨率拉高、候选框数量暴涨的时候,Python 版本的 NMS 会吃掉大量时间,甚至比模型推理本身还慢。我在实际项目里就遇到过这种情况——模型推理只花了十几毫秒,NMS 却要几十毫秒,整体帧率被硬生生拖下来。

这个问题的根源在于 Python 的解释执行特性和 GIL 限制。NMS 本质上是一个循环密集型的算法,需要反复做 IoU 计算、排序、阈值比较,这些操作在 Python 里都是逐条解释执行的,每一次循环都有巨大的解释器开销。而 C++ 编译成机器码之后,同样的逻辑可以跑到接近硬件极限的速度。这就是为什么我决定把 NMS 从 Python 迁移到 C++,最终实测下来加速比达到了 359 倍。

这篇文章会完整讲清楚三件事:NMS 的算法原理和性能瓶颈到底在哪、怎么用 C++ 写一个高效的 NMS 并用 pybind11 暴露给 Python、在 RK3588 这种 ARM 平台上编译和调优有哪些坑。如果你正在做 RK3588 上的 YOLO 系列部署,或者任何需要在 Python 里加速数值计算的场景,这套思路都可以直接复用。

需要提前说明的是,本文假设你已经完成了 RKNN 模型转换和基础推理链路的搭建,如果你还卡在模型转换那一步,建议先把那部分跑通再来看后处理优化。另外,本文的代码在 x86 和 RK3588 的 ARM64 环境都验证过,编译方式略有差异,我会分别说明。

2. NMS 算法拆解与 Python 版本的性能瓶颈定位

2.1 NMS 到底在做什么

NMS 全称 Non-Maximum Suppression,非极大值抑制。用最直白的话说:目标检测模型会对同一个物体输出一堆重叠的候选框,NMS 的工作就是把这些重叠的框里只保留最靠谱的那一个,其余的丢掉。

具体流程分四步:

  1. 把所有候选框按置信度从高到低排序
  2. 取出当前置信度最高的框,作为保留框
  3. 计算剩余所有框与这个保留框的 IoU(交并比)
  4. 把 IoU 超过阈值的框删掉,因为它们和保留框指的是同一个物体

重复 2 到 4,直到没有框剩下。这个逻辑本身不复杂,但候选框数量一多,计算量就上来了。假设有 N 个候选框,最坏情况下要做 N 次循环,每次循环内部还要遍历剩余框算 IoU,整体复杂度是 O(N²) 级别。

2.2 Python 版本慢在哪

我最初用的是标准 Python + NumPy 的 NMS 实现,逻辑清晰,代码也就几十行。但在 RK3588 上实测,当候选框数量到几千个的时候,单次 NMS 耗时能到几十毫秒。用 cProfile 分析之后,时间主要花在三个地方:

  • Python 层的循环开销:每次 for 循环都要做类型检查、引用计数、字节码分发,这些在 C 里几乎不存在
  • NumPy 小数组操作的固定开销:每次调用 np.max、np.where 这些函数,即使数组很小,也有函数调用和临时对象创建的成本
  • GIL 导致的无法并行:Python 的多线程在 CPU 密集型任务上基本没用,NMS 这种纯计算任务完全吃不到多核红利

我做过一个对比测试,同样 3000 个候选框、IoU 阈值 0.45,Python 版本平均耗时约 18ms,而 C++ 版本只要 0.05ms 左右。这个差距就是 359 倍的来源。当然这个倍数会随候选框数量和硬件配置浮动,但数量级上的差异是稳定的。

2.3 为什么不用现成的库

你可能会问,为什么不直接用 torchvision.ops.nms 或者 OpenCV 的 NMSBoxes?原因有几个:

  • 依赖太重:torchvision 在 RK3588 上装起来很麻烦,而且会引入大量不必要的依赖
  • OpenCV 的 NMS 接口限制:cv2.dnn.NMSBoxes 对输入格式有要求,而且内部实现不一定针对 ARM 优化
  • 可控性:自己写可以针对具体场景做优化,比如支持批量处理、自定义 IoU 计算方式、调整内存布局

最关键的是,自己写一遍之后你对整个后处理链路会有完全不同的理解,后面调优的时候知道该动哪里。

3. 用 C++ 重写 NMS 的核心实现细节

3.1 数据结构的选择

C++ 版本的第一件事是确定用什么数据结构存候选框。最直观的是用std::vector<std::array<float, 4>>存坐标,std::vector<float>存置信度。但这样内存不连续,缓存命中率低。

我最终用的是结构体数组的方案:

struct BBox { float x1, y1, x2, y2; float score; int label; };

所有框存在一个std::vector<BBox>里,内存完全连续。这样遍历的时候 CPU 缓存能一次性加载多个框的数据,对性能提升很明显。实测下来比分离存储的方式快了大概 15% 到 20%。

排序的时候不要动原始数组,而是维护一个索引数组,按 score 排序索引。这样避免了大结构体的频繁拷贝,排序本身也更快。

3.2 IoU 计算的优化

IoU 的计算公式很简单:交集面积除以并集面积。但实现的时候有几个细节会影响性能:

inline float iou(const BBox& a, const BBox& b) { float xx1 = std::max(a.x1, b.x1); float yy1 = std::max(a.y1, b.y1); float xx2 = std::min(a.x2, b.x2); float yy2 = std::min(a.y2, b.y2); float w = std::max(0.0f, xx2 - xx1); float h = std::max(0.0f, yy2 - yy1); 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); return inter / (area_a + area_b - inter + 1e-6f); }

几个关键点:

  • 用inline让编译器有机会内联展开,省掉函数调用开销
  • 分母加一个极小值1e-6f防止除零,这个比判断是否为零再分支要快
  • 面积可以预先算好存在 BBox 里,避免每次重复计算

我一开始没预存面积,后来加上之后又快了大概 8%。这种小优化累积起来很可观。

3.3 排序策略与提前终止

NMS 的第一步是按置信度排序。C++ 里用std::sort配合 lambda 就行,但要注意降序排列,因为我们要从最高置信度开始处理。

std::vector<int> indices(n); std::iota(indices.begin(), indices.end(), 0); std::sort(indices.begin(), indices.end(), [&](int i, int j) { return boxes[i].score > boxes[j].score; });

排序之后进入主循环。这里有个容易被忽略的优化点:当剩余最高置信度已经低于某个阈值时,可以提前终止。因为后面的框置信度只会更低,如果业务上只关心高置信度目标,这个提前退出能省不少时间。

另外,如果同一张图里目标类别很多,可以按类别分别做 NMS,这样每次处理的候选框数量大幅减少,整体复杂度下降。YOLOv5s 默认是 80 类,按类别分组之后每组平均只有几十个框,NMS 几乎瞬间完成。

3.4 完整实现代码

把上面的点整合起来,核心函数大概长这样:

std::vector<int> nms(std::vector<BBox>& boxes, float iou_thresh) { int n = boxes.size(); std::vector<int> result; if (n == 0) return result; std::vector<int> indices(n); std::iota(indices.begin(), indices.end(), 0); std::sort(indices.begin(), indices.end(), [&](int i, int j) { return boxes[i].score > boxes[j].score; }); std::vector<bool> suppressed(n, false); for (int i = 0; i < n; ++i) { int idx = indices[i]; if (suppressed[idx]) continue; result.push_back(idx); for (int j = i + 1; j < n; ++j) { int jdx = indices[j]; if (suppressed[jdx]) continue; if (iou(boxes[idx], boxes[jdx]) > iou_thresh) { suppressed[jdx] = true; } } } return result; }

用suppressed布尔数组标记被抑制的框,比每次从 vector 里 erase 要快得多,因为 erase 涉及内存搬移。这个细节在候选框多的时候差别很明显。

4. pybind11 封装:让 Python 无痛调用 C++

4.1 为什么选 pybind11 而不是 ctypes

把 C++ 函数暴露给 Python 有好几种方式:ctypes、cffi、SWIG、pybind11。我选 pybind11 的理由很直接:

  • 语法简洁:几行代码就能完成绑定,不用写额外的接口描述文件
  • 类型转换自动:numpy 数组和 C++ vector 之间的转换基本不用手动处理
  • 支持 STL 容器:直接返回std::vector<int>,Python 那边拿到就是 list
  • 编译集成方便:配合 CMake 或者 setuptools 都很顺

ctypes 虽然不用额外依赖,但手动管理内存和类型转换太痛苦,尤其是涉及 numpy 数组的时候。pybind11 是 header-only 的,编译时把头文件路径加进去就行,部署时也不增加运行时依赖。

4.2 绑定代码的写法

绑定部分单独放一个文件,比如bindings.cpp:

#include <pybind11/pybind11.h> #include <pybind11/stl.h> #include <pybind11/numpy.h> #include "nms.h" namespace py = pybind11; PYBIND11_MODULE(fast_nms, m) { m.doc() = "Fast NMS implemented in C++"; m.def("nms", &nms_py, "Run NMS on boxes", py::arg("boxes"), py::arg("iou_thresh") = 0.45f); }

这里nms_py是一个包装函数,负责把 Python 传进来的 numpy 数组转成 C++ 的 BBox 向量,调用核心 NMS,再把结果转回去:

std::vector<int> nms_py(py::array_t<float> boxes, float iou_thresh) { auto buf = boxes.request(); float* ptr = static_cast<float*>(buf.ptr); int n = buf.shape[0]; int cols = buf.shape[1]; std::vector<BBox> bboxes(n); for (int i = 0; i < n; ++i) { bboxes[i].x1 = ptr[i * cols + 0]; bboxes[i].y1 = ptr[i * cols + 1]; bboxes[i].x2 = ptr[i * cols + 2]; bboxes[i].y2 = ptr[i * cols + 3]; bboxes[i].score = ptr[i * cols + 4]; } return nms(bboxes, iou_thresh); }

注意py::array_t<float>默认要求数组是 C 连续的,如果 Python 那边传进来的是切片或者转置过的数组,需要先np.ascontiguousarray处理一下,否则会抛异常。这个坑我踩过,排查了半天才发现是数组内存布局的问题。

4.3 编译配置

用 setuptools 编译最简单,写一个setup.py:

from setuptools import setup, Extension import pybind11 ext_modules = [ Extension( 'fast_nms', ['bindings.cpp', 'nms.cpp'], include_dirs=[pybind11.get_include()], language='c++', extra_compile_args=['-O3', '-march=native', '-fvisibility=hidden'], ), ] setup(name='fast_nms', ext_modules=ext_modules)

在 RK3588 上编译的时候,-march=native可能会因为交叉编译环境识别错误导致问题,建议改成-march=armv8-a明确指定架构。另外-O3是必须的,-O2和-O3在这种循环密集的代码上差距能到 20% 以上。

编译命令就是python setup.py build_ext --inplace,生成的.so文件直接放在 Python 路径下就能 import。

5. RK3588 上的编译踩坑与性能实测

5.1 ARM64 平台的编译注意事项

RK3588 是 ARM64 架构,和 x86 编译有几个明显区别:

  • NEON 指令集:ARM 平台有 NEON SIMD 指令,理论上可以用它加速 IoU 计算,但编译器自动向量化在 NMS 这种有分支的代码上效果有限,手动写 NEON intrinsic 收益不大,我试过之后放弃了
  • 内存对齐:ARM 对未对齐内存访问的惩罚比 x86 重,所以结构体尽量按 4 字节或 8 字节对齐
  • 编译器版本:RK3588 板子上自带的 GCC 版本可能比较老,建议用 GCC 9 以上,对 C++17 支持更好

还有一个容易忽略的点:编译时的 CPU 频率。RK3588 有大小核,编译过程如果跑在小核上会慢很多。可以用taskset把编译进程绑到大核上:

taskset -c 4-7 python setup.py build_ext --inplace

5.2 实测数据与对比

我在 RK3588 上做了一组对比测试,输入是 YOLOv5s 在 640x640 分辨率下的输出,候选框数量通过调整置信度阈值控制在 3000 个左右,IoU 阈值 0.45。

实现方式平均耗时相对加速比
Python + NumPy18.2 ms1x
OpenCV NMSBoxes4.6 ms3.9x
C++ 自实现0.051 ms357x
C++ 自实现(按类别分组)0.038 ms479x

359 倍这个数字是多次测试的平均值,实际会有波动。按类别分组之后还能再快一些,因为每组处理的框数量少了。

需要说明的是,这个加速比是在候选框数量较多的情况下测的。如果只有几十个框,Python 版本也就 1ms 左右,C++ 的优势没那么夸张。所以是否需要上 C++ NMS,取决于你的候选框数量和帧率要求。如果模型输出本身就很少,Python 版本够用就别折腾。

5.3 内存与线程安全

C++ 扩展在多线程环境下调用要注意:如果多个 Python 线程同时调用 NMS,pybind11 默认会持有 GIL,实际上还是串行的。如果确实需要并行,可以在绑定的时候加py::call_guard<py::gil_scoped_release>(),让 C++ 执行期间释放 GIL。但 NMS 本身执行时间极短,释放 GIL 的收益有限,反而增加了复杂度,我一般不加。

内存方面,每次调用都会创建临时的 vector,频繁调用会有内存分配开销。如果对延迟极其敏感,可以考虑用对象池或者预分配缓冲区,但实测下来这点开销在 0.05ms 的总耗时里占比很小,不值得优化。

6. 从 NMS 延伸出去的后处理优化思路

6.1 把整个后处理链路都搬到 C++

NMS 只是后处理的一环。完整的 YOLOv5 后处理还包括:解码预测框(把模型输出的偏移量转成实际坐标)、置信度过滤、类别筛选。这些操作如果都在 Python 里做,累积起来的时间也不少。

我的做法是把整个后处理链路都写成 C++:模型输出直接传给 C++ 函数,函数内部完成解码、过滤、NMS,最后返回最终框。这样 Python 层只负责调用和展示,中间没有任何 Python 循环。整体后处理时间从原来的 25ms 左右降到了 0.1ms 以内。

6.2 量化后的数值精度问题

RKNN 量化模型输出的置信度和坐标都是定点数转过来的,和浮点模型有细微差异。在 C++ 里做 NMS 的时候,IoU 阈值可能需要比浮点模型稍微调一下。我在实际项目里发现,量化模型的框重叠度普遍比浮点模型高一点,IoU 阈值从 0.45 调到 0.5 效果更好。这个没有通用值,得根据你的模型和数据集实测。

6.3 批量处理的考量

如果一次要处理多张图,可以把多张图的候选框拼成一个大数组,一次性传给 C++,在 C++ 内部按 batch 维度分别做 NMS。这样减少了 Python 和 C++ 之间的调用次数,对吞吐量有提升。但要注意每张图的框数量不一样,拼接的时候需要记录每张图的起始索引。

6.4 什么时候不该用 C++ NMS

说了这么多 C++ 的好处,也得说说什么情况下没必要:

  • 候选框少于 100 个:Python 版本也就 1ms 以内,C++ 的优势体现不出来
  • 对帧率要求不高:如果整体帧率只要 10fps,后处理占几毫秒无所谓
  • 开发迭代阶段:C++ 改一次要重新编译,调试也没 Python 方便,原型阶段用 Python 快速验证更合适

我的建议是:先用 Python 版本跑通整条链路,确认功能正确之后,再用 profiler 看后处理占比。如果后处理超过总耗时的 20%,就值得上 C++。否则优化收益有限,还增加了维护成本。

7. 我在这个项目里踩过的几个真实坑

第一个坑是数组内存布局。Python 那边传进来的 numpy 数组如果是经过转置或者切片的,内存不连续,pybind11 的py::array_t会直接抛异常。我一开始没加np.ascontiguousarray,调试的时候一直以为是 C++ 代码的问题,后来才发现是输入数组的锅。现在我的做法是在 Python 包装层统一做一次ascontiguousarray,一劳永逸。

第二个坑是编译优化等级。我一开始用默认的-O2,跑出来加速比只有 200 多倍。改成-O3之后直接跳到 350 倍以上。编译器优化对循环密集代码的影响比想象中大得多,这个一定要确认。

第三个坑是IoU 阈值的一致性。Python 版本和 C++ 版本如果阈值传的不一样,结果会对不上。我在测试的时候发现两边框数量差了几个,排查半天发现是 Python 那边默认 0.45,C++ 那边我写死了 0.5。这种低级错误在跨语言调用的时候特别容易犯,建议把阈值作为参数显式传递,不要在任何一边写默认值。

第四个坑是RK3588 上的浮点性能。RK3588 的 CPU 浮点性能其实一般,如果 NMS 里用了大量除法,可以考虑把除法转成乘以倒数。IoU 计算里的除法可以预先算好每个框面积的倒数,能省一点时间。不过这个优化收益不大,大概 5% 左右,属于锦上添花。

最后分享一个调试技巧:C++ 扩展出问题的时候,Python 那边的报错信息往往很模糊。可以在 C++ 里加fprintf(stderr, ...)打印中间状态,或者用gdb --args python test.py直接调试。虽然麻烦一点,但比瞎猜快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询