☰
瑞芯微RK3576图像分类部署:ONNX转RKNN与INT8量化实测
2026/10/13 21:21:19 网站建设 项目流程

做端侧部署这件事,最容易产生的错觉就是模型在 PC 上能跑,到了嵌入式板子上就一定也能跑。最近我基于瑞芯微 RK3576 芯片做了一轮系统性的图像分类模型部署实验,把一批主流分类网络从 ONNX 转成 RKNN 格式,在板卡 NPU 上完成了 INT8 量化推理,同时把延迟、精度、内存占用和算子兼容性全部录了下来。这篇文章不讲某一个模型的单独部署,而是把整个实验怎么设计、中间遇到哪些坑、最终数据说明了什么事情,完整交代清楚。如果你正在评估 RK3576 的算力,或者准备在这块平台上跑视觉模型,这篇应该能帮你省掉不少摸索时间。

1. 实验目标与评估框架:先想清楚再跑板子

1.1 为什么选择图像分类任务

图像分类是整个视觉算法体系里最基础、也最能反映 NPU 底子的一项任务。它不像目标检测那样依赖大量后处理,也不像分割任务那样有 pixel-level 输出,分类网络的结构相对规矩,主干大多由卷积、批归一化、池化、全连接、Softmax 这些常见算子组成。所以在一颗新的芯片上做算力摸底,分类模型是最合适的“标尺”。

我选择 RK3576 作为实验平台,主要是看中它片上集成的 NPU 算力比较均衡,官方标称的整数算力在 6 TOPS 左右。这个量级放在端侧设备里有很明确的应用场景:IPC 的周界识别、门禁的人脸分类、工业质检的缺陷类型判断,甚至是农业上的农作物叶片病害分类,都落在 224x224 或 320x320 这种输入尺寸上。跑分类模型不是为了刷榜,而是为了验证一件事:这颗芯片在真实业务负载下,到底能把模型压到多少毫秒、多少帧,精度损失又控制在什么范围内。

另一个原因是 RK3576 的推理链路足够完整。从模型转换工具到板端推理运行时都有配套方案,转换过程支持 ONNX、PyTorch 等常见格式,这让我可以把精力集中到模型改造和精度对比上,而不是纠结底层算子怎么适配。

1.2 评估指标怎么定

实验没有只看推理延迟,我把评估拆成四个维度:

  • 延迟:模型单次推理耗时,单位毫秒,这是最直观的算力体现。
  • 精度:INT8 量化后的 Top-1 准确率,以及相对 FP32 参考值的下降幅度。
  • 内存:推理过程中板卡进程峰值占用,影响同设备能否继续并发其它算法。
  • 算子兼容性:模型能否被工具链完整转换,是否存在算子落到 CPU 执行的情况。

这四个维度需要放在同一张表里看,才能反映真实部署体验。只看 FPS 会被“模型很小但算子碎片化严重”这类假象骗过;只看精度又会忽略板端资源约束。我在整个实验里坚持每条记录都按完全相同的前处理和推理顺序执行,保证数据之间有可比性。

评估指标的统计方式我也做了固定:先预热 5 次,再连续推理 50 次,记录中位数作为“平均延迟”,同时统计 P95 作为稳定性参考。预热这一步很关键,因为 NPU 运行时第一次调用会包含模型加载、内存池初始化、算子调度表生成等额外开销,不预热测出来的数据会被严重污染。

2. 模型选型与校准集准备:哪些主流分类网络值得测

2.1 RK3576 对算子支持的约束

在选模型之前,先要弄清楚 RKNN 工具链对分类网络算子的大致支持情况。从我这轮实验的体感来看,传统 CNN 里的卷积、池化、全连接、Softmax 这些算子基本都有优化实现,转换时不用太担心。真正需要留意的是几类算子的“翻译方式”:Resize 和 Upsample 在早期版本里对部分 opset 支持不够好,Transpose 和 Reshape 如果数量太多,容易在板端生成大量内存搬运指令,而 Channel Shuffle 这类偏门算子偶尔会被落到 CPU 上执行。

这意味着模型选择不完全是看“精度高不高”,还要看“结构适不适合 NPU”。同样是轻量网络,有些模型算子排列整齐,转换后几乎全跑在 NPU 上;有些模型为了省参数量引入了过多维度变换,转换后性能反而不如传统网络。我建议在跑正式测试前,先拿工具链的模拟器跑一遍 eval_perf,看每个 layer 的耗时分布,确认没有大段算子落到 CPU。

