优化器与训练技巧 入门

梯度下降 primer 收尾于一行:w ← w − η · ∇L。真实训练脚本裹在这行外面的每个旋钮,都是因为「没有它就会坏」才存在。本篇证明三件事:Adam 的更新量是两份平均之比,因而与尺度无关;第一步永远是 ±1,这就是预热存在的理由;bf16 权重根本吃不下 3e-4 的更新,这就是主副本必须是 fp32 的理由。

01

Adam 到底在平均什么

对一个参数的梯度做两份滑动汇总,再取二者之比。整个优化器就这么点东西。

w ← w − η · ∇L 掩盖了一件事:单个权重在单个 mini-batch 上量到的 ∇L 大部分是噪声。Adam 的回答是:别再相信任何单独一个梯度,改为对这条梯度流保存两份滑动汇总 —— 一份是梯度本身,一份是它的平方。

第一份是指数加权平均:保留 β₁ 份旧值,再吸收 1 − β₁ 份新梯度 —— 于是过去每一步都还在里面,份额按几何级数衰减。每个圆点就是一个梯度在寄存器里占的份额。把 β₁ 拖大,看最新那一步把份额交还出去:

β₁ = 0.00,窗口 1.0 步

注意 β₁ = 0 时只有一个圆点,它独占 100%,寄存器读数 0.769 —— 就是最新那个梯度本身。窗口只有一步长的「平均」就是那个梯度。到 (十步窗口)时,最新一步降到 13.9%,十二步里最旧的那一步仍供给 4.4%,寄存器读数 0.470。所谓动量,就是这个,没有别的。

第二份汇总平均的是 g² 而不是 g,真正有用的是它的平方根:√v̂ 估计的是这个参数的梯度有多大,符号被丢掉了。把 β₂ 拧大,看第二个寄存器在第一个旁边被填起来:

β₂ = 0.00,窗口 1.0 步

看 β₂ = 0 时的 √v̂:它就是最新那个样本自身的幅值 0.769,报的是一次抽样,跟这个参数本身无关。到了 ,最新一步只占其中 10.9%,寄存器稳在 0.677 —— 这个参数梯度的典型大小,代价是每个权重一次乘加、一个存储的数。

把一个寄存器除以另一个,量纲就抵消了。m̂ / √v̂ 是一个量级为 1 的纯数,跟梯度本身在哪个量级上完全无关。把整条流缩小五十倍,看两个寄存器一起塌下去,而底部那条量表纹丝不动:

梯度保持原尺度

注意 m̂ 从 4.70e−1 掉到 9.40e−3,√v̂ 从 6.77e−1 掉到 1.35e−2 —— 两个都正好小五十倍 —— 而量表停在 0.69。于是 η 不再是「最陡参数」和「最平参数」之间的折中,而变成每个参数都走的同一段距离。

还差一个修正。两个寄存器都从空的开始,所以在窗口填满之前,它们求和的对象里有一部分步根本还没发生 —— 各自除以 1 − βᵗ 就把这份偏差精确地去掉。把窗口往回走,看还空着的槽位在未校正的总和下面露出来:

第 1 步

因为 1 − 0.9¹ 等于 0.1, 的未校正总和是 0.135,只有该有的十分之一,而校正后的寄存器已经是 1.353 —— 那个梯度本身。到第 12 步是 0.337,为校正值 0.470 的 0.718。它对 v 的意义远大于对 m —— §03 接着往下讲。

02

衰减该加在哪里

更新规则里没有任何东西会拦住一个权重变大。权重衰减就是那一项 —— 而你把它加在哪里,决定了它到底在做什么。

只要梯度一直指向同一边,Adam 就一直发出差不多大的步长,而 m̂ / √v̂ 里没有任何一项会随权重变大而变小。于是被认为有用的权重会无止境地长下去 —— 而权重巨大的网络,离「某个坏 batch 让激活值溢出」只差一步。

