这两年做嵌入式AI的同仁应该都有体会:边缘设备上跑图像分类模型,最愁的不是模型选型,而是芯片算力和工具链。我这次拿瑞芯微RV1103这颗低功耗芯片做了一轮主流图像分类模型的部署实验,从MobileNetV2、MobileNetV3到ResNet18、ResNet50都过了一遍,把模型转换、INT8量化、板端推理、精度对比的完整过程都记录下来。这篇内容适合刚接触NPU部署、或者正在为低成本摄像头方案选型的朋友,看完你就能知道0.5TOPS算力到底能跑什么模型、怎么跑,以及哪些常见的坑完全不用踩。
1. 实验背景与整体思路:为什么拿RV1103做测试
1.1 这颗芯片的定位决定了实验的边界
RV1103是面向IPC、可视门铃、电池类摄像头这类产品的低成本方案,内部集成了双核Cortex-A7 CPU和一个0.5TOPS算力的NPU,整体功耗低、BOM成本控制得不错。这类芯片和手机SoC或者边缘计算盒子上的大算力平台完全不同,它没法承载大模型,设计目标就是在有限的资源里把轻量化模型跑稳。
所以这次实验的核心问题很简单:在0.5TOPS算力、有限内存带宽、INT8量化约束下,主流图像分类模型实际能跑到什么水平。我不光关心推理耗时,更关心量化后掉点严不严重、NPU占用率怎么样、算力利用率值不值得为某个模型浪费内存空间。
1.2 模型选型:覆盖轻量级与通用级两条线
模型选择上没有盲目堆数量,而是按两种路线各挑了几个代表:
- 轻量级路线:MobileNetV2、MobileNetV3-Small、ShuffleNetV2 1.0
- 通用级路线:ResNet18、ResNet50
选MobileNetV2是因为它几乎是嵌入式视觉部署的默认选项,卷积拆分结构在各类NPU上兼容性好。MobileNetV3-Small则是有代表性的轻量模型,但里面有些激活函数和复杂结构在算子映射时容易出问题。ResNet18和ResNet50是通用分类基线,主要是为了测试RV1103在“稍微重一点”的模型上会不会明显吃力。
这里有个容易忽略的点:FLOPs低不代表NPU上跑得快。因为NPU对不同类型的算子支持差异很大,深度可分离卷积和普通卷积在不同芯片上的效率差距能拉开好几倍。所以只看理论算力没用,必须实际转换后拿板子测。
1.3 整体实验流程设计
我的实验流程分成五步:PyTorch模型准备、ONNX导出、RKNN-Toolkit2转换量化、PC端模拟器验证、开发板实测。实际走下来发现,PC模拟的耗时和精度与板端结果会有一点点出入,但整体趋势一致,可以作为快速筛选的手段。
整个流程里最关键的两个验证节点分别是:ONNX导出后的算子检查,以及量化后的精度对比。前者决定模型能不能转,后者决定模型能不能用。
2. RV1103硬件底细与RKNN工具链:0.5TOPS算力到底能干什么
2.1 芯片规格与算力边界分析
RV1103的CPU是双核Cortex-A7,主频不高,NPU提供了0.5TOPS算力,支持的典型运算是INT8精度。如果按理论数字来算,0.5TOPS意味着每秒5000亿次INT8运算,听起来不少,但一对照模型复杂度就会发现完全不是一回事。
以ResNet50为例,它在224x224输入下大约有4.1G次乘加运算,相当于8.2GFLOPs浮点运算量。在0.5TOPS满载的情况下,理论只需要16毫秒左右,看起来完全可以接受。但实际测试下来,ResNet50在RV1103上要跑80到100毫秒甚至更久,合理利用率可能只有两三成。
原因不难理解:NPU峰值算力是理想状态下的数字,实际会受到内存带宽、DDR读写速度、算子调度效率、层间同步开销等因素的影响。芯片越小,内存带宽的瓶颈越明显,算力再高也发挥不出来。
提示:评估这类芯片时,不要只看TOPS,要重点看内存位宽、带宽和具体模型在官方跑分里的真实数据。
2.2 RKNN-Toolkit2版本与runtime匹配
RV1103的NPU推理主要通过瑞芯微的RKNN工具链实现,涉及PC端的rknn-toolkit2和板端的runtime库。版本匹配问题非常影响开发体验,不同版本生成的模型文件不一定能互相兼容,有些时候换了板端runtime后加载失败,报错信息还特别抽象。
我这次使用1.x版本的rknn-toolkit2,配套的板端runtime也尽量保持同版本。强烈建议在conda独立环境里安装rknn-toolkit2,避免和现有Python环境里其他依赖打架。安装过程可以用下面这组命令:
conda create -n rknn python=3.8 conda activate rknn pip install rknn-toolkit2 pip install onnx onnxruntime opencv-python numpy工具链的坑往往是部署周期里最浪费时间的部分,建议拿到开发板后先跑一遍官方demo,确认工具链版本能正常加载模型,再开始折腾自己的模型。
2.3 在线量化与离线量化的选择
RKNN工具链支持在线量化和离线量化,通俗说就是模型转换过程在PC端完成还是板端完成。实际开发基本上走PC端离线量化,也就是在PC上把浮点模型转成INT8 RKNN模型,再把模型文件放到板子里加载推理。
离线量化的好处是可以在PC上反复调整量化配置,速度更快,也方便对比不同量化参数的结果。RV1103的NPU只跑INT8模型,所以浮点模型必须量化。量化策略上默认用PTQ(训练后量化),大部分模型可以直接转,但如果精度掉得厉害,就要考虑混合量化和敏感层保留浮点。
3. 模型转换全流程实录:从PyTorch到RKNN的5个关键环节
3.1 模型导出ONNX的细节
我用torchvision里预训练的MobileNetV2做示范,导出ONNX的代码如下:
import torch from torchvision.models import mobilenet_v2 model = mobilenet_v2(weights=MobileNet_V2_Weights.IMAGENET1K_V1) model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, "mobilenet_v2.onnx", opset_version=12, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )导出时有两个容易踩的坑。第一是opset版本,一般来说11或12都比较稳妥,太新的opset在转RKNN时部分算子可能不识别。第二是dynamic_axes,如果板端推理时只用固定batch size,建议直接不设置动态轴,减少转换出错概率。
导出完成后,用onnxruntime跑一遍同一张输入图,对比PyTorch输出,确认模型结构没有坏掉。这一步快速便宜,能为后面省掉大量排查时间。
3.2 RKNN转换配置与量化数据集准备
模型转RKNN时,初始化配置里的mean_values和std_values必须跟训练时保持一致,这一点非常容易被忽略。很多人从PyTorch导出的模型训练时用的是ImageNet的归一化方式,但板端预处理代码常常写错顺序,导致喂进NPU的输入和训练分布不一致,最终精度掉好几个点。
我准备量化数据集时选了几百张贴近实际场景的图片,而不是随便拿ImageNet原始验证集。这一点很重要,量化校准数据最好和真实部署场景尽量接近。比如这个芯片后续用在室内摄像头,那么量化数据集里就应该多放些室内光照条件的图片。
下面这段代码演示了如何加载ONNX模型并完成量化转换:
from rknn.api import RKNN rknn = RKNN() # 配置输入输出信息 rknn.config(mean_values=[[123.675, 116.28, 103.53]], std_values=[[58.395, 57.12, 57.375]], target_platform="rv1103", quantized_dtype="w8a8") # 加载onnx模型 ret = rknn.load_onnx(model="mobilenet_v2.onnx") assert ret == 0 # 量化构建 ret = rknn.build(do_quantization=True, dataset="dataset.txt") assert ret == 0 # 导出rknn模型 ret = rknn.export_rknn("mobilenet_v2.rknn") assert ret == 0数据类型上,RV1103主要支持w8a8的权重量化方案,也就是权重和激活都量化成INT8。如果量化掉点明显,可以考虑用hybrid_quantization方案,对敏感层保留更高的精度,后面我会细说排查思路。
3.3 板端推理代码骨架
板上推理时,如果只是快速验证,可以直接用Python接口,实际产品集成则建议走C接口。下面是一段板端Python推理的最小示例:
from rknnlite.api import RKNNLite rknn_lite = RKNNLite() rknn_lite.load_rknn("mobilenet_v2.rknn") rknn_lite.init_runtime() import cv2 import numpy as np img = cv2.imread("test.jpg") img = cv2.resize(img, (224, 224)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = img.astype(np.float32) # 归一化要与转换时保持一致 mean = np.array([123.675, 116.28, 103.53]) std = np.array([58.395, 57.12, 57.375]) img = (img - mean) / std img = np.expand_dims(img, axis=0).transpose(0, 3, 1, 2) outputs = rknn_lite.inference(inputs=[img]) print(outputs[0].shape)这段代码里有个关键点:RKNN输入默认是NHWC还是NCHW取决于转换时的配置,我习惯在rknn.config里明确指定输入布局,避免后面板端代码跟转换配置对不上。
3.4 转换后先跑PC模拟再上板
RKNN模型生成后,我先在PC端通过模拟器跑一遍验证输出,确认模型能正常推理、输出形状正确,再把模型部署到板子上。这一步的意义在于,PC模拟环境可以帮你快速排除模型结构问题和明显配置错误,不用频繁烧板、重启、看日志。
有个小技巧:对比PC模拟和板端结果时,固定同一张输入图,分别保存输出向量,计算余弦相似度。如果相似度低于0.99,基本可以断定板端预处理或加载配置没对齐。
4. 各模型实测数据对比:精度、耗时与算力利用率的真实情况
4.1 几张表格看懂模型表现
这次实验里我把每个模型都跑了几百张测试图,统计了单帧平均耗时和量化后的Top-1精度。以下是整理出来的参考数据,不同SDK版本和不同数据分布下会有浮动,但量级趋势是对的:
| 模型 | 参数量 | 理论FLOPs | RKNN文件大小 | INT8 Top-1 | 单帧耗时 |
|---|---|---|---|---|---|
| MobileNetV2 1.0 | 3.4M | 0.62GFLOPs | 约3.2MB | 约68.5% | 约14ms |
| MobileNetV3-Small | 2.5M | 0.12GFLOPs | 约2.1MB | 约63.7% | 约9ms |
| ShuffleNetV2 1.0 | 2.3M | 0.15GFLOPs | 约2.2MB | 约65.5% | 约11ms |
| ResNet18 | 11.7M | 1.82GFLOPs | 约11.5MB | 约61.2% | 约35ms |
| ResNet50 | 25.6M | 4.12GFLOPs | 约25MB | 约72.1% | 约90ms |
从数据能明显看出,ResNet50精度确实高,但90毫秒的推理耗时意味着在要求流畅体验的交互场景里基本没法用。如果任务是离线图片分类、按设备端定时批量处理,ResNet50依然有它的价值,因为单图一次不在乎这一点时间。
而MobileNet系列和ShuffleNet都能做到30到60fps的实时推理,这个速度用在摄像头实时画面分析里是足够了的。
4.2 算力利用率的计算思路
我顺手帮大家算一下算力利用率,这个概念对选型特别有用。以MobileNetV2为例,它的理论运算量是0.62GFLOPs,大约对应0.31G次乘加运算。如果把0.5TOPS当作每秒0.5T次运算,理想情况下只需要1.2毫秒。但实测14毫秒,反推利用率只有不到10%。
这不是说芯片不行,而是小芯片在层间切换、DDR带宽、中间数据搬运上的开销占比太大。模型越小,这些开销占比反而越高,算力利用率就越难看。想要提升实际性能,与其换模型,不如优化输入分辨率,从224降到192或者160,推理耗时常能直接降三分之一。
4.3 输入尺寸与批处理影响
我还测试了不同输入分辨率对性能的影响。在RV1103上,把MobileNetV2的输入从224x224降到160x160后,单帧耗时大约从14ms降到8ms左右,而精度只掉了不到一个点。这是一个性价比非常高的优化思路。
批处理在RV1103上也值得测。部分NPU架构对batch=1的推理效率高,但也有芯片对batch>1有额外优化。考虑到IPC场景通常逐帧处理,实际部署中我很少开多batch,内存占用和延迟反而不好控制。
5. 部署排雷手册:几个能让你省一周的排查技巧
5.1 算子不支持与opset冲突
转模型时最容易遇到的报错就是“不支持某算子”或者“找不到某op”。我遇到的一个典型案例是把RReLU、Hardswish这类不常见激活函数直接塞进模型,转换时直接失败。处理办法可以从三条线入手:
- 升级rknn-toolkit2版本,新版算子覆盖更全
- 修改onnx导出时的opset版本
- 把不支持的自定义算子用等效的常规算子在PyTorch层面重写
如果模型里带了很多自定义模块,建议先用onnxruntime逐个节点做单算子测试,能更快定位到坏掉的节点。
注意:ResNet这类结构里如果训练时部分层被折叠成了identity或者带scale的BN层,导出ONNX后可能无法被RKNN正确解析。遇到这种情况,导出前最好把模型结构固定成推理模式,并显式合并BN层到卷积中。
5.2 量化后精度崩掉的排查思路
INT8量化后精度掉两三个点是正常的,但如果Top-1从70%直接掉到50%以下,就得认真排查。通常原因有这么几类:
- 量化校准图片太少或没覆盖真实场景
- 归一化参数和实际预处理不一致
- 模型个别层对量化特别敏感,比如Detection Head、最后的全连接层
排查这个坑有一个很实用的办法:先在PC上用RKNN模拟器,对同一批图片分别跑fp32模型和量化模型,记录逐层输出差异,找到误差放大最厉害的那几层。如果敏感层很明确,就用混合量化,把这几层保留为更高精度类型,其他层继续用INT8,整体能恢复大部分精度。
5.3 预处理对齐问题
预处理不对齐引发的精度问题特别隐蔽,因为不会报错,只会静悄悄掉点。板端如果用C代码做图片缩放和归一化,很容易在RGB/BGR通道顺序、减均值除方差的方式上出错。
一个稳妥的做法是:把预处理逻辑写成独立函数,在PC上和板端共用同一份Python原型做对照,用同一张图比较预处理后的tensor数值。只要预处理结果一致,推理输出就基本一致。
另外,输入图像resize时注意算法差异。OpenCV的cv2.resize默认是线性插值,板端如果用了最近邻插值,图像细节会变糙,可能直接影响分类精度。建议两侧统一用双线性插值。
5.4 内存占用与加载失败
RV1103方案的外接DDR通常不大,有些配置下可用系统内存不到200MB,加载大模型时容易失败。我遇到过一次ResNet50模型加载后系统内存被吃光,摄像头采集直接黑屏的例子。
解决思路是合理布局内存:模型尽量不要复制多份、输入输出的buffer尽量复用,推理结束后及时释放中间tensor。如果在RKNNLite的Python接口里发现内存增长明显,就要检查是不是每帧都重新分配了输出数组,改成复用同一块buffer会有很大缓解。
6. 选型策略与后续扩展方向
6.1 不同场景下的模型推荐
经过这轮实验,我对RV1103上的模型选型形成了比较清晰的判断。
实时视频流分类场景优先考虑MobileNetV2和ShuffleNetV2。MobileNetV2的兼容性和综合精度更好,ShuffleNetV2在某些芯片上算子效率高、耗时更低。MobileNetV3-Small适合低分辨率、低功耗、长续航的电池类产品,虽然精度稍微逊色,但速度优势明显。
对精度要求较高、又不需要实时响应的场景,比如离线图片聚类、夜间事件二次确认,ResNet50是可以接受的,但要注意内存吃紧。ResNet18这种定位比较尴尬,速度不如轻量级,精度又不如ResNet50,除非项目对ResNet结构有特殊依赖,否则不太推荐。
VGG系列、大Transformer模型在RV1103上完全没有部署价值,算子支持差、模型体积大、推理耗时动辄几百毫秒,属于明确要避开的对象。
6.2 进一步提升性能的方向
如果后续想把RV1103的性能再压榨一截,可以沿着几个方向做:
- 将教师模型的知识蒸馏到更小的MobileNetV3或者ShuffleNet结构上
- 对模型做结构化剪枝或者通道剪枝,降低实际算力需求
- 针对特定场景做部分层INT8、部分层保留高精度的混合量化调优
- 减少输入分辨率,配合ROI区域检测,只对感兴趣区域做高分辨率分类
- 使用双模型策略,低算力模型先做粗分类或唤醒,高精度模型只在必要时启动
另外一个值得尝试的思路是图像分类和目标检测联动。RV1103的主要场景是IPC,单纯做图像分类往往不能满足业务需求,可以在分类模型前面接一个轻量目标检测模型,检测到目标后再触发分类判断。这样的多模型复用方案比单模型硬扛精度更实用。
总的来说,RV1103这轮实验让我对低算力平台的部署逻辑理解深了不少。以前选模型总习惯先看精度榜单,现在得先问一句:这个模型转成INT8之后,在目标芯片上能不能跑得动、跑得稳。工具链的坑、量化的坑、内存的坑,这些都要在选型阶段提前考虑进去。
最后分享一个我个人特别推荐的操作习惯:每次转换模型后,一定要把PC模拟器的输出跟板端实跑的输出做一个量化对比,并存下日志配置记录。这个习惯在排查问题的时候价值巨大,省下的排查时间远超当初的试错成本。如果你也正在RV1103或者同级别芯片上折腾模型部署,希望这篇记录能帮你少走一段弯路。