RK3588 部署 YOLOONNX 转换、CPU 算子判断与处理实战把 Ultralytics YOLO 部署到 RK3588 NPU最折腾人的不是调 API而是算子哪些算子能上 NPU、哪些被踢回 CPU、CPU 算子怎么处理。这篇文章把ONNX→RKNN 转换流程、如何判断哪些算子回退 CPU、CPU 算子的三种处理策略讲透——全部基于一次真实部署的实测数据不灌水。TL;DR转换.pt → ONNXraw-head→ RKNN。关键是用del head.one2one_cv2/cv3导出无 NMS 的原始头别用端到端[1,300,6]。判断 CPU 算子rknn-toolkit2加verboseTrue编译会打印每个算子的TargetNPU/CPUDataType——一眼看出哪些回退 CPU。处理 CPU 算子NPU/CPU 归属是硬件决定、改不了唯一的办法是让图里没有这些算子。对 YOLO 的 NMS就是 raw-head 导出 C 自己做 NMS。yolo26s640 FP16 在 RK3588 上 NPU ~101ms 官方基准 99.2msraw-head 后0 个真实 CPU 算子。一、ONNX 模型转换.pt → ONNX → RKNN全流程1.1 为什么要分两步先 ONNX再 RKNNRK3588 的.rknn是 Rockchip 专有格式由rknn-toolkit2从 ONNX 转换而来也支持从 PyTorch/TF 直转但 ONNX 最通用、最可控。所以链路是yolo26s.pt ──(ultralytics export)──▶ yolo26s.onnx ──(rknn-toolkit2)──▶ yolo26s.rknn关键点ONNX 这一步决定了模型图里有哪些算子。端到端导出会把 NMS 算子带进 ONNX → 后面全链路受累。所以必须在 ONNX 阶段就把 NMS 摘掉见第三章。1.2 ONNX 导出raw-head 的正确姿势Ultralytics 的 Detect 头有个end2end开关。默认端到端导出end2endTrue会把 NMS 烤进模型输出[1,300,6]。我们要的是原始头[1,4nc,8400]。很多人第一反应是model.export(nmsFalse)——对这些端到端检查点没用导出来还是带 NMS。真正有效的是删掉 Detect 头里的 one2one 层fromultralyticsimportYOLO mYOLO(yolo26s.pt)headm.model.model[-1]# 取 Detect 头forattrin(one2one_cv2,one2one_cv3):ifhasattr(head,attr):delattr(head,attr)# ← 删掉 one2one 层pathm.export(formatonnx,imgsz640,opset12,simplifyTrue,nmsFalse)原理Detect.end2end是个 property内部hasattr(self, one2one)而one2oneproperty 又访问self.one2one_cv2。删掉one2one_cv2/cv3后访问one2one抛 AttributeError →hasattr返回 False →end2endFalse→forward跳过 NMS 分支 → 导出原始 conv 头。导出后务必校验形状确认 NMS 真的没了importonnx shape[d.dim_valuefordinonnx.load(yolo26s.onnx).graph.output[0].type.tensor_type.shape.dim]assertshape[1,84,8400],f应是 raw-head [1,4nc,A]实际{shape}# 如果是 [1,300,6] → NMS 没摘干净end2end 还是 True1.3 ONNX → RKNNrknn-toolkit2 转换fromrknn.apiimportRKNN rkRKNN(verboseTrue)# ← verbose 很重要第三章会用到# 配置mean/std 决定输入归一化。YOLO 训练输入是 RGB/255所以 mean0/std255runtime 自动 /255rk.config(mean_values[[0,0,0]],std_values[[255,255,255]],target_platformrk3588)rk.load_onnx(modelyolo26s.onnx)rk.build(do_quantizationFalse)# FP16不量化INT8 见第五章rk.export_rknn(yolo26s.rknn)几个坑librknnrt.so版本必须和 toolkit 匹配。模型用 toolkit 2.3.2 转板子 runtime 也得是 2.3.2板子自带的常是 1.5.2 老版会导致rknn_initabort 报unsupport TopK op。从 airockchip/rknn-toolkit2 对应 tag 下rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so覆盖。官方YOLO.export(formatrknn)在 ARM64 板子上导不了官方文档明说「必须 x86 PC」。但rknn-toolkit2本身在 ARM64 上能跑load_onnxbuildexport_rknn所以板端手动转换是可行的——比官方流程还方便不用 x86 PC。rknn_query(RKNN_QUERY_INPUT_ATTR)前必须清零并设attr.index否则 v2.x runtime 报p_attr-index(垃圾值) input_num。二、CPU 算子的判断怎么知道哪些算子回退了 CPU这是部署里最关键的一步——你得先看见问题才能解决它。判断方法用rknn-toolkit2的verbose 编译日志它会打印一张完整的算子分配表。2.1 拿到算子分配表就是上面转换时那个verboseTrue。编译时会输出类似这样的表每个算子一行D RKNN: ID OpType DataType Target InputShape OutputShape Cycles(DDR/NPU/Total) FullName D RKNN: 0 InputOperator FLOAT16 CPU - (1,3,640,640) 0/0/0 InputOperator:images D RKNN: 1 ConvExSwish FLOAT16 NPU (1,3,640,640),... (1,32,320,320) 381160/1843200/1843200 Conv:/model.0/conv/Conv D RKNN: 2 ConvExSwish FLOAT16 NPU ... ... D RKNN: 177 Transpose FLOAT16 CPU ... ... ... Transpose:/model.23/Transpose D RKNN: 181 TopK FLOAT16 CPU ... ... ... TopK:/model.23/TopK D RKNN: 184 GatherElements FLOAT16 CPU ...Target列就是答案NPU 在 NPU 跑CPU 回退 CPU。2.2 怎么读这张表关注TargetCPU的行——这些就是 NPU 跑不了、被踢回 CPU 的算子。端到端 YOLO 的 CPU 算子几乎全集中在model.23/*检测头的 NMS 后处理TopK/GatherElements/Mod/ReduceMax/Gather/Transpose/Cast/Div……还有个Cycles(DDR/NPU/Total)列能估算每个算子的耗时——CPU 算子的 cycles 标 0不在 NPU 跑但实际 CPU 执行有开销且会打断 NPU 流水线、引入数据搬运。快速统计 NPU/CPU 算子数量解析 verbose 日志# 把 verbose 输出重定向到文件后importre linesopen(build.log).read()npulen(re.findall(r\bNPU\s,lines))cpulen(re.findall(r\bCPU\s,lines))print(fNPU 算子:{npu}, CPU 算子:{cpu})# 端到端 yolo26s: NPU 184, CPU 18其中 15 个是 model.23 NMS# raw-head yolo26s: NPU 180, CPU 0InputOperator/OutputOperator 是占位符不算力2.3 为什么这些算子只能 CPU硬件指令集决定RK3588 的 NPU 是专用卷积/矩阵加速器硬件指令集只支持Conv/Concat/Add/Resize/Split/Transpose/Sigmoid/Mul/Sub等张量运算。它不支持TopK/Gather/GatherElements/Mod/NonMaxSuppression/ReduceMax沿非常规轴这类索引/选择/排序类算子。编译器遇到这些只能回退到 CPU 执行。这个归属改不了——不是配置项是硅片决定的。所以处理 CPU 算子的思路不是「让它上 NPU」而是见下一章。三、CPU 算子的处理三种策略发现 CPU 算子后有三种处理思路按优先级策略 1消除——让图里根本没有这些算子首选对 NMS 最有效既然 NPU 跑不了 NMS那就把 NMS 从模型图里拆出去搬到 C 做。这就是 raw-head第一章的del one2one。效果实测端到端NMS 在图里raw-headNMS 在 CNPU 算子184180CPU 算子1815 个 NMS0TopK/Gather/Mod/NMS有 → 全 CPU 回退INT8 下输出 0图里不存在检测结果0 框正常检出NMS 搬到 C 怎么做rknn_yolo.cpp的decodeOutput模型吐[1, 4nc, 8400]4 框参数 nc 类分数channel-majorC 做// pred 是 [1, 4nc, A]channel-majorpred[c*A a]// Pass 1: 每个 anchor 找最大类分数按 channel 顺序扫描cache 友好for(intc0;cnc;c){constfloat*chpred(4c)*A;// 第 c 类的 A 个分数连续存放for(inta0;aA;a)if(ch[a]best[a]){best[a]ch[a];cls[a]c;}}// Pass 2: 过阈值的 anchor → xywh 转 xyxy → 反 letterbox 回原图 → clip// Pass 3: cv::dnn::NMSBoxes 去重这是纯 CPU 常规后处理~16ms可靠可调——和那种「挂在 NPU 推理上下文里、跟图绑死、INT8 下输出 0」的 baked-in NMS 完全不同。顺带一个坑raw-head 输出的框坐标在letterbox640×640空间画回原图必须反算x_orig (x_lb - pad_x) / scale否则框全跑偏。端到端模型自带 NMS 会做这步自己解码就得补上。策略 2替换——换用 NPU 支持的等价算子少数情况如果某个 CPU 算子有 NPU 支持的等价实现可以在导出时改写。比如某些ReduceMax/Cast能通过重写 ONNX 图换成 NPU 友好的形式。但对 YOLO 的 NMS一整套 TopKGatherMod没有简单的等价替换——只能用策略 1。策略 3接受——CPU 算子少且无害时就留着如果 CPU 算子只有零星几个、且不在热路径上比如首尾的InputOperator/OutputOperator占位符可以接受。raw-head 模型编译完也有 2 个TargetCPU的行但那是 I/O 占位符不算力不影响性能。判断标准看TargetCPU的算子是不是model.23/*NMS 后处理这类大块、敏感的——是的话必须消除策略 1是零星 I/O 占位的话可以忽略。四、一个完整案例端到端 vs raw-head 的算子对比以 yolo26s 为例编译日志统计端到端 [1,300,6] NPU 算子 184CPU 算子 18TopK×2, ReduceMax, GatherElements×2, Mod, Gather, Transpose×2, Cast, Div, Reshape×3, Expand×2... → 这 18 个里 15 个是 model.23 的 NMS 后处理 → 实测NPU 上检出 0 个NMS 算子在 INT8 下输出全 0FP16 也只有 ~0.001 raw-head [1,84,8400]del one2one 后 NPU 算子 180CPU 算子 0真实计算 → NMS 算子全部从图里消失 → 实测正常检出person1框准转换前后用第二章的方法看一遍Target列model.23/*那一坨 CPU 行从有到无——这就是「CPU 算子处理成功」的直观证据。五、精度与性能FP16 是甜点INT8 是坑算子处理完raw-head后精度正常。接下来是速度。5.1 FP16 性能实测拆解yolo26s640 FP16加计时打点预处理(letterbox memcpy) ≈ 7 ms NPU(inputs_setrunoutputs) ≈ 101 ms ← 大头FP16 带宽受限 解码(8400 anchors NMS) ≈ 16 msNPU 101ms 和Ultralytics 官方基准yolo26s rknn 99.2ms/im完全吻合——到硬件天花板了。优化手段及实测优化效果预处理逐像素循环 → memcpyprep 27→7ms3 核 NPURKNN_NPU_CORE_0_1_2无提升FP16 是 DDR 带宽受限加核不解带宽两模型并行 核隔离std::asynccore0_1 vs core25.0→6.8fps36%5.2 为什么没碰 INT8精度塌 混合精度被挡为了冲 10fps 试了 INT8全失败纯 INT8w8a8~12fps 但 person 检测 1→0检测头量化后分数全 0。自动混合精度quantized_hybrid_level1直接报错This model does not support expand batch, because there is an OP of type ReduceMax——yolo26s 里的ReduceMax破坏了混合精度 proposal 需要的 batch 扩展。被挡死。官方 Ultralytics hybrid 模型int8True, datacoco8.yamlPython rknnlite 下也 0 检出——只用 8 张 COCO 图校准INT8 精度塌了。结论YOLO 检测头对 INT8 极其敏感。除非用 100 张真实场景图精心校准且不保证成功否则 INT8 救不回精度。FP16 raw-head 是当前最稳的方案。六、其它零散但重要的坑rknn_context是unsigned long不是指针别写ctx_ nullptr用0否则编不过。OpenCV4 没有CV_BGR2RGB用cv::COLOR_BGR2RGB。官方 end2end 模型输入是FLOAT16 AFFINE_ASYMMETRIC(zp-128)C APIrknn_inputs_set(typeUINT8)的转换跟 Python rknnlite 不一致 → 喂 uint8 得 0。所以坚持自己转 raw-headuint8 友好。RKNN_NPU_CORE_*核掩码在rknn_init的 flags 参数里设不是配置文件。多模型并行时按核隔离分配A 模型 core0_1B 模型 core2。七、给后来者的 checklist✅librknnrt.so版本 toolkit 版本板子自带的常是旧的。✅ YOLO 导出raw-headdel one2one_cv2/cv3校验输出是[1,4nc,A]不是[1,300,6]。✅ 转换时开verboseTrue看算子分配表的Target列——确认model.23/*那一坨 CPU 算子消失了。✅ C 解码记得letterbox 反算 自己做 NMS。✅ 输入 dtype/格式跟模型 attr 对得上手转的 uint8 NHWC 最省心。✅ 性能基准yolo26s640 FP16 ≈ 100ms/im这是 RK3588 的物理天花板别指望 INT8 一量化就又快又准。核心心法RK3588 NPU 的算子归属是硬件决定的处理 CPU 算子的正解是「让图里没有它」而不是想办法让它上 NPU。对 YOLO 来说就是把 NMS 从模型里拆到 C——这一步做对后面就顺了。所有数据在 RK3588 librknnrt v2.3.2 rknn-toolkit2 2.3.2 上实测。yolo26s 指 Ultralytics YOLO26 系列。