qinghuan
← Transformer 与语言建模 / 全部笔记

单元 24 / Transformer 与语言建模

P1 · 重点提升L2 · 进阶

混合精度与梯度累积:控制显存与数值误差

用较低计算精度和分成小批的梯度累积节省显存,同时分清损失缩放,并按有效目标数量还原完整大批的梯度。

本篇目录 · 按需要跳转

读完这篇,你能做什么

检查不等长微批的加权累积与完整批梯度一致,验证先 unscale(还原梯度尺度)再检查,并观察 CPU BF16 输出误差。

学习理由、准备要求与知识点

为什么学它

节省显存时也要保持各样本对损失的权重。缩放系数或裁剪顺序错了,实际训练就会偏离原来的目标。

先读这些内容

知识点清单

  • FP32和FP16与BF16
  • autocast按算子选精度
  • loss scaling与反缩放
  • 有效大批和微批
  • 不等长微批的分母
  • 累积窗口内zero_grad时机
  • unscale与裁剪顺序
  • 梯度溢出跳步
  • 统计等价的前提

运行前先确认PyTorch 2.9.1+cpu、Python 3.14、Java 17。CPU实验验证BF16 autocast接口和梯度累积数学,并演示CPU GradScaler顺序;不等价于GPU FP16性能或显存测试。

01先弄懂它是什么

想减少训练的计算量和存储开销,可以让部分运算使用较低精度,但不必把所有数字都改成同一种类型。混合精度让一些矩阵乘法用较低精度,而一些求和、平均和损失计算保留较高精度;autocast 会按运算和设备选择计算类型。参数、优化器状态、输出也可能各用不同类型。FP16 和 BF16 都占两个字节,但能表示的数值范围和精细程度不同,溢出、下溢与舍入表现也会不同。

很小的梯度用低精度保存时,可能直接变成零。损失缩放先把 loss 乘正数 s,让反向传播得到 s 倍梯度,更新前再除以 s,主要用于缓解这类下溢问题。它不会让模型凭空获得更强表达能力。

动态缩放还会检查 NaN 或无穷梯度,再按规则跳过更新或调整 s。BF16 的指数范围较大,常见 CPU BF16 路径通常不需要相同缩放策略;本实验另外用 CPU GradScaler 演示调用顺序,不把它当作必需配置。

梯度累积把一个更新所需数据拆成若干微批,逐次反向相加,最后更新一次参数。若总目标是所有N个有效位置的平均损失,每个微批应贡献其损失总和除以N。微批长度不同,却把各自mean再等权平均,会让短微批中的每个token权重更大,已不再是原来的训练目标。

文中这几个词,先认一认

Autocast
在一段代码的作用范围内,按运算选择合适的数据类型;它不会永久把整个模型的参数都改成低精度。
微批
一次前向和反向计算处理的小批数据。可以累计多个微批的梯度后,再更新一次参数。
有效批量
按损失采用的单位,数一次参数更新实际汇总了多少样本或有效 token。多微批或多进程训练时,要把所有微批和各个工作进程(rank)的贡献一起算,不能只看一小批的大小。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

设微批j包含nj个有效token,其平均损失为Lj,完整均值为sum(nj*Lj)/sum(nj)。因此对每个Lj乘nj/N后反向,累加梯度才与完整目标一致。若每个微批有效数相同,除以微批数量才是这个公式的特例。序列padding、SFT掩码和最后不足窗口都会让等长假设失效。

梯度累积像把几批数据的贡献先加在一起:窗口开始时 zero_grad 一次,中途不能清掉已有梯度,也不能提前 step 改参数,否则后几批就在不同参数上计算,不再对应同一个大批。整个窗口保持同一个缩放因子,全部反向结束后再 unscale 一次,然后裁剪、step、update。若提前 unscale,之后又加上仍被缩放的梯度,就把不同尺度的数混在一起了。

