前几天在嵌入式板子上跑一个手势识别模型,板子只有 320KB RAM,模型量化后整整 180KB,剩下的空间连跑系统的缓冲都吃紧。折腾了一晚上把 Tensor Arena 的复用策略改掉,才把内存压在 90KB 以内。那一刻我突然觉得,TinyML 这个领域,真正值钱的不是会训练模型,而是知道怎么把模型塞进一个连“足够大”都谈不上的单片机里。

这篇接着之前的内容继续深入。如果你还没看过第一部分,建议先回去把基础流程和工具链过一遍,因为接下来所有讨论都建立在“你已经能在开发板上跑通一个最简单的模型”这个前提下。这篇会更聚焦在工程落地:模型怎么优化、部署时踩了哪些坑、性能怎么调、常见故障怎么排查。我会尽量保持口语,但技术细节一点都不会少。

1. 工具链选型与完整工作流

1.1 为什么我现在更看重端到端流程

很多人刚接触 TinyML 时,习惯把“训练”和“部署”看成两个割裂的阶段:在电脑上训好模型,导出一个文件,然后丢给嵌入式环境跑。这思路在原型验证阶段没问题,真到产品化就麻烦了。我在实际项目里吃过不少亏,最典型的一次是模型在 PC 端推理完全正常,一到板子上输出全是垃圾值,最后排查下来是量化参数在导出环节出了偏差,训练时用的归一化方式跟推理端不一致。

所以现在我上手一个新项目,第一件事不是急着找数据集,而是先把整套端到端流程画出来:从数据采集、清洗、增强,到模型训练、量化感知训练、导出 TFLite,再到用 TFLite Micro 在目标板子上跑推理。每一环都要考虑“下游能不能消费我的产物”。比如你在训练阶段用了 Batch Normalization,导出时如果不对算子做折叠处理,嵌入式端的模型就会多出不少运行时开销;又比如你在 TF 里定义了动态 shape,转换到 TFLite 时就会因为 shape 不明确报错。提前把流程想清楚,后面会少很多事情。

工具链上,我常用的组合是 TensorFlow + TFLite Converter + TFLite Micro。TFLite Converter 负责把 SavedModel 或 Keras 模型转成 .tflite 文件,这个文件里的算子会被映射成 TFLite Micro 能支持的一组精简算子。转换这一步看起来简单,坑却不少。

1.2 从 TF 到 TFLite Micro 的必经环节

先说说标准流程长什么样,然后再讲为什么每个环节都不能省。

第一步,训练并保存模型。我建议用 Keras 写好模型后,直接保存为 SavedModel 格式,因为 SavedModel 携带了完整的图结构和变量,后续转换各种格式都比较方便。

第二步,用 TFLite Converter 转换为 FlatBuffer 格式。这里有一个很容易被忽略的点:默认转换会保留 float 权重,如果你直接拿去部署,会发现板子上跑一次推理要几十甚至几百毫秒,RAM 占用也大得吓人。更好的做法是用量化感知训练。简单说,就是在训练阶段就模拟量化误差,让网络权重和激活值适应低比特表示。这样转换时即使把权重压到 int8,精度损失也能控制在可接受范围内。

第三步,转换后一定要做校验。不要只盯着 .tflite 文件是否生成成功,真正的坑在算子兼容性上。我在一个语音唤醒项目里用到 Mel Spectrogram 预处理,训练时用 TensorFlow 自带的 ops,转换后模型文件能生成,却在 TFLite Micro 上加载时报“Unsupported op”。后来只好把预处理挪到 MCU 上,用纯 C 手写了一个特征提取,才算绕过这个坑。而且这一步需要对比 PC 端和 MCU 端的输出,逐层比对最大误差,误差过大的层就要考虑是否用更高比特精度或调整量化策略。

第三步做完,就轮到 TFLite Micro 运行时了。它本质上是一个针对单片机优化的解释器,只支持有限的算子集合。你在 PC 端模型里随便用个 tf.split,到了这里可能就一脸懵。所以选算子时,心里要有一张“TFLite Micro 支持/不支持”的清单。Conv2D、DepthwiseConv2D、AveragePool2D、FullyConnected 这些常用算子基本没问题,但像 TensorFlow 里复杂的控制流、动态 shape 依赖,就得提前规避。

