# 第 14 课:学习率、梯度累积与混合精度 这一课只解决一个问题:MiniMind 的训练循环如何把多个 micro-batch 的 loss 变成一次稳定的参数更新。 ## 目录 - [0. 本节主线](#l14-mainline) - [1. 原理讲解](#l14-principle) - [2. 源码阅读顺序图](#l14-reading-order) - [3. MiniMind 源码走读](#l14-source-walkthrough) - [4. 本节必须会写 / 暂时不要求](#l14-must-write) - [5. 手写模块](#l14-handwrite) - [6. 对齐测试](#l14-alignment-test) - [7. 阶段组装](#l14-stage-assembly) - [8. 本节检查](#l14-check) - [9. 下一课](#l14-next) ## 0. 本节主线 MiniMind 的一个训练 micro-step 是: ```text 取一个 batch -> 把 input_ids / labels 放到 device -> 按当前全局 step 设置 learning rate -> autocast forward 得到 lm loss 和 aux loss -> loss 除以 accumulation_steps -> backward,把梯度累积到参数上 -> 每 accumulation_steps 个 micro-step 做一次 optimizer step -> step 前 unscale + clip grad -> step 后 scaler.update + zero_grad -> epoch 结束时处理不足 accumulation_steps 的尾巴 ``` 这一课的核心不是“调用 `loss.backward()`”,而是看懂这四件事如何配合: ```text learning rate schedule gradient accumulation mixed precision / GradScaler gradient clipping + optimizer.step ``` ## 1. 原理讲解 ### 1.1 一个训练 step 到底更新了什么 模型训练的目标是让参数 $\theta$ 沿着 loss 下降的方向移动。 如果一个 batch 的训练损失是 $L(\theta)$,反向传播计算的是: $$ g = \nabla_{\theta} L(\theta) $$ 优化器做的事可以粗略理解成: $$ \theta_{\text{new}} = \theta_{\text{old}} - \eta \cdot \mathrm{Update}(g) $$ 这里: | 符号 | 形状 | 含义 | |---|---|---| | $\theta$ | 每个参数自己的形状 | 模型参数 | | $L$ | 标量 | 当前 batch 的训练损失 | | $g$ | 和参数同形状 | 反向传播得到的梯度 | | $\eta$ | 标量 | learning rate | 对 MiniMind 来说,训练 loss 通常是: $$ L_{\text{train}} = L_{\text{lm}} + L_{\text{aux}} $$ 其中 $L_{\text{lm}}$ 是 next-token loss,$L_{\text{aux}}$ 是 MoE router 的辅助损失。非 MoE 模型里,`aux_loss` 通常就是 0。 ### 1.2 学习率不是固定不变的 Learning rate 控制每次参数更新走多大一步。太大容易震荡,太小训练太慢。 MiniMind 的 `get_lr` 用的是余弦衰减,但不是衰减到 0,而是从初始学习率衰减到 10% 初始学习率。 公式是: $$ \eta_s = \eta_0 \left(0.1 + 0.45\left(1 + \cos\left(\frac{\pi s}{T}\right)\right)\right) $$ 其中: | 符号 | 形状 | 含义 | |---|---|---| | $s$ | 标量 | 当前全局 micro-step | | $T$ | 标量 | 总 micro-step 数 | | $\eta_0$ | 标量 | 命令行传入的初始学习率 | | $\eta_s$ | 标量 | 当前 step 实际使用的学习率 | 代入两个边界: $$ \begin{aligned} \eta_0^{\text{actual}} &= \eta_0 \left(0.1 + 0.45(1 + 1)\right) = \eta_0 \\ \eta_T &= \eta_0 \left(0.1 + 0.45(1 - 1)\right) = 0.1\eta_0 \end{aligned} $$ 也就是说,它从 `learning_rate` 平滑降到 `0.1 * learning_rate`。 ### 1.3 梯度累积解决什么问题 显存放不下很大的 batch 时,可以用多个小 batch 模拟一个大 batch。 假设: | 符号 | 形状 | 含义 | |---|---|---| | $B$ | 标量 | 单个 micro-batch 的 batch size | | $K$ | 标量 | `accumulation_steps` | | $B_{\text{eff}}$ | 标量 | 等效 batch size | 单卡非 DDP 时: $$ B_{\text{eff}} = B \times K $$ 如果是 DDP,多卡训练还要乘以进程数 $W$: $$ B_{\text{eff}} = B \times K \times W $$ 为什么要把 loss 除以 $K$? 如果第 $i$ 个 micro-batch 的原始 loss 是 $L_i$,MiniMind 用的是: $$ \tilde{L}_i = \frac{L_i}{K} $$ 连续反向传播 $K$ 次后,参数上累积的梯度是: $$ \begin{aligned} g &= \sum_{i=1}^{K}\nabla_{\theta}\tilde{L}_i \\ &= \sum_{i=1}^{K}\nabla_{\theta}\left(\frac{L_i}{K}\right) \\ &= \frac{1}{K}\sum_{i=1}^{K}\nabla_{\theta}L_i \end{aligned} $$ 这相当于取了 $K$ 个 micro-batch 梯度的平均值,而不是把它们直接相加。否则 `accumulation_steps` 越大,梯度尺度越大,学习率也会被隐式放大。 ### 1.4 混合精度做了什么 混合精度的目标是减少显存和加速计算。 MiniMind 的 forward 放在 `autocast_ctx` 里: ```text with autocast_ctx: res = model(...) loss = ... ``` 这表示某些矩阵乘法可以用 `float16` 或 `bfloat16` 执行,而不是全部用 `float32`。 但 `float16` 动态范围小,梯度可能 underflow。`GradScaler` 的思想是先把 loss 放大: $$ L_{\text{scaled}} = c \cdot L $$ 反向传播得到放大后的梯度: $$ g_{\text{scaled}} = c \cdot g $$ 真正更新前再除回来: $$ g = \frac{g_{\text{scaled}}}{c} $$ MiniMind 只在 `dtype == float16` 时启用 `GradScaler`。如果是 `bfloat16`,代码仍然调用 `scaler.scale(...)`,但 scaler 是 disabled 状态,本质上是 no-op。 ### 1.5 为什么要先 unscale 再 clip grad 梯度裁剪限制梯度范数,避免一次异常梯度把参数推太远。 如果最大范数是 $C$,裁剪可以理解成: $$ g_{\text{clipped}} = g \cdot \min\left(1, \frac{C}{\lVert g \rVert_2}\right) $$ 如果用了 `GradScaler`,参数上的梯度可能还是放大后的 $g_{\text{scaled}}$。这时直接 clip,裁剪的就不是原始梯度。 所以 MiniMind 的顺序是: ```text scaler.unscale_(optimizer) -> clip_grad_norm_ -> scaler.step(optimizer) -> scaler.update() ``` 先 unscale,再 clip,最后 step。 ### 1.6 变量形状 训练循环里最重要的张量形状是: | 变量 | 形状 | 含义 | |---|---|---| | `input_ids` | `[B, S]` | token id | | `labels` | `[B, S]` | 训练目标,忽略位置是 `-100` | | `logits` | `[B, S, V]` | 每个位置对词表的预测 | | `res.loss` | 标量 | causal LM loss | | `res.aux_loss` | 标量 | MoE aux loss,非 MoE 时通常是 0 | | `loss` | 标量 | `(res.loss + res.aux_loss) / accumulation_steps` | | `param.grad` | 和参数同形状 | 当前累计梯度 | 注意:`loss` 是标量,不是 `[B, S]`。前面第 10 课已经讲过,cross entropy 会把有效 token 的 loss 聚合成一个标量。 ## 2. 源码阅读顺序图 这节源码按这个顺序读: ```text trainer_utils.get_lr -> train_pretrain.py 参数 -> autocast / GradScaler 初始化 -> train_epoch 前半段:lr + forward + loss / accumulation_steps -> train_epoch 中段:backward + unscale + clip + step + zero_grad -> train_epoch 末尾:不足 accumulation_steps 的尾批处理 ``` 对应文件: ```text trainer/trainer_utils.py trainer/train_pretrain.py ``` 先看 `get_lr`,是为了知道每个 step 用什么学习率。 再看 `train_epoch`,是为了知道 loss、梯度、optimizer step 如何串起来。 最后看初始化部分,是为了知道 `autocast_ctx` 和 `GradScaler` 是怎么决定的。 ## 3. MiniMind 源码走读 ### 第 1 步:学习率公式 File: `trainer/trainer_utils.py:40-41` Read this to understand: MiniMind 的 learning rate schedule 不是 PyTorch scheduler,而是每个 step 手动计算。 Code/config/template excerpt: ```python def get_lr(current_step, total_steps, lr): return lr*(0.1 + 0.45*(1 + math.cos(math.pi * current_step / total_steps))) ``` This code shows: - `current_step` 是当前训练进度。 - `total_steps` 是总训练步数。 - 返回值直接写入 optimizer 的 param group。 - 它是 cosine decay,终点是 `0.1 * lr`,不是 0。 ### 第 2 步:训练参数决定 batch、lr、累积步数和裁剪阈值 File: `trainer/train_pretrain.py:87-95` Read this to understand: 训练机制的关键超参都来自命令行参数。 Code/config/template excerpt: ```python parser.add_argument("--epochs", type=int, default=2, help="训练轮数") parser.add_argument("--batch_size", type=int, default=32, help="batch size") parser.add_argument("--learning_rate", type=float, default=5e-4, help="初始学习率") parser.add_argument("--dtype", type=str, default="bfloat16", help="混合精度类型") parser.add_argument("--accumulation_steps", type=int, default=8, help="梯度累积步数") parser.add_argument("--grad_clip", type=float, default=1.0, help="梯度裁剪阈值") ``` This code shows: - `batch_size` 是单个 micro-batch 的大小。 - `accumulation_steps` 决定多少个 micro-batch 做一次 optimizer update。 - `learning_rate` 是 schedule 的初始值。 - `dtype` 决定 autocast 用 `float16` 还是 `bfloat16`。 - `grad_clip` 决定梯度裁剪上限。 ### 第 3 步:初始化 autocast 和 GradScaler File: `trainer/train_pretrain.py:119-138` Read this to understand: 混合精度由 `autocast_ctx` 和 `GradScaler` 两部分组成。 Code/config/template excerpt: ```python device_type = "cuda" if "cuda" in args.device else "cpu" dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16 autocast_ctx = nullcontext() if device_type == "cpu" else torch.cuda.amp.autocast(dtype=dtype) ... scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == 'float16')) optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate) ``` This code shows: - CPU 上不用 autocast,直接是 `nullcontext()`。 - CUDA 上会进入 `torch.cuda.amp.autocast(dtype=dtype)`。 - `bfloat16` 只用 autocast,不启用 GradScaler。 - `float16` 同时使用 autocast 和 GradScaler。 - optimizer 是 AdamW。 ### 第 4 步:每个 micro-step 设置学习率 File: `trainer/train_pretrain.py:24-34` Read this to understand: 学习率不是 epoch 级别更新,而是每个 micro-step 更新。 Code/config/template excerpt: ```python for step, (input_ids, labels) in enumerate(loader, start=start_step + 1): input_ids = input_ids.to(args.device) labels = labels.to(args.device) last_step = step lr = get_lr(epoch * iters + step, args.epochs * iters, args.learning_rate) for param_group in optimizer.param_groups: param_group['lr'] = lr ``` This code shows: - `step` 从 `start_step + 1` 开始,支持断点续训。 - `epoch * iters + step` 是跨 epoch 的全局 step。 - 每个 param group 都会被写入当前 lr。 - `input_ids` 和 `labels` 先搬到训练 device。 ### 第 5 步:forward、loss 和梯度累积缩放 File: `trainer/train_pretrain.py:35-40` Read this to understand: MiniMind 是先求完整 loss,再除以 `accumulation_steps`。 Code/config/template excerpt: ```python with autocast_ctx: res = model(input_ids, labels=labels) loss = res.loss + res.aux_loss loss = loss / args.accumulation_steps scaler.scale(loss).backward() ``` This code shows: - `res.loss` 是语言模型主 loss。 - `res.aux_loss` 是 MoE 辅助 loss。 - `loss / accumulation_steps` 是为了让累积梯度保持平均尺度。 - `scaler.scale(loss).backward()` 兼容 float16 AMP;如果 scaler disabled,就等价于普通 backward。 ### 第 6 步:到达累积步数后才更新参数 File: `trainer/train_pretrain.py:42-50` Read this to understand: 不是每个 micro-batch 都调用 optimizer step。 Code/config/template excerpt: ```python if step % args.accumulation_steps == 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True) ``` This code shows: - 只有 `step % accumulation_steps == 0` 时才更新参数。 - `unscale_` 必须在 `clip_grad_norm_` 前面。 - `scaler.step(optimizer)` 内部会在 scaler enabled 时检查溢出。 - `optimizer.zero_grad(set_to_none=True)` 清掉已使用的梯度,为下一组累积做准备。 ### 第 7 步:日志里的 loss 要乘回去 File: `trainer/train_pretrain.py:51-59` Read this to understand: 训练内部用的是缩放后的 loss,但日志要展示原始尺度。 Code/config/template excerpt: ```python current_loss = loss.item() * args.accumulation_steps current_aux_loss = res.aux_loss.item() if res.aux_loss is not None else 0.0 current_logits_loss = current_loss - current_aux_loss current_lr = optimizer.param_groups[-1]['lr'] ``` This code shows: - `loss` 已经除过 `accumulation_steps`。 - 打日志时乘回 `accumulation_steps`,得到原始训练 loss 尺度。 - `current_logits_loss` 是总 loss 减掉 aux loss。 - `current_lr` 从 optimizer 里读当前实际学习率。 ### 第 8 步:epoch 结束时处理尾巴 File: `trainer/train_pretrain.py:75-80` Read this to understand: 如果最后剩下不足 `accumulation_steps` 的梯度,也要做一次 optimizer step。 Code/config/template excerpt: ```python if last_step > start_step and last_step % args.accumulation_steps != 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True) ``` This code shows: - 如果 epoch 里真的跑过 batch,`last_step > start_step`。 - 如果最后一个 step 不是累积边界,说明还有没 step 的梯度。 - 这时需要 flush tail,否则最后几个 micro-batch 的梯度会被丢掉。 ## 4. 本节必须会写 / 暂时不要求 必须会写: 1. MiniMind 的 cosine lr: $$ \eta_s = \eta_0 \left(0.1 + 0.45\left(1 + \cos\left(\frac{\pi s}{T}\right)\right)\right) $$ 2. 梯度累积的 loss 缩放: $$ \tilde{L} = \frac{L}{K} $$ 3. optimizer step 判断: $$ \text{should\_step}(s, K) = \begin{cases} \text{True}, & s \bmod K = 0 \\ \text{False}, & s \bmod K \ne 0 \end{cases} $$ 4. 尾批 flush 判断: $$ \text{flush\_tail} = (s_{\text{last}} > s_{\text{start}}) \land (s_{\text{last}} \bmod K \ne 0) $$ 5. AMP update 顺序: ```text scale loss -> backward -> unscale grad -> clip grad -> optimizer step -> scaler update -> zero grad ``` 暂时不要求: ```text 1. DDP 梯度同步细节 2. checkpoint / resume 的完整状态恢复 3. torch.compile 对训练循环的影响 4. wandb / swanlab 日志工程 5. PPO / GRPO 里的 scheduler 版本 ``` 这一课只要求你能手写 pretrain/SFT 这类监督训练最小循环。 ## 5. 手写模块 本节你要补的是: ```text course/impl/core/train_loop.py ``` ### 5.1 补 `cosine_lr` 接口: ```python def cosine_lr(current_step: int, total_steps: int, base_lr: float) -> float: ... ``` 对齐源码: ```text trainer/trainer_utils.py:40-41 ``` 你要实现的行为: - 输入 `current_step`、`total_steps`、`base_lr`。 - 输出和 MiniMind `get_lr` 完全一致的 float。 - 不要调用原项目的 `get_lr`,要自己写公式。 ### 5.2 补 `scale_loss_for_accumulation` 接口: ```python def scale_loss_for_accumulation(loss: torch.Tensor, accumulation_steps: int) -> torch.Tensor: ... ``` 对齐源码: ```text trainer/train_pretrain.py:35-40 ``` 你要实现的行为: $$ \tilde{L} = \frac{L}{K} $$ 其中 $K$ 就是 `accumulation_steps`。 ### 5.3 补 `should_step_optimizer` 接口: ```python def should_step_optimizer(step: int, accumulation_steps: int) -> bool: ... ``` 对齐源码: ```text trainer/train_pretrain.py:42 ``` 你要实现的行为: ```text step=1, K=4 -> False step=2, K=4 -> False step=3, K=4 -> False step=4, K=4 -> True ``` ### 5.4 补 `should_flush_tail` 接口: ```python def should_flush_tail(last_step: int, start_step: int, accumulation_steps: int) -> bool: ... ``` 对齐源码: ```text trainer/train_pretrain.py:75 ``` 你要实现的行为: ```text last_step=10, start_step=0, K=4 -> True last_step=8, start_step=0, K=4 -> False last_step=0, start_step=0, K=4 -> False ``` ### 5.5 补 `train_one_epoch` 接口已经在骨架里。第一版只需要对齐监督训练主循环,不要写 DDP、wandb、checkpoint。 对齐源码: ```text trainer/train_pretrain.py:24-80 ``` 建议实现顺序: ```text 1. 遍历 loader,得到 input_ids / labels。 2. 搬到 device。 3. 用 cosine_lr 设置 optimizer lr。 4. 进入 autocast_ctx。 5. 调 model(input_ids, labels=labels)。 6. loss = outputs.loss + outputs.aux_loss。 7. loss = scale_loss_for_accumulation(loss, accumulation_steps)。 8. scaler.scale(loss).backward()。 9. 如果 should_step_optimizer 为 True: unscale -> clip -> step -> update -> zero_grad。 10. epoch 结束后,如果 should_flush_tail 为 True: 再执行一次 unscale -> clip -> step -> update -> zero_grad。 ``` 注意: - 如果 `scaler is None`,你可以创建 `torch.cuda.amp.GradScaler(enabled=False)`,让 CPU 路径也能跑。 - 如果 `autocast_ctx is None`,可以用 `contextlib.nullcontext()`。 - `outputs.aux_loss` 可能是 0 标量,不要特殊跳过。 ## 6. 对齐测试 本节新增对齐测试: ```text course/impl/tests/test_train_loop_mechanics.py ``` 运行命令: ```bash cd /home/sun/minimind python course/impl/tests/test_train_loop_mechanics.py ``` 现在还没有实现时,这个测试会因为 `NotImplementedError` 失败。等你补完前四个小函数后,它应该打印类似: ```text lr_step_0_diff=0.000000000000 lr_step_1_diff=0.000000000000 ... scaled_loss=2.000000 optimizer_step_micro_steps=[4, 8] train_loop_mechanics=passed ``` 这个测试先不检查完整 `train_one_epoch`,因为完整训练循环需要模型、数据和 optimizer 共同参与。先把机制函数对齐,再进入阶段组装更稳。 另外可以观察原项目 tiny 训练 step: ```bash cd /home/sun/minimind python course/labs/trace_pretrain_step.py --batch_size 2 --max_length 48 --hidden_size 64 --num_hidden_layers 2 ``` 这个实验会打印: ```text input_ids.shape labels.shape logits.shape loss aux_loss lr grad_norm_before_clip max_first_param_delta_after_step ``` ## 7. 阶段组装 本节完成后,Pretrain 阶段会多出训练循环核心能力: ```text course/impl/core/train_loop.py::cosine_lr course/impl/core/train_loop.py::scale_loss_for_accumulation course/impl/core/train_loop.py::should_step_optimizer course/impl/core/train_loop.py::should_flush_tail course/impl/core/train_loop.py::train_one_epoch ``` 它们以后会接到: ```text course/impl/train_pretrain_impl.py ``` 阶段目标是: ```text dataset -> DataLoader -> CourseMiniMindForCausalLM -> train_one_epoch -> save course_pretrain checkpoint ``` 当前 Pretrain 阶段还缺: ```text 1. 教学版 CausalLM 完整 forward/loss 2. 教学版 pretrain dataset 或 dataset 适配 3. checkpoint 保存和加载 4. tiny pretrain 脚本组装 ``` 第 14 课只先把训练循环的机械部分写清楚。 ## 8. 本节检查 1. MiniMind 的 `get_lr` 起点和终点分别是多少? 2. 为什么梯度累积时要把 loss 除以 `accumulation_steps`? 3. 如果 `batch_size=4`,`accumulation_steps=8`,单卡训练的等效 batch size 是多少? 4. `autocast` 和 `GradScaler` 分别解决什么问题? 5. 为什么 `clip_grad_norm_` 前要先 `scaler.unscale_(optimizer)`? 6. `step % accumulation_steps == 0` 控制的是什么? 7. 为什么 epoch 结束时还要处理不足 `accumulation_steps` 的尾巴? 8. 写出 `input_ids`、`labels`、`logits`、`loss`、`param.grad` 的形状。 ## 9. 下一课 第 15 课讲 checkpoint 与断点续训。 下一课要解决: ```text 保存哪些状态; 为什么只保存 model 不够; optimizer / scaler / epoch / step 如何恢复; SkipBatchSampler 如何跳过已经训练过的 batch; 普通权重文件和 resume checkpoint 有什么区别。 ```