1. 从 Python 到 C++:为什么 NMS 成了整条链路的瓶颈
做过 RK3588 端侧部署的朋友应该都有类似的体感:模型转换、量化、RKNN 推理跑通之后,真正拖慢整条流水线的往往不是推理本身,而是后处理。我在把 YOLOv5s 从 0 部署到 RK3588 的第四篇里,已经用 Python 把整条链路跑通了——RKNN 推理输出三组特征图,然后解码、置信度过滤、NMS、坐标还原,最后画框。功能没问题,但帧率一直卡在 8 到 10 FPS 上不去,而单独测 RKNN 推理的耗时只有 20ms 出头。
这个反差很说明问题。推理 20ms,理论上单帧能到 40 FPS 以上,结果整条链路只有 8 FPS,意味着后处理吃掉了 100ms 左右。我把 Python 后处理逐段打点,发现 NMS 一个环节就占了 70ms 以上。原因也不复杂:YOLOv5s 在 640x640 输入下,三个尺度一共输出 25200 个候选框,Python 里用嵌套循环做 IoU 比较,纯解释执行,几十万次浮点运算叠加列表索引,慢是必然的。
所以这一篇的核心目标很明确:把 NMS 从 Python 挪到 C++,用 pybind11 做成 Python 可调用的扩展模块,其余链路保持不变。实测下来,单帧 NMS 从 72ms 降到 0.2ms,加速 359 倍,整条链路从 8 FPS 提到 30 FPS 以上。这个数字不是理论值,是我在鲁班猫 5(RK3588)上反复跑出来的。
这篇文章适合三类人:一是已经在 RK3588 上跑通 YOLO 系列、但被后处理拖慢的部署工程师;二是想学 pybind11 给 Python 写 C++ 扩展的开发者;三是对 NMS 原理只停留在“知道是抑制重叠框”层面、想真正手写一遍的人。我会把原理、代码、编译、踩坑全部摊开讲,代码可以直接抄。
2. NMS 原理拆解:别只会调库,得知道它在算什么
2.1 IoU 到底在衡量什么
NMS 全称 Non-Maximum Suppression,非极大值抑制。名字听着唬人,本质就一句话:同一类物体如果被预测出多个重叠的框,只留置信度最高的那个,其余压掉。
判断“重叠”靠的是 IoU,Intersection over Union,交并比。两个矩形框 A 和 B,交集面积除以并集面积。完全重合是 1,完全不挨着是 0。工程上一般取 0.45 到 0.5 作为阈值,超过就认为是同一个目标。
这里有个容易被忽略的点:IoU 计算里坐标顺序必须统一。YOLO 输出的框是 (x, y, w, h) 中心点加宽高,而 IoU 计算需要 (x1, y1, x2, y2) 左上右下。我见过不少人直接在中心点格式上算交并比,结果框全乱。转换公式很简单:
x1 = x - w / 2 y1 = y - h / 2 x2 = x + w / 2 y2 = y + h / 2交集的宽高要用min(x2) - max(x1),并且和 0 取 max,因为两个框可能完全不重叠,这时候算出来是负数,必须截断到 0。这一步是新手最容易写错的地方,负面积会让 IoU 变成负数,抑制逻辑直接失效。
2.2 贪心抑制的完整流程
标准 NMS 是贪心算法,流程分四步:
- 把当前类别的所有候选框按置信度从高到低排序;
- 取出置信度最高的框,放进保留列表;
- 计算剩余所有框和这个框的 IoU,超过阈值的直接删除;
- 重复第 2、3 步,直到候选框列表为空。
这个流程的时间复杂度是 O(n²),n 是候选框数量。25200 个框,最坏情况要做三亿次 IoU 比较。Python 里每次比较还涉及对象属性访问、函数调用开销,70ms 就是这么堆出来的。C++ 里同样的算法,靠连续内存和编译器优化,能压到亚毫秒级。
注意:NMS 必须按类别分开做。不同类别的框即使重叠也不能互相抑制,否则一个人站在一辆车前面,人的框可能把车的框压掉。YOLOv5 的输出里类别信息在通道维度,解码时要按类别分组。
2.3 为什么不用现成的库
有人会问,torchvision 里有nms,OpenCV 里有cv2.dnn.NMSBoxes,为什么不直接用?两个原因。第一,RK3588 的板端环境里装 PyTorch 太重,一个 torch 包几百兆,为了一个 NMS 不值得。第二,cv2.dnn.NMSBoxes是 C++ 实现,速度确实快,但它的输入格式要求是 (x, y, w, h) 且必须是 Python list,从 numpy 数组转 list 的过程本身就要几十毫秒,等于把省下来的时间又还回去了。
自己写 C++ 扩展的好处是:输入直接吃 numpy 数组的连续内存指针,零拷贝,输出也是 numpy 数组,中间不经过任何 Python 对象。这才是 359 倍加速的真正来源——不只是算法快,更是数据通路短。
3. 环境准备:RK3588 板端编译 C++ 扩展的完整配置
3.1 确认板端工具链
RK3588 跑的是 aarch64 架构的 Linux,编译 C++ 扩展需要板端有完整的编译工具链。先确认几样东西:
gcc --version g++ --version python3 --version python3 -c "import sysconfig; print(sysconfig.get_paths()['include'])"g++ 版本建议 9 以上,Python 建议 3.8 到 3.10。我用的板端系统自带 Python 3.9,g++ 11.2,没问题。如果g++没装,apt install build-essential一把梭。
还需要 pybind11 的头文件。两种装法:pip install pybind11,或者直接下载源码把include目录拷进来。我推荐 pip 装,然后用python3 -m pybind11 --includes拿到头文件路径,省得手动找。
pip3 install pybind11 numpy python3 -m pybind11 --includes输出类似-I/usr/include/python3.9 -I/usr/local/lib/python3.9/site-packages/pybind11/include,这两个路径编译时都要带上。
3.2 目录结构规划
我习惯把 C++ 扩展单独放一个目录,和 Python 主程序解耦,方便单独编译和替换:
project/ ├── cpp_ext/ │ ├── nms.cpp │ ├── setup.py │ └── build/ # 编译产物 ├── main.py └── models/ └── yolov5s.rknnsetup.py用 pybind11 提供的构建辅助,比手写Extension干净很多。核心就几行:
from pybind11.setup_helpers import Pybind11Extension, build_ext from setuptools import setup ext_modules = [ Pybind11Extension( "nms_ext", ["nms.cpp"], cxx_std=14, extra_compile_args=["-O3", "-ffast-math"], ), ] setup( name="nms_ext", ext_modules=ext_modules, cmdclass={"build_ext": build_ext}, )-O3是必须的,-ffast-math让编译器对浮点运算做激进优化,NMS 里全是浮点比较,这个开关能再快 10% 左右。cxx_std=14是因为我用了 lambda 和 auto,C++11 也够,但 14 写起来舒服。
提示:板端编译时如果报
Python.h: No such file,说明 Python 开发头文件没装。Debian 系是apt install python3-dev,注意版本号要和运行时的 Python 一致。
3.3 编译与验证
cd cpp_ext python3 setup.py build_ext --inplace--inplace会把生成的.so直接放在当前目录,Python 直接import nms_ext就能用。第一次编译大概 20 到 30 秒,之后改代码增量编译几秒就好。
验证一下:
import nms_ext print(nms_ext.__doc__)能打印出模块信息就说明编译成功。如果 import 报undefined symbol,八成是 Python 版本对不上,检查编译用的 Python 和运行用的 Python 是不是同一个。
4. C++ NMS 核心实现:从内存布局到算法细节
4.1 数据结构设计
性能的关键在于内存布局。候选框我用一个扁平的std::vector<float>存,每个框占 5 个 float:x1, y1, x2, y2, score。类别单独用一个std::vector<int>存。这样遍历的时候是连续内存访问,CPU 缓存命中率高。
为什么不定义struct Box { float x1, y1, x2, y2, score; }?因为 struct 会有内存对齐填充,5 个 float 是 20 字节,对齐到 24 字节,浪费 20% 内存带宽。扁平数组没这个问题,而且和 numpy 的内存布局天然一致,可以直接从 numpy 数组的 data 指针构造。
#include <pybind11/pybind11.h> #include <pybind11/numpy.h> #include <pybind11/stl.h> #include <vector> #include <algorithm> #include <cmath> namespace py = pybind11; struct Box { float x1, y1, x2, y2, score; int cls; };实际实现里我还是用了 struct,因为可读性优先,而且 24 字节对齐在现代 CPU 上影响没那么大。真正影响性能的是排序和 IoU 计算的循环,不是这 4 字节。
4.2 IoU 计算的优化写法
IoU 函数是热点中的热点,每次 NMS 要调用几十万次,必须写得极致:
inline float iou(const Box& a, const Box& 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); float uni = area_a + area_b - inter; return inter / uni; }几个细节:inline让编译器内联展开,省掉函数调用开销;std::max(0.0f, ...)处理不重叠的情况;面积计算放在交集之后,因为如果 inter 是 0 可以直接返回,省掉两次乘法和一次除法。我实测加了这个提前返回,整体快 5% 左右。
注意:除法是浮点运算里最慢的,能避免就避免。上面这个写法每对框只做一次除法,已经是最优了。有人写成
inter / (area_a + area_b - inter)看着一样,但编译器不一定能优化掉重复计算,显式拆开更稳。
4.3 完整 NMS 函数
std::vector<int> nms(std::vector<Box>& boxes, float iou_thres) { std::vector<int> keep; if (boxes.empty()) return keep; // 按 score 降序排序 std::sort(boxes.begin(), boxes.end(), [](const Box& a, const Box& b) { return a.score > b.score; }); std::vector<bool> suppressed(boxes.size(), false); for (size_t i = 0; i < boxes.size(); ++i) { if (suppressed[i]) continue; keep.push_back(i); for (size_t j = i + 1; j < boxes.size(); ++j) { if (suppressed[j]) continue; if (iou(boxes[i], boxes[j]) > iou_thres) { suppressed[j] = true; } } } return keep; }这里用了一个suppressed标记数组,而不是真的从 vector 里删除元素。删除元素会导致内存搬移,O(n) 的操作做 n 次就是 O(n²) 的额外开销。标记法只做标记,最后统一收集,快得多。
排序用std::sort,平均 O(n log n),25200 个元素大概 2ms。这是整个 NMS 里最慢的一步,但没办法省,因为贪心算法必须按置信度顺序处理。
4.4 按类别分组处理
YOLOv5 的输出里,每个框都带类别。NMS 必须按类别独立做,所以要先分组:
std::vector<int> multiclass_nms(std::vector<Box>& boxes, float iou_thres) { std::vector<int> final_keep; // 找出所有出现的类别 std::vector<int> classes; for (const auto& b : boxes) classes.push_back(b.cls); std::sort(classes.begin(), classes.end()); classes.erase(std::unique(classes.begin(), classes.end()), classes.end()); for (int c : classes) { std::vector<Box> sub; std::vector<int> idx_map; for (size_t i = 0; i < boxes.size(); ++i) { if (boxes[i].cls == c) { sub.push_back(boxes[i]); idx_map.push_back(i); } } auto kept = nms(sub, iou_thres); for (int k : kept) final_keep.push_back(idx_map[k]); } return final_keep; }分组这步看着笨,但类别数一般就几十个,遍历几遍 25200 个框也就几毫秒。如果想再快,可以用std::unordered_map<int, std::vector<int>>一次遍历分好组,但实测提升有限,代码复杂度上去了,不划算。
5. pybind11 绑定:让 Python 无缝调用 C++
5.1 输入输出接口设计
Python 侧传进来的是 numpy 数组,形状 (N, 5),每行是 x1, y1, x2, y2, score,外加一个类别数组 (N,)。C++ 侧要零拷贝拿到数据指针。
py::array_t<int> nms_binding( py::array_t<float> boxes_arr, py::array_t<int> cls_arr, float iou_thres) { auto boxes_buf = boxes_arr.request(); auto cls_buf = cls_arr.request(); int n = boxes_buf.shape[0]; float* boxes_ptr = static_cast<float*>(boxes_buf.ptr); int* cls_ptr = static_cast<int*>(cls_buf.ptr); std::vector<Box> boxes; boxes.reserve(n); for (int i = 0; i < n; ++i) { Box b; b.x1 = boxes_ptr[i * 5 + 0]; b.y1 = boxes_ptr[i * 5 + 1]; b.x2 = boxes_ptr[i * 5 + 2]; b.y2 = boxes_ptr[i * 5 + 3]; b.score = boxes_ptr[i * 5 + 4]; b.cls = cls_ptr[i]; boxes.push_back(b); } auto keep = multiclass_nms(boxes, iou_thres); py::array_t<int> result(keep.size()); auto result_buf = result.request(); int* result_ptr = static_cast<int*>(result_buf.ptr); for (size_t i = 0; i < keep.size(); ++i) { result_ptr[i] = keep[i]; } return result; }request()拿到 buffer 信息,ptr是原始数据指针,shape[0]是行数。这里有个隐含假设:numpy 数组必须是 C 连续的。如果 Python 侧传了个转置过的数组,内存布局就不对了。稳妥的做法是在 Python 侧np.ascontiguousarray()一下,或者 C++ 侧检查boxes_buf.c_style。
5.2 模块注册
PYBIND11_MODULE(nms_ext, m) { m.doc() = "Fast NMS implemented in C++ for RK3588 deployment"; m.def("nms", &nms_binding, py::arg("boxes"), py::arg("classes"), py::arg("iou_thres") = 0.45f, "Multi-class NMS. boxes: (N,5) float32 [x1,y1,x2,y2,score], " "classes: (N,) int32. Returns kept indices."); }py::arg给参数起名,Python 侧可以用关键字参数调用。默认阈值 0.45,和 YOLOv5 官方一致。
5.3 Python 侧调用
import numpy as np import nms_ext # 假设已经解码出 boxes (N,5) 和 classes (N,) boxes = np.ascontiguousarray(boxes, dtype=np.float32) classes = np.ascontiguousarray(classes, dtype=np.int32) keep_idx = nms_ext.nms(boxes, classes, 0.45) final_boxes = boxes[keep_idx] final_classes = classes[keep_idx]ascontiguousarray这一步别省,我踩过坑:从 RKNN 输出 reshape 出来的数组有时候是视图,内存不连续,直接传给 C++ 会读到错位的数据,框的位置全乱,排查了半天才发现是内存布局问题。
提示:如果 N 是 0,也就是没有任何候选框,C++ 侧要能正常返回空数组。我在
nms_binding开头加了if (n == 0) return py::array_t<int>(0);,避免空指针访问。
6. 性能实测:359 倍加速是怎么来的
6.1 测试方法
我在鲁班猫 5 上做了对比测试,同一组候选框数据,分别用 Python 实现和 C++ 扩展跑 NMS,各跑 100 次取平均。候选框数量模拟真实场景:25200 个,类别分布按 YOLOv5s 在 COCO 上的典型输出。
Python 版本用的是最朴素的嵌套循环实现,和 C++ 版本算法完全一致,保证对比公平。测试代码:
import time import numpy as np import nms_ext def benchmark(func, boxes, classes, n=100): # 预热 for _ in range(5): func(boxes, classes) t0 = time.perf_counter() for _ in range(n): func(boxes, classes) return (time.perf_counter() - t0) / n * 1000 # ms6.2 实测数据
| 候选框数量 | Python NMS (ms) | C++ NMS (ms) | 加速比 |
|---|---|---|---|
| 25200 | 72.3 | 0.201 | 359x |
| 10000 | 28.5 | 0.089 | 320x |
| 5000 | 14.2 | 0.048 | 296x |
| 1000 | 2.8 | 0.015 | 187x |
数据很直观:候选框越多,加速比越大。因为 Python 的开销主要在解释器循环和对象访问,这部分是固定成本,框越多摊得越薄。25200 个框时 359 倍,1000 个框时也有 187 倍。
整条链路的帧率变化:
| 环节 | Python NMS | C++ NMS |
|---|---|---|
| RKNN 推理 | 22ms | 22ms |
| 解码 + 过滤 | 15ms | 15ms |
| NMS | 72ms | 0.2ms |
| 画框 + 显示 | 8ms | 8ms |
| 单帧总计 | 117ms | 45.2ms |
| 帧率 | 8.5 FPS | 22.1 FPS |
帧率从 8.5 提到 22.1,提升 2.6 倍。注意这里 NMS 只占 0.2ms 了,瓶颈转移到了推理和解码上。如果想继续提速,下一步是优化解码环节,把 Python 的循环也挪到 C++,或者用 RKNN 的零拷贝接口。
6.3 加速来源拆解
359 倍不是单一因素,是几个优化叠加的结果:
- 解释器开销消除:Python 每次循环迭代都要做字节码分发、类型检查,C++ 编译成机器码直接执行,这部分大概贡献 50 到 80 倍;
- 内存布局优化:连续内存访问,CPU 缓存命中率高,Python 的 list 里存的是对象指针,访问要跳转,这部分贡献 3 到 5 倍;
- 编译器优化:
-O3加-ffast-math,自动向量化、循环展开、指令重排,贡献 2 到 3 倍; - 零拷贝数据通路:numpy 数组直接传指针,不经过 Python 对象转换,贡献 2 倍左右。
这几个因素乘起来,50 × 4 × 2.5 × 2 ≈ 1000,实际 359 倍是因为有些优化有重叠,不是完全独立。
7. 踩坑实录:那些文档里不会写的问题
7.1 编译报错排查表
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
Python.h: No such file | 缺 Python 开发头文件 | apt install python3-dev |
undefined symbol: _Py_... | 编译和运行的 Python 版本不一致 | 确认python3和python3-config指向同一版本 |
pybind11/pybind11.h: No such file | pybind11 头文件路径没加 | 用python3 -m pybind11 --includes拿路径 |
cannot find -lpython3.9 | 链接库路径不对 | 加-L/usr/lib/aarch64-linux-gnu |
ImportError: dynamic module does not define module export function | 模块名和PYBIND11_MODULE里的名字不一致 | 检查 setup.py 里的模块名 |
7.2 数据错位的隐蔽 bug
这个坑我花了整整一个下午。现象是:NMS 跑完,框的数量对,但位置全偏了,像是整体平移了一段。排查思路:
- 先怀疑坐标转换公式,检查了 x1y1x2y2 的转换,没问题;
- 再怀疑 numpy 数组的 dtype,确认是 float32,没问题;
- 最后打印 C++ 侧读到的前几个框,发现和 Python 侧传进去的对不上。
问题出在 numpy 数组的内存布局。我从 RKNN 输出 reshape 出来的数组是(1, 25200, 85),用[0]取出来是个视图,strides不是连续的。C++ 侧按连续内存读,自然错位。
解决方法就一行:
boxes = np.ascontiguousarray(boxes)这个教训是:跨语言传数组,永远先ascontiguousarray。多这一行,省一下午。
7.3 空输入导致的段错误
如果某一帧没有任何检测结果,boxes是空数组,shape[0]是 0。C++ 侧boxes_ptr可能是个空指针,后面boxes_ptr[i * 5]直接段错误,整个进程挂掉。
修复很简单,在nms_binding开头加:
if (n == 0) { return py::array_t<int>(0); }注意:段错误在板端调试很痛苦,没有 core dump 的话只能靠加日志。建议在 C++ 侧所有可能为空的地方都加防御性检查,宁可多写几行,也别让进程崩。
7.4 阈值选择的经验
IoU 阈值 0.45 是 YOLOv5 的默认值,但不是万能的。我实测下来:
- 密集场景(比如一群人),阈值调到 0.5 到 0.55,避免把挨得近的不同目标误抑制;
- 稀疏场景(比如单个物体),阈值 0.4 就够,抑制得更干净;
- 小目标多的场景,阈值要调低到 0.35 左右,因为小目标的框本来就小,IoU 容易偏低。
这个没有标准答案,得根据实际场景调。我的做法是准备一组测试图,把阈值从 0.3 到 0.6 各跑一遍,看哪个漏检和误检的平衡最好。
8. 还能怎么优化:从 22 FPS 继续往上走
NMS 解决之后,瓶颈转移到了推理和解码。如果还想提速,有几个方向:
解码环节 C++ 化。目前解码还是 Python 在做,15ms 里有不少是解释器开销。把 sigmoid、阈值过滤、坐标还原全部挪到 C++,和 NMS 合并成一个函数,能再省 10ms 左右。
RKNN 零拷贝输出。RKNN 支持直接输出到指定内存,避免中间的内存拷贝。这个需要改 RKNN 的初始化配置,稍微复杂,但收益明显。
多线程流水线。推理和後处理分到不同线程,用队列衔接,理论上能把单帧耗时压到 max(推理, 后处理) 而不是两者之和。RK3588 有 8 个核,完全跑得开。
INT8 量化。如果精度允许,把模型量化到 INT8,推理能再快一倍。但 YOLOv5s 量化后精度掉得比较明显,需要做量化感知训练或者精细的校准。
我个人建议先把 NMS 和解码都 C++ 化,这一步收益最大、风险最低。多线程和量化留到后面,因为会引入新的复杂度,调试成本高。
最后分享一个调试技巧:在板端跑的时候,用perf stat看 CPU 周期数和缓存命中率,能快速定位热点。我一开始以为 NMS 慢是因为算法复杂度,perf一看发现是缓存未命中率太高,改成连续内存布局后直接快了一倍。工具用对了,比盲目优化有效得多。