news 2026/10/1 7:17:31

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 TOPS(INT8),跑 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、h),1 个目标置信度 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/(1+exp(-x)),换成 C++ 后要注意浮点数一致性,别小看这个差异,后面我会讲它引发的排查事故。

2.2 标准 NMS 三段式流程

NMS 的全称是 Non-Maximum Suppression,非极大值抑制。它的目标很简单:在重叠的候选框里,只保留分数最高的那一个,把其他冗余框干掉。

标准流程分三段:

第一段,置信度过滤。遍历所有候选框,如果目标置信度乘以类别置信度的最大值低于某个阈值(比如 0.25),直接丢弃。这一步能砍掉至少 95% 的框,大幅减少后续计算量。

第二段,按分数从高到低排序。把所有保留的框按照最终分数降序排列,分数最高的排最前面。

第三段,循环抑制。从最高分框开始,遍历后面的所有框,如果两个框的 IoU 大于设定阈值(比如 0.45),就把后面的框抑制掉。然后处理下一个未被抑制的框,重复这个过程。

我用一个具体例子说明:假设现在有 5 个框,分数分别是 A=0.9、B=0.85、C=0.8、D=0.7、E=0.6。按分数排序后从 A 开始,发现 A 和 B 的 IoU 是 0.7,B 被抑制;A 和 C 的 IoU 是 0.3,C 保留;A 和 D 的 IoU 是 0.8,D 被抑制;A 和 E 的 IoU 是 0.2,E 保留。然后处理 C,发现 C 和 E 的 IoU 是 0.1,都保留。最终输出 A、C、E 三个框。

这个逻辑不复杂,但计算量藏在“每一对框都要算 IoU”这一步。假设过滤后还剩 500 个框,最坏情况要算 25 万次 IoU。如果过滤不严格或者场景里目标很多,这个数字还会涨。

2.3 基线 Python 代码的慢点逐个拆

我最初的 Python 后处理逻辑大概是这样的结构:

