
1. 从一个反直觉的现象说起为什么模型能跑起来内存却没爆第一次接触 TFLite 的人几乎都会有一个疑问一个几十兆甚至上百兆的模型文件加载到手机上之后为什么没有把内存撑爆更奇怪的是同一个模型在推理过程中反复调用内存占用居然能保持在一个相对稳定的水位而不是随着调用次数线性增长。这个问题的答案就藏在 TFLite 推理引擎内部一个叫内存规划器Memory Planner的组件里。它做的事情听起来很朴素——给张量分配内存——但真正让它有价值的地方在于它不只是分配而是规划。分配是你要一块我给一块规划是提前算清楚谁和谁可以共用一块、谁必须独占、什么时候可以回收。这两者之间的差距直接决定了推理引擎在端侧设备上能不能跑得动。我最初做端侧推理优化的时候踩过一个很典型的坑自己手写了一套张量内存管理逻辑每个张量单独 malloc推理跑完再逐个 free。功能上完全没问题但内存峰值高得离谱一个本来 20MB 就能跑完的模型实测峰值冲到了 80MB 以上在低端机上直接触发系统内存回收推理延迟从 30ms 抖到 200ms。后来换成 TFLite 默认的内存规划器峰值直接压到 25MB 左右。这个差距不是靠调参调出来的而是靠规划这两个字。这篇文章想做的事情是把 TFLite 内存规划器这套机制拆开讲清楚。它适合谁看如果你正在做端侧模型部署、推理性能优化或者单纯好奇一个推理引擎内部是怎么管理内存的那这篇内容应该能给你一些可以直接用的东西。我会从它要解决的核心问题讲起然后拆解 ArenaPlanner 和 SimpleMemoryArena 这两个关键组件的设计逻辑再讲实际使用中怎么观察和调优内存行为最后分享几个我在真实项目里踩过的坑。2. 内存规划器到底在解决什么问题张量生命周期与内存复用的本质2.1 推理过程中的内存需求到底有多大要理解内存规划器的价值先得算清楚一笔账。假设一个模型有 N 个张量每个张量的大小是 S_i如果每个张量都独立分配内存那总内存需求就是所有张量大小之和。但问题是在推理过程中并不是所有张量都同时活着。一个张量的生命周期从它被某个算子写满数据开始到它被后续所有依赖它的算子读完为止。在这之后这块内存理论上就可以被别的张量复用了。举个具体的例子一个卷积层的输出张量被后面的激活层读走之后如果后面没有别的算子再依赖它那这块内存就空出来了。而下一个卷积层的输出张量完全可以复用这块刚空出来的内存。这就是内存规划器要解决的核心问题在保证正确性的前提下让尽可能多的张量共享同一块内存从而把峰值内存压到最低。注意这里的关键词是峰值因为端侧设备关心的是最坏情况下的内存占用而不是平均值。2.2 为什么不能简单地用引用计数有人可能会想这不就是引用计数能干的事吗张量引用归零就回收下一个张量来了就复用。逻辑上没错但引用计数解决的是什么时候可以回收解决不了回收之后给谁用。真正的难点在于内存规划器需要在推理开始之前就把整个内存分配方案算出来。为什么因为端侧推理对延迟极其敏感如果每执行一个算子都去做一次动态内存分配那分配器本身的开销就会成为瓶颈。更麻烦的是动态分配会导致内存碎片化跑着跑着内存就碎了峰值反而更高。所以 TFLite 的内存规划器采用的是一种静态规划的思路在推理开始前先分析整个计算图搞清楚每个张量的生命周期然后一次性算出一个内存分配方案。这个方案告诉引擎总共需要多大一块内存每个张量应该放在这块内存的哪个偏移位置。推理过程中引擎只需要按照这个方案去读写完全不需要再做分配和释放。2.3 静态规划带来的一个关键约束静态规划有一个绕不开的约束它必须假设最坏情况。也就是说规划器算出来的内存大小必须保证在任何输入条件下都不会溢出。这就意味着如果模型里有动态形状的张量比如输入序列长度可变规划器要么按最大可能形状来分配要么就得走另一套动态分配的逻辑。这个约束直接影响了 TFLite 内存规划器的设计。对于静态形状的模型规划器可以精确计算每个张量的大小和生命周期做出非常紧凑的规划。对于动态形状的模型规划器需要预留额外的余量或者退化成更保守的分配策略。理解这一点对后面理解 ArenaPlanner 的行为很关键。3. ArenaPlanner 的工作机制从计算图到内存偏移表的完整推导3.1 ArenaPlanner 在 TFLite 架构中的位置ArenaPlanner 是 TFLite 内存规划的核心执行者。它接收的是已经解析好的计算图包含所有算子和张量的元信息输出的是一个内存分配方案。这个方案的核心内容是一张表每个张量对应一个内存偏移量offset所有张量共享同一块连续的内存区域这块区域就叫Arena。用生活化的类比来说Arena 就像一个大仓库ArenaPlanner 就是仓库的调度员。它不负责进货出货只负责在货物到达之前把每个货物应该放在仓库的哪个位置规划好确保任意时刻需要同时存放的货物不会重叠同时尽量让仓库的总面积最小。3.2 张量生命周期的精确计算ArenaPlanner 做的第一件事是计算每个张量的生命周期区间。这个区间用两个数字表示出生时刻和死亡时刻。出生时刻是第一个写这个张量的算子执行的时间点死亡时刻是最后一个读这个张量的算子执行的时间点。这里有一个容易忽略的细节TFLite 的计算图在执行前会做一个拓扑排序每个算子会被分配一个执行顺序编号。ArenaPlanner 就是基于这个编号来计算生命周期的。比如张量 A 在第 3 个算子被写入在第 7 个算子被最后读取那它的生命周期就是 [3, 7]。有了所有张量的生命周期区间问题就转化成了一个经典的区间调度问题给定一组区间如何用最少的资源在这里是内存偏移区间来容纳它们使得任意两个重叠的区间不共享同一块内存。3.3 内存偏移分配的具体算法逻辑ArenaPlanner 采用的是一种基于贪心的分配策略。它按照张量的出生时刻排序依次为每个张量寻找可用的内存偏移。对于当前张量它会检查所有已经分配出去的内存块找出那些生命周期已经结束的块然后从中选择一个大小合适的来复用。如果找不到合适的已释放块就在 Arena 的末尾新开一块。这里合适的判断标准是已释放块的大小必须大于等于当前张量的大小。如果已释放块比需要的大多出来的部分就浪费了但 ArenaPlanner 不会去做内存块的拆分和合并因为那会显著增加规划复杂度而收益在大多数模型上并不明显。这个策略有一个直接后果Arena 的总大小并不等于所有张量大小之和而是等于任意时刻同时存活的张量大小之和的最大值。对于典型的卷积神经网络这个值通常只有所有张量大小之和的 20% 到 40%。这就是为什么内存规划器能把峰值压下来。3.4 一个具体的分配过程推演为了把这个过程讲清楚我用一个简化的例子来推演。假设有四个张量大小和生命周期如下张量大小生命周期T1100[1, 3]T2200[2, 5]T3150[4, 6]T4100[6, 7]按出生时刻排序后依次处理。T1 出生最早在偏移 0 处分配 100 字节Arena 大小变成 100。T2 出生时 T1 还活着生命周期到 3所以不能复用在偏移 100 处分配 200 字节Arena 大小变成 300。T3 出生时 T1 已死3 4T1 的 100 字节块空出来了但 T3 需要 150 字节放不下所以只能在偏移 300 处新分配 150 字节Arena 大小变成 450。T4 出生时 T2 已死5 6T2 的 200 字节块空出来了T4 需要 100 字节可以复用放在偏移 100 处。最终 Arena 大小是 450而所有张量大小之和是 550。这个例子里节省不算多但在真实模型里张量数量是几百上千个复用率会高得多。4. SimpleMemoryArena 的内存管理细节对齐、预留与动态扩展4.1 SimpleMemoryArena 的角色定位ArenaPlanner 负责规划SimpleMemoryArena 负责执行。规划出来的方案是一张偏移表但真正在运行时引擎需要通过 SimpleMemoryArena 来获取每个张量的实际内存地址。SimpleMemoryArena 管理着一块连续的字节缓冲区对外提供按偏移量获取指针的接口。这个分工很清晰规划是离线的、一次性的执行是在线的、高频的。把两者分开可以让规划逻辑专注于优化内存布局而执行逻辑专注于快速地址计算互不干扰。4.2 内存对齐一个容易被忽视但影响性能的细节SimpleMemoryArena 在分配内存时会做内存对齐处理。默认的对齐粒度通常是 64 字节这个数字不是随便定的。现代处理器的缓存行大小一般是 64 字节如果张量的起始地址没有对齐到缓存行边界一次内存访问可能会跨越两个缓存行导致额外的缓存读取性能会下降。对齐带来的代价是内存浪费。一个 100 字节的张量对齐到 64 字节后实际占用 128 字节浪费了 28 字节。对于小张量密集的模型这个浪费比例可能相当可观。但在实际测试中对齐带来的性能收益通常远大于内存浪费的代价所以 TFLite 默认开启对齐。提示如果你的模型里小张量特别多且内存极度紧张可以尝试调整对齐粒度。但要注意某些硬件平台对未对齐访问有严格限制调整前务必确认目标平台的支持情况。4.3 内存预留策略与动态扩展的边界SimpleMemoryArena 在初始化时会根据规划结果预留一块内存。但有些场景下规划时无法确定精确大小比如动态形状的输入。这时候 SimpleMemoryArena 需要支持动态扩展。动态扩展的逻辑是当需要的内存超过当前 Arena 大小时重新分配一块更大的内存把原有数据拷贝过去然后释放旧内存。这个操作的开销不小所以 TFLite 会尽量在规划阶段就把大小算准避免运行时扩展。这里有一个实操中容易踩的坑如果模型有多个子图比如控制流算子产生的分支每个子图可能有自己的内存需求规划器需要为所有子图统一规划取峰值最大的那个作为 Arena 大小。如果忽略了这一点运行时就会频繁触发扩展性能会明显下降。4.4 内存复用与数据依赖的正确性保证内存复用最大的风险是一个张量的内存被复用后如果还有算子需要读它就会读到错误的数据。ArenaPlanner 通过生命周期分析来避免这个问题但有一个边界情况需要特别注意原地算子in-place operator。原地算子是指输出张量和输入张量共享同一块内存的算子比如某些激活函数。对于这类算子规划器需要特殊处理确保输出张量的生命周期和输入张量的生命周期正确衔接不会出现输出写完了输入还需要读的情况。TFLite 在算子注册时会标记哪些算子支持原地执行ArenaPlanner 会根据这些标记来调整规划策略。5. 实际使用中怎么观察和调优内存行为5.1 用内置工具查看内存规划结果TFLite 提供了一些工具可以帮助你观察内存规划的结果。最直接的方式是在构建解释器时开启详细日志日志里会输出 Arena 的总大小、每个张量的偏移量等信息。通过这些信息你可以判断规划是否合理有没有明显的浪费。另一个实用的方法是使用 TFLite 的 benchmark 工具它会报告推理过程中的内存峰值。把这个峰值和模型文件大小对比一下如果峰值远大于模型大小说明内存规划可能有问题或者模型本身存在大量中间张量。5.2 影响内存规划效果的关键因素内存规划的效果受几个因素影响理解这些因素有助于你在模型设计阶段就做出更好的决策。第一个因素是算子执行顺序。TFLite 默认按照计算图的拓扑顺序执行但某些情况下可以调整顺序来缩短张量生命周期。比如把两个不相关的分支交错执行而不是串行执行可以让某些张量的生命周期重叠从而提高复用率。第二个因素是张量形状。形状越规整规划器越容易找到复用机会。如果模型里有大量形状各异的张量复用率会下降。这也是为什么很多端侧模型会做形状统一化处理。第三个因素是算子融合。融合后的算子减少了中间张量的数量直接降低了内存需求。TFLite 的转换器会自动做一部分融合但有些融合需要手动触发。5.3 内存与延迟的权衡内存规划不是越省越好。过度追求内存复用可能会导致某些张量被放在不连续的内存区域影响缓存局部性反而增加推理延迟。我在一个项目里遇到过这种情况为了把峰值内存从 30MB 压到 25MB调整了规划策略结果推理延迟增加了 15%。后来发现是因为复用导致张量地址分散缓存命中率下降。所以调优的时候内存和延迟要一起看。TFLite 的 benchmark 工具可以同时报告这两个指标建议每次调整后都对比一下。6. 几个真实项目里踩过的坑6.1 动态形状导致的规划失效有一次部署一个文本分类模型输入序列长度是可变的。测试时用短序列跑得好好的内存占用很低。上线后遇到长序列内存直接翻倍触发了系统的内存警告。排查后发现规划器对动态形状的处理是按最大可能形状预留的但实际运行时短序列用不到那么多长序列又刚好卡在边界上。解决办法是在模型转换阶段就把输入形状固定下来或者显式指定一个合理的最大长度。如果业务上确实需要支持任意长度那就得接受内存按最大长度预留的事实或者在应用层做长度截断。6.2 多解释器共享内存的陷阱在一个需要同时加载多个模型的场景里我最初的做法是每个模型创建一个独立的解释器各自管理自己的 Arena。结果内存占用是线性叠加的三个模型加起来直接超过了设备限制。后来改成让多个解释器共享同一个 SimpleMemoryArena通过时间片轮转的方式复用内存。这个方案的前提是多个模型不会同时推理否则会有数据竞争。实现上需要自己管理 Arena 的分配和释放TFLite 本身不直接支持这种模式但通过自定义内存分配器接口可以做到。6.3 对齐粒度调整引发的兼容性问题前面提到过对齐粒度可以调整我在一个内存极度紧张的项目里尝试把对齐从 64 字节降到 16 字节内存确实省了一些。但在某些设备上出现了推理结果错误排查后发现是那些设备的硬件对未对齐访问的处理不一致导致读到了错误的数据。这个坑的教训是对齐粒度的调整必须做充分的跨设备测试不能只看一两个设备的结果。如果目标设备范围广建议保持默认对齐通过其他方式优化内存。6.4 规划器版本差异带来的行为变化TFLite 的内存规划器在不同版本之间有过几次调整主要是优化了复用算法和动态形状的处理。我在升级 TFLite 版本后发现同一个模型的内存峰值变了有的场景变好了有的场景反而变差了。所以升级 TFLite 版本时不要只看功能更新日志一定要重新跑一遍内存和延迟的 benchmark。如果发现退化可以对比新旧版本的规划日志看看是哪个张量的分配策略变了必要时可以通过调整模型结构来适配新版本的规划逻辑。7. 从内存规划器身上能学到什么通用思路抛开 TFLite 本身内存规划器这套设计思路在其他领域也有借鉴价值。它的核心思想是把运行时的动态决策提前到编译时用一次性的规划开销换取运行时的稳定和高效。这个思路适用于任何对运行时性能敏感、且资源受限的场景。比如游戏引擎的资源加载、嵌入式系统的任务调度、甚至数据库的查询计划生成本质上都是在做类似的事情提前分析、提前规划、运行时只执行。另一个值得借鉴的点是分层设计。ArenaPlanner 负责规划SimpleMemoryArena 负责执行两者通过一张偏移表解耦。这种分层让每一层都可以独立优化和替换。比如你想换一种规划算法只需要改 ArenaPlannerSimpleMemoryArena 完全不用动。这种设计在复杂系统里非常重要因为它把变化的影响范围控制在了最小。我在自己的项目里也借鉴了这个模式把资源分配拆成规划层和执行层规划层负责算最优方案执行层负责快速落地。实测下来这种拆分让代码的可维护性和性能都有了明显提升尤其是在需求频繁变化的场景里改规划层不会影响到执行层的稳定性。