数学等价还依赖模型行为。BatchNorm使用每个微批自己的统计,Dropout可能因为随机调用布局而产生不同掩码,浮点归约顺序也不同,所以实际模型不保证逐位等同。本实验采用无随机层的线性回归,先隔离损失加权机制,再单独检查BF16矩阵结果与FP32接近;这是CPU正确性实验,不能推出GPU训练速度或显存节省比例。

先跑通一个最小例子

CPU线性回归,对比分别含1和3个样本的微批;再检查CPU缩放器与BF16计算。

展开收起代码 · experiment.pypython · 38 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

experiment.py · python
import copy
import torch
from torch import nn
torch.manual_seed(24)
torch.set_num_threads(1)
x = torch.tensor([[1., 2.], [2., 1.], [3., 1.], [4., -1.]])
y = torch.tensor([[1.], [0.], [2.], [3.]])
base = nn.Linear(2, 1, bias=False)
full, accumulated, wrong = copy.deepcopy(base), copy.deepcopy(base), copy.deepcopy(base)
nn.functional.mse_loss(full(x), y).backward()
for part in [slice(0, 1), slice(1, 4)]:
    loss = nn.functional.mse_loss(accumulated(x[part]), y[part], reduction="sum") / len(x)
    loss.backward()  # 每个微批共用完整批的分母
    (nn.functional.mse_loss(wrong(x[part]), y[part]) / 2).backward()
assert torch.allclose(full.weight.grad, accumulated.weight.grad, atol=1e-6)
assert not torch.allclose(full.weight.grad, wrong.weight.grad)
scaled = copy.deepcopy(base)
optimizer = torch.optim.SGD(scaled.parameters(), lr=.01)
scaler = torch.amp.GradScaler("cpu", init_scale=8.)
for part in [slice(0, 1), slice(1, 4)]:
    loss = nn.functional.mse_loss(scaled(x[part]), y[part], reduction="sum") / len(x)
    scaler.scale(loss).backward()
scaler.unscale_(optimizer)  # 所有缩放梯度累积后只还原一次
assert torch.allclose(scaled.weight.grad, full.weight.grad, atol=1e-6)
failed = False
try:
    scaler.unscale_(optimizer)
except RuntimeError:
    failed = True
assert failed
scaler.step(optimizer)
scaler.update()
assert torch.isfinite(scaled.weight).all()
with torch.no_grad(), torch.autocast("cpu", dtype=torch.bfloat16):
    low = base(x)
assert low.dtype == torch.bfloat16
assert torch.allclose(low.float(), base(x), atol=.03, rtol=.02)
print("7 accumulation and CPU precision checks passed")

核对运行结果正确加权梯度与全批一致,错误等权微批均值不同;重复unscale被拒绝,BF16结果在指定容差内。

正在加载流程图…

损失单位和缩放单位在整个累积窗口内必须一致。
查看流程图源码(Mermaid)
flowchart TD
A["更新窗口总有效计数 N"] --> B["微批损失总和除N"]
B --> C["统一scale后反向累加"]
C --> D["所有微批完成"]
D --> E["unscale一次再裁剪"]
E --> F["step和scaler.update"]
F --> G["清理梯度开始下一窗口"]

按顺序看,每一步做了什么

  1. 统计每个微批有效目标数和总N。
  2. 先在FP32无随机层验证加权累积等价。
  3. 启用autocast并检查关键张量dtype与误差。
  4. 把反缩放、裁剪、更新放在窗口结束。

03对照实现代码

GradScaler约束同一更新中的反缩放状态 (新标签页)↗

PyTorch v2.9.1 · GradScaler.unscale_;GradScaler.step;GradScaler.update

原创缩放状态机示意,不实现设备级非有限检查、异步操作或动态缩放算法。

核心机制代码 · python
class ScaleWindow:
    def __init__(self, scale):
        self.scale = scale
        self.unscaled = False
    def unscale(self, gradients):
        if self.unscaled:
            raise RuntimeError("already unscaled")  # 同一窗口不能重复除以scale
        self.unscaled = True
        return [g / self.scale for g in gradients]  # 单位恢复到原始损失梯度
    def next_window(self):
        self.unscaled = False  # 实际框架还会按非有限结果调整scale