整个工具链的大致关系可以这样理解:TensorFlow 负责把“模型的灵魂”造出来,Converter 负责把它翻译成一种更精简的中间语言,最后 TFLite Micro 在 MCU 上用最小代价把这种中间语言解释执行。任何一层的产物有问题,后面都会连锁出错。

2. 模型瘦身三板斧:量化、剪枝、蒸馏

2.1 量化不是简单把 float 变 int

先纠正一个常见误区:量化不等于“把 float32 小数点四舍五入成整数”。真正的量化是一个分布映射过程,你要搞清楚每个张量的数值范围。比如 float32 的范围是 [-1, 1],你想映射到 int8 的 [-128, 127],那就需要一个 scale 和一个 zero_point,分别对应缩放比例和零点偏移。训练后量化的时候,框架会自动统计数据范围,但如果你有特殊操作改变了数据分布,量化误差就会变大。

量化感知训练(Quantization-Aware Training,QAT)是我在精度敏感项目里的首选方案。它在训练图里插入伪量化节点,让模型在训练时就“适应”低比特表示。训练完成后,再用转换器将伪量化节点“蒸发”掉,得到真正 int8 权重的模型。这里有个细节要注意:QAT 训练时的学习率要适当调低,因为量化带来的噪声相当于一种正则化,学习率太大会导致收敛不稳定。我通常会把基础学习率从 1e-3 调到 3e-4 甚至更低,同步观察训练集和验证集的 loss 曲线,防止过拟合。

还有一点,动态范围量化跟全整数量化是两回事。动态范围量化只把权重变成 int8,激活值在推理时才动态量化,这样在 ARM Cortex-M 上能省一部分内存,但速度提升有限。全整数量化则把激活值也固定成 int8,这样推理路径完全走 int8 计算,速度会快很多,也更适合没有 FPU 的 MCU。判断项目用哪种量化,要看你目标板子有没有硬件浮点单元,以及你的实时性要求。

我在部署一个 1D CNN 模型时做过对比:float32 模型体积约 196KB,动态范围量化后 83KB,全整数量化后 68KB。推理耗时的差距更明显,float32 在 Cortex-M4 上单次推理约 31ms,动态范围量化约 15ms,全整数量化只要 7.8ms。而精度方面,全整数量化与 float32 的准确率差距通常在 1% 以内,这完全在可接受范围内。

2.2 剪枝的粒度怎么选

量化是“降低每个参数的存储和计算开销”,剪枝则是“干脆把不重要的连接删掉”。剪枝的核心是问一个问题:哪些权重对最终输出影响最小?常见做法是训练完后按权重的绝对值大小排序,把低于某个阈值的权重置零。但要注意,如果只是粗暴置零而不做微调,网络精度会瞬间崩掉。正确姿势是“剪枝-微调”迭代:剪掉一部分,再继续训练几个 epoch 让模型适应,然后再剪一部分。也有人用更结构化的方式,比如整个 channel 去掉,这在硬件上更友好,因为通道剪枝能真正减少计算量,而细粒度稀疏剪枝在通用 MCU 上其实很难提速,除非你有专门的稀疏推理库。

剪枝比例怎么定?我的经验是从 30% 开始,逐步往上加,每次加 10%,每轮都要用验证集评估精度变化。如果精度掉了超过 2%,就回退到上一档。在过一个传感器异常检测模型时,我把参数剪掉 50%,模型体积减半,精度反而小幅提升——这是因为模型本身有过拟合迹象,剪枝等于做了隐式正则化。

剪枝之后别忘了结合量化,二者是叠加关系不是互斥关系。先剪枝,后量化,已经验证是效率最高的组合。在第一个实战里,我把一个 DNN 从 18K 参数压到 8K,再量化为 int8,最终模型体积只有 8KB,在目标开发板上跑一次推理 1ms 出头,性能余量非常充足。

2.3 蒸馏时温度参数怎么调

知识蒸馏听起来很玄,其实思路非常简单:大模型(教师模型)在训练时学到的不只是正确答案,还包括“类别之间的相似关系”。比如一张图看起来既有猫的特征又有狗的特征,教师模型可能会给猫 0.6、狗 0.3,这个软分布对学生模型来说就是额外信息。学生模型不光学习正确答案,还要模仿教师的软输出,因此能学到更平滑的决策边界。

