RK3588 的 NPU 白嫖实践系列第三篇来了。前两篇聊的是怎么把 YOLOv5s 训练到能用的状态、怎么从 PyTorch 导出干净的 ONNX这篇解决的是整个部署链路里最容易翻车的一环把 ONNX 转成 RKNN同时完成 INT8 量化让模型真正在 NPU 上跑起来。整个系列的目标很朴素不搞 PPT 里的“边缘 AI 落地”就是把 YOLOv5s 放到一块 RK3588 开发板上拿到一个能用的实时帧率。这篇的主要内容就两块rknn-toolkit2 的使用流程以及 INT8 量化那些坑。我会把转换脚本、校准集准备、精度对比、板端推理调用都过一遍再附上实测性能数据和问题排查记录。如果你是第一次碰 RK3588 部署 YOLO这篇基本可以当操作手册用如果你已经转出过 rknn 文件那直接跳到第 3 章看量化和第 5 章的排障应该能找到些值回票价的东西。1. 为什么非要转 RKNNRK3588 的 NPU 只认自家格式1.1 从 ONNX 到 RKNN一层隐形的“编译器”很多朋友第一次接触 RKNN 会有个疑问我上一篇文章不是在 x86 机器上把 PyTorch 模型导出成 ONNX 了吗ONNX 不是号称跨平台、通用中间表示吗为什么不能直接把 .onnx 丢到 RK3588 上跑答案在于 RK3588 的 NPU 并不是传统意义上的 GPU 或者 CPU它有一套完全私有的指令集和算子实现。ONNX 只是描述计算图的“图纸”NPU 不会看图施工它只认瑞芯微自己定义的指令序列和数据布局。rknn-toolkit2 在这里扮演的角色就是交叉编译器 图优化器的合体。它会做三件事把 ONNX 的算子映射到 NPU 支持的算子列表把浮点网络按照量化配置重写成整数运算图最后把计算图组织成适合 NPU 异构调度的执行单元。这也是为什么 RKNN 转换听起来简单做起来经常出幺蛾子。某个算子没被 NPU 支持、某个 reshape 的行为和预期不一致、某个自定义层被忽略都会导致转换失败或推理结果错误。我见过有人在 GitHub 上问“为什么我的 RKNN 转出来比 ONNX 慢十倍”十有八九是模型里大量的算子掉到了 CPU 上去执行NPU 根本没吃上力。所以转换之前先认清一件事RKNN 不是“一锤子买卖”的格式而是你整条部署链路的第一个工程质量关卡。1.2 环境准备一套能跑通的最小工具链工欲善其事必先利其器。RK3588 的转换工具链有两种玩法一是直接在板子上跑 rknn-toolkit2板端乌班图环境可以装但性能拉胯转换大模型能把 CPU 干烧二是在 x86 主机上装 rknn-toolkit2 的 Python 包转换完成后把 .rknn 文件拷贝到板子再用板端 runtimelibrknnrt.so加载推理。第二种是主流做法我推荐所有人无脑用。在 x86 主机上我的建议是用 conda 建一个独立 Python 环境Python 版本选 3.8 或者 3.10然后 pip 安装 rknn-toolkit2。注意瑞芯微官方 GitHub releases 里提供了.whl文件不同版本对应不同的 runtime 库务必让转换端和板端 runtime 版本保持一致否则会出现“板子加载失败”或者“init 卡死”这种玄学问题。我踩过一次的教训是转换端用 1.6.0 版的 toolkit、板端却放了一个 0.8.x 的 librknnrt.so结果 init 返回一个不明所以的错误码排查了一下午。板端环境也顺手说一句。RK3588 的 Linux 系统上跑 YOLOv5s 推理只需要三样东西librknnrt.soruntime 核心库、librknn_api.soC API 封装、以及 Python 部署时可选装的 rknn-toolkit-lite2。其中 librknnrt.so 必须和宿主内核、硬件匹配一般直接把官方 releases 包里的对应版本拷到/usr/lib/即可。如果你的板子跑的是 Ubuntu 20.04也可以直接用 apt 装系统依赖但 runtime 库一定不要用 apt 源里的老版本不然 NPU 驱动不认账跑起来全是乱码输出。2. ONNX 转 RKNN 的核心环节2.1 配置与加载mean 和 std 决定量化的起点先给出一段我实际使用的转换脚本然后逐行拆解。完整脚本大概长这样import os from rknn.api import RKNN rknn RKNN(verboseTrue) # 1. 配置转换参数 rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588, ) # 2. 加载 ONNX ret rknn.load_onnx(model./yolov5s.onnx) assert ret 0, load onnx failed # 3. 构建 RKNN开启 INT8 量化 ret rknn.build( do_quantizationTrue, dataset./dataset.txt, pre_compileFalse, ) assert ret 0, build failed # 4. 导出 ret rknn.export_rknn(./yolov5s_rknn_int8.rknn) assert ret 0, export failed rknn.release()先说mean_values和std_values。这两个参数很容易被忽略但实际上是整个转换链路里最容易埋雷的地方。YOLOv5 训练时把像素值除以 255 归一化到 [0,1]所以如果训练脚本里用的就是(x / 255 - mean) / std这种流程那么转换配置里 mean[[0,0,0]]、std[[255,255,255]] 就是在把“归一化操作”送进 NPU 去执行。换句话说只要你在 config 里设置了这两个值板端输入就应该是原始 0~255 的 BGR 图片不用再手动归一化NPU 会在预处理阶段自动完成减均值、除方差。这个机制和 ONNX 转 RKNN 时对输入张量的解释方式有关转换工具默认认为你给的输入范围是 0~255于是通过 mean/std 在模型内部做归一化。如果你在 config 里不设置任何值那么板端输入就必须提前归一化好否则模型出来的分数全乱。两种做法都可行但只能选一种别两头都做。另外注意load_onnx时对输入 shape 的要求。YOLOv5s 在导出 ONNX 时一般会固定成 640x640 输入如果导出时用了动态 batch 或动态分辨率RKNN 转换不一定统一处理建议老老实实用静态 shape 的 ONNX。2.2 构建与导出量化开关和预编译不是一回事build()里最核心的参数是do_quantization。把它设为 True转换工具会对模型做 INT8 量化设为 False 则输出一个全精度float 图、fp16 存储的 RKNN 模型。很多人第一次测试全链路时喜欢先关掉量化跑一版浮点模型这个思路非常对。我建议的流程是先导出一版不量化的 rknn板端推理确认结果和 ONNX 基本一致再开量化对比精度变化。这样出了问题容易定位是“转换环节的问题”还是“量化掉点的问题”。dataset参数传入的是一个文本文件路径文件内容是一行一张用于统计激活值分布的图片路径。这个文件只在开启量化后才会被使用。关于校准图片的选择我先卖个关子第 3 章专门展开讲。pre_compile这个参数我建议默认先不开。它的作用是固定 NPU 编译产物让板端首次 init 模型更快但会牺牲一部分转换灵活性。如果你的算法还在迭代、模型结构可能还要调就不要加等模型结构稳定要量产部署了再开 pre_compile 压缩板端加载时间。export_rknn导出时还会检查是否设置outputs指定输出节点。YOLOv5s 导出的 ONNX 一般有 3 个输出头对应 80x80、40x40、20x20 三个尺度的检测结果。rknn-toolkit2 默认会继承 ONNX 的全部输出节点所以无需手动指定。如果某些裁剪过的模型只保留了一个输出头那就要在export_rknn之前用rknn.load_onnx(..., outputs[yolov5s])这类参数去限定输出节点否则板端拿到的输出维度会和后处理对不上。3. INT8 量化为什么值得做、怎么做稳3.1 量化原理与 RK3588 的算力账RK3588 的 NPU 标称算力是 6 TOPSINT8这是很多人选择这块芯片的核心原因。但很多人不知道的是如果跑 FP16 模型算力直接腰斩到 3 TOPS 左右如果跑 FP32那基本是在用 NPU 模拟器“硬算”性能惨不忍睹。这就是为什么 INT8 量化几乎成了 RK3588 部署 YOLO 的必经之路——它不是“可选项”而是让 NPU 发挥真实性能的前提条件。INT8 量化本质上是线性量化把一段浮点范围内的数值映射到 [-128, 127] 的整数空间。每个张量或每个通道会有一组 scale 和 zero_point推理时乘法变成整数乘加激活值变成整数加减。由于 NPU 的矩阵乘单元对 INT8 有原生支持吞吐量远高于同尺寸的浮点运算所以量化模型在 NPU 上的帧率提升非常可观。不过要泼一盆冷水量化不是白嫖的。如果校准做得不好YOLOv5s 的 mAP0.5 掉 5 个点以上是常有的事。问题往往不在模型本身而在校准数据集的覆盖度和量化粒度的选择。我在 3.2 和 3.3 里展开讲。3.2 校准数据集几十张图就能决定精度上限校准数据集是 INT8 量化最重要的“隐形参数”。它的作用不是训练模型而是通过一批具有代表性的输入图片跑一遍伪量化过程统计每一层激活值的直方图和动态范围然后为每个量化层确定最优的 scale 和 zero_point。换句话说校准集决定了你的量化模型能保留多少精度。dataset.txt的格式非常简单每行一个图片路径/path/to/dataset/img_001.jpg /path/to/dataset/img_002.jpg /path/to/dataset/img_003.jpg那该放多少张图、放什么样的图我自己的经验是起步 100 张左右覆盖你要检测的主要场景即可。比如说你以后要在园区场景检测行人车辆那校准集里就不要全是网上爬的猫咪图片而要放足够多的行人车辆样本要覆盖不同亮度、不同角度、不同远近。原则上校准集的分布越接近真实推理时的输入分布量化后的精度损失越小。还有一个容易踩的坑dataset.txt 里的图片一定要用输入尺寸等比缩放到 640x640 之后的内容工具对每个文件会做类似 OpenCVresize的预处理但如果你混入了一些图是横屏、一些是竖屏NPU 内部的 resize 逻辑可能造成裁切错位进而污染量化统计。我习惯先用 Python 脚本统一把图片整理成 640x640 的方形图、采样方式固定为双线性再写 dataset.txt。校准图片数量也不是越多越好。超过 500 张之后耗时明显上升精度提升却基本可以忽略。毕竟量化统计的是动态范围分布不是追求测试准确率。另外校准集不要使用验证集里用来打分的同一批图片否则你后面做精度评估时看到的指标会偏乐观本质上是一种数据泄漏。3.3 精度评估与混合量化调优转换完第一版 INT8 模型后一定要做精度评估不要只在板子上“目测”几张图片正常就完事。rknn-toolkit2 提供了accuracy_analysis接口可以逐层对比浮点模型和量化模型的输出误差。更实用的是在板端或 PC 端对同一组验证集分别跑 ONNX 浮点推理和 RKNN INT8 推理统计 mAP 指标。自己写评估脚本也没多复杂把 RKNN 的输出按 YOLOv5 的后处理流程解码即可。以我实测的 YOLOv5sCOCO 类输入 640x640为例量化前后的精度对比大致如下模型版本mAP0.5mAP0.5:0.95推理耗时RK3588 单核ONNX FP3256.8%37.2%CPU 上无法实时约 1sRKNN FP16未量化56.5%36.9%约 28msRKNN INT8普通量化54.1%33.8%约 18msRKNN INT8混合量化调优55.2%35.1%约 19ms从这个表能看出几件事。FP16 相比 FP32 基本无损INT8 则会有 2~4 个点的 mAP 下滑这在很多业务场景里是可接受的但只要多掉一两个点就可能影响小目标召回。如果你对精度要求高可以尝试混合量化。混合量化的思路是让网络中那些对量化敏感的层继续用 FP16其余层用 INT8。rknn-toolkit2 支持通过quantized_dtype和逐层config的方式指定某些算子的量化策略。不过 RKNN 的混合量化调优在 1.x 版本里更多是“黑盒策略”而非逐层手控。实际操作中我更推荐先用accuracy_analysis找出 RMS 误差最大的若干层然后通过分段排查的方式确定哪些层拖了后腿。还有一种更直接的办法从 per-channel 量化改成 per-tensor 量化试试或反过来YOLOv5s 的 BN 层在两种策略下表现差异很大。如果调了一圈 INT8 掉点还是超过 3 个点还有一个备胎方案在导出 ONNX 之前先对训练好的模型做“量化感知训练”的微调QAT让模型适应 INT8 的数值分布。不过 QAT 需要改动训练管线和数据加载器对 Coco 这类数据集的复训成本不低。前期先靠校准集和混合量化解决 80% 的问题是最划算的路径。4. 板端部署与性能调优实测4.1 板端 runtime 与推理流程转换好yolov5s_rknn_int8.rknn之后把它拷到板子上。板端 Python 推理我用的工具是 rknn-toolkit-lite2核心代码非常简洁from rknnlite.api import RKNNLite rknn_lite RKNNLite() ret rknn_lite.load_rknn(./yolov5s_rknn_int8.rknn) assert ret 0 ret rknn_lite.init_runtime(core_maskRKNNLite.NPU_CORE_AUTO) assert ret 0 # 读取 0~255 的 BGR 原图 import cv2 img cv2.imread(./test.jpg) img_resized cv2.resize(img, (640, 640)) # 推理输出是一个 list包含 3 个尺度的检测头数据 outputs rknn_lite.inference(inputs[img_resized]) print([o.shape for o in outputs])注意几个细节。第一inference的输入必须是 NCHW 排列的原始图像数据吗不一定rknn-toolkit2 在转换时默认按 NHWC 排序输入取决于 config 里的quant_img_RGB等参数实际以转换脚本为准。如果不确定去 outputs 的第一个张量 shape 里看一眼就能推断。第二上面这段推理传入的是 0~255 的 BGR 图像不做额外归一化因为 config 里已经配置过 mean/std。第三core_mask参数可以指定 NPU 核心调度策略NPU_CORE_AUTO会自动选择最优核心也可以强制单核、双核或三核。板端的推理结果里默认情况下 RKNN 的 runtime 做了反量化你拿到的输出是 float 数组。这对后处理来说很方便——你可以沿用 ONNX 浮点输出时的 decode 逻辑。如果你的输出头是 INT8 且后端忘了反量化那拿到的整数数组再配合每个输出张量的 scale/zero_point 也能换算但没必要自找麻烦。4.2 实测性能数据和调参技巧我在一块 RK3588 开发板上Ubuntu 20.04 系统CPU 和 NPU 均未做超频的默认状态下跑 YOLOv5s 640x640 的实测数据是INT8 量化模型单核 NPU预处理resize 不算加推理加 NMS整体单帧 22~25ms如果只统计inference接口的耗时大约 15~18ms启用 NPU 三核并行后单帧推理可以压到 10ms 左右。FP16 模型单核大概 25ms 左右和三核 INT8 比差距很明显。数据会因板卡电源策略、NPU 驱动版本和模型具体结构略有浮动但整体量级可以作为部署预估的参考。想榨干性能我有几个实际用过的技巧。第一个是固定输入分辨率。如果你的业务允许把输入从 640 降到 512 甚至 416推理耗时会近似按像素规模下降尤其在 NPU 上非常明显。很多端侧场景不需要很高的输入分辨率YOLOv5s 在 416 输入下依然能保持可接受的目标检测效果。第二个是减少后处理开销。YOLOv5 默认的 NMS 用 PyTorch 实现在板端 Python 环境里跑会吃掉不少 CPU 时间建议换成纯 numpy 或者 C 实现的 NMS。第三个是使用多线程批量推理同一条多线程流水线里输入抓帧、NPU 推理、输出后处理三个环节并行可以在不改变单帧延迟的前提下提升整体吞吐。还有一个容易忽略的优化点量化的输入格式。RKNN 模型默认期望的输入是 uint8也就是说 NPU 对输入张量的位宽也有要求。如果你在板端直接把 float 归一化后的数据传进去runtime 会多一步类型转换反而更慢。我的建议是保持 0~255 uint8 输入让 NPU 内部完成归一化这也正是 2.1 说的 mean/std 配置的意义之一。5. 常见问题排查实录整个 RKNN 转换 INT8 量化的过程我前前后后踩的坑不少整理成一张速查表。如果你遇到类似问题可以直接按表排查。现象可能原因解决办法load_onnx报算子不支持ONNX opset 版本偏高或模型中包含自定义算子导出 ONNX 时选择 opset 11~13检查是否有 Focus、特殊激活等必要时对 ONNX 做简化onnxsim 工具build时提示输入 shape 不匹配ONNX 模型是动态 shape或 dataset 图片尺寸不为 640x640固定输入 shape 重新导出 ONNX统一校准图片尺寸板端 init 失败或返回异常错误码转换端和板端 runtime 版本不一致确认 rknn-toolkit2 版本与 librknnrt.so 版本匹配用官方配对发布包量化后 mAP 掉点严重校准集分布不对、校准图片数量太少、某些层过于敏感增加校准图片数量更换更接近真实数据的校准集开启混合量化或 QAT 微调推理结果全是 0 或乱码mean/std 配置与训练不一致输入图像通道顺序不对核对归一化流程确认输入是 BGR 还是 RGB必要时用cv2.cvtColor转换NPU 推理反而比 CPU 慢大量算子回退到 CPU 执行NPU 核心分配不正确用verbose日志检查算子映射尽量用 NPU 支持的算子设置core_maskNPU_CORE_AUTO模型加载/init 时间很长RKNN 模型首次 init 需要编译/解析对稳定模型开启pre_compileTrue上线前预加载模型上面表格里第一行值得多说一句。YOLOv5 的导出 ONNX 在很多时候会带上一些比较“刁钻”的算子比如早期的 Focus 层会被展开成切片加拼接。这些操作在 ONNX 里看着没问题但 NPU 对某些 reshape/slice 模式的实现效率低甚至不支持。一个有效的处理手段是对 ONNX 模型做onnx-simplifier把冗余节点折叠掉。化简之后如果还有不支持的算子再考虑在 PyTorch 侧调整模型结构尽量避免使用 YOLOv5 本身不太常见的那几个边缘算子。再说一个后处理相关的坑。RKNN 输出的 3 个 feature mapshape 一般对应[1, 3, 80, 80, 85]、[1, 3, 40, 40, 85]、[1, 3, 20, 20, 85]这个 85 是 4 个 bbox 坐标 1 个 objectness 80 个类别分数。如果你的 YOLOv5s 用的是自定义类别数最后一维要改成5 num_classes。很多朋友转换完模型拿官方 demo 的后处理代码一跑发现什么都检测不出来十有八九就是类别数量对不上或者是 ONNX 输出节点顺序和 YOLOv5 官方后处理代码里假设的顺序不一致。最后分享一个我在排查“乱码输出”时总结的套路先用不量化的 FP16 RKNN 模型测一遍如果 FP16 输出正常而 INT8 输出异常问题几乎肯定出在量化环节如果 FP16 就不正常那问题大概率出在输入预处理或算子映射上。这个二分法能帮你把问题范围直接缩小一半省掉无数次盲改重启。整个 RKNN INT8 的流程我从摸索到跑通大概花了两周中间最折磨人的不是转换本身而是工具链版本和量化精度这两件事。现在回头想如果一开始就能严格遵循“版本配对 校准集覆盖 先 FP16 后 INT8”这三个原则至少能少走一半弯路。RK3588 的部署链路走到这一步模型已经在 NPU 上实时跑起来了接下来值得深入的就是 C API 部署、多路视频流接入、以及把检测结果接入 FFmpeg 推流这样的完整业务闭环。等我把那部分工程化细节再沉淀一下回来继续写第四篇。