跳转到主要内容

训练基础:一个 Step 的执行图、前向、反向与优化器

用一个 training step 串起执行图、权重、激活、梯度与优化器状态,建立训练显存与算力的基础账本

· 约 8 分钟阅读

本文目录

推理一个 step 只做一次前向;训练一个 step 要做「前向 + 反向 + 参数更新」三段,并且显存里同时住着四份数据:权重、梯度、优化器状态和激活。理解训练系统——显存为什么爆、分布式为什么要切片、checkpointing 为什么值得——都从这一个 step 的执行图开始。

30 秒复习
  • 一句话:一个 training step = forward 算出 loss → backward 沿执行图逆序做链式法则求梯度 → optimizer 用梯度和历史状态更新权重;显存里同时住着 Θ、∇W、optimizer states 和 activations 四份数据。
  • 三个判断:执行图由前向算子序列决定,反向图是框架自动生成的镜像,不需要手写;激活显存正比于 B×S×L,是 batch size 的主要约束;Adam 系优化器让静态显存达到约 16 bytes/param(混合精度),远超权重本身。
  • 核心模型:算力账 ≈ 每 token 6P FLOPs(前向 2P + 反向 4P);显存账 = 静态 16P bytes(bf16 + AdamW)+ 激活(随 B、S 线性增长)。
  • 边界:16 B/param 与 6P/token 是「bf16 混合精度 + AdamW + 不重算」的经验账本;activation checkpointing、ZeRO 分片、8-bit optimizer 分别改写其中一项,不改语义。

1. 一个 Step 总览

训练循环的最小形态:

for batch, labels in dataloader:
    optimizer.zero_grad()        # 清空上一轮梯度
    logits = model(batch)        # ① 前向:逐层算子,顺带保存反向要用的激活
    loss = criterion(logits, labels)
    loss.backward()              # ② 反向:autograd 沿执行图逆序求 ∇W
    optimizer.step()             # ③ 更新:AdamW 用 m/v 状态改写 Θ

把这三段摊开,就是训练的执行图:

训练执行图:一个 Step 的前向、反向与参数更新

三条流向对应三种数据:

  • 蓝色(前向数据流):batch tokens 与权重 Θ 进入 forward,逐层产出激活,最后得到 logits 和标量 loss。前向顺手把反向要用的激活「记入账本」。
  • 琥珀色(反向梯度流):从 loss 出发逆序遍历算子,每一步做两件事——算出对输入的梯度 dL/dX 继续向上游传,算出对权重的梯度 dL/dW 留下来。反向要回头读前向保存的激活(虚线)。
  • 绿色(参数更新回路):optimizer 把 ∇W 和自己的状态(Adam 的 m、v)合成更新量,写回 Θ;下一个 step 用新权重重复全过程。

2. 执行图是什么样子的

前向代码里的每个算子(GEMM、LayerNorm、Softmax、GELU……)是图的一个节点,张量是节点之间的边。以一个两层 MLP 为例:

forward:   x ─▶ [Linear1] ─▶ a ─▶ [GELU] ─▶ h ─▶ [Linear2] ─▶ y ─▶ [CE] ─▶ loss
backward:  dx ◂─ [Linear1'] ◂─ da ◂─ [GELU'] ◂─ dh ◂─ [Linear2'] ◂─ dy ◂─ [CE'] ◂─ 1
                 └─▶ dW1                                            └─▶ dW2

要点:

  • 反向图是前向图的逆序镜像。PyTorch eager 模式下,每执行一个前向算子就在输出张量上挂一个 grad_fn,反向时 autograd 从 loss 沿 grad_fn 链逆序回调,每个算子的 backward 实现产出 dL/dX(继续上传)和 dL/dW(挂到对应参数上)。反向不需要也不应该手写。
  • 反向依赖前向的中间值。比如 Y = X·W 的 wgrad 是 dW = Xᵀ·dY——必须留着前向的输入 X 才能算。这就是 save_for_backward 的来源,也是激活显存的来源:反向不是免费重放,前向必须替它「记账」。
  • 动态图 vs 静态捕获。eager 每个 step 重新建图,灵活但有 Python 调度开销;torch.compile、XLA 这类路径先捕获整张图(含反向)再执行,可以做算子融合、省 kernel launch。图是逻辑视图,物理执行仍然是一串 GPU kernel,在 trace 里能直接看到 forward/backward/step 三段的分界(见 GPU Trace 分析)。

3. 四份数据:权重、梯度、优化器状态、激活

一个 step 期间显存里的常驻与流动数据:

数据符号大小生命周期混合精度下的精度
权重ΘP常驻全程bf16 前向用 + fp32 master
梯度∇WPstep 内产生,step 末消费bf16
优化器状态m, v2P常驻全程fp32
master 权重Θ_fp32P常驻全程fp32
激活activations∝ B·S·L·Hforward 累积,backward 逐层释放bf16

前三份半(Θ、∇W、m、v、master)只随参数量 P 变化,与 batch、序列长度无关,合称静态显存。bf16 混合精度 + AdamW 的标准账本:

Mstatic=2P⏟bf16 Θ+2P⏟bf16 ∇W+4P⏟fp32 master+4P⏟m+4P⏟v=16 bytes/paramM_{\text{static}} = \underbrace{2P}_{\text{bf16 } \Theta} + \underbrace{2P}_{\text{bf16 } \nabla W} + \underbrace{4P}_{\text{fp32 master}} + \underbrace{4P}_{m} + \underbrace{4P}_{v} = 16 \text{ bytes/param}

7B 模型仅静态部分就是 112 GB——单卡 80 GB 的 H100 连「站着不动」都做不到,这是 ZeRO/FSDP 把这些状态切片分置的根本动机(切分后的组合见 Megatron 并行)。精度的具体分配见 混合精度训练。