实现上,最常用的是软化后的 softmax,温度 T 控制分布平滑度。T 越高,输出越平缓,负标签的信息保留得越多。我踩过的一个坑是 T 设太高,学生模型陷入过度平滑,对困难样本的判断变得模糊。一般推荐 T 在 3 到 10 之间,具体数值要靠验证集调。学生模型的结构不用太复杂,它会比教师小很多,但效果却能接近大模型。

在这个流程里,温度 T 的调节和损失函数里两个 loss 的权重是强相关的。如果你把 soft target 的权重设得太大,学生模型可能过度拟合教师的输出,反而忽略了真实标签;太小,又失去了蒸馏的意义。我用过的经验公式是让两个 loss 处于同一数量级,然后在小范围内调比例。举个例子,如果蒸馏 loss 是 0.8,真实标签的 cross-entropy 是 1.2,那整体就先这样跑,看曲线再微调。不用过度纠结最优值,关键是别让某一项主导训练。

说到蒸馏,还有一个很多人忽略的坑:教师模型和学生模型的输入预处理必须完全一致。如果教师模型用的输入是 128 维 MFCC 特征,学生模型却用了 64 维,哪怕你强行训练,学生模型也学不到正确的映射。好的做法是直接把学生模型的输入层宽度设成与教师一致,只缩窄隐藏层。

3. 部署阶段的核心优化与踩坑

3.1 Tensor Arena 规划与内存生命周期

TFLite Micro 有个概念叫 Tensor Arena,它本质上是一块预先分配好的大 buffer,解释器在运行时会在这里为输入、输出、中间结果分配内存。听起来很简单,实际规划不好就是一场灾难。因为内存可能重叠复用,不同 tensor 的生命周期一旦判断错,数据就会意外被覆盖,推理结果就成了“玄学”。我遇到过的 case 是,模型连续跑几次后输出值逐渐漂移,最后定位到是中间激活值被其他 tensor 覆盖了。

解决这个问题的办法有两个层面。第一,在模型转换时,用 interpreter 提供的 arena 大小建议值,它会根据算子依赖计算出最小内存需求。第二,如果你的模型是多路的(比如同时处理两路传感器数据),一定要把两个输入 tensor 的生命周期错开,或者干脆用两个 arena。看到有人为了省内存强行共用 arena,结果一路数据被另一路覆盖,怎么调都不对。内存优化不该以正确性为代价,这是底线。

另一个常见问题是 arena 开太大。很多开发板 RAM 一共就 192KB,你给 interpreter 分配个 120KB 的 arena,系统其他部分就没法用了。这时候就要看模型的峰值内存到底是多少。TFLite Micro 的工具链里有个 experimental 的 API 可以输出内存规划详情,我建议在部署前先跑一遍,拿到每个 tensor 的 offset 和大小,再决定 arena 尺寸,而不是凭感觉乱塞。

3.2 CMSIS-NN 和硬件加速带来的提升

如果你用的是 Arm Cortex-M 系列芯片,CMSIS-NN 是绕不开的提速手段。它是一套基于 Cortex-M 内核的神经网络计算库,针对 int8 的卷积、全连接和池化做了深度优化。集合运算符会被展开成 DSP 指令,例如 SIMD 指令一次能处理多个 int8 数据,吞吐量直接翻倍。实际测试中,在 Cortex-M4 上同一个 int8 模型的推理耗时,从没用 CMSIS-NN 的 18.2ms 降到了 9.4ms,几乎是一倍差距。

使用 CMSIS-NN 不一定要直接在 C 代码里调用它。TFLite Micro 的某些内核实现会自动链接到 CMSIS-NN。关键是要把编译器优化等级打开,-O2 或 -O3 是基本操作。如果你发现部署后推理很慢,先检查编译优化等级,再检查是否真的链接到了 CMSIS-NN。我见过很多人代码逻辑完全没问题,就是构建配置里没把 CMSIS-NN 的路径加进去,结果一直走的是参考实现,性能自然上不去。

