边缘 AI 推理

本文系统讲解 MCU 级边缘 AI 推理的工程落地,回答模型怎么压进几百 KB 内存、量化精度损失多少、TFLite Micro 运行时怎么用、CMSIS-NN 如何加速、NPU 怎么接入、模型如何分发等实战问题。覆盖 FlatBuffer 模型表示、张量内存规划、PTQ 与 QAT、端云协同,附权衡取舍与常见坑清单。

引言

把 AI 推理放到 MCU 上,听起来像「用指甲刀砍树」,但现实需求非常硬:语音唤醒要在本地完成(不然一直上传音频费流量又侵犯隐私)、振动异常检测要在本地判定(云端往返延迟太高)、电池设备不可能常连网络。这些场景的共同约束是「几十 KB 内存、几十 MHz 主频、毫瓦级功耗」,而模型往往来自一个用 GPU 训练的几 MB 网络。TinyML 要解决的就是这个落差。

落地的技术路线其实很清晰:把模型量化到 8 位整数(体积和算力都降一个数量级),用针对 MCU 优化的推理内核(CMSIS-NN 之类)跑,用一套运行时(TFLite Micro)管理算子与内存。难点不在概念,而在细节:量化到底掉多少精度、张量内存怎么算够、算子裁剪后会不会跑不起来、NPU 能不能用上。这些细节错了,表现为「模型在 PC 上好好的,烧到板子上要么编译不过、要么结果全错、要么内存溢出」。

本文聚焦 MCU 与低端 SoC 上的推理,与 边缘计算与边缘网关 里讲的 x86/ARM 网关推理(ONNX Runtime、OpenVINO)是不同层次的方案:那篇面向 4 到 64 核、跑 Linux 的设备,本文面向几百 KB 内存、跑裸机或 RTOS 的节点。硬件约束与低功耗模式见 ESP32 与 STM32 嵌入式开发 。

目录

  1. MCU 上做推理的约束
  2. 模型表示:TFLite FlatBuffer
  3. TFLite Micro 运行时与算子
  4. CMSIS-NN 与内核优化
  5. 量化:PTQ 与 QAT
  6. 剪枝与结构压缩
  7. 内存规划与张量复用
  8. NPU 与加速器接入
  9. 端云协同与模型分发
  10. 性能剖析与实测
  11. 典型管线:关键词唤醒与视觉检测
  12. 权衡取舍
  13. 常见坑清单
  14. 小结

1. MCU 上做推理的约束

MCU 推理受四个硬约束:内存、算力、精度、功耗。四者互相牵制,先看量级。

平台主频SRAMFlash典型可跑模型
Cortex-M0+48MHz32KB256KB极小 MLP、简单分类
Cortex-M4F80 到 168MHz128 到 320KB1MBDS-CNN 关键词唤醒
Cortex-M7480MHz512KB 到 1MB2MB小型 CNN、姿态估计
ESP32-S3240MHz512KB + PSRAM8MB人脸检测、图像分类
Cortex-A + NPU1GHz+GB 级eMMC多路视觉、目标检测

内存是最紧的:模型权重放 Flash(只读),激活值(张量)放 SRAM。一个 100KB 的 int8 模型,激活峰值可能就要 50 到 150KB SRAM,很容易撞上 320KB 的上限。算力上,Cortex-M4F 在 168MHz 下大约能提供 100 到 200 MIPS(int8 有 DSP 指令加成),跑一个 30 万 MAC 的 DS-CNN 大约 20 到 60ms,这个延迟对语音唤醒够用,对实时视频不够。

功耗是电池设备的命门。一次推理 20ms、峰值电流 20mA,一天跑 1000 次就是约 0.1mAh/天,配合深度睡眠(uA 级)总体可控。但如果推理频率高、模型大,平均电流会迅速吃掉电池预算——这也是为什么边缘 AI 的模型要「够用就好」,而不是越大越好。

2. 模型表示:TFLite FlatBuffer

TFLite 用 FlatBuffer 序列化模型,文件后缀 .tflite。理解它的结构是排查「模型跑不起来」的第一步。

.tflite 文件结构:
  Model
  ├── version
  ├── operator_codes[]      算子类型列表(CONV_2D、FULLY_CONNECTED、SOFTMAX...)
  ├── subgraphs[]           子图,通常只有一个
  │   ├── tensors[]         张量定义(形状、类型、量化参数、buffer 索引)
  │   ├── inputs[]          输入张量索引
  │   ├── outputs[]         输出张量索引
  │   └── operators[]       算子实例(输入张量、输出张量、算子码索引)
  ├── buffers[]             权重与偏置的原始字节
  └── description           元数据(作者、版本)