解药是一项随权重一起变大的东西:在自适应步之后,每一步都减去 η · λ · w,不管梯度做了什么。把 λ 拧大,看那个权重怎样停止攀升:

λ = 0.00

注意它是稳住,不是缩到零。推力与拉力在 E|m̂/√v̂| / λ 处平衡,所以在 时权重停在 1.70 附近,正贴着那条天花板,而没有衰减的那条已经到 3.51 还在涨。权重衰减不是把权重往零拉,而是一个高度为 1/λ 的天花板。

接下来是这个领域花了三年才注意到的那一部分。Adam 原论文把衰减写成 L2 —— 往梯度里加 λ · w —— 于是这个和会跟其它一切一样,再过一遍同一个 1/√v̂ 除数。在两种做法之间切换,看同一个 λ 对六组参数分别做了什么:

AdamW —— 解耦 · λ = 0.10

在 AdamW 下,每一组每步都损失自身的 3e-5。在把 L2 加进梯度时,真正落地的衰减是 η · λ / √v̂:梯度很大的词嵌入损失 1e-3,而梯度小得多的偏置损失 7.5e-1 —— 每一步掉掉自身的四分之三。同一个 λ,750 倍的离散度,而且哪里都不会报错。

这就是让 AdamW 成为处处默认的那个修正。它的代价是显存 —— 一个参数占的十六字节里,有十二字节是优化器的,不是模型的。拖动模型规模,看那根柱子与一张 H100 的 80 GiB相比:

7B 参数 · 每张 GPU 各存一份

注意哪几格是灰的。4 字节的 bf16 权重与梯度是模型本身,另外十二字节全是优化器的。 7B 时这是 104 GiB 对 80 GiB,柱子有四分之一高出那条线; 要 1,043 GiB,而激活还一个没存。

办法是别再各存一份。FSDP 把这十六字节全部切分到数据并行组上,每张 GPU 只持有 16P/N:,104 GiB 变成 52.2 GiB,落到线以下。账单以带宽的形式到来 —— 每一遍都要 all-gather 一次本层权重 —— 那正是 §07 的算术。

03

为什么第 1 步最危险

二阶矩从第一步起就是无偏的,也从第一步起就是没用的。预热就是补上这段差距的那道斜坡。

§01 说偏差校正是精确的,确实如此 —— 在期望意义上。代数解决不了的是:第 1 步时第二份平均里只有一个样本,而 Adam 马上要拿它的平方根做除数。

把式子写开:在 t = 1 时 m̂ = g₁、v̂ = g₁²,于是更新量对模型里每一个参数都是 ±1,跟它的梯度是多少完全无关。改变 β₂,看开头那一步怎样纹丝不动:

β₂ = 0.950

注意:滑块能到的每一个 β₂,开头那个值都是 1.00。此刻还没有东西可平均,所以这个比值就是一个带噪数的符号 —— 没有斜坡的话,七十亿个参数同时朝随机方向迈出整整一个 η。前四十个更新量里仍有三十个超过 0.5。

一个样本是极端情形;真正有用的问题是:要多少步之后这个除数才值得信。跟着十二个互相独立的参数,看√v̂ 的那条带怎样收拢到真值上:

β₂ = 0.950 · 第 1 步

第 1 步时这十二条散布在真值的 0.01 到 2.86 倍之间 —— 最离谱的一条偏了 186%,还有一条低了 99% —— 而且在每一个 β₂ 下都是从这里起步,因为第 1 步时它们各自就是自己那一个样本。 β₂ 的价值体现在后面:到 ,那条带在 β₂ = 0.999 时已收到 ±2%,在 0.95 时仍是 ±19%,因为短窗口永远在重新采样。

所以解法不是把除数做得更好,而是在除数不靠谱的这段时间里把步子迈小。让 η 从零线性爬升,看一步真正把权重挪动的距离怎样被压平:

预热 0 步 · 第 1 步