真实unscale_检查优化器当前阶段,重复反缩放或step之后调用会被拒绝,避免静默改变梯度尺度。

内部按设备和dtype组织梯度,调用非有限检测与反缩放操作;Python示意未复现这些内核。

step在必要时执行反缩放并根据非有限状态决定是否真正调用优化器更新。

update根据收集结果更新缩放状态,应位于完整有效批更新之后;累积中途改变scale会破坏统一单位。

代码先调用谁,再调用谁

  1. scale(loss)
  2. 多个backward累积
  3. unscale_与非有限检查
  4. 可选梯度裁剪
  5. step后update

04动手写一个例子

按有效计数聚合微批平均梯度

每个微批提供平均梯度向量和有效目标数,计算完整批平均梯度。零有效数微批不贡献;全部为空拒绝,计数溢出或非有限梯度拒绝。

输入 1
微批均值2和6,计数1和3
输出 1
完整均值5,而非4
输入 2
两批计数都2,梯度[1,2]和[3,4]
输出 2
[2,3]

先求总有效数并验证维度,避免在总分母尚未知时使用错误平均。

每个微批的平均梯度乘自身计数占比,再逐维求和。

使用计数比例而不是先乘巨大计数,减少不必要的中间溢出,但浮点求和仍有误差。

为什么这样写是对的处理完前k个微批后,累积向量等于它们按完整批有效计数加权的梯度贡献。

时间 O(md)额外空间 O(d)

m个微批、d维参数梯度,两遍读取计数与梯度,不保存新矩阵。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 34 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
  record Micro(double[] mean,long count) {}
  static double[] combine(List<Micro> parts){
    if(parts.isEmpty()||parts.get(0).mean().length==0)throw new IllegalArgumentException();
    int d=parts.get(0).mean().length;long total=0;
    for(Micro m:parts){
      if(m.count()<0||m.mean().length!=d)throw new IllegalArgumentException();
      total=Math.addExact(total,m.count()); // 样本计数溢出不能静默回绕
      for(double g:m.mean())if(!Double.isFinite(g))throw new IllegalArgumentException();
    }
    if(total==0)throw new IllegalArgumentException("no effective targets");
    double[] answer=new double[d];
    for(Micro m:parts){
      double weight=(double)m.count()/total; // 先形成占比,避免均值乘巨大计数
      for(int j=0;j<d;j++)answer[j]+=weight*m.mean()[j];
    }
    for(double g:answer)if(!Double.isFinite(g))throw new ArithmeticException("range");
    return answer; // 等权平均仅是各计数相同时的特例
  }
  static void close(double a,double b){if(Math.abs(a-b)>1e-10)throw new AssertionError();}
  public static void main(String[] args){
    close(combine(List.of(new Micro(new double[]{2},1),new Micro(new double[]{6},3)))[0],5);
    double[] v=combine(List.of(new Micro(new double[]{1,2},2),new Micro(new double[]{3,4},2)));
    close(v[0],2);close(v[1],3);
    close(combine(List.of(new Micro(new double[]{99},0),new Micro(new double[]{7},1)))[0],7);
    close(combine(List.of(new Micro(new double[]{-2},1),new Micro(new double[]{2},1)))[0],0);
    boolean bad=false;try{combine(List.of(new Micro(new double[]{0},0)));}catch(IllegalArgumentException e){bad=true;}
    if(!bad)throw new AssertionError();
    bad=false;try{combine(List.of(new Micro(new double[]{1},Long.MAX_VALUE),new Micro(new double[]{1},1)));}catch(ArithmeticException e){bad=true;}
    if(!bad)throw new AssertionError();
    System.out.println("6 weighted gradient checks passed");
  }
}

这些特殊情况也要试一试

  • 零有效目标的微批不会贡献平均值,即便其占位梯度非零。
  • 所有微批均无目标时应跳过或拒绝更新,不返回虚假的训练进展。
  • 数学梯度等价不代表含BatchNorm与随机层时实际逐位一致。
