图 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 层模型开检查点后激活只剩一成出头,而代价固定是多一次前向——这笔交换几乎总是划算的。
图 2:三张手段卡各砍一段账,先定位缺口再选刀。
把钱花在刀刃上:先算账,再选手段。
实战中还有几个常用补丁值得知道:梯度累积把一个大 batch 拆成多个 micro-batch 分次算梯度再统一更新,直接压低激活峰值,代价是更新频率变低;CPU offload 把优化器状态甚至参数挪到内存里,用便宜的内存补昂贵的显存;激活重算之外还有激活压缩、序列并行等更细的刀法。但不管手段多花哨,决策流程都一样:先按 16N 加激活估算缺口在哪本账上——静态装不下就找 ZeRO 和 offload,激活爆了就用检查点和梯度累积,单价嫌贵就上混合精度和量化。
显存问题从来不是「买更大的卡」一句话能解决的,它是一道算术题。你训练时最先撑爆的是哪本账?当时用的是什么手段压下去的?欢迎对一对账本。