关键点:权重不是存在张量定义里,而是存在 buffers 数组里,张量通过 buffer 索引引用它。一个张量的 quantization 字段带 scale 和 zero_point,是 int8 推理正确性的核心——推理时 real = (int8 - zero_point) * scale,这个换算必须在训练与推理两侧一致。

flatc --json --raw-buffer schema.fbs -- model.tflite    # 反序列化成可读 JSON
python3 -c "import tensorflow as tf; i=tf.lite.Interpreter('model.tflite'); i.allocate_tensors(); print(i.get_input_details(), i.get_output_details())"   # 看输入输出

排查模型问题时的顺序:先用 flatc 看结构与量化参数,再用 Python 的 TFLite Interpreter 在 PC 上验证输入输出是否符合预期。如果 PC 上结果就对不上,问题在模型转换;如果 PC 上对、板子上错,问题在运行时的算子实现或内存。

3. TFLite Micro 运行时与算子

TFLite Micro(TFLM)是专为 MCU 设计的推理运行时,没有动态内存分配、没有操作系统依赖、没有异常。它的核心对象是解释器与张量竞技场(tensor arena)。

#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "model_data.h"   // 由 xxd 生成的模型字节数组

// 1. 静态分配张量竞技场(大小必须手工估算,不能动态扩展)
constexpr int kArenaSize = 60 * 1024;
static uint8_t tensor_arena[kArenaSize];

// 2. 只注册用到的算子,能省 Flash
static tflite::MicroMutableOpResolver<6> resolver;
void RegisterOps() {
    resolver.AddConv2D();
    resolver.AddDepthwiseConv2D();
    resolver.AddFullyConnected();
    resolver.AddSoftmax();
    resolver.AddReshape();
    resolver.AddQuantize();
}

static tflite::MicroInterpreter* interpreter = nullptr;

void Setup() {
    static const tflite::Model* model = tflite::GetModel(g_model);
    RegisterOps();
    static tflite::MicroInterpreter static_interpreter(
        model, resolver, tensor_arena, kArenaSize);
    interpreter = &static_interpreter;
    TfLiteStatus status = interpreter->AllocateTensors();
    if (status != kTfLiteOk) {
        // 竞技场太小,表现为 AllocateTensors 失败
        return;
    }
}

void RunInference(const int8_t* input) {
    int8_t* in = interpreter->input(0)->data.int8;
    memcpy(in, input, interpreter->input(0)->bytes);
    interpreter->Invoke();
    int8_t* out = interpreter->output(0)->data.int8;
    // out 是量化后的值,需用输出张量的 scale/zero_point 还原
}

两个设计要点。第一是算子解析器:MicroMutableOpResolver<N> 只注册用到的算子,N 是数量上限,用 AllOpsResolver 会把所有算子都链进来,Flash 占用翻好几倍。第二是张量竞技场:一块静态分配的缓冲区,所有中间张量在里面复用,大小要手工估算——太小则 AllocateTensors 失败,太大则浪费 RAM。

4. CMSIS-NN 与内核优化

CMSIS-NN 是 ARM 为 Cortex-M 优化的神经网络内核库,用 int8 和 DSP 指令把卷积、深度卷积、全连接等算子加速。TFLM 在 ARM 上会自动链接 CMSIS-NN 版本的算子,前提是正确配置构建。

CMSIS-NN 的加速来源:
 - int8 数据 + SIMD(如 SMLAD 一次算两个 16 位乘加)
 - 深度卷积的专用实现(MobileNet 类模型的主力算子)
 - 利用 Cortex-M4/M7 的 DSP 扩展与 M 系列的流水线
 - 避免浮点(M0/M3 无 FPU,M4F/M7 的 FPU 在 int8 场景也用不上)

典型加速比(相对纯 C 参考实现):
 - 深度卷积:5 到 10 倍
 - 全连接:2 到 4 倍
 - 整体模型:3 到 8 倍

启用 CMSIS-NN 的方式:在 TFLM 的构建里定义 CMSIS_NN 宏,或使用厂商的预集成版本(如 STM32Cube.AI、Espressif 的 ESP-DL 封装)。注意 CMSIS-NN 的算子有对齐与布局要求(如输入通道按 4 字节对齐),模型转换时如果布局不符,可能退化到参考实现,表现为「开了 CMSIS-NN 但没变快」。

其他优化手段:把热路径函数放进 RAM 执行(Flash 取指有等待周期)、用 DMA 搬运输入数据、把不常变的权重留在 Flash。对 M7 这类带缓存的核,数据布局要尽量连续以提高缓存命中。

