1. 从 Python 后处理瓶颈说起:为什么 NMS 值得单独拎出来优化
在 RK3588 上跑 YOLOv5s,模型推理本身其实没有想象中那么慢。RK3588 的 NPU 算力摆在那里,INT8 量化后的 YOLOv5s 单帧推理通常能压到几十毫秒级别。但很多人部署完之后会发现一个很尴尬的现象:推理快了,整体帧率却上不去。问题往往不出在 NPU 上,而是出在推理之后的那段后处理代码里。
后处理里最耗时的环节,十有八九是NMS(Non-Maximum Suppression,非极大值抑制)。YOLOv5s 在三个尺度上输出预测框,以 640x640 输入为例,三个特征图分别是 80x80、40x40、20x20,每个格子 3 个 anchor,总共是 (80×80 + 40×40 + 20×20) × 3 = 25200 个候选框。经过置信度阈值过滤之后,通常还剩几百到几千个框,这些框都要参与 NMS 的 IoU 计算和排序。
我最初用的是 Python 版本的 NMS,逻辑清晰、写起来快,但实测下来单帧后处理要 30ms 到 80ms 不等,框多的时候甚至超过 100ms。这就导致一个很别扭的局面:NPU 推理 40ms,后处理 80ms,整体帧率被后处理拖到了个位数。把 NMS 换成 C++ 实现之后,同样的输入,后处理时间直接掉到了 0.2ms 左右,加速比达到 359 倍。这个数字不是理论值,是我在同一块板子、同一组测试图片上反复跑出来的。
这篇文章就把这条链路完整讲清楚:Python NMS 到底慢在哪、C++ NMS 怎么写、怎么用 pybind11 把它接回 Python 流程、以及实测中那些容易翻车的细节。适合已经在 RK3588 上跑通了 YOLOv5s 推理、但被后处理卡住帧率的人,也适合想理解 NMS 底层实现和 pybind11 混合编程的读者。
2. Python NMS 到底慢在什么地方
2.1 逐框循环 + Python 解释器开销
先看一段典型的 Python NMS 实现,逻辑上没问题,但性能上问题很大:
def nms_python(boxes, scores, iou_threshold): # boxes: [N, 4], scores: [N] order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) if order.size == 1: break # 计算当前框与剩余所有框的 IoU xx1 = np.maximum(boxes[i, 0], boxes[order[1:], 0]) yy1 = np.maximum(boxes[i, 1], boxes[order[1:], 1]) xx2 = np.minimum(boxes[i, 2], boxes[order[1:], 2]) yy2 = np.minimum(boxes[i, 3], boxes[order[1:], 3]) w = np.maximum(0.0, xx2 - xx1) h = np.maximum(0.0, yy2 - yy1) inter = w * h area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1]) area_rest = (boxes[order[1:], 2] - boxes[order[1:], 0]) * \ (boxes[order[1:], 3] - boxes[order[1:], 1]) iou = inter / (area_i + area_rest - inter) inds = np.where(iou <= iou_threshold)[0] order = order[inds + 1] return keep这段代码看起来用了 NumPy 向量化,比纯 Python 循环快不少,但它有两个致命问题。第一,外层 while 循环是串行的,每轮只处理一个框,框多的时候循环次数就多。第二,每轮循环都在做数组切片和重新分配,order[1:]、np.where、order[inds + 1]这些操作都会产生新的临时数组,内存分配和拷贝的开销累积起来非常可观。
2.2 内存分配与缓存不友好的代价
Python 的 NumPy 数组底层虽然是连续的 C 内存,但每次切片、np.maximum、np.where都会新建数组。假设有 3000 个候选框,NMS 平均要循环几百轮,每轮分配好几个临时数组,总的内存分配次数就是几千次。在 RK3588 这种 ARM 平台上,内存带宽和分配器性能都不如桌面 x86,这些开销会被放大。
更关键的是缓存局部性。Python 版本每轮只访问一个框boxes[i],然后和剩余所有框比较,访问模式是跳跃的。而 C++ 版本可以把框按分数排好序之后,用连续内存顺序访问,CPU 缓存命中率高得多。这一点在候选框数量大的时候差异特别明显。
2.3 GIL 与单线程限制
Python 有全局解释器锁(GIL),纯 Python 代码的多线程没法真正并行。虽然后处理本身可以设计成多线程,但在 Python 层面做这件事收益很低。C++ 实现天然没有这个限制,后续如果想进一步并行化(比如按类别分组并行 NMS),扩展起来也方便。
我实测过一组数据,用同一张 640x640 的图片,候选框数量约 2500 个,置信度阈值 0.25,IoU 阈值 0.45:
| 实现方式 | 单帧后处理耗时 | 相对加速比 |
|---|---|---|
| Python NumPy NMS | 约 72 ms | 1x |
| C++ NMS(单线程) | 约 0.20 ms | 约 360x |
| C++ NMS(多线程,4 核) | 约 0.08 ms | 约 900x |
这个表格里的数字是多次测量取中位数的结果,不同图片、不同阈值会有波动,但量级差异是稳定的。359 倍这个数字就是来自 Python 72ms 对 C++ 0.2ms 的对比。
3. C++ NMS 的实现思路与关键取舍
3.1 数据结构选型:为什么用 vector 而不是链表
C++ NMS 的核心数据结构很简单:一个std::vector<cv::Rect2f>或者自定义的struct Box { float x1, y1, x2, y2, score; int label; }。用std::vector而不是链表,原因是 NMS 需要频繁的随机访问和排序,vector 的连续内存布局对缓存友好,排序也快。
实际实现里我推荐把框和分数打包成一个结构体,排序的时候直接按 score 排,避免维护两个平行数组:
struct Box { float x1, y1, x2, y2; float score; int label; }; // 按分数降序排序 std::sort(boxes.begin(), boxes.end(), [](const Box& a, const Box& b) { return a.score > b.score; });这里有个细节:排序是 NMS 里除了 IoU 计算之外的第二大开销。std::sort平均复杂度 O(N log N),2500 个框排序大概几十微秒,可以接受。如果候选框数量特别大(比如上万),可以考虑用基数排序或者部分排序,但一般 YOLOv5s 的场景用std::sort就够了。
3.2 IoU 计算的向量化与提前终止
IoU 计算是 NMS 的热点。每个保留框都要和剩余框算 IoU,朴素实现是双重循环 O(N²)。优化手段有几个:
第一,按分数排序后,一旦遇到 IoU 超过阈值的框就把它从候选集里剔除,这样候选集每轮都在缩小,实际复杂度远低于 N²。
第二,用提前终止。如果当前框和某个候选框的 x 方向或 y 方向完全不重叠,可以直接跳过面积计算。判断两个矩形是否相交只需要四次比较:
inline bool overlap(float x1, float y1, float x2, float y2, float x1_, float y1_, float x2_, float y2_) { return (x1 < x2_ && x2 > x1_ && y1 < y2_ && y2 > y1_); }第三,用 SIMD 指令批量计算。RK3588 是 ARM Cortex-A76/A55 架构,支持 NEON 指令集。如果候选框数量很大,可以用 NEON 一次算 4 个框的 IoU。不过实测下来,对于 YOLOv5s 这种量级,标量代码加上提前终止已经足够快,NEON 优化带来的额外收益有限,反而增加代码复杂度。我的建议是先把标量版本写对、写稳,再考虑 SIMD。
3.3 按类别分组 NMS 的处理
YOLOv5s 是多类别检测,NMS 必须按类别独立进行。如果所有类别的框混在一起做 NMS,不同类别的框会互相抑制,导致漏检。正确做法是先按 label 分组,每组单独 NMS,最后合并结果。
分组的方式有两种:一种是用std::unordered_map<int, std::vector<Box>>按 label 分桶;另一种是先按 label 排序,然后顺序处理每个类别段。前者代码直观,后者缓存更友好。我实际用的是后者,因为排序一次就能把同类别框聚在一起,后续处理都是连续内存访问。
std::sort(boxes.begin(), boxes.end(), [](const Box& a, const Box& b) { if (a.label != b.label) return a.label < b.label; return a.score > b.score; }); std::vector<Box> result; size_t start = 0; while (start < boxes.size()) { size_t end = start; while (end < boxes.size() && boxes[end].label == boxes[start].label) end++; // 对 [start, end) 这一段做 NMS nms_single_class(boxes, start, end, iou_threshold, result); start = end; }这个写法比 map 分桶少了一次内存分配,实测在 2500 框规模下能省下十几微秒。
4. pybind11 把 C++ NMS 接回 Python 流程
4.1 为什么选 pybind11 而不是 ctypes 或 Cython
把 C++ 函数暴露给 Python,常见方案有 ctypes、Cython、SWIG、pybind11。我选 pybind11 的理由很直接:
- ctypes需要手写 C 接口,类型转换麻烦,传 NumPy 数组要手动处理指针和 shape,容易出错。
- Cython需要写 .pyx 文件,编译流程多一层,调试不如纯 C++ 直观。
- pybind11是 header-only 的,直接 include 就能用,对 NumPy 数组的支持通过
pybind11/numpy.h开箱即用,类型转换自动完成。
对于 NMS 这种输入是 NumPy 数组、输出是索引列表的场景,pybind11 的py::array_t<float>用起来非常顺手。
4.2 接口设计:输入输出怎么定
接口设计要考虑两点:一是 Python 侧调用方便,二是 C++ 侧解析高效。我最终的接口是这样的:
#include <pybind11/pybind11.h> #include <pybind11/numpy.h> #include <pybind11/stl.h> namespace py = pybind11; std::vector<int> nms(py::array_t<float> boxes, py::array_t<float> scores, py::array_t<int> labels, float iou_threshold, float score_threshold) { auto boxes_buf = boxes.request(); auto scores_buf = scores.request(); auto labels_buf = labels.request(); // ... 解析、过滤、排序、NMS ... return keep_indices; } PYBIND11_MODULE(nms_cpp, m) { m.doc() = "C++ NMS for YOLOv5s on RK3588"; m.def("nms", &nms, "Non-Maximum Suppression", py::arg("boxes"), py::arg("scores"), py::arg("labels"), py::arg("iou_threshold") = 0.45f, py::arg("score_threshold") = 0.25f); }输入用三个独立的 NumPy 数组(boxes、scores、labels),而不是一个打包的结构体数组。原因是 Python 侧从模型输出解析出来的就是分开的数组,打包反而多一步。输出是std::vector<int>,pybind11 会自动转成 Python list。
这里有个性能细节:py::array_t默认会做类型检查和 shape 检查,如果确定输入格式正确,可以用py::array_t<float, py::array::c_style | py::array::forcecast>来减少检查开销。不过对于 NMS 这种调用频率(每帧一次),这点开销可以忽略。
4.3 编译配置与 RK3588 上的注意事项
编译用 CMake 最省事,关键是找到 pybind11 和 Python 的头文件路径。在 RK3588 的 Ubuntu 系统上,Python 通常是 3.8 或 3.10,用python3-config --includes能拿到正确的 include 路径。
cmake_minimum_required(VERSION 3.10) project(nms_cpp) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(pybind11 REQUIRED) find_package(Python3 COMPONENTS Development REQUIRED) pybind11_add_module(nms_cpp nms.cpp) target_compile_options(nms_cpp PRIVATE -O3 -march=native)-O3是必须的,-march=native让编译器针对 RK3588 的 Cortex-A76 生成优化指令。实测下来,开-O3比-O2在 NMS 这种循环密集的代码上能快 20% 到 30%。
注意:如果编译时报
Python.h not found,检查python3-dev是否安装。RK3588 的板子有些精简系统默认不带开发头文件,需要apt install python3-dev。
编译完之后会生成nms_cpp.cpython-38-aarch64-linux-gnu.so这样的文件,直接放在 Python 脚本同目录下import nms_cpp就能用。
5. 实测数据与踩坑记录
5.1 加速比是怎么测出来的
测试环境:RK3588 开发板,Ubuntu 20.04,Python 3.8,YOLOv5s INT8 RKNN 模型,输入 640x640。测试方法是用同一组 100 张图片,分别跑 Python NMS 和 C++ NMS,记录后处理阶段的耗时,取平均值。
测试代码的核心逻辑:
import time import nms_cpp # 假设 boxes, scores, labels 已经从模型输出解析好 t0 = time.perf_counter() keep = nms_cpp.nms(boxes, scores, labels, 0.45, 0.25) t1 = time.perf_counter() print(f"C++ NMS: {(t1 - t0) * 1000:.3f} ms")Python 版本用同样的输入,同样的阈值。为了避免单次测量误差,每张图跑 10 次取中位数。
结果汇总:
| 指标 | Python NMS | C++ NMS |
|---|---|---|
| 平均耗时 | 72.3 ms | 0.201 ms |
| 最大耗时 | 118.5 ms | 0.312 ms |
| 最小耗时 | 41.2 ms | 0.156 ms |
| 加速比 | 1x | 359x |
Python 版本耗时波动很大,因为 NumPy 的临时数组分配受内存状态影响。C++ 版本非常稳定,基本在 0.2ms 上下浮动。
5.2 踩过的坑:数组内存布局与 dtype
第一个坑是NumPy 数组的内存布局。从 RKNN 模型输出解析出来的数组,有时候是 C 连续(row-major),有时候是 Fortran 连续(column-major),甚至可能是非连续的切片。pybind11 的py::array_t默认要求 C 连续,如果传进去的是非连续数组,会抛异常或者静默拷贝。
解决办法是在 Python 侧显式转换:
boxes = np.ascontiguousarray(boxes, dtype=np.float32) scores = np.ascontiguousarray(scores, dtype=np.float32) labels = np.ascontiguousarray(labels, dtype=np.int32)np.ascontiguousarray保证内存连续,dtype保证类型匹配。这一步看起来多余,但不做的话在 RK3588 上会随机崩溃,而且崩溃位置不固定,非常难查。
第二个坑是dtype 不匹配。RKNN 输出的分数有时候是 float16,有时候是 float32,如果 C++ 侧声明的是py::array_t<float>而实际传的是 float16,pybind11 会尝试转换,转换失败就报错。我的做法是在 Python 侧统一转成 float32,C++ 侧只接受 float32。
5.3 踩过的坑:阈值边界与空输入
第三个坑是空输入。如果某一帧没有任何框超过置信度阈值,boxes数组的 shape 是(0, 4),这时候 C++ 侧的排序和循环要能正确处理空数组。我最初的实现里boxes[0]直接越界,导致段错误。修复方法是在函数开头加判断:
if (boxes_buf.shape[0] == 0) return {};第四个坑是IoU 阈值的边界条件。当两个框完全重合时,IoU 是 1.0,如果阈值设成 1.0,iou > threshold为 false,两个框都会保留。实际使用中阈值一般设 0.45 到 0.5,不会碰到这个边界。但如果做单元测试,要覆盖 IoU 等于阈值的情况,判断条件用>还是>=会影响结果。我统一用>,即 IoU 严格大于阈值才抑制。
5.4 踩过的坑:多线程与线程安全
如果后续想把 NMS 放到多线程里跑(比如多个摄像头流并行),要注意 C++ 函数本身是线程安全的,因为它不依赖任何全局状态,所有数据都在栈上或参数里。但 pybind11 的模块初始化是单次的,多线程调用同一个函数没问题。
不过有个细节:Python 的 GIL 在调用 C++ 函数时默认是持有的。如果 C++ 函数执行时间很长,会阻塞其他 Python 线程。对于 NMS 这种 0.2ms 的函数,无所谓。但如果以后扩展成更复杂的后处理,可以在 pybind11 绑定里加py::call_guard<py::gil_scoped_release>()来释放 GIL:
m.def("nms", &nms, py::call_guard<py::gil_scoped_release>());加了之后 C++ 执行期间不持有 GIL,其他 Python 线程可以继续跑。但要注意,释放 GIL 后不能再操作 Python 对象,所以这个函数里不能碰py::array_t之外的东西。
6. 从 NMS 延伸出去的优化空间
6.1 把后处理整体搬到 C++
NMS 只是后处理的一环。完整的 YOLOv5s 后处理还包括:从模型输出解码边界框(anchor 解码)、sigmoid 激活、置信度过滤、坐标缩放。这些步骤如果都在 Python 里做,累积开销也不小。我后来的做法是把整个后处理链路都写成 C++,Python 只负责调用和拿结果。
这样做的好处是减少 Python 和 C++ 之间的数据往返。如果只把 NMS 放 C++,那解码和过滤还在 Python,中间还是要传大数组。整体搬过去之后,Python 侧只需要传原始模型输出,C++ 侧一次性完成所有处理,返回最终框。
6.2 与 RKNN 推理流程的衔接
RKNN 的 Python API 返回的输出是 NumPy 数组,直接喂给 C++ 后处理函数即可。如果用的是 RKNN 的 C API,那整个流程都在 C++ 里,连 pybind11 都不需要。但对于快速原型开发,Python + pybind11 的组合更灵活,调试也方便。
实际部署时,如果追求极致性能,可以把推理和后处理都放在 C++ 里,Python 只做业务逻辑。如果追求开发效率,Python 调 C++ 后处理已经能拿到 359 倍的加速,性价比很高。
6.3 进一步压榨:NEON 与多线程
前面提到 NEON 优化收益有限,但在候选框数量特别大(比如 10000 以上)的场景下,NEON 批量计算 IoU 还是有意义的。另外,如果一帧里有多个类别,可以按类别分组后用多线程并行 NMS,RK3588 有 8 个核,理论上能再快几倍。
不过要注意,多线程的收益受限于候选框数量和核间通信开销。2500 个框分到 4 个线程,每个线程处理 600 多个框,线程创建和同步的开销可能吃掉一部分收益。实测 4 线程比单线程快 2 到 3 倍,没有达到理想的 4 倍,就是因为开销。
我的建议是:先用单线程 C++ 版本,确认瓶颈确实在 NMS 上,再考虑多线程。很多时候单线程 C++ 已经把后处理压到 0.2ms,相对于推理的 40ms 已经可以忽略,没必要过度优化。
7. 一些实际部署中的经验
在 RK3588 上做这套东西,有几个经验值得单独说。
第一,编译产物要跟 Python 版本匹配。RK3588 的板子上可能同时装了 Python 3.8 和 3.10,pybind11 编译出来的 .so 文件名里带版本号,import的时候要确保用的是对应版本的解释器。我踩过一次坑,用 3.8 编译的模块在 3.10 里 import 报错,查了半天才发现是版本问题。
第二,交叉编译 vs 板端编译。如果是在 x86 主机上交叉编译,要确保 CMake 里的 Python 路径指向目标板的 Python,而不是主机的。我一般直接在板子上编译,虽然慢一点,但省去了交叉编译工具链配置的麻烦。RK3588 的板子编译一个几百行的 C++ 文件也就十几秒。
第三,内存对齐。RK3588 的 NPU 输出有时候是 16 字节对齐的,如果 C++ 侧按普通数组处理,可能触发未对齐访问。虽然 ARM 一般能处理未对齐访问,但性能会下降。用py::array_t的时候,pybind11 会保证对齐,不用额外操心。
第四,测试要覆盖边界情况。空输入、单框、所有框 IoU 都超过阈值、所有框 IoU 都不超过阈值,这些情况都要测。我写了一个小的测试脚本,构造各种极端输入,确保 C++ 函数不崩溃、结果正确。
import numpy as np import nms_cpp # 空输入 assert nms_cpp.nms(np.zeros((0, 4), np.float32), np.zeros((0,), np.float32), np.zeros((0,), np.int32), 0.45, 0.25) == [] # 单框 boxes = np.array([[0, 0, 10, 10]], np.float32) scores = np.array([0.9], np.float32) labels = np.array([0], np.int32) assert nms_cpp.nms(boxes, scores, labels, 0.45, 0.25) == [0] # 完全重合的两个框 boxes = np.array([[0, 0, 10, 10], [0, 0, 10, 10]], np.float32) scores = np.array([0.9, 0.8], np.float32) labels = np.array([0, 0], np.int32) assert nms_cpp.nms(boxes, scores, labels, 0.45, 0.25) == [0]这几个测试跑通,基本能保证函数在各种输入下行为正确。
最后说一个我自己的体会:性能优化要先测量再动手。我一开始以为瓶颈在 NPU 推理,花了很多时间调模型量化,结果发现后处理才是大头。用time.perf_counter()把推理和后处理分开计时,一眼就能看出问题在哪。C++ NMS 这个优化,从动手到跑通大概花了一个下午,但带来的帧率提升是立竿见影的。如果你也在 RK3588 上跑 YOLOv5s,并且被帧率卡住,先看看后处理耗时,很可能就是 NMS 在拖后腿。