深蓝黑背景封面。左侧是一根竖向堆叠的显存账柱,总高标注「混合精度 + Adam 的静态账 ≈16N 字节」:最底部紫色大段「优化器状态 12N」,注「fp32 主参数 4N + 动量 m 4N + 二阶矩 v 4N」;其上青色段「参数 2N(fp16)」与「梯度 2N(fp16)」;最顶端斜线纹理段「激活:随 batch 与序列长度变」。账柱右侧竖排注「优化器状态才是大头,占静态显存的 3/4」。右侧大标题「大模型训练的显存都花在哪,一图看懂省钱路线」,副标题「参数、梯度、优化器状态、激活,四本账分开算」。左上标注「知识延伸 · 训练显存」,右上圆角胶囊标注「Day 05 · 11-22」。 图 1:四本账账柱,优化器状态占掉静态显存四分之三。

聊 ZeRO 的面试题里有个前置问题常被跳过:那 16N 字节的账到底是怎么来的?这篇文章把那笔账拆开算清楚——训练显存由哪几本账构成、各占多少、每种优化手段分别砍哪一本。看懂账本,「选 ZeRO 还是梯度检查点」就不再是背答案,而是查表。

第一本账:参数。

混合精度训练下,前向反向计算用的是 fp16(或 bf16)参数副本,每个参数 2 字节,共 2N。这是账面上最「干净」的一笔,但别忘了优化器里还压着一份 fp32 主参数——那是第四本账的事。

第二本账:梯度。

反向传播算出的梯度同样按 fp16 存,2N。梯度有个特性:它只在反向结束到参数更新之间短暂存在,但这期间必须全量驻留,省不掉,只能靠切分摊薄。

第三本账:优化器状态,真正的大头。

Adam 要为每个参数维护三份 fp32 状态:主参数副本 4N、一阶动量 m 4N、二阶动量 v 4N,合计 12N。把三本静态账加起来:2N + 2N + 12N = 16N。一个 7B 模型静态就要约 104 GiB,70B 模型超过 1 TiB——这就是为什么「参数才 14 GB 的模型,一张 80 GB 的卡却训不动」。

第四本账:激活,唯一的浮动项。

反向传播要用前向的中间结果,所以每层的激活都得存到反向算完。激活与参数量无关,粗略正比于 batch 大小 × 序列长度 × 隐藏维度 × 层数——batch 翻倍它翻倍,序列拉长它拉长,长序列大 batch 时甚至能反超前三本账之和。这也是唯一一本「调训练配置就能立刻改变」的账:静态三本由模型和优化器锁死,激活却随你的 batch 与序列设置浮动,所以它常常是第一处可以动手的缺口。

三种主流手段,各砍一本账。

混合精度砍的是参数与梯度的账面单价:fp16 存算让这两本账从 4N 降到 2N,同时留 fp32 主副本保住更新精度。它不改变账的结构,只是全面打了五折。

ZeRO 砍的是冗余:数据并行下每卡都存一整份 16N,ZeRO 把优化器状态、梯度乃至参数按卡切片,n 张卡合存一份。Stage 1 只切 12N 那块最肥的,Stage 3 全切。它砍的是「重复」,不是「单价」。

梯度检查点砍的是激活:不在前向时保存所有层的中间结果,只留少数检查点,反向用到时再就地重算。2016 年陈天奇等人的论文(arXiv:1604.06174)证明,n 层网络的激活占用可以压到 O(√n) 量级,代价只是每个 mini-batch 多跑一次前向——典型的用算力换显存。

用一段脚本把账算成数字。

Node.js 22.15.0 运行(node trainmem.js):

function report(N, layers) {
  const GB = 1024 ** 3;
  const param = 2 * N, grad = 2 * N, optim = 12 * N;
  console.log(`N=${N / 1e9}B:静态 ${((param + grad + optim) / GB).toFixed(1)} GiB(≈16N)`);
  const ckpt = Math.ceil(Math.sqrt(layers)) + 1;
  console.log(`梯度检查点:${layers} 层只存约 ${ckpt} 个检查点,` +
    `激活约为全存的 ${(ckpt / layers * 100).toFixed(0)}%`);
}
report(7e9, 32);
report(70e9, 80);

实测输出:

N=7B:静态 104.3 GiB(≈16N)
梯度检查点:32 层只存约 7 个检查点,激活约为全存的 22%
N=70B:静态 1043.1 GiB(≈16N)
梯度检查点:80 层只存约 10 个检查点,激活约为全存的 13%

80 层模型开检查点后激活只剩一成出头,而代价固定是多一次前向——这笔交换几乎总是划算的。

深蓝黑背景概念图。中央是一根横向堆叠的显存长条,按字节数成比例分成四段:青色小段「参数 2N」、青色小段「梯度 2N」、紫色大段「优化器状态 12N」、灰色斜线段「激活(浮动)」。长条上方三张手段卡,各用箭头指向它砍的那一段:青色卡「混合精度」指向参数与梯度段,注「fp16 存算,fp32 留主副本」;紫色卡「ZeRO」指向优化器状态段,注「冗余状态按卡切分,三阶段逐级加」;灰色卡「梯度检查点」指向激活段,注「只存少数层输入,反向时重算,用算力换显存」。底部总结条给出估算式「静态 ≈ 16N 字节(2+2+12),激活 ≈ 随 batch 与序列长度增长」,并附一句先算账后选手段的原则。 图 2:三张手段卡各砍一段账,先定位缺口再选刀。

把钱花在刀刃上:先算账,再选手段。

实战中还有几个常用补丁值得知道:梯度累积把一个大 batch 拆成多个 micro-batch 分次算梯度再统一更新,直接压低激活峰值,代价是更新频率变低;CPU offload 把优化器状态甚至参数挪到内存里,用便宜的内存补昂贵的显存;激活重算之外还有激活压缩、序列并行等更细的刀法。但不管手段多花哨,决策流程都一样:先按 16N 加激活估算缺口在哪本账上——静态装不下就找 ZeRO 和 offload,激活爆了就用检查点和梯度累积,单价嫌贵就上混合精度和量化。

显存问题从来不是「买更大的卡」一句话能解决的,它是一道算术题。你训练时最先撑爆的是哪本账?当时用的是什么手段压下去的?欢迎对一对账本。