林静是城市里一家新开酒店的前台主管。今天她接到一个大任务:一个六人旅行团要入住,但每个人到达和离开的时间都不一样。林静手里只有三间房,她必须保证每位客人在入住期间有房住、不会和别人挤进同一间。她打开行程表一看,发现只要安排得当,三间房完全够用——关键在于提前看清每位客人的「入住时间」和「退房时间」,让从不重叠的客人住进同一间房。 这个场景,就是 AI 编译器里「内存规划」的缩影。深度学习模型在 GPU 上运行时,会不断创建和销毁各种中间数据(专业上叫「张量」),而 GPU 的显存是有限的。内存规划,就是让编译器像前台主管一样,在模型真正运行之前做好一张「排房表」:哪个张量用哪块内存、什么时候可以复用,从而用最少的内存跑完整个模型。本文会用酒店排房这条故事线,带你完整理解内存规划的原理、算法和工程实践。
掌握内存规划,是看懂 AI 编译器后端优化的关键一步。它能帮你定位模型运行时的显存瓶颈,也是推理引擎与训练框架优化岗位的必备技能。
目录
可视化演示
展示Groq LPU三维度:TSP张量流处理器架构(数据流驱动执行模型功能单元MEM/VC/MM/SF/MX流水线230MB片上SRAM确定性执行每token延迟一致)、性能对比(LPU推理700+token/s单卡超越H100 8卡约7倍优势确定性延迟P99约等于平均延迟GPU因缓存未命中P99大于平均)、多芯片扩展(多LPU通过高速SerDes互联形成逻辑大容量SRAM池4芯片920MB统一SRAM支持更大模型保持确定性推理),三模式切换理解Groq LPU推理加速架构。
本节配有交互动效(进入视口后自动加载,约 3KB)。
'+C1+'
规划概述:前台手中的行程表
林静的酒店开业第一天,她收到一份特殊的时间表:上面不是客人姓名,而是一串「入住—退房」记录,每一条对应旅行团中的一位成员。如果她不提前规划,就只能等客人到了再临时找房,结果往往是房间不够——多出来的客人只能挤在一起,或者干脆住不下。
在 AI 编译器的世界里,这份「行程表」就是模型的计算图。一个深度学习模型可以看成由许多算子组成的流水线:每个算子读取输入、产生输出,中间结果就是张量。张量和酒店客人一样,有自己的生命周期:在某个算子的输出位置诞生(创建),在最后一个用到它的算子执行完毕后消亡(不再需要)。
为什么需要规划?因为 GPU 显存是稀缺资源。一块高端消费级显卡只有十几 GB 显存,而稍大一点的模型,权重和激活值就可能占满好几个 GB。如果每个张量都「从一而终」地独占一块内存,整个模型根本跑不起来。这就像林静如果不看行程表就给每个人都开一间房,她至少需要六间房,而手里只有三间。
内存规划因此要回答三个问题:每个张量什么时候出生、什么时候死亡;哪些张量可以共享同一块内存;如何安排让模型运行期间的「峰值内存」(任意时刻占用内存的最大值)尽量小。这三个问题,正是林静的故事接下来要展开的核心。
张量生命周期:客人的入住与退房
林静的排房技巧只依赖两件事:客人什么时候到、什么时候走。只要把这两条信息搞准确,后面的安排都很自然。AI 编译器同样如此:计算每个张量的「入住时间」和「退房时间」,这一步通常叫「活跃区间分析」或「生命周期分析」。
先说「入住时间」。张量总是由某个算子产生的,比如卷积层输出的张量,在卷积算子执行完的那一刻就诞生了。它的「生日」就是定义它的算子在执行序列中的位置。这个位置越靠前,它占内存的时间就开始得越早。
再说「退房时间」。张量一旦被后续某个算子读取,就算「被使用」。有的张量只被用一次,用完就再也没人碰;有的会被多个算子接力使用。生命周期终点不是「被释放的那刻」(编译器通常没有显式 free),而是「最后一次被使用的时刻」。这就像酒店的退房时间,是客人最后一次出现在房间的截止点。
如果一个张量的定义点很靠前、最后一次使用点很靠后,它的生命周期就长,意味着它长时间占着内存。反之,那些「用一次就扔」的临时结果,生命周期很短,是内存复用的最佳候选。
回到酒店的例子:张量的活跃区间画在时间轴上就是一根横条。林静把这些横条并排贴在前台墙上,一眼就能看出什么时候最拥挤。AI 编译器同样会把每个张量的区间记录下来,作为下一步内存复用的输入数据。
- 定义点 \(\text{def}(t)\)张量 \(t\) 出生处的执行位置,通常是产生该张量的算子编号
- 使用集合 \(uses(t)\)所有会读取张量 \(t\) 的算子组成的集合
- 最后使用位置取所有使用位置序列的最大值,即最后一个使用它的算子的位置
- 合成区间以左端点为创建点、右端点为最后使用点,组成闭区间表示生命周期
内存复用:让不相遇的客人共享房间
林静发现了一个省钱秘诀:既然客人 A 上午退房、客人 B 下午才到,那他们可以住同一间房。只要两段时间不交叠,房间就能「一房两用」。在内存规划里,这个秘诀叫「内存复用」:只要两个张量的生命周期没有交集,它们就可以被分配到同一个内存地址。
怎么判断有没有交集呢?两个区间,要么一个在左、一个在右完全不相接,要么互相穿插。判断规则很简单:如果张量 \(i\) 的终点还没到,张量 \(j\) 的起点就已经来了(即 \(i\) 的死期早于 \(j\) 的出生),二者就能复用;反之,只要有哪怕一瞬间的重叠,就必须各占一块。
当把「复用关系」画成图——每个张量是一个点,如果两个张量不能复用就在它们之间连一条线——我们就得到了区间图。给这些点染色(每种颜色代表一块内存),要求相邻的点颜色不同,最少的颜色数就是最小内存块数。这恰好是经典的「区间图着色」问题。一般图的着色是 NP 难问题,但区间图有个漂亮性质:按左端点排序后贪心着色,颜色数恰好等于任意时刻最大重叠深度。
林静在排房表上做标记:把能共享房间的客人名字写在同一个格子里。内存规划器做的事几乎一模一样——它并不真的为每个张量分配显存,而是在逻辑上决定「谁和谁住同一个地址」,等到运行时再把地址映射到物理显存上。
- 先死与后生张量 \(i\) 在 \(j\) 出生之前就已经死亡,两者从未同时存在,可复用
- 对称情形\(j\) 先死、\(i\) 后生,同样可复用
- 区间有交集剩余情况说明存在某个时刻两者都活跃,必须分别占用内存
- 共享条件满足 \(false\) 条件的张量可以填进同一个内存块
内存池机制:预置的酒店楼层
如果每次有客人入住,林静都临时去装修一个新房间,那效率太低了。现实的做法是:酒店本来就有固定的楼层和房间,前台只要在楼层平面图上做标记,告诉保洁阿姨哪间房免费、哪间房有人。AI 编译器里的「内存池」(arena)就扮演这个楼层平面图的角色。
通常的做法是向 GPU 驱动申请一块很大的显存(比如 1GB),这块显存叫作「内存池」。之后所有张量都在这块池子里切分,而不是频繁调用底层分配接口。为什么这样好?底层显存分配每次都要经过驱动、可能触发同步,非常昂贵;而且大量小块的频繁申请容易造成显存碎片。内存池相当于一次批发、长期零售,既快又整齐。
内存池内部由「块」(block)组成。规划器维护一个空闲块列表:当一个张量死亡,它占的块归还到空闲列表;当一个新张量诞生,优先从空闲列表里找一块足够大的块分给它,找不到才从池子尾部开辟新块。最常见的策略是「首次适配」(first-fit):从头到尾扫一遍空闲列表,遇到第一块装得下的就用。
这正好回答了前面埋下的伏笔——生命周期分析算出「谁什么时候空闲」,内存池则负责「空闲块怎么高效周转」。两件事合起来,才构成完整的内存规划方案。林静的房间从不浪费,正是因为她的楼层平面图上永远标注着每个房间的当前状态:有人、空闲、或即将空闲。
峰值内存优化:三间房住六位客人
林静的最终目标很明确:把「任意时刻被占用的房间数」尽量压下来。这个最大值,就是酒店运营中的「峰值房量」。对应到内存规划里,就是峰值内存(peak memory)。降低峰值,意味着可以用更小的显存跑更大的模型。
先给峰值一个严格定义:在模型执行的每一个时刻,把当前所有活跃张量的大小加起来,取所有时刻的最大值。注意,不是把所有张量加起来——因为复用让很多张量错开了时间。林静手里三间房能住六个客人,正是因为「同时活跃的客人」从来没超过三个。
峰值优化有三大常见武器。一是「重计算」:有些中间张量先不用内存保存,等要用了再重新算一遍。这是典型的「用时间换空间」,适合那些计算快但占地方大的结果。二是「算子融合」:把连续的几个算子合成一个,让中间结果只在片内传递、不落显存。三是「层间交换」:把暂时用不到的张量从 GPU 显存搬到 CPU 内存,用到时再搬回来,相当于把酒店的行李房变成临时卧房。
林静看着最终排房表满意地点点头:六个客人、三间房,每个时刻房间里最多住着三个人。峰值优化不是魔法,而是把「重叠」这个敌人一个个拆掉——把重叠的区间错开、把高峰期的张量换走、把反复使用的中间值重算。内存规划的艺术,就是让大楼永远不拥挤。
- 活跃集合 \(\mathcal{A}(t)\)在时刻 \(t\) 仍处于生命周期内的张量集合
- 瞬间占用量把活跃集合中每个张量的大小相加,得到该时刻的总占用
- 遍历取最大对所有时刻 \(t\) 遍历求最大值,得到峰值内存 \(P\)
规划算法:一张排房表的诞生
最后,让我们看看林静(或者说 AI 编译器里的规划器)到底是怎么一步步做出这张排房表的。整个过程遵循一个朴素的贪心思路:按时间顺序一遍过,能复用就复用。
第一步是「整理行程表」。规划器拿到计算图后,按照拓扑序为算子排序。严格来说,算子本身有先后依赖,这个执行顺序就是张量创建的大致顺序。在这一步,每个张量的生命周期区间被全部算出来。
第二步是「按序处理」:按照张量的创建时间逐一遍历。每遇到一个新张量,先检查有没有「已经死掉的张量」留下了空闲块——如果有,把这些块放入空闲池;然后从空闲池里挑一块分配给新张量。如果空闲池是空的,就新开一块内存。
第三步是「记录与验证」:规划完成后,编译器把所有分配结果(地址、偏移、大小)记录成一张内存布局表,并做一次模拟执行,核对每个时刻的峰值内存是否符合预期。这一步相当于把排房表拿给保洁阿姨核对:每间房在任意时刻最多只有一组住客。
为什么贪心在这里有效?因为「排序后从前到后」的处理方式天然契合区间着色的规则——区间图着色问题按左端点排序后贪心即为最优。这也是区间图最可爱的地方:复杂的问题,在正确的顺序下变得简单。
林静合上排房表,六位客人的行程被安排得井井有条。AI 编译器里的内存规划器,做的正是这样一件事:把乱糟糟的中间结果,变成一张优雅的内存地图。
- 空闲块池 \(free\_blocks\)已结束生命周期的张量归还的内存块集合,等待复用
- 首次适配选择从池中按顺序找到第一块大小足够的内存块,分配给当前张量
- 兜底开辟新块若池子为空或没有够大的块,就在池子末尾追加新块
AI 应用场景
推理引擎的显存规划
阶段:编译期优化 / 运行期内存管理
解决的问题:传统推理框架每执行一次算子就动态申请显存,malloc/free 调用频繁、开销大,且长期运行会产生显存碎片。AI 在编译期提前算出所有张量的生命周期,规划出固定大小的内存池,运行期一次分配、全程复用。
具体案例:用 ONNX Runtime 部署 YOLOv8 目标检测模型时,启用内存规划后,GPU 显存从「一次推理多次动态申请」变成「启动时一次性分配全局 arena」,推理延迟降低约 10%~20%,端侧显存占用下降 30% 以上。
大模型训练中的激活重计算
阶段:训练图优化 / 显存优化
解决的问题:训练大模型时,每一层前向传播的激活值(中间张量)都要保存到反向传播时使用,这些激活占了训练显存的大头,导致 batch size 上不去。内存规划器会选择性地丢弃部分激活,待反向需要时重新计算。
具体案例:PyTorch 的 Activation Checkpointing(梯度检查点)就是重计算的工程实现。在 GPT 级别的大模型训练中,启用重计算后峰值激活内存下降约 60%,在相同显存下可以把 batch size 翻倍。
边缘设备上的内存受限推理
阶段:交叉编译 / 静态内存布局
解决的问题:MCU 和嵌入式设备的 SRAM 只有几百 KB,一张稍大的张量都可能放不下,必须做极致的生命周期复用。AI 编译器将所有中间 buffer 规划进同一个静态数组中,避免任何动态分配。
具体案例:TFLite Micro 内置了 arena 内存规划器,在 Cortex-M4 设备上把原本需要 1MB 内存的模型压进 256KB SRAM 运行,靠的正是「张量生命周期分析 + 内存复用」这两板斧。
Python 演示
# -*- coding: utf-8 -*-"""简化版内存规划器: 给定张量生命周期, 用贪心复用策略分配内存块""" import heapq class Tensor: def __init__(self, name, size, start, end): self.name = name self.size = size self.start = start self.end = end def first_fit(free_list, size): """找到第一块能装下 size 的空闲块下标, 找不到返回 -1""" for i, (_, block_size) in enumerate(free_list): if block_size >= size: return i return -1 def plan_memory(tensors): """按创建时刻处理: 先回收已结束的张量, 再优先复用空闲块""" by_start = sorted(tensors, key=lambda t: t.start) by_end = [(t.end, t) for t in tensors] heapq.heapify(by_end) free_blocks = [] # (地址, 大小) 空闲块池 alive = {} # 张量名 -> (地址, 大小) 当前占用 next_addr = 0 peak = 0 for t in by_start: # 1. 把所有在 t.start 之前就结束的张量释放 while by_end and by_end[0][0] <= t.start: _, dead = heapq.heappop(by_end) if dead.name in alive: addr, size = alive.pop(dead.name) free_blocks.append((addr, size)) # 2. 优先复用空闲块, 否则开辟新块 idx = first_fit(free_blocks, t.size) if idx == -1: addr = next_addr next_addr += t.size else: addr, block_size = free_blocks.pop(idx) if block_size > t.size: free_blocks.append((addr + t.size, block_size - t.size)) alive[t.name] = (addr, t.size) cur_mem = sum(s for _, s in alive.values()) peak = max(peak, cur_mem) return alive, peak if __name__ == "__main__": tensors = [ Tensor("w1", 512, 0, 3), Tensor("a1", 128, 0, 1), Tensor("w2", 256, 1, 4), Tensor("a2", 128, 2, 3), Tensor("out", 64, 3, 4), ] layout, peak = plan_memory(tensors) for name, (addr, size) in layout.items(): print("{}: 地址 {} 大小 {}".format(name, addr, size)) print("峰值内存: {}".format(peak))
by_end = [(t.end, t) for t in tensors]把所有张量按结束时刻组织成最小堆,方便快速找出「已死」的张量heapq.heapify(by_end)将普通列表转换为最小堆,堆顶永远是最早结束的张量while by_end and by_end[0][0] <= t.start:先处理到期张量,把它们占用的内存块归还到空闲池addr, size = alive.pop(dead.name)从活跃表中移除已死亡的张量,取出它占用的地址和大小free_blocks.append((addr, size))把回收的块放进空闲列表,供后续张量复用idx = first_fit(free_blocks, t.size)尝试从空闲池中找一块足够大的内存块addr = next_addr当没有可复用块时,在池子末尾开辟新块addr, block_size = free_blocks.pop(idx)从空闲池中取出命中的块分配给当前张量free_blocks.append((addr + t.size, block_size - t.size))若空闲块比需求大,把剩余部分放回池中cur_mem = sum(s for _, s in alive.values())统计当前所有活跃张量的总占用,用于更新峰值
常见误区
误区内存规划不就是程序运行时动态申请和释放内存吗?编译器顶多提供一个内存池,跟我们平时写代码用 `malloc` 差不多。
正解内存规划的核心恰恰是「编译期静态分析」:在模型运行之前就算出每个张量的生命周期和复用关系。运行期 malloc/free 开销大且容易碎片化,而预计算的内存布局一次分配、永久复用。林静是在客人到达之前就排好房间,而不是等客人到前台再现场找房。
为何常见:通用软件开发中动态内存管理太常见了,初学者容易把编译器的「规划」误解为运行时的即时行为。
误区只要两个张量的生命周期没有交集,就一定能把它们放到同一块内存地址上,没有任何限制。
正解生命周期不重叠只是必要条件,还有三大约束:一是块大小,后住的张量不能大于空闲块能容纳的空间;二是对齐要求,某些算子要求 16 字节或 32 字节对齐;三是分配顺序,先分配的大块可能把空闲区域切碎,导致后续复用失败。规划器必须在这些约束下寻找可行分配。
为何常见:区间图着色的理想模型忽略了物理内存的连续性和对齐限制,把抽象模型当成了真实硬件。
误区模型有这么多中间张量,显存需求肯定是把它们全部加起来,所以模型越大、需要的显存越高,线性增长。
正解这是最直接的直觉误区。内存复用的存在使得同一块地盘在不同时刻可以住完全不同的张量,所以峰值远小于总和。酒店有六位客人但只有三间房,只要不同时入住就行。合理的生命周期分析和复用可以让峰值内存下降数倍。
为何常见:把「并发需求」误当成「串行需求」,忽略了时间维度在内存分配中的决定性作用。
误区重计算能减少显存,那就在所有地方都用重计算好了,反正不用白不用,内存一定越低越好。
正解重计算的本质是用时间换空间,不存在免费午餐。它增加了算子执行次数,可能拖慢训练或推理速度。正确的做法是只在「计算便宜但存储昂贵」的地方使用,例如激活值、中间特征图,并结合计算开销做成本权衡。林静不会为了省房间,让客人每天上下楼跑十趟。
为何常见:只看到显存收益,忽略了「时间预算」同样是稀缺资源,把空间优化当成无代价的福利。
误区我的显卡显存很大,模型跑得动,内存规划对我来说没意义,只有边缘设备才需要关心。
正解内存规划在资源充足的场景同样重要。更低的峰值内存意味着可以把 batch size 调大、把模型做得更大、减少设备间数据传输。在多租户 GPU 场景下,低峰值内存直接转化为更高吞吐。三间房规划得好,也许还能再接一单生意。
为何常见:初学者常把优化当作「救火」工具,而忽略了它作为「提效杠杆」在日常场景中的恒定价值。
面试题
Q1. 内存规划的首要输入是什么?
- A.训练数据集的样本总数
- B.每个张量的生命周期区间← 正确答案
- C.图形处理器的架构与主频
- D.编译器源码的版本号
答案:B。内存规划的输入是每个张量的生命周期,即它的创建点和最后使用点。GPU 架构、训练集、编译器版本都不是规划的直接输入,它们只是影响性能的辅助信息。
Q2. 张量 t 的活跃区间通常定义为?
- A.从定义点到第一次使用处
- B.从首次使用点到释放位置
- C.从创建到最后一次使用点← 正确答案
- D.从算子融合到显存回收
答案:C。活跃区间是左端点为创建点、右端点为最后使用点的闭区间。第一次使用点不准确,因为张量在创建到第一次使用之间也占内存;释放位置一般不由编译器显式管理。
Q3. 两个张量生命周期不重叠意味着?
- A.它们可以共享同一块内存← 正确答案
- B.它们的大小必须完全相同
- C.它们必须同时驻留显存
- D.它们不能出现在同一张图
答案:A。生命周期不重叠说明它们在时间轴上没有交集,可以复用同一块内存。大小不同也可以通过分块调整适配,无需完全相同;不重叠也不代表不能共存于同一模型。
Q4. 内存规划的核心优化目标通常是?
- A.最大化 GPU 核心利用率
- B.最小化峰值内存占用← 正确答案
- C.最小化算子执行次数
- D.最小化模型参数量
答案:B。内存规划追求的是任意时刻内存占用最大值尽可能小,即峰值内存最小化。GPU 利用率是调度器关心的事,算子和参数量属于模型结构层的问题。
Q5. 内存池(arena)相比直接 malloc 的主要优势是?
- A.预分配大块并复用来减少开销← 正确答案
- B.每次分配都经过底层驱动更可靠
- C.可以自动取消生命周期的分析
- D.能让显存容量无限制扩展
答案:A。内存池一次批发、长期零售,避免频繁调用底层驱动分配接口,也减少了碎片。它并不能取消生命周期分析,也不能让显存变大,反而更依赖预分析的准确性。
Q6. 「重计算」优化策略的本质是?
- A.用额外内存换取计算速度
- B.用额外计算换取内存空间← 正确答案
- C.减少训练过程的迭代步数
- D.降低权重数值的存储精度
答案:B。重计算的核心是丢弃部分中间张量,待需要时重新计算,这是典型的「时间换空间」。它并不减少迭代,也不改变权重精度,而是主动用计算开销换取更低的峰值内存。
Q7. 下列哪些属于降低峰值内存的常用手段?
- A.生命周期复用← 正确答案
- B.激活值重计算← 正确答案
- C.张量换出到 CPU← 正确答案
- D.增大 Batch Size
答案:ABC。生命周期复用让不重叠的张量共享内存,重计算用时间换空间,换出则把暂时不用的张量转移到 CPU 内存——三者都能降低 GPU 显存峰值。增大 batch size 会增加活跃张量数量,反而让峰值更高。
Q8. 将张量看作区间后,内存复用问题等价于?
- A.背包问题的最优求解
- B.最长公共子序列问题
- C.区间图的最少着色问题← 正确答案
- D.最大流问题的最小割
答案:C。每个张量是一个区间,不能复用的张量之间连边,最少内存块数就是区间图的最小着色数。背包、子序列、最大流虽然也是经典问题,但描述的不是区间重叠与着色结构。
Q9. 关于两个张量 i 和 j 能否复用,下列说法正确的有?
- A.end_i 早于 start_j 时可以复用← 正确答案
- B.end_j 早于 start_i 时可以复用← 正确答案
- C.两个区间有交集时不可复用← 正确答案
- D.复用后大小差异毫无影响
答案:ABC。A 和 B 分别对应两种「先死、后生」的对称情形,满足其一即可复用;C 是重叠检测的直接结论。D 错误,因为空闲块必须能容纳新张量的大小,大小差异会通过分块或首次适配策略影响结果。
Q10. 在区间图着色模型中,按起点排序贪心分配内存块会得到?
- A.任意情况下都得到最差结果
- B.必须进行回溯搜索才能修正
- C.只能用于等大张量的场景
- D.得到等于最大重叠深度的最优着色← 正确答案
答案:D。区间图是完美图,按左端点排序后贪心着色,颜色数恰好等于最大重叠深度,这是区间图的最优解。不需要回溯,也不要求张量等大,虽然真实工程中还有对齐等额外约束,但着色模型本身贪心可达最优。