进阶选读 · 生产拓展:变长token与分布式累积

场景不同rank和微批的有效token数不同,DDP默认平均梯度可能改变权重。

做法先统计全局有效数,按全局损失总和构造权重,并考虑AllReduce默认除以world size的约定;对小数据与单进程全批梯度作对照。

复杂度本地梯度工作随有效数据量增长,计数同步和梯度通信需要额外集体操作。

好处与代价精确目标权重更可靠,但跨rank同步和不齐批处理更复杂。

05项目里怎么用

估算显存时,要分别列出模型参数、梯度、优化器为更新保存的状态,以及前向计算后留给反向传播用的中间值(激活),还要算临时缓冲。autocast 只让部分运算使用较低精度,不表示这些项目全都减半。梯度累积则把一次要处理的数据拆小,主要减少单次计算需要保留的激活;参数和优化器状态通常仍要放在那里。因此看到微批变小,不能直接推断整个模型的显存按同样比例下降。

监控实际有效批、更新次数、scale变化与跳步数量。若有些更新因非有限梯度未执行,却继续推进学习率或宣称处理了相同优化预算,实验对比会失真。

性能评测需要固定硬件、batch、序列长度和精度策略,并观察数值质量。CPU BF16能运行并不证明它一定比FP32快;本实验只验证接口与有限误差,GPU吞吐和显存必须另行实测。

容易踩的坑

  • 不等长微批mean直接等权平均。
  • 每个微批都zero_grad导致前面积累丢失。
  • 先裁剪缩放梯度,再反缩放。
  • 认为autocast让全部参数和优化器状态都变成半精度。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
FP32基线调试较直观部分场景计算和内存成本较高正确性基准
Autocast混合精度可利用合适低精度算子需要误差与范围验证硬件和算子支持的训练
梯度累积用较小微批构造更大更新批增加更新延迟且统计等价有前提激活内存受限

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01loss乘s后梯度如何变化?

展开参考答案与推导

在相同图和数值可表示前提下也乘s,更新前需除回。

02两个微批mean能直接平均吗?

展开参考答案与推导

只有有效计数相同或目标确实定义为微批等权时才等价。

03BF16与FP16都是两字节,为什么不同?

展开参考答案与推导

指数和尾数位分配不同,数值范围与精度不同。

换个情况,还会处理吗

01最后窗口只有两个微批,却仍除以固定四,会怎样?

展开参考答案与推导

若按等大小窗口目标,梯度被缩小,需要按实际有效目标数归一化。

02unscale后又backward一个缩放loss有什么风险?

展开参考答案与推导

同一缓冲混入已还原和仍缩放的梯度,单位不一致。

03累积与大批BN模型结果不同,是否一定是bug?

展开参考答案与推导

不一定,BN按微批统计本身就改变函数,需区分机制差异和实现错误。

写代码试一试

01均值2、6,计数1、3,完整梯度多少?

输入 / 条件
两个微批
输出 / 预期
5

提示 按计数加权。

展开参考答案与推导

(1乘2+3乘6)/4=5。

02什么时候调用zero_grad?

输入 / 条件
一个累积窗口多个微批
输出 / 预期
窗口开始一次

提示 中途要保留梯度。

展开参考答案与推导

完成一次更新后清理,再进入下个窗口。

03如何验证累积实现?

输入 / 条件
无随机层小线性模型
输出 / 预期
累积与完整批梯度在容差内一致

提示 固定参数和总分母。

展开参考答案与推导

分别反向对照每个参数梯度,并加入不等微批长度样例。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

检查不等长微批的加权累积与完整批梯度一致,验证先 unscale(还原梯度尺度)再检查,并观察 CPU BF16 输出误差。

本篇重点加练

