RK3588 部署 YOLO:ONNX 转换、CPU 算子判断与处理实战
把 Ultralytics YOLO 部署到 RK3588 NPU,最折腾人的不是调 API,而是算子:哪些算子能上 NPU、哪些被踢回 CPU、CPU 算子怎么处理。这篇文章把ONNX→RKNN 转换流程、如何判断哪些算子回退 CPU、CPU 算子的三种处理策略讲透——全部基于一次真实部署的实测数据,不灌水。
TL;DR
- 转换:
.pt → ONNX(raw-head)→ RKNN。关键是用del head.one2one_cv2/cv3导出无 NMS 的原始头,别用端到端[1,300,6]。 - 判断 CPU 算子:
rknn-toolkit2加verbose=True编译,会打印每个算子的Target(NPU/CPU)+DataType——一眼看出哪些回退 CPU。 - 处理 CPU 算子:NPU/CPU 归属是硬件决定、改不了;唯一的办法是让图里没有这些算子。对 YOLO 的 NMS,就是 raw-head 导出 + C++ 自己做 NMS。
- 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=False → forward 跳过 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_initabort 报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-toolkit2 的 verbose 编译日志,它会打印一张完整的算子分配表。
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 算子 | 184 | 180 |
| CPU 算子 | 18(15 个 NMS) | 0 |
| TopK/Gather/Mod/NMS | 有 → 全 CPU 回退,INT8 下输出 0 | 图里不存在 |
| 检测结果 | 0 框 | 正常检出 |
NMS 搬到 C++ 怎么做(rknn_yolo.cpp 的 decodeOutput):模型吐 [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)完全吻合——到硬件天花板了。
优化手段及实测:
| 优化 | 效果 |
|---|---|
| 预处理逐像素循环 → memcpy | prep 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_context是unsigned long不是指针:别写ctx_ = nullptr,用0,否则编不过。- OpenCV4 没有
CV_BGR2RGB:用cv::COLOR_BGR2RGB。 - 官方 end2end 模型输入是
FLOAT16 + AFFINE_ASYMMETRIC(zp=-128):C APIrknn_inputs_set(type=UINT8)的转换跟 Python rknnlite 不一致 → 喂 uint8 得 0。所以坚持自己转 raw-head(uint8 友好)。 RKNN_NPU_CORE_*核掩码在rknn_init的 flags 参数里设(不是配置文件)。多模型并行时按核隔离分配(A 模型 core0_1,B 模型 core2)。
七、给后来者的 checklist
- ✅
librknnrt.so版本 == toolkit 版本(板子自带的常是旧的)。 - ✅ YOLO 导出 raw-head(
del one2one_cv2/cv3),校验输出是[1,4+nc,A]不是[1,300,6]。 - ✅ 转换时开
verbose=True,看算子分配表的Target列——确认model.23/*那一坨 CPU 算子消失了。 - ✅ C++ 解码记得 letterbox 反算 + 自己做 NMS。
- ✅ 输入 dtype/格式跟模型 attr 对得上(手转的 uint8 NHWC 最省心)。
- ✅ 性能基准: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 系列。
1610

被折叠的 条评论
为什么被折叠?