不做预热时,第一步把每个权重挪动整整 3.00e-4;做 时是 1.50e-7,正好小了两者之比那么多。真实 LLM 的预热在 500 到 2,000 步之间 —— 占整轮训练的 0.1% 到 2%,而且并非巧合地,差不多就是 β₂ = 0.999 所平均的那个 1,000 步窗口。

04

选整条曲线

斜坡之后是一路往下的曲线。而「哪条曲线」这件事,最后没有「它在哪里结束、从哪里开始」重要。

预热只占开头百分之零点几。剩下的百分之九十九是 η 的衰减,而每一家实验室的每一份训练报告,给的都是同样三个数:峰值、形状、下限。

在用的四种形状,差别比它们的名字听上去小。让游标走完十万步,并在它底下切换那条曲线:

余弦 · 第 0 步,共 100,000 步

看这些曲线积出来是多少。这一轮里,余弦和线性都是 16.47 —— 同样的预算,花法不同:余弦前期把学习率托得高,结尾掉得快。 WSD 是例外,27.05,因为它把峰值一直保持到最后五分之一 —— 这也正是它可以续训的原因:开始时你不需要知道总步数。

曲线在哪里结束,是比形状更大的杠杆。把下限抬离零,看曲线下的面积怎样长大:

下限 = 峰值的 0%

因为那块面积就是优化器被允许走的总距离, 会多买到 9.8% —— 而且全部花在尾巴上,那时模型是在打磨而不是在探索。 token 预算已经定死就衰减到零;还想接着练就留一条下限。

峰值才是值得纠结的那个数,而且它不是随便挑的:模型越大,它越小。逐个走过 GPT-3 的八个模型,把两条轴都读一遍:

第 4 个模型,共 8 个

两条轴都是对数轴,所以那条直线是幂律,不是正比 —— 往右一格是模型大十倍,而这条线在这一格上掉掉固定的一个比例。对八个点做拟合得到 η ∝ N^−0.31,而从 到 这 1,400 倍之间,峰值正好掉了 10 倍,从 6.0e-4 到 0.6e-4 —— 一条背后有 GPT-3 论文表 2.1 撑着的经验法则。

05

救下这轮训练的那一行

裁剪梯度在训练健康时什么都不花,不健康时它花的是全部。而它只有一种错法。

Adam 约束的是每个参数的更新量,它并不约束更新向量的总长度:一个坏 batch 可以让梯度的所有坐标同时变大,由此迈出的那一跃是损失曲线再也回不来的。裁剪约束的是整个向量。

规则就是一行算术 —— 如果 ‖g‖ 超过阈值,就把整个梯度乘上 c/‖g‖。这一个已经超了。拖动梯度绕着球走,看被拉回来的是什么:

‖g‖ = 2.24. 拖动梯度末端;方向键每次移动 0.1,Home 键回到初始位置
‖g‖ = 2.24 · 阈值 = 1.00 · 方向不变

注意什么没有变。裁剪后的向量还在同一条射线上,夹角 0.0°:裁剪改变的是这一步走多远,从不改变它走去哪里。这一节剩下的一切都建立在这条性质上。

在真实训练里,这个阈值大部分时间什么也不做。把它往下调,看它在两百步的梯度范数里抓住多少次:

阈值 = 1.00

在标准阈值 1.00 上,它在 200 步里触发 4 次 —— 就是那几个尖峰,最高的一个 7.39 —— 另外 196 步原封不动。把它拖到 ,它在 200 步里触发 180 次:现在每一步长度都一样,有效学习率变成了 c·η/‖g‖,整轮训练悄悄慢了下来,而且没有任何一条报错会提到这件事。

这件事只有一种错法,而且在每个框架里都只隔一个标识符。这一幅一开始就是 clip_grad_value_ —— 是方框,不是球 —— 拖动梯度绕着它走,看方向怎么动;切回范数,那个转角就归零:

‖g‖ = 2.24. 拖动梯度末端;方向键每次移动 0.1,Home 键回到初始位置
逐坐标 · ‖g‖ = 2.24 · 被转了