2.2 我选了哪些主流图像分类模型

这一轮我一共测了 8 个模型,覆盖了几种典型路线:

模型参数量(M)结构特点测试意图
ResNet5025.6经典残差结构中等规模基线
ResNet1811.7浅层残差结构残差网络下限
MobileNetV3-Large5.4深度可分离卷积移动端轻量化代表
ShuffleNetV2_1.02.3分组卷积+通道重排极轻量网络
EfficientNetV2-S21.5NAS 搜索复合缩放高精度均衡路线
DenseNet1218.0密集连接结构特征复用型网络
SqueezeNet1.11.2Fire Module极小参数量
DeiT-Tiny5.7Vision Transformer注意力结构兼容性

选择这些模型不是随意的。ResNet 系列作为残差网络标杆,能帮助验证工具链对 shortcut 分支的融合能力;MobileNetV3 和 ShuffleNetV2 是端侧部署最常见的候选;EfficientNetV2 检验 NAS 搜索结构里 SE 注意力模块的转换支持;DeiT-Tiny 则专门用来试探 Transformer 类结构在 NPU 上的真实表现。

每个模型我都统一使用公开的预训练权重,导出成 ONNX 后再做 RKNN 转换。需要提醒的是,PyTorch 导出的 ONNX 版本和 opset 会影响最终算子生成,我统一采用 opset 11,这也是兼容性比较稳妥的一个版本。

2.3 校准集的数量和质量比想象中更重要

INT8 量化不是简单的“把 32 位浮点砍成 8 位整数”,它需要一个校准过程:用一批有代表性的图片跑一遍 FP32 推理,统计激活值的分布范围,然后为每一层确定合适的量化缩放系数。校准集的质量直接决定量化后的精度。

我在实验中把校准集和验证集做了严格分离。校准集从训练数据的不同类别里均匀抽取,总数量控制在 500 张左右。这里不要贪多,校准集数量超过 1000 张后,收益会明显递减,反而增加转换时间和内存消耗。真正重要的是覆盖率:如果分类任务有 20 个类别,每个类别至少要有 10 张以上样本进入校准集,否则某些类别激活值分布会被整体带偏。

校准集图片在送入转换工具前,还需要和板端推理保持完全一致的前处理。我在实验里发现,很多人校准图和验证图用的尺寸不一样,或者灰度图混入 RGB 图,导致量化的动态范围计算错误,最终精度直接掉 3 个百分点以上。

3. 部署全流程记录:从 ONNX 到板端推理

3.1 环境准备

整个部署链路分两侧:开发环境和板端运行环境。开发环境负责模型转换、量化、仿真,板端环境负责加载 RKNN 模型并推理。

开发环境的搭建相对简单,安装官方提供的 rknn-toolkit2 工具包即可。需要注意 Python 版本和工具包版本的匹配关系,我就是因为一开始用了过新的 Python 版本,导致依赖安装报错,后来退回推荐版本才顺利跑起来。板端环境则需要安装 rknnlite 推理库,同时把 OpenCV 之类的图像处理库准备好。

硬件方面我用的是一块标准 RK3576 核心板,运行 Linux 根文件系统,预留了足够的 DDR 内存给模型推理。板卡和开发机之间通过有线网络连接,方便传输 .rknn 模型文件。强烈建议在实验初期就建立一个固定目录结构,把原始 ONNX 模型、校准图片、转换脚本、板端测试代码分别归档,避免后续反复拷文件把自己搞晕。

3.2 模型导出与 RKNN 转换

在 PyTorch 里导出 ONNX 模型时,我固定使用以下方式:

import torch model = torch.load("resnet50.pth") model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "resnet50.onnx", opset_version=11, input_names=["input"], output_names=["output"], dynamic_axes=None, )

导出完成后先跑一次 onnx 合法性检查,确认输入输出节点名称正确、shape 固定。RKNN 转换阶段,我使用工具链提供的 Python API 完成配置、加载、构建和导出:

from rknn.api import RKNN rknn = RKNN() ret = rknn.config( mean_values=[[0, 0, 0]], std_values=[[1, 1, 1]], target_platform="rk3576", quantized_dtype="w8a8", quant_img_RGB2BGR=True, ) ret = rknn.load_onnx(model="resnet50.onnx") ret = rknn.build(do_quantization=True, dataset="dataset.txt") ret = rknn.export_rknn("resnet50.rknn")

dataset.txt 里是校准图片的路径列表,每行一个图片文件,这个文件会告诉工具链用哪些数据来标定量化范围。我把 mean_values 和 std_values 都设成 0 和 1,因为模型本身已经在训练阶段吃过归一化处理,板端只做像素值输入,不再叠加归一化逻辑,这样可以避免双重归一化带来的精度损失。

转换完成后,我习惯先用工具链自带的模拟器跑一遍推理,确认输出结果的分类置信度合理,再传到板卡上做真机验证。这一步能提前暴露“模型转换成功但输出异常”的隐蔽问题。

3.3 板端推理脚本与性能统计

板端推理通过 rknnlite 库完成,使用方式非常直接:

from rknnlite.api import RKNNLite rknn = RKNNLite() rknn.load_rknn("resnet50.rknn") rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2) # 输入图像预处理 img = cv2.imread("test.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (224, 224)) img = img.astype(np.float32) # 推理 outputs = rknn.inference(inputs=[img])

这里有几个细节我特意强调。第一是通道顺序,模型训练时如果用的是 RGB 顺序,那么 OpenCV 读进来的 BGR 图必须转成 RGB,否则精度会出现灾难性下降。第二是输入类型,RKNN 的 inference 接口可以接收 uint8 或 float32 输入,具体选择要和转换配置里的归一化参数匹配,我采用的是转换时 mean=0、std=1,所以板端直接传原始像素值,不再做任何除法。

性能统计部分,我在完整脚本里加入了预热和循环计时逻辑:

import time # 预热 for _ in range(5): outputs = rknn.inference(inputs=[img]) # 正式测试 latencies = [] for _ in range(50): t0 = time.perf_counter() outputs = rknn.inference(inputs=[img]) latencies.append((time.perf_counter() - t0) * 1000) latencies.sort() avg = sum(latencies) / len(latencies) p95 = latencies[int(len(latencies) * 0.95)] print(f"avg: {avg:.2f} ms, p95: {p95:.2f} ms")

内存占用统计我使用 Linux 下的 psutil 库读取进程 RSS,每推理 10 次记录一次,取整个测试过程中的最大值。这个数据对后续判断“板子上还能不能同时跑别的算法”至关重要。

4. 性能实测与数据解读

4.1 延迟、精度、内存的完整对比

所有模型在统一环境下完成测试后,我整理出了核心对比数据:

模型参数量(M)榜单参考 Top-1INT8 相对 FP32 损失(pp)平均延迟(ms)稳定 FPS峰值内存(MB)
ResNet5025.6约 76%0.718.55068
ResNet1811.7约 70%0.510.39045
MobileNetV3-Large5.4约 75%0.97.113032
ShuffleNetV2_1.02.3约 72%1.15.616528
EfficientNetV2-S21.5约 82%0.811.48255
DenseNet1218.0约 74%1.214.86361
SqueezeNet1.11.2约 58%0.65.217022
DeiT-Tiny5.7约 72%2.329.33174

表格里的延迟是纯 NPU 推理时间,不含图像解码和前处理。如果算上整体链路,每帧还要增加 2 到 4 毫秒的预处理开销。这个数据可以直接作为业务选型的参考:想要高帧率、低功耗,ShuffleNetV2 和 SqueezeNet 是优先选择;想要精度和速度均衡,MobileNetV3 和 EfficientNetV2-S 值得考虑。

DeiT-Tiny 的数据很有参考价值。它的参数量只有 5.7M,延迟却接近 ResNet50 的两倍,这说明注意力结构在 NPU 上的算子融合不如传统卷积彻底。如果你需要跑 ViT 类模型,建议先评估板端可接受的延迟上限,不要只看模型体积。

4.2 轻量模型为什么不一定更快

从表格中可以明显看出,SqueezeNet 参数量最小,但延迟只比 ShuffleNetV2 快了不到 0.5ms,并没有出现“体积减半、速度翻倍”的现象。深挖原因,问题出在算子数量和内存访问模式上。