def nms_python(boxes, scores, iou_threshold=0.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::vector<Box> boxes;

这个结构在框数量少的时候没问题,但你要知道,RK3588 的 CPU 是大小核架构,A76 大核有 L1 Cache 和 L2 Cache,内存访问模式如果很差,缓存命中率会严重影响性能。

vector 的每个元素是连续存储的,这本身没有问题。但如果你在遍历时频繁地读取 boxes[i].x1、boxes[i].x2、boxes[i].score,而 Box 结构体大小超过 32 字节,每个 64 字节的缓存行可能只能容纳 1 到 2 个元素,导致缓存利用率下降。

一个更激进的方案是把结构体拆成多个数组,也就是 SoA(Structure 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::vector<Box> 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::vector<Box>& boxes, float iou_threshold, std::vector<int>& keep) { // 按分数降序排序 std::sort(boxes.begin(), boxes.end(), [](const Box& a, const Box& b) { return a.score > b.score; }); std::vector<char> 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::vector<Box>& input, float conf_threshold, float iou_threshold, int num_classes, std::vector<int>& keep) { // 每个类别维护一个独立容器,这样线程之间不会互相干扰 std::vector<std::vector<Box>> 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::vector<int> 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 这个 vector<vector > 每次都会重新创建,开销也不小。更好的做法是把它做成成员变量或全局变量,跨帧复用。

第二,每个线程的 keep_local 是独立的,最后用 critical 合并。如果保留的框数量不多,合并的开销可以忽略。但如果你对锁极其敏感,也可以让每个线程把自己的结果写到 keep[c] 对应的固定位置,避免加锁。我实际测试下来,加 critical 的开销很小,因为 80 个类别里绝大多数类别结果都是空或只有一两个框,合并操作几乎瞬间完成。

4.3 编译配置:让 -O3 和 NEON 真正生效

C++ 代码写好了,编译配置不对,加速效果会大打折扣。我当时的编译命令是这样的:

g++ -std=c++17 -O3 -march=armv8-a+simd -fopenmp -funroll-loops -o yolov5_nms yolov5_nms.cpp

几个参数逐个解释:

-O3 是最重要的优化级别,它会开启函数内联、循环展开、自动向量化等一系列优化。

-march=armv8-a+simd 告诉编译器目标平台的 CPU 架构,让它能生成 NEON SIMD 指令。如果没有这个参数,编译器只生成通用的 ARMv8 指令,性能会损失一些。

-fopenmp 是启用 OpenMP 多线程支持,如果没有链接这个选项,代码里所有的 #pragma omp 都会被忽略。

-funroll-loops 对 NMS 里的双循环有帮助,虽然编译器在 -O3 下一般也会自动做,但显式指定能让循环展开得更激进。

如果你的代码是集成到更大的工程里,建议用 CMake 管理,关键是在 target_link_libraries 里加上 OpenMP:

find_package(OpenMP REQUIRED) add_executable(yolov5_demo main.cpp) target_link_libraries(yolov5_demo OpenMP::OpenMP_CXX) target_compile_options(yolov5_demo PRIVATE -O3 -march=armv8-a+simd)

这里要特别提醒:RK3588 的 NPU 输出格式和你解码后的 Box 数据之间,最好直接用指针引用,不要做一次完整的数据拷贝。如果你的 NPU 输出是通过 RKNN 的 rknn_outputs_get 拿到的,那里面是连续内存的 float 数组,你可以直接把它 cast 成 Box 数组的指针,或者用 memcpy 一次性拷入 vector,而不是逐元素赋值。这个细节能省下不少时间。

4.4 集成到完整推理链路后的真实性能数据

我把 C++ NMS 接到完整链路后,做了一个比较完整的基准测试。测试场景是一段 1080P 道路监控视频,每帧缩放到 640x640 输入,检测目标是车辆和行人。连续处理 500 帧,统计各阶段平均耗时。

阶段Python 后处理版C++ 后处理版
图像预处理3.1ms2.8ms
RKNN 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基线
Python(NumPy 优化版)30.2ms仍有大量临时数组
C++ 单线程 -O30.25ms本文主体实现
C++ 4 线程 OpenMP0.11ms多路场景收益明显

需要说明的是,0.25ms 这个数字针对的是我测试用的典型道路场景:单帧有效目标约 30 个,86% 的类别没有任何候选框。如果你的场景是密集人群检测,有效目标可能上千,C++ NMS 耗时也会涨到 1 到 2ms,但相比 Python 的几百毫秒,加速倍数依然非常可观。

5. 踩坑实录与排查思路

5.1 排序稳定性引发的检测结果抖动

第一次把 C++ NMS 接入项目后,我发现一个诡异的问题:相邻两帧的检测框有时候会“跳动”,明明场景没变,同一个目标的框偶尔会从 x=100 跳到 x=102 再跳回来。

排查后发现是 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 这种 vector<vector > 结构,并且每帧都重新创建,那么你实际上是在每帧都做 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-contract=off),或者统一用相同的数学库实现 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 结果直接引擎级联,这些就留到后面的文章再聊了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 7:17:12

BL450:集成多路视觉、AI推理与实时控制的ARM工业计算机

最近好几个做机器视觉集成的朋友都在问 BL450 是什么。我第一次听到这个名字也愣了一下&#xff0c;后来拿到设备、翻了完整规格书、又在实际项目里压了几轮负载&#xff0c;才算把这类产品真正吃透。简单说&#xff0c;BL450 是一款把多路相机采集、边缘 AI 推理和实时运动控制…

作者头像 李华
网站建设 2026/10/1 7:16:49

Codex Harness 免配置一键包|桌面 AI Agent 免解压快速部署教程

前言 相比手动安装 Node.js、再敲命令行&#xff0c;一键安装包将环境配置、依赖安装与客户端部署一并打包&#xff0c;省去了繁琐的搭建步骤&#xff0c;非常适合不想折腾、希望快速上手的用户。全程只需按提示操作&#xff0c;几分钟内即可开始使用。 一、下载安装包 打开…

作者头像 李华
网站建设 2026/10/1 7:16:49

TCP/IP协议入门:用TaoToken统一Key打通网络调试与AI工具链

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 7:16:16

微软AI Test Lab实战:集成VS Code的测试神器与TaoToken统一Key配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 7:16:15

WordPress开发入门07:WP_Query 自定义循环配 TaoToken 的 settings.json 骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 7:15:52

土木结构工程有限元模态分析与抗震响应汇报的AIGC特征识别及规范表述

土木结构工程有限元模态分析与抗震响应汇报的AIGC特征识别及规范表述在结构工程、防灾减灾工程以及桥梁隧道工程领域的学位论文中&#xff0c;关于超高层建筑、大跨空间网壳结构或桥梁结构在复杂地震荷载作用下的有限元动力特性建模&#xff08;基于 ANSYS、ABAQUS 或 SAP2000&…

作者头像 李华