PyTorch 训练框架内核深度解析
第10章 优化器与梯度更新
"Note that we default to foreach and pass False to use_fused. This is not a mistake--we want to give the fused impl bake-in time before making it the default, even if it is typically faster."
——
torch/optim/adam.py,adam()函数内的注释(v2.11.0)
本章要点
Optimizer基类是个简单的"参数容器 + step 方法":核心数据结构是param_groups(list of dict)和state(per-param 状态)。基类不算梯度更新,子类(SGD / Adam / AdamW)实现step()- 三档性能模式:
single_tensor(朴素 for 循环)、multi_tensor / foreach(一次操作所有参数,用torch._foreach_*批量算子)、fused(一个融合 kernel 算完所有参数 + 状态更新;v2.11 里 CPU / CUDA / MPS 等都有实现) capturable=True让 optimizer 兼容 CUDA Graph:不依赖 Python 端 step counter,所有状态保留在 GPU 张量里zero_grad(set_to_none=True)是默认行为:把param.grad设为 None 而非 inplace 写零,省一次 zero kernel 调用- param_groups 是分组学习率的核心:每个 group 可以有自己的 lr / weight_decay 等。learning rate scheduler 只是在 group 上调 lr 字段
- state_dict 与 ckpt:optimizer 的状态(如 Adam 的 momentum / variance)也能保存加载,但要留意 param_groups 的 ID 与新模型的 ID 对齐
10.1 一行代码引发的疑问
每次训练循环都长这样:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for batch in dataloader:
optimizer.zero_grad()
loss = model(batch).sum()
loss.backward()
optimizer.step()
optimizer.step() 这一行做的事说起来简单 —— "用梯度更新参数"。但仔细想要回答:
- optimizer 怎么知道要更新哪些参数?
- Adam 的 momentum / variance 状态存在哪?
- step 内部怎么并行处理几百万个参数?
- 参数张量成百上千,为什么 step 不会成为训练的瓶颈?
答案在 torch/optim/optimizer.py(1193 行)和 torch/optim/adam.py(991 行)等具体优化器实现里。本章拆它的设计。
10.1.1 优化器在训练中的位置
把第 7-9 章的内容串起来,optimizer 是训练循环的最后一环:
graph LR
F["forward<br/>model(batch) → loss"] --> B["loss.backward<br/>反向图执行<br/>填 param.grad"]
B --> O["optimizer.step<br/>用 grad 更新 param"]
O --> Z["optimizer.zero_grad<br/>清梯度"]
Z --> F
style O fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
optimizer 拿到的输入是已经填好的 param.grad(第 7 章 §7.6 AccumulateGrad 的产物),它把 grad 转成 param 的更新。optimizer 完全不参与 forward 与 backward —— 它就是个 "param 与 grad → param" 的纯函数(加上 momentum / variance 等状态)。这种"职责清晰"的拆分让 optimizer 可以独立演进、独立测试,与模型无关。
这种契约让 PyTorch 内置的十几种 optimizer(v2.11 的 torch/optim/__init__.py 导出 15 个:SGD/Adam/AdamW/Adagrad/Adadelta/RMSprop/LBFGS/RAdam/NAdam/...)都能用同一个 optim.step() 接口工作。第三方扩展(如 Apex 的 FusedLAMB)也都遵循这个契约 —— 不需要改 PyTorch 主仓就能集成。
10.2 Optimizer 基类:参数与状态的容器
torch/optim/optimizer.py:342 是基类:
class Optimizer:
def __init__(self, params: ParamsT, defaults: dict[str, Any]) -> None:
self.defaults = defaults # 全局默认参数 (lr, eps...)
# 6 种 hooks,先于一切初始化
self._optimizer_step_pre_hooks = OrderedDict()
self._optimizer_step_post_hooks = OrderedDict()
self._optimizer_state_dict_pre_hooks = OrderedDict()
self._optimizer_state_dict_post_hooks = OrderedDict()
self._optimizer_load_state_dict_pre_hooks = OrderedDict()
self._optimizer_load_state_dict_post_hooks = OrderedDict()
...
self.state: defaultdict[torch.Tensor, Any] = defaultdict(dict)
self.param_groups: list[dict[str, Any]] = []
param_groups = list(params)
if len(param_groups) == 0:
raise ValueError("optimizer got an empty parameter list")
if not isinstance(param_groups[0], dict):
param_groups = [{"params": param_groups}] # 用户传 list 时包装成 group
for param_group in param_groups:
self.add_param_group(cast(dict, param_group))
核心数据结构两个:
param_groups是list[dict]。每个 dict 至少有'params'key(参数列表)+ defaults 里的所有字段(lr / weight_decay 等)。支持每个 group 独立配置(如 backbone 用低 lr,head 用高 lr)state是defaultdict[Tensor, dict]。每个参数张量映射到一个状态 dict(Adam 里包含step/exp_avg/exp_avg_sq)。lazy 初始化:第一次 step 时才创建状态
graph TB
Opt[Optimizer 实例]
Opt --> PG["param_groups: list[dict]<br/>──────────<br/>group 0: {'params': [...], 'lr': 0.001, 'weight_decay': 0.01}<br/>group 1: {'params': [...], 'lr': 0.0001}"]
Opt --> S["state: defaultdict[Tensor, dict]<br/>──────────<br/>p1 → {'step': tensor(100.), 'exp_avg': tensor, 'exp_avg_sq': tensor}<br/>p2 → {'step': tensor(100.), 'exp_avg': tensor, 'exp_avg_sq': tensor}"]
Opt --> D["defaults: dict<br/>{'lr': 0.001, 'betas': (0.9, 0.999), ...}"]
Opt --> H["6 种 hooks<br/>(step pre/post, sd pre/post, load_sd pre/post)"]
style PG fill:#dbeafe,stroke:#3b82f6
style S fill:#fef3c7,stroke:#f59e0b
10.2.1 为什么 param_groups 是 list[dict] 而不是简单 list
新手以为 optimizer 就是"一组参数 + 一个 lr"。实际生产代码常见写法是分组:
backbone_params = list(model.backbone.parameters())
head_params = list(model.head.parameters())
optimizer = torch.optim.AdamW([
{'params': backbone_params, 'lr': 1e-5, 'weight_decay': 0.01},
{'params': head_params, 'lr': 1e-3, 'weight_decay': 0.0},
])
backbone 通常已经预训练,用低 lr 微调;head 是新加的,用高 lr 训练。这种"按 group 分配 hyperparameter"在迁移学习几乎是标配。param_groups 的设计就是为这个场景。
每个 dict 里没指定的字段从 defaults 继承。所以你可以只写差异 —— PyTorch 自动补全。
10.2.2 为什么 state 用 defaultdict[Tensor, dict]
state 是 per-param 状态:每个参数 p 对应一个 state[p] dict。Adam 的 state[p] 包含:
{
'step': torch.tensor(100.), # 步数:0-d float32 张量;capturable/fused 时放在参数所在 device,否则在 CPU
'exp_avg': tensor(shape=p.shape), # 一阶动量 m
'exp_avg_sq': tensor(shape=p.shape), # 二阶动量 v
}
第一次 step 时 PyTorch 给每个参数初始化这些状态(exp_avg / exp_avg_sq 全零)。后续每步更新它们。
为什么用 defaultdict 而非普通 dict?因为 lazy 初始化:访问 state[p] 时如果没有,自动返回空 dict。这避免了在 __init__ 里就为每个参数预分配状态(那时还不知道用户会不会真的训练)。
10.2.3 state 的内存账
state 占的内存惊人。Adam 每个 param 要 exp_avg + exp_avg_sq 两份与 param 同 shape 同 dtype 的张量。对 70B 模型:
- params: 70B × fp32 = 280 GB(或 fp16 = 140 GB)
- gradients: 70B × fp32 = 280 GB
- exp_avg: 70B × fp32 = 280 GB
- exp_avg_sq: 70B × fp32 = 280 GB
合计 1120 GB!没有任何单卡能装下。这是为什么 70B 训练必须用 FSDP/ZeRO 把这套 state 切到多卡。第 18 章 FSDP 章会展开 ZeRO-1/2/3 三档怎么分别分片"params / grads / optimizer state"。
理解 state 的内存占用,也就明白 SGD 在显存上的优势 —— SGD 的 momentum_buffer 只是参数量的 1 倍,不是 Adam 的 2 倍。在显存极致紧张的场景,"用 SGD 换显存"是一个可考虑的选项(代价是收敛行为与调参经验都要重来)。
10.2.4 add_param_group 的动态扩展
add_param_group(param_group) 让你在 optimizer 创建后动态加新参数组:
optimizer = torch.optim.AdamW(model.backbone.parameters(), lr=1e-5)
# 后来加了新 head, 想给它高 lr
optimizer.add_param_group({'params': model.head.parameters(), 'lr': 1e-3})
这种"事后追加"在 fine-tuning 场景常见 —— 加载预训练 backbone 后再加自己的 head。运行时的 state 以参数张量本身为 key(整数 ID 只在 state_dict() 里出现),新 group 的参数本来就没有 state,所以追加 group 不会破坏已有 state。
新加 group 时所有字段从 defaults 继承(除非用户在 dict 里显式指定)。这种"局部覆盖 + 全局兜底"的 dict 合并模式让用户可以只写差异、不重复指定相同字段,与 React Props 的 spread 操作思想一致。
但要注意:add_param_group 的参数必须不在已有任何 group 里,否则 PyTorch 会报错(避免参数被重复更新)。这种"参数唯一性"检查让 optimizer 不会出现"双更"灾难。
10.2.5 differentiable optimizer
各内置优化器传给 Optimizer.__init__ 的 defaults 里都有 differentiable: False。这个标志让 optimizer 自身的 step 操作可以被 autograd 跟踪 —— 用于 meta-learning 这种"对 optimizer 求二阶导"的场景。
正常训练 differentiable=False,所有 inplace 更新都在 with torch.no_grad(): 里跑(避免反向图爆炸)。Meta-learning 时 differentiable=True,step 内部允许构建反向图,让 outer loop 能对 inner step 求导。这是给研究场景留的扩展点。
10.3 step() 的全流程
step() 是子类实现的,但基类用 _patch_step_function 包装了它,加入 hook 调用:
# torch/optim/optimizer.py:564
def _patch_step_function(self) -> None:
self._zero_grad_profile_name = (
f"Optimizer.zero_grad#{self.__class__.__name__}.zero_grad"
)
hooked = getattr(self.__class__.step, "hooked", None)
if not hooked:
self.__class__.step = self.profile_hook_step(self.__class__.step)
self.__class__.step.hooked = True
注意它打的补丁是打在类上(self.__class__.step = ...),所以用 hooked 属性做幂等标记,避免同一个 optimizer 类被创建多次时套上一层又一层 wrapper。包装体 profile_hook_step(torch/optim/optimizer.py:511)里第一件事就是开一个名为 Optimizer.step#{类名}.step 的 record_function —— 这正是你在 profiler trace 里看到 Optimizer.step#AdamW.step 的来源,也是量化 optimizer 占比的抓手。
调 optimizer.step() 时实际跑的是包装版:
sequenceDiagram
autonumber
participant U as 用户: optimizer.step()
participant W as profile_hook_step (包装)
participant Pre as step_pre_hooks
participant S as 真实 step (子类实现)
participant Post as step_post_hooks
U->>W: step()
W->>Pre: 跑所有 pre-hook
Pre-->>W: 可能修改 args
W->>S: 进入子类的 step (Adam.step / SGD.step)
Note over S: 1. 遍历 param_groups<br/>2. 对每个 param 取出 grad<br/>3. 取出 state, lazy init<br/>4. 调用 _single/_multi/_fused 函数<br/>5. 写入 param 与 state
S-->>W: 完成
W->>Post: 跑所有 post-hook
W-->>U: 返回
10.3.1 step 接受 closure 参数
Optimizer.step(closure=None) 的 closure 参数让某些 optimizer(如 LBFGS)可以多次重新算 loss:
def closure():
optimizer.zero_grad()
loss = compute_loss(...)
loss.backward()
return loss
optimizer.step(closure)
LBFGS 等"二阶"优化器需要在 line search 中多次评估同一点的 loss / grad,closure 提供了"如何重新算 loss"的回调。普通 SGD/Adam 不需要这个,把 closure 设为 None 即可。
理解 closure 的存在能让你看 PyTorch 源码时不困惑:每个 optimizer 的 step 签名都接受 closure,但 99% 的优化器代码里都是 if closure is not None: ... else: ... 简单分支。这是 PyTorch 给 LBFGS 一类特殊 optimizer 留的扩展点。
10.3.2 6 种 hooks 的工程价值
回顾 §10.2 的 hook 列表(step pre/post、state_dict pre/post、load_state_dict pre/post),这 6 种 hook 各有典型用途(下面除注明源码出处的两处外,都是常见用法举例):
- step_pre_hook:step 前做检查或统计;v2.11 里
torch/distributed/_tools/mem_tracker.py就用全局 step pre/post hook 统计 optimizer step 期间的显存 - step_post_hook:参数更新后做 EMA(exponential moving average)、log 参数 norm 等;
torch/profiler/__init__.py在设置了KINETO_USE_DAEMON时用全局 post-hook 给 Kineto 计步 - state_dict_pre_hook:保存前做转换(如把 fp32 转 fp16 缩小 ckpt)
- state_dict_post_hook:保存后注入额外 metadata
- load_state_dict_pre_hook:加载前做版本迁移、key rename
- load_state_dict_post_hook:加载后做后处理(如重置 step counter)
理解 hook 的设计意图,你写训练框架时能用 hook 优雅扩展,而不是 monkey-patch optimizer 类。这种"打开扩展点"的工程文化让 PyTorch 生态既稳定又能持续演进。
10.4 SGD:最简优化器
torch/optim/sgd.py 的核心 _single_tensor_sgd:
def _single_tensor_sgd(params, grads, momentum_buffer_list, *,
weight_decay, momentum, lr, dampening, nesterov, ...):
for i, param in enumerate(params):
grad = grads[i]
if weight_decay != 0:
grad = grad.add(param, alpha=weight_decay)
if momentum != 0:
buf = momentum_buffer_list[i]
if buf is None:
buf = grad.detach().clone()
momentum_buffer_list[i] = buf
else:
buf.mul_(momentum).add_(grad, alpha=1 - dampening)
if nesterov:
grad = grad.add(buf, alpha=momentum)
else:
grad = buf
param.add_(grad, alpha=-lr) # 更新: param = param - lr * grad
——SGD 的全部数学就这一段:
- 应用 weight_decay:
grad += weight_decay * param - 应用 momentum:
buf = momentum * buf + (1-dampening) * grad - Nesterov(可选):再用 momentum buf 修正
- inplace 更新:
param -= lr * grad
add_ 等 inplace 操作在 with torch.no_grad(): 上下文里跑,避免参数更新被 autograd 记录。
注意 SGD 的状态只有 momentum_buffer(如果 momentum != 0),所以 SGD 的 state 比 Adam 少一半 —— Adam 的 m + v 是参数量的 2 倍,SGD 的 buf 只是 1 倍。
更深一层:常见的经验说法是 Adam 早期收敛快,SGD with momentum 收敛慢但在一些视觉任务上泛化更好。于是有研究提出先用 Adam、再切 SGD(如 Keskar & Socher 2017 的 SWATS,Improving Generalization Performance by Switching from Adam to SGD)。PyTorch 让这种"切 optimizer"非常容易:
opt_adam = torch.optim.AdamW(params, ...)
# train phase 1 with adam
opt_sgd = torch.optim.SGD(params, ...)
# train phase 2 with sgd
两套 optimizer state 互不影响,因为各自维护自己的 state dict。理解这种"optimizer 独立 state"是深度学习训练流水线设计的基础。
10.5 Adam:状态优化器的代表
torch/optim/adam.py:347 的 _single_tensor_adam(精简版):
def _single_tensor_adam(params, grads, exp_avgs, exp_avg_sqs, ..., state_steps, *,
beta1, beta2, lr, weight_decay, eps, ...):
for i, param in enumerate(params):
grad = grads[i]
exp_avg = exp_avgs[i] # 一阶动量
exp_avg_sq = exp_avg_sqs[i] # 二阶动量
step_t = state_steps[i]
step_t += 1 # 步数 +1(源码里这一步在 weight_decay 之前)
step = _get_value(step_t) # 非 capturable 路径:取成 Python 数
if weight_decay != 0:
if decoupled_weight_decay: # AdamW 走这条
param.mul_(1 - lr * weight_decay)
else: # Adam 走这条
grad = grad.add(param, alpha=weight_decay)
exp_avg.lerp_(grad, 1 - beta1) # m = beta1*m + (1-beta1)*g
exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1-beta2) # v = beta2*v + (1-beta2)*g^2
bias_correction1 = 1 - beta1 ** step
bias_correction2 = 1 - beta2 ** step
denom = (exp_avg_sq.sqrt() / sqrt(bias_correction2)).add_(eps)
param.addcdiv_(exp_avg, denom, value=-lr/bias_correction1)
# 等价 param -= lr * (m_hat / (sqrt(v_hat) + eps))
——Adam 的核心更新公式实现。注意:
- 大量 inplace 操作(
exp_avg.lerp_、exp_avg_sq.mul_):避免分配新张量 - bias correction 修正初期偏估计:开始几步 m / v 接近 0,要除以
1 - beta^step修正 addcdiv_是融合算子(a += value * b / c),一次做加 + 除 + scale,比拆开三步快
每步对每个参数都跑一次这套逻辑。对 70B 模型,参数量 7e10,每个元素十来次逐元素运算,单步 Adam 就是 10^11–10^12 量级的运算,而且 single_tensor 版还要为每个参数张量逐个发起这些算子调用。这就是为什么需要后面的优化模式。
10.5.1 与 SGD 的 Adam 字段对比
直观对比:
| 字段 | SGD | Adam |
|---|---|---|
| step counter | 不需要 | 需要 (用于 bias correction) |
| momentum buffer | momentum_buffer (1x param) | exp_avg (1x param) |
| 二阶动量 | 无 | exp_avg_sq (1x param) |
| 显存倍率 | 1x | 2x |
| 超参数 | lr, momentum(常调 1-2 个;另有 weight_decay / dampening / nesterov) | lr, beta1, beta2, eps, weight_decay (5 个) |
SGD 的简洁让它适合"显存极致紧张"或"超参数极致敏感"的场景。Adam 的丰富 state 让它在初始化早期收敛更快、对 lr 选择更宽容。两者各有优劣,今天大模型训练用 AdamW 居多,但 SGD 在某些 vision 任务(如 ImageNet 上的经典 ResNet 训练)仍很常用。
10.5.2 fused 算子的 Apex 渊源
NVIDIA 早期在 Apex 库里提供过 FusedAdam、FusedLAMB 等 CUDA 融合算子,那时 PyTorch 主仓还没有 fused 实现。后来主仓吸收了同样的思路(2022 年引入 CUDA fused Adam 的提交 5f26df0345,标题就是 "APEX style Fused Adam"),做成 torch.optim.Adam(fused=True) 这个内置选项 —— v2.11 里它对应 torch/optim/adam.py:803 的 _fused_adam,底层是 torch._fused_adam_ / torch._fused_adamw_ 这两个 ATen 算子。
这条历史让你理解:今天的 fused optimizer 不是 PyTorch 团队从零写的,是站在 Apex 几年优化经验的肩膀上。Apex 仍然存在并提供更激进的 fused 算子(如 LAMB、FusedSGD with Nesterov),但生产代码越来越多直接用 PyTorch 主仓的内置 fused —— 因为不依赖外部库更省心。
10.5.3 bias_correction 的数学解释
为什么 Adam 需要 bias correction?因为 exp_avg 和 exp_avg_sq 初始为零,前几步的指数滑动平均严重偏低估真实统计量。考虑只跑一步:
m_1 = beta1 * 0 + (1 - beta1) * g_1 = (1 - beta1) * g_1 = 0.1 * g_1 # 假设 beta1 = 0.9
m_1 只有 g_1 的 10%!如果直接用 m_1 做更新,前几步会非常迟缓。bias_correction1 = 1 - beta1^t 在 t=1 时是 0.1,把 m_1 除以这个值就还原成 g_1,消除偏置。
随着 t 增大,beta1^t 与 beta2^t 指数衰减到接近 0,两个 bias_correction 都趋近 1,修正就自动退场了。衰减有多快只取决于 beta:beta1=0.9 时几十步就基本归位,beta2=0.999 时要几千步。所以 bias correction 影响的是训练最开头那一段,而且 beta2 那一路的影响远比 beta1 持久 —— 这正是 warmup 常被认为与 Adam 早期方差估计不稳定有关的原因之一(这是社区里被广泛讨论的假说,不是本章能给出的证明;训练曲线上的其他现象也别一股脑归到 bias correction 头上)。
10.5.4 关于 inplace 操作与 autograd 的协调
注意 Adam 内部的 exp_avg.lerp_(...)、param.addcdiv_(...) 都是 inplace。如果不在关掉梯度的上下文里跑,对 requires_grad=True 的叶子参数做 inplace 更新会被 autograd 直接拒绝,抛 "a leaf Variable that requires grad is being used in an in-place operation."(CPU 实跑确认)。
关梯度的不是 profile_hook_step(它只负责 record_function 和 hooks),而是内置优化器 step 上的另一个装饰器 _use_grad_for_differentiable(torch/optim/optimizer.py:59):它在调用 step 前执行 torch.set_grad_enabled(self.defaults["differentiable"]),结束后恢复原状态。differentiable=False(默认)时 step 内部就等价于 no_grad,所以函数体里可以放心地用 inplace 操作。
基类不会替子类关梯度。如果你写自定义 optimizer 忘了给 step 加 @torch.no_grad(),第一步就会报上面那个错。这个错误在 PyTorch 错误信息里足够明显,但新手不熟悉时会困惑半天。
10.5.5 AdamW vs Adam:weight decay 的微小差别
v2.11 里 AdamW 直接是 Adam 的子类(torch/optim/adamw.py:20,自 v2.7 起如此):它只是把 decoupled_weight_decay=True 传给 Adam.__init__,所以 Adam(..., decoupled_weight_decay=True) 与 AdamW 的更新完全一致(CPU 实跑逐元素相等)。另一个容易忽略的差别是默认值:AdamW 的 weight_decay 默认 0.01,Adam 默认 0。两者在数学上的区别是 weight_decay 应用方式:
# Adam: weight_decay 加到 grad 里 (污染 momentum)
grad = grad + weight_decay * param
# AdamW: weight_decay 直接从 param 里减 (不污染 momentum)
param = param - lr * weight_decay * param
数学上不等价 —— Adam 里 L2 项混进梯度后,会和梯度一起被 sqrt(v_hat) 归一化,历史梯度大的参数实际受到的正则反而更弱;AdamW 的衰减绕开这层归一化,对所有参数按同一比例 lr * weight_decay 收缩。论文 Decoupled Weight Decay Regularization(Loshchilov & Hutter,ICLR 2019;arXiv 2017)的实验表明,解耦后 weight decay 与学习率的最优取值更加独立,Adam 的泛化性能也明显改善。注意 PyTorch 的实现把衰减系数乘上了 lr(param.mul_(1 - lr * weight_decay)),论文算法里衰减项只乘调度系数 η_t、不乘步长 α,两边的 weight_decay 数值不能直接照搬。
今天公开的大模型训练配置大多用 AdamW(如 LLaMA 论文写明使用 AdamW)。Adam 在某些 fine-tuning 场景仍有用,但作为默认选择已经被淘汰。
10.6 三档性能模式:foreach / fused / capturable
考虑大模型场景:一个几十层的 transformer,每层有 attention 的 QKVO 四个投影加 MLP 的两三个投影,再算上各类 norm 的 weight/bias —— 参数张量的个数通常是几百到上千。single_tensor 循环里每个张量都要跑 5-6 个算子,dispatcher 调用因此是"张量数 × 算子数"的量级。每次 dispatcher 调用本身很便宜(第 5 章 §5.8 讨论过),但乘上这个系数之后,optimizer.step 就能变成训练 step 里一个肉眼可见的固定开销。
PyTorch 提供三档优化路径:
graph LR
Single["single_tensor (朴素)<br/>for p in params: update(p)<br/>开销 ∝ 张量数 × 算子数"]
Foreach["multi_tensor / foreach<br/>torch._foreach_add_(params, grads, ...)<br/>开销 ∝ 算子数 (张量数被吃进一次调用)"]
Fused["fused (CUDA kernel 融合)<br/>一个 kernel 处理所有 param + state<br/>开销 ∝ 常数"]
Single -->|每 op 调一次 dispatcher| ForeachSlow[N 次 dispatcher]
Foreach -->|一次 dispatcher 跑所有张量| ForeachFast[1 次 dispatcher + 内部 loop]
Fused -->|一次 kernel launch 完成所有数学| FusedFast[1 次 kernel launch]
style Single fill:#fee2e2,stroke:#ef4444
style Foreach fill:#fef3c7,stroke:#f59e0b
style Fused fill:#dcfce7,stroke:#22c55e
10.6.1 foreach (multi_tensor) 模式
torch._foreach_* 是一组特殊算子,一次操作多个张量:
# 朴素: N 次 dispatcher
for p, g in zip(params, grads):
p.add_(g, alpha=-lr)
# foreach: 一次 dispatcher
torch._foreach_add_(params, grads, alpha=-lr)
_foreach_add_ 在 CUDA 上跑 multi-tensor 融合 kernel,对张量列表做批量操作,dispatcher 开销除以 N;CPU 上它注册的是逐张量循环的 slow path(foreach_tensor_add_list_kernel_slow_),这也是 CPU 默认不走 foreach 的原因。
_multi_tensor_adam(torch/optim/adam.py:554)就是把 _single_tensor_adam 里所有 inplace 操作换成 foreach:
# 简化版
torch._foreach_add_(device_state_steps, 1) # 所有 step += 1
torch._foreach_lerp_(device_exp_avgs, device_grads, 1 - beta1) # 所有 m = lerp(m, g)
torch._foreach_mul_(device_exp_avg_sqs, beta2) # 所有 v *= beta2
torch._foreach_addcmul_(device_exp_avg_sqs, device_grads, device_grads, value=1-beta2)
...
它把"N 个张量各调一次算子"压成"一次算子调用内部循环 N 个张量",省下的正是那个 N 倍的框架开销 —— 参数张量越多、每个张量越小,省得越明显。v2.11 里 Adam / AdamW / SGD 等在满足条件时默认就走 foreach(见下面 §10.6.4)。
_foreach_* 算子的 CUDA 实现(aten/src/ATen/native/cuda/ForeachBinaryOpList.cu 等)用模板加宏(如 FOREACH_BINARY_OP_LIST)为常见 binary / unary 操作批量实例化 multi-tensor 版本。每个 _foreach 内部用一个 fused CUDA kernel 一次处理几百个张量,把 dispatcher overhead 与 launch overhead 都摊到一次 launch 上。
注意 foreach 有个隐藏约束:所有张量必须在同一 device、同一 dtype。Optimizer 的 multi_tensor 实现在跑 _foreach_* 之前会按 (device, dtype) 分组,每组单独 foreach。这种分组细节让 mixed-precision 训练(如 bfloat16 weight + fp32 master copy)能正确工作。
10.6.2 fused 模式:把整个 step 压成一个 CUDA kernel
fused 模式更激进 —— 把整个 Adam step(十几个 foreach 操作)压成 一个融合 kernel(每个 (device, dtype) 组还另有一次 _foreach_add_ 给 step 计数加 1):
# fused 的伪代码 (实际是 C++/CUDA 实现)
torch._fused_adam_(
params, grads,
exp_avgs, exp_avg_sqs, max_exp_avg_sqs,
state_steps,
lr=lr, beta1=beta1, beta2=beta2, weight_decay=weight_decay, eps=eps,
amsgrad=amsgrad, maximize=maximize,
)
相对 foreach,fused 进一步省下两样东西:
- kernel launch 次数:foreach 版 Adam 仍然是十几次
_foreach_*调用(每个数学步骤一次),fused 版把它们合成一次 - 中间张量的显存往返:foreach 每一步都要把
exp_avg/exp_avg_sq从显存读出、算完写回;fused 在一个 kernel 内把整条链算完,中间量留在寄存器里
启用方式:optimizer = torch.optim.Adam(params, fused=True)。但 fused 有限制:
- 只支持部分优化器(v2.11 里是 Adam / AdamW / SGD / Adagrad)和浮点参数;设备上 CUDA、CPU、MPS 等都有 kernel(
native_functions.yaml里_fused_adam_注册了 CPU / CUDA / MPS) - 不支持
differentiable=True - 数值上与 foreach 可能有末位差异(运算顺序与融合方式不同)
注意 fused 并不会被自动打开(原因见 §10.6.4),要显式传 fused=True。它在大模型训练里是常规优化,但收益占总 step 时间的比例完全取决于你的 optimizer.step 本来占多少 —— 先 profile,再决定值不值得。
10.6.3 capturable:兼容 CUDA Graph
capturable=True 是另一档优化。它解决一个特殊问题:CUDA Graph 录下的是一串固定的 kernel 与参数,replay 时 CPU 端的 Python 代码根本不会再跑;任何在 CPU 上算好、再作为常量塞进 kernel 的值,都会被固化成录制那一刻的值。
非 capturable 的 Adam 恰好有这样的代码:
step_t += 1 # step_t 是放在 CPU 上的 0-d 张量
step = _get_value(step_t) # .item() 取成 Python 数
bias_correction1 = 1 - beta1 ** step # 在 CPU 端算 bias correction,再作为标量传给 kernel
这种 "CPU 端算标量" 让 step 无法被 CUDA Graph 正确捕获。capturable=True 把 step 改成 GPU 张量 + 用 GPU 算子算 bias correction:
state["step"] = torch.zeros((), dtype=torch.float32, device=p.device) # GPU 上的 0-d 张量
# step += 1 改成 _foreach_add_(state_steps, 1)
# bias_correction = torch.pow(beta1, step) 在 GPU 上算
代价是少量 GPU 算子开销。收益是 整个 step 能进 CUDA Graph,配合 torch.compile 可以让训练 hot loop 几乎零 CPU 开销。
第 15 章 §15.7.2 讲 CUDA Graph 捕获时,就是把 forward + backward + optimizer.step() 整段录进 graph。
10.6.4 一个看起来微小但重要的细节:_default_to_fused_or_foreach
torch/optim/optimizer.py 里有个 _default_to_fused_or_foreach 函数。它不在 __init__ 里调用 —— __init__ 只把 foreach=None、fused=None 原样存进 defaults;每次 step 时,函数式入口(如 adam()、sgd())发现两者都是 None,才调它决定这一步走哪条路:
fused, foreach = _default_to_fused_or_foreach(params, differentiable, ...)
它的签名是 _default_to_fused_or_foreach(params, differentiable, use_fused=False)(torch/optim/optimizer.py:164),逻辑是:
torch.jit.is_scripting()或differentiable=True→ 两档都关,走 single_tensoruse_fused为真且所有 param 的 device 在_get_fused_kernels_supported_devices()里且是浮点 → fused- 否则所有 param 的 device 在
_get_foreach_kernels_supported_devices()里 → foreach - 都不满足 → single_tensor
这里有个关键事实经常被记错:fused 不会被自动打开。 torch/optim/adam.py:939 的调用点写死了 use_fused=False,源码注释解释得很清楚:
# Respect when the user inputs False/True for foreach or fused. We only want to change
# the default when neither have been user-specified. Note that we default to foreach
# and pass False to use_fused. This is not a mistake--we want to give the fused impl
# bake-in time before making it the default, even if it is typically faster.
if fused is None and foreach is None:
_, foreach = _default_to_fused_or_foreach(params, differentiable, use_fused=False)
torch/optim/sgd.py:283 同理。所以 Adam(params, lr=1e-3) 在 GPU 上默认拿到的是 foreach,不是 fused —— 想要 fused 必须自己传 fused=True。反过来,想强制走 single_tensor(如调试时),传 foreach=False, fused=False。
10.6.5 _foreach 算子的内部实现
_foreach_* 的 CUDA 实现散在 aten/src/ATen/native/cuda/Foreach*.cu 这一组文件里(ForeachBinaryOpList.cu、ForeachBinaryOpScalar.cu、ForeachPointwiseOp.cu、ForeachReduceOp.cu、ForeachTernaryOp.cu、ForeachUnaryOp.cu 等),共用的"一次 launch 处理一批张量"骨架在 aten/src/ATen/native/cuda/MultiTensorApply.cuh。思路可以用这样一段伪代码表达:
// 极简版 _foreach_add_ 内部 (简化)
template <typename scalar_t>
__global__ void multi_tensor_apply_kernel(
void** params, void** grads, int* sizes, scalar_t alpha) {
int t = blockIdx.x; // 第几个张量
scalar_t* p = (scalar_t*) params[t];
scalar_t* g = (scalar_t*) grads[t];
int n = sizes[t];
int i = threadIdx.x;
while (i < n) {
p[i] += alpha * g[i];
i += blockDim.x;
}
}
真实的 MultiTensorApply.cuh 比这段伪代码复杂:它把张量地址与尺寸打包进一个有大小上限的 TensorListMetadata 结构(因为 CUDA kernel 参数区容量有限),张量太多时分成多批、每批一次 launch;每个 chunk(不是每个张量)对应一组 block,这样大小悬殊的张量之间也能负载均衡。
理解这层实现,你能预判 foreach 的适用边界:它省的是"每张量一次框架调用"的固定开销,所以张量数量多时收益最大;如果你的模型只有寥寥几个巨大的参数张量,foreach 与 single_tensor 差别就不明显了。
10.6.6 多 device 并发的微妙性
multi_tensor 模式有个隐藏前提:所有张量都在同一 device 才能 fused。如果你的模型一部分在 cuda:0、一部分在 cuda:1(如 model parallel 场景),foreach 内部要按 device 分组分别跑:
分组的粒度实际是 (device, dtype) 两级 —— 混合精度训练里同一个 device 上同时有 bf16 与 fp32 张量,也必须分开。每个 (device, dtype) 组各跑一遍完整的 _foreach_* 序列,所以组数越多,"省掉 N 倍框架开销"这件事的分母就越小。
10.6.7 三档模式的开销结构
不背数字,只看每档的调用次数怎么随规模增长(设参数张量数为 T、Adam 每步的数学步骤数为 K ≈ 十几个):
| 模式 | 算子 / kernel launch 次数 | 随 T 增长 |
|---|---|---|
| single_tensor | T × K | 线性 |
| foreach (multi_tensor) | K × (device, dtype) 组数 | 与 T 无关 |
| fused | 约 1–2 × (device, dtype) 组数(融合 kernel + step 计数的 _foreach_add_) |
与 T 无关,且常数更小 |
| fused + capturable + CUDA Graph | graph replay,CPU 侧接近 0 | 与 T 无关 |
三档之间是数量级的差别,但差多少绝对时间完全取决于 T、K、张量大小与你的 GPU —— 换台机器结论就变。判断自己该走哪档的正确姿势只有一个:用 torch.profiler 抓一段训练,看 Optimizer.step#Adam.step 这个 record_function 区间占了整个 step 的多少。如果它只占百分之几,换 fused 的收益上限就是那百分之几。
10.6.8 三档模式的选择决策树
实战里怎么选?决策树:
flowchart TD
Start[选 optimizer 模式]
Start --> CPU{CPU 还是 GPU?}
CPU -->|CPU only| Single[默认 single_tensor<br/>Adam/AdamW/SGD/Adagrad 可显式 fused=True]
CPU -->|GPU| HasFused{支持 fused?<br/>Adam/AdamW/SGD/Adagrad?}
HasFused -->|是| Fused[fused=True<br/>最快]
HasFused -->|否, 如自定义 optimizer| Foreach[foreach 模式]
Foreach --> CG{要 CUDA Graph?}
CG -->|是| Capturable[capturable=True]
CG -->|否| ForeachOK[默认 foreach 已够]
style Fused fill:#dcfce7,stroke:#22c55e
简化版:
- 什么都不传 → CUDA 上是 foreach,CPU 不在 foreach 默认设备列表里、回落到 single_tensor,不会自动变 fused(CPU 实跑:
_default_to_fused_or_foreach返回(False, False)) - 想要 fused → 显式
AdamW(params, fused=True);它与foreach=True互斥,同时传会报 "fusedandforeachcannot beTruetogether." - 要 CUDA Graph / 编译 optimizer → 再加
capturable=True - 调试数值问题 →
foreach=False, fused=False退回 single_tensor,最容易逐行对照 - 自定义优化器(如 LAMB)→ 自己实现 foreach 版本才能享受这层加速
10.6.9 一个真实场景:torch.compile + capturable optimizer
torch.compile 默认编译的是 forward 与(经 AOTAutograd 的)backward,optimizer.step() 不在其中。把 step 也包进 torch.compile 是可行的,配合 capturable=True 能让整个 step 变成编译产物:
optimizer = torch.optim.AdamW(params, fused=True, capturable=True)
@torch.compile
def opt_step():
optimizer.step()
optimizer.zero_grad()
# 在训练循环里
for batch in loader:
loss = model(batch).sum()
loss.backward()
opt_step()
这种"编译 optimizer step" 是大模型训练 squeezing performance 的最后一刀。配合 CUDA Graph,整个 forward + backward + step 可以在 GPU 上完全异步执行,CPU 端几乎没有任何 dispatcher 开销。
第 15 章 torch.compile 端到端章会展开 CUDA Graph 这条路径。capturable 的意义不只在性能:torch/optim/optimizer.py 的 _disable_dynamo_if_unsupported 规定,编译时若 capturable=False 而 step 计数在 CUDA 上,就不追踪这段优化器函数、退回 eager 执行 —— 对 fused 这类把 step 计数放在 GPU 上的配置,想让 optimizer.step 真正进入编译器栈,capturable=True 是前提。
10.6.10 fused / capturable 与 dynamic shape
值得注意的边界情况:动态参数集("某些层在某些 batch 跳过"、运行时增减参数)。在 eager 下 foreach / fused 并不怕它 —— 每次 step 都从 p.grad is not None 的参数现拼张量列表(见 Adam._init_group),某层这一步没有梯度就不进列表,没有需要失效的缓存,也不会切换到 single_tensor。真正怕变化的是"录下来"的路径:CUDA Graph 录的是固定的一串 kernel 与固定地址,参数集一变就得重录;torch.compile 编译的 step 对输入列表有 guard,变化会触发重编译。生产代码建议固定模型架构,少用动态层 —— 这也是 torch.compile / FSDP 等特性的基础假设。
10.7 zero_grad:清梯度的两种语义
torch/optim/optimizer.py:1028:
def zero_grad(self, set_to_none: bool = True) -> None:
foreach = self.defaults.get("foreach", False) or self.defaults.get("fused", False)
...
with torch.autograd.profiler.record_function(self._zero_grad_profile_name):
for group in self.param_groups:
for p in group["params"]:
if p.grad is not None:
if set_to_none:
p.grad = None
else:
if p.grad.grad_fn is not None:
p.grad.detach_()
else:
p.grad.requires_grad_(False)
if not foreach or p.grad.is_sparse:
p.grad.zero_()
else:
per_device_and_dtype_grads[p.grad.device][p.grad.dtype].append(p.grad)
if foreach:
for per_dtype_grads in per_device_and_dtype_grads.values():
for grads in per_dtype_grads.values():
torch._foreach_zero_(grads)
set_to_none=True 是默认行为:直接把 p.grad 设 None。下次 backward 时 AccumulateGrad 看到 grad 是 None,会创建新张量;如果不是 None,会 inplace add。
set_to_none=False 是老行为:跑一次 zero kernel inplace 把 grad 清零。
差异:
| 维度 | set_to_none=True | set_to_none=False |
|---|---|---|
| zero kernel 调用 | 0 | N (每个 param 一次) |
| 显存 | 第一次 backward 后才分配 grad | grad 一直分配 |
| 性能 | 更快 | 更慢 |
| 与某些第三方代码兼容 | 可能不兼容(期望 grad 永远不是 None) | 兼容 |
省下的是 N 次(N = 参数张量数)zero kernel,参数越碎省得越多;第 7 章 §7.6.5 已经讨论过,这里不重复。
另外注意 v2.11 的 zero_grad 在 set_to_none=False 这条路上也做了 foreach 优化:如果 optimizer 的 defaults 里 foreach 或 fused 为真,它会先按 (device, dtype) 把所有 grad 收集起来,最后一次 torch._foreach_zero_(grads) 清完,而不是逐个 p.grad.zero_()。
注意一个容易被忽略的语义细节:set_to_none=True 让下一次 backward 第一次给 param.grad 赋值时,PyTorch 会新分配一个张量而不是 inplace 加。也就是说:
- set_to_none=True:每个 batch 的 grad 是新对象
- set_to_none=False:所有 batch 共享同一个 grad 对象(inplace clear)
如果你的代码持有 g = param.grad 引用、期望它在下一个 batch 仍指向有效梯度,set_to_none=True 会让你拿到旧值:g 仍是上一轮那个张量,新梯度在另一个新张量里(CPU 实跑:g is param.grad 为 False,g 保持旧值);set_to_none=False 时 g 与 param.grad 是同一对象,内容随新 backward 更新。这种隐性差异在用 hook 检查梯度的代码里偶尔出现。生产代码里不要 cache param.grad 引用跨 batch 用。
10.8 state_dict 与 ckpt
optimizer 也有 state_dict() / load_state_dict(),跟 nn.Module 类似但简单。一个 Adam optimizer 的 state_dict 大约:
{
'state': {
0: {'step': tensor(100.), 'exp_avg': tensor(...), 'exp_avg_sq': tensor(...)},
1: {'step': tensor(100.), 'exp_avg': tensor(...), 'exp_avg_sq': tensor(...)},
...
},
'param_groups': [
{'lr': 0.001, 'betas': (0.9, 0.999), ..., 'params': [0, 1, 2, ...]}
],
}
注意 state 的 key 不是 Tensor 对象(不能序列化),而是整数 ID。param_groups 里的 params 列表也是整数 ID。这套 ID 是 Optimizer.state_dict 里那个 pack_group 闭包(torch/optim/optimizer.py:744)现算的:它维护一个 param_mappings: dict[id(p) -> 序号] 和一个跨 group 累加的 start_index,按 group 顺序、group 内参数顺序依次编号。
具体的 ID 编号规则:optimizer 在 save 时按 param_groups 的顺序、每个 group 内 params 的顺序,给每个 param 分配从 0 开始的递增整数。所以 ID 0 是第一个 group 的第一个 param、ID 1 是第二个,依此类推。这种简单递增编号让 ckpt 在 resume 时只要参数顺序不变就能正确匹配。
如果你想跨"不同模型架构"加载 optimizer state(如 fine-tuning 时模型加了新层),就得用名字匹配 + 手动重建 state。HuggingFace 的 save_pretrained / from_pretrained 内部就有这套基于参数名的 state 迁移逻辑。PyTorch 这边只走到一半:如果 optimizer 是用 named_parameters() 构造的,add_param_group 会把名字记进 group 的 param_names(torch/optim/optimizer.py:1132-1144),state_dict 随 group 一并存下;add_param_group 还会校验所有 group "要么都带名字、要么都不带"(:1186)。但 load_state_dict 的文档写明名字不影响加载,真正的 state 匹配仍然是按 ID 顺序 zip 的,state_dict 文档里那句 "match state WITHOUT additional verification"(:705)说得很明白。
加载时 PyTorch 重新匹配新 optimizer 的参数与 ckpt 里的 ID:
new_optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
new_optimizer.load_state_dict(saved_state_dict)
# 每个 param 的 state 自动恢复
这个 ID 匹配机制有个微妙坑:新模型的参数顺序必须与保存时一致。如果你重新构造 model 时改了 layer 顺序,ID 对不上,state 会被张冠李戴地装到别的参数上。HuggingFace 等框架用 named_parameters() 的名字匹配规避这个坑;PyTorch 内置 optimizer 即便存了 param_names,匹配仍按 ID 顺序走。
10.8.1 optimizer ckpt 的体积
很多新手发现 torch.save(optimizer.state_dict()) 文件比 torch.save(model.state_dict()) 还大。这不是 bug —— Adam 每个 param 的 state(exp_avg + exp_avg_sq)大小与 param 一致,两个 state 张量加起来是 param 体积的 2 倍。所以 Adam optimizer ckpt ≈ model ckpt × 2。
这就是为什么生产代码里通常只保存 model state_dict 不保存 optimizer,除非要 resume training:
# 仅保存模型 (用于推理 / 部署 / 模型分享)
torch.save(model.state_dict(), 'model.pt')
# 保存完整训练状态 (用于 resume)
torch.save({
'model': model.state_dict(),
'optimizer': optimizer.state_dict(),
'scheduler': scheduler.state_dict(),
'epoch': epoch,
}, 'checkpoint.pt')
按 §10.2.3 那笔账算:一个 70B 参数的模型,若权重与 Adam 状态都是 fp32,model 部分约 280 GB、optimizer 部分约 560 GB —— 完整训练 ckpt 落到 TB 量级。这是纯算术推导(实际数字随 dtype、是否存 master weights、是否分片而变),但足以说明为什么大模型训练的 ckpt 存储本身就是个工程课题。
10.9 Learning Rate Scheduler:在 group 上调 lr
torch.optim.lr_scheduler 提供一组 scheduler:StepLR、ExponentialLR、CosineAnnealingLR、OneCycleLR、ReduceLROnPlateau 等。它们的实现都很简单 —— 改 param_group['lr'] 字段:
# 简化示意:真实的 StepLR 继承 LRScheduler,由 get_lr() 计算新 lr,
# last_epoch 从 -1 起步、构造时先做一次初始 step
class StepLR:
def __init__(self, optimizer, step_size, gamma):
self.optimizer = optimizer
self.step_size = step_size
self.gamma = gamma
self.last_epoch = 0
def step(self):
self.last_epoch += 1
if self.last_epoch % self.step_size == 0:
for group in self.optimizer.param_groups:
group['lr'] *= self.gamma
这套设计让 scheduler 与 optimizer 解耦 —— scheduler 不需要知道 optimizer 内部,只需要修改 param_groups。这种"在共享 dict 上协作"是 Python 库间互操作的优雅模式。
注意调用顺序:
optimizer.step() # 先用当前 lr 更新参数
scheduler.step() # 再调整 lr 给下一次用
PyTorch 1.1.0 之前的约定与此相反;自 1.1.0 起改成"先 optimizer.step()、再 scheduler.step()"。顺序写反会跳过 schedule 的第一个 lr 值,LRScheduler.step 检测到"scheduler 先于 optimizer step"时会发 UserWarning:"Detected call of lr_scheduler.step() before optimizer.step()..."(CPU 实跑确认)。
10.9.1 chained / sequential schedulers
实际训练经常组合多个 scheduler(如 warmup + cosine decay):
warmup = torch.optim.lr_scheduler.LinearLR(optimizer, start_factor=0.01, total_iters=1000)
cosine = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100000)
scheduler = torch.optim.lr_scheduler.ChainedScheduler([warmup, cosine])
ChainedScheduler 让多个 scheduler 串联,每个 scheduler 各自在 param_groups 上累积修改 lr。这套组合让"先线性 warmup 1000 步、再余弦退火"的复杂 schedule 用一两行代码搞定。
SequentialLR 是另一种组合:根据当前 step 数选不同 scheduler。比如前 1000 步 warmup,之后交给 cosine:
scheduler = torch.optim.lr_scheduler.SequentialLR(
optimizer,
schedulers=[warmup, cosine],
milestones=[1000])
理解这两种组合,你可以构造任意复杂的 lr schedule,不需要自己写 scheduler 子类。
10.9.2 LR Scheduler 与 optimizer 的隐式契约
scheduler 和 optimizer 之间有一个约定俗成的契约:scheduler 假设 optimizer 的 param_groups 里有 'lr' 字段。所有 PyTorch 内置 scheduler 都直接读写 group['lr']。
如果你写自定义 optimizer 没有 'lr' 字段(比如某个论文里的 "scale-invariant" optimizer),用 PyTorch scheduler 就要崩。修法:
class MyOptimizer(Optimizer):
def __init__(self, params, my_step_size=0.1):
# 把 my_step_size 别名为 'lr', 让 scheduler 能改它
defaults = dict(lr=my_step_size)
super().__init__(params, defaults)
这种"用约定俗成的字段名"是 Python 库间互操作的常见模式 —— 没有显式接口,但大家都知道某些字段名是 contract。
10.9.3 ReduceLROnPlateau:另一种 scheduler
ReduceLROnPlateau 与其他 scheduler 不同 —— 它的 step 接受一个 metric 参数:
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, factor=0.5, patience=10)
for epoch in range(100):
train(...)
val_loss = validate(...)
scheduler.step(val_loss) # 注意传入 val_loss
它会监控 val_loss,连续超过 10 个 epoch 没有改善(即第 11 个 epoch 仍无改善)时把 lr 减半(CPU 实跑确认)。这种"基于性能动态调整"在某些训练场景比固定 schedule 更好用。
接口上 ReduceLROnPlateau.step(metric) 与其他 scheduler.step() 不一致,是 PyTorch 历史遗产之一。如果你写 wrapper 库,要兼容这两种 step 签名。训练监控怎么接入,见第 21 章 §21.33。
10.9.4 一些 optimizer 与训练流水线的关键交互
1. DDP 与 optimizer step:DDP 在 backward 后做 AllReduce 同步梯度,完成后才能调 optimizer.step()。如果你 step 在 AllReduce 完成前调,每个 rank 的更新会基于本地梯度而非全局平均梯度,训练完全失效。DDP 通过 with model.no_sync(): 提供"梯度累积"模式让用户控制何时同步,但默认每 backward 都同步。第 17 章会展开。
2. 混合精度训练:fp16 训练时 grad 容易 underflow 成 0,导致 optimizer.step() 实际不更新参数。GradScaler 先把 loss 放大来防下溢;GradScaler.step(optimizer) 在更新前 unscale 并检查 grad 是否有 inf/nan,有就跳过这次更新,随后 update() 调小 scale。这套机制让 fp16 训练可以"动态调整 loss scale"维持训练稳定。第 20 章 量化与混合精度会展开。
3. ZeRO/FSDP 与 optimizer 分片:FSDP 把参数分片到多 rank,每个 rank 只持有自己的 1/N 参数与对应 1/N optimizer state。step 时每个 rank 只更新自己那份分片,下次 forward 用到时再 AllGather 拼回完整参数。这种分片让 params / grads / optimizer state 这几项的显存降到约 1/N。第 18 章会展开。
理解 optimizer 与这三个系统(DDP / GradScaler / FSDP)的协作,你就理解了大模型训练的工程全景。optimizer 看起来是单点,实际是整个训练流水线的串联点。
10.10 跨专栏关联
- 《vLLM 推理内核深度解析》专栏第 7 章(模型加载):vLLM 推理只加载 model state_dict,完全不需要 optimizer state —— 对照 §10.8.1 那笔账就知道,推理侧的 ckpt 天然比训练 ckpt 小一大截
- 《Tokio 源码深度解析》专栏第 12 章(异步 Mutex / RwLock / Semaphore):optimizer 与 scheduler 通过共享
param_groups这个可变 dict 协作,与 Tokio 里多个 Task 共享配置的模式相通 —— 都是"约定字段名而非显式接口" - 《MCP 协议设计与实现》专栏第 4 章(生命周期与能力协商):optimizer 的 per-param state 与 MCP server 的 per-session state 思路类似 —— 都是"按某个 key 维护独立状态"
- 《Serde 元编程》专栏第 12-13 章(派生宏代码生成):自定义 optimizer(LAMB、Sophia 等)新增 state 字段后也要能存进 state_dict,这与 Serde 派生的"按字段定义序列化"思想一致
10.11 几条工程经验
实战 optimizer 相关:
1. 想要 fused 必须自己传:v2.11 的默认是 foreach 而非 fused(§10.6.4 有源码注释为证)。GPU 训练想吃这层收益就写 fused=True,但先 profile 确认 Optimizer.step#Adam.step 这段确实占了可观比例
2. foreach 与 fused 互斥:两者同时传 True 会在构造时直接报 RuntimeError("fused and foreach cannot be True together.")
3. param_groups 用具名 dict 更可读:{'name': 'backbone', 'params': [...], 'lr': 1e-5} 加 name 字段,调试时打印能立刻看出哪个 group
4. resume training 时:load_state_dict(opt_state) 后 lr 会被恢复成 ckpt 时的值,不是你新设的 default。如果想重置 lr,要在 load 后手动改 for g in opt.param_groups: g['lr'] = new_lr
5. 学习率与 batch size 同步缩放:经典的 linear scaling rule(lr 与 batch size 成正比,见 Goyal et al. 2017 Accurate, Large Minibatch SGD)是针对 SGD 提出的;Adam 类优化器不宜照搬线性缩放,放大 batch 后的 lr 需要实验确定
6. AdamW 的 weight_decay 与 grad clipping 顺序:先 clip 再 step,weight_decay 在 step 内自动应用。如果手动写了 param -= weight_decay * param * lr,要小心不要重复 decay
7. CUDA Graph 训练务必 capturable=True:忘了这条会让 graph capture 失败或行为不一致
8. mixed precision 训练用 GradScaler 配合:fp16 训练时梯度容易下溢成 0,需要 torch.amp.GradScaler('cuda') 把 loss 放大、反向后再缩小(老写法 torch.cuda.amp.GradScaler 在 v2.11 已挂 @deprecated,见 torch/cuda/amp/grad_scaler.py:18)。这个 wrapper 内部也跟 optimizer 协作(在 step 前检查梯度是否 inf/nan)
9. 给 optimizer 加 hook 比 wrap 优于继承:要给 optimizer 加自定义行为(如 grad 监控、lr 自适应),用 register_step_pre_hook / register_step_post_hook 而非继承重写。这种 hook 模式让你的扩展不会破坏 internal state 的演进
10. 不要在训练循环中 optimizer = MyOpt(...):每次新建都会丢掉已积累的 state(momentum、exp_avg 等从零开始),下一次 step 还要重新分配全部 state 张量。optimizer 在训练前创建一次即可
10.12 自定义 optimizer 实战
把整章串起来,写一个 toy 带 momentum 的 SGD optimizer 演示完整套路:
class MySGD(torch.optim.Optimizer):
def __init__(self, params, lr=0.01, momentum=0.9):
defaults = dict(lr=lr, momentum=momentum)
super().__init__(params, defaults)
@torch.no_grad()
def step(self, closure=None):
loss = None
if closure is not None:
with torch.enable_grad():
loss = closure()
for group in self.param_groups:
lr = group['lr']
momentum = group['momentum']
for p in group['params']:
if p.grad is None:
continue
state = self.state[p]
if 'momentum_buffer' not in state:
state['momentum_buffer'] = torch.zeros_like(p)
buf = state['momentum_buffer']
buf.mul_(momentum).add_(p.grad)
p.add_(buf, alpha=-lr)
return loss
二十几行就实现了带 momentum 的 SGD(CPU 实跑:与 torch.optim.SGD(lr=0.1, momentum=0.9) 逐元素一致)。所有 PyTorch 内置 optimizer 都是这个套路:
__init__定义 defaults,调super().__init__step()在关梯度的上下文里跑(内置优化器用的是 §10.5.4 的_use_grad_for_differentiable,自定义时用@torch.no_grad()即可)- 遍历 param_groups → 遍历 params → lazy 初始化 state → 计算更新
接 closure 参数是为了支持 LBFGS 等需要"算 loss 多次"的优化器。普通 optimizer 忽略它即可。
理解这套模板,你可以快速实现各种学术论文里的新 optimizer(Sophia、Adan、Lion 等都是这个套路实现)。
如果你想让自定义 optimizer 也享受 foreach 加速,要再实现一个 _multi_tensor_my_sgd 函数,把所有 inplace 操作换成 torch._foreach_* 版本,然后在 step 里根据 foreach flag 选择路径。这是 PyTorch 内置 optimizer 的标准实现方式,在 torch/optim/sgd.py / adam.py 等文件里能看到完整模板。
写一个"生产级"optimizer 的代码量远超 toy 版:v2.11 的 torch/optim/adam.py 是 991 行、sgd.py 是 545 行,多出来的部分几乎全在 single_tensor / multi_tensor / fused 三套实现,加上 capturable、differentiable、complex、AMP 的 grad_scale/found_inf、TorchScript 兼容等边界处理。对学术 prototype 来说这太重了,所以很多论文的 reference implementation 只写 single_tensor 版本。如果你的论文 optimizer 被广泛采用,社区会有人贡献 multi_tensor 与 fused 版本。这是 PyTorch 优化器生态的演进路径。
10.13 一些常被忽略的 PyTorch optimizer 实现细节
把整章看完,几个不常被提及但在源码里反复出现的工程细节:
1. _to_scalar(x) 的处理:lr / betas 等超参既可以传 float,也可以传 1 元素 Tensor(配合 capturable)。_to_scalar(torch/optim/optimizer.py:231)把非 0 维的 1 元素 Tensor squeeze 成 0-d Tensor,float 原样返回,统一成后续算子可直接用的形式。这是 PyTorch 在"标量 vs 张量统一接口"上的工程胶水
2. differentiable=False 的 fast path:step 实现里散布着 if differentiable: ... else: ... 分支。non-differentiable 路径走纯 inplace + no_grad;differentiable 路径多做 clone 等处理以保留反向图,更慢但允许 meta-learning
3. grad_scale / found_inf 参数:fused optimizer 会设置 _step_supports_amp_scaling = True,GradScaler 看到后把 grad_scale 与 found_inf 两个张量挂到 optimizer 上(torch/amp/grad_scaler.py),Adam.step 再把它们传进 fused kernel,让 unscale 与 inf 处理也在 kernel 内完成,省掉单独的 unscale 遍历
4. complex 张量支持:has_complex 标志让 optimizer 在复数参数(罕见但支持)场景走专用代码路径。绝大多数代码走默认 real-only 路径
5. JIT script 的兼容性 hack:源码里大量 if torch.jit.is_scripting(): 分支处理 TorchScript 限制(如不能传 Tensor 当 lr)。这些是历史遗产,今天 TorchScript 已经被 torch.compile 替代但兼容代码仍在
理解这些细节,你看 _single_tensor_adam / _multi_tensor_adam 的几百行代码就不会被那么多 if 分支搞晕 —— 它们都对应"为某个特殊场景留的口子"。
10.14 几条 optimizer 设计的"通用启示"
把 optimizer 思想抽象到任何"状态化算子"系统:
第一:defaults + groups 双层配置:让用户既能"一刀切"(defaults)又能"按组定制"(param_groups)。这套模式在数据库连接池、HTTP 客户端、日志器等场景同样适用
第二:lazy 初始化 state:开始不知道用户怎么用,等第一次访问时按需创建。defaultdict 是 Python 实现这种 pattern 的最佳工具
第三:ID 而非对象作为持久化 key:state_dict 的 key 是整数 ID 而非 Tensor 对象,避免序列化复杂对象。这种"用整数代理对象"的模式在 ORM、缓存系统都常见
第四:性能 / 通用性多档:single_tensor → foreach → fused 三档对应"通用慢"到"专门快"。允许用户按场景选择,而不是"一种实现包打天下"
第五:hooks 让框架可扩展:optimizer step 前后开放 hook,让显存追踪、profiler 计步等工具能 hook 进来(§10.3.2)。这是与 nn.Module hook 思想一致的扩展点设计
第六:inplace 数学操作 + no_grad 上下文是底层数值更新的标配:避免分配中间张量、避免污染反向图,是任何"状态化更新"系统的最佳模式。游戏引擎的物理更新、数据库的索引更新都用这套思想
第七:显存不只是模型 — optimizer state 占同样级:开始一个新训练任务时除了算 model 显存,必须算上 optimizer state(Adam 是 2x 参数量)+ gradients(1x)+ activations(depends on batch size)。这个"四件套"显存账是大模型训练的基础数学
第八:closure 接口是给少数高级 optimizer 留的:99% 优化器不用 closure,但接口里始终预留它。这种"为少数边缘场景留扩展点"是稳定 API 设计的常见折衷
10.15 一段反思:optimizer 的"小而美"哲学
最后回顾一下 PyTorch optimizer 设计哲学。它的核心是让"如何更新参数"成为一个可替换的小模块:
- 接口窄:
step()+zero_grad()+state_dict()几乎是全部公开 API - 状态简单:param_groups (list of dict) + state (defaultdict)
- 与其他系统解耦:optimizer 不知道 model 是什么,只看到 params。model 不知道 optimizer 在做什么,只期待 step 后参数被更新
这套"窄接口 + 简单状态"让 optimizer 是 PyTorch 整个训练栈中最稳定的部分。Module、autograd、dispatcher 都重构过几次,optimizer 的接口从 v0.x 到 v2.11 几乎没变 —— 多年前写的自定义 optimizer 代码,今天大多还能直接跑。
这种"内部反复演进、接口长期稳定"的 trade-off 是 PyTorch 工程文化的体现。理解这条哲学,你看任何"长寿命接口设计"问题时都能更清楚地选择 —— 哪些放出去、哪些藏起来、哪些预留扩展点。
下一章拆 DataLoader:多进程数据流水线、pin_memory、collate_fn 怎么协作。