新闻详情

新闻详情

首页 / 资讯中心 / 详情

RK3588上YOLOv5s的NMS后处理C++化:从90ms到0.25ms实战

发布时间:2026/10/1 7:17:33来源:尧图网络
RK3588上YOLOv5s的NMS后处理C++化:从90ms到0.25ms实战
先说结论在 RK3588 上把 YOLOv5s 的 NMS 后处理从 Python 重写成 C实测单帧耗时从 89.75ms 降到 0.25ms加速 359 倍。这不是玄学也不是靠“换了个更快的语言”这种粗颗粒度的解释就能说清楚的。这篇是《RK3588 上从 0 部署 YOLOv5s我的全链路实践》的第五篇前面几篇分别写了环境搭建、模型转换、NPU 推理和 Python 后处理。如果你一路跟过来应该已经发现一个很尴尬的现象NPU 推理只要十几毫秒后处理却把时间全吃回去了。这篇文章就是来解决这个问题的我会从瓶颈分析、原理拆解、C 实现、编译集成、踩坑记录五个部分完整讲一遍适合正在 RK3588 或类似 ARM Linux 平台上做目标检测部署的开发者参考。1. 为什么 NMS 会成为部署链路里的性能瓶颈1.1 RK3588 推理流水线四段式最容易被忽视的是 CPU 后处理RK3588 这套平台做目标检测典型流程是摄像头或视频文件取帧图像预处理NPU 推理后处理。前两项能优化的空间有限NPU 推理是硬算力真正拉开差距的反而是最后一步后处理。很多人在板子上跑通 demo 就以为完事了实际把完整链路接上才发现帧率上不去的元凶根本不是 NPU而是 CPU 上的 Python NMS。先给个量化概念。RK3588 的 NPU 算力大约是 6 TOPSINT8跑 YOLOv5s 的 INT8 量化模型640x640 输入单帧推理大概在 10 到 20ms 之间具体看量化质量和算子的支持情况。这个速度对于边缘设备来说已经算不错了但当你用 Python 写 NMS单帧后处理动辄 80 到 100ms整个端到端帧率就被压到了 8 到 10FPS 左右。推理 15ms后处理 90ms这个比例完全倒挂。我最初在 RK3588 上做调试时打印每一段耗时看到 NPU 推理 16ms、前处理 3ms、Python NMS 89.75ms 这个数字第一反应是代码出 bug 了。排查了半天发现没 bug纯粹就是语言和算法实现方式导致的性能灾难。这件事给我提了个醒在嵌入式 AI 部署里后处理从来不是“随便写写就行”的边角料它直接决定系统能不能用。1.2 Python NMS 慢的三个根因循环、临时对象、解释器开销Python 版 NMS 慢很多人会笼统地说“因为 Python 慢”但如果你要做优化就得搞清楚到底慢在哪三个层面。第一纯 Python 的 for 循环太慢。YOLOv5s 的输出经过解码之后会有大约 25200 个候选框三个尺度特征图 80x80、40x40、20x20每个位置 3 个 anchor加起来就是 25200。NMS 的第一步是置信度过滤你要遍历这 25200 个框哪怕只做一次简单的 if score threshold 判断纯 Python 循环也要消耗好几毫秒。等你再嵌套一层类别循环、一层框与框之间的 IoU 比对循环次数直接爆炸。第二NumPy 虽然底层是 C但它每次调用 np.where、np.argsort、np.maximum 这类操作都会产生新的临时数组分配内存再释放内存这个开销比计算本身还大。而且 NumPy 在处理这种“每个框和另一个框做条件计算”的逻辑时往往要构造布尔掩码、取索引、再做 gather中间变量的内存占用和拷贝非常可观。小规模数据看不出来到 2 万多个框的规模就非常明显。第三解释器的边界开销。Python 的每个列表元素都是一个 PyObject你在循环里取一个框的坐标做一次浮点运算Python 都要做类型检查和对象引用计数。这个成本摊到几十万次 IoU 计算上就会比 C 慢两个数量级。打个比方Python 版 NMS 像是你去快递仓库找一批货每次都要从头到尾走一遍货架找到一件拿一件走一趟下来腿都酸了C 版则是先把货架按编号排好序一眼定位目标位置拿完就走。效率差距就是这么来的。1.3 359 倍这个数字是怎么测出来的为了避免自嗨我花了点时间把基线测准了。测试条件是RK3588 开发板Ubuntu 20.04 系统同一份 YOLOv5s ONNX 模型转出来的 RKNN 输出输入一张包含车辆和行人的 640x640 测试图。后处理输入是 NPU 推理得到的原始输出张量格式是 [1, 255, 80, 80]、[1, 255, 40, 40]、[1, 255, 20, 20]需要先解码成候选框再做 NMS。Python 版采用最常规的写法先遍历所有 anchor 做解码再用 NumPy 做置信度过滤和排序最后按类别循环做 IoU 抑制。连续跑 500 帧取平均单帧耗时约 89.75ms。C 版采用连续内存结构体数组 先过滤后排序 避免平方根运算 标记法抑制单帧耗时约 0.25ms。89.75 除以 0.25正好 359 倍。这个数字的成立条件有两个一是输入数据完全一致二是两边做的是同一个算法逻辑没有靠减少计算量来作弊。所以这个加速是实打实的“语言和实现层面”的收益。2. NMS 原理与基线 Python 实现拆解2.1 YOLOv5s 输出到底长什么样要写一个高效的 C NMS你首先得把 YOLOv5s 的输出结构彻底搞清楚否则后面所有的索引计算都会让你怀疑人生。YOLOv5s 的输出是三个尺度的特征图。以 640x640 输入为例三个输出分别是 [1, 255, 80, 80]、[1, 255, 40, 40]、[1, 255, 20, 20]。这里的 255 是 3 乘以 85 得来的每个位置有 3 个 anchor每个 anchor 预测 85 个值其中 4 个坐标中心点 x、y 和宽高 w、h1 个目标置信度 objectness剩下 80 个是 COCO 类别分数。解码过程就是把特征图上的格子坐标换算成原图坐标。比如第 80x80 层每个格子对应原图 8 个像素640 除以 80第 i 行第 j 列的 anchor 中心点就在 (j * 8, i * 8) 附近再加上网络预测的偏移量。做完这一步你会得到 3 x (80x80 40x40 20x20) 等于 25200 个候选框。这 25200 个框绝大部分都是低置信度的背景噪声真正有意义的可能只有几十个。有一个问题在写代码时特别容易踩坑YOLOv5 的坐标解码公式里用了 sigmoid 函数Python 里是 scipy.special.expit 或自己实现 1/(1exp(-x))换成 C 后要注意浮点数一致性别小看这个差异后面我会讲它引发的排查事故。2.2 标准 NMS 三段式流程NMS 的全称是 Non-Maximum Suppression非极大值抑制。它的目标很简单在重叠的候选框里只保留分数最高的那一个把其他冗余框干掉。标准流程分三段第一段置信度过滤。遍历所有候选框如果目标置信度乘以类别置信度的最大值低于某个阈值比如 0.25直接丢弃。这一步能砍掉至少 95% 的框大幅减少后续计算量。第二段按分数从高到低排序。把所有保留的框按照最终分数降序排列分数最高的排最前面。第三段循环抑制。从最高分框开始遍历后面的所有框如果两个框的 IoU 大于设定阈值比如 0.45就把后面的框抑制掉。然后处理下一个未被抑制的框重复这个过程。我用一个具体例子说明假设现在有 5 个框分数分别是 A0.9、B0.85、C0.8、D0.7、E0.6。按分数排序后从 A 开始发现 A 和 B 的 IoU 是 0.7B 被抑制A 和 C 的 IoU 是 0.3C 保留A 和 D 的 IoU 是 0.8D 被抑制A 和 E 的 IoU 是 0.2E 保留。然后处理 C发现 C 和 E 的 IoU 是 0.1都保留。最终输出 A、C、E 三个框。这个逻辑不复杂但计算量藏在“每一对框都要算 IoU”这一步。假设过滤后还剩 500 个框最坏情况要算 25 万次 IoU。如果过滤不严格或者场景里目标很多这个数字还会涨。2.3 基线 Python 代码的慢点逐个拆我最初的 Python 后处理逻辑大概是这样的结构def nms_python(boxes, scores, iou_threshold0.45): order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) 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 iou inter / (area_i area[order[1:]] - inter) inds np.where(iou iou_threshold)[0] order order[inds 1] return keep这段代码看着挺“向量化”了但它的慢点很隐蔽第一argsort 对全量数组排序。虽然 NumPy 的排序是 C 级别但当框数量达到几万时排序本身并不是瓶颈真正的问题是内存分配。第二循环里每次都要创建一堆临时数组。xx1、yy1、xx2、yy2、w、h、inter、iou这些变量每迭代一次就重新分配一次。如果保留下来的框有 50 个这个循环就要创建 50 组临时数组每组好几个数组每个数组长度几百。内存的分配和销毁开销远远大于计算开销。第三np.where 返回的是一个新数组order order[inds 1] 又是一次数组拷贝。这些操作叠加起来89.75ms 一点不冤枉。所以我常说Python NMS 的优化第一步不是把 Python 换成 C而是先砍掉不必要的工作量先做严格的置信度过滤让参与排序和 IoU 的框数量从 25200 降到几百甚至几十。第二步才是用 C 重写把语言层的开销彻底消除。这两个动作缺一不可。3. C NMS 的核心优化手段3.1 数据结构从 vector 到连续内存缓存局部性决定一切写 C NMS 的第一件事是设计数据结构。很多从 Python 转过来的开发者习惯这样写struct Box { float x1, y1, x2, y2; float score; int class_id; }; std::vectorBox boxes;这个结构在框数量少的时候没问题但你要知道RK3588 的 CPU 是大小核架构A76 大核有 L1 Cache 和 L2 Cache内存访问模式如果很差缓存命中率会严重影响性能。vector 的每个元素是连续存储的这本身没有问题。但如果你在遍历时频繁地读取 boxes[i].x1、boxes[i].x2、boxes[i].score而 Box 结构体大小超过 32 字节每个 64 字节的缓存行可能只能容纳 1 到 2 个元素导致缓存利用率下降。一个更激进的方案是把结构体拆成多个数组也就是 SoAStructure of Arrays布局所有 x1 放在一个 float 数组里所有 y1 放另一个数组以此类推。这样遍历坐标时内存访问是完全顺序的缓存命中率最高。不过从工程实践角度我建议折中如果框数量在几千以内vector 配合预留容量reserve已经完全够用只有当框数量特别大、性能要求极其苛刻时才值得做 SoA。我在 RK3588 上实测SoA 版本比 AoS 版本大概再快 10% 到 20%但代码可读性下降不少。对大多数场景来说vector 已经能拿到 359 倍的加速没必要把代码改得过于复杂。3.2 先过滤再排序别在一堆噪声里做无用功NMS 流程里最容易被忽视的优化是顺序先置信度过滤再排序再抑制。很多实现版本上来就全量排序或者过滤和排序挤在一起。但 YOLOv5s 这种一阶段检测器25200 个候选框里有 95% 以上都是低置信度背景框。如果先把这些噪声全部过滤掉参与后续排序和 IoU 的框数量可能只剩几十个计算量直接下降两个数量级。在 C 里实现过滤很简单遍历所有 box把 score 大于阈值的复制到一个新数组。这里有一个关键细节新数组要提前调用 reserve预分配一个合理的容量避免在 push_back 过程中反复扩容。vector 的扩容机制是容量翻倍每次扩容都要把旧数据拷贝到新内存这个开销在框数量多时非常明显。我习惯这样写std::vectorBox filtered; filtered.reserve(1024); // 根据场景预估避免频繁扩容 for (const auto b : boxes) { if (b.score conf_threshold) { filtered.push_back(b); } }实测下来过滤后的框数量通常在 30 到 200 之间即使最密集的场景也很少超过 1000。这个数量级的排序和 IoU 计算耗时非常短。3.3 IoU 计算能省的运算一个都别留IoU 计算是 NMS 的核心也是计算量最大的部分。它的公式是交集面积除以并集面积。Python 版通常这么写inter_area max(0, min_x2 - max_x1) * max(0, min_y2 - max_y1) iou inter_area / (area_a area_b - inter_area)C 里很多人会照搬但有几个可以优化的点。第一避免 std::max 和 std::min 的函数调用开销。虽然编译器会内联但如果你用的是自定义比较逻辑直接写成三元表达式更明确float inter_w std::max(0.0f, std::min(a.x2, b.x2) - std::max(a.x1, b.x1));第二避免平方根运算。有人会用欧几里得距离来判断“两个框是否接近”这在 NMS 里完全没必要。IoU 只需要面积比不需要任何距离计算。第三提前计算好每个框的面积。area (x2 - x1) * (y2 - y1)避免每次比较都重复计算。第四一个容易被忽略的优化点如果两个框的中心点距离很远它们的 IoU 必为 0。可以先做一次粗略的包围盒判断比如先检查 x1 b.x2 x2 b.x1 y1 b.y2 y2 b.y1如果这个条件不成立直接跳过 IoU 计算。这个检查是几个浮点比较代价极低但能筛掉大量完全不相交的框对。在我优化后的 C 实现里IoU 计算用的是最朴素的代码没有做任何花哨的向量化虽然 RK3588 的 A76 支持 NEON 指令但 NMS 这种条件分支密集的逻辑老老实实写标量代码反而更容易被编译器优化好。唯一做的就是对不相交框的快速跳过。3.4 多线程并行按类别并行不要按框并行RK3588 的 CPU 有 8 个核心其中 4 个是 Cortex-A76 大核4 个是 Cortex-A55 小核。多线程优化是能让 NMS 再快一步的手段但并行策略选错了反而会拖慢速度。正确的并行粒度是按类别并行。COCO 数据集有 80 个类别每个类别的 NMS 过程是相互独立的车辆检测的结果不需要和行人的结果做 IoU 抑制因为不同类别天然是不同物体。所以可以把 80 个类别的 NMS 任务分配到多个线程同时执行。错误的并行粒度是按框并行。同一个类别内的框NMS 是有严格顺序依赖的你要先处理最高分的框然后再处理后续框高分的框是否保留会影响低分框的判断。按框并行会导致数据竞争或者逼着你去加锁结果比串行还慢。多线程实现我使用的是 OpenMP因为它最简单一个 pragma 指令就能把循环并行化#pragma omp parallel for schedule(dynamic) num_threads(4) for (int c 0; c num_classes; c) { // 提取该类别下所有框 // 排序 IoU 抑制 }注意这里要用 schedule(dynamic)因为每个类别的框数量差别很大有的类别可能只有 1 个框有的类别可能有 100 个框。动态调度能让先完成任务的线程去取下一个任务负载均衡更好。在使用多线程时还有一个 RK3588 特有的问题线程亲和性affinity。Linux 调度器默认会尽量让所有核心都有活干但它可能把高优先级任务调度到 A55 小核上导致速度变慢。如果你确实追求极致性能可以用 pthread_setaffinity_np 把工作线程钉在 4 个大核上。不过要注意这样会减少小核处理系统任务的能力可能引入别的卡顿实际项目里要权衡。4. 完整实现与集成到 RK3588 推理链路4.1 核心代码框架从解码到 NMS 的完整串起来我直接贴一段核心代码用的是我最终在板子上跑通的版本。为了篇幅省去了解码部分重点展示 NMS 主体。struct Box { float x1, y1, x2, y2; float score; int class_id; }; static inline float compute_iou(const Box a, const Box b) { // 快速不相交判断省去大量无效计算 if (a.x1 b.x2 || b.x1 a.x2 || a.y1 b.y2 || b.y1 a.y2) { return 0.0f; } 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); float inter_area inter_w * inter_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 / (area_a area_b - inter_area); } static void nms_per_class(std::vectorBox boxes, float iou_threshold, std::vectorint keep) { // 按分数降序排序 std::sort(boxes.begin(), boxes.end(), [](const Box a, const Box b) { return a.score b.score; }); std::vectorchar suppressed(boxes.size(), 0); for (size_t i 0; i boxes.size(); i) { if (suppressed[i]) continue; keep.push_back((int)i); for (size_t j i 1; j boxes.size(); j) { if (suppressed[j]) continue; // 不同类别的框不需要抑制 if (boxes[i].class_id ! boxes[j].class_id) continue; if (compute_iou(boxes[i], boxes[j]) iou_threshold) { suppressed[j] 1; } } } }这里有一个很重要的小细节我用了 std::vector 作为抑制标记而不是直接修改 box 的 score。这样做有两个好处一是避免在循环中修改正在遍历的数组内容引起缓存问题二是方便后续按保留索引去恢复结果。如果你的工程对实时性要求特别高还可以考虑用 bool 数组替代 vector 或者在知道最大框数量的情况下用栈上数组比如 unsigned char suppressed[4096]。但大多数情况下 vector 就够了它已经比 Python 的实现快了两个数量级。4.2 多线程封装把 80 个类别的 NMS 并行跑起来把上面的 nms_per_class 扩展到多线程版本我选择按类别并行。这里的思路是先把所有框按 class_id 分组然后每个线程处理一个类别。改进后的代码框架如下void nms_all_classes(const std::vectorBox input, float conf_threshold, float iou_threshold, int num_classes, std::vectorint keep) { // 每个类别维护一个独立容器这样线程之间不会互相干扰 std::vectorstd::vectorBox per_class(num_classes); for (const auto b : input) { if (b.score conf_threshold) { per_class[b.class_id].push_back(b); } } keep.clear(); keep.reserve(256); // 方法一OpenMP 并行 #pragma omp parallel for schedule(dynamic) num_threads(4) for (int c 0; c num_classes; c) { std::vectorint keep_local; nms_per_class(per_class[c], iou_threshold, keep_local); // 合并结果时注意线程安全 #pragma omp critical { keep.insert(keep.end(), keep_local.begin(), keep_local.end()); } } }这里有两个细节值得说。第一per_class 容器的大小是 num_classes也就是 80。这个内存分配是一次性的后续只是往里塞数据不会频繁扩容。但要注意如果你每帧都调一次这个函数per_class 这个 vectorvector 每次都会重新创建开销也不小。更好的做法是把它做成成员变量或全局变量跨帧复用。第二每个线程的 keep_local 是独立的最后用 critical 合并。如果保留的框数量不多合并的开销可以忽略。但如果你对锁极其敏感也可以让每个线程把自己的结果写到 keep[c] 对应的固定位置避免加锁。我实际测试下来加 critical 的开销很小因为 80 个类别里绝大多数类别结果都是空或只有一两个框合并操作几乎瞬间完成。4.3 编译配置让 -O3 和 NEON 真正生效C 代码写好了编译配置不对加速效果会大打折扣。我当时的编译命令是这样的g -stdc17 -O3 -marcharmv8-asimd -fopenmp -funroll-loops -o yolov5_nms yolov5_nms.cpp几个参数逐个解释-O3 是最重要的优化级别它会开启函数内联、循环展开、自动向量化等一系列优化。-marcharmv8-asimd 告诉编译器目标平台的 CPU 架构让它能生成 NEON SIMD 指令。如果没有这个参数编译器只生成通用的 ARMv8 指令性能会损失一些。-fopenmp 是启用 OpenMP 多线程支持如果没有链接这个选项代码里所有的 #pragma omp 都会被忽略。-funroll-loops 对 NMS 里的双循环有帮助虽然编译器在 -O3 下一般也会自动做但显式指定能让循环展开得更激进。如果你的代码是集成到更大的工程里建议用 CMake 管理关键是在 target_link_libraries 里加上 OpenMPfind_package(OpenMP REQUIRED) add_executable(yolov5_demo main.cpp) target_link_libraries(yolov5_demo OpenMP::OpenMP_CXX) target_compile_options(yolov5_demo PRIVATE -O3 -marcharmv8-asimd)这里要特别提醒RK3588 的 NPU 输出格式和你解码后的 Box 数据之间最好直接用指针引用不要做一次完整的数据拷贝。如果你的 NPU 输出是通过 RKNN 的 rknn_outputs_get 拿到的那里面是连续内存的 float 数组你可以直接把它 cast 成 Box 数组的指针或者用 memcpy 一次性拷入 vector而不是逐元素赋值。这个细节能省下不少时间。4.4 集成到完整推理链路后的真实性能数据我把 C NMS 接到完整链路后做了一个比较完整的基准测试。测试场景是一段 1080P 道路监控视频每帧缩放到 640x640 输入检测目标是车辆和行人。连续处理 500 帧统计各阶段平均耗时。阶段Python 后处理版C 后处理版图像预处理3.1ms2.8msRKNN NPU 推理16.4ms16.2ms解码 NMS 后处理89.75ms0.25ms端到端单帧总耗时109.25ms19.25ms等效帧率约 9 FPS约 52 FPS这个数据是在单线程 C NMS 条件下测的没有开多线程。如果打开 4 线程并行NMS 从 0.25ms 可以进一步降到 0.11ms 左右但端到端收益已经不明显了因为 NPU 推理的 16ms 才是大头。所以我的结论是这种场景下C NMS 的意义是让后处理不再成为瓶颈而不是把整体帧率推到极限。多线程的真正价值体现在多路视频流场景。如果你同时跑 4 路或 8 路视频流每一路都要做 NMS并行处理能让你在同样时间窗内处理完更多路的后处理。我测试过 4 路输入4 线程并行后处理的总耗时约 0.6ms相比单线程逐个处理节省了 75% 的时间。配置单帧 NMS 耗时备注Python朴素循环版89.75ms基线PythonNumPy 优化版30.2ms仍有大量临时数组C 单线程 -O30.25ms本文主体实现C 4 线程 OpenMP0.11ms多路场景收益明显需要说明的是0.25ms 这个数字针对的是我测试用的典型道路场景单帧有效目标约 30 个86% 的类别没有任何候选框。如果你的场景是密集人群检测有效目标可能上千C NMS 耗时也会涨到 1 到 2ms但相比 Python 的几百毫秒加速倍数依然非常可观。5. 踩坑实录与排查思路5.1 排序稳定性引发的检测结果抖动第一次把 C NMS 接入项目后我发现一个诡异的问题相邻两帧的检测框有时候会“跳动”明明场景没变同一个目标的框偶尔会从 x100 跳到 x102 再跳回来。排查后发现是 std::sort 导致的。std::sort 是不稳定排序当两个框的分数恰好相等时它们的相对顺序是未定义的。在 Python 里argsort 默认是稳定的所以同样的输入排序后顺序是确定的。C 里换成 std::sort 后相同分数的框顺序每次调用可能不同如果恰好这两个框 IoU 很高NMS 抑制的结果就会不一样造成检测框抖动。解决办法有两个一是在分数相同时增加一个次级排序条件比如按 x1 坐标排序让顺序完全确定二是换成 std::stable_sort。我最终用的是第一种因为 stable_sort 在数据量大时可能更慢而增加一个 tie-breaker 几乎零成本。std::sort(boxes.begin(), boxes.end(), [](const Box a, const Box b) { if (a.score ! b.score) return a.score b.score; return a.x1 b.x1; // 分数相同时按坐标兜底保证确定性 });5.2 vector 扩容导致的性能尖峰运行一段时间后我发现后处理耗时偶发性地从 0.25ms 跳到 2ms虽然不频繁但对于要稳定输出的系统来说这种尖峰很致命。定位过程比较折腾。我用 perf 工具采样发现耗时大头在 memmove也就是内存搬运。原因是我的 filtered 容器在最开始没有 reserve导致每帧运行时都可能触发 vector 扩容。当框数量恰好超过当前容量时vector 会分配一块新内存、把旧数据全部拷贝过去、再释放旧内存。这个拷贝操作虽然量不大但频繁触发时会打乱缓存造成耗时尖峰。解决办法很粗暴根据输入规模预估一个合理的容量直接 reserve。比如我的场景最多也就几百个候选框reserve(1024) 就绰绰有余。永远不要在每帧循环里让 vector 反复扩容。还有一个更隐蔽的问题如果你用 per_class 这种 vectorvector 结构并且每帧都重新创建那么你实际上是在每帧都做 80 次容器构造和析构内存分配次数非常多。建议把所有中间容器声明为成员变量或 static 变量跨帧复用只在每帧开始时 clear 一下。5.3 多线程绑定与大小核调度的问题我最初用 OpenMP 默认线程数8 个线程跑多线程 NMS结果性能不升反降。分析原因是 RK3588 的 4 个小核 A55 也被拉来干活了而 A55 的性能只有 A76 的一半甚至更低线程间同步和缓存一致性开销反而拖慢了整体速度。在 ARM 大小核平台上多线程编程必须考虑核间差异。我的经验是对于 NMS 这种计算密集但分支多的任务用 4 个线程、绑在 4 个大核上是最均衡的选择。如果你想用满 4 个大核可以通过设置 CPU affinity 实现#pragma omp parallel num_threads(4) { int tid omp_get_thread_num(); cpu_set_t set; CPU_ZERO(set); CPU_SET(tid, set); // 将线程绑定到 0-3 号 CPU也就是 A76 大核 pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), set); }但这里要提醒一句绑核不是万能的。如果你的主程序还有别的线程在跑比如视频解码、图像缩放、UI 渲染你把所有核心都占满大核反而可能让其他线程全挤到小核上造成整体卡顿。实际项目里是否绑核、绑几个核要结合整个系统的线程模型来决策。5.4 浮点精度差异导致的结果不一致还有一个让我排查了很久的坑同样的输入Python 版 NMS 输出的框和 C 版 NMS 输出的框数量完全一致但偶尔有个框的 score 差 0.000001 级别导致它的排序位置不同最终保留结果略有差异。这个差异来自两处。第一YOLOv5 的 sigmoid 解码在 Python 和 C 里实现方式可能不同Python 用 exp 函数C 也调用 expf但不同库的实现精度有细微差别。第二ARM 平台开 -O3 后编译器可能会自动生成 NEON FMA融合乘加指令FMA 的中间结果不截断精度比标准的先乘后加更高但这个“更高”和 CPU 原生的乘以加分开计算的结果不一样于是导致了微小的浮点差异。对于目标检测部署来说这种差异在绝大多数场景下不影响实际使用因为检测框的坐标差可能只有 0.1 个像素肉眼根本看不出来。但如果你做的是精度回归测试、要和 Python 版对拍就需要注意不要用浮点值的绝对相等做断言而是允许一个很小的 epsilon 范围比如坐标差小于 0.5 像素就算通过。如果确实需要 Python 和 C 结果完全一致可以在两边都禁用 FMA 编译选项-ffp-contractoff或者统一用相同的数学库实现 sigmoid。我在实测项目里没有做这一步因为收益太小反而可能牺牲一部分性能。5.5 内存对齐与缓存行争用这个坑比较深入但值得知道。有一版本我把 Box 结构体写成了这样struct Box { float x1, y1, x2, y2; float score; char obj_class; // 用 char 存类别 };结构体大小从 32 字节变成 36 字节但由于对齐规则编译器会填充到 40 字节。这个填充导致每个 Box 横跨多个缓存行遍历时的缓存命中率下降。解决方法是显式控制结构体布局把 4 字节对齐的成员放一起或者使用attribute((aligned(64))) 让结构体对齐到缓存行大小。在 NMS 场景里最实用的做法是让 Box 大小保持 32 字节的倍数避免 padding 浪费。不过说实话除非你对性能有极致追求这个优化带来的收益可能只有 5% 到 10%。我提这个是想说明C 优化的过程是个“抠细节”的过程从大到小一层层抠每个环节省一点最后才能把 89ms 压到 0.25ms。但工程上也要有取捨不能为了优化而优化把代码变得难以维护。6. 最后补充一点我的实际体会这篇文章写的优化手段单看每一步都不稀奇用连续内存、先过滤再排序、避免重复计算、多线程并行。这些知识点任何一个写 C 的工程师都懂但真正把它们串起来用在一个嵌入式 AI 部署项目里还是需要一些经验的。我自己最大的体会是先测基线再动手优化。如果没有一开始那 500 帧的耗时统计我不会知道后处理占了 90ms也就不会投入精力去重写。很多开发者在板子上跑通 demo 后觉得“能出框就行了”然后直接上生产结果帧率不达标再来回头找瓶颈反而浪费时间。性能优化这件事数据永远是第一位的。另外想说的是C NMS 重写完之后整个项目的代码结构发生了变化原来 Python 后处理是独立脚本现在 C 代码要集成到主程序里还得处理 NPU 输出的内存布局、线程模型、日志打印等一堆问题。这些“非 NMS 本身的工程量”往往比写 NMS 代码本身更耗时。如果你是第一次做这种迁移建议先写一个独立的小程序验证 NMS 的正确性和性能确认没问题再往大工程里集成别一上来就全部推倒重来。这套 NMS 优化思路不仅适用于 YOLOv5s也适用于 YOLOv8、YOLOX 等一阶段检测器。YOLOv8 的输出虽然改成了解耦头NMS 的逻辑和本文描述的完全一致。如果你在这篇的基础上继续做优化下一步可以考虑把 NMS 合并到 NPU 推理阶段或者把多个类别的 NMS 结果直接引擎级联这些就留到后面的文章再聊了。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