一个容易被忽视的优化点是算子融合:卷积加批归一化加激活,在推理时可以融合成一个算子,减少中间张量的读写。TFLite 转换器会自动做一部分融合,但自定义算子或非标准结构可能漏掉。融合后不仅省内存(中间张量消失),也省时间(少一次遍历)。检查方法是看反序列化后的算子列表,如果看到紧邻的 CONV_2D 与 ADD 而没有被融合,说明转换时没触发优化。

5. 量化:PTQ 与 QAT

量化是把 fp32 权重与激活转成 int8,是 MCU 推理的关键一步:体积降 4 倍,算力需求降 4 倍以上,精度通常掉 1% 以内(取决于模型与任务)。

方式做法精度成本
动态范围量化只量化权重,激活运行时量化一般最低
全整型 PTQ权重与激活都量化,需代表性数据集好低
QAT训练时模拟量化误差最好高(要重训)

PTQ(Post-Training Quantization)只需要一个代表性数据集(几百到几千个样本)来统计激活范围,无需重训,是首选。转换命令:

import tensorflow as tf

def representative_dataset():
    for sample in calib_samples:            # 100 到 500 个代表性样本
        yield [sample.astype("float32")]

converter = tf.lite.TFLiteConverter.from_saved_model("saved_model")
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8      # 输入输出也用 int8
converter.inference_output_type = tf.int8
tflite_quant_model = converter.convert()
open("model_int8.tflite", "wb").write(tflite_quant_model)

几个关键细节:一是代表性数据集要覆盖真实分布,只用训练集首尾样本会让激活范围统计偏差,导致量化后精度骤降;二是per-channel 量化(每个输出通道独立的 scale)比 per-tensor 精度高,转换器默认对卷积权重做 per-channel;三是输入输出也量化成 int8 能省掉端上的量化/反量化开销,但需要应用侧配合换算。

QAT 在训练图里插入伪量化节点(fake quant),让模型在训练时就适应量化误差,精度最好但需要重训与调参。当 PTQ 掉点超过可接受范围(如分类任务掉 3% 以上)时才上 QAT。

6. 剪枝与结构压缩

量化之后如果模型还是太大,下一步是剪枝(去掉不重要的权重)与结构压缩。

方法原理收益代价
幅度剪枝去掉绝对值小的权重稀疏化,可压缩存储需稀疏推理支持才提速
结构化剪枝去掉整个通道或滤波器直接减小模型需重训恢复精度
权重聚类权重聚到少数几个值压缩存储收益有限
知识蒸馏小模型学大模型输出小模型精度提升训练复杂

对 MCU 而言,结构化剪枝最有价值:去掉整个通道后,模型的实际计算量真的下降,而幅度剪枝产生的稀疏矩阵在 MCU 上没有高效的稀疏推理内核,压缩了存储但不提速。结构化剪枝的流程是「训练 → 剪枝 → 微调」,剪枝后必须微调,否则精度掉得厉害。

另一条路是直接设计小模型:MobileNet 系列的深度可分离卷积、DS-CNN(语音唤醒)、以及针对 MCU 设计的 MCUNet 系列。很多时候重新设计一个小而高效的网络,比压缩一个大网络效果更好——因为压缩后的网络结构往往不是最优的。

7. 内存规划与张量复用

张量竞技场的大小估算是 MCU 推理最容易翻车的地方。TFLM 的内存规划器(memory planner)会在 AllocateTensors 时给每个中间张量分配偏移,并复用生命周期不重叠的张量。

竞技场大小估算方法:
 1. 先用一个「足够大」的竞技场(如 1MB)跑通,读 arena_used_bytes()
 2. 把竞技场设为略大于该值(如 1.2 倍)验证
 3. 逐步缩小直到 AllocateTensors 失败,取失败前的值加余量

经验法则:激活峰值 ≈ 最大中间张量 × 2 到 3(复用不完美时)
代码里读取:
  size_t used = interpreter->arena_used_bytes();

优化竞技场的三种手段:一是改变算子顺序,让大张量的生命周期尽量不重叠(转换时可用工具重排);二是分段推理,把大模型切成几段分别推理,中间结果落 Flash(慢但省 RAM);三是降低输入分辨率,这是最有效的杠杆——输入从 224x224 降到 96x96,激活内存降 5 倍以上。

