模型推理依赖解释器,本文主要介绍解释器的创建细节和内部实现。
之前的文章完成了:模型加载(拿到了 FlatBuffer 数据指针) 和算子注册(准备好了 7 种算子的计算函数,16 个实例共用) 。但这两样东西各自独立,需要一个”指挥官”把它们串联起来。
这个指挥官就是 MicroInterpreter (解释器)。它做两件事:
规划并分配内存 :在初始化阶段一次性完成所有内存规划——遍历每个算子,收集其内存需求(tensor 数据缓冲区、scratch buffer、op_data),然后由内存规划器根据各 tensor 的生命周期(何时写入、何时读完)让不重叠的 tensor 共享同一块内存,算出最小 Arena 需求。初始化完成后,推理阶段零动态分配
调度算子 :按模型定义的顺序,依次调用每个算子的 init → prepare → invoke
flowchart LR
subgraph without["没有解释器"]
direction LR
M1["Model (指针)"]
R1["Resolver (7种算子)"]
M1 x-.-x R1
end
subgraph with["有解释器"]
direction TB
M2["Model"] --> I["Interpreter 规划并分配内存 调度算子"]
I --> R2["Resolver (7种算子)"]
end
创建解释器的典型代码:
1 2 static tflite::MicroInterpreter static_interpreter ( model, resolver, tensor_arena, TENSOR_ARENA_SIZE) ;
传入 4 个参数:Model 指针、OpResolver 引用、Arena 缓冲区地址和大小。
1.2 构造函数的整体流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 MicroInterpreter::MicroInterpreter ( const Model* model, const MicroOpResolver& op_resolver, uint8_t * tensor_arena, size_t tensor_arena_size, ...) : model_ (model), op_resolver_ (op_resolver), allocator_ (*MicroAllocator::Create ( tensor_arena, tensor_arena_size, ...)), graph_ (&context_, model, &allocator_, ...), tensors_allocated_ (false ), initialization_status_ (kTfLiteError), input_tensors_ (nullptr ), output_tensors_ (nullptr ), micro_context_ (&allocator_, model_, &graph_) { Init (profiler); }
7 个步骤可以分成四组:
1 2 3 4 ①② 保存输入参数 → 拿到 Model 和 Resolver 的指针/引用 ③ 创建内存管理器 → MicroAllocator 在 Arena 里自举 ④ 创建图执行引擎 → MicroInterpreterGraph 保存子图指针 ⑤⑥⑦ 初始化框架 → 状态标志、MicroContext、C 回调表
1.3 步骤③——Arena 内存管理体系的创建 构造函数中最复杂的步骤。MicroAllocator::Create() 被调用时,会在 Arena 里依次创建三层管理对象 :
1 2 3 4 MicroAllocator::Create(tensor_arena, arena_size) ├─ SingleArenaBufferAllocator::Create() // 第一层:原始内存指针管理 ├─ CreateMemoryPlanner(kGreedy) // 第二层:内存布局优化器 └─ new MicroAllocator(...) // 第三层:协调员
为什么要三层?因为 TFLM 需要解决三个独立的问题:
底层指针运算 :从 Arena 的头尾双向分配内存(SingleArenaBufferAllocator)
内存复用规划 :让生命周期不重叠的 tensor 共享同一块内存(GreedyMemoryPlanner)
协调调度 :把 FlatBuffer 中的模型信息翻译成内存分配请求,调用规划器并提交方案(MicroAllocator)
三者都在 Arena 尾部自举分配——这是嵌入式系统零 malloc 的核心约束。
SingleArenaBufferAllocator——五个指针管好一块内存 SingleArenaBufferAllocator 是最底层的管理器。它不关心”谁用什么”,只关心”这块内存还能往哪里放”。核心是五个指针 :
1 2 3 4 5 buffer_head_ Arena 的起始地址(创建后不变) buffer_tail_ Arena 的结束地址(创建后不变) head_ head 区的当前位置(向高地址方向增长,存放 tensor 数据) tail_ tail 区的当前位置(向低地址方向增长,存放持久对象) temp_ temp 区的当前位置(在 head 之上,临时分配)
Arena 的三区布局(5 个指针全部标出):
1 2 3 4 5 6 7 8 9 10 11 12 低地址 高地址 buffer_head_ buffer_tail_ │ │ ▼ ▼ ┌────────────────────────┬────────┬──────┬────────────────────────┐ │ HEAD 区 │ TEMP区 │ 空闲 │ TAIL 区 │ │ tensor overlay │ 临时 │ │ 管理对象·持久数据 │ │ (规划后复用) │ 借用 │ │ (op_data等) │ └────────────────────────┴────────┴──────┴────────────────────────┘ ↑ ↑ ↑ head_ temp_ tail_(向左增长) (向右增长) (head_之上波动)
三种分配操作 :
操作
方向
实现要点
AllocatePersistentBuffer
tail 向低地址
AlignPointerDown(tail_ - size, alignment) → tail_ = aligned_result
AllocateTemp
temp 向高地址
AlignPointerUp(temp_, alignment) → temp_ = aligned_result + size
ResizeBuffer
head 向高地址
head_ = aligned_result + size,同时 temp_ = head_
ResizeBuffer 的具体意义 :所有算子的内存规划完成后,GreedyMemoryPlanner 算出了 head 区所需的最大字节数 max_head_buffer_usage_。ResizeBuffer 被调用一次,把 head_ 推到最终位置。之后 head_ 不再移动,推理阶段直接使用这块预分配好的空间。核心实现只有两行:
1 2 head_ = aligned_result + size; temp_ = head_;
AllocatePersistentBuffer 为什么只检查 head_ 而不检查 temp_?
该函数的碰撞检测是 aligned_result < head_,但 temp_ ≥ head_,理论上 tail_ 向下增长时可能先碰到 temp 区。TFLM 通过调用顺序规避了这个问题:
1 2 3 4 5 6 7 Init 阶段:所有 persistent buffer(op_data、LUT 表)集中分配 此时 temp_ == head_,不存在 temp 区 tail_ 从 buffer_tail_ 向下增长 Prepare 阶段:每个算子执行前分配 temp tensor,执行后归还 persistent buffer 在 Init 阶段已分配完毕 tail_ 几乎不再移动
如果代码层面要严格保证,应检查 aligned_result < temp_(当 temp_ > head_ 时)。TFLM 选择用调用顺序保证正确性,而非在每次分配时做更严格的检查。
关键约束:
head_ 只会增长,不会缩小 ——ResizeBuffer 只支持扩大
**temp_ ≥ head_**——临时分配在 head 之上;ResetTempAllocations() 时 temp_ 回到 head_
head 和 tail 从不交叉 ——如果 aligned_result < head_,说明 Arena 不够了
head_ 与 temp_ 的本质区别 :
head_ 是已提交的边界(tensor overlay 空间),temp_ 是可以回收的游标(临时借用空间)。
head_ 管的区域
temp_ 管的区域
内容
tensor overlay(规划好的 tensor + scratch buffer 数据)
prepare 阶段的临时 TfLiteTensor 结构体
生命周期
整个推理过程都在
单个算子的 prepare() 期间
谁分配
ResizeBuffer — 一次性确定大小
AllocateTemp — 按需借,用完还
谁释放
不释放
ResetTempAllocations() 一把收回
如果没有这个分离设计,16 个算子实例的 prepare 阶段需要 16 份临时空间。有了 head_/temp_ 分离,所有 prepare 共享同一块空间轮流使用。
temp 区分配的具体对象——临时 TfLiteTensor 结构体
temp 区分配的是 TfLiteTensor 结构体(约 80-100 字节),它是 FlatBuffer 中 tensor 信息的”C 语言翻译版”。kernel 代码(如 ConvPrepare)通过它读取量化参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 TfLiteStatus ConvPrepare (TfLiteContext* context, TfLiteNode* node) { MicroContext* micro_context = GetMicroContext (context); TfLiteTensor* input = micro_context->AllocateTempInputTensor (node, 0 ); TfLiteTensor* filter = micro_context->AllocateTempInputTensor (node, 1 ); TfLiteTensor* output = micro_context->AllocateTempOutputTensor (node, 0 ); micro_context->DeallocateTempTfLiteTensor (output); micro_context->DeallocateTempTfLiteTensor (input); }
TfLiteTensor 结构体的内容:
1 2 3 4 5 6 7 8 TfLiteTensor { ← 整个结构体从 Arena temp 区分配 dims → 指向 FlatBuffer 中的维度信息 type = kTfLiteInt8 params.scale = 0.035 ← 从 FlatBuffer 的 QuantizationParameters 解析 params.zero_point = -128 data.data → 指向 Arena HEAD 区的实际 tensor 数据 ... }
为什么要翻译?因为 FlatBuffer 中的 tensor 信息是压缩存储的(offset 链表),kernel 代码不能直接读取。需要解压为连续的 C 结构体,kernel 才能直接访问 input->params.scale。
为什么是临时的?这个结构体只在 Prepare 期间用一下(读 scale、zero_point、dims 来计算量化参数),用完就归还。推理阶段用的是更轻量的 TfLiteEvalTensor(持久分配在 tail 区),不需要这个完整的 TfLiteTensor。
GreedyMemoryPlanner——让不重叠的 buffer 共享内存 如果 head 区为每个 tensor 单独分配空间,17 个需要 Arena 分配的 tensor(输入 + 中间激活 + 输出)需要 17 份大小。但 TFLM 的推理是顺序执行 的:第 1 个算子用完的中间 tensor,第 5 个算子可以覆盖。
GreedyMemoryPlanner 解决的问题:给定 N 个 buffer 的大小和生命周期,计算每个 buffer 在 head 区的偏移量,使总内存最小 。
输入 :每个 buffer 的四个属性
1 2 3 4 5 6 struct BufferRequirements { int size; int first_time_used; int last_time_used; int offline_offset; };
输出 :buffer_offsets_[i] = 第 i 个 buffer 在 head 区的起始偏移。
贪心算法 :
算法维护一个 candidate_offset(候选位置),从 0 开始。按偏移顺序逐个检查已放置的活跃 buffer,遇到够大的间隙就停下。
1 2 3 4 5 6 7 8 9 10 11 1. 按大小降序排列所有 buffer(大块先放,小块填缝) 2. 第一个 buffer 放在偏移 0,记入偏移有序链表 3. 对每个后续 buffer B(按大小降序): candidate_offset = 0 按偏移顺序遍历已放置的 buffer: 跳过与 B 时间不重叠的(它们不冲突,不产生约束) 对每个与 B 时间重叠的已放置 buffer: gap = 该 buffer 的起始偏移 - candidate_offset 如果 gap ≥ B.size → 放在 candidate_offset(找到了间隙) 否则 → candidate_offset 推进到该 buffer 结尾之后 遍历结束仍无间隙 → 放在 candidate_offset
为什么按大小降序?大 buffer 难以放入间隙,小 buffer 容易填缝。先放大块再插小块,减少碎片。
实例 :3 个 buffer 的布局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Buffer A: size=100, lifetime=[0,1] (算子0-1使用) Buffer B: size=80, lifetime=[2,3] (算子2-3使用) Buffer C: size=50, lifetime=[1,2] (算子1-2使用) 排序: A(100) > B(80) > C(50) --- 放置 A --- candidate_offset = 0,无已放置 buffer A 放在 offset=0,占 [0,100) --- 放置 B --- 检查 A: A 的 lifetime [0,1],B 的 lifetime [2,3],不重叠 → 跳过 B 放在 candidate_offset=0,占 [0,80) ← 与 A 共享内存! --- 放置 C --- 检查 A: lifetime [0,1] 与 C [1,2] 重叠 gap = 0,不够放 50 → candidate_offset = 100 检查 B: lifetime [2,3] 与 C [1,2] 重叠 B.offset(0) < candidate_offset(100),不影响 → candidate_offset = 100(不变) C 放在 candidate_offset=100,占 [100,150) 最终: head 区只需 150 字节(不共享需要 230 字节,节省 35%)
MicroAllocator——协调员 MicroAllocator 是上层接口。它持有一个 SingleArenaBufferAllocator 指针和一个 MicroMemoryPlanner 指针,对上提供语义化的分配 API,对下调用底层分配和规划。
核心数据成员 :
1 2 3 4 5 6 7 8 class MicroAllocator { INonPersistentBufferAllocator* non_persistent_buffer_allocator_; IPersistentBufferAllocator* persistent_buffer_allocator_; MicroMemoryPlanner* memory_planner_; bool model_is_allocating_; size_t scratch_buffer_request_count_; uint8_t * scratch_buffer_head_; };
注意:non_persistent_buffer_allocator_ 和 persistent_buffer_allocator_ 都指向同一个 SingleArenaBufferAllocator 对象。这是因为 SingleArenaBufferAllocator 同时继承了两个接口——用不同基类指针访问不同功能。
MicroAllocator 提供的关键 API :
API
用途
调用时机
StartModelAllocation
解析 FlatBuffer,分配 TfLiteEvalTensor 和 NodeAndRegistration 数组
AllocateTensors() 开始
RequestScratchBufferInArena
记录算子的 scratch buffer 需求
算子 prepare() 中
FinishModelAllocation
运行规划器,提交内存方案
AllocateTensors() 结束
AllocatePersistentBuffer
分配持久内存(从 tail)
任何阶段
AllocateTempTfLiteTensor
分配临时 tensor(从 temp)
算子 prepare() 中
自举过程详解 理解了三个组件各自的职责,回头看 MicroAllocator::Create() 的完整链路:
Step 1: 对齐 Arena
1 2 uint8_t * aligned_arena = AlignPointerUp (tensor_arena, 16 ); size_t aligned_arena_size = tensor_arena + arena_size - aligned_arena;
如果 tensor_arena 本身不是 16 字节对齐的,会损失几个字节。
Step 2: 创建 SingleArenaBufferAllocator(自举)
这是最巧妙的一步。Create() 需要在 Arena 里创建自己:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 SingleArenaBufferAllocator::Create (buffer_head, buffer_size) { SingleArenaBufferAllocator tmp (buffer_head, buffer_size) ; uint8_t * allocator_buffer = tmp.AllocatePersistentBuffer ( sizeof (SingleArenaBufferAllocator), alignof (SingleArenaBufferAllocator)); return new (allocator_buffer) SingleArenaBufferAllocator (tmp); }
为什么用临时对象?因为真正的对象还没构造,不能调用自己的方法。用栈上的临时对象做一次”预演”,计算出 tail 的正确位置,然后在 Arena 中构造真正的对象。
Step 3: 创建 GreedyMemoryPlanner
1 2 3 uint8_t * planner_buffer = memory_allocator->AllocatePersistentBuffer ( sizeof (GreedyMemoryPlanner), alignof (GreedyMemoryPlanner)); MicroMemoryPlanner* memory_planner = new (planner_buffer) GreedyMemoryPlanner ();
从 tail 继续向低地址分配,placement new 构造。此时 GreedyMemoryPlanner 是空壳——还没接收任何 buffer 信息。
Step 4: 创建 MicroAllocator 自身
1 2 3 4 uint8_t * allocator_buffer = memory_allocator->AllocatePersistentBuffer ( sizeof (MicroAllocator), alignof (MicroAllocator)); MicroAllocator* allocator = new (allocator_buffer) MicroAllocator (memory_allocator, memory_allocator, memory_planner);
同样从 tail 分配,placement new 构造。
完成后 Arena 的状态 :
1 2 3 4 5 6 ← 低地址 高地址 → ┌──────────────────────┬────────────────────────────────────────┐ │ 空闲空间 │ TAIL 区 (~1KB) │ │ head_ = temp_ │ MicroAllocator | GreedyPlanner | SAB │ │ 待 AllocateTensors │ ← tail_ │ └──────────────────────┴────────────────────────────────────────┘
三个管理对象占用 Arena 的尾部。头部全是空闲空间,等待 AllocateTensors() 分配 tensor 数据。
1.4 步骤④⑥——Graph 和 MicroContext MicroInterpreterGraph ——图执行引擎:
1 2 3 4 5 6 7 8 9 10 11 12 MicroInterpreterGraph::MicroInterpreterGraph ( TfLiteContext* context, const Model* model, MicroAllocator* allocator, ...) : context_ (context), model_ (model), allocator_ (allocator), current_subgraph_index_ (0 ), current_operator_index_ (0 ) { if (model != nullptr ) { subgraphs_ = model->subgraphs (); } }
只是保存指针。subgraphs_ 直接指向 FlatBuffer 中的数据。
MicroInterpreterContext ——kernel 与框架之间的桥梁:
它持有一个状态机 (InterpreterState),随推理流程推进:
1 kInit → kPrepare → kMemoryPlanning → kInvoke
状态机控制每个阶段允许哪些 API 调用。比如 kInit/kPrepare 阶段可以分配内存,kInvoke 阶段只能读取 tensor 和 scratch buffer。这防止了 kernel 在错误阶段做不该做的事(比如 invoke 期间分配内存)。
1.5 步骤⑦——Init() 设置 TfLiteContext C 回调表 为什么必须是 C 回调? kernel 代码(Conv2D、MaxPool 等)需要申请内存、读取 tensor,但它们只认识 TfLiteContext*——一个 C 结构体。同一套 kernel 代码要跑在 Android(TFLite)和 STM32(TFLM)上,两者的解释器实现完全不同。C 接口是它们之间的稳定契约 :
1 2 3 4 5 6 7 8 Kernel 代码(跨平台通用): context->AllocatePersistentBuffer(context, size) │ ▼ C 函数指针调用 TfLiteContext.AllocatePersistentBuffer ── 指向哪个函数? │ ├─ Android TFLite → 指向 TFLite 的 C++ 实现(有 malloc) └─ STM32 TFLM → 指向 TFLM 的 C++ 实现(从 Arena tail 分配)
Init() 的工作就是把 7 个函数指针填上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void MicroInterpreter::Init (MicroProfilerInterface* profiler) { micro_context_.SetInterpreterState (InterpreterState::kInit); context_.impl_ = static_cast <void *>(µ_context_); context_.ReportError = MicroContextReportOpError; context_.GetTensor = MicroContextGetTensor; context_.GetEvalTensor = MicroContextGetEvalTensor; context_.RequestScratchBufferInArena = MicroContextRequestScratchBufferInArena; context_.GetExternalContext = MicroContextGetExternalContext; context_.AllocatePersistentBuffer = MicroContextAllocatePersistentBuffer; context_.GetScratchBuffer = MicroContextGetScratchBuffer; initialization_status_ = kTfLiteOk; }
桥接层只做一件事 ——把 C 函数指针调用转发到 C++ 对象。context_.impl_ 是那座桥,指向 MicroInterpreterContext(C++ 对象)。
以 AllocatePersistentBuffer 为例,三步拆解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 步骤 1: Init() 中存入 C++ 对象地址 context_.impl_ = static_cast<void*>(µ_context_); // MicroInterpreterContext* → void*,抹掉类型信息 步骤 2: Kernel 通过 C 函数指针调用 context->AllocatePersistentBuffer(context, sizeof(OpData)) 步骤 3: 桥接函数恢复类型,转发到 C++ 方法 void* MicroContextAllocatePersistentBuffer(TfLiteContext* ctx, size_t bytes) { return GetMicroContext(ctx)->AllocatePersistentBuffer(bytes); // GetMicroContext(ctx) 展开为: // reinterpret_cast<MicroContext*>(ctx->impl_) // 从 void* 恢复为 MicroContext*,然后调 C++ 方法 }
所有 7 个回调都遵循同一模式:C 函数指针 → 从 impl_ 恢复 C++ 对象 → 调用 C++ 方法。这是实现多态的最轻量方案——不需要虚函数表,不需要继承体系,一个指针 + 一个静态函数就够了。
完整调用链——从用户代码到 Arena 分配
以 CONV_2D 算子申请 op_data 为例,从 ai_engine.cpp 入口追踪完整路径:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ai_engine.cpp 用户代码 └─ MicroInterpreter(model, resolver, tensor_arena, size) ├─ micro_context_(&allocator_, ...) 创建 MicroContext C++ 对象 └─ Init() ├─ context_.impl_ = µ_context_ 把 C++ 对象指针藏进 C 结构体 └─ context_.AllocatePersistentBuffer = &桥接函数 之后,解释器调用 Conv 算子的 Init: conv_common.cc: ConvInit(context, ...) └─ context->AllocatePersistentBuffer(context, sizeof(OpDataConv)) │ │ ① 通过函数指针间接调用 ▼ MicroContextAllocatePersistentBuffer(ctx, bytes) 桥接函数(C 接口) │ │ ② GetMicroContext(ctx) = reinterpret_cast<MicroContext*>(ctx->impl_) ▼ MicroContext::AllocatePersistentBuffer(bytes) C++ 方法 │ │ ③ 转发到分配器 ▼ MicroAllocator::AllocatePersistentBuffer(bytes) │ │ ④ 调用底层 Arena 分配 ▼ SingleArenaBufferAllocator::AllocatePersistentBuffer(bytes) │ │ ⑤ 从 Arena tail 区分配 ▼ tail_ = AlignPointerDown(tail_ - size, alignment) 你的 tensor_arena[] 里的空间
kernel 代码只知道第 1 步的 C 接口,不知道底层是 malloc(Android)还是 Arena tail(STM32)。
1.6 构造函数完成后的内存全景 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ┌─────────────────────────────────────────────┐ │ Arena (1MB) │ │ ┌──────────────────────┬────────────────┐ │ │ │ 空闲空间 │ 管理对象 (~1KB)│ │ │ │ head_ = temp_ │ MicroAllocator│ │ │ │ 待 AllocateTensors │ GreedyPlanner │ │ │ │ │ SingleArenaBuf│ │ │ └──────────────────────┴────────────────┘ │ └─────────────────────────────────────────────┘ 栈/静态区: MicroMutableOpResolver<20> registrations_[20] MicroInterpreter model_, context_, graph_ TfLiteContext 7 个函数指针
1.7 完整流程图 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 MicroInterpreter(model, resolver, tensor_arena, arena_size) │ ├─ ① model_ = model (保存指针) ├─ ② op_resolver_ = resolver (保存引用) │ ├─ ③ MicroAllocator::Create(arena, size) │ ├─ SingleArenaBufferAllocator::Create() │ │ └─ 在 arena tail 分配自身 (~40字节) │ ├─ CreateMemoryPlanner(kGreedy) │ │ └─ 在 arena tail 分配 GreedyMemoryPlanner (~20字节) │ └─ 在 arena tail 分配 MicroAllocator (~60字节) │ ├─ ④ MicroInterpreterGraph(&context_, model, &allocator_) │ └─ 保存指针,subgraphs_ 指向 FlatBuffer │ ├─ ⑤ tensors_allocated_ = false ├─ ⑥ micro_context_(&allocator_, model_, &graph_) │ └─ ⑦ Init(profiler) ├─ state_ = kInit ├─ context_.impl_ = µ_context_ ├─ context_.AllocatePersistentBuffer = 桥接函数 ├─ context_.GetScratchBuffer = 桥接函数 ├─ context_.RequestScratchBufferInArena = 桥接函数 ├─ context_.ReportError = 桥接函数 ├─ context_.GetTensor = 桥接函数 ├─ context_.GetEvalTensor = 桥接函数 └─ initialization_status_ = kTfLiteOk 此时:模型指针就绪,分配器就绪,回调表就绪 但还没有解析 FlatBuffer,还没有分配 tensor 内存 下一步是 AllocateTensors()