开发者赛事推广为什么“曝光好看、报名难看”?一次三层转化漏斗的设计复盘 2026/10/1 8:21:15

开发者赛事推广为什么“曝光好看、报名难看”?一次三层转化漏斗的设计复盘

技术比赛从来不难在“让人知道”,难在“让人报名”“让人把作品交出来”。这篇文章拆解我们在一场支付能力开发者赛事里用过的推广链路设计方法,包括诊断维度、漏斗分层与结算口径,供做开发者增长、社区运营、生态推广的同学参考。一、先明确…

阅读更多 →
从 Prompt 到 Harness:AI Agent 工程的三次范式迁移 2026/10/1 8:21:15

从 Prompt 到 Harness:AI Agent 工程的三次范式迁移

如果你最近在做 Agent,或者尝试推动 AI 在真实业务中的落地,很可能已经遇到这样的问题:为什么同样的模型,在别人手里可以稳定运行并完成复杂任务,而在自己系统中却始终表现不稳定,成功率难以提升&#xff1…

阅读更多 →
雅思单词背了又忘?这个免费备考网站可以试试 2026/10/1 8:21:15

雅思单词背了又忘?这个免费备考网站可以试试

备考雅思时,很多人都会遇到这些问题:阅读生词太多、听力反应慢、写作不会表达。虽然背过不少单词,但真正做题时还是用不上。 如果你也有类似困扰,可以试试免费的雅思学习网站——雅思团。 为什么值得尝试? 1. 词汇更…

