3、模型存储机制:掌握模型参数在Flash中的存储方式,理解权重、激活值、缓冲区等不同数据的内存分配
TinyML 系统最根本的约束并非算力,而是内存与存储的物理极限。一颗典型的 Cortex-M4 微控制器可能只有 256KB Flash 和 64KB SRAM,而一个中等规模的 MobileNetV2 模型权重就超过 3MB。理解模型参数在 Flash 中的排布方式、激活值与中间缓冲区在 RAM 中的生命周期,是进行模型压缩、算子融合和内存规划的前提。本章将拆解 TinyML 模型在典型 MCU 上的存储拓扑,厘清权重、激活、缓冲区三类数据的本质差异,并给出可操作的内存估算与优化方法。
上图展示了 TinyML 推理时 Flash 与 SRAM 的典型分工。Flash 侧存放模型结构描述、权重张量、常量查找表和算子代码,这些数据在推理过程中只读;SRAM 侧则承载输入缓冲区、中间激活张量、算子工作缓冲区和输出张量,这些数据在每次推理时被反复写入和覆盖。理解这张图的关键在于:Flash 决定模型能装多大,SRAM 决定模型能否跑起来。两者共同约束了 TinyML 模型的设计上限。
3.1 核心原理:三类数据的本质差异与生命周期
在 TinyML 系统中,数据按存储介质和生命周期可分为三个层次:持久只读数据(权重、偏置、查找表)、会话级数据(输入输出张量)、瞬时数据(算子中间结果)。这三类数据在 Flash 和 SRAM 中的分配策略截然不同。
权重参数是模型的核心,通常以量化后的 int8 或 uint8 格式存储。一个 3×3 卷积层,输入通道 32、输出通道 64,其权重数量为 3×3×32×64 = 18,432 个参数。若采用 int8 量化,则占用 18KB Flash;若采用 float32,则占用 72KB。权重在 Flash 中的排布必须遵循层序连续原则——即同一层的权重在物理地址上连续存放,以便 DMA 或 CPU 顺序读取,减少 Flash 随机访问带来的延迟惩罚。
激活值是推理过程中每层输出的特征图。以输入 96×96×3 的图像为例,经过第一层 16 通道卷积后,激活张量变为 96×96×16 = 147,456 个值,int8 格式下占用 144KB SRAM——这已经超过了许多 MCU 的 SRAM 总量。因此激活值必须采用内存复用策略:后一层的激活可以覆盖前一层不再需要的激活空间。TensorFlow Lite Micro 的 arena 机制正是基于这一原理,将整个推理过程所需的激活内存压缩到一个预分配的静态缓冲区中。
缓冲区则是最容易被忽视的部分。im2col 重排缓冲区、矩阵乘法的累加器(通常需要 int32 精度)、量化偏移表等,都属于算子工作缓冲区。这些缓冲区生命周期极短,仅在单个算子执行期间存在,但峰值可能相当可观。例如,一个 1×1 卷积若采用 im2col 优化,需要将输入特征图重排为 (输出像素数 × 输入通道数) 的矩阵,对于 32×32×16 的输入,im2col 缓冲区大小为 1024×16 = 16,384 个 int8 值,即 16KB——这比激活张量本身还大。
3.2 判断方法:如何精确估算模型的内存占用
在开始移植模型之前,必须进行内存预算。以下是经过验证的四步估算法:
- 统计权重总量:遍历模型每一层,累加所有可训练参数(权重 + 偏置)的字节数。量化后 int8 每个参数占 1 字节,偏置通常为 int32 占 4 字节。例如 MobileNetV2 的 1.0 版本量化后权重约 3.4MB,偏置约 13KB。
- 计算激活峰值:逐层计算输出特征图大小,找出最大值。公式为:
激活大小 = 输出高度 × 输出宽度 × 输出通道数 × 字节数。对于 96×96 输入、16 通道第一层,激活为 96×96×16×1 = 144KB。若采用深度可分离卷积,激活峰值通常出现在第一个全连接层之前。 - 估算算子工作区:检查每个算子是否需要额外缓冲区。卷积层若使用 im2col,需要
输入像素数 × 输入通道数 × 核大小²字节;全连接层需要批量大小 × 输入维度字节。取所有算子中最大的工作区需求。 - 叠加系统开销:加上输入输出缓冲区(通常各占 1-4KB)、运行时栈(建议 2-4KB)、以及 RTOS 或调度器开销(若使用)。最终 SRAM 需求 = 激活峰值 + 最大算子工作区 + 系统开销。
| 数据类别 | 存储介质 | 生命周期 | 典型大小 | 优化策略 |
|---|---|---|---|---|
| 模型结构描述 | Flash | 持久 | 0.5-2KB | 紧凑二进制格式,避免 JSON |
| 权重张量 | Flash | 持久 | 几十KB ~ 数MB | int8 量化、剪枝、聚类 |
| 常量查找表 | Flash | 持久 | 0.1-4KB | 映射到 Flash 直接寻址 |
| 输入缓冲区 | SRAM | 会话级 | 1-100KB | 与输出缓冲区重叠 |
| 中间激活 | SRAM | 层间复用 | 10-200KB | arena 内存池复用 |
| 算子工作区 | SRAM | 瞬时 | 1-50KB | 静态分配,避免堆碎片 |
实际工程中,建议使用脚本自动解析模型文件(如 TFLite 的 flatbuffer 格式),生成内存报告。TensorFlow Lite 的 interpreter.GetMemoryPlan() 接口可以直接输出各张量的内存分配情况,这是验证手工估算的最快途径。
3.3 案例拆解:关键词唤醒模型的存储优化实战
以一个典型的 2 秒关键词唤醒模型为例,输入为 40 维 MFCC 特征、时间步 49,模型结构为 2 层卷积 + 1 层 GRU + 全连接输出。初始模型使用 float32 存储,Flash 占用 1.2MB,SRAM 峰值 380KB,远超目标芯片(STM32L4 系列,512KB Flash / 128KB SRAM)的容量。
初始现象与约束:模型在 PC 上推理正常,但部署到 MCU 后编译失败,提示 Flash 溢出。进一步分析发现,GRU 层的权重占 480KB(float32),激活峰值出现在 GRU 层,为 49×128×4 字节 = 25KB(float32 下为 100KB)。
分析判断过程:首先将所有权重转为 int8 量化,Flash 占用降至 300KB,但 SRAM 峰值仍为 95KB(GRU 激活 + 工作区)。接着检查 GRU 的实现——标准 GRU 需要保存门控中间结果,若使用 TFLite 的 unidirectional_sequence_gru 算子,其工作区需要额外 49×128×3 个 int8 值(约 18KB)。此时 SRAM 总需求为 95 + 18 + 系统开销 10KB = 123KB,仍接近 128KB 上限。
实际操作步骤:
- 将 GRU 层替换为量化的
tf.lite.experimental.nn.GRU版本,该版本将门控计算融合,工作区减少 40%。 - 将输入 MFCC 缓冲区从 40×49×4 字节(float32)改为 40×49×1 字节(int8),输入缓冲区从 7.8KB 降至 2KB。
- 将输出层与输入层共享同一块 SRAM 区域(推理结束后输入不再需要),节省 2KB。
- 将激活函数查找表(sigmoid/tanh)映射到 Flash 地址,通过 const 指针直接访问,不占用 SRAM。
结果对比:优化后 Flash 占用 312KB(含代码段),SRAM 峰值 86KB,编译通过且推理延迟从 210ms 降至 145ms(得益于 int8 的 SIMD 加速)。代价是精度从 94.2% 降至 93.1%,在可接受范围内。
复盘经验:内存优化不能只盯着权重,GRU/LSTM 类循环网络的工作区往往比激活更占内存。遇到 SRAM 瓶颈时,优先检查循环层的工作区实现,其次才是激活复用策略。
3.4 常见误区与总结复盘
误区一:认为 Flash 容量足够就万事大吉。Flash 只解决存储问题,不解决访问带宽问题。当权重超过 512KB 时,Flash 读取可能成为性能瓶颈,因为 Cortex-M 内核的 Flash 等待周期会随频率升高而增加。此时需要将热权重(如第一层卷积)缓存到 SRAM,或使用 Flash 加速器(如 ST 的 ART 加速器)。
误区二:动态内存分配(malloc)用于激活张量。TinyML 系统严禁在推理路径中使用堆分配,因为堆碎片会导致不可预测的失败。所有激活和缓冲区必须静态分配或使用内存池。TensorFlow Lite Micro 的 arena 机制就是完全静态的——在初始化时一次性分配最大需求,推理过程中零 malloc。
误区三:忽略 Flash 的擦写寿命。虽然推理时 Flash 只读,但若模型需要 OTA 更新,频繁擦写会损耗 Flash。设计时应将模型存储区与代码区分离,并使用差分更新策略,减少擦写次数。
误区四:所有数据都放在默认段。嵌入式链接脚本中,应显式将权重数组放入 .rodata 段,将激活缓冲区放入 .bss 段(零初始化),将大数组按对齐要求放置。使用 __attribute__((section(".rodata.model_weights"))) 可以精确控制存放位置,便于后续做 Flash 映射或外部 Flash 扩展。