3. scratch buffer 设计原理:为什么需要 scratch buffer、生命周期、与 tensor arena 的关系

TFLite Micro 的推理引擎在嵌入式设备上运行时,面临的最核心约束不是算力,而是内存。一个典型的 Cortex-M4 设备可能只有 192KB RAM,而一个 MobileNetV1 模型仅权重就需要约 4.2MB。显然,模型权重和中间张量不可能全部常驻内存。TFLite Micro 通过 tensor arena(张量竞技场)统一管理中间张量,而 scratch buffer(临时缓冲区)则是 arena 内部一种特殊的、由算子主动申请和释放的临时内存块。理解 scratch buffer 的设计原理,是排查“申请失败”类问题的前提——只有知道它为什么存在、何时存活、与 arena 如何交互,才能准确判断失败发生在哪一层。

TFLite Micro 内存架构:tensor arena 与 scratch buffer 关系 模型输入张量 Input Tensor tensor arena(连续内存块,由 MicroAllocator 管理) 静态张量区(生命周期=整个推理) 权重、偏置、常量张量 模型输入/输出张量 持久化中间张量 分配时机:模型加载时 释放时机:模型卸载时 临时张量区(可复用) 算子中间计算结果 激活值、池化输出等 分配时机:算子执行前 释放时机:算子执行后 scratch buffer 区(arena 内动态子区域) Scratch #1 算子A:卷积 im2col Scratch #2 算子B:深度卷积 Scratch #3 算子C:全连接 生命周期:算子 invoke 期间 可重叠复用 由 RequestScratch 声明 分配顺序:先静态后临时,scratch 在临时区尾部动态分配

3.1 为什么需要 scratch buffer:从算子执行的内存需求说起

在 TFLite Micro 的推理流程中,每个算子(Operator)的 Invoke() 函数负责完成一次张量到张量的计算。但很多算子并非“输入直接写到输出”那么简单。以卷积为例,为了提升计算效率,通常需要将输入数据按卷积核窗口重新排列成矩阵(im2col),这个中间矩阵的大小可能远超输入张量本身。又比如某些量化算子需要临时的浮点累加器,或者某些融合算子需要暂存中间结果。

这些临时内存有两个显著特点:大小在编译期无法确定(取决于输入尺寸、算子参数),生命周期极短(仅在当前算子执行期间有效)。如果为每个算子都预留一块独立内存,arena 会迅速膨胀;如果直接复用输出张量内存,又可能破坏尚未读取的输入数据。scratch buffer 正是为解决这一矛盾而设计的——它允许算子在执行前向 MicroAllocator 申请一块临时内存,执行完毕后立即释放,供下一个算子复用。

核心价值:scratch buffer 将“算子内部实现细节”与“全局内存规划”解耦。算子作者只需声明“我需要 N 字节临时空间”,而无需关心这块内存在 arena 中的具体位置。这种设计让 TFLite Micro 能够在极小的 RAM 上运行原本需要数 MB 内存的模型。

3.2 scratch buffer 的生命周期:从 Request 到 Release

scratch buffer 的生命周期严格绑定在单个算子的执行周期内,具体分为四个阶段:

  1. 声明阶段(Prepare):在算子的 Prepare() 函数中,通过 context->RequestScratchBufferInArena(&context, bytes, &buffer_index) 请求一块大小为 bytes 的缓冲区。此时只是“登记”需求,并未真正分配内存。
  2. 分配阶段(Allocate):所有算子的 Prepare() 执行完毕后,TFLite Micro 的 MicroAllocator 统一计算所有 scratch 请求的总大小,在 arena 的临时区尾部一次性分配。
  3. 使用阶段(Invoke):算子执行时,通过 context->GetScratchBuffer(&context, buffer_index) 获取实际内存指针,进行读写操作。
  4. 释放阶段(Reset):算子执行完毕后,scratch buffer 并不会立即释放,而是由 MicroAllocator 在下一轮推理开始前统一重置。这意味着同一块 scratch 内存可以被不同算子在不同推理轮次中复用。
阶段 调用时机 关键 API 内存状态
声明 Prepare() RequestScratchBufferInArena 仅记录大小,不分配
分配 所有 Prepare 完成后 MicroAllocator 内部处理 在临时区尾部预留空间
使用 Invoke() GetScratchBuffer 可读写
重置 推理轮次开始前 ResetTempAllocations 标记为可复用
关键陷阱:scratch buffer 的分配是“惰性”的——RequestScratchBufferInArena 只是登记需求,真正的内存分配发生在所有 Prepare() 完成后。如果在 Prepare() 阶段之后才调用 RequestScratchBufferInArena,会导致分配失败或内存重叠。

3.3 scratch buffer 与 tensor arena 的关系:子区域与复用机制