阅读更多 →
Agent落地一年,交付9个项目,说点不太好听但真实的行业现状 2026/10/1 8:21:15

Agent落地一年,交付9个项目,说点不太好听但真实的行业现状

做了一年Agent项目,交付了9个企业项目以后,我想说一些可能和网上宣传不太一样,但非常真实的行业现状。 很多人现在看AI Agent,看到的是非常美好的一面:一个Prompt,让AI自动完成任务;一个工作流&…

阅读更多 →
明星动态去哪个网站 2026/10/1 8:21:15

明星动态去哪个网站

明星动态去哪个网站 「明星动态」这个词有两种读法:一种是公开动态——新作品、公开活动、官方声明、获奖;另一种是私下行程——今天在哪吃饭、坐什么航班、和谁一起。前者可以用名读(https://famereads.com/),英文名 …

阅读更多 →
企业培训视频切片怎么做?把长培训拆成能用、能更新的微课 2026/10/1 8:21:08

企业培训视频切片怎么做?把长培训拆成能用、能更新的微课

企业培训视频切片要按员工需要完成的任务确定边界。每条片段应交代适用对象和前提,演示必要步骤,并说明怎样算完成。如果这些内容放不进一条短片,就把任务拆成连续单元,或保留较长版本。播放量和“高光评分”不能说明员工能否照着…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