ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TFLite内存规划器:中间tensor复用,压降低端侧推理峰值内存

TFLite内存规划器:中间tensor复用,压降低端侧推理峰值内存 你第一次把一个TFLite模型塞进手机时大概率不会关注内存规划器。直到系统因为内存占用过高杀掉进程才意识到在端侧跑推理模型计算量是一回事中间tensor把内存顶爆更是常事。TFLite作为面向移动端和嵌入式设备的推理引擎性能上最大的隐形差异往往不在算子快不快而在内存规划器有没有把每一块buffer都安排到位。简单说TFLite内存规划器做的就是在模型真正执行前把所有中间张量的生命周期排一遍让不会同时存活的张量共用同一个地址把峰值内存压到最小。这篇文章没有高深公式我会从原理、观察方法和优化习惯三个角度把内存规划器这个“内存管家”的底牌翻出来。无论你是刚把模型转成TFLite的新手还是已经在移动端部署过几个模型但总觉得内存不合理的开发者都可以照着试一遍。1. 从“内存管家”说起推理引擎为什么离不开内存规划器1.1 推理跑起来之前内存规划已经发生很多人以为interpreter-AllocateTensors()只是简单地为模型里的每个张量分配一块内存。实际上这一步触发的是整个推理引擎内存规划的开始。TFLite从模型文件里读出的是计算图图上每个节点是一个算子每个边是一个张量。执行顺序可以在加载阶段通过拓扑排序确定每个张量被哪个算子读、被哪个算子写也是完全确定的。基于这些信息规划器可以在计算还没有开始之前就推算出每个张量的生存区间然后把生存区间错开的张量塞到同一个内存地址。我用一个类比来说明。把模型推理看成一场酒店接待每个张量是一个入住客人每个算子是一个活动环节。如果两个客人入住时间错开管家完全可以让后一位客人住进前一位刚退出的房间。内存规划器就是这个管家。它和操作系统的malloc不同malloc不知道你什么时候释放只能等你在运行时手动free但推理引擎在做事前就知道所有中间结果什么时候产生、什么时候不再被引用因此可以做离线规划。只要执行计划不变所有的“房间安排”可以一次算好反复使用。1.2 规划器要解决的三个矛盾第一个矛盾是峰值内存和分配速度。如果为了绝对省内存每次执行都用精确算法求全局最优布局规划阶段可能花费过多时间这对端侧设备的冷启动不友好。TFLite选择的是在一个连续的内存池arena内做贪心式的复用规划快执行期几乎不进行系统级内存申请换来的是内存绝对峰值比理论最优略高一点但已经很够用。第二个矛盾是张量生命周期判断的复杂度。表面上看只要两个张量不同时活就能重叠。但真实模型里存在分支、循环、多输入多输出还有部分算子要求输出不能与输入重叠。比如一些in-place不友好的算子会强制创建独立buffer。所以规划器必须在保证正确性的前提下尽可能多地复用空间而不是无脑地全部拼在一起。这个“正确性优先”的约束恰恰是很多性能调优新手最容易忽略的。第三个矛盾是静态规划和动态形状。输入尺寸固定时所有中间tensor大小都能提前算准规划器可以一次性定死所有地址。一旦输入shape可以变化例如NLP模型的变长输入规划器只能按当前shape重新规划之前的地址可能失效。后续章节里我会专门说这个问题的排查思路。这些矛盾决定了内存规划器不是“可选项”。只要你的TFLite模型不是小到只有几个tensor规划效果就会直接影响你能不能在当前设备上跑起来。2. 内存规划器的工作方式从“用时分配”到“提前排布”2.1 静态分析谁活得长谁可以和谁“拼房”内存规划的第一步是给每个tensor算生命周期。TFLite内部的ArenaPlanner会先读取图的执行顺序然后对每个tensor记录两个关键下标第一次出现的节点位置和最后一次出现的节点位置。第一次出现既包括作为某算子的输出被创建也包括作为某算子的输入被首次使用最后一次出现则是它作为输入的最后一个算子位置。生命周期覆盖从第一次到最后一次的整个区间只要区间不相交两个tensor就有机会共享同一块内存。分配阶段常采用“按大小降序、首次适应”的启发式。大tensor先放进内存池小tensor再尝试塞进大tensor之间的“缝隙”。如果塞得下缝隙被利用碎片就被压住如果塞不下就另起一块新buffer。你可能会问为什么不从小到大排经验上看小tensor灵活、容易填缝先放大tensor能减少后期“大块找不到位置”的概率。这个顺序看起来简单但对最终内存峰值有决定性影响。判断生命周期不是只看算子依赖还要看算子类型。TFLite中有一些算子例如某些自定义kernel要求输入和输出内存不能重叠防止在in-place计算时破坏未被读取的输入数据。规划器面对这类算子会人为延长必须独立分配的约束。这里没有统一的魔法公式所以部署自定义算子时如果你的实现里声明了“输出不能与输入共享”内存规划器就会遵守峰值也相应上升。2.2 Arena分配器一段连续内存里的“拼图游戏”TFLite把规划结果落到一个叫arena的内存池里。arena可以理解成一段提前向系统申请好的连续地址空间规划器在上面用偏移量的方式切出不同大小的“槽位”。推理执行期间普通malloc/free几乎不会发生全是内存池内部的偏移定位。这让内存行为变得可预测也大幅降低了堆上反复分配带来的碎片问题。在TFLite中张量有几种分配类型。常用的内部张量标成kTfLiteArenaRw表示它的数据放在可读写的arena buffer中。还有kTfLiteArenaRwPersistent表示它需要一直保留到推理结束不会中途被复用。输入输出张量通常由调用方提供的buffer接管不占arena空间。如果你在调试时看到某个tensor的data指针落在arena区域内而另一些tensor的指针完全一样不要慌这正是你希望看到的复用结果。arena还有一个好处规划结果可以缓存。同一个模型只要输入尺寸不变每次执行的内存布局都一样。你可以在进程启动阶段完成一次规划之后线程反复推理都不需再向系统讨要内存。这种特性对长时间运行的服务和实时推理尤其重要内存水位稳定抖动小。2.3 计划结果用指针重叠来观察“拼图”规划器最终不直接给你一张好看的内存地图它把结果体现在每个tensor的data.data指针上。只要两个tensor的data指向同一个起始地址就说明它们被安排到了arena的同一个槽位生命周期必然完全不重叠或者按要求错开。你可以用这个方法快速验证打印所有中间tensor的地址按地址分组组内tensor数量越多说明复用越充分。我之前没有足够重视这个观察方法直到有一次模型从MobileNetV3换到EfficientNet-Lite内存没降反而升高。拿地址分组一数发现新模型里有一组很大的中间tensor生命周期重叠得厉害它们各自占了一块大空间哪怕图面结构上看起来“差不多”。所以说只看模型文件大小来判断推理内存是很容易被误导的实际要看规划器怎么安排这些tensor。3. 实操查看并解析一个具体模型的内存规划3.1 准备一个可运行的C最小程序这里我给出一段很实用的调试代码不需要改TFLite源码只依赖公开API用来打印每个内部tensor的地址、大小和分配类型。你需要准备libtensorflow-lite.a或对应的动态库以及一个.tflite模型文件。如果还没有模型随便找一个转好的MobileNetV1量化模型就行。#include cstdio #include memory #include tensorflow/lite/interpreter_builder.h #include tensorflow/lite/kernels/register.h #include tensorflow/lite/model_builder.h int main(int argc, char** argv) { if (argc 2) { printf(usage: %s model.tflite\n, argv[0]); return 1; } std::unique_ptrtflite::FlatBufferModel model tflite::FlatBufferModel::BuildFromFile(argv[1]); if (!model) return 1; tflite::ops::builtin::BuiltinOpResolver resolver; tflite::InterpreterBuilder builder(*model, resolver); std::unique_ptrtflite::Interpreter interpreter; builder(interpreter); if (!interpreter) return 1; interpreter-AllocateTensors(); for (int i 0; i interpreter-tensors_size(); i) { TfLiteTensor* tensor interpreter-tensor(i); if (tensor-allocation_type ! kTfLiteArenaRw tensor-allocation_type ! kTfLiteArenaRwPersistent) { continue; } printf(id%d name%s bytes%zu type%d data%p\n, i, tensor-name ? tensor-name : (null), static_castsize_t(tensor-bytes), tensor-allocation_type, tensor-data.data); } return 0; }这段代码在AllocateTensors()后遍历所有张量把arena管理的内部张量打印出来。allocation_type区分普通复用型条目和持久条目。需要说明的是不同版本TFLite对tensor-bytes的类型定义可能不同编译时以你手头版本为准我这里用static_castsize_t只是示意。3.2 从输出里还原内存规划关系打印出来的每行代表一个内部tensor。假设输出中有这样几行Tensor ID名称bytesallocation_typedata 指针5MobilenetV1/Conv2d_0/Relu640140810x557f3a00008012MobilenetV1/Conv2d_1/Relu680281610x557f3a0002009MobilenetV1/Conv2d_2/Relu640140810x557f3a000080这里的“指针相同”就是一个关键信号id5和id9在模型执行中不会同时存活所以被复用了同一个arena槽位。你可以继续对全部输出做一个简单分组把地址相同的一组列出来计算每组的最大bytes所有组的最大bytes加在一起再加上外部buffer才接近真实峰值。有些tensor显示kTfLiteArenaRwPersistent它们的地址通常是独立的生命周期持续到模型销毁。这类tensor一般是全图都要用的常驻变量比如LSTM的state、控制流中的可变变量。如果这类tensor过多内存峰值很难降下来你需要从模型结构反推是否有大量长生命周期的中间结果。3.3 用脚本快速计算峰值和复用率手动一行行看太慢我通常会把上面C程序的输出重定向到文件再用一个小脚本分析。脚本逻辑很简单读取名称、bytes、地址按地址分组输出组大小和最大bytes同时把地址唯一的总字节数和所有tensor字节总和作对比算出复用率。复用率越高说明内存规划器把空间“压缩”得越好。import re from collections import defaultdict groups defaultdict(list) total_bytes 0 with open(tensor_map.txt, r) as f: for line in f: m re.search(rname(\S) bytes(\d) type(\d) data(0x[0-9a-fA-F]), line) if not m: continue name, size, typ, addr m.groups() size int(size) total_bytes size groups[addr].append((name, size, typ)) unique_bytes 0 for addr, items in groups.items(): max_item max(items, keylambda x: x[1]) unique_bytes max_item[1] print(faddress {addr}: {len(items)} tensors, max bytes{max_item[1]}) print(ftotal tensor bytes{total_bytes}) print(farena unique bytes{unique_bytes}) print(freuse rate{(1 - unique_bytes / total_bytes) * 100:.1f}%)这个脚本把同一地址的一组tensor视为槽位用组内最大bytes估算这个槽位实际需要的空间。注意里面有个简化arena规划时可能还会产生对齐填充所以槽位实际占用可能比最大bytes略大。但用来做模型版本间的横向对比量级完全够用。3.4 如果地址全都不一样意味着什么如果你打印完发现几乎每个tensor地址都不同说明你的模型里大多数中间tensor生命周期重叠或者TFLite无法安全复用。常见原因有三个。第一模型分支过多每个分支都要保留上游输出中间结果必须同时存活。第二有大量自定义算子被声明为不可in-place规划器被迫为每个输出单独划分区域。第三输入尺寸是动态的规划器每次按当前形状重新布局地址天然不稳定但你看到的每个tensor独立地址仍能说明生命周期没有交集。遇到这种“全都不一样”的情况不要急着怪TFLite先看模型图的拓扑结构。是不是有一段很长的串行路径每个算子都依赖前一个输出而后面的算子还没开始前面的输出就已经被消耗掉了其实串行路径反而是最容易复用的因为前面的中间结果只在两个相邻算子之间存活。真正让你内存爆掉的往往是那些从很远处就开始产生、直到最后才被消费的特征图。4. 调优内存规划器的常见问题与排查思路4.1 峰值超预期用系统水位确认真实占用部署到安卓或Linux后不要只看tensor打印的估算值直接看进程真实峰值。Linux下可以用/proc/self/status里的VmHWM它记录进程历史最高虚拟内存适合推理运行后读取。grep VmHWM /proc/self/status如果你有多个执行阶段可以在每个阶段前后打印这个值定位内存是在加载模型时涨的还是在第一次推理时涨的。很多时候你会发现模型加载就吃了几十MB这是flatbuffer解析和权重加载占用真正由内存规划器控制的是第一次AllocateTensors()之后的那部分。若是VmHWM在推理时才急剧上升重点查arena内部若是在加载阶段就很高查模型文件本身和权重tensor的映射方式。我踩过一次比较深的坑模型采用mmap方式加载权重理论上只映射磁盘页不占额外内存。但在低内存设备上页缓存被回收时照样会以物理内存的形式计入。排查时不能只看tensor bytes求和还要考虑操作系统和文件缓存的影响。4.2 动态形状预留最大尺寸还是频繁重规划动态形状是内存规划的“不确定性来源”。当用户通过ResizeInputTensor改变输入尺寸后TFLite需要重新执行内存规划旧的arena布局作废。如果你在一段视频流里每帧都有微小变化理论上每次推理都可能触发重规划旧buffer被释放、新buffer被申请峰值内存会短暂翻倍。应对方法有两种。第一种是把输入尺寸固定到可能遇到的最大值比如视频帧固定为1280x720这样规划器只需规划一次所有推理共享同一布局。对于实际使用中较小的帧多出来的空间就当“安全垫”。第二种是尽量复用同一个预分配buffer减少resize频率。你可以在应用层维护一个输入尺寸的“最近值”只有在尺寸变化超过阈值时才调用ResizeInputTensor。内存峰值和灵活性没法兼得但不能让规划器在主链路高频重规划。如果你确实需要动态尺寸建议对几个典型尺寸分别跑一遍内存地址打印看同一组tensor在尺寸切换后地址变化幅度。如果只是尾部拼接的tensor变化说明布局整体稳定如果核心大tensor全部换地址那么峰值波动大概率剧烈这时候就该考虑固定尺寸。4.3 为什么arena策略下也要关心“碎片”我遇到过有人问既然都在一块连续arena里静态分配为什么还会有内存碎片其实这里的碎片不是堆碎片而是arena内部插入不同大小tensor后留下的“缝隙”。比如你有一个2MB的tensor先占住槽位接下来两个800KB的tensor无法塞进去只能放入另一个区域2MB内部剩下的400KB就浪费了。这是内部碎片不是系统堆碎片但同样会造成峰值偏高。TFLite的ArenaPlanner会尽量用大小降序排列来减少这种浪费但不是所有模型都能达到理论最优。当你发现按地址分组计算出的unique_bytes明显大于关键tensor总和时可以尝试从模型层面调整。比如把一个大算子拆成几个小算子改变中间tensor的产生顺序有时候可以让小tensor填进大tensor留下的缝隙。这个操作看着像是在“画蛇添足”实际效果却立竿见影。还有一点不同对齐要求也会造成浪费。TFLite内部tensor一般要求8字节或16字节对齐某些硬件加速器还会要求更大的对齐。每个tensor的起始地址都可能因为对齐产生paddingtensor越小对齐带来的相对浪费越明显。所以不要对几百字节的小tensor斤斤计较真正的优化空间永远在大tensor的布局上。4.4 模型结构对生命周期的影响分支比串行更“吃内存”很多开发者在优化内存时首先想到量化或剪枝却忽略了一个基础事实模型结构已经决定了内存规划的上限。串行网络是最理想的结构每个中间结果只在一个算子之后、下一个算子之前存活很容易复用。一旦出现分支比如FPN、ASPP这种多尺度特征融合某个tensor可能在很靠前的层就生成直到最后融合阶段才被使用整个生命周期横跨几十个节点期间其他tensor都无法占用它的空间。在这种结构下你能做的并不是抱怨规划器不够聪明而是尽量降低长生命周期tensor的尺寸。量化最直接把float32的中间结果变成int8或int16生命周期跨度不变但占用减一半。另一个可行方向是算子融合比如把BatchNorm和卷积融合消除一个中间tensor。虽然融合主要影响计算量但消除一个tensor的存活区间也给规划器腾出了一块完整可复用的槽位。我建议你每次调整模型结构后都重新跑一遍第3节的内存地址分组。结构看起来相似的两个模型内存版本的差异可能非常大只有数据能让你看清谁在占用大头。5. 几条被实践验证过的规划优化建议5.1 把输入尺寸固定成“最大可能值”最简单也最有效的一招是给模型的输入指定固定shape。转换TFLite时用tf.lite.TFLiteConverter带上input_shapes参数或者在C端创建Interpreter后用ResizeInputTensor显式固定。固定后规划器在第一次AllocateTensors()时就能把所有中间tensor的大小和地址全部定下来后续推理不再有动态调整的包袱。对于视频、图像类任务你可以设一个既能覆盖业务场景又不会过大的尺寸。比如实际使用中可能遇到1920x1080但处理时通常会先缩放到640x360那就直接把模型固定成640x360别用416x416这种奇怪尺寸。输入尺寸变大中间特征图的面积是按平方增长的每一层都要多出好几倍的bytes内存影响远比计算量更直观。曾有同事为了追求几乎不可见的精度差把输入从640x640改成672x672内存峰值直接多了9%。类似这种无谓的shape扩大在端侧部署时能避免就避免。5.2 把持久张量和普通张量分清楚TFLite里的kTfLiteArenaRwPersistent是给那些生命周期跨越整次推理或多次推理的tensor用的。它不会参与普通arena复用所以每一次出现都会扩大内存“底盘”。典型例子是循环神经网络的state、统计模型里的全局计数变量。如果你在模型里设计了一个会被重复写入、但必须保留到最后的buffer转换后它大概率会变成persistent。从模型设计角度建议把真正需要跨推理保存的数据放到模型输入输出上或者放到应用层自己管理别让推理引擎内部产生过多persistent tensor。一个常见误区是为了减少每帧重复计算把上一帧的中间特征保留到这一帧。这种做法会直接制造一个横跨多次推理的持久张量内存占用翻倍还不一定省下多少计算时间。实时任务里宁可每帧重算局部特征也不要让中间结果长期驻留。5.3 算子融合和量化不要只盯着计算量融合和量化通常被看作“加速”手段但在内存规划器视角下它们同样是“减内存”手段。量化让每个tensor的bytes缩小相同生命周期下占用降低融合让两个算子之间的中间tensor消失释放一段完整的生命周期区间给旁边的大tensor腾位。我的一次优化经历是一个使用EfficientNet骨干的检测模型先用int8量化内存降了约40%再把卷积BN融合、以及把一些没必要保留的Resize节点改为更小的中间表示峰值又降了约15%。整个过程中模型精度几乎没有变化但设备从OOM崩溃变成稳定运行。这说明在部署阶段谈内存不能只看模型本身得把执行路径上的每个tensor都当作可压缩的“住户”。5.4 版本升级后重新核对内存地图模型迭代是常态。今天跑得好好的布局明天换新版本模型可能就崩了。原因可能是新加了跳跃连接、改变了分支顺序或者只是算子实现变了导致in-place约束变化。因此我把“打印tensor地址”固定为部署流程的一步每次模型升级都跑一次并保留一份之前的tensor_map.txt做对比。对比时重点看两类差异一是是否有新的独立大buffer出现二是原本复用的tensor分组被打散。前者说明模型新增了长生命周期大张量后者说明某个算子禁止了内存复用。两种变化都能从内存地图里直观看出来。这种对比比看模型FLOPs或者对内存估计有用得多因为它反映的是运行时真实布局。最后再分享一个个人习惯拿到新模型我会先跑一次内存地址打印把所有地址相同的tensor列成一张表贴在部署文档里。后续如果模型升级只要这张表里的分组还稳定我基本敢打包票内存不会出大问题。很多内存问题其实都能在部署前用这张表提前发现省掉的都是现场调试的时间。TFLite内存规划器不是那种特别炫的模块但理解它能让你在端侧推理这条路上少走很多弯路。
返回列表