要区分「权重内存」与「激活内存」:权重在 Flash 里,不影响 SRAM;激活在 SRAM 里,是竞技场的主要占用。很多人以为「模型 200KB 就要 200KB RAM」,其实权重 200KB 的模型激活可能只有 50KB,也可能有 300KB,取决于网络结构。所以竞技场必须实测,不能拍脑袋。

8. NPU 与加速器接入

当 MCU 算力不够时,可以外挂或选用带 NPU 的芯片。

加速器平台算力接入方式
Ethos-U55/U65Cortex-M55/M850.5 到 4 TOPSVela 编译器 + TFLM delegate
ESP-DL / ESP-NNESP32-S3向量指令加速厂商封装
MAX78000 CNNMAX78000硬件 CNN 引擎厂商工具链
Coral Edge TPU带 USB/PCIe 的主机4 TOPSTFLite delegate

Ethos-U 是最主流的 MCU NPU 方案:它由 ARM 提供,配合 Cortex-M55 使用,模型通过 Vela 编译器编译成 NPU 指令,运行时由 TFLM 的 delegate 把兼容算子卸载到 NPU。Vela 编译时会报告「哪些算子能在 NPU 上跑、哪些回退到 CPU」,回退的算子会成为性能瓶颈——所以设计模型时要尽量用 NPU 支持的算子集。

接入 NPU 的三个注意点:一是算子兼容性,不是所有算子都能卸载,设计网络时先查支持的算子列表;二是数据布局,NPU 常要求特定的张量布局(如 NHWC),转换时注意;三是回退路径,NPU 不可用时要有 CPU 兜底,避免整个推理失败。ESP32-S3 的方案更简单:用 ESP-DL 提供的加速算子,无需额外编译器,但只能用厂商支持的模型类型。

9. 端云协同与模型分发

边缘模型不是一次烧录就完事,它需要随数据分布变化而更新。模型分发本质是一次特殊形式的 OTA。

端云协同闭环:
 1. 端侧推理,把「低置信度样本」或「特征摘要」上传(不是原始数据)
 2. 云端聚合、标注、重新训练
 3. 新模型量化、验证、生成版本
 4. 通过 OTA 通道分发到设备(可灰度)
 5. 设备加载新模型,上报推理指标
 6. 云端对比新旧模型的表现,决定是否全量

模型分发的工程要求与固件 OTA 类似:版本管理、签名校验、灰度、回滚。区别在于模型可以「热加载」——存在独立的模型分区,运行时切换到新模型,不必重启整机。模型文件要带哈希与签名,加载前校验,防止被替换成恶意模型。

模型版本要与固件版本解耦:一个固件可能支持多个模型版本,模型更新不必等固件更新。但要处理「固件不支持新模型算子」的情况——新模型引入了固件里没有的算子,加载会失败。所以模型分发前要检查目标设备的固件版本与算子支持,机制与 OTA 固件升级与差分更新 里讲的兼容性判断一致。

端侧还可以做「数据选择」:不是所有数据都值得上传,优先上传「模型不确定的样本」(置信度接近决策边界的),这些样本对再训练最有价值。这样能用最少的流量获得最大的模型提升。

10. 性能剖析与实测

推理延迟与内存必须实测,不能估算。TFLM 自带微剖析器(Micro Profiler),能输出每个算子的耗时。

#include "tensorflow/lite/micro/micro_profiler.h"

static tflite::MicroProfiler profiler;
interpreter->SetProfiler(&profiler);
interpreter->Invoke();
profiler.Log();   // 打印每个算子的耗时(微秒)

实测的关键指标:

指标含义目标
单次推理延迟从输入到输出的时间按业务定(唤醒 <100ms)
峰值内存arena_used_bytes留 20% 余量
单次推理能耗电流积分按电池预算定
端到端延迟含采集、预处理通常比推理大得多

常见认知误区:只关注推理延迟,忽略预处理(MFCC 特征提取、图像缩放)的耗时——在很多管线里预处理比推理还慢。例如语音唤醒的 MFCC 计算可能占端到端延迟的一半,图像缩放的耗时也可能超过推理本身。剖析要覆盖整条管线,而不只是 Invoke()。

能耗测量要用库仑计或高精度电流表,测一个完整推理周期(唤醒、采集、预处理、推理、上报、睡眠)的平均电流,而不是单看推理峰值。

11. 典型管线:关键词唤醒与视觉检测

两个最典型的 MCU 推理场景,管线结构值得参考。

