想象你要搬家。所有家当要先装进一辆货车运到新家,装车的人必须反复盘算:什么时候车厢里同时堆的箱子最多?如果那一瞬间超了载重,整个搬家计划就得重来——要么少带东西,要么把大件拆开分几趟。 AI 训练就是一场持续数小时甚至数天的“搬家”。显卡的内存是车厢,每一步计算都会产生一些暂存的“纸箱”,用完后又被扔掉。纸箱不会同时都在车上——有的已经送进“新家”(释放),有的还等着下一趟计算(存活)。“车厢最重的那一刻”,就是计算机要应付的压力巅峰。 这篇文章用“搬家装车”的思路,带你搞懂三件事:怎么靠搬家清单提前算出最危险的时刻(静态估算)、怎么在地磅上实测验证(profiling 工具)、以及怎么拆箱换包装化解风险(优化策略)。看懂峰值,你就看懂了显存规划的一半。
掌握峰值内存分析,你可以在训练前预判显存瓶颈,在 OOM 发生时快速定位元凶,把显存规划从“碰运气”变成“算得清”——这是大模型时代 AI 工程师的基本功。
可视化演示
展示计算图张量生命周期与峰值内存估算、激活值检查点优化及内存复用策略,交互切换查看峰值定位与减负方法。
本节配有交互动效(进入视口后自动加载,约 3KB)。
生命周期:张量时间区间与峰值 张量从创建到消费的存活区间 共存张量叠加形成峰值区间 M_peak=max_load(t) 拓扑序估算峰值显存
内存基本概念
搬家之前,你得先把家当分成两堆:证件、首饰这类要一路贴身带的,和装进纸箱、随取随用的杂物。AI 程序的显存占用也天然分两拨:一拨从程序启动到结束都赖在显存里不走,另一拨随计算任务来了又走。
前者叫静态内存,装的是模型参数、嵌入表、常量缓存;后者叫动态内存,装的是激活值、中间结果和梯度。想算清楚“货车最重的那一刻”,先得给每一箱标好重量——也就是算出一个张量到底占多少显存。
张量占用的字节数只由两个因素决定:元素总数和单元素字节数。元素总数等于形状各维度的乘积,单元素字节数由数据类型决定——FP32 每元素 4 字节,FP16 和 BF16 只有 2 字节。
这就好比纸箱的重量等于体积乘纸板密度。先给每只纸箱称好重,后面的装车模拟才有依据。静态内存像一个永远占着车厢角落的大衣柜,动态内存才是不断上下车的那些箱子——两者的称重规则,用同一杆秤就能称。
- 元素总数 NN 等于张量各维度大小的乘积,形状为 (C,H,W) 的张量共有 C×H×W 个元素。
- 单元素字节数 bb 由数据类型决定,FP32 取 4 字节,FP16/BF16 取 2 字节。
- 张量总字节数 S元素总数乘以单元素字节数,得到该张量在显存中的占用字节数。
峰值内存定义
装车时你会发现,车厢里的纸箱数量忽多忽少:这箱刚搬上车,那箱已经被搬进新家。最危险的时刻,不是今天总共搬了多少箱,而是某一瞬间车厢里同时堆着多少箱——再多一箱,货车就可能压垮。
计算图执行也一样。每一步算子执行都会做两件事:把不再需要的张量释放掉(纸箱下车),把新算出的张量分配出来(纸箱上车)。“存活”张量就是那些已上车还没下车的箱子,它们的字节数之和就是这一瞬间的显存占用。
把每一步的瞬时占用画成一条曲线,曲线的最高点就是峰值内存。它决定了你的卡够不够用,也决定了 batch size 和模型规模的天花板。
峰值曲线就像车厢载重的“心电图”:心跳最高的那一下,才是判断会不会超载的唯一标准。抓不住它,前面搬得再顺利也是白费。
- 存活张量集合alive(t) 表示第 t 步仍占用显存的张量集合,张量从被分配到释放的区间都属于它。
- 瞬时内存求和把集合内所有张量的字节数 S_i 加起来,就是这一步的瞬时显存占用。
- 取最大值遍历全部时间步,最大的 M(t) 就是峰值内存。
拓扑序估算
搬家有铁一般的顺序:先搬床架才放得下床垫,先装灯泡才能通电试灯。顺序乱了,后面全得返工。计算图也有这样的先后约束——一个算子的输出,必须等前面的算子算完才能出现。
把这种依赖关系展开成一个合法的执行序列,就是拓扑排序。有了这个序列,我们就能像看装车录像一样,从头到尾推演每一步哪些张量下车、哪些上车,算出每个时刻车厢里的总重量。
推演的规则只有两条:执行某算子时,新输出张量上车;某张量最后一次被消费后,立刻下车。内存的递推式写成公式就是这样。
这张“装车时间表”就是静态估算的核心。不需要真卡、不需要真跑,拿一张纸就能预判最危险的时刻——搬家公司最喜欢这种提前量。
- 上一步内存M(t_{k-1}) 是第 k 个算子执行前一刻的瞬时内存。
- 释放集合free(t_k) 是第 k 步执行完成后被释放的张量,它们的最后一次消费都发生在该步。
- 分配集合alloc(t_k) 是本算子新产生的输出张量,它们从该步开始存活。
- 递推与峰值新内存 = 旧内存 - 释放 + 分配,遍历所有步骤取最大即为峰值。
Profiling工具
清单估算再准,也怕路上出意外:道路颠簸、箱子移位、临时加货。搬家公司最后都会在车上装地磅,每搬一箱称一笔,事后回放数据看车厢哪一段最重。
Profiling 工具就是训练时的“地磅”。它记录每一次显存的分配和释放事件,关联到具体算子,画出一条真实的显存占用时间线。它与静态估算互补:前者给理论值,后者给实测值。
常用工具三件套:PyTorch Profiler 记录算子级显存事件并输出 trace 时间线;NVIDIA Nsight Systems 提供 kernel 级视图;Nsight Compute 深入单个 kernel 分析资源占用。注意,地磅本身也有重量——profiling 是有开销的。
多花几分钟称重,换来的是“知道哪一刻超载、是谁造成的”。这笔账怎么算都划算——总比到了半路货车抛锚(OOM)再回头强。
- 基线时间T_baseline 是不开启任何 profiling 时训练循环的执行时间。
- 带分析时间T_profiled 是开启 profiling 后同样循环的执行时间,包含记录开销。
- 开销率两者之差除以基线时间并换算成百分比,得到 profiling 的额外开销比例。
峰值来源分析
回放地磅数据时你会发现,货车最重的那一刻,往往是几件大家具恰好挤在一起:大沙发、大冰箱、大衣柜同时占着车厢。峰值来源分析,就是找出这些“大家具”是谁。
计算图里的“大家具”主要有三类。头号嫌疑是尺寸巨大的激活值张量:Transformer 的中间特征动辄形状为 B×L×H,单张就占几个 GB。
第二个嫌疑是中间结果堆积:有些框架或实现把中间值缓存下来不释放,本该下车的纸箱赖在车上不走。第三个是显存碎片化:小块分配释放把显存切得零零碎碎,空间够但不连续,大张量塞不进去,占用率虚高。
把几只“大家具”从车上请下来各归其位,峰值自然回落。对症下药的前提,是先认出它们是谁——这就是来源分析的价值。
- 批量大小 B一次输入模型的样本数,是激活张量的第一维。
- 序列长度 L文本或序列的 token 数,是激活张量的第二维。
- 隐藏维度 HTransformer 隐藏层宽度,是激活张量的第三维。
- 相乘再乘字节数 b三个维度乘积乘以 b,得到单个激活张量的显存占用。
优化策略选择
知道超载原因后,搬家公司有三板斧:大件拆开分批运、腾出空间先搬别的、用轻便的压缩包装替代笨重木箱。AI 训练里对应的招数叫梯度重计算、内存复用、混合精度。
梯度重计算(又称激活检查点)不再保存每一层激活值,只保存少量检查点,反向传播时把中间的激活重新算一遍——用算力换显存。内存复用让生命周期不相交的张量共享同一块显存;混合精度把 FP32 降到 FP16/BF16,字节数直接减半。
以重计算为例,把计算图切成 K 段,只保留每段输入的检查点。激活内存从“全存”变成“存一段加检查点”,峰值大幅下降,代价是训练时间通常增加 20%~30%。
拆开的沙发到新家还得重新组装,多花力气但车不超载。用时间换空间,是显存紧张时最实惠的买卖。
- 原始激活内存M_act 是不采用重计算时缓存全部激活值所需的显存。
- 分段数 K把计算图切成 K 段,只保存每段的输入作为检查点。
- 检查点内存M_ckpt 是全部检查点张量占用的显存总和。
- 重计算后内存任意时刻只有一段激活在内存中,总占用约为 M_act/K 加检查点。
规划与验证
靠谱的搬家公司出发前会做载重预算:按清单算出最大载重,再加两成余量决定租多大的车。到新家后还要复核家具是否完好、实际载重是否和预估一致。
内存规划也一样:先用静态估算(或上次 profiling 结果)得到峰值,乘上安全系数得到显存预算,据此决定 batch size、序列长度和模型规模能不能塞进一张卡。安全系数通常取 0.2~0.3,用来兜住框架缓存和碎片化等意外。
更重要的是一条纪律:每次修改模型结构或超参后,都要重新做 profiling 回归对比,确保峰值没有异常抬升。预算不是一次性作业,而是伴随训练的长期账本。
留余量、做复盘,搬家和训练才能都不“翻车”。峰值内存分析不是一次测量,而是一套贯穿开发始终的工作流。
- 峰值估算M_peak 来自静态拓扑序推演或上一次 profiling 结果。
- 安全系数α 是预留余量比例,通常取 0.2~0.3,覆盖碎片化与框架缓存。
- 最终预算预算等于峰值乘上 (1+α),用来决定 batch size 和模型规模。
AI 应用场景
大模型训练前的显存预算评估
阶段:训练规划与资源配置
解决的问题:传统做法是“先租卡再试”,batch size 开多大全凭经验,经常出现训练一启动就 OOM,浪费算力和时间。用峰值内存分析,在写训练代码前就能按拓扑序估算各层激活内存,乘上安全系数得到预算。
具体案例:某团队要训练 7B 参数 LLM,先用分析脚本模拟前向/反向的峰值,发现序列长 4096、batch=8 时峰值约 62GB,超过单卡上限;于是把 batch 调到 4 并启用梯度检查点,估算峰值降到 52GB,一次通过,省下多次试错成本。
训练 OOM 故障的快速定位
阶段:训练问题排查
解决的问题:训练中途 OOM,报错只告诉你“显存不足”,不告诉你哪一步、哪个算子。用 profiler 抓完整时间线,对比每一步显存曲线,立刻能看到峰值出现在哪个算子。
具体案例:某 NLP 训练在第 47 步 OOM,用 torch.profiler 抓 trace 后发现峰值来自 attention 的中间 QKV 拼接张量——一个 3.2GB 的临时大激活值没有被及时释放。定位后优化算子执行顺序并做内存复用,峰值下降 18%,训练平稳跑完。
推理服务的 batch 上限调优
阶段:推理部署优化
解决的问题:在线推理服务想在显存允许范围内尽量加大 batch 提吞吐,但盲目调大会导致 OOM 崩掉整个服务。用峰值内存分析算出每个请求的增量内存,设置安全 batch 上限。
具体案例:某文本生成服务用峰值分析脚本绘制“batch 大小 vs 峰值显存”曲线,发现 batch=8 时峰值 11GB,batch=16 时跳到 23GB 接近上限,于是把上限设为 12,吞吐提升 40% 且无 OOM。
Python 演示
# -*- coding: utf-8 -*-"""基于拓扑序的峰值内存估算模拟器.用搬家装车思维: 每个算子=一次搬运动作, 张量=纸箱,显存=车厢, 峰值=车厢里纸箱总重量最大的时刻.""" class Tensor: """张量(纸箱): 记录大小和生产者/消费者算子编号""" def __init__(self, tid, size, producer, consumers): self.tid = tid self.size = size self.producer = producer self.consumers = consumers def build_graph(): """构造一个简化计算图, 返回 (算子列表, 张量字典)""" ops = [ (0, [], [0, 1]), (1, [0], [2]), (2, [1], [3]), (3, [2, 3], [4]), (4, [4], [5, 6]), (5, [5], [7]), (6, [6, 7], []), ] sizes = {0: 4, 1: 6, 2: 8, 3: 5, 4: 9, 5: 7, 6: 3, 7: 5} tensors = {} for tid in sizes: producer = None consumers = [] for op_id, ins, outs in ops: if tid in outs: producer = op_id if tid in ins: consumers.append(op_id) tensors[tid] = Tensor(tid, sizes[tid], producer, consumers) return ops, tensors def simulate(ops, tensors): """按拓扑序模拟显存分配与释放, 返回 (快照列表, 峰值)""" last_use = {} for op_id, ins, _ in ops: for tid in ins: last_use[tid] = op_id cur = 0 peak = 0 snapshots = [] for op_id, ins, outs in ops: alloc = sum(tensors[t].size for t in outs) peak = max(peak, cur + alloc) # 执行瞬间: 输入尚未释放 for tid in ins: if last_use[tid] == op_id: cur -= tensors[tid].size # 最后一次消费, 纸箱下车 for tid in outs: cur += tensors[tid].size # 新输出上车 snapshots.append(cur) return snapshots, peak def main(): ops, tensors = build_graph() snapshots, peak = simulate(ops, tensors) print("memory after each op (MB):", snapshots) print("peak memory (MB):", peak) if __name__ == "__main__": main()
last_use[tid] = op_id记录每个张量最后一次被哪个算子消费,决定它的“下车”时机。peak = max(peak, cur + alloc)算子执行瞬间,输入还没释放而新输出已分配,是瞬态峰值最容易出现的时刻。cur -= tensors[tid].size最后一次消费完成后,纸箱下车,显存归还。cur += tensors[tid].size新输出张量上车,显存占用增加。snapshots.append(cur)记录每一步执行后的内存快照,形成可视化曲线。
常见误区
误区我总觉得只要把模型参数、每一层的输出都加起来,就知道需要多大显存了,峰值内存不就是这个总数吗?
正解峰值内存是“同一时刻”同时存活张量的总和,不是所有张量的累计。就像搬家货车永远装不下全部家当,装车时总是一边卸一边装,最重的那一刻可能只有全部家当的三成。若按累计总数规划,你会多租一辆根本用不上的车。
为何常见:人们习惯用“总量”思维理解存储,忽略了张量有生命周期、会随计算释放这个动态事实。
误区显存不够就把 batch 减半,那 60GB 的峰值不就变成 30GB 了吗?
正解大部分激活值的尺寸与 batch 成正比,但模型参数、优化器状态、梯度张量、嵌入表与 batch 无关。就像一个车队里,司机的体重不会因为乘客减半而减半。batch 减半只对激活相关部分有效,对固定部分毫无影响。
为何常见:大家最容易观察到与 batch 相关的激活张量,忽略了显存里还有其他“常住人口”。
误区打开 profiler 记录一下显存而已,应该对训练速度没有影响吧?
正解profiling 要拦截和记录每一次显存分配、释放和算子事件,本身有开销。开销公式 O=(T_profiled - T_baseline)/T_baseline 通常带来 5%~30% 的额外耗时。就像装地磅称重:每箱过磅都要多花几秒,但换来的是精确记录。
为何常见:profiling 的结果看起来像“免费的额外信息”,人们低估了观测行为自身的代价。
误区碎片化不就是显存不足吗?换一张更大的显卡就好了。
正解碎片化指显存总量足够,但空闲块被切得零零碎碎,放不下一个连续的大张量。就像车厢里还有空间,但被小箱子占满隔档,大沙发塞不进去。换更大的车能缓解,但治本要靠内存复用规划和分配器策略。
为何常见:OOM 报错长得一样,人们容易把所有显存问题都归结为“容量不够”。
误区框架不是会自动复用显存吗?那峰值内存分析是不是就没用了?
正解内存复用只能让“生命周期不相交”的张量共享空间。如果两个大张量同时存活,谁也不让谁,复用也无能为力。峰值分析正是要找出那些“同时存活”的组合,才能决定哪些可以复用、哪些需要重计算或降精度。
为何常见:看到框架宣传内存复用,误以为优化是自动且无条件的,忽略了生命周期约束。
面试题
Q1. 峰值内存指的是训练过程的哪一刻?
- A.训练数据全部读取的时刻
- B.反向传播全部结束的时刻
- C.模型参数加载完毕的时刻
- D.存活张量占用显存最大的时刻← 正确答案
答案:D。峰值内存是某一瞬间所有存活张量占用显存的最大值,对应“车厢最重的那一刻”。其他选项都是单一事件时刻,没有考虑“多个张量同时存活”这个动态概念。
Q2. 一个 FP32 张量的元素总数为 1024×1024,它占用多少 MB?
- A.约 16 MB
- B.约 8 MB
- C.约 4 MB← 正确答案
- D.约 1 MB
答案:C。FP32 每元素 4 字节,元素总数 1024×1024=1048576,乘 4 得 4194304 字节 ≈ 4MB。这就像知道纸箱体积和纸板密度,重量一乘便知。
Q3. 拓扑排序对峰值内存分析的主要作用是?
- A.确定算子执行顺序以估算峰值← 正确答案
- B.缩小网络中的参数总量
- C.直接减少模型的参数量
- D.替换计算图的算子类型
答案:A。拓扑排序把计算图的依赖关系展开成合法执行序列,才能模拟张量“何时上车、何时下车”。它不改变参数数量或算子类型,只是确定“搬运顺序”。
Q4. 梯度重计算(检查点)主要降低哪类显存占用?
- A.中间激活值所占用的显存← 正确答案
- B.模型参数所占用的显存
- C.优化器状态所占用的显存
- D.训练样本所占用的显存
答案:A。梯度重计算将激活值分段保存、反向时重算,直接砍掉的是中间激活的显存。参数和优化器状态依然全量常驻,不受影响——就像只拆大件沙发,不动固定家具。
Q5. 静态估算中张量释放的正确时机是其?
- A.被创建出来的那一时刻
- B.网络完成所有训练的时刻
- C.最后一次被消费执行完成时← 正确答案
- D.下一个算子执行的开始时
答案:C。张量在最后一次被消费的算子执行完成后即可释放,这正是拓扑序模拟中“纸箱下车”的判定标准。过早释放会导致数据丢失,过晚释放会虚增峰值。
Q6. profiling 开销公式中 T_baseline 表示的是?
- A.开启分析器后的执行时间
- B.显存分配器的调用次数
- C.模型参数总量的字节数
- D.未开启分析器的基线时间← 正确答案
答案:D。T_baseline 是未开启 profiling 的基线执行时间,T_profiled 是开启后的时间,两者差值比例即开销率。地磅本身的重量只有和空车对比才能算出来。
Q7. 下列哪一项不是显存峰值升高的常见来源?
- A.序列长度很长的大激活值
- B.模型参数被复制到了CPU← 正确答案
- C.未及时释放的中间结果
- D.显存碎片化导致空间不足
答案:B。大激活值、中间结果堆积、碎片化都是峰值升高的典型来源。参数复制到 CPU 反而释放了显存,不会抬高峰值——就像把大件暂时搬下车,车厢反而更空。
Q8. 下列哪些做法能降低训练时的峰值内存?
- A.使用梯度重计算方法← 正确答案
- B.随意增大训练的批次大小
- C.不相交张量内存复用← 正确答案
- D.采用混合精度来减小存储← 正确答案
答案:ACD。梯度重计算减少激活缓存,内存复用让不相交张量共享空间,混合精度让每个张量字节减半,三者都直接压低峰值。增大 batch 会放大激活张量,反而推高峰值。
Q9. 关于内存预算与安全边界,正确的认识有?
- A.预算只需等于模型参数大小
- B.预算应以峰值估算乘以余量系数← 正确答案
- C.安全系数通常选择 0.2 到 0.3← 正确答案
- D.每次改动后需做回归对比验证← 正确答案
答案:BCD。预算 = 峰值 × (1+α),α 取 0.2~0.3 覆盖碎片和框架缓存;每次改动都要回归验证峰值。预算只算参数大小会严重低估——货车不可能只装沙发不装别的。
Q10. 张量的生命周期指的是它从?
- A.开始训练到训练结束全程
- B.被分配到最后一次使用的区间← 正确答案
- C.数据加载到模型输出的过程
- D.被保存到被删除的文件区
答案:B。生命周期 = 从张量被分配(纸箱上车)到它最后一次被使用(纸箱下车)的区间。只有落在生命周期内的步骤,该张量才算“存活”并计入峰值。