还要提醒一点,CMSIS-NN 的优化是依赖内存对齐和缓冲区 padding 的。如果你的 tensor 尺寸不是 4 的倍数,CMSIS-NN 的某些算子会自动进行 padding,这时候内存消耗会增加。实践中要把模型输入尺寸设计成 4 的倍数或 8 的倍数,别小看这个,它能避免很多莫名其妙的性能回退。

3.3 一个完整的部署检查清单

部署环节细碎,容易漏事。我给自己整理了一份检查清单,每次发板前都照着过一遍,节省了很多时间。

第一项,模型转换时选择正确的 opts 列表。用 TFLite Converter 时,把 target_spec 设为 TFLite_BUILTINS_INT8,把 inference_input_type 和 inference_output_type 都设为 int8。如果不做这一项,即使模型权重量化成了 int8,输入输出还可能是 float,板子上还得额外做一次类型转换。

第二项,确认算子兼容性。在 PC 端用 TFLite interpreter 加载 .tflite 做一次推理,把每一层的输出 dump 出来,跟嵌入式端的输出做对比。误差一般允许在 1e-2 这个量级;如果达到 0.1 以上,就要检查哪一层出了问题。这一项很多人会跳过,但我强烈建议不要省,因为算子实现在不同平台上有细微差别,早发现早修复。

第三项,检测 arena 大小和 tensor 生命周期。方法就是前面说的,用工具导出内存规划,对照板子的 RAM 使用情况。如果超过板子 RAM 的 70%,就要考虑剪枝或减模型宽度,别硬塞。

第四项,做连续运行压力测试。嵌入式设备不会只跑一次推理,它会反复跑。连续跑 1000 次,观察内存是否稳定、输出是否抖动。很多时候问题会在第 500 次之后才冒出来,如果只测一次,你根本发现不了。

第五项,测量端到端功耗。如果项目是电池供电,功耗曲线比推理耗时更重要。我习惯用低功耗模式配合定时唤醒,推理完立刻进入 sleep,这样平均电流能从几十毫安降到几毫安。测功耗时要注意把调试打印口全部禁用,否则串口外设本身就在耗电,测出来的数据不具备参考价值。

4. 三个典型应用场景的实战拆解

4.1 关键词识别:从数据集到模型结构

TinyML 上最常见的应用之一就是关键词唤醒,比如智能音箱上的“Hey Siri”。但真正做一款低功耗的离线关键词识别,远没有想象中那么简单。

先说数据。Google 的 Speech Commands 数据集是一个很好的起点,里面有几十类词条,每条一秒的 WAV 音频。不过实际产品不可能只用公开数据集,还要自己采集环境噪声、多人声线、不同距离的声音。我通常把采集到的音频做 16kHz 采样、单声道、16bit 编码,然后切成 1 秒窗口,提取 40 维 MFCC 特征。MFCC 特征的好处是能把音频压缩成更紧凑的表示,同时保留对识别最重要的频谱特征。

模型结构上,我用的是一个很轻量的 CNN:两层 Conv1D + 一层 Fully Connected,参数量控制在 30K 左右。部署到 Cortex-M4 上,单次推理大约 23ms,低于交互场景需要的 50ms 预算。这里有个关键点,特征提取通常放在 MCU 端实现,因为把原始音频传给云端浪费电不说,还有隐私问题。所以要用 C 语言手写 MFCC,或者在 MCU 上集成一个轻量的 DSP 库。

在 MCU 上做 MFCC 有个常见坑:FFT 的输入 buffer 大小必须是 2 的幂。我会把 1 秒音频按 25ms 窗长、10ms 步长切帧,每帧 400 个采样点,然后 padding 到 512 点做 FFT。这么做虽然多算了 112 个点,但能利用高效的 radix-2 FFT 实现,整体反而更快。

4.2 振动异常检测:在工业设备上的落地

工业设备预测性维护是 TinyML 的高价值场景。简单说,把加速度传感器贴到电机轴承附近,采集振动信号,模型实时判断设备是否异常。这个场景的好处是数据量大、重复性强,非常适合边缘推断。

我在做这个项目时遇到一个难题:正常数据非常多,异常数据却很稀有,直接训练分类器会严重偏向正常类。后来用了两个手段:一是用“重构误差”的思路,训练一个自编码器,正常数据的重构误差很小,异常数据的重构误差就会变大;二是用数据增强模拟异常状态,比如给正常信号添加不同频率的扰动,让模型见过更多“接近异常”的样本。