NPU 的算力不是无限带宽,它更像一个流水线工厂,算得快不快不仅取决于计算量,还取决于中间产物的搬运次数。SqueezeNet 通过把大卷积拆成 1x1 卷积和 3x3 卷积来减少参数量,但每个 Fire Module 都引入了大量拼接和维度变换。这些操作会打断连续的矩阵计算,造成计算单元空转。ShuffleNetV2 同样存在通道重排操作,它在 CPU 上可以做到极快,到了 NPU 上却要消耗额外指令来完成张量重排。

这解释了轻量模型在这类 NPU 上的一个共性规律:参数量的减少不等于延迟的线性下降。对于部署工程师来说,选模型时不能只看 FLOPs 和参数量,还要看架构里有多少算子属于“计算稀疏、搬运密集”的类型。

4.3 量化策略和精度损失的控制

INT8 量化带来精度损失很难完全避免,但通过调整策略可以控制在可接受范围。我在这轮实验中对比了两种量化配置:per-tensor 量化和 per-channel 量化。per-tensor 对整个张量使用同一个缩放系数,实现简单,但遇到激活值范围波动大的层时损失明显;per-channel 则针对每个卷积通道单独计算系数,精度更高,代价是模型体积和计算开销略有增加。

从实测结果看,ResNet 这类权重大小比较均匀的模型,两种量化方式差异不大;但 MobileNetV3 里的 depthwise 卷积对量化异常敏感,per-channel 比 per-tensor 在 Top-1 上能挽回 1 到 1.5 个百分点。

另一个有效的控制手段是只量化对精度影响小的层。RKNN 工具链支持混合量化配置,可以把特别敏感的首尾层保留为 FP16,中间的卷积层继续走 INT8。我在 DeiT-Tiny 上试过保留 Embedding 层和 LayerNorm 层为 FP16,精度损失从 2.3pp 降到了 1.1pp,延迟只增加了 4ms 左右,这个方案对 Transformer 类结构尤其值得尝试。

5. 完整踩坑记录与排查思路

5.1 转换期最容易卡住的三个环节

第一个高频问题在 ONNX 导出阶段。PyTorch 模型里的某些动态维度操作在导出时会生成动态轴,RKNN 转换时如果不固定输入尺寸,经常报 shape 相关的错误。解决方法是始终保持固定输入分辨率,或者把 dynamic_axes 参数去掉。

第二个问题是算子不支持或映射异常。我试过的一个 EfficientNet 变体包含某种自定义激活函数,工具链报 Unknown op。这时候需要到官方算子支持列表里确认,或者把自定义函数替换成等价的组合算子。举个例子,一些模型里的 h-swish 可以改为 ReLU6 和移位操作的组合,这是工具链原生支持的。

第三个问题是校准集路径和预处理不匹配。dataset.txt 里的图片路径必须是绝对路径或相对工具链运行目录的相对路径,同时图片要统一编码为 RGB 三通道。我曾经不小心把一批单通道灰度图混进校准集,结果模型精度直接崩掉,排查了很久才发现是校准数据通道数不一致导致的。

转换期还有一个容易被忽略的点:ONNX 模型里的批归一化层是否已经折叠。PyTorch 导出的模型默认带有 BN 层,工具链在优化阶段会自动折叠,但有些模型在导出时 BN 层与前面的卷积层融合不彻底,会在板端多出一段 CPU 计算。我建议转换前先看工具链的优化日志,确认 BN 层被正确吸收。

5.2 板端运行精度出问题的排查顺序

如果模型在板端运行精度明显低于预期,不要急着怀疑量化策略,我的排查顺序如下:

先检查输入数据通道顺序。这是最容易出错的一环,OpenCV 默认读入的是 BGR,而大部分预训练模型的训练数据是 RGB,两者颠倒会导致 Top-1 直接掉十几个点。再用一张已知分类结果的图片验证输出概率分布是否合理。

然后检查前处理的一致性。板端推理的 resize 方式、像素归一化逻辑必须和转换校准期保持完全一致。比如校准阶段用的是双线性插值,板端就不能用最近邻插值;转换配置里如果填了 mean 和 std,板端推理时就不要再手动减均值除方差,否则就是双重归一化。

再检查输入张量维度顺序。RKNN 默认输入格式是 NCHW,如果推理时给成 NHWC,模型跑起来不会报错,但结果会乱成一团。遇到输出全为 0 或分布异常时,先看这个。

