搞嵌入式AI的,这两年很难绕开瑞芯微RV1106这颗芯片。我最近把一批主流图像分类模型挨个在它上面跑了一遍,从MobileNet到EfficientNet,从FP32到INT8量化,记录了完整的部署链路、转换脚本和实测数据。这篇文章就是这轮实验的总结,适合准备在RV1106上做图像分类落地、又不想只看官方demo的朋友,也适合那些在算力受限设备上做模型选型、被算子支持和量化精度折磨过的开发者。
我手上这块板子是RV1106的核心模组,主控是ARM Cortex-A7单核,内置NPU算力在0.5 TOPS左右,板载DDR方案,整体功耗和成本都压得很低。这类芯片典型的场景是IPC摄像头、智能门锁、边缘盒子,图的是一个低成本和低功耗,不是让你跑Transformer大模型。所以实验的重点很明确:在这么点算力上,主流轻量级图像分类模型到底能不能跑、能跑多快、精度损失多少、部署过程中会踩哪些坑。
1. 平台能力与部署环境准备
1.1 RV1106的NPU算力到底够用吗
先说结论:0.5 TOPS听起来很寒酸,但做单路图像分类任务其实够用,前提是模型选对、量化做对。
很多人对TOPS这个概念没有体感,我打个比方:MobileNetV1的INT8模型在RV1106上单帧推理耗时大约在30到60毫秒,对应每秒15到30帧;而ResNet50这种大模型即便量化后也接近300毫秒以上,基本告别实时场景。所以这颗NPU的定位非常清晰——它适合1M到5M参数量的轻量模型,不适合大模型硬塞。
RV1106的NPU只支持INT8/INT16推理,FP32模型会在编译阶段被强制量化,或者你手动指定量化方式。这个限制决定了整个部署链路的一个核心逻辑:模型能不能上板,不只看参数量,还要看算子能不能被RKNN工具链映射到NPU上。有些模型结构里一旦出现NPU不支持的算子,整个模型就会被切到CPU跑,速度直接崩盘。
我这次实验选的板子跑的是官方提供的Buildroot系统,内核里已经带好了NPU驱动,省去自己移植驱动的过程。如果你用的是第三方核心板,一定要确认驱动版本和rknn-toolkit2的匹配关系,这是后面所有坑的源头。
1.2 RKNN工具链的版本要分清
瑞芯微的NPU工具链现在主流是rknn-toolkit2,RV1103/RV1106/RV1126这些平台都用它。这里有个特别容易踩的坑:rknn-toolkit(不带2)是老版本,只支持RK3399Pro、RK1808那一代芯片,你要是对着老教程用rknn-toolkit去转换RV1106的模型,第一步就会报芯片平台不支持。
rknn-toolkit2本身是跑在x86电脑上的Python工具包,它负责把ONNX、PyTorch、TensorFlow模型转成.rknn格式的文件。注意,这个工具包不跑在板子上,板子上运行时用的是librknnrt.so,这是推理运行时库。整个工作流是:电脑上完成转换和量化,生成rknn文件,然后拷贝到板子,用C/C++或者Python API调用。
我用的版本对应关系仅供参考:rv1106平台建议用rknn-toolkit2 1.6.0或更新的版本,配套的librknnrt版本要和转换时生成的rknn文件兼容。版本不匹配最常见的表现就是rknn_init直接返回错误码,或者加载模型时报版本不支持。
1.3 交叉编译和运行环境准备
RV1106上跑推理,我建议直接用C API,Python在板子上跑太浪费资源,而且启动开销大。C API的交叉编译需要准备arm架构的工具链,官方Buildroot里自带,如果你是自己搭的环境,用arm-linux-gnueabihf-gcc也可以,但注意RV1106是Armv7-A架构,不是aarch64,别选错工具链。
我这次实验的交叉编译命令大致是这样:
arm-linux-gnueabihf-gcc -o rknn_classify demo.c -I./include -L./lib -lrknnrt -lm编译出来的可执行文件拷到板子后,需要确保librknnrt.so在动态库搜索路径里。我习惯把它放到/usr/lib或者直接放在程序同目录然后用LD_LIBRARY_PATH指定。还有一点,板子上需要确认NPU设备节点存在,一般就是/dev/dri或者/dev/npu开头的设备节点,如果找不到,多半是内核驱动没加载或者设备树没配置对。
2. 图像分类模型选型与量化转换实操
2.1 候选模型名单和选择逻辑
这次实验我挑了几类有代表性的图像分类模型,不是随便抓一把,而是覆盖了不同结构和不同算子风格:
- MobileNetV1和MobileNetV2:深度可分离卷积的代表,算子简单,NPU友好,是做嵌入式分类的首选。
- MobileNetV3:加入了SE注意力模块和Hard-Swish激活,理论上精度更高,但SE模块里的全局平均池化和全连接层在NPU上不一定被支持,得实测。
- ShuffleNetV2:Channel Shuffle和分组卷积是特色,分组卷积在某些NPU上支持得不好。
- EfficientNetB0:用了Swish激活和SE模块,结构稍复杂,参数量适中。
- ResNet18:传统残差结构,算子规整,作为“稍大模型”的对照组。
- GhostNet:华为提出的轻量网络,用了Ghost Module,近两年在端侧很流行,值得测一下。
实验用的是CIFAR-10和自建的10类小数据集,避免ImageNet动辄几百兆的文件在调试阶段拖慢节奏。等模型选型确定后,再在完整数据集上做最终验证。分类类别数我统一改成10,输入分辨率统一用224x224,个别模型在CIFAR-10上原始结构是32x32输入,我调整了最后分类层并改用224输入,这样性能和精度对比才公平。
2.2 RKNN转换的完整脚本与参数
模型转换是整个部署流程的第一道坎。我先说整体思路:先把模型导出成ONNX,再用rknn-toolkit2的Python接口加载、配置、量化、导出rknn文件。不要直接拿PyTorch权重转,中间过一遍ONNX会省掉很多算子兼容的麻烦。
我以MobileNetV2为例,写了一个转换脚本,核心参数都标了注释:
from rknn.api import RKNN rknn = RKNN(verbose=True) # 输入给rknn的ONNX模型路径 ret = rknn.load_onnx(model='mobilenetv2.onnx') if ret != 0: print('load onnx failed') # 配置量化参数 ret = rknn.config( mean_values=[[123.675, 116.28, 103.53]], # 和训练时预处理保持一致 std_values=[[58.395, 57.12, 57.375]], target_platform='rv1106', quantized_dtype=' asymmetric_quantized-8', quantized_algorithm='normal', optimization_level=3 ) if ret != 0: print('config failed') # build会做模型解析和量化, 需要准备校准图片目录 ret = rknn.build(do_quantization=True, dataset='dataset.txt') if ret != 0: print('build failed') # 导出最终部署文件 ret = rknn.export_rknn('mobilenetv2.rknn') if ret != 0: print('export failed') rknn.release()dataset.txt每一行是一张校准图片的绝对路径,我放了大约200张和真实场景分布一致、覆盖各个类别的图片。这里有个关键点:量化不是随便拿照片凑数,校准集如果和实际应用场景差异大,量化后的精度会很惨。
2.3 量化校准与精度回退策略
量化是整个部署实验里最玄学的部分。理论上INT8量化会把权重从FP32压到8位,精度一般掉1%到3%都在合理范围,但实际跑起来掉5%甚至直接崩的情况也不少见。
我这次测试遇到的情况是这样的:MobileNetV1量化后几乎不掉点,Top-1精度从92.1%掉到91.8%;EfficientNetB0掉了接近4个百分点,从94.5%掉到90.6%。为什么差距这么大?核心原因在于EfficientNet的Swish激活和SE模块里的动态特征,对数值范围非常敏感,量化后的信息损失更大。
遇到明显掉点,我用的回退方案是:
- 先把校准图片数量从100张加到500张,有时模型某些通道在少量数据下统计出的min/max不准确。
- 修改config里的quantized_algorithm,从normal改成mmse,mmse全称是最大最小均方误差,对分布偏斜的层更友好。
- 对某些敏感层做混合量化,保留少量层的FP16精度,这个rknn-toolkit2高版本支持按层指定,但RV1106的NPU对混合精度的支持有限,效果有时不明显。
- 实在不行就换模型,不要和量化死磕。
我测下来,MobileNetV2和ShuffleNetV2的量化表现最稳,EfficientNetB0虽然精度高但量化损失大,ResNet18中规中矩但速度吃亏。后面实测数据部分会详细对比。
3. 推理代码编写和摄像头接入的细节
3.1 RKNN C API的最小实现
转换完成后,板子上的推理代码是另一套逻辑。我先把最精简的C API调用流程写出来,这套流程我用了一年多,基本没变过:
#include "rknn_api.h" #include <stdio.h> int main() { // 1. 初始化,读取rknn模型文件 FILE *fp = fopen("mobilenetv2.rknn", "rb"); fseek(fp, 0, SEEK_END); int model_len = ftell(fp); fseek(fp, 0, SEEK_SET); unsigned char *model_data = malloc(model_len); fread(model_data, 1, model_len, fp); fclose(fp); rknn_context ctx; int ret = rknn_init(&ctx, model_data, model_len, 0, NULL); if (ret < 0) { printf("rknn_init error %d\n", ret); return -1; } // 2. 查询输入输出属性,确认tensor的shape和type rknn_input_output_num io_num; rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num)); printf("input num: %d, output num: %d\n", io_num.n_input, io_num.n_output); rknn_tensor_attr in_attr; in_attr.index = 0; rknn_query(ctx, RKNN_QUERY_INPUT_ATTR, &in_attr, sizeof(in_attr)); printf("input shape: %d-%d-%d-%d, type: %d\n", in_attr.dims[0], in_attr.dims[1], in_attr.dims[2], in_attr.dims[3], in_attr.type); // 3. 准备输入输出buffer int input_size = 224 * 224 * 3; // NHWC布局 unsigned char *input_data = malloc(input_size); rknn_input inputs[1]; inputs[0].index = 0; inputs[0].type = RKNN_TENSOR_UINT8; inputs[0].size = input_size; inputs[0].fmt = RKNN_TENSOR_NHWC; inputs[0].buf = input_data; rknn_output outputs[1]; outputs[0].want_float = 1; // 直接拿float输出, 省得自己解码 // 4. 推理 rknn_run(ctx, NULL); rknn_outputs_get(ctx, 1, outputs, NULL); float *out_data = (float *)outputs[0].buf; // 找到最大score对应的类别 int cls = 0; float max_score = -1.0f; for (int i = 0; i < 10; i++) { if (out_data[i] > max_score) { max_score = out_data[i]; cls = i; } } printf("predict class: %d, score: %f\n", cls, max_score); rknn_outputs_release(ctx, 1, outputs); rknn_destroy(ctx); free(input_data); free(model_data); return 0; }这段代码看起来简单,但有几个细节容易坑新手:第一,输入tensor的fmt不要搞错,rknn_toolkit2转换时默认输入通常是NHWC,如果代码里用NCHW布局去填数据,输出结果会完全错乱;第二,输入type是UINT8的时候,数据要自己完成减均值除方差的预处理,npU只负责推理,不负责归一化;第三,outputs数组的want_float设成1后,需要调用rknn_outputs_release释放buffer,否则连续推理会出现内存泄漏。
我实际在板子上跑的时候,推理函数封成了单例,初始化一次后循环喂图,这样能省掉反复rknn_init的开销。rknn_init本身挺耗时,我在板子上实测接近50毫秒,虽然比不上大模型加载,但在实时视频场景里也不能每次都重新初始化。
3.2 FPS和耗时统计的正确姿势
性能测试最大的误区是只统计rknn_run的时间,把前处理、后处理、图像读取全部忽略。真实端侧场景里,从摄像头拿一帧到拿到分类结果,整个端到端链路都算才算合理。
我统计耗时时把流程拆成四段:
- capture:从摄像头或图片文件读一帧。
- preprocess:图像缩放、归一化、NHWC布局转换。
- inference:rknn_run加rknn_outputs_get。
- postprocess:找最大score,做阈值过滤或其他业务处理。
每段用clock_gettime(CLOCK_MONOTONIC)记录微秒时间戳。CLOCK_MONOTONIC是单调时钟,不受系统改时间影响,比gettimeofday准。这样不仅能看总FPS,还能定位瓶颈在哪一段。
实测发现,MobileNetV1在RV1106上的rknn_run约40毫秒,但preprocess因为要自己写普通C代码做缩放和归一化,竟然也要20多毫秒。这个前处理耗时占比很高,优化空间很大。后面我对比了OpenCV的cv::resize和手工双线性插值,发现OpenCV在RV1106上有SIMD优化,速度快一截,能压到10毫秒左右。
3.3 摄像头实时分类流程
如果是做实时摄像头分类,流程上还有一个容易忽略的点:UVC摄像头或者MIPI摄像头的输出格式通常是YUYV或NV12,而模型输入需要RGB。所以在preprocess阶段要多一步格式转换。
我用的流程是:
V4L2取帧(YUYV) -> 转RGB -> 缩放224x224 -> 减均值除方差 -> NPU推理 -> 输出分类YUYV转RGB我一开始用浮点公式算,速度惨不忍睹,一帧720p要300毫秒以上。后来改成查表法,把Y、U、V每个分量的映射表提前算好,转RGB的耗时直接降到80毫秒左右。如果想更快,可以直接用官方ISP输出RGB,但需要配置ISP和v4l2的pixelformat,不是所有摄像头都支持。
在摄像头场景下我还发现一个事:不要把NPU推理和图像采集放在同一个线程里。V4L2采集是阻塞的,如果采集慢,推理就会一直等;反过来推理时间长了,摄像头buffer又会堆积。我建议开两个线程,中间用环形队列传递帧指针,采集线程只管填帧,推理线程只管取帧处理。板子是单核A7,跑双线程不会加速计算,但能避免采图阻塞导致推理空转,逻辑上更清晰。
4. 全部模型实测数据与选型结论
4.1 测试口径和评测条件
为了让对比有意义,我统一了测试条件:
- 输入分辨率224x224x3。
- 全部模型用INT8量化,校准集200张。
- 推理时间统计的是rknn_run加rknn_outputs_get的平均耗时,连续跑200帧取均值,避免第一帧冷启动干扰。
- 精度用CIFAR-10的测试集,共10000张图。
- 板子CPU频率保持默认,不额外超频。
这里要说一下为什么取200帧平均值而不取单帧最小值:NPU在连续推理时存在buffer切换和内存分配抖动,单帧最小值容易让人产生“模型很快”的错觉,平均值才代表真实长期运行水平。如果想要稳定帧率,建议进一步统计P95耗时,也就是95%的帧能在多少毫秒内完成,这个指标对判断是否丢帧更有参考价值。
4.2 性能对比结果
直接上我实测到的数据,参数和精度都会因训练设置不同而浮动,但相对关系是可靠的:
| 模型 | 参数量 | 平均推理耗时(ms) | 帧率(FPS) | FP32精度 | INT8精度 | 精度损失 |
|---|---|---|---|---|---|---|
| MobileNetV1 | 4.2M | 43 | 23.3 | 92.1% | 91.8% | 0.3% |
| MobileNetV2 | 3.4M | 38 | 26.3 | 93.4% | 93.1% | 0.3% |
| MobileNetV3-Large | 5.4M | 55 | 18.2 | 94.2% | 90.5% | 3.7% |
| ShuffleNetV2 (1.0x) | 2.3M | 29 | 34.5 | 93.0% | 92.4% | 0.6% |
| EfficientNetB0 | 5.3M | 72 | 13.9 | 94.5% | 90.6% | 3.9% |
| ResNet18 | 11.7M | 143 | 7.0 | 93.8% | 92.9% | 0.9% |
| GhostNet (1.0x) | 4.6M | 51 | 19.6 | 93.5% | 92.7% | 0.8% |
这个表有几个信息量很大的点。第一,ShuffleNetV2速度居然最快,比MobileNetV1还快,说明它的分组卷积在RV1106的NPU上映射得不错,这和其它某些平台的体验很不一样。第二,MobileNetV3和EfficientNetB0耗时明显偏高,原因是SE模块里的全局平均池化和全连接层有部分算子被落到CPU执行,导致整个推理链路变慢。第三,ResNet18虽然精度高、量化损失小,但帧率只有7 FPS,做实时交互很勉强。
4.3 根据场景选模型的建议
如果做实时视频流分类,我首推ShuffleNetV2,29毫秒的延迟配合34 FPS的帧率,在0.5 TOPS的平台上已经很难得,而且量化精度稳定。
如果做拍照识别或者对延迟不敏感、更看重精度,MobileNetV2是性价比之王,38毫秒不慢,精度在所有轻量模型里属于第一梯队,而且量化后几乎不掉点。
如果要跑一个固定类目少、光照稳定的小任务,MobileNetV1也可以考虑,虽然速度略慢于ShuffleNetV2,但它结构极简,NPU算子覆盖完整,部署踩坑概率最小。
我不建议在RV1106上为了几个点的精度去硬上EfficientNetB0或者更大的模型。量化掉点多,算子还得CPU兜底,功耗和发热也会上来。0.5 TOPS的平台就该干0.5 TOPS的活,模型选小一号,系统和帧率都会舒服很多。
5. 部署中的高频问题和排障经验
5.1 算子不支持与模型结构替换
这次实验里,MobileNetV3和EfficientNetB0都出现了算子不支持的情况。rknn-toolkit2在build阶段会详细打印每个op的映射情况,我遇到最多的是Hard-Swish和SE模块里的某些自定义算子。
Hard-Swish在MobileNetV3里用得很频繁,表达式是x乘上ReLU6(x加3)再除以6。老版本的rknn工具链不直接支持这个激活函数,解决方案有两个:一个是对模型做算符融合,把Hard-Swish拆成ReLU6加乘法和除法的基本算子组合;另一个更省事的方法是直接改用支持Hard-Swish的高版本rknn-toolkit2。
SE模块的问题更麻烦。SE的全称是Squeeze-and-Excitation,里面有全局平均池化和两个全连接层。全局平均池化在NPU上支持没问题,但全连接层在某些版本里会被降到CPU。我在工具链日志里看到被CPU执行的层会有明显标志,输出日志里搜“CPU”或者“not support”就能定位。
遇到这类问题,我的处理原则是:能改结构就改结构,把SE模块整个去掉重新训练,对精度影响可能不到1个点,但速度能提升30%以上。在算力受限平台上,牺牲一点精度换帧率,往往比硬撑结构更划算。
5.2 量化掉点的主体排查思路
量化掉点如果超过3%,不要盲目调校准图数量,先判断掉点来自预处理不匹配还是模型本身敏感。
我踩过一次特别经典的坑:训练时用的是ImageNet的mean和std做归一化,但转换脚本里我填错了mean_values,结果量化后精度直接掉了10个点。这种问题特征很明显,精度暴跌且所有类别输出都偏到某个固定类别上。排查方法很简单,拿同一张图分别跑FP32模型和INT8模型,打印输出向量,看数值分布是否整体偏移。
如果预处理没问题,但个别类别精度特别差,通常是因为这些类别的样本在量化时代表性不足。我建议把校准集按类别均匀采样,不要用完全随机的图片,否则高频类别会把量化范围带偏,低频类别精度崩掉。
如果模型本身对量化敏感,可以把敏感层的输出范围打出来看看。工具链提供了per-layer量化误差分析工具,在build之后可以生成量化误差报告。EfficientNetB0的掉点主要来自最后几个卷积层,这些层输出特征的数值范围很大,INT8存不下。针对这种情况,我最后的方案是换模型,不去做精细的混合量化调参,因为在小平台上投入产出比太低。
5.3 内存、线程与驱动相关的坑
RV1106板载内存有限,我测试时通过rknn_query查询到模型占用的NPU内存,MobileNetV2的模型权重和中间buffer加起来大概30到50MB,对大内存平台不算什么,但和摄像头buff、系统其它进程放在一起,有时会触发OOM。
遇到OOM,第一反应是检查是否有多个RKNN context没有销毁。我最初在调试时连续rknn_init了两次,第二次init直接返回内存不足错误。所以强烈建议整个进程生命周期只保留一个context,按需创建和销毁。
还有一个很隐蔽的问题:rknn_outputs_get在want_float=1时,返回的浮点结果是在CPU端分配的内存,连续推理后如果不调rknn_outputs_release,内存会在每次推理后累积增长。我在做连续2000帧压力测试时,内存从30MB一路涨到200MB,最终导致系统卡死。这个问题排查出来后,我把所有outputs的释放管理统一封装到推理函数末尾,再测就稳定了。
驱动方面,我遇到过板子重启后NPU设备节点偶尔不出现的情况。排查思路是先确认内核模块加载顺序,再看设备树里NPU节点的status是否设置为okay。如果驱动本身没问题但节点还是丢失,检查板子供电是否稳定,电量不足时NPU驱动会在初始化阶段失败。
5.4 排障速查表
我把这轮实验遇到的典型问题整理成了一张表,方便你以后对照排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| rknn_init返回-1 | rknn文件与运行库版本不匹配 | 对比rknn-toolkit2和librknnrt版本号 |
| build阶段报算子不支持 | 模型中有NPU未覆盖的op | 查看日志中CPU执行标记,替换或简化结构 |
| 精度暴跌10%以上 | 预处理mean/std填错 | 打印输出向量,核对归一化参数 |
| 精度掉3%-5% | 校准集分布不代表性 | 类别均匀采样,校准图加到300张以上 |
| 连续推理内存持续上涨 | 忘记调用rknn_outputs_release | 检查输出buffer释放逻辑 |
| 帧率不稳定忽高忽低 | 摄像头采集和推理同线程互相阻塞 | 改成双线程加环形队列 |
| 程序启动时卡死 | 内存不足或驱动没准备好 | 查看dmesg,检查内存占用和NPU设备节点 |
最后再分享一个小技巧:多模型并行或者需要频繁切换模型的时候,不要每次都重新rknn_init加载文件,而是把所有需要的rknn模型在程序启动时一次性加载到内存,后面按需激活。RV1106虽然内存不大,但加载两个轻量分类模型还是绰绰有余的,切换时间能从50毫秒降到5毫秒以内。这种细节在真实产品里很影响体验,尤其当你要做多目标识别、前背景分类轮流切换的时候,提前加载模型能让整个逻辑顺畅很多。我自己在后续做多任务扩展时一直沿用这个思路,实测下来稳定性和响应速度都比反复加载要好。