clip_grad_value_ 逐坐标夹取,砍短大的那一维,不碰小的:初始梯度 (2.0, 1.0) 变成 (1.0, 1.0),比反向传播的方向偏了 18.4°。不是更小的一步,是不同的一步,而且静默失败。请用 clip_grad_norm_。

06

用两个字节,而不是四个

十六个比特,在范围和精度之间分成两份。fp16 与 bf16 的每一处差别,都从「这一刀切在哪里」推出来。

上面都假定这些数是精确的。它们不是。现代训练把权重、激活和梯度都放在两个字节里,显存减半,吞吐在同一批单元上大致翻倍:H100 SXM 的 tensor core 上, bf16 是 989 TFLOPS,同样这些单元上的 TF32 是 495。向量 fp32 的 67 TFLOPS 是另一条通路,拿它去比得到的 15 倍谁也没见过。

浮点数把它的比特花在两件事上:指数位买范围,尾数位买精度,而总共只有十六位可分,一边多拿一位就是另一边少一位。滑动这一刀,看可达区间怎样被拉长:

指数 5 位,尾数 10 位

fp16 和 bf16 就是这条线上的两个点。fp16 取 、10 位尾数;bf16 取 ,指数字段跟 fp32 一模一样。于是 bf16 够到 3.4e38,而 fp16 停在 6.6e4;代价是 2.4 位十进制有效数字,对 fp16 的 3.3 位。

精度不是抽象概念,它就是某个格式真正能存的两个相邻值之间的间隔。沿轴拖动一个数,看这次转换把它放到哪里:

x = 1.288e+0. 沿轴拖动;方向键每次移动十分之一个数量级,Home 键回到初始值
x = 1.288e+0 · bf16

因为这个间隔是数值的一个固定比例 —— 在 bf16 里处处是 2⁻⁷,也就是 0.78% —— 所以相对误差在 1e-30 处和在 1e30 处一样大,而且是 fp16 那 0.098% 的八倍。每个数都落在两个刻度中的一个上,中间没有落点。

在实践中把 fp16 弄坏的是范围。梯度住在 1 以下好几个数量级的地方,而 fp16 最小的次正规数是 5.96e-8。滑动损失缩放,看它把那片分布在两堵墙之间搬动:

损失缩放 = 2^0 · fp16

不做缩放时,这片建模出来的分布有 8.0% 落在 fp16 的地板以下 —— 静默地被冲成零,那些参数就不训练了。反向传播前把损失乘上 ,这个比例是 0.05%;推到 ,换成顶上有 5.9% 溢出成 inf。 bf16 在每一档上都保住整片分布,这基本上就是它胜出的原因。

不管用哪个格式,有一样东西必须留在 fp32,而这张图就是为什么。比较一下:给一个值为 1.0 的权重加上一个更新量,它真正涨了多少:

更新量 = 1e-3 · fp32 主副本

1e-3 的更新在 fp16 里落成 9.77e-4,在 bf16 里则什么都没落下 —— 它不到 bf16 那 0.0078 间隔的一半,于是这次加法把权重原样还回来。当 η 是 3e-4 时,真实训练里大部分更新都是这个量级。所以工作副本用 bf16 换速度,主副本用 fp32,好让训练真的能累加起来。

07

切开一个模型的三种切法

数据、张量、流水线。三刀互相独立,而一套主要关于线上字节数的算术,告诉你哪一刀该切在哪里。

§02 算出 7B 的状态是 104 GiB,而一张 H100 只有 80 GiB,并给了答案:切分到数据并行组上 —— 用带宽换显存,而带宽正是这一整节的货币。每次前沿训练都沿三条可以叠加的轴切开,每一条的规模由它必须往线上说多少话决定。

最容易的是数据并行:每张 GPU 都持有完整模型,各跑一份 batch 切片,然后把梯度平均掉。有意思的是这个「平均」本身 —— 让环转起来,看每个分块怎样每跳一次就多收一份贡献:

4 张 GPU · 第 0 阶段

注意没有谁是中心。头 N−1 个阶段里,每个分块沿环走一步,每停一站就收下一张 GPU 的贡献,于是到第 3 阶段每张 GPU 各持有一个已完整归约的分块;接下来的 N−1 个阶段再把这些成品沿环递回去。任何东西都不经过协调者。

这一点之所以重要,是因为另一种做法的代价。把 GPU 数量拧大,看环和中心服务器在双对数坐标里怎样分开:

8 张 GPU

环上最忙的那条链路承载 2(N−1)/N · M,它爬向 2M 然后就停住:13 GiB 的 bf16 梯度,在 8 张 GPU 上是每条链路 22.8 GiB,到 张是 26.0 GiB。中心服务器那条链路承载 2NM —— 8 张时 209 GiB,512 张时 13.0 TiB。一条对 N 是平的,另一条在双对数坐标里是一条上升的直线,那正是 N 的幂该有的样子。 all-reduce 之所以是环,就是这个原因。

数据并行要求模型能装进一张 GPU。装不下时,就改切权重矩阵:每张 GPU 保留每一层的一块板。把这一层切开,看这刀的代价:

1 路张量并行

隐藏维 4096 的一层是 201M 参数;后每张 GPU 持有 25.2M。显存上省下的,要在流量上还回来:每层每次迭代是四次激活 all-reduce,不是两次 —— Megatron 把每个块拆成一对 f/g,一层有两个这样的块,于是前向两次、反向两次。八路切分、8,192 token 的一个微批下这是 448 MiB,而那份激活本身是 64 MiB。每一层、每一次迭代 —— 这就是张量并行只待在一个 NVLink 节点里、不跨网络的原因。

第三刀顺着层切:第 0 级持有前面几个块,第 1 级持有接下来的。这一刀的代价里根本没有通信。加微批,看那些空格子怎样合上:

4 级 · 1 个微批

只有一个微批时,四级流水线有 75% 的时间在空转:一级要等前一级做完才能开工。气泡正好是 (p−1)/(m+p−1),所以对付它的办法是加微批 —— ,四级就降到 16%。真实训练三刀一起用:一个 1,024 卡的作业可能是 32 路数据 × 8 路张量 × 4 路流水线,这三个数乘起来就是 GPU 总数。

08

九行代码,每行是干什么的

这一页上的每一个旋钮,都落在一个训练步里的某个位置。就是这些位置。

翻开一份训练脚本,这套机械装置平平无奇:一个构造函数、一个循环,循环里七次调用。让它成为「能跑通的训练」而不是「发散的训练」的,是每一次调用都在回应一个具体的失败模式,而且这些回应必须按正确的顺序到场。

走一遍一次迭代,读一读每个操作掌管的那个旋钮,以及它每个参数要在显存里搬多少字节:

第 1 个操作,共 7 个 · 前向

注意哪一条最长。整次迭代每参数搬 56 字节,优化器那一步一个人占 28,而前向只占 2 —— 这就是 Adam 的一步受带宽限制、把它融合或切分能买到很多东西的原因。顺序同样不是随便排的:还原缩放排在 all-reduce 之后、裁剪之前 —— 拿去跟 1.0 比的范数必须是真的,对缩放过的梯度做裁剪,是在错误的阈值上静默地裁。

opt = AdamW(p, lr=3e-4, betas=(.9, .95),
            eps=1e-8, weight_decay=0.1)

for step in range(1, TOTAL + 1):
    set_lr(opt, lr_at(step))     # §03, §04
    with autocast(dtype=bfloat16):  # §06
        loss = model(batch).loss
    loss.backward()              # §07 ring
    clip_grad_norm_(p, 1.0)      # §05
    opt.step(); opt.zero_grad()  # §01, §02

这些就是前沿实验室公开的那几个数,也是每一次微调继承下来的那几个数。