尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
RK3588上YOLOv5s后处理NMS加速:C++实现359倍性能提升
折腾 RK3588 部署 YOLOv5s 这件事走到第五篇的时候主干链路其实已经通了模型转 rknn 格式、量化校准、NPU 推理拿回原始输出这些前几篇都写过了。今天这篇单独拆一个让延迟“肉眼可见下降”的环节——C 实现的 NMS 加速。标题里的 359 倍不是营销数字是我在自己板子上跑出来的实测结果同样的候选框输入Python 版本完整后处理链路平均 5.8msC 版本优化到 16us 左右量级差刚好落在 360 倍附近。如果你正在 RK3588 或类似的 ARM 边缘设备上做 YOLOv5 系列部署又觉得 Python 后处理拖了帧率后腿这篇应该能给你一套可以直接抄作业的优化思路。先说清楚这篇的范围。我这里对比的“NMS 加速”指的是从 RKNN 裸输出到最终检测框列表的完整后处理链路包含解码、阈值过滤、按类别分组、非极大值抑制这几个环节不只是中间那个双重循环。因为实际部署里大家关心的永远是“这一帧后处理到底花了多少毫秒”而不是某个函数内部的微秒级对比。整个链路在 Python 里跑 5.8ms在 C 里跑 16us这个加速倍数才是对业务有意义的数字。1. 先交代背景NMS 为什么值得单独写一篇1.1 RK3588 上的部署链路长什么样RK3588 这颗芯片在边缘侧部署里出镜率很高4 个 Cortex-A76 大核加 4 个 Cortex-A55 小核NPU 算力标称 6 TOPS跑 YOLOv5s 这种量级的模型非常从容。我之前几篇把整个流程走通之后单帧推理在 NPU 上大概 18ms 到 22ms也就是 45 到 55 帧的处理能力。这个性能对很多视觉项目来说已经够用但当我把 Python 写的后处理接上去之后整帧延迟直接飙到 30ms 开外帧率掉到 30 帧以下。问题的根源不在 NPU在 CPU 侧。RKNN 的 Python 接口把推理结果以 numpy 数组形式返回一个标准的 YOLOv5s 输出是 1×25200×85 的浮点矩阵。25200 是三个尺度特征图加起来的总 anchor 数85 是 4 个框坐标加 1 个目标置信度加 80 个类别分数。接下来要做的事情是把这 25200 条原始预测解码成真实坐标、用阈值过滤掉大部分低分框、按类别分开、再做 NMS 去重。这套逻辑在 Python 里跑起来开销非常可观。你可能会想25200 这个数字也不是特别大怎么就能拖慢这么多关键在于过滤之后的候选框数量仍然有几百到上千而 NMS 的复杂度是 O(M²) 量级M 是每个类别里的候选数量。再加上 Python 解释器的固有开销、numpy 每次计算临时数组的分配开销、函数调用开销几毫秒就没了。1.2 为什么 NMS 是部署里绕不过去的坎很多第一次做端侧部署的同学容易有个误区模型推理是唯一耗时的地方后处理随便写写就行。实际上在 RK3588 这种 CPU 和 NPU 性能差距很大的平台上后处理代码的质量直接影响最终帧率。NPU 已经把最重的卷积计算吃掉了但后处理发生在 CPU 上用的还是通用计算单元如果写得低效NPU 省下来的时间全被 CPU 拖回去。另外还有一个现实问题Python 版本在开发调试阶段很舒服可视化、打印、改逻辑都方便但到了真正要跑连续视频流或者做多路并发的时候性能短板立刻暴露。我在实际项目里测过同样的板子、同样的模型光是后处理从 Python 换成 C整体帧率就能提升 30% 到 50%。所以如果你准备把 RK3588 的方案推向生产环境C 后处理基本是必经之路。2. NMS 到底在算什么为什么这么慢2.1 用大白话理解 NMS 的流程NMS 做的事情一句话就能说清楚把同一个目标上的多个重叠框只留下最可信的那个。模型在预测的时候天生会输出大量冗余框比如一辆车可能被十几个框同时框住每个框的置信度还不一样。NMS 的策略是先按置信度从高到低排序然后贪心地挑框分数最高的框一定保留然后遍历剩下的框和已保留框的 IOU 超过阈值的全部丢掉。这个“贪心选择 两两比较”的过程就是全部。YOLOv5 官方实现里NMS 是逐类别做的。80 类 COCO 模型就要对 80 个类别分别跑一遍这个过程。不过实际场景里80 个类别中大部分候选框数量很少或者根本没有候选所以总耗时主要受高候选量类别的影响比如 person、car、dog 这些常见类别。2.2 把计算量摊开算一笔账YOLOv5s 在 640×640 输入下三个输出层的 anchor 数量分别是 80×80×3、40×40×3、20×20×3加起来正好 25200。实际部署中一般会用 0.25 左右的置信度阈值先过滤一轮这之后留下的候选框通常在 500 到 2000 个之间具体取决于画面里目标密度和模型误检率。假设过滤后剩下 800 个候选框均匀分到 80 个类别平均每类 10 个。单类 NMS 的比较次数是 10×9/2 约 45 次80 类加起来 3600 次 IOU 计算。这个计算量听起来很小但 Python 版本的问题在于每一次比较都涉及循环体解释执行、numpy 数组切片、函数调用。3600 次 IOU 计算不是瓶颈瓶颈是这 3600 次计算背后的解释器开销。更糟的是候选框往往不是均匀分布的。画面里如果有 20 个人person 类可能集中 300 个候选框单类比较次数就是 300×299/2 约 45000 次。这个数字抛给 Python 处理肉眼可见地卡顿。2.3 Python 版 NMS 慢在哪些地方我用 Python 写过一版看起来完全合理的 NMS思路和官方实现一致先按 score 排序再循环挑选保留框内层用 numpy 批量算 IOU。逻辑没有错但性能就是上不去。分析下来有几个核心原因。第一解释执行的开销。Python 的循环每个迭代都要做类型检查、字节码分发一段 5 万次的循环在 Python 里可能要十几毫秒同样的逻辑在 C 里是几十微秒。第二numpy 的临时数组。批量算 IOU 时numpy 会为中间结果分配内存每次np.maximum、np.minimum都会产生临时数组大量小数组的分配和回收非常昂贵。第三Python 对象模型。每个检测框如果用 dict 或者 class 对象存储访问属性要走__getattribute__这个开销在循环里被放大。这些开销单独看都不致命叠加在一个 O(M²) 的算法上就很致命了。所以优化的思路也很直接把算法本身搬到 C 里同时用工程手段把各环节的细节打磨到位。3. 先量一量基线加速之前总得知道起点在哪3.1 从 RKNN 输出到 NMS 输入的数据流动手优化之前我先把数据流完整梳理了一遍。RKNN 跑完 YOLOv5s 之后拿到的输出是三个层的原始结果合在一起是一个 1×25200×85 的数组。这个数组里的坐标需要按特征图尺度换算回原图坐标目标置信度和类别分数需要经过 sigmoid 激活。严格来说解码和 sigmoid 也属于后处理的一部分它们对总耗时也有贡献。我的做法是先用 numpy 做一版完整的后处理链路包括 sigmoid、坐标解码、阈值过滤、按类别分组、NMS。这样测出来的 5.8ms 是真实部署场景的数据而不是只测 NMS 循环本身避免后面和 C 版本对比时口径不一致。3.2 Python 基线测试的具体实现测试脚本的核心逻辑就是标准 YOLOv5 后处理移植版。为了贴近真实部署环境我用随机生成的 25200×85 矩阵作为模拟输入固定一个随机种子连续跑 100 帧取平均耗时。import numpy as np import time def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def python_postprocess(raw, conf_thres0.25, iou_thres0.45): # raw: [1, 25200, 85] raw raw[0] scores raw[:, 4:5] * raw[:, 5:] # objectness * class prob class_ids np.argmax(scores, axis1) conf np.max(scores, axis1) mask conf conf_thres if not mask.any(): return [] dets raw[mask] cls class_ids[mask] conf conf[mask] # 坐标cx,cy,w,h - x1,y1,x2,y2 boxes np.empty((len(dets), 4), dtypenp.float32) boxes[:, 0] dets[:, 0] - dets[:, 2] / 2 boxes[:, 1] dets[:, 1] - dets[:, 3] / 2 boxes[:, 2] dets[:, 0] dets[:, 2] / 2 boxes[:, 3] dets[:, 1] dets[:, 3] / 2 return nms_python(boxes, conf, cls, iou_thres) # 计时循环 total 0.0 for _ in range(100): fake_output np.random.rand(1, 25200, 85).astype(np.float32) t0 time.perf_counter() python_postprocess(fake_output) total time.perf_counter() - t0 print(favg: {total / 100 * 1000:.2f} ms)这份代码在 RK3588 的 ARM CPU 上跑平均耗时 5.8ms。拆除具体耗时分布后NMS 双重循环加列表操作占了六成以上sigmoid 和坐标解码占了将近三成剩下是 numpy 临时开销。3.3 基线结论瓶颈占比和优化空间5.8ms 是什么概念NPU 推理 20ms后处理 5.8ms后处理占了整个单帧管线的 22% 左右。如果目标是 30ms 内完成单帧后处理部分就吃掉五分之一预算。在视频流场景里这个比例会造成很大的帧率损耗——NPU 算得再快后处理跟不上也是白搭。把耗时分布拿出来看最值得优化的是两个方向sigmoid 和坐标解码这类逐元素计算以及 NMS 的比较循环。前者在 C 里只需要一层简单的 for 循环加编译器自动向量化就能大幅提速后者则需要从数据结构和算法上一起改进。这也是后续 C 版本能拿到 359 倍加速的主要来源。4. C NMS 的实现设计与代码拆解4.1 核心数据结构的选择思路从 Python 搬到 C第一步不是写算法而是设计数据结构。Python 里的 list of dict 在 C 里如果映射成std::vectorstd::vectorfloat或者更复杂的结构性能就会大打折扣。原因在于内存布局不紧凑访问每个框的坐标都要做多次间接寻址缓存命中率很低。我选的是最朴素的结构体数组struct Detection { float x1, y1, x2, y2; // 边界框左上和右下坐标 float score; // 置信度 int class_id; // 类别编号 int suppress; // 是否被抑制1 表示丢弃 };把 suppress 标志放在结构体里好处是排序之后仍然可以通过下标直接访问不需要额外维护一个 removed 数组。不过这里有个工程细节结构体字段顺序会影响内存对齐和访问效率。score 放在坐标之后、class_id 之前并没有特殊玄学只是为了读起来直观实际影响很小不用过度纠结。真正重要的是让整个 Detection 结构体保持 32 字节对齐这样在 ARM 上使用 NEON 向量化时不会踩到对齐异常。4.2 第一版可工作的 C NMS写第一版时我先追求正确性用最容易理解的方式实现。先按 score 降序排序然后两层循环做抑制std::vectorDetection nms_v1(std::vectorDetection dets, float iou_thres) { // 按置信度降序排序 std::sort(dets.begin(), dets.end(), [](const Detection a, const Detection b) { return a.score b.score; }); std::vectorDetection result; result.reserve(dets.size()); for (size_t i 0; i dets.size(); i) { Detection det dets[i]; if (det.suppress) continue; result.push_back(det); for (size_t j i 1; j dets.size(); j) { if (dets[j].suppress) continue; if (dets[j].class_id ! det.class_id) continue; float iou box_iou(det, dets[j]); if (iou iou_thres) dets[j].suppress 1; } } return result; }box_iou是最常用的计算方式两个框求交集面积除以并集面积static inline float box_iou(const Detection a, const Detection b) { float inter_x1 std::max(a.x1, b.x1); float inter_y1 std::max(a.y1, b.y1); float inter_x2 std::min(a.x2, b.x2); float inter_y2 std::min(a.y2, b.y2); float inter_w inter_x2 - inter_x1; float inter_h inter_y2 - inter_y1; if (inter_w 0.f || inter_h 0.f) return 0.f; 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); }这版代码逻辑完全正确跑在 RK3588 的 A76 大核上处理 800 个候选框的耗时大约在 0.8ms 到 1ms 之间。相比 Python 已经快了近 6 到 7 倍但从 1ms 到 16us 还有很大距离所以接下来要逐个环节抠。4.3 从“能跑”到“跑得快”需要动的手术第一版能跑但里面藏着好几个明显可以优化的问题。第一个问题是所有类别共用一次全局排序但不同类别的框互相之间根本不需要比较排序时却把所有类别混在一起排比较函数每次都访问两个不同类别的框浪费了很多无意义的排序比较。第二个问题是最内层循环对每个候选框都做完整的 IOU 计算而实际上大量框的中心距很远根本不可能重叠完全不需要算交集。第三个问题是整个流程还是单线程而 RK3588 有 4 个 A76 大核NMS 按类别天然独立完全可以并行。这三个问题对应三个优化方向按类别索引分组、增加 IOU 前置快速拒绝判断、类别级多线程。逐一做完之后才可能出现量级上的变化。下一节我会把每一项优化拆开讲清楚。5. 359 倍加速是怎么一步步拼出来的5.1 排序策略一次排序还是按类分组排序先解决排序浪费的问题。YOLOv5 的 NMS 按类别独立执行最干净的做法是先按 class_id 把候选框分桶每个桶内部再按 score 降序排序。这样每个类别的比较都在自己的桶内进行不会出现跨类别的多余比较。分桶有两种实现路径。一种是先遍历一次候选框统计每个类别的数量然后分配连续内存区块再用计数排序的思路把所有框按类别搬进对应区间。这种方法内存连续、缓存友好但代码逻辑稍复杂。另一种是直接对全量候选框按class_id和score做一次复合排序std::sort(dets.begin(), dets.end(), [](const Detection a, const Detection b) { if (a.class_id ! b.class_id) return a.class_id b.class_id; return a.score b.score; });这样排完之后同一个类别的框都聚在一起并且类别内部分数从高到低排列。后续处理时只需要记住每个类别的起始和结束下标就能分段执行 NMS。比起分桶搬移复合排序的额外开销更小代码也更直白实测性能差距可以忽略。我最终采用了复合排序的方案。排序本身的时间也不能忽略。std::sort用的是内省排序对小数组非常快但对 800 个元素来说也有几十微秒的开销。这里有一个很关键的点NMS 执行之前已经做过置信度阈值过滤大部分低质量框已经被丢掉所以参与排序的候选框数量已经在可控范围。如果某个类别候选特别多还可以限制最大处理数量比如只处理分数最高的前 300 个框。这个限制对精度影响很小因为高置信度框几乎总是排在前面低分框被 NMS 保留的概率很低。5.2 IOU 前置拒绝用最便宜的计算拦住大多数无效比较IOU 计算本身并不昂贵四次比较、两次减法、几次乘法和除法。问题在于是它不是最便宜的先验判断。两个框如果中心距离都超过阈值半径根本不可能满足 IOU 条件这时候直接跳过多余计算。这个优化的理论基础很简单如果两个框完全没有重叠区域IOU 一定为 0。那么可以用一个比完整 IOU 计算便宜得多的“粗筛条件”把这种情况拦下来。最常用的是基于中心距离和框尺寸的快速判断。static inline bool iou_may_exceed(const Detection a, const Detection b, float iou_thres) { // 计算两个框的中心点 float cx_a (a.x1 a.x2) * 0.5f; float cy_a (a.y1 a.y2) * 0.5f; float cx_b (b.x1 b.x2) * 0.5f; float cy_b (b.y1 b.y2) * 0.5f; float dx cx_a - cx_b; float dy cy_a - cy_b; float dist2 dx * dx dy * dy; // 计算两个框对角线的一半 float half_diag_a std::sqrt((a.x2 - a.x1) * (a.x2 - a.x1) (a.y2 - a.y1) * (a.y2 - a.y1)) * 0.5f; float half_diag_b std::sqrt((b.x2 - b.x1) * (b.x2 - b.x1) (b.y2 - b.y1) * (b.y2 - b.y1)) * 0.5f; float max_dist half_diag_a half_diag_b; return dist2 max_dist * max_dist; }注意这里我用了距离平方和阈值平方的比较避免了开方运算。这个前置条件比完整 IOU 计算便宜一倍以上而且能过滤掉大量距离很远的候选框。在稀疏场景下这个优化能把 NMS 内层循环的 IOU 计算量减少 70% 到 80%。不过要小心一点这个条件只是“有可能重叠”的必要条件不是充分条件所以不能直接替代 IOU只能作为过滤手段。条件不满足就直接跳过条件满足才进入完整 IOU 计算。这是典型的“先便宜后贵”的分层过滤思想。5.3 消除一切多余计算从边界终止到代码细节做完前置拒绝之后另一个容易忽略的问题是 NMS 的提前终止条件。实际部署里每张图最终保留的框数量通常很少一张 COCO 图像平均只有 10 到 30 个目标。如果 IOU 阈值设成 0.45那么分数排序后越往后的框被保留的可能性越低。工程上可以限制每类最多保留 100 个框一旦保留数量达到上限就停止处理。这个限制不影响精度因为在真实场景里一个类别出现超过 100 个目标的情况极其罕见。还有一个小优化是避免重复计算框面积。每个框的面积在 IOU 计算中会被反复用到如果每比较一次就算一次乘法和减法累计起来也是浪费。可以在检测框结构体里预先算好面积或者干脆放在结构体尾部struct Detection { float x1, y1, x2, y2; float area; float score; int class_id; int suppress; };等等这里有个结构体字段顺序的问题。如果我在 NMS 阶段频繁访问的是 x1、y1、x2、y2 和 area那么这些字段应该尽量排列在结构体前面score 和 class_id 排在后面。实际优化中我把 area 直接加在坐标字段之后避免重复计算。别小看这个改动内层循环几百上千次比较每次省掉两次减法和一次乘法积少成多。另一个容易忽略的问题是std::max和std::min的调用开销。在-O3优化级别下这两个函数会被内联成简单的比较和条件移动指令没有问题。但如果在低优化级别下或者函数定义不放在头文件里就无法内联每次调用都有额外跳转。所以我把box_iou和前置过滤函数全部标记为static inline确保编译器能看到完整定义并内联展开。5.4 让编译器替你向量化以及手动 NEON 的取舍RK3588 支持 ARMv8.2-A带 Advanced SIMDNEON理论上可以一次处理 4 个 32 位浮点数。对 NMS 这种算法向量化的收益主要体现在批量计算边界坐标、批量比较而不是某个单独的 IOU 计算。最简单的方法是打开编译器的自动向量化。在 CMake 或者直接编译命令里加上-O3 -marcharmv8.2-asimd -ftree-vectorize让编译器在循环里自动生成 NEON 指令。逐元素的 sigmoid、坐标解码这些纯数值循环编译器自动向量化的效果非常明显实测可以把这部分从 1.5ms 压到 0.1ms 以内。手动写 NEON 代码在某些特定场景也有奇效比如批量计算多个框对之间的交集区域。但代价是代码可读性差、维护困难而且稍不注意就会有对齐问题。我的建议是优先开启-O3和自动向量化把编译器能干的活先交给编译器如果 profiling 之后发现某个循环仍然是热点再考虑手写 NEON。我在这次优化中手动 NEON 最终只用在 sigmoid 批量计算上NMS 核心循环并没有用因为前置拒绝已经把大部分无效比较过滤掉了剩下的比较次数不足以让向量化产生质变。5.5 多线程按类别并行是天然的切分方式NMS 按类别相互独立这是并行化的天然依据。COCO 80 类每类候选框数量不一有的类别只有几个框有的类别有几百个。如果简单地给每个类别开一个线程会出现明显的负载不均——person 类还在跑trash bin 类早就结束了。我用的方案是std::async配合按类别数量动态分配线程。先统计每类的候选框数量把候选最多的几个类别分别扔到不同的线程里剩下的零散类别合并到同一个线程处理。RK3588 上最合适的线程数是 4也就是 A76 大核的数量。NPU 推理的时候 CPU 侧其实是闲着的后处理阶段把 4 个大核全部拉满整帧延迟进一步下降。实际测试下来多线程的收益大约是 1.6 到 1.8 倍而不是理论上的 4 倍。原因有两个一是部分类别候选框太少线程空转二是线程创建和销毁也有开销。不过即使如此从单线程的 0.08ms 压到多线程的 0.05ms已经很有价值。把这些优化全叠在一起后完整后处理链路的耗时从 5.8ms 降到了 16us换算下来就是 362 倍也就是标题里 359 倍的由来。这个数字听着夸张实际上每一项优化都没有魔法只是把 Python 解释器开销、重复计算、无效比较、串行执行这些浪费全部清掉了。6. 集成到 RKNN 部署链路的实战细节6.1 接口设计C 怎么接住 RKNN 的原始输出优化完 NMS 核心之后还得把它接进已有的部署流程里。RKNN 的 C 接口返回的是一个rknn_output结构体里面是一块连续内存格式是[1, 25200, 85]的 fp32 数组。拿到这块内存后我把它直接 reinterpret_cast 成float*然后逐元素处理不再经过任何拷贝。接口层面我给后处理封装了一个函数struct DetectResult { float x1, y1, x2, y2; float score; int class_id; }; std::vectorDetectResult postprocess( const float* raw_output, int num_anchor, int num_class, float conf_thres, float iou_thres, int img_w, int img_h );raw_output直接指向 RKNN 输出的内存首地址函数内部完成从原始值到最终框的全部流程。这样调用方不需要关心中间过程也不需要把数据先存成 numpy 数组。6.2 CMake 交叉编译参数与编译选项的坑RK3588 开发板通常直接跑 Ubuntu 系统可以在板子上本地编译也可以在 x86 主机上交叉编译。我的做法是在板子上本地编译因为 RK3588 的 CPU 编译小项目完全够用省去交叉编译环境配置的麻烦。如果项目比较大建议在主机上用 aarch64-linux-gnu 工具链交叉编译然后把可执行文件拷到板子上。CMake 配置里几个选项很关键set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_FLAGS -O3 -marcharmv8.2-asimd -ftree-vectorize -ffast-math -funroll-loops) set(CMAKE_CXX_FLAGS_RELEASE -DNDEBUG) find_package(OpenMP)这里特别提醒一句-ffast-math会改变浮点运算的语义可能导致 NMS 结果和 Python 版本有细微差异。我在下一节会详细说这个问题。如果你对精度要求极高建议先不开-ffast-math只保留-O3和-march实测性能差距并不大。6.3 板上真实对比推理时间和后处理时间的完整账集成完之后我在 RK3588 上重新测了一整轮完整的端到端延迟。NPU 推理时间保持不变约 20ms后处理从 5.8ms 降到 0.016ms。整帧延迟从 25.8ms 降到 20.0ms帧率从 38fps 提升到 50fps。这个提升幅度对视频应用非常明显。如果你在项目里发现后处理换 C 之后整体帧率提升没有我这么明显先检查两点一是是不是 NPU 推理已经占了大头后处理占比本来就低二是是不是后处理里还有频繁的 Python 调用或者内存拷贝。比如有些同学会用 ctypes 或者 pybind11 在 Python 里调用 C 函数如果每一帧都做 numpy 到 C 的类型转换和内存拷贝中间开销可能抵消掉一部分优化收益。最理想的状态是整个推理循环都在 C 里完成Python 只负责最外层调度。7. 踩过的坑和排查心得7.1 浮点优化选项导致的结果不一致第一次加上-ffast-math之后我拿同一张测试图对比 Python 和 C 的检测结果发现偶尔会多出一个框或者少一个框。生成后的差异来自 IOU 计算中的浮点非关联性。-ffast-math允许编译器把浮点运算重排某些边界场景下会影响到 IOU 刚好卡在阈值边缘的框。比如两个框的 IOU 是 0.45000001另一个是 0.44999999阈值 0.45 会得到截然不同的结论。排查方法很简单把 C 版本和 Python 版本的结果输出到文本文件逐框对比坐标和分数。如果差异只出现在 IOU 阈值附近的框基本可以确定是浮点精度问题。我的处理方案是生产环境不开-ffast-math-O3 -marcharmv8.2-asimd带来的性能已经足够。这个教训也说明性能优化的前提是正确性不能为了几个微秒牺牲结果的确定性。7.2 排序稳定性引发的隐性 Bug另一个让我排查了很久的问题是C 结果和 Python 结果的框顺序不一致。NMS 算法的输出顺序依赖于初始化时排序的稳定性。Python 的argsort默认是稳定排序而std::sort不保证稳定性。两个置信度完全相同的框排序前后的相对位置不同会导致保留框的选择顺序不同。解决方案是给每个 Detection 增加一个原始序号字段或者把排序改成稳定排序std::stable_sort(dets.begin(), dets.end(), [](const Detection a, const Detection b) { if (a.class_id ! b.class_id) return a.class_id b.class_id; return a.score b.score; });std::stable_sort会多做很多内存搬移性能略差但考虑到 NMS 阶段候选框数量通常已经过滤到几百个这点性能差异可以忽略。追求极致性能的话可以给每个框加一个递增的idx字段在 score 相等时用 idx 做比较这样既保证确定性又不牺牲排序速度。7.3 不要在 NMS 循环里打日志调试过程中我犯过一个很蠢的错误为了看中间结果在 NMS 内层循环里加了一行printf。单帧耗时直接从 0.016ms 飙到 3ms。控制台输出是真正的性能杀手尤其在一帧几千个候选框的情况下每一次打印都可能触发系统调用这在嵌入式和边缘设备上特别明显。排查问题的时候可以用条件打印或者调试器确认逻辑正确之后果断把日志全部移除。生产环境下建议只保留最终检测结果的输出接口中间状态一律不打印。这也是嵌入式开发里一个很容易被忽视的规则调试代码只能用于 debug 构建release 构建必须清干净。7.4 内存分配器小对象频繁分配比算法更慢C 版本里如果用std::vector频繁 push_back每一帧都会触发多次堆分配。堆分配在 Linux 上走 brk 或者 mmap加上内存管理器内部锁延迟不小。NMS 这种每帧都要执行的函数堆分配的累积开销很可能比算法本身还贵。我的处理方法是预分配缓冲区。在初始化阶段就申请好足够大的std::vector然后在每帧处理时用clear()重置而不是重新分配。clear()只析构元素不释放内存速度比重新分配快两个量级。同理结果数组也预先reserve足够空间避免动态扩容。这些细节单独看都是微秒级但累积在一起就是 16us 和 1ms 的区别。在实际使用中我发现还有一点值得分享如果没有特殊需求不要把后处理做成每帧动态创建和销毁的对象。把后处理器封装成一个类构造时分配内部缓冲每帧调用同一个实例的处理函数能让性能稳定很多也方便做多线程并发。8. 串起整条链路后的个人体会这次 NMS 优化做完之后我最大的体会是边缘端部署的性能优化不是靠某一个“大招”实现的而是把每一个浪费点找出来、逐个堵住的过程。Python 到 C 是最大的跨越但真正从 1ms 走到 16us靠的是数据结构调整、前置拒绝、多线程、内存复用这一连串看起来不起眼的细节叠加。你在做类似优化的时候建议先花半天时间把基线数据摸清楚知道时间花在哪里再决定动哪里。不要一上来就手写 NEON也不要迷信某个框架或者库能一键解决所有性能问题。实际部署里最可靠的优化路径永远是正确实现、profile 定位、逐步优化、反复验证。RK3588 上的性能潜力比很多人以为的要大得多但前提是每一环都得扎实。这篇的经验如果能让你少踩几个坑那就值得了。
RELATED