梯度累积的等价性依赖目标分母和模型状态,重点识别“大批等价”失效的前提。

  • 不等长微批的分母
  • 统计等价的前提

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    比较 FP32、FP16、BF16 的表示范围、有效精度与在训练中的角色;为 autocast 与 loss scaling 分别写可检验的职责定义。

    提交内容
    格式表与职责说明
    怎样算完成
    • 存储和计算类型分开
    • 范围与精度不混称

    练到的知识点FP32和FP16与BF16 · autocast按算子选精度 · loss scaling与反缩放

  2. 作业 02代码推演

    一个累积窗口含三个微批,有效 token 数为 2、5、1。写出统一目标的损失分母和事件时序,含最后一个不足窗口的情况。

    提交内容
    损失定义与时序图
    怎样算完成
    • 有效目标权重明确
    • 清零更新边界完整

    练到的知识点有效大批和微批 · 不等长微批的分母 · 累积窗口内zero_grad时机

  3. 作业 03原理问答

    同一批样本带不同损失权重,且一个微批没有有效目标。定义完整批目标后,列出累积实现需要保持的统计量和零目标契约。

    提交内容
    统一目标及统计状态表
    怎样算完成
    • 权重分母明确
    • 空微批不能隐式改变目标

    练到的知识点不等长微批的分母 · 统计等价的前提

综合应用

  1. 作业 04编程实践

    在 CPU 双精度上比较一个完整批次与不等大小微批累积的参数更新,使用含忽略标签的小模型并报告数值容差。

    提交内容
    两种实现及参数差异记录
    怎样算完成
    • 损失归约一致
    • 等价前提显式列出

    练到的知识点有效大批和微批 · 不等长微批的分母 · 统计等价的前提

  2. 作业 05排错实验

    用手动可控缩放因子的 CPU 实验,分别注入提前裁剪、遗漏反缩放和窗口中途清零,记录最终梯度变化。

    提交内容
    三类故障复现与检验
    怎样算完成
    • 模拟与真实 AMP 执行区分
    • 比较使用同一目标

    练到的知识点loss scaling与反缩放 · 累积窗口内zero_grad时机 · unscale与裁剪顺序

  3. 作业 06面试场景

    某次更新因非有限梯度被跳过,但调度器和样本计数继续前进。设计状态记录协议并明确每种计数的含义。

    提交内容
    状态机及审计样例
    怎样算完成
    • 跳步事件可追踪
    • 样本消费与参数更新分开

    练到的知识点unscale与裁剪顺序 · 梯度溢出跳步

  4. 作业 07编程实践

    在 CPU 小模型中加入逐样本不同权重,将完整批分成三种不同切法,比较梯度和更新;保持非随机层及初始参数完全一致。

    提交内容
    三切法代码与误差报告
    怎样算完成
    • 有效权重总量可对账
    • 比较前提全部固定

    练到的知识点不等长微批的分母 · 统计等价的前提

  5. 作业 08排错实验

    向同一模型加入依赖批统计的层,再比较完整批与微批累积;记录输出、内部状态和梯度,判断差异属于实现错误还是前提变化。

    提交内容
    有无批依赖层的对照
    怎样算完成
    • 模型状态也被记录
    • 数值误差和数学差异分开

    练到的知识点统计等价的前提

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    选做:有兼容 GPU 时比较两种混合精度配置与 FP32 的数值误差、峰值内存和吞吐;无设备时仅提交待执行协议,不填写测量结果。

    提交内容
    设备清单与实测或待执行方案
    怎样算完成
    • 真实设备及版本明确
    • 未运行项目保持未知

    练到的知识点FP32和FP16与BF16 · autocast按算子选精度 · 梯度溢出跳步 · 统计等价的前提

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 开放探究

    在数据流中无法提前知道窗口总有效目标数,能否仍严格实现预先定义的加权批目标?提出有界 CPU 实验与必要状态,不要求使用 GPU 混合精度。

    提交内容
    在线约定、原型与等价验收
    怎样算完成
    • 信息何时可得写明
    • 近似方案需明确标注

    练到的知识点不等长微批的分母 · 统计等价的前提

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