1. 为什么我选择从 CNN 重新切入端侧 AI
1.1 一个嵌入式老兵的路径反思
做了七八年嵌入式开发,从裸机寄存器到 Linux 驱动再到 Qt 应用层,我一度觉得自己对“底层”这两个字已经理解得够透了。直到去年开始认真接触端侧 AI 部署,才发现自己踩进了一个完全陌生的战场。模型量化、算子融合、NPU 调度、内存对齐,这些东西跟传统的嵌入式开发有交集,但思维方式差别很大。我最初的想法很功利:直接上大模型,直接搞 Transformer,毕竟热词都在往那边跑。结果折腾了两个月,连一个能在开发板上稳定跑起来的目标检测都没搞定。
问题出在哪?我后来复盘,发现自己跳过了太多基础环节。端侧 AI 不是把 PC 上的模型文件拷到板子上就能跑的事情,它涉及到模型结构的选择、量化策略的制定、推理框架的适配、硬件加速单元的调用,每一个环节都有坑。而 CNN 作为深度学习里结构最清晰、部署工具链最成熟的一类网络,恰恰是打通整条链路的最佳切入点。这不是退步,是补课。
1.2 端侧 AI 到底在解决什么问题
说“端侧 AI”这个词,很多人第一反应是“把 AI 放到设备上跑”。这话没错,但太笼统了。我理解端侧 AI 的核心价值在于三个字:低延迟、低功耗、数据不出端。举个例子,工厂里的视觉质检,如果每帧图像都要传到云端推理再返回结果,网络抖动一下产线就得停;再比如智能门锁的人脸识别,把用户人脸数据传到服务器上处理,隐私风险先不说,断网了门都开不了。
这些场景对嵌入式工程师来说其实很熟悉,本质上就是实时性约束下的资源调度问题。只不过以前我们调度的是任务和中断,现在调度的是算力和内存带宽。CNN 在这个语境下特别合适,因为它的计算模式规整,卷积、池化、全连接这几类操作对硬件非常友好,NPU 的指令集和内存布局基本都是围绕这类操作设计的。你拿一个结构复杂的动态图模型去跑,NPU 可能一半算子都不支持,最后回退到 CPU 上跑,性能直接崩掉。
1.3 为什么不是一上来就搞大模型
我见过太多人(包括我自己)一上来就想在端侧跑大语言模型,觉得这才是“先进生产力”。但现实是,一个 7B 参数的模型即使量化到 4bit,也要占 3.5GB 左右的内存,而大多数嵌入式设备的 RAM 也就 512MB 到 2GB。就算你有 8GB 内存的板子,推理速度也慢得让人抓狂,token 生成速度可能只有每秒几个字。
CNN 就不一样了。一个 MobileNetV2 量化到 int8 之后,模型文件不到 4MB,推理一次在 NPU 上只要几毫秒。这个差距不是数量级的差距,是能用和不能用的差距。我的策略是:先用 CNN 把端侧 AI 的整条工具链跑通,理解量化、编译、部署、调优的全流程,然后再考虑往上叠更复杂的模型。基础打牢了,后面换网络结构只是替换模型文件的事。
2. CNN 在嵌入式端侧的核心技术点拆解
2.1 卷积到底在算什么
很多人学 CNN 的时候被各种术语绕晕,其实卷积操作本身很朴素。你可以把它想象成一个滑动窗口在图像上扫,每扫到一个位置,就把窗口里的像素值和一组权重做乘加运算,得到一个输出值。这组权重就是卷积核,也叫滤波器。不同的卷积核提取不同的特征,有的对边缘敏感,有的对纹理敏感。
在嵌入式端侧,卷积的计算量是核心瓶颈。一个标准的 3x3 卷积,输入通道 64,输出通道 128,特征图大小 56x56,乘加运算次数是 3×3×64×128×56×56,算下来大约 2.3 亿次。这个量级在 PC 上不算什么,但在主频 1GHz 左右的嵌入式处理器上,如果纯用 CPU 跑,一秒钟也就跑几次。所以必须依赖 NPU 或者 DSP 来加速。
这里有个关键点:深度可分离卷积。MobileNet 系列之所以能在端侧流行,就是因为把标准卷积拆成了深度卷积和逐点卷积两步,计算量能降到原来的八分之一到九分之一。代价是精度会掉一点,但通过合理的通道数设计和训练策略,这个损失可以控制在可接受范围内。我在实际项目里做过对比,同样的输入分辨率,标准卷积的模型在 NPU 上跑一次要 40ms,换成深度可分离卷积只要 6ms,精度只掉了 1.2 个百分点。
2.2 模型量化:从 float32 到 int8 的关键一跃
量化是端侧 AI 部署绕不开的一步。训练出来的模型权重是 float32 的,每个参数占 4 个字节,一个 10MB 的模型量化到 int8 之后就只剩 2.5MB。这不只是省存储空间的问题,更重要的是 NPU 的算力单元通常对 int8 有专门的加速支持,吞吐量能比 float32 高好几倍。
量化的核心思想是线性映射。假设某一层的权重范围是 [-2.5, 3.1],我们要把它映射到 [-128, 127] 这个整数区间。缩放因子 scale = (3.1 - (-2.5)) / (127 - (-128)) = 5.6 / 255 ≈ 0.022。然后每个浮点权重除以 scale 再取整,就得到了量化后的整数值。推理的时候,整数乘加完之后再乘回 scale,就近似还原了浮点结果。
但这里有个坑:不是所有层都适合量化。第一层卷积和最后一层全连接对精度特别敏感,量化之后精度掉得厉害。我一般的做法是这两层保持 float16 或者 float32,中间的层全部 int8。另外,激活值的量化范围需要通过校准数据集来统计,不能拍脑袋定。校准集一般从训练集里抽 100 到 500 张图就够了,太多没必要,太少统计不准。
2.3 NPU 是怎么加速卷积的
NPU 加速卷积的思路跟 CPU 完全不同。CPU 是通用计算单元,靠高主频和缓存来提升性能;NPU 是专用电路,靠并行度和数据复用来提升吞吐。具体来说,NPU 内部通常有大量的乘加单元阵列,可以同时计算几百甚至上千个乘加运算。卷积操作里每个输出像素的计算是独立的,天然适合并行。
另一个关键是数据复用。卷积核在特征图上滑动的时候,同一组权重会被反复使用。NPU 会把卷积核权重缓存在片上 SRAM 里,避免反复从 DDR 读取。特征图也会分块加载,块与块之间有重叠区域,这些重叠数据也会被缓存。我实测过,同样的 MobileNetV2,在 CPU 上跑一次要 120ms,在 NPU 上只要 8ms,十五倍的差距,主要就来自并行度和数据复用。
不过 NPU 也有它的脾气。算子支持有限,不是所有卷积变体都支持,比如空洞卷积、转置卷积在某些 NPU 上就没有硬件加速,会回退到 CPU。所以选模型结构的时候,一定要先查清楚目标 NPU 支持哪些算子。我一般会优先选 MobileNet、ShuffleNet、EfficientNet-Lite 这几个系列,它们的算子都是标准卷积和深度卷积,兼容性最好。
2.4 内存布局与数据排布的影响
这个点很多人会忽略,但在嵌入式端侧特别重要。CNN 的特征图在内存里怎么排,直接影响 NPU 的访存效率。常见的排布有 NCHW 和 NHWC 两种。NCHW 是通道在前,NHWC 是通道在后。CPU 上的推理框架通常喜欢 NCHW,因为对缓存友好;但 NPU 往往更喜欢 NHWC,因为通道维度连续排列更利于向量化加载。
我在一个项目里就吃过这个亏。模型转换的时候没注意,框架默认输出 NCHW,结果 NPU 每次都要做一次 transpose 操作,白白多了 20% 的耗时。后来在转换配置里改成 NHWC,耗时直接降下来了。所以模型转换的时候一定要看清楚输入输出的数据排布格式,跟 NPU 的要求对齐。
3. 从零搭建一个端侧 CNN 推理项目的完整流程
3.1 环境准备与工具链选型
先说硬件。我手头用的是瑞芯微 RK3588 的开发板,8GB 内存,自带 NPU,算力标称 6TOPS。选它是因为资料相对全,社区活跃,遇到问题容易找到参考。另一块是树莓派 5,没有 NPU,纯 CPU 推理,用来做性能对比。如果你刚开始入门,建议先用手头的 Linux 开发板把 CPU 推理跑通,再上 NPU 的板子。
软件工具链方面,训练我用 PyTorch,版本 2.1 以上。模型转换用 ONNX 作为中间格式,然后通过 RKNN Toolkit 转成 NPU 能跑的 rknn 模型。ONNX 的好处是通用,几乎所有训练框架都能导出,几乎所有推理框架都能导入。RKNN Toolkit 是瑞芯微官方提供的转换工具,支持量化、算子融合、内存优化这些功能。
安装 RKNN Toolkit 的时候注意版本匹配。它的 Python 版本依赖比较严格,我建议用 conda 建一个独立环境,Python 3.8 或者 3.9,不要用太新的版本。另外,转换工具只在 x86 的 Linux 上跑,Windows 和 Mac 都不行,所以要么装个 Ubuntu 虚拟机,要么直接用 Linux 开发机。
3.2 模型选择与训练要点
入门阶段我建议从 MobileNetV2 开始。它的结构清晰,有预训练权重,输入分辨率可以灵活调整。我这次做的项目是一个简单的垃圾分类 demo,输入 224x224 的 RGB 图像,输出 6 个类别。数据集是自己拍的,每个类别大概 500 张图,总共 3000 张左右。
训练的时候有几个点要注意。学习率不要设太大,我从 0.001 开始,用 cosine 衰减。数据增强要做,随机裁剪、水平翻转、颜色抖动这些都能提升泛化能力。Batch size 根据显存来,我用的 32,如果显存不够就降到 16 或者 8。训练轮数不用太多,这种小数据集 50 到 80 个 epoch 就够了,再多容易过拟合。
训练完之后先别急着转换,在 PC 上验证一下精度。我一般会留 20% 的数据做验证集,精度能到 90% 以上再往下走。如果精度不够,要么加数据,要么换更大的模型,要么调超参数。这一步偷懒的话,后面部署完了发现精度不行,回头改成本更高。
3.3 模型导出与量化实操
训练好的 PyTorch 模型先导出成 ONNX。导出的时候要指定输入尺寸和动态轴,我一般把 batch 维度设为动态,方便后面调整。导出命令大概是这样:
torch.onnx.export( model, dummy_input, "mobilenetv2.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=11 )opset_version 用 11 或者 12 比较稳,太新的版本有些转换工具不支持。导出之后可以用 Netron 打开看看结构对不对,有没有多余的算子。
接下来是量化。RKNN Toolkit 支持两种量化方式:训练后量化和量化感知训练。训练后量化简单,只需要准备一个校准集,工具会自动统计激活值范围。量化感知训练复杂一些,需要在训练阶段插入伪量化节点,但精度通常更好。我这次用的是训练后量化,校准集从训练数据里随机抽了 200 张图。
量化配置里有个参数叫quantized_dtype,可以选w8a8或者w8a16。w8a8 是权重和激活都量化到 int8,速度最快但精度损失稍大;w8a16 是权重 int8、激活 float16,精度好一些但速度慢一点。我先试了 w8a8,精度掉了 3 个百分点,换成 w8a16 之后只掉了 0.8 个百分点,最后选了 w8a16。
3.4 板端部署与推理测试
转换完成之后会得到一个 .rknn 文件,把它拷到开发板上。板端推理的代码用 Python 写,调用 RKNN 的运行时库。核心步骤是初始化运行时、加载模型、准备输入数据、执行推理、解析输出。
输入数据要注意格式。模型训练的时候用的是 RGB 通道,归一化到 [0, 1] 再减均值除方差。板端也要做同样的预处理,否则精度会对不上。我一般会把预处理写成单独的函数,PC 端和板端共用同一套逻辑,避免不一致。
推理测试的时候,我习惯先跑单张图看结果对不对,再跑批量测性能。性能测试用 100 张图循环跑,统计平均耗时和内存占用。RK3588 上 MobileNetV2 的 int8 推理,单张耗时大概 8ms,内存占用不到 50MB。这个性能跑实时视频流完全没问题,30fps 绰绰有余。
4. 实操中踩过的坑与排查经验
4.1 量化后精度暴跌的排查思路
精度暴跌是量化部署里最常见的问题。我遇到过一次,量化前验证集精度 92%,量化后直接掉到 65%,基本没法用。排查过程大概花了三天,最后定位到两个原因。
第一个原因是校准集分布不对。我一开始图省事,校准集只用了每个类别 10 张图,而且都是从同一个视频里截的,场景太单一。模型在真实场景下的激活值分布跟校准集差很远,量化范围统计偏了。后来把校准集扩到 200 张,覆盖不同光照、不同角度,精度回升到 85%。
第二个原因是某些层的权重量化范围过大。有一层卷积的权重里有个别异常大的值,导致整个层的量化 scale 被拉得很大,大部分权重被压缩到很小的整数区间,精度损失严重。解决办法是开启逐通道量化,每个通道单独统计 scale,而不是整层共用一个。RKNN Toolkit 里有个channel_quant选项,打开之后精度又回升了 4 个百分点。
4.2 NPU 算子不支持的回退问题
NPU 不是万能的,有些算子它不支持,转换的时候会报错或者自动回退到 CPU。我遇到过一次,模型里有个HardSwish激活函数,NPU 不支持,转换工具默默把它回退到 CPU 执行。结果整个模型被切成好几段,NPU 跑一段、CPU 跑一段、再 NPU 跑一段,段与段之间还有数据拷贝开销,整体耗时反而比纯 CPU 还慢。
解决办法有两个:一是换激活函数,把HardSwish换成ReLU6,NPU 原生支持,精度影响很小;二是如果非要用某个算子,看看有没有等效的算子组合能替代。我一般会在模型设计阶段就查清楚目标 NPU 的算子支持列表,避免后面返工。
4.3 内存不足与性能调优
嵌入式设备的内存很紧张,模型加载和推理过程中如果内存不够,轻则报错,重则系统卡死。我遇到过一次,模型文件只有 4MB,但推理的时候内存占用飙到 800MB,直接把系统搞崩了。后来发现是输入分辨率设太大了,224x224 的输入,中间特征图最大的一层有 56x56x256,float32 存储要 3MB 多,但推理框架会同时保留多个中间结果,累积起来就爆了。
调优的手段有几个:降低输入分辨率,从 224 降到 192,内存占用能降 30% 左右;限制中间结果的缓存数量,有些框架支持配置;用内存池,提前分配好固定大小的内存块,避免频繁申请释放。我最后把输入降到 192x192,内存占用控制在 200MB 以内,稳定运行没问题。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 量化后精度暴跌 | 校准集分布不对 | 对比量化前后各层输出 | 扩充校准集,覆盖更多场景 |
| 量化后精度暴跌 | 权重量化范围过大 | 检查各层权重最大最小值 | 开启逐通道量化 |
| 推理速度比 CPU 还慢 | 算子回退到 CPU | 查看转换日志 | 替换不支持的算子 |
| 推理时内存溢出 | 输入分辨率过大 | 监控内存占用曲线 | 降低分辨率或限制缓存 |
| 输出结果全错 | 输入预处理不一致 | 对比 PC 和板端预处理 | 统一预处理逻辑 |
| 模型加载失败 | 版本不匹配 | 检查工具链版本 | 对齐训练和转换环境 |
5. 从 CNN 出发的后续扩展方向
5.1 目标检测与分割的端侧落地
分类任务跑通之后,下一步自然是目标检测。YOLO 系列在端侧很流行,YOLOv5 和 YOLOv8 都有官方的量化部署方案。不过检测模型比分类模型复杂,后处理里的 NMS 操作在 NPU 上支持不好,通常要放到 CPU 上做。我实测过 YOLOv8n 在 RK3588 上的表现,输入 640x640,NPU 推理加 CPU 后处理,单帧大概 25ms,勉强能跑 30fps。
语义分割的话,DeepLabV3 和 FastSCNN 是比较适合端侧的。FastSCNN 专门为嵌入式设计,参数量小,推理速度快。不过分割模型对输入分辨率要求高,分辨率降太多边缘会很粗糙,需要在速度和精度之间找平衡。
5.2 多模型串联与流水线设计
实际项目里很少只跑一个模型。比如智能门锁,要先跑人脸检测,再跑人脸识别,可能还要跑活体检测。多个模型串联的时候,要考虑模型之间的数据传递、内存复用、调度顺序。我的做法是把多个模型编译到一个 rknn 文件里,共享输入输出缓冲区,减少数据拷贝。RKNN Toolkit 支持多模型打包,转换的时候把多个 ONNX 一起传进去就行。
流水线设计上,如果两个模型之间没有依赖关系,可以并行跑在不同的 NPU 核心上。RK3588 有三个 NPU 核心,可以同时跑三个模型。但如果模型之间有依赖,就只能串行,这时候要优化的是每个模型的耗时,让总延迟尽量低。
5.3 端侧训练与增量学习
端侧训练是个比较前沿的方向,目前主流做法还是在 PC 上训练好再部署。但有些场景需要设备根据新数据做增量学习,比如智能摄像头要适应新的光照条件。端侧训练对算力和内存要求更高,通常只能微调最后几层,或者用轻量级的适配器结构。
我目前还在探索阶段,试过在 RK3588 上用 CPU 微调 MobileNetV2 的最后一层全连接,100 张图训练 10 个 epoch,大概要 3 分钟。这个速度对于某些低频更新的场景勉强能用,但离实用还有距离。后续可能会考虑用 NPU 做前向传播、CPU 做反向传播的混合方案。
5.4 工具链的持续跟进
端侧 AI 的工具链更新很快,RKNN Toolkit 从 1.0 到 1.6 变化很大,API 和配置项都有调整。我的经验是不要盲目追新,新版本可能引入新的 bug,而且社区资料还没跟上。我一般会等一个新版本发布两三个月,看社区反馈稳定了再升级。升级之前一定要备份当前可用的环境,用 conda 导出环境配置文件,出问题了能快速回滚。
另外,多关注芯片厂商的官方示例仓库。瑞芯微、晶晨、全志这些厂商都会在 GitHub 上放示例代码,虽然质量参差不齐,但至少能跑通。我很多配置参数都是从官方示例里抄来的,比自己摸索快得多。
6. 一些个人体会
从嵌入式转到端侧 AI,最大的感受是思维方式要变。传统嵌入式开发讲究确定性,时序、中断、内存布局都是精确可控的;端侧 AI 充满了近似和取舍,量化是近似,算子融合是近似,甚至模型本身也是对现实世界的近似。你得接受这种不确定性,然后在精度和性能之间找平衡点。
另一个体会是工具链的坑比算法本身多。CNN 的原理其实不复杂,网上教程一大把,但把模型成功部署到板子上跑起来,中间要趟过的坑太多了。版本兼容、算子支持、内存对齐、数据排布,每一个都能卡你半天。我的建议是先用最简单的模型把整条链路跑通,哪怕只是一个三层的 CNN 做手写数字识别,跑通了再往上加复杂度。
最后说一个实际的小技巧:保留一个最小可复现的测试用例。我在项目里会单独维护一个 mini 模型,输入 32x32,两个卷积层加一个全连接,转换和部署只要几秒钟。每次工具链升级或者换新板子,先用这个 mini 模型验证环境,没问题了再上正式模型。这样能快速定位是环境问题还是模型问题,省下大量排查时间。