模型本身非常简单,一个三层的全连接自编码器,输入 128 维(1 秒振动信号的 FFT 特征),中间层压缩到 32 维,输出再还原为 128 维。判断异常时,计算输入和输出的 MSE,超过阈值就报警。这种模型非常小,量化和剪枝后只有几 KB,在 Renesas RA 系列板子上跑一次推理不到 1ms,完全能靠一颗纽扣电池运行几个月。

这里有个重要的实操细节:振动信号的采样频率和特征窗口长度要匹配设备的转动频率。比如一个 3000 RPM 的电机,主轴转一圈是 20ms,那么在 4kHz 采样率下,每转一圈就有 80 个采样点。取 1 秒窗口(4000 点)做 FFT,频率分辨率是 1Hz,能清晰看到频谱上的转频及其谐波。不要盲目用高采样率,因为数据量越大,MCU 的搬运和处理压力就越大。

4.3 图像分类:STM32 上的部署参考

图像分类在 MCU 上是资源消耗较大的场景,不像音频和振动那样轻松。选模型时要特别克制。我测试过 MobileNetV1、MobileNetV2 和 EfficientNet-Lite0,结论是:在 256KB RAM 级别的板子上,MobileNetV1 0.25 和 MobileNetV2 0.35 是可以跑的,EfficientNet-Lite0 反而因为特定算子支持问题在 TFLite Micro 上遇到兼容性麻烦。

以 MobileNetV2 0.35 为例,输入 96x96x3,参数量约 280K,全 int8 量化后模型文件约 110KB。这在 STM32F746(320KB RAM)上能跑,arena 大概要 160KB,推理一次需要 180ms 左右。这个速度在静态图像识别场景足够,但在实时视频流场景就偏慢。如果一定要做视频流,建议把输入降到 64x64,或者用 Depthwise Separable Convolution 手工搭更小的网络。

图像部署中还容易踩一个坑:图像预处理(resize、归一化)最好在 MCU 端用整数运算实现,不要在模型里放 Resize 算子。因为 Resize 算子在 TFLite Micro 上支持不统一,而且会占用额外内存。我在图像分类项目里,直接把摄像头输出的 RGB565 转成 RGB888,再做整数归一化(像素值减 128,映射到 int8 范围),省掉了一层层浮点转换,速度提升很明显。

5. 调试、性能分析与常见问题速查

5.1 调试环境怎么搭最快

嵌入式端的调试和 PC 端完全是两回事,你没法打断点单步看 tensor,很多时候只能靠日志输出。我调试 TFLite Micro 模型时,最常用的办法是在主循环里周期性打印推理结果和内存占用。TFLite Micro 的 interpreter 有内存计划信息,可以通过 API 获取峰值内存使用量。打印时把信息控制在合理频率,比如每 100 次推理打印一次,否则模拟口输出会成为瓶颈,影响对实时性的判断。

另外一个很实用的技巧是做一个“golden value”的对比测试。在 PC 端 TFLite interpreter 上加载同一个模型,输入固定数据,把各层输出保存成数组;然后在板子上跑同样的输入,把实时输出的每一层结果也保存下来,做逐层比对。哪一层偏差大,就往哪一层排查。这个方案比盲目猜问题要高效十倍。唯一要注意的是,MCU 端浮点运算和 PC 端浮点运算可能因为编译优化产生微小差异,所以比对时允许一定误差范围,不要因为 1e-6 级别的差异就大惊小怪。

5.2 性能 Profile 的三种手法

性能调优不能凭感觉,必须能量化每一段耗时。我有三种常用的手法。

第一种是裸用 GPIO 翻转。在推理开始前拉高一个 GPIO,推理结束后拉低,用示波器测高电平持续时间。这个方法非常土,但非常准确,不会受到日志输出的干扰。实测中,我拿着示波器量卷积层耗时,迅速定位到 TFLite Micro 自带的某算子实现没有走 CMSIS-NN 加速路径。

