RK3588 部署 YOLO:ONNX 转换、CPU 算子判断与处理实战

RK3588 部署 YOLO:ONNX 转换、CPU 算子判断与处理实战

把 Ultralytics YOLO 部署到 RK3588 NPU,最折腾人的不是调 API,而是算子:哪些算子能上 NPU、哪些被踢回 CPU、CPU 算子怎么处理。这篇文章把ONNX→RKNN 转换流程如何判断哪些算子回退 CPUCPU 算子的三种处理策略讲透——全部基于一次真实部署的实测数据,不灌水。

TL;DR

  1. 转换.pt → ONNX(raw-head)→ RKNN。关键是用 del head.one2one_cv2/cv3 导出无 NMS 的原始头,别用端到端 [1,300,6]
  2. 判断 CPU 算子rknn-toolkit2verbose=True 编译,会打印每个算子的 Target(NPU/CPU)+ DataType——一眼看出哪些回退 CPU。
  3. 处理 CPU 算子:NPU/CPU 归属是硬件决定、改不了;唯一的办法是让图里没有这些算子。对 YOLO 的 NMS,就是 raw-head 导出 + C++ 自己做 NMS。
  4. yolo26s@640 FP16 在 RK3588 上 NPU ~101ms(= 官方基准 99.2ms),raw-head 后 0 个真实 CPU 算子

一、ONNX 模型转换:.pt → ONNX → RKNN 全流程

1.1 为什么要分两步:先 ONNX,再 RKNN

RK3588 的 .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 开关。默认端到端导出(end2end=True)会把 NMS 烤进模型,输出 [1,300,6]。我们要的是原始头 [1,4+nc,8400]

很多人第一反应是 model.export(nms=False)——对这些端到端检查点没用,导出来还是带 NMS。真正有效的是删掉 Detect 头里的 one2one 层:

from ultralytics import YOLO
m = YOLO('yolo26s.pt')
head = m.model.model[-1]                # 取 Detect 头
for attr in ('one2one_cv2', 'one2one_cv3'):
    if hasattr(head, attr):
        delattr(head, attr)             # ← 删掉 one2one 层
path = m.export(format='onnx', imgsz=640, opset=12, simplify=True, nms=False)

原理Detect.end2end 是个 property,内部 hasattr(self, "one2one");而 one2one property 又访问 self.one2one_cv2。删掉 one2one_cv2/cv3 后,访问 one2one 抛 AttributeError → hasattr 返回 False → end2end=Falseforward 跳过 NMS 分支 → 导出原始 conv 头。

导出后务必校验形状(确认 NMS 真的没了):

import onnx
shape = [d.dim_value for d in onnx.load('yolo26s.onnx').graph.output[0].type.tensor_type.shape.dim]
assert shape == [1, 84, 8400], f"应是 raw-head [1,4+nc,A],实际 {shape}"
# 如果是 [1,300,6] → NMS 没摘干净,end2end 还是 True

1.3 ONNX → RKNN:rknn-toolkit2 转换

from rknn.api import RKNN
rk = RKNN(verbose=True)   # ← verbose 很重要,第三章会用到
# 配置:mean/std 决定输入归一化。YOLO 训练输入是 RGB/255,所以 mean=0/std=255(runtime 自动 /255)
rk.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588')
rk.load_onnx(model='yolo26s.onnx')
rk.build(do_quantization=False)         # FP16(不量化);INT8 见第五章
rk.export_rknn('yolo26s.rknn')

几个坑

  • librknnrt.so 版本必须和 toolkit 匹配。模型用 toolkit 2.3.2 转,板子 runtime 也得是 2.3.2(板子自带的常是 1.5.2 老版,会导致 rknn_init abort 报 unsupport TopK op)。从 airockchip/rknn-toolkit2 对应 tag 下 rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so 覆盖。
  • 官方 YOLO.export(format='rknn') 在 ARM64 板子上导不了(官方文档明说「必须 x86 PC」)。但 rknn-toolkit2 本身在 ARM64 上能跑(load_onnx+build+export_rknn),所以板端手动转换是可行的——比官方流程还方便(不用 x86 PC)。
  • rknn_query(RKNN_QUERY_INPUT_ATTR) 前必须清零并设 attr.index,否则 v2.x runtime 报 p_attr->index(垃圾值) >= input_num

二、CPU 算子的判断:怎么知道哪些算子回退了 CPU

这是部署里最关键的一步——你得先看见问题,才能解决它。判断方法:用 rknn-toolkit2verbose 编译日志,它会打印一张完整的算子分配表。

2.1 拿到算子分配表

就是上面转换时那个 verbose=True。编译时会输出类似这样的表(每个算子一行):

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 怎么读这张表

  • 关注 Target=CPU 的行——这些就是 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 输出重定向到文件后
import re
lines = open('build.log').read()
npu = len(re.findall(r'\bNPU\s', lines))
cpu = len(re.findall(r'\bCPU\s', lines))
print(f"NPU 算子: {npu}, CPU 算子: {cpu}")
# 端到端 yolo26s: NPU 184, CPU 18(其中 15 个是 model.23 NMS)
# raw-head yolo26s: NPU 180, CPU 0(InputOperator/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-head(NMS 在 C++)
NPU 算子184180
CPU 算子18(15 个 NMS)0
TopK/Gather/Mod/NMS有 → 全 CPU 回退,INT8 下输出 0图里不存在
检测结果0 框正常检出

NMS 搬到 C++ 怎么做rknn_yolo.cppdecodeOutput):模型吐 [1, 4+nc, 8400](4 框参数 + nc 类分数,channel-major),C++ 做:

// pred 是 [1, 4+nc, A],channel-major:pred[c*A + a]
// Pass 1: 每个 anchor 找最大类分数(按 channel 顺序扫描,cache 友好)
for (int c = 0; c < nc; ++c) {
    const float* ch = pred + (4 + c) * A;      // 第 c 类的 A 个分数连续存放
    for (int a = 0; a < A; ++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 输出的框坐标在 letterbox(640×640)空间,画回原图必须反算:x_orig = (x_lb - pad_x) / scale,否则框全跑偏。端到端模型自带 NMS 会做这步;自己解码就得补上。

策略 2:替换——换用 NPU 支持的等价算子(少数情况)

如果某个 CPU 算子有 NPU 支持的等价实现,可以在导出时改写。比如某些 ReduceMax/Cast 能通过重写 ONNX 图换成 NPU 友好的形式。但对 YOLO 的 NMS(一整套 TopK+Gather+Mod),没有简单的等价替换——只能用策略 1。

策略 3:接受——CPU 算子少且无害时就留着

如果 CPU 算子只有零星几个、且不在热路径上(比如首尾的 InputOperator/OutputOperator 占位符),可以接受。raw-head 模型编译完也有 2 个 Target=CPU 的行,但那是 I/O 占位符(不算力),不影响性能。

判断标准:看 Target=CPU 的算子是不是 model.23/*(NMS 后处理)这类大块、敏感的——是的话必须消除(策略 1);是零星 I/O 占位的话可以忽略。


四、一个完整案例:端到端 vs raw-head 的算子对比

以 yolo26s 为例,编译日志统计:

端到端 [1,300,6]:
  NPU 算子 184,CPU 算子 18(TopK×2, ReduceMax, GatherElements×2, Mod, Gather, 
                              Transpose×2, Cast, Div, Reshape×3, Expand×2...)
  → 这 18 个里 15 个是 model.23 的 NMS 后处理
  → 实测:NPU 上检出 0 个(NMS 算子在 INT8 下输出全 0;FP16 也只有 ~0.001)

raw-head [1,84,8400](del one2one 后):
  NPU 算子 180,CPU 算子 0(真实计算)
  → NMS 算子全部从图里消失
  → 实测:正常检出(person=1,框准)

转换前后用第二章的方法看一遍 Target 列,model.23/* 那一坨 CPU 行从有到无——这就是「CPU 算子处理成功」的直观证据。


五、精度与性能:FP16 是甜点,INT8 是坑

算子处理完(raw-head)后,精度正常。接下来是速度。

5.1 FP16 性能(实测拆解)

yolo26s@640 FP16,加计时打点:

预处理(letterbox + memcpy)   ≈ 7 ms
NPU(inputs_set+run+outputs)  ≈ 101 ms   ← 大头,FP16 带宽受限
解码(8400 anchors + NMS)     ≈ 16 ms

NPU 101ms 和 Ultralytics 官方基准(yolo26s rknn = 99.2ms/im)完全吻合——到硬件天花板了。

优化手段及实测:

优化效果
预处理逐像素循环 → memcpyprep 27→7ms
3 核 NPU(RKNN_NPU_CORE_0_1_2无提升(FP16 是 DDR 带宽受限,加核不解带宽)
两模型并行 + 核隔离(std::async,core0_1 vs core2)5.0→6.8fps(+36%)

5.2 为什么没碰 INT8:精度塌 + 混合精度被挡

为了冲 10fps 试了 INT8,全失败

  • 纯 INT8(w8a8):~12fps 但 person 检测 1→0,检测头量化后分数全 0。
  • 自动混合精度quantized_hybrid_level=1):直接报错 This model does not support expand batch, because there is an OP of type ReduceMax——yolo26s 里的 ReduceMax 破坏了混合精度 proposal 需要的 batch 扩展。被挡死
  • 官方 Ultralytics hybrid 模型int8=True, data='coco8.yaml'):Python rknnlite 下也 0 检出——只用 8 张 COCO 图校准,INT8 精度塌了。

结论:YOLO 检测头对 INT8 极其敏感。除非用 100+ 张真实场景图精心校准(且不保证成功),否则 INT8 救不回精度。FP16 raw-head 是当前最稳的方案。


六、其它零散但重要的坑

  • rknn_contextunsigned long 不是指针:别写 ctx_ = nullptr,用 0,否则编不过。
  • OpenCV4 没有 CV_BGR2RGB:用 cv::COLOR_BGR2RGB
  • 官方 end2end 模型输入是 FLOAT16 + AFFINE_ASYMMETRIC(zp=-128):C API rknn_inputs_set(type=UINT8) 的转换跟 Python rknnlite 不一致 → 喂 uint8 得 0。所以坚持自己转 raw-head(uint8 友好)。
  • RKNN_NPU_CORE_* 核掩码rknn_init 的 flags 参数里设(不是配置文件)。多模型并行时按核隔离分配(A 模型 core0_1,B 模型 core2)。

七、给后来者的 checklist

  1. librknnrt.so 版本 == toolkit 版本(板子自带的常是旧的)。
  2. ✅ YOLO 导出 raw-headdel one2one_cv2/cv3),校验输出是 [1,4+nc,A] 不是 [1,300,6]
  3. ✅ 转换时开 verbose=True看算子分配表的 Target——确认 model.23/* 那一坨 CPU 算子消失了。
  4. ✅ C++ 解码记得 letterbox 反算 + 自己做 NMS。
  5. ✅ 输入 dtype/格式跟模型 attr 对得上(手转的 uint8 NHWC 最省心)。
  6. ✅ 性能基准:yolo26s@640 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 系列。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值