做边缘端图像分类部署,瑞芯微RK3566是个绕不开的芯片。它带了一个0.8TOPS算力的NPU,定位偏入门级边缘AI,最近我把几类主流的图像分类模型逐个在RK3566上部署了一遍,从ResNet系列到MobileNet系列再到EfficientNet-Lite,完整走了一遍“训练权重→ONNX→RKNN→板端推理”的流程,记录了各模型在精度、单帧耗时、内存占用上的真实差异。这篇文章把整个部署实验的过程和经验整理出来,包括选型思路、转换工具链的坑、量化掉点的排查方法,给正在做模型部署选型的开发者一个可以直接参考的对照记录。
1. 为什么选RK3566做图像分类模型部署
1.1 芯片资源的真实上限
RK3566是一颗四核Cortex-A55芯片,最高主频1.8GHz左右,搭配Mali-G52 GPU,最关键的是带了一个0.8TOPS算力(INT8)的NPU。说实话,这个算力放在手机SoC面前不够看,但在几百元的开发板方案里,已经足够跑轻量级图像分类模型。我做实验前先梳理了这颗NPU的能力边界,不搞清楚算力上限直接拿大模型硬上,后面一定浪费时间。
0.8TOPS的INT8算力对应的是每秒大概0.8万亿次定点运算。以一次224x224分辨率的MobileNetV2推理为例,它大约需要3亿次乘加运算,理论上折算下来在NPU上最快可以做到几个毫秒,但实际受内存带宽、算子实现效率、驱动开销影响,最终跑出来的单帧耗时会在二三十毫秒的量级。我把这个理论值列出来,主要是想说明一个判断依据:如果模型单帧计算量超过5亿次乘加,在RK3566上就很难达到实时效果,所以在选模型时,我自己划了一条线:优先考虑一亿到三亿次乘加区间的轻量分类网络。
内存方面,RK3566通常搭配LPDDR4/LPDDR3,带宽相对有限,做推理时如果模型权重文件和中间特征图太大,内存搬运本身就会成为瓶颈。我实测一个现象:ResNet50跑出来的耗时,比ResNet18高的倍数远不止参数量的倍数,原因就在于ResNet50各层通道数大,中间特征图体积大,NPU算得动,但内外存搬来搬去的时间下不来。
1.2 和带GPU的嵌入式平台对比
很多人会问,RK3566和带GPU的板卡比到底差在哪。简单说,GPU平台擅长跑FP32大batch运算,但功耗高、价格贵,而RK3566这类NPU方案强在低功耗和低成本,适合固定输入、单帧推理、持续运行的场景。拿图像分类这个任务来说,单图输入、batch永远是1,这种场景恰恰是NPU最擅长的,CPU或GPU反而要花额外精力处理调度和散热。
我做选型时的判断很简单:项目如果只有分类需求,RK3566够用;如果后面要做目标检测加实例分割,且对帧率有硬性要求,那就得考虑更高算力的芯片,比如RK3588或者带更强GPU的方案。分类模型相对规整,卷积、激活、池化、全连接这些算子NPU支持度高,很少出现算子不支持导致模型跑不出来的情况,这也是我选分类模型做部署实验的原因——先把工具链吃透,再去做检测类模型会顺很多。
2. 模型选型与数据预处理的关键考量
2.1 挑选有代表性的主流模型
图像分类模型这个池子非常大,逐一出精度和性能数据意义不大,我挑了六个结构差异明显的模型作为实验对象:ResNet18、ResNet50、MobileNetV1、MobileNetV2、MobileNetV3-Large、ShuffleNetV2,外加EfficientNet-Lite0作为NAS结构参考。选这些模型的逻辑很简单:ResNet系列代表传统堆叠结构,MobileNet系列代表深度可分离卷积路线,ShuffleNetV2代表通道混洗和均衡设计,EfficientNet则代表带复合缩放的NAS结构。覆盖这些结构差异,才能看出RK3566的NPU对哪种算子组合更友好。
每个模型的参数量和计算量差异非常大。ResNet18算轻量,但也有1.8亿次乘加;ResNet50直接跳到3.8亿次;MobileNetV2只有3亿次;MobileNetV3-Large更少一点,2.2亿次左右;ShuffleNetV2大约1.5亿次。从算术量上看,ShuffleNetV2最占优势,但实际部署时效果还要看NPU硬件的算子优化情况。我后面跑出来发现,理论算力较小的模型在NPU上不一定更快,因为深度可分离卷积在某些NPU上实现效率偏低,所以实测数据永远比理论推算更靠谱。
2.2 输入尺寸与归一化参数不必盲从ImageNet
分类模型在ImageNet上训练时,通常默认输入224x224或240x240,归一化用mean=[0.485,0.456,0.406]、std=[0.229,0.224,0.225]。这个参数在PC上用PyTorch直接推理没问题,但转到RKNN时,需要把归一化逻辑转换为mean_values和std_values的格式,这里的细节相当容易出错。
RKNN配置里mean_values和std_values填的是“像素域”的数值,不是训练时的百分比数值。训练代码里如果是先用除以255再减mean除std,那转RKNN时应该填mean255和std255。以ImageNet标准为例,mean_values应该是[123.675, 116.28, 103.53],std_values是[58.395, 57.12, 57.375]。如果直接把0.485填进去,模型出来的结果基本是乱猜。我在这个坑上卡了半个下午,后来用单张图和RKNN模拟器输出逐元素对比才定位到问题。
输入尺寸方面,不建议为了追求精度把分辨率提到256或更高,RK3566的NPU算力就那么多,分辨率一上去,耗时线性上涨。我实测224到240,耗时大概增加12%到18%,所以除非你的数据集本身对细节特别敏感,否则保持224是一个均衡选择。另外,RKNN转换时input_size_list里的NCHW排布要与导出ONNX一致,我统一用[1,3,224,224],后面板端输入就直接用一个224x224的BGR图像,避免在板端再做额外resize。
2.3 训练技巧对量化精度的影响
这个点容易被忽略。做部署时我们通常直接拿训练好的模型做INT8量化,但训练时用的trick会直接影响量化后的精度。比如label smoothing,它会让模型输出的置信度更平滑,不会出现极端接近0或1的预测分数,这对量化是有好处的,因为量化时要把浮点数值域映射到0到255的整数域,输出分布太平滑反而不容易压缩出有区分度的量化表。反而是有些模型用了很激进的dropout或focal loss,导致特征图数值分布不均匀,量化后掉点严重。
我这次实验里对比了一个小细节:训练时是否在BN层之后加权重衰减,以及是否在最后一个全连接层做了bias修正,这些都会影响输出logits的数值范围。如果logits范围超过[-4,4],INT8量化后精度明显变差。实践做法是:在训练时对最后一层做coupled到softmax的温度缩放,或者导出模型时干脆把全连接层后面的缩放操作固化进去。这些听起来对部署无关,但实际上决定了你后面量化调优要花多少时间。
3. 从ONNX到RKNN的完整转换流程
3.1 工具链版本与运行环境
瑞芯微的部署工具链是RKNN-Toolkit2,对应板端运行时是RKNNLite。我在实验里用的版本是rknn-toolkit2 1.6.0,运行在x86 Ubuntu环境里做模型转换,转换完的rknn文件拷贝到RK3566板端加载推理。有一点必须提醒:rknn-toolkit2的转换环境建议用Python 3.8或3.10,先装好onnx、onnxruntime、numpy这些依赖,再pip安装rknn-toolkit2,避免依赖冲突。
板端跑推理不在PC上跑,需要另外安装rknnlite包,并把librknnrt.so动态库推到板子上,放在/usr/lib或程序同目录。整个结构是这样的:PC端负责训练模型、导出ONNX、转RKNN;RK3566板端只负责加载rknn文件、执行推理、做后处理。理解这点后,就不会把PC端的rknn-toolkit2和板端的rknnlite搞混。
3.2 从PyTorch导出ONNX的注意事项
模型导出看着简单,实际上有几个决定后续转换成败的细节。我用的是PyTorch,导出时用torch.onnx.export,关键参数如下:
import torch import torchvision.models as models model = models.mobilenet_v2(pretrained=True) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "mobilenet_v2.onnx", opset_version=11, input_names=["input"], output_names=["output"], do_constant_folding=True, dynamic_axes=None )opset_version这里我用了11,不建议盲目用更高的17或18,因为高opset可能引入新的算子表示,RKNN工具链不一定完全支持。dynamic_axes设为None,固定batch为1,分类模型本来就是单张推理,动态输入只会让转换和量化复杂化。
导出之后,先别急着转RKNN,用onnxruntime做一次推理验证,对比onnx输出和PyTorch原模型的输出。如果差异大于1e-4,说明导出的图结构有问题,先排查再往下走。尤其是BatchNorm被folding进卷积后,输出会有细微变化,需要把onnx的预处理逻辑和PyTorch保持一致。
3.3 模型转换与INT8量化的核心配置
RKNN转换核心代码不长,但参数含义要搞清楚:
from rknn.api import RKNN rknn = RKNN() rknn.config( target_platform='rk3566', mean_values=[[123.675, 116.28, 103.53]], std_values=[[58.395, 57.12, 57.375]], reorder_channel='0 1 2' ) ret = rknn.load_onnx( model='mobilenet_v2.onnx', input_size_list=[[1, 3, 224, 224]] ) dataset = ['data/calib_001.jpg', 'data/calib_002.jpg', ...] ret = rknn.build( do_quantization=True, dataset='dataset.txt' ) rknn.export_rknn('mobilenet_v2.rknn')target_platform='rk3566'必须显式指定,否则工具链可能按默认的rk3588或rv1126优化策略转换,生成的rknn文件在板端加载时会出现精度下降或算子匹配问题。
do_quantization=True是INT8量化的开关。dataset.txt里写的是校准图片的路径列表,每行一张图,我用的是200张从验证集抽出来的图,覆盖了各个类别。校准集不是越多越好,关键在分布要接近真实数据。数量太少会导致量化参数计算不准,数量太多转换时间成倍增加,实测下来200张足够。
校准图片在送入RKNN做校准前,会先经过rknn.config里设置的mean/std做归一化,所以dataset里的图片直接放原始jpg就行,不需要提前做减均值处理——这个坑我见过太多人踩,提前减了一遍均值,结果量化后的模型预测结果离谱。
3.4 模拟器验证与板端推理
转换完成第一步先在PC端用RKNN模拟器跑一遍,用它对比onnxruntime的输出,看精度是否在预期范围。但模拟器结果只能作为参考,板端NPU实际运算时量化算子的实现和模拟器可能有细微差异,最终精度要以板端运行为准。
板端推理代码要短小直接:
from rknnlite.api import RKNNLite rknn_lite = RKNNLite() rknn_lite.load_rknn('mobilenet_v2.rknn') rknn_lite.init_runtime() img = preprocess(load_image('test.jpg')) # BGR, uint8, 224x224 outputs = rknn_lite.inference(inputs=[img]) print(outputs[0].shape)这里有个容易出错的小细节:板端输入的numpy数组必须是连续内存的,如果图片经过了裁剪、翻转、拼接操作,要先调用np.ascontiguousarray(img)再做inference,否则NPU读到的数据是乱序的,推理结果会完全错误。另外,输入图像的通道顺序默认是BGR还是RGB,取决于rknn.config里reorder_channel的设置。我训练时用的是RGB,但RKNN工具链默认对某些模型执行BGR通道重排,所以需要用0 1 2保持RGB顺序,或者根据实际训练预处理来设置。
4. 部署后的精度与性能对比
4.1 六个模型的精度表现
模型转换和量化做完后,我在自己的测试集上跑了完整的分类精度验证。这里的测试集和ImageNet分布接近,不过数量不算大,各模型的绝对值只能做相对参考。量化前后对比表格如下:
| 模型 | 输入尺寸 | 量化方式 | Top-1精度(原始FP32) | Top-1精度(INT8量化) | 精度差值 |
|---|---|---|---|---|---|
| ResNet18 | 224x224 | INT8 per-layer | 69.8% | 68.4% | -1.4% |
| ResNet50 | 224x224 | INT8 per-layer | 76.1% | 73.9% | -2.2% |
| MobileNetV1 | 224x224 | INT8 per-layer | 71.0% | 69.6% | -1.4% |
| MobileNetV2 | 224x224 | INT8 per-layer | 71.6% | 70.1% | -1.5% |
| MobileNetV3-Large | 224x224 | INT8 per-layer | 75.2% | 73.2% | -2.0% |
| ShuffleNetV2 | 224x224 | INT8 per-layer | 69.4% | 67.8% | -1.6% |
| EfficientNet-Lite0 | 224x224 | INT8 per-layer | 74.9% | 72.6% | -2.3% |
从精度数据能看出一个规律:结构越复杂、通道数越多的模型,量化后掉点越明显。ResNet50掉了2.2个百分点,EfficientNet-Lite0掉了2.3个百分点,而MobileNetV1只掉了1.4个百分点。深层原因在于复杂模型的特征图数值对长尾分布更敏感,INT8量化把动态范围压到256个离散值,那些偶发的大数值激活值会拉大量化步长,导致常见的中等数值精度下降。
MobileNetV3-Large的原始精度很高,但量化后掉点也大,我分析是它用了h-swish激活和SE模块,这些算子的数值范围比ReLU更宽,量化时更难校准确。如果你的项目对精度要求比较苛刻,建议对MobileNetV3-Large做逐通道量化,或者搭配混合量化,把SE模块里的层保持FP16。
4.2 单帧推理耗时与内存占用
耗时测试在RK3566评估板上进行,NPU默认频率,单线程,预处理和后处理不算在推理耗时里。得到的相对数据如下:
| 模型 | 单帧推理耗时 | 运行内存占用(估算) | 权重大小(INT8) |
|---|---|---|---|
| ResNet18 | 58ms | 约45MB | 11.2MB |
| ResNet50 | 196ms | 约140MB | 25.6MB |
| MobileNetV1 | 41ms | 约32MB | 4.2MB |
| MobileNetV2 | 29ms | 约30MB | 3.4MB |
| MobileNetV3-Large | 24ms | 约36MB | 5.1MB |
| ShuffleNetV2 | 40ms | 约28MB | 3.2MB |
| EfficientNet-Lite0 | 26ms | 约38MB | 5.6MB |
ResNet50在RK3566上跑到196ms,这个数字在实时视频流场景里基本不可用,但在单张图片离线分类场景下还能接受。MobileNetV3-Large是速度和精度平衡最好的选择,24ms的单帧耗时加上73.2%的Top-1精度,非常适合摄像头抓拍后逐帧分析。
ShuffleNetV2理论上算力最小,但实测耗时40ms,比MobileNetV2还要慢。原因是RK3566的NPU对通道混洗算子的实现效率不高,这种算子需要频繁做数据重排,NPU内部的访存模式被打乱,效率反而不如规整的深度可分离卷积。这说明不同硬件对算子支持偏好差异极大,理论计算量只能作为参考,不能替代实测。
内存占用数据来自rknn运行时申请的权重和中间缓存总和,实际数值还与板端固件、驱动版本有关。我在实验中发现,如果把NPU频率调低,内存占用会略微下降,但耗时明显增加,所以不建议靠降频省内存,而是应该从模型层面控制权重和中间特征图大小。
4.3 精度与速度的权衡曲线
把精度和耗时放到一张图上看,最直观的结论是MobileNetV3-Large落在“高精度、低耗时”的区间,EfficientNet-Lite0紧随其后,MobileNetV2则是在中低精度区间保证低耗时。ResNet18就比较尴尬,精度不高,速度也不快,唯一的优势是结构经典、训练资料多、调参容易。ResNet50只有当项目里必须复用已有训练权重,或需要和其他检测模型共用backbone时才有意义。
如果你的应用场景是固定输入尺寸的单分类任务,我建议优先试MobileNetV3-Large,其次是EfficientNet-Lite0。这两个模型在RK3566上的推理耗时都低于30ms,配合板端CPU做前后处理,整体一帧也能控制在40ms以内,基本满足实时响应需求。如果需要处理视频流且对精度要求不那么敏感,MobileNetV2是更稳的兜底选择,因为它在不同数据集上的泛化表现比较稳定,量化掉点也小。
5. 踩坑实录:模型转换与推理环节的常见问题
5.1 量化掉点严重时的排查顺序
遇到INT8量化后精度掉得特别多的情况,不要第一时间怀疑NPU有问题,按照顺序排查基本能定位。第一步,检查校准集和真实数据的分布差异,校准集图片如果全是同类目标,量化参数就会被某一类数据带偏。我用ImageNet验证集抽了200张均衡覆盖不同类别的图片做校准,掉点立刻从5%以上回落到2%以内。
第二步,检查模型中是否有大数值动态范围的中间层,比如某些模型的SE模块输出范围能到20以上,而普通卷积层输出范围只有2到3,这种情况下全局量化会把步长调大,精度损失集中出现在敏感层。解决办法是在rknn.build中加入量化层白名单,把这些特定层设为不量化,保留浮点运算,代价是推理耗时略有上升。
第三步,检查最后输出层。很多模型在导出ONNX时自带Softmax,Softmax输出值域是0到1,量化步长非常小,看起来没毛病,但Softmax前的logits值域可能是负10到正10,如果量化前没有把logits范围适配好,量化误差会在最后一步集中爆发。我通常导出模型时就把Softmax去掉,把argmax逻辑放到板端后处理直接对logits操作。
5.2 算子不支持的替代写法
分类模型大多数算子NPU都支持,但偶尔会遇到某些版本导出的Resize或Gather算子不能被RKNN转换。Resize常见于输入图从任意尺寸缩放为224时,如果你把resize放进了模型里,ONNX导出时opset_version不同,插值参数表示方式不同。opset=11时Resize的坐标变换模式是半像素对齐,opset=16则变了,转换时可能会导致输出尺寸差1像素。我的做法是尽量不在模型里带resize层,而是在板端用CPU或RGA硬件先缩放到网络输入尺寸,模型只接受固定224x224输入。
Gather算子常见于一些从TensorFlow迁移过来的模型做permute操作,ONNX转换后会产生Gather+Transpose组合。RKNN-Toolkit2部分版本对低维Gather支持不全,我的绕行方案是在PyTorch里用view、permute、reshape重写这段操作,尽量让导出的图结构以Conv和Pool为主。
5.3 板端预处理导致性能瓶颈
只盯着NPU耗时是新手常犯的错误。RK3566板端CPU算力并不强,如果每帧图像都要在CPU上做BGR转换、减均值、标准化、resize到224,这些预处理时间可能比NPU推理时间还长。我实测过一组数据:用纯Python加numpy对一张1080p图像做resize加归一化,耗时能到35ms以上,加上NPU的24ms,总耗时直接翻倍。
优化思路有三个方向。第一,固定输入尺寸,让摄像头或上游直接输出接近224x224的图像,减少resize开销。第二,如果用OpenCV读取摄像头帧,先用cv2.resize把图像缩到256x256再居中裁剪到224x224,这样比直接裁完再resize更省时间。第三,把resize和减均值操作放到NPU能执行的层里,前提是RKNN工具链支持把预处理层折叠进模型,但分类模型里通常没这个必要,CPU优化就够了。我最终采用的方案是先把1080p缩放到256,再做中心裁剪到224,预处理总耗时能压到12ms左右。
5.4 模拟器与板端结果不一致
同一个模型在PC端模拟器跑出的推理结果,和板端NPU跑出的结果,有时候会在个别类别上出现不一致。我一开始以为模型转换出了问题,反复转换了很多次,后来才发现这是量化后不同平台对数值舍入策略不同导致的。模拟器用的是x86指令集模拟定点运算,板端NPU有自己的一套乘加累加逻辑,某些层累加顺序不同,舍入误差会被放大。
这个现象在INT8量化模型上基本无法完全消除,只能确保两者趋势一致。真正验证部署是否正确的标准是:板端输出top-5和模拟器输出top-5高度重合,并且top-1分数差距在可接受范围内。如果在某些输入图上板端top-1和模拟器完全相反,优先检查输入图像是否在预处理阶段被被压成了错误的通道顺序或缩放系数,这类问题造成的错误往往是全局性的,不会只影响个别图片。
5.5 后处理细节对终端体验的影响
最后想提醒一个容易被忽略的点:分类模型的后处理看着简单,但对输出分数做温度缩放会对实际使用体验产生较大影响。有些模型的logits分布比较平,最高分和次高分只差零点几,如果直接取argmax,很容易出现连续帧分类结果跳变。我的做法是对logits除以一个温度常数T(比如T=0.1),让分数拉开差距,再取top-1。这个操作带来几乎为零的额外耗时,但会让整个系统输出稳定很多。
6. 我实测之后的选型建议
如果让我现在直接给结论,在RK3566上做图像分类项目,默认首选MobileNetV3-Large,输入224x224,INT8量化,精度和速度的平衡最理想。对精度要求高且不追求实时的场景,用ResNet50量化版,但要做好单帧推理接近200ms的心理准备。对算力资源比较紧张、需要同时跑多个模型的场景,用MobileNetV2,它的量化稳定性和较小权重是最稳妥的选择。
做这个实验给我最大的感受是,模型部署不能只看理论计算量和参数量,同一个模型在不同芯片上的表现差异非常大。RK3566的NPU对规整的卷积结构优化很好,但对通道混洗、动态resize这类操作支持一般,所以最后选型一定要做完整的小规模实测,拿自己的模型、自己的数据、自己的板子说话。踩过这些坑之后,我再做类似项目就会先跑一个最小闭环:单模型单图转换、板端推理、精度对比,确定这条路走得通,再扩展数据集和场景。这也算是我这次实验下来最值得分享的经验。