第二种是使用 DWT->CYCCNT 寄存器,这是 Cortex-M 内核的周期计数器。通过读取 CYCCNT 的值,能精确测量某段代码消耗了多少个 CPU 周期,再结合主频换算成时间。注意要在系统初始化时使能 DWT 跟踪单元,否则读出来是 0。

第三种是直接在代码里用循环计时。比如测 100 次推理的总耗时,除以 100 得到平均值。这个方法采样的粒度较粗,但足以判断整体性能是否达标。如果在调用 CMSIS-NN 和调用默认实现之间做对比,这种测试就能给你一个直观数据,让你确定要不要继续花时间优化。

三种手法各有用武之地:定位算子耗时首选 GPIO 翻转,“板级对比测试”用周期计数器,整体优化评估就用循环计时。

5.3 一张表查完常见故障

现象 可能原因 解决思路
加载模型时报错“Op not supported” 模型里有 TFLite Micro 不支持的算子 查看支持的算子列表,把不支持算子替换或用 MCU 端预处理实现
推理输出全是随机值 Tensor Arena 规划错误,数据覆盖 导出内存规划图,检查 tensor 生命周期,增大或调整 arena
推理结果漂移,跑几次后变差 中间缓存被其他模块覆盖 检查内存重叠,确保不同模块的 buffer 不混用
性能太低,推理耗时超过预算 没链接 CMSIS-NN / 编译优化等级太低 检查构建配置,开启 -O2/-O3,链接 CMSIS-NN
量化后精度掉得很厉害 没有用量化感知训练,或量化校准数据 改 QAT,用有代表性的样本做校准

这个速查表是我自己每次项目都会对照的。尤其是输出全随机值这个现象,几乎每个 TinyML 项目都会至少遇到一次,根源多数是内存复用问题。排查时优先看 arena 大小和 tensor 偏移量,别一上来就怀疑模型本身。

6. 关于多任务并发与版本管理的几点体会

6.1 多模型轮流运行的内存复用

实际产品不一定只跑一个模型。比如一个可穿戴设备,既要识别手势,也要检测跌倒。如果两个模型同时常驻内存,RAM 很容易爆掉。我的做法是将两个模型放在同一块物理内存上的不同分区,切换到哪个模型就加载哪个。因为任一时刻只会有一个模型在推理,所以内存压力跟单模型时差不多。

这里要想清楚的是模型切换的成本。如果模型文件放在外部 Flash,每次切换都要从 Flash 读取,可能占用几十毫秒甚至几百毫秒。对响应速度要求高的场景,可以用双 buffer 机制:一半内存放当前模型,另一半预加载下一个模型。这种方案会牺牲内存占用,但切换几乎无感。取舍的关键还是看业务对延迟的容忍度。

6.2 工程化版本管理与回归测试

TinyML 项目的模型迭代非常快,但很多团队还在靠“把模型文件重命名成最终版”这种粗糙方式管理。模型文件一旦和代码版本脱节,产品上线后想回退就非常痛苦。我现在的做法是把模型文件纳入 Git LFS 管理,每个模型附带一份 JSON 格式的说明文件,记录训练数据、量化方式、精度指标和部署目标。这样任何一次模型更新都有据可查。

回归测试也很重要。不要以为模型精度在验证集上达标就没问题,部署端的算子表现很可能不一样。我每个模型发布前都会跑一遍 golden value 测试,确保 PC 端和 MCU 端在多个输入样本下的输出一致,误差在阈值内。这个过程完全自动化,CI 集成里会跑,省去了大量手工检查时间。

6.3 最后一点个人心得

做 TinyML 项目这几年,我最大的感受是:难点从来不在某个单一环节,而在所有环节的衔接。训练时觉得量化是部署的事,部署时又发现训练时没考虑算子兼容性,这种互相甩锅的循环会让人精疲力尽。如果能把整个链路当成一个整体来看待,每一步都为下游留好接口,效率和成功率会高很多。

另外,别迷信“模型越新越好”。在 MCU 这种资源极度受限的地方,一个结构简单的旧模型,只要部署得当,往往比一个结构复杂的新模型好用得多。我见过太多人因为网络结构很先进而选了它,结果内存超了、算子不兼容、推理太慢,最后又退回简单的 DNN。先用最朴素的模型跑通全流程,再在瓶颈处做针对性优化,这才是 TinyML 项目的正路。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