关键词唤醒(KWS):麦克风以 16kHz 采样,每 20 到 30ms 一帧,提取 MFCC(13 维、40ms 窗、20ms 步长),喂给 DS-CNN 或 TC-ResNet 模型,输出「是否包含唤醒词」的概率。模型通常 20 到 100KB,推理 10 到 40ms,配合 VAD(语音活动检测)只在有声音时推理,平均功耗能压到毫安级以下。难点在误唤醒率:太灵敏会频繁误触发,太迟钝会漏唤醒,需要在真实噪声环境下调阈值。

视觉检测:ESP32-S3 加摄像头,QVGA(320x240)图像缩放后喂给轻量检测模型(如 MobileNet-SSD 的裁剪版)。瓶颈常在图像搬运与缩放,而非推理本身。为了省内存,可以用灰度图、降低分辨率、或只在 PIR 触发时才开启摄像头。带 NPU 的芯片(如 K230、RV1106)能跑更复杂的检测,但仍受内存带宽限制。

两条管线的共同经验:预处理与后处理往往比推理更耗资源,模型压缩省下的算力可能被图像缩放吃掉;触发机制(VAD、PIR)比优化推理更能省电,因为大部分时间设备在睡觉。

12. 权衡取舍

决策点方案 A方案 B判据
量化方式PTQQAT掉点在可接受范围用 PTQ,否则 QAT
模型压缩剪枝重新设计小模型结构合适时剪枝,否则重新设计
推理位置端侧云端延迟与隐私要求高的端侧,算力需求高的云端
算子注册MicroMutableOpResolverAllOpsResolver生产用前者省 Flash,原型可临时用后者
加速CMSIS-NNNPU无 NPU 用 CMSIS-NN,有 NPU 且算子兼容则卸载
模型更新随固件独立模型分区模型迭代频繁用独立分区热加载

一条原则:先让模型「跑得起来且够准」,再优化「跑得快」。过早优化内存和延迟会陷入反复调参,而模型结构一旦定了,优化空间其实有限。

13. 常见坑清单

  1. 现象:AllocateTensors 返回失败。原因:张量竞技场太小。规避:先给大竞技场跑通,读 arena_used_bytes 再收缩并留余量。
  2. 现象:板子上推理结果全错。原因:输入量化换算错误,或 scale/zero_point 用错。规避:按 real=(int8-zero_point)*scale 换算,与训练侧核对。
  3. 现象:模型转换后精度骤降。原因:代表性数据集不具代表性。规避:用覆盖真实分布的几百个样本,检查量化前后在验证集上的差异。
  4. 现象:Flash 占用翻倍。原因:误用 AllOpsResolver 链入全部算子。规避:用 MicroMutableOpResolver 只注册用到的算子。
  5. 现象:开了 CMSIS-NN 却没提速。原因:张量布局不满足对齐要求,退化为参考实现。规避:检查通道对齐,确认构建宏正确启用。
  6. 现象:端到端延迟远超推理延迟。原因:预处理(MFCC、图像缩放)耗时被忽略。规避:剖析整条管线,优化预处理与内存搬运。
  7. 现象:NPU 只跑了一部分算子。原因:部分算子不支持,回退到 CPU。规避:查 Vela 报告的算子覆盖,调整模型结构。
  8. 现象:模型更新后设备加载失败。原因:新模型算子固件不支持。规避:分发前校验固件版本与算子兼容性。
  9. 现象:误唤醒率高。原因:阈值不当或训练数据噪声不足。规避:在真实噪声环境采集数据并调阈值。
  10. 现象:电池续航远低于预期。原因:推理频率高、常开麦克风或摄像头。规避:用 VAD/PIR 触发,降低推理频率。

14. 小结

MCU 级边缘 AI 的工程路径可以概括为「量化优先、内存为王、够用就好」。量化把模型从 fp32 压到 int8,是能在 MCU 上跑起来的前提;张量竞技场的大小决定了模型能不能加载,必须实测;模型的复杂度要匹配业务需求,而不是追求 PC 上的最高精度。

落地的优先级:先把量化流程与精度验证做扎实(这决定了可行性),再把内存规划与算子裁剪做对(这决定了能否烧进芯片),然后优化预处理与触发机制(这决定了功耗),最后才是 NPU 接入与端云协同(这决定了长期迭代效率)。

下一步建议:x86 与 ARM 网关上的推理方案(ONNX Runtime、OpenVINO)属于边缘计算与边缘网关一文的范围;MCU 的内存、功耗与外设约束在 ESP32 与 STM32 嵌入式开发一文中有系统展开;模型作为制品分发的通道设计见 OTA 固件升级一文。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「物联网」更多文章

  1. 工业物联网协议与网关
  2. 设备配网与批量运维
  3. 嵌入式 Linux 与 Yocto 构建