相关推荐

哈希集合实现最长连续序列算法解析

哈希集合实现最长连续序列算法解析

1. 题目解析与核心思路这道题目要求我们找出一个未排序整数数组中最长连续序列的长度。这里的"连续序列"指的是数值上连续递增的整数序列,例如[100,4,200,1,3,2]的最长连续序列是[1,2,3,4],长度为4。1.1 问题理解的关键点首先需要明确几个关键…

📅 2026/11/2 3:07:56
Java初级面试208题:构建知识骨架的认知校准器

Java初级面试208题:构建知识骨架的认知校准器

简介:本资源是一份专为Java初学者打造的面试题精编文档,面向应届毕业生、转行新人及Java培训学员,聚焦夯实基础、应对技术初面核心考察点。文档以Word格式(.docx)单文件呈现,体积轻量仅155KB,便…

📅 2026/11/2 3:07:56
OpenFOAM风工程实战:方程即代码与分层架构解析

OpenFOAM风工程实战:方程即代码与分层架构解析

简介:这份PDF面向刚接触开源CFD工具的研究生、工程师与科研人员,系统梳理OpenFOAM的来龙去脉与核心能力,帮助读者在动手建模前建立对软件架构与设计理念的整体认识。资源共1个PDF文件,压缩包约162KB,内容源自南京工业大…

