前几个月我在调一个边缘设备上的推理引擎,模型权重已经压缩到很小了,参数量也就百来万,按理说显存完全够用,结果一跑还是报 OOM。翻日志发现一个很尴尬的事实:每一层算完的中间张量并没有立刻释放,而是堆在那里,等到整个子图跑完才统一回收。真正撑爆内存的根本不是单层算子的开销,而是这些中间结果叠加出来的内存使用峰值。后来我花时间做了一个内存优化调度程序:不动网络结构、不改算子实现、不加任何牺牲精度的压缩,只调整计算图中算子的执行顺序,峰值内存就降了一大截。
这篇文章就把这个调度程序里的核心思路、模型设计、工程落地时踩过的坑,以及一套可以直接上手的最小实现,完整拆开讲清楚。适合正在做推理引擎、编译优化,或者被显存问题搞得头大的朋友参考。
1. 为什么峰值内存不由"参数总量"决定,而由执行顺序决定
1.1 一个让我开始折腾调度算法的 OOM 现场
先还原一下当时的场景。我手里的模型结构并不复杂,权重占用不到 300MB,理论上中间激活值最大也就 800MB 左右,放在 4GB 的设备上绰绰有余。但实际运行时会先加载权重,再跑预处理,然后进入模型主体,跑到第三个大模块的时候直接报显存不足。
我把内存快照打出来一看,活跃张量清单里躺着一堆早就"逻辑死亡"的中间结果:有些中间张量只是在某个分支里被用过一次,但因为它所在的子图还没有全部执行完,引用计数一直没有归零,整块显存就被白白占着。
这就是问题的本质:框架的默认执行顺序往往按图的层级或拓扑批次推进,哪个算子该先执行、哪个张量该早释放,并没有一个全局视角去规划。换句话说,内存峰值不是参数总量决定的,而是所有同时存活张量的大小之和决定的。只要执行顺序变了,这个"同时存活"的集合就会变,峰值自然跟着变。
1.2 张量生命周期:什么在涨,什么能立刻缩
要理解调度优化,先得建立一个基础概念:张量的生命周期。一个张量从被算子创建的那一刻起进入活跃状态,一直到它最后一个消费者读完它为止,这段区间内它必须驻留在显存里。
举个例子:假设有算子 A 生成张量 X,X 会被算子 B 和算子 C 依次使用。那么 X 的生命周期是从 A 执行完开始,到 C 执行完结束。B 执行完的时候 X 还不能释放,因为 C 还没读它。
如果我们记录每个张量剩余的消费者数量,也就是 refcnt(引用计数),那么每当一个算子执行完,它的每个输入张量的 refcnt 就减一。当 refcnt 变成 0,说明这个张量已经没有后续消费者了,可以立刻释放。调度算法要做的,就是决定"在所有当前可执行的算子中,先执行谁",让大块内存更早进入 refcnt=0 的状态。
有个直观的感受:内存分配器通常不愿意把内存立刻归还给系统,因为申请和释放都有开销。只要逻辑上还能复用,它就把块留在自己的池子里。这导致一个现象:即使张量的引用计数已经归零,物理显存也未必真的空出来。调度优化是在逻辑层面先把活跃集合缩下去,后面分配器才有机会把空出来的块派给其他算子。
1.3 层序执行为何最容易推高峰值内存
大多数框架默认的调度方式是"按层执行":先跑完某一层所有算子,再进入下一层。这种顺序在实现上最简单,但在内存上往往是最差的。
我画一个最简的树形图来说明。假设第一层有 1 个算子,输出张量大小 100MB;第二层有 2 个算子,各自依赖第一层的输出,每个输出 80MB;第三层有 4 个算子,各自依赖第二层的一个输出,每个输出 50MB。
如果按层序执行:
- 第一层跑完,活跃张量 100MB;
- 第二层两个算子都跑完,第一层的输出还不能释放,因为两个分支都在用,所以活跃总量变成 100 + 80 + 80 = 260MB;
- 第三层四个算子依次跑完,活跃总量会变成 260 + 4 × 50 = 460MB。
峰值就是 460MB。
如果改成深度优先执行,先把第一个分支跑到底:
- 第一层输出 100MB;
- 跑第二层第一个节点,输出 80MB,此时活跃 180MB;
- 跑第三层第一个节点,输出 50MB,同时第二层的 80MB 被消费并释放,活跃变成 100 + 50 = 150MB;
- 回溯跑第二个分支,第二层第二个节点输出 80MB,活跃变成 180MB;
- 再跑第三层第二个节点,释放 80MB,活跃变成 150MB。
整个过程峰值只有 180MB。同一个图,只换了执行顺序,峰值从 460MB 降到 180MB。这就是调度算法的价值,也是我决定自己写一个调度器的直接原因。
2. 把调度问题变成可计算的模型:DAG、活跃集与峰值变量
2.1 形式上我们到底在优化什么
先给问题一个清楚的边界。计算图可以看成一个有向无环图,节点是算子,边代表张量依赖。每个算子有若干输入张量和输出张量,每个张量有一个内存大小。
调度要做的事情是:在满足依赖关系的前提下,决定所有算子的一个执行序列。给定一个合法序列,我们模拟执行它,每一步结束后统计当前所有活跃张量的总大小,取整个过程中的最大值,就是这次调度的峰值内存。
优化目标就是找到一个合法序列,让这个峰值尽量小。
这里有两个容易混淆的点。第一,调度器优化的是"峰值",不是"总内存"——总内存是每个算子输出大小之和,不管怎么排序都不会变。第二,权重等持久驻留的内存其实是一个常数项,它不会随顺序变化,所以调度器在比较时通常只关注中间张量部分。
2.2 为什么不能靠穷举:拓扑排序数量会爆炸
一个看起来很自然的想法是:把所有合法拓扑排序都枚举一遍,选出峰值最小的那个。对于 10 个节点以下的小图,这个办法确实可行;但一旦节点数量上去,拓扑排序的数量是指数级增长的。
我做过一个小实验:一个 20 节点的随机图,拓扑排序数量就已经大到秒级都枚举不完。工业模型动辄几百上千个算子,穷举完全没有可行性。
这个问题在理论上和寄存器分配里的寄存器溢出、区间图着色问题有很近的关系。简单说,如果存在多项式时间的精确算法,那很多经典问题也会跟着被解决。所以工业界几乎所有内存调度器都采用启发式或者贪心策略,追求"足够好",而不是"最优"。
2.3 两种常见的优化目标变体
在工程上,内存优化调度还会根据目标不同分岔出两种形态。
第一种是给定内存上限,求可行解。比如设备只有 2GB 显存,问某个模型能不能跑。这时候调度器要回答的不是"峰值最小是多少",而是"是否存在一个顺序,让峰值不超过 2GB"。如果存在,再考虑延迟;如果不存在,就需要引入重计算或者算子拆分。
第二种是不设硬上限,直接最小化峰值。这对应标题里的内存优化调度程序。这类调度器适合显存紧张但还没到完全跑不动的场景,目标是尽量压低峰值,减少内存换入换出的概率,或者干脆为更大 batch 腾出空间。
我在这个调度器里没有引入重计算机制,因为重计算是用额外算力换内存,会增加延迟。调度顺序本身是零额外计算开销的,这是它最划算的地方。
3. 调度程序的核心机制:贪心规则、释放收益与模拟调度
3.1 每一步选谁:从"可执行集合"到"净释放收益"
调度器的主体是一个循环:每次从当前所有可执行算子中挑一个执行,更新状态,然后继续下一个。问题的关键就是"挑哪一个"。
我采用的规则是看净内存变化量。对于每个候选算子,执行它会导致两件事发生:它的输入张量里那些"最后一个消费者就是自己"的张量被释放;同时它的输出张量被创建,成为新的活跃张量。
于是可以定义一个净释放收益:
freed = 所有在本算子执行后 refcnt 归零的输入张量大小之和 created = 本算子所有输出张量的大小之和 net_mem_change = created - freed注意这个值如果为负,说明执行这个算子会让内存下降;如果是正,说明会让内存上升。贪心调度器优先选择 net_mem_change 最小的算子,也就是让当前活跃集合缩得最狠的那一个。
举个例子。现在有两个候选算子:
- 算子 P 消费一个 500MB 的张量,输出一个 100MB 的张量,净变化 100 - 500 = -400MB;
- 算子 Q 消费一个 50MB 的张量,输出一个 200MB 的张量,净变化 200 - 50 = +150MB。
显然应该先执行 P。先跑 P 可以让内存先降下来,然后再跑 Q 时就有了更多余量。这个逻辑和一个人整理房间一样:先把最大的杂物扔掉,再拆零碎的东西,空间才腾得出来。
3.2 光看释放量不够:关键路径与饥饿问题
如果只按"谁降内存多谁先跑"来排序,很快会发现一个问题:某些算子虽然输出很小,但位于关键路径上,后面一大串节点都在等它。把它无限搁置,内存峰值可能降下来了,但整个图执行时间被拉得极长,得不偿失。
我实际处理方法是给每个候选算子算一个综合分数,由三部分构成:
- 净释放收益:权重最大;
- 关键路径长度:越靠关键路径,越应该提前;
- 一个稳定排序键:避免两个算子分数完全相同造成不可复现。
具体实现时,我用的是优先级队列,排序键直接取一个元组,例如(-net_mem_change, -critical_path_len, node_id)。这里critical_path_len表示从当前节点到图终点最长路径上剩余的计算规模。
经验上,内存收益和关键路径的权重可以达到 8:2 甚至 9:1。纯内存优先适合离线推理,延迟敏感的在线推理场景需要把关键路径权重调高一些。
3.3 模拟调度要维护什么:refcnt、live set 与峰值更新
为了找到一个好的执行顺序,调度器需要先"凭空模拟"整个执行过程。它不真正申请显存,只维护三样东西:
- 每个张量当前的 refcnt;
- 当前活跃张量集合 live set;
- 全局峰值 peak。
模拟的核心逻辑可以写成下面这层伪代码。它也是我后续实现的基础:
def simulate_peak_memory(graph, order): refcnt = {t: len(graph.consumers[t]) for t in graph.tensors} live = {} peak = 0 for node in order: # 释放阶段:消费输入,可能让某些张量归零 for t in node.inputs: refcnt[t] -= 1 if refcnt[t] == 0: live.pop(t, None) # 分配阶段:输出张量进入活跃集合 for t in node.outputs: live[t] = t.size current = sum(live.values()) peak = max(peak, current) return peak注意顺序:先释放输入,再分配输出。如果一个算子是 in-place 类型,输入和输出共用同一块内存,那么实际上不应该先释放再分配,而是直接保持大小不变。这个细节在模型层面必须被处理掉。
3.4 和最优解到底差多少:小图上的暴力验证
贪心调度器写出来以后,我最关心的问题是:它离最优峰值到底差多远?
做法很简单:在节点数少于 10 的小图上,直接枚举所有合法拓扑排序,求出精确的最优峰值;再跑贪心调度器,看两者的差距。
我跑了一批随机图,规律是:大多数图差距在 5% 以内,少数几个专门构造出来的反例会差到 20% 以上。反例的共同特点是:存在两个大小相近的大块分支,贪心选错了先执行哪一个,导致两块大内存同时存活。
所以我的结论是:贪心算法不是银弹,但工程上足够可靠。如果之后需求更极端,可以考虑加一层局部的回溯搜索,或者在贪心基础上对 top-k 个候选做一步前瞻。后者是性价比很高的改进。
4. 工程落地时那些调度器管不了的现实约束
4.1 显存碎片:逻辑顺序对了,物理内存不一定拼得回来
调度器优化的是"逻辑活跃总量",但真实显存分配是按块来的。即使两个张量在逻辑上没有同时存活,它们在物理地址上也可能分布在不同的内存块里,中间夹着细小的空洞。
我踩过一个很典型的坑:一个图经过调度后,模拟峰值只有 1GB,但实际运行仍然在申请一块 400MB 的大内存时失败。原因就是前面的小张量分配把内存切碎了,大块连续空间不够。
解决办法通常是把小张量放进统一的内存池,按对齐尺寸分桶管理;或者干脆一次性预留一块大 arena,在 arena 内部做偏移分配。我的做法是给调度器加了一层"分配后处理":调度结果只决定每个张量的生命周期区间,真正的物理地址由 arena 分配器负责安排,两者各司其职。
4.2 算子自身的隐藏约束:in-place、副作用和不可重排的节点
计算图的 DAG 依赖只是第一层约束,实际运行时还有一堆隐性约束。最典型的是 in-place 算子,输入输出共用同一块 buffer。对这类算子来说,调度器如果机械地先释放输入再分配输出,等于让一个本来不增加内存的算子凭空多出一块峰值。
还有一类算子带有副作用,比如随机数生成、统计量更新、量化参数校准。它们虽然在图结构上可以随便挪位置,但挪动会影响结果精度。调度器需要给这类节点打上"锁"标记,让它们严格保持在原执行位置附近。
我的经验是:在建模阶段就把这些约束折算成图的边。比如 in-place 算子,给它的输入输出之间额外加一条边,人为拉开执行间距,这样贪心过程就不会乱动它。
4.3 动态 Shape 与多方案切换
离线算好的调度顺序有一个前提:张量大小是静态已知的。但在很多真实场景里,输入分辨率、 batch 大小都是变化的,同一模型在不同输入下的激活值大小可以差好几倍。一个在 512×512 输入下调出的最优顺序,到了 1024×1024 时未必最优。
工程上我采用的策略是分桶预计算:按输入尺寸分成若干个区间,每个区间离线算一个调度方案,运行时根据实际输入选择最接近的桶。如果输入变化特别频繁,也可以做成运行时轻量贪心。我用 C++ 实现过一次,模拟调度几百个节点的图只需要几十微秒,性能完全不是问题。
4.4 实测收益:调度器不是万灵药
任何优化手段都有边界。我给一个解码器模型的计算图做模拟,默认层序的峰值是 2.9GB,调度后降到 1.8GB,降幅接近 38%。但同一套调度器用在另一个已经大量做了算子融合的模型上,收益只有 5% 左右。
原因也很简单:算子融合本身就是一种内存优化手段。两个算子合并成一个,中间张量直接消失,需要调度的对象本来就不多了。所以调度器适合作为融合和量化之外的补充维度,而不是替代品。在真实系统里,我的习惯是先做融合,再做调度,最后看看哪些张量值得重计算。
5. 从零写一个最小内存调度器:核心结构与踩坑记录
5.1 核心数据结构
一个可用的最小实现只需四个类或者结构体:
Tensor:记录大小、生产者、消费者列表;OpNode:记录输入张量、输出张量、关键路径长度、原始执行位置;Graph:把 Tensor 和 OpNode 串起来,提供拓扑遍历接口;MemoryScheduler:负责维护 refcnt、live set、优先队列并产出最终顺序。
设计时有一个很重要的点:Tensor 必须用唯一的 ID 标识,不能用名字。同一个名字在不同分支里可能对应不同张量,靠名字做 refcnt 一定会出错。这个错误非常隐蔽,我调试了很久才定位到。
5.2 调度循环:Ready 堆、refcnt 更新与收益计算
下面是一个可以在原型阶段跑通整个流程的 Python 风格实现。它假设图已经完成静态 shape 推断,每个张量都有size_bytes字段。
import heapq def schedule(graph): indegree = {n: len(n.inputs) for n in graph.nodes} refcnt = {t: len(t.consumers) for t in graph.tensors} ready = [] for n in graph.nodes: if indegree[n] == 0: # 优先级:净释放收益越大(net越小)越靠前 net = n.output_size() - n.freed_size(refcnt) heapq.heappush(ready, (net, -n.critical_path_len, n.id, n)) live = {} peak = 0 order = [] while ready: _, _, _, node = heapq.heappop(ready) order.append(node) for t in node.inputs: refcnt[t] -= 1 if refcnt[t] == 0: live.pop(t.id, None) for t in node.outputs: live[t.id] = t.size_bytes for succ in node.successors: indegree[succ] -= 1 if indegree[succ] == 0: net = succ.output_size() - succ.freed_size(refcnt) heapq.heappush(ready, (net, -succ.critical_path_len, succ.id, succ)) peak = max(peak, sum(live.values())) return order, peak这里的freed_size是动态计算的:累加所有输入中 refcnt 即将归零的张量大小。注意必须在执行该算子之前计算,因为执行后 refcnt 会被更新。
5.3 怎么验证写对了:用生命周期日志观察释放点
写完调度器后,建议先在小图上做一次验证,别直接上大模型。我的方法是构造一个不超过 8 个节点的树形图,手动给出每个张量的大小,然后跟基线顺序(比如层序)对比峰值。
验证的时候不只比较峰值数字,还要打印每个时间步的 live set。重点看两个地方:
- 每个张量必须精确地在最后一个消费者执行完时消失;
- 峰值出现的时间点是否和预期一致,如果峰值出现在一个奇怪的中间步骤,说明某个大张量被过早创建或者过晚释放。
把这个日志和模拟峰值函数放在一起,很多问题一眼就能看出来。
5.4 我踩过的坑:按大小贪心、忽略常量节点和重复张量
最后集中写几个我在实现过程中真实踩过的坑。
第一个坑是只按输出大小排序。一开始我以为大张量输出者应该靠后执行,小张量靠前执行,结果峰值不降反升。因为输出小的算子可能消费一个巨大的输入,把它延后执行反而让那个大输入活得更久。改成"净释放收益"排序之后才正常。
第二个坑是张量 ID 冲突。图解析阶段如果只是简单地用"节点名_输出序号"作为 Tensor ID,不同子图之间可能出现重复。refcnt 一旦统计错,所有调度结果都是错的。
第三个坑是纯视图算子。reshape、transpose 这类算子不产生新内存,但会延续某些张量的生命周期。把它们当成普通算子处理会导致生命周期被错误缩短,后续算子对已经被释放内存的访问就成了悬空访问。
第四个坑是常量节点。常量在推理阶段通常常驻内存,不应该参与释放收益计算。如果建模时把常量也放进 live set,调度器会把大量精力花在"安排先加载哪个常量"上,毫无意义。正确的做法是单独建一个常驻表,和中间张量分开统计。
这些都处理完之后,调度器才算真正能放到实际模型中跑。我最后的一个体会是:调度优化最值得投入的时间其实不在算法本身,而在把张量生命周期定义得准确、把设备的物理分配模型还原得足够真实。顺序本身只是决策结果,生命周期和分配模型才是地基。