最后才考虑量化策略。如果以上检查都正常,再尝试混合量化和 per-channel 重新构建模型。根据我踩过的坑,双归一化一个失误就能吃掉两三个百分点的精度,这种问题在量化策略层面是找不回来的。

5.3 性能不达标的定位手段

性能不达标往往比精度问题更难定位,因为涉及系统层面的资源竞争。我遇到过一次板端 FPS 比预期低了 40% 的情况,查到最后发现是别的进程占满了 CPU 核心,而模型的前处理代码里有相当一部分是 Python 层面的像素循环,导致 CPU 成为瓶颈。

我的建议是把推理链路拆成三段分别计时:图像解码、前处理、NPU 推理。哪一段耗时长就针对哪一段优化。图像解码可以用硬件解码接口代替 OpenCV 软件解码;前处理尽量使用 OpenCV 的 C++ 底层实现,不要在 Python 里做逐像素循环;NPU 推理则要确认 core_mask 是否配置正确,以及模型是否被完全映射到 NPU。

工具链自带的性能分析接口也很实用。它能够输出每个算子在板端的实际耗时,如果发现某个 Transpose 或 Concat 占了很大比例,基本可以确定是模型结构导致算子调度碎片化。这时候可以考虑重新设计网络结构,或者换一个算子更规整的模型。

还有一个高频场景是内存不足。模型加载时提示 alloc memory failed,但板卡内存明明还有余量。这是因为 NPU 运行时会为每个模型预留一整块连续物理内存,内存碎片化会导致分配失败。解决方法是调整模型的 input shape 缩小工作集,或者重启进程让内存重新整理。

6. 下一步扩展与个人经验心得

6.1 如何从单图推断到视频流

单图推理跑通后,很自然的下一步就是把模型接入视频流。接触这一环节的人往往会发现,模型在单图推理时延迟是 10ms,但做成视频流之后 FPS 反而上不去,主要原因是解码和推理串行执行,每一帧都在等上一帧处理完。

我建议把流程改成双缓冲加多线程:一个线程专做帧采集和解码,另一个线程专做 NPU 推理,两者之间用队列传递。队列里只存解码后的 yuv 或 rgb 数据,不要存放原始编码字节流。实际测试中,这种设计能把视频流场景的有效吞吐提升 50% 以上,尤其是 RK3576 内置硬件解码单元本身不占 CPU 资源,配合 NPU 推理可以形成很好的并行效果。

6.2 多模型分时复用 NPU 的建议

实际项目中经常会有“既要人脸分类,又要活体检测”的需求,这时候需要在同一块板子上跑多个模型。RK3576 的 NPU 支持多个 rknn 实例共存,但要注意资源分配。我的做法是加载阶段把所有模型都 load 进来,推理时通过 core_mask 参数决定每个模型使用多少个 NPU 核心。

多模型共存的另一个关键点是内存预算。板端内存总量是有上限的,每个模型推理时的临时缓冲区不能超过预定值。我的经验是每个模型单独测量峰值内存,然后留出 20% 余量作为调度缓冲,避免多个模型同时推理时内存溢出。

6.3 几句心里话

如果让我把这轮实验重新做一遍,我会更早固定好校准集和验证集,并且把整个部署链路拆成“原生 demo 跑通、替换模型、调精度、压性能”四个阶段。很多问题之所以难查,是因为环境、数据、脚本搅在一起,根本无法判断是哪一步引入了异常。

另外一个牢记在心的原则是:所有数据以板端实测为准,转换工具给出的估算数值只能作为参考。模型在模拟器上的表现和真机之间存在差异,尤其在涉及 DDR 带宽和 NPU 调度时,估算会严重偏乐观。只有把模型真正烧到 RK3576 上,用统一流程跑完 50 次推理,得到的中位数才值得写进项目评审报告。

致谢流程不写,但这轮实验用到的 ONNX 模型、转换脚本和板端测试代码,我已经整理成一套可复用的模板。后续无论是换模型、换输入分辨率,还是切换到 RKNN 工具链的新版本,只要按照这套流程重新构建,数据和结论都能保持一致性。这也是整个实验最有价值的部分:不是某个模型快或慢,而是一套稳定可复现的部署评估方法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询