从内存布局上看,tensor arena 是一个连续的大数组,由 MicroAllocator 统一管理。arena 内部划分为两个逻辑区域:静态区(存放权重、常量张量、模型输入输出)和临时区(存放中间激活值)。scratch buffer 位于临时区的尾部,与临时张量共享同一块内存池。

这种设计带来一个关键特性:scratch buffer 与临时张量可以相互覆盖。例如,当算子 A 执行完毕,其输出张量(临时区中的一块内存)不再需要时,算子 B 的 scratch buffer 可以复用这块内存。TFLite Micro 通过 MicroAllocator 的分配记录来跟踪哪些内存块是“活跃”的,哪些是“可回收”的。

理解这一关系对排查“申请失败”至关重要:

  • arena 总大小固定:由 MicroMutableOpResolver 和 tensor_arena_size 参数决定,通常在模型初始化时传入。
  • 静态区优先分配:模型加载时,所有持久化张量先占用 arena 头部空间。
  • 临时区动态分配:推理过程中,临时张量和 scratch buffer 在剩余空间中“挤占”内存。
  • 失败原因:当剩余空间不足以满足某个算子的 scratch 请求时,MicroAllocator 返回 kTfLiteError,导致推理中断。
经验法则:如果模型在加载时成功,但在推理中途报 scratch 申请失败,通常不是 arena 总大小不够,而是峰值内存计算错误——即某个算子的临时张量与 scratch buffer 同时达到最大占用。此时应使用 RecordingMicroAllocator 获取实际峰值内存,再调整 arena 大小。

3.4 实战场景:一个典型的 scratch 申请失败案例

假设我们在一款基于 STM32F746(320KB RAM)的设备上部署一个语音唤醒模型,arena 设置为 200KB。模型包含 5 个深度可分离卷积层和一个全连接层。初始现象是:模型加载成功,前 3 层推理正常,执行到第 4 层时返回 kTfLiteError,日志显示 Failed to allocate scratch buffer。

分析过程:

  1. 使用 RecordingMicroAllocator 记录内存分配,发现第 4 层卷积的 im2col 缓冲区需要 48KB,而当前 arena 剩余空间仅 32KB。
  2. 进一步检查发现,第 3 层的输出张量(16KB)虽然在第 4 层执行前已不再需要,但 TFLite Micro 的临时张量复用策略是“按需分配”,不会主动回收已释放的临时张量。
  3. 真正的问题在于:第 4 层的输入张量(第 3 层输出)与 scratch buffer 同时存在,而 arena 的临时区被前几层的中间结果碎片化。

解决方案:

  • 将 arena 从 200KB 增加到 240KB,为 scratch 预留足够空间。
  • 优化模型结构:将第 4 层的 dilation_rate 从 2 改为 1,减少 im2col 缓冲区大小(从 48KB 降至 24KB)。
  • 在代码中显式调用 context->RequestScratchBufferInArena 时,将大缓冲区拆分为多个小缓冲区,提高内存碎片利用率。

结果对比:调整后,峰值内存从 198KB 降至 176KB,推理成功,且推理速度提升 12%(因为 im2col 数据量减少)。

复盘教训:scratch 申请失败不一定是“内存不够”,更常见的是“内存碎片化”或“生命周期重叠”。排查时应先确认:① 哪个算子申请失败?② 申请多大?③ 当时 arena 剩余多少?④ 是否有其他临时张量占用了空间?只有回答这四个问题,才能精准定位根因。

3.5 常见误区与设计建议

在实际项目中,开发者对 scratch buffer 的理解常存在以下误区:

误区 实际情况 正确做法
认为 scratch 越大越好 过大的 scratch 会挤压临时张量空间,导致其他算子失败 按需申请,尽量复用已有缓冲区
在 Invoke 中动态申请 Invoke 阶段不允许申请新 scratch 所有申请必须在 Prepare 阶段完成
忽略对齐要求 某些硬件(如 SIMD)要求 16 字节对齐 使用 AlignTo 宏确保对齐
认为 scratch 是全局共享的 每个算子只能访问自己申请的 buffer_index 通过 context 传递 buffer_index,不可跨算子访问

设计建议:在编写自定义算子时,优先考虑是否可以使用已有的临时张量替代 scratch buffer。例如,如果算子的中间结果可以写入输出张量(在确认不会破坏输入的前提下),就无需额外申请 scratch。此外,对于多个算子共用的临时缓冲区,可以在模型层面合并为一个大的 scratch 请求,减少分配次数和碎片。

总结:scratch buffer 是 TFLite Micro 在极端内存约束下实现灵活算子实现的关键机制。它的生命周期严格限定在算子执行周期内,与 tensor arena 的临时区共享内存池。理解其设计原理,不仅能帮助排查“申请失败”问题,更能指导我们编写内存高效的算子实现,从而在资源受限的 MCU 上部署更复杂的模型。