第四份激活是训练特有、且唯一随 B 和 S 增长的部分,它是 batch size 的实际约束。作为对照:推理侧没有反向、没有优化器,显存只有权重(2P)加一份随上下文增长的 KV Cache;训练侧的状态账比推理重一个量级。

4. 前向:算 loss 的同时记账

前向逐层执行 Y = f(X; W),每个算子在算出输出的同时,把反向要用的量留下来:

  • GEMM:保存输入 X(wgrad 需要);
  • 非线性(GELU/SiLU):保存输入或输出(求导需要函数值);
  • Softmax:保存输出(反向用输出重算);
  • Dropout:保存 mask(反向按同一 mask 置零);
  • LayerNorm:保存均值、方差和归一化后的值。

最终 logits 对照 labels 算出标量 loss。loss 是整张图的终点,也是反向的起点——反向传播的种子就是 dL/dL = 1。

前向的算力账很整齐:每个参数在每层 GEMM 里对经过的每个 token 做一次乘加(2 FLOPs),即

Cforward≈2PFLOPs/tokenC_{\text{forward}} \approx 2P \quad \text{FLOPs/token}

5. 反向:链式法则的工程化

反向就是链式法则的机械执行:

∂L∂X=∂L∂Y⋅∂Y∂X,∂L∂W=XT⋅∂L∂Y\frac{\partial L}{\partial X} = \frac{\partial L}{\partial Y} \cdot \frac{\partial Y}{\partial X}, \qquad \frac{\partial L}{\partial W} = X^{\mathsf{T}} \cdot \frac{\partial L}{\partial Y}

对每个 GEMM 算子,backward 要算两个矩阵乘:dgrad(∂L/∂X,继续向上游传)和 wgrad(∂L/∂W,留下给 optimizer)。各需 2P FLOPs/token:

Ctotal=2P⏟forward+2P⏟dgrad+2P⏟wgrad=6PFLOPs/tokenC_{\text{total}} = \underbrace{2P}_{\text{forward}} + \underbrace{2P}_{\text{dgrad}} + \underbrace{2P}_{\text{wgrad}} = 6P \quad \text{FLOPs/token}

这就是「训练算力 ≈ 6ND」(N = 参数量,D = 训练 token 数)的来源,估算训练时长和算力成本的第一公式。

反向同时是激活显存的「释放过程」:每算完一层的反向,该层保存的激活就可以释放。所以一个 step 的显存曲线是——前向爬坡(激活逐层堆积)→ loss 处到峰 → 反向下坡(激活逐层释放、梯度累积)→ step 原地更新。

Activation checkpointing 打破「前向必须全量记账」的默认:只保存少数层边界激活,反向到某层时先用边界激活就地重跑一遍该层前向,把中间激活补出来。显存从随 L 线性压到只存边界,代价是多约一次前向的算力(6P → 8P per token)。它改执行图不改语义,是「时间换显存」的元操作,也是长序列大 batch 训练的标配。机制细节与 selective 重算见 Activation Checkpointing。

6. 优化器:从 SGD 到 AdamW

优化器回答一个问题:拿到 ∇W 之后,这一步走多大、朝哪走。

  • SGD:Θ ← Θ − η·g。零额外状态,但对 loss 曲面尺度敏感,大模型训练基本不用裸 SGD。
  • SGD + Momentum:m ← β·m + g; Θ ← Θ − η·m。多存一份动量(+4P fp32),方向更稳。
  • AdamW(当前主流):
m←β1m+(1−β1) g,v←β2v+(1−β2) g2m \leftarrow \beta_1 m + (1-\beta_1)\, g, \qquad v \leftarrow \beta_2 v + (1-\beta_2)\, g^2 m^=m1−β1t,v^=v1−β2t,Θ←Θ−η(m^v^+ϵ+λΘ)\hat{m} = \frac{m}{1-\beta_1^t}, \qquad \hat{v} = \frac{v}{1-\beta_2^t}, \qquad \Theta \leftarrow \Theta - \eta \left( \frac{\hat{m}}{\sqrt{\hat{v}} + \epsilon} + \lambda \Theta \right)

m 是梯度的一阶滑动平均(方向),v 是二阶平均(逐参数的步长归一),bias correction 修正冷启动,λ 是解耦的 weight decay。代价是第 3 节里那 12P 的 fp32 状态。

优化器还有三个常配套出现的部件:

  • Gradient clipping:按全局范数截断梯度,防单步爆炸,先于 step 执行;
  • LR schedule:warmup + cosine/linear decay,η 不是常数;
  • 省状态变体:8-bit Adam、Adafactor、Lion 等压缩 m/v 的存储,改写但不推翻 16P 账本的结论。

7. 从单卡到分布式

本文的执行图是单卡语义视图。分布式并行改的是「数据住在哪里、通信插在哪条边上」,不改图本身:

  • DP:每卡一份完整执行图和静态状态,反向末尾对 ∇W 做 all-reduce;
  • TP/PP:把图本身切开,层内切张量或层间切段,通信插进前向反向的算子之间(切分见 Megatron 并行、调度气泡见 流水线并行、推理侧对照见 推理并行);
  • ZeRO/FSDP:把静态三份(Θ、∇W、optimizer states)按卡切片,用通信换回显存(机制见 ZeRO 与 FSDP);
  • Activation checkpointing:改图的记账方式(第 5 节),与上面任意组合叠加。

排查训练 OOM 的顺序也按这个账本来:先动激活(调小 B/S、开 checkpointing),再动静态(上分片或换省状态优化器)。

相关页面