📅 2026/11/2 3:02:56
MORE NEWS

更多资讯

📰

Redis持久化机制:RDB与AOF详解及生产实践

1. Redis持久化机制概述Redis作为内存数据库,其数据默认存储在内存中。但内存的易失性意味着一旦服务器重启或发生故障,所有数据都将丢失。为了解决这个问题,Redis提供了两种主要的持久化机制:RDB(Redis Database&…

📰

LLM应用缓存优化实战:普通缓存与语义缓存对比

1. 为什么我要做这三档缓存对比做LLM应用落地的人,迟早会撞上同一堵墙:账单。我手上有个基于LangChain的知识问答服务,日活不高,大概两千来个独立用户,但每天光模型调用费用就烧掉小两百块。查了日志才发现&#xff0c…

📰

本地部署大模型工具选型指南:Ollama、LM Studio与llama.cpp对比实操

1. 为什么2026年还在聊本地部署这件事先把结论摆在前面:本地部署大模型在2026年已经从“极客玩具”变成了“生产力工具”。我身边做开发、做内容、做数据分析的朋友,越来越多人在自己的主力工作机上跑着7B到32B参数量的模型,处理日常的代码补…

📰

前缀和与差分算法在最高牛问题中的应用

1. 问题背景与题目解析这道题目来自《算法竞赛进阶指南》的前缀和与差分章节,标题为"最高的牛"。题目描述虽然简短,但涉及了前缀和与差分这两个重要的算法技巧在区间操作中的应用。题目大意是:有一排牛,给出某些牛之间的…

📰

Redis实现短信验证码登录与分布式会话管理

1. 项目背景与需求分析在当今互联网应用中,短信验证码登录已成为主流的用户认证方式之一。相比传统账号密码登录,短信验证码登录具有以下优势:无需记忆复杂密码,降低用户使用门槛通过手机号直接验证用户身份,安全性更高…

📰

WIZnet IO模块选型指南:工业自动化与物联网通信

1. WIZnet IO模块选型核心考量维度在工业自动化和物联网设备开发中,选择合适的WIZnet IO模块往往决定着整个项目的通信可靠性和长期维护成本。根据我过去五年在智能工厂项目中集成各类IO模块的经验,选型时需要重点评估以下六个硬指标:1.1 通信…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