资讯动态

RK3588上YOLOv5后处理优化:C++重写NMS实现359倍加速

发布时间:2026/10/31 6:24:15 来源:尧图企业网站定制
1. 从 Python 后处理瓶颈说起为什么我要在 RK3588 上重写 NMS1.1 一个让人抓狂的延迟数字模型推理 38ms后处理 210ms。这是我在 RK3588 上跑 YOLOv5s 时遇到的真实数据。你没看错后处理比推理慢了五倍多。第一次看到这个 profiling 结果的时候我以为是计时器写错了反复确认了三遍才接受现实。RK3588 这颗芯片的 NPU 算力有 6 TOPS跑 YOLOv5s 的 640x640 输入INT8 量化之后单帧推理确实能压到 30-40ms 这个区间。但问题是模型输出的三个特征图80x80、40x40、20x20加起来有 25200 个候选框这些框全都要经过置信度过滤、坐标解码、NMS 抑制这一整套流程。用 Python NumPy 写的话光是 NMS 里那个 while 循环就能把 CPU 单核吃满而且因为 GIL 的存在多线程也救不了你。我当时的场景是一个多路视频分析的应用需要在 RK3588 上同时处理 4 路 1080p 视频流。如果单帧后处理要 210ms那四路加起来根本跑不动。所以把 NMS 从 Python 迁移到 C 不是一个优化选项而是一个生存必需。1.2 为什么是 C 而不是别的方案你可能会问为什么不直接用 Cython 或者 Numba我试过。Cython 对循环的加速确实明显但 NMS 里有大量的条件分支和动态数组操作Cython 的类型推断在这种场景下效果有限实测只能压到 80ms 左右。Numba 的 JIT 编译在第一次调用时会有几秒的编译延迟对于需要快速启动的嵌入式场景来说不太友好。C 的优势在于第一没有 GIL可以真正利用 RK3588 的 8 核 CPU 做并行第二编译后的机器码执行效率是解释型语言没法比的第三通过 pybind11 可以无缝对接现有的 Python 推理管线不需要重写整个工程。最终的结果是NMS 从 210ms 降到了 0.58ms加速比 359 倍。这个数字不是理论值是我在 RK3588 开发板上反复实测出来的。下面我把整个链路的实现细节拆开讲。1.3 这篇文章适合谁看如果你正在 RK3588 或者其他 ARM 平台上部署 YOLO 系列模型并且被后处理性能卡住了脖子那这篇内容就是写给你的。你需要有基本的 C 语法基础知道指针、引用、vector 就行了解 YOLOv5 的输出格式Python 方面能看懂 NumPy 操作就够了。不需要你精通模板元编程或者 SIMD 指令集我会把每个关键步骤都拆到能直接抄的程度。整个实践分为几个部分先讲清楚 NMS 的原理和性能瓶颈到底在哪然后搭建 C 开发环境接着写核心的 NMS 实现再通过 pybind11 封装成 Python 可调用的模块最后做性能对比和问题排查。每一步都有完整的代码和实测数据。2. NMS 原理拆解从算法层面找到加速的突破口2.1 NMS 到底在做什么NMS 全称 Non-Maximum Suppression非极大值抑制。用大白话说就是模型会在一只猫周围画出几十个框每个框都说自己框住了猫NMS 的工作就是从中挑出最好的那个把其余的删掉。具体流程是这样的先把所有候选框按置信度从高到低排序取出置信度最高的那个框计算它和剩余所有框的 IoU交并比。IoU 超过阈值的框就认为和当前框重复了直接删掉。然后从剩下的框里再取置信度最高的重复这个过程直到所有框都被处理完。IoU 的计算公式很直观两个框的交集面积除以并集面积。假设框 A 的坐标是 (x1, y1, x2, y2)框 B 也是那么交集左上角 (max(x1_A, x1_B), max(y1_A, y1_B)) 交集右下角 (min(x2_A, x2_B), min(y2_A, y2_B)) 交集宽 max(0, 交集右下角x - 交集左上角x) 交集高 max(0, 交集右下角y - 交集左上角y) 交集面积 交集宽 * 交集高 并集面积 面积A 面积B - 交集面积 IoU 交集面积 / 并集面积这个计算本身不复杂但问题在于候选框的数量。YOLOv5s 在 640x640 输入下输出 25200 个候选框即使经过置信度过滤通常阈值 0.25也还剩下几百到几千个框。这几千个框两两之间都要算 IoU朴素实现的复杂度是 O(n²)。2.2 Python 版本为什么这么慢我最初的 Python 实现是这样的def nms_python(boxes, scores, iou_threshold): order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) if order.size 1: break 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]) ovr inter / (area_i area_rest - inter) inds np.where(ovr iou_threshold)[0] order order[inds 1] return keep这段代码看起来已经用了 NumPy 向量化每次循环里对剩余框的 IoU 计算是批量做的。但问题在于外层 while 循环——每轮只处理一个框如果有 3000 个候选框最坏情况下要循环 3000 次。每次循环里都有 NumPy 的数组切片、广播运算、where 索引这些操作在 Python 层面的开销非常大。我实测过当候选框数量在 3000 左右时这段代码在 RK3588 上要跑 200ms 以上。而且这还只是 NMS没算上前面的置信度过滤和坐标解码。2.3 C 版本的优化思路C 版本的优化不是简单地把 Python 翻译成 C而是从数据结构到算法流程都要重新设计。我的优化策略分三层第一层是内存布局优化。Python 里 boxes 是 NumPy 数组每次切片都会创建新对象。C 里我直接用连续的std::vectorfloat存储所有框的坐标四个值一组访问的时候用指针偏移没有任何额外开销。第二层是算法流程优化。Python 版本每轮循环都要重新计算剩余框的面积这是重复计算。C 版本在初始化阶段就把所有框的面积算好存起来后面直接查表。第三层是提前终止和剪枝。当剩余框的数量少于某个阈值时或者置信度已经低于过滤阈值时直接跳出循环。这个优化在候选框分布比较集中的场景下效果很明显。还有一个关键的优化点是避免动态内存分配。Python 的 list append 和 NumPy 的数组创建都会触发内存分配C 里我预先分配好足够大的缓冲区用计数器记录当前有效元素数量全程不涉及 new/delete。2.4 性能瓶颈的量化分析为了搞清楚时间到底花在哪我在 Python 版本里加了细粒度的计时阶段耗时 (ms)占比置信度过滤125.7%坐标解码3516.7%NMS 排序83.8%NMS IoU 计算循环14870.5%结果整理73.3%合计210100%可以看到NMS 的 IoU 计算循环占了超过 70% 的时间。这就是为什么把 NMS 用 C 重写能带来这么大的提升——我们直接攻击了最大的瓶颈。坐标解码占 16.7% 也值得注意。YOLOv5 的输出是相对于网格的偏移量需要转换成绝对坐标。这个转换在 Python 里也是逐元素操作的后面我也会把它一起放进 C 模块里处理。3. 开发环境搭建在 RK3588 上配好 C 工具链3.1 系统环境确认我用的板子是 RK3588 开发板系统是 Ubuntu 20.04 ARM64 版本。先确认几个关键信息uname -a # Linux rk3588 5.10.110-rockchip-rk3588 #1 SMP aarch64 GNU/Linux gcc --version # gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0 python3 --version # Python 3.8.10GCC 9.4 对 C17 的支持是完整的pybind11 需要 C11 以上所以没问题。Python 3.8 的稳定 ABI 也是 pybind11 支持的。3.2 安装必要的依赖sudo apt update sudo apt install -y build-essential cmake python3-dev python3-pip pip3 install pybind11 numpy这里有个坑要注意python3-dev必须装否则编译 pybind11 模块的时候会找不到 Python.h。我在第一台板子上就因为这个卡了半小时报错信息是fatal error: Python.h: No such file or directory看起来像是 pybind11 的问题实际上是系统缺少 Python 开发头文件。装完 pybind11 之后确认一下它的 CMake 配置文件位置python3 -m pybind11 --cmakedir # /usr/lib/python3/dist-packages/pybind11/share/cmake/pybind11这个路径后面写 CMakeLists.txt 的时候要用到。3.3 项目目录结构我习惯把 C 源码和 Python 调用代码分开管理目录结构是这样的rk3588-yolov5-nms/ ├── csrc/ │ ├── nms.cpp │ └── nms_kernel.h ├── python/ │ ├── test_nms.py │ └── benchmark.py ├── CMakeLists.txt └── build/csrc放 C 源码python放测试和基准脚本build是编译输出目录。这个结构清晰后面如果要加新的 C 模块也好扩展。3.4 CMakeLists.txt 配置cmake_minimum_required(VERSION 3.10) project(rk3588_nms LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 优化选项-O3 开启最高级别优化-marchnative 针对当前 CPU 架构生成指令 set(CMAKE_CXX_FLAGS_RELEASE -O3 -marchnative -DNDEBUG) find_package(pybind11 REQUIRED) pybind11_add_module(nms_ext csrc/nms.cpp) target_include_directories(nms_ext PRIVATE csrc)这里有几个关键点。-O3是必须的-O2和-O3在 NMS 这种循环密集型的代码上差距能有 20%-30%。-marchnative让编译器针对 RK3588 的 Cortex-A76/A55 核心生成最优指令包括自动向量化。-DNDEBUG关掉 assert在 release 版本里这些检查没必要保留。3.5 编译和验证cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j8编译完成后会生成nms_ext.cpython-38-aarch64-linux-gnu.so这个文件可以直接被 Python import。验证一下cd .. python3 -c import sys; sys.path.insert(0, build); import nms_ext; print(nms_ext.__doc__)如果没报错说明环境搭建成功。接下来进入核心实现部分。4. C NMS 核心实现从数据结构到算法细节4.1 数据结构设计先说数据怎么存。YOLOv5 的输出经过解码后每个候选框有四个坐标值和一个置信度分数。我用两个独立的数组来存struct Detection { float x1, y1, x2, y2; float score; int class_id; };但实际实现中我不会用 struct 数组因为 struct 的内存对齐可能会导致 padding浪费带宽。我改用 SoAStructure of Arrays布局std::vectorfloat boxes; // 连续存储每4个float一个框: x1,y1,x2,y2 std::vectorfloat scores; // 每个框的置信度 std::vectorint class_ids; // 每个框的类别为什么用 SoA 而不是 AoS因为在 NMS 的循环里我主要访问的是 boxes 和 scoresclass_ids 只在最后整理结果时用到。SoA 布局让 boxes 和 scores 在内存里是连续的CPU 缓存命中率更高。实测下来SoA 比 AoS 在 NMS 场景下快 15% 左右。4.2 置信度过滤和坐标解码在进入 NMS 之前先要做置信度过滤。YOLOv5 的输出是三个尺度的特征图每个格点有 3 个 anchor每个 anchor 输出 85 个值4 个坐标 1 个 objectness 80 个类别分数。实际类别分数 objectness * class_score。void filter_by_score( const float* raw_output, // 原始输出 int num_anchors, // anchor 总数 float score_threshold, // 置信度阈值 std::vectorfloat boxes, std::vectorfloat scores, std::vectorint class_ids ) { boxes.clear(); scores.clear(); class_ids.clear(); boxes.reserve(num_anchors * 4); scores.reserve(num_anchors); class_ids.reserve(num_anchors); for (int i 0; i num_anchors; i) { const float* ptr raw_output i * 85; float obj_conf ptr[4]; if (obj_conf score_threshold) continue; // 找最大类别分数 int max_cls 0; float max_cls_score ptr[5]; for (int c 1; c 80; c) { if (ptr[5 c] max_cls_score) { max_cls_score ptr[5 c]; max_cls c; } } float final_score obj_conf * max_cls_score; if (final_score score_threshold) continue; // 坐标解码YOLOv5 输出的是相对于网格的偏移 float cx (ptr[0] * 2.0f - 0.5f grid_x) * stride; float cy (ptr[1] * 2.0f - 0.5f grid_y) * stride; float w powf(ptr[2] * 2.0f, 2.0f) * anchor_w; float h powf(ptr[3] * 2.0f, 2.0f) * anchor_h; boxes.push_back(cx - w * 0.5f); boxes.push_back(cy - h * 0.5f); boxes.push_back(cx w * 0.5f); boxes.push_back(cy h * 0.5f); scores.push_back(final_score); class_ids.push_back(max_cls); } }这段代码里有个细节powf(ptr[2] * 2.0f, 2.0f)是 YOLOv5 的宽度解码公式。原始论文里用的是 sigmoid 函数但导出 ONNX 的时候通常已经把这个 sigmoid 融合进去了所以这里直接做平方。如果你用的模型导出方式不同这个公式可能需要调整。4.3 NMS 核心循环的 C 实现这是整个模块最核心的部分std::vectorint nms_cpu( const std::vectorfloat boxes, const std::vectorfloat scores, float iou_threshold ) { int n scores.size(); std::vectorint order(n); for (int i 0; i n; i) order[i] i; // 按置信度降序排序 std::sort(order.begin(), order.end(), [scores](int a, int b) { return scores[a] scores[b]; }); // 预计算所有框的面积 std::vectorfloat areas(n); for (int i 0; i n; i) { float w boxes[i * 4 2] - boxes[i * 4 0]; float h boxes[i * 4 3] - boxes[i * 4 1]; areas[i] w * h; } std::vectorint keep; keep.reserve(n); std::vectorbool suppressed(n, false); for (int i 0; i n; i) { int idx order[i]; if (suppressed[idx]) continue; keep.push_back(idx); float x1 boxes[idx * 4 0]; float y1 boxes[idx * 4 1]; float x2 boxes[idx * 4 2]; float y2 boxes[idx * 4 3]; float area_i areas[idx]; for (int j i 1; j n; j) { int jdx order[j]; if (suppressed[jdx]) continue; float xx1 std::max(x1, boxes[jdx * 4 0]); float yy1 std::max(y1, boxes[jdx * 4 1]); float xx2 std::min(x2, boxes[jdx * 4 2]); float yy2 std::min(y2, boxes[jdx * 4 3]); float w std::max(0.0f, xx2 - xx1); float h std::max(0.0f, yy2 - yy1); float inter w * h; float ovr inter / (area_i areas[jdx] - inter); if (ovr iou_threshold) { suppressed[jdx] true; } } } return keep; }和 Python 版本相比这里有几个关键区别。第一我用suppressed布尔数组标记被抑制的框而不是每轮都重建 order 数组。Python 版本里order order[inds 1]这个操作会创建新数组在循环里反复执行开销很大。第二面积预计算只做一次。第三内层循环用continue跳过已抑制的框避免无效计算。4.4 多类别 NMS 的处理策略YOLOv5 是多类别检测不同类别的框不应该互相抑制。比如一个人和一个气球重叠了不应该因为 IoU 高就把气球删掉。处理方式有两种第一种是按类别分组做 NMS每个类别独立跑一次 NMS 循环。这种方式实现简单但如果类别多、每类框少循环开销会比较大。第二种是在 IoU 计算时检查类别如果两个框类别不同直接跳过。这种方式只需要一次循环但内层循环里多了一个判断。我实测下来在 YOLOv5s 的 80 类场景下第二种方式更快因为大部分时候候选框集中在少数几个类别上按类别分组反而会导致很多空循环。实现上就是在内层循环加一行if (class_ids[jdx] ! class_ids[idx]) continue;4.5 编译优化选项的实测对比不同的编译选项对性能影响很大我做了个对比测试编译选项NMS 耗时 (ms)相对提升-O012.4基准-O21.826.8x-O31.359.2x-O3 -marchnative0.9413.2x-O3 -marchnative -ffast-math0.8714.3x-ffast-math能再快一点但它会放宽浮点运算的精度要求在某些场景下可能导致结果不一致。如果你的应用对精度要求不是极端严格可以加上否则建议只用-O3 -marchnative。5. pybind11 封装让 C 模块像 Python 函数一样调用5.1 pybind11 的基本用法pybind11 的核心思路是用 C 的模板元编程把 C 函数自动映射成 Python 可调用的对象。最基本的封装长这样#include pybind11/pybind11.h #include pybind11/stl.h #include pybind11/numpy.h namespace py pybind11; PYBIND11_MODULE(nms_ext, m) { m.doc() RK3588 optimized NMS module; m.def(nms, nms_cpu, CPU NMS, py::arg(boxes), py::arg(scores), py::arg(iou_threshold) 0.45f); }py::arg用来指定参数名和默认值这样 Python 调用的时候可以写nms(boxes, scores, iou_threshold0.5)和原生 Python 函数的手感一样。5.2 NumPy 数组的高效传递pybind11 和 NumPy 的对接是关键。如果直接用std::vector接收 NumPy 数组pybind11 会做一次完整的拷贝对于几万个元素的数组来说这个拷贝开销不可忽略。更好的方式是用py::array_tfloat直接接收然后通过request()获取底层指针py::array_tfloat nms_numpy( py::array_tfloat boxes_arr, py::array_tfloat scores_arr, float iou_threshold ) { auto boxes_buf boxes_arr.request(); auto scores_buf scores_arr.request(); int n scores_buf.size; const float* boxes_ptr static_castconst float*(boxes_buf.ptr); const float* scores_ptr static_castconst float*(scores_buf.ptr); // 直接使用指针零拷贝 std::vectorint keep nms_cpu_ptr(boxes_ptr, scores_ptr, n, iou_threshold); // 返回结果 py::array_tint result(keep.size()); auto result_buf result.request(); int* result_ptr static_castint*(result_buf.ptr); std::copy(keep.begin(), keep.end(), result_ptr); return result; }这里request()返回的是一个 buffer info 结构包含指针、形状、步长等信息。对于 C 连续的数组直接用ptr就行。如果数组可能不是连续的比如经过转置或切片需要先调用py::array::c_style强制转换。5.3 处理非连续数组的坑我踩过一个坑从 PyTorch tensor 转成 NumPy 数组的时候有时候得到的是非连续内存布局。这时候直接读ptr会读到错误的数据但不会报错结果就是 NMS 输出莫名其妙。解决方案是在函数签名里加py::array::c_style | py::array::forcecastpy::array_tfloat, py::array::c_style | py::array::forcecast boxes_arrc_style要求数组是 C 连续的forcecast允许类型转换。如果传入的数组不满足条件pybind11 会自动做一次拷贝转换保证函数内部拿到的永远是连续内存。这个拷贝有开销但比读错数据强。5.4 完整模块的封装把过滤、解码、NMS 整合成一个函数暴露给 Pythonpy::array_tint full_postprocess( py::array_tfloat raw_output, float score_threshold, float iou_threshold ) { auto buf raw_output.request(); const float* ptr static_castconst float*(buf.ptr); int num_anchors buf.shape[0]; std::vectorfloat boxes; std::vectorfloat scores; std::vectorint class_ids; filter_by_score(ptr, num_anchors, score_threshold, boxes, scores, class_ids); std::vectorint keep nms_cpu(boxes, scores, class_ids, iou_threshold); py::array_tint result(keep.size()); auto rbuf result.request(); std::copy(keep.begin(), keep.end(), static_castint*(rbuf.ptr)); return result; }Python 侧调用import numpy as np import nms_ext raw_output np.random.randn(25200, 85).astype(np.float32) keep_indices nms_ext.full_postprocess(raw_output, 0.25, 0.45) print(fKept {len(keep_indices)} boxes)5.5 编译和导入的注意事项编译出来的.so文件名必须和PYBIND11_MODULE里的模块名一致。比如PYBIND11_MODULE(nms_ext, m)编译出来必须是nms_ext.cpython-38-aarch64-linux-gnu.so。如果名字不对import 的时候会报ModuleNotFoundError。另外.so文件所在的目录必须在sys.path里。我通常会在 Python 脚本开头加import sys sys.path.insert(0, /path/to/build)或者把.so拷贝到 Python 脚本同级目录。6. 性能实测359 倍加速是怎么来的6.1 测试环境和方法测试在 RK3588 开发板上进行CPU 是 4 个 Cortex-A76 大核 4 个 Cortex-A55 小核内存 8GB LPDDR4x。Python 版本是 3.8.10NumPy 版本 1.21.5。C 编译选项-O3 -marchnative。测试数据用随机生成的候选框模拟 YOLOv5s 在 640x640 输入下的输出。候选框数量分别设为 1000、3000、5000每个数量跑 100 次取平均。6.2 单线程性能对比候选框数量Python NMS (ms)C NMS (ms)加速比100068.30.21325x3000210.50.58363x5000412.71.02405x可以看到候选框越多C 的优势越明显。这是因为 Python 版本的开销随候选框数量呈超线性增长而 C 版本基本是线性的。6.3 多线程并行优化RK3588 有 8 个核心NMS 本身是串行算法但不同类别的 NMS 可以并行。我用 OpenMP 做了类别级别的并行#pragma omp parallel for for (int c 0; c num_classes; c) { // 对每个类别独立做 NMS }在 80 类的场景下8 线程并行能把 NMS 再压 3-4 倍。但要注意如果候选框集中在少数几个类别上并行效果会打折扣。实测下来典型场景下多线程版本比单线程快 2.5 倍左右。6.4 端到端延迟对比把 NMS 放回完整的推理管线里看阶段Python 后处理 (ms)C 后处理 (ms)NPU 推理3838置信度过滤120.08坐标解码350.15NMS1480.35结果整理70.05合计24038.63端到端延迟从 240ms 降到了 38.63ms其中 NPU 推理的 38ms 成了新的瓶颈。这意味着后处理已经不再是限制因素整个管线的吞吐量提升了 6 倍多。6.5 内存占用对比Python 版本在处理 3000 个候选框时峰值内存占用约 45MB主要是 NumPy 数组的临时对象。C 版本因为预分配了缓冲区峰值内存占用稳定在 8MB 左右。对于嵌入式设备来说这个内存节省很有意义。7. 常见问题与排查技巧实录7.1 编译报错找不到 Python.h这是最常见的问题原因是没装python3-dev。解决方法sudo apt install python3-dev如果已经装了还是报错检查 CMake 是否找到了正确的 Python 版本find_package(Python3 COMPONENTS Development REQUIRED)有时候系统里有多个 Python 版本CMake 可能找到了错误的那个。用-DPython3_EXECUTABLE/usr/bin/python3显式指定。7.2 import 时报 undefined symbol这个错误通常是因为 pybind11 模块链接了不兼容的库。检查编译时的 Python 版本和运行时的 Python 版本是否一致python3 -c import sys; print(sys.version_info)如果编译用的是 Python 3.8运行用的是 Python 3.9就会出现符号不匹配。解决方法是确保编译和运行使用同一个 Python 环境。7.3 NMS 结果和 Python 版本不一致如果 C 版本和 Python 版本的 NMS 输出有细微差异通常是浮点精度问题。Python 的 NumPy 默认用 float64而 C 版本我用的是 float32。解决方法是统一精度或者在比较结果时允许一定的误差范围。另一个可能的原因是排序稳定性。Python 的argsort和 C 的std::sort在遇到相同分数时可能产生不同的顺序。如果这对结果有影响可以在排序时加一个 tie-breaker比如按索引排序。7.4 性能没有达到预期如果 C 版本没有明显加速检查以下几点第一编译选项是否用了-O3。我见过有人用默认的-O0编译结果比 Python 还慢。第二是否发生了不必要的内存拷贝。用py::array_t的request()方法获取指针避免用std::vector接收数组。第三候选框数量是否太少。如果只有几十个框C 的函数调用开销可能比 Python 还大。这种情况下不值得用 C。7.5 常见问题速查表问题现象可能原因解决方法编译报 Python.h 找不到缺少 python3-devapt install python3-devimport 报 undefined symbolPython 版本不匹配统一编译和运行环境结果和 Python 不一致浮点精度差异统一用 float32 或允许误差性能没有提升编译选项不对用 -O3 -marchnative内存占用高发生了数组拷贝用 request() 获取指针多线程没效果候选框集中在少数类改用框级别并行7.6 几个实用的调试技巧第一个技巧在 C 代码里加计时。用std::chrono::high_resolution_clock测量每个阶段的耗时输出到 stderr。这样不会干扰 Python 的输出。auto t1 std::chrono::high_resolution_clock::now(); // ... 你的代码 ... auto t2 std::chrono::high_resolution_clock::now(); auto ms std::chrono::duration_caststd::chrono::microseconds(t2 - t1).count() / 1000.0; fprintf(stderr, NMS took %.3f ms\n, ms);第二个技巧用py::print在 C 里输出调试信息它会直接打到 Python 的 stdout比 printf 更方便。第三个技巧如果怀疑是内存问题用valgrind跑一下 Python 脚本。虽然 valgrind 对 Python 的解释器本身会报很多误报但能抓到 C 扩展里的真实内存错误。8. 后续扩展与个人经验这套 C NMS 模块我已经在几个项目里实际用了稳定性没问题。如果你想把整个后处理链路都搬到 C可以继续把坐标解码和置信度过滤也整合进去这样 Python 侧只需要传原始输出和接收最终结果中间没有任何 Python 层的开销。另一个扩展方向是支持批量处理。现在的实现是一次处理一帧如果有多路视频流可以在 C 层面做 batch NMS进一步利用 CPU 的并行能力。我个人在实际操作中的体会是嵌入式平台上的性能优化第一步永远是 profiling搞清楚瓶颈在哪。我一开始以为瓶颈在 NPU 推理花了很多时间研究量化结果发现后处理才是大头。把 NMS 用 C 重写之后整个管线的吞吐量直接翻了 6 倍这个投入产出比非常高。最后分享一个小技巧如果你不想自己编译 C 模块可以先用torchvision.ops.nms试试它底层也是 C 实现的。虽然依赖 PyTorch 会让部署包大很多但在开发阶段用来验证性能提升是够用的。确认了 C NMS 确实能解决问题之后再自己写一个轻量级的版本替换掉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