Rust in Everything
基于 burn 0.22.1

优化器状态

主线位置:训练主线的优化器一步,看它跨步保存的东西:Adam 的一阶、二阶矩和步数,学习率调度器的位置,以及每步更新前对梯度做的裁剪。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB
  subgraph pt ["PyTorch"]
    po["opt.state"] -->|"键"| pp["Parameter 对象"]
    sd["state_dict"] -->|"键"| pi["序号 0 到 3"]
  end
  subgraph bn ["burn"]
    mo["ModuleOptimizer"] -->|"键"| id["ParamId"]
    rec["优化器记录"] -->|"名字前缀"| id
  end
  pp -.-> same["exp_avg exp_avg_sq step"]
  id -.-> same2["moment_1 moment_2 time"]

实验代码:PyTorch 侧 adam10.py、clip.py、sched.py、rebuild.py 和几个对照脚本,burn 侧 crate train-adam(主程序和 bin clip、sched、rebuild、defaults、factors),模型和输入同一步(输入两边逐位相同的那一版)。burn 侧 feature 同模板,release 构建。平台 M2 Max、macOS 27、rustc 1.99.0。

Adam 训练 10 步

A1 的模型和数据,同样的初值,Adam 学习率 0.01,同一批数据训练 10 步,每步记下更新前的 loss 和更新后的 67 个参数:

kw = {"eps": float(sys.argv[1])} if len(sys.argv) > 1 else {}
opt = torch.optim.Adam(model.parameters(), lr=0.01, **kw)
out = {}
for step in range(1, 11):
  loss = nn.functional.cross_entropy(model(x), y)
  opt.zero_grad()
  loss.backward()
  opt.step()
  out[f"s{step:02}"] = {"loss": loss.item(), "params": {n: p.tolist() for n, p in model.named_parameters()}}
let config = match std::env::args().nth(1).and_then(|s| s.parse::<f32>().ok()) {
  Some(eps) => AdamConfig::new().with_epsilon(eps),
  None => AdamConfig::new(),
};
let mut optim = config.init();
let mut out = Map::new();
for step in 1..=10 {
  let loss = ce.forward(model.forward(x.clone()), y.clone());
  let loss_v: f64 = loss.clone().into_scalar::<f64>();
  let grads = GradientsParams::from_grads(loss.backward(), &model);
  model = optim.step(LR, model, grads);
  out.insert(format!("s{step:02}"), json!({ "loss": loss_v, "params": params_json(&model) }));
}

先各用各的默认值。按步打印两边 loss 的差和 67 个参数的最大差:

step  loss_a       loss_b       |dloss|   max|dparam|  where
  01  1.14005756  1.14005744  1.19e-07  9.44e-05     fc2.weight[8]
  02  1.10198629  1.10199320  6.91e-06  1.23e-04     fc2.weight[8]
  03  1.06721914  1.06722951  1.04e-05  1.42e-04     fc2.weight[8]
  04  1.03404224  1.03405893  1.67e-05  1.58e-04     fc1.weight[29]
  05  1.00236571  1.00239074  2.50e-05  1.80e-04     fc1.weight[29]
  06  0.97519988  0.97522748  2.76e-05  1.99e-04     fc1.weight[29]
  07  0.94985896  0.94989014  3.12e-05  2.19e-04     fc2.weight[9]
  08  0.92572045  0.92575532  3.49e-05  2.48e-04     fc2.weight[9]
  09  0.90281999  0.90285790  3.79e-05  2.75e-04     fc2.weight[9]
  10  0.88104737  0.88108951  4.21e-05  3.01e-04     fc2.weight[9]

第一步就差 9.44e-5,compare.py 报 worst=3.01e-04 tol=1e-06 MISMATCH,退出码 1。原因是 ε 的默认值不同:两边打印出的配置是

{'lr': 0.01, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0, 'amsgrad': False, 'maximize': False, 'foreach': None, 'capturable': False, 'differentiable': False, 'fused': None, 'decoupled_weight_decay': False}
AdamConfig { beta_1: 0.9, beta_2: 0.999, epsilon: 1e-5, amsgrad: false, weight_decay: None, grad_clipping: None }

PyTorch 1e-8,burn 1e-5(AdamConfig 上的 #[config(default = 1e-5)])。把任何一边改成和另一边一样(pt_default 对 burn_eps1e-8:burn 改成 with_epsilon(1e-8);pt_eps1e-5 对 burn_default:PyTorch 改成 eps=1e-5),差别就回到 f32 舍入的量级:

== pt_default vs burn_eps1e-8
step  loss_a       loss_b       |dloss|   max|dparam|  where
  01  1.14005756  1.14005744  1.19e-07  1.49e-08     fc1.weight[8]
  02  1.10198629  1.10198629  0.00e+00  5.96e-08     fc1.weight[3]
  03  1.06721914  1.06721926  1.19e-07  1.49e-07     fc1.weight[3]
  04  1.03404224  1.03404260  3.58e-07  1.79e-07     fc1.weight[3]
  05  1.00236571  1.00236607  3.58e-07  2.09e-07     fc2.weight[11]
  06  0.97519988  0.97520036  4.77e-07  2.38e-07     fc1.weight[13]
  07  0.94985896  0.94985944  4.77e-07  2.98e-07     fc1.weight[13]
  08  0.92572045  0.92572105  5.96e-07  3.58e-07     fc1.weight[13]
  09  0.90281999  0.90282059  5.96e-07  4.17e-07     fc1.weight[13]
  10  0.88104737  0.88104808  7.15e-07  4.77e-07     fc1.weight[23]
== pt_eps1e-5 vs burn_default
step  loss_a       loss_b       |dloss|   max|dparam|  where
  01  1.14005756  1.14005744  1.19e-07  2.98e-08     fc1.weight[4]
  02  1.10199320  1.10199320  0.00e+00  5.96e-08     fc1.weight[0]
  03  1.06722939  1.06722951  1.19e-07  1.79e-07     fc2.weight[5]
  04  1.03405857  1.03405893  3.58e-07  1.79e-07     fc2.weight[3]
  05  1.00239038  1.00239074  3.58e-07  2.38e-07     fc2.weight[15]
  06  0.97522700  0.97522748  4.77e-07  2.38e-07     fc2.weight[3]
  07  0.94988978  0.94989014  3.58e-07  2.98e-07     fc2.weight[3]
  08  0.92575479  0.92575532  5.36e-07  3.58e-07     fc2.weight[3]
  09  0.90285730  0.90285790  5.96e-07  4.17e-07     fc2.weight[3]
  10  0.88108885  0.88108951  6.56e-07  4.47e-07     fc2.weight[3]

ε 对齐后,第 10 步参数最大差 4.77e-7、loss 差 7.15e-7,compare.py(容差 1e-6)通过,连 fc2.bias 的 Adam 状态也一起比了(见下一节):

s10.params.fc2.bias      n=  3  max|diff|=3.65e-07
state_fc2_bias.moment_1  n=  3  max|diff|=1.12e-07
state_fc2_bias.moment_2  n=  3  max|diff|=1.35e-09
worst=7.15e-07 tol=1e-06 OK
📝NOTE
没有蠢问题

问: ε 只是分母上加的一个小数,1e-5 和 1e-8 为什么在第一步就让参数差出 9.44e-5?

答: 因为 Adam 的第一步几乎是 lr · g / |g|:第一步的 m̂ 就是 g,√v̂ 就是 |g|,更新量是 lr · g / (|g| + ε)。差最大的 fc2.weight[8] 梯度只有 1.05e-3(PyTorch 输出里是 0.001047986326739192),ε = 1e-5 时分母大了约 0.95%,这一步就少走 0.01 × 0.95% ≈ 9.5e-5,正是表里的 9.44e-5。梯度越小的参数,ε 的影响越大;梯度在 0.1 量级的参数几乎不受影响。从 PyTorch 搬 Adam,要么 AdamConfig::new().with_epsilon(1e-8),要么接受这个差别。

对齐之后为什么还在涨

对齐 ε 后,参数的最大差从第 1 步的 1.49e-8 一路涨到第 10 步的 4.77e-7,每步多 3e-8 到 9e-8。A1 那一步 SGD 只差一个 ulp,这里却越走越远。是 f32 舍入在 Adam 里累积,还是两边算的不是同一个公式?把两边都放到 f64 里跑,f32 舍入就退出了:PyTorch 侧把 A1 的 f32 初值转成 float64,betas 和 ε 取 burn 实际用的 f32 值(0.9 和 0.999 在 f32 里是 0.8999999761581421 和 0.9990000128746033);burn 侧先 Device::flex().configure(FloatDType::F64)。下面 ref_eps1e-8 是 PyTorch 的 f64 运行(adam10_ref.py),burn_f64_eps1e-8 是 burn 的 F64 运行:

== ref_eps1e-8 vs burn_f64_eps1e-8
step  loss_a       loss_b       |dloss|   max|dparam|  where
  01  1.14005747  1.14005747  0.00e+00  2.23e-10     fc2.weight[8]
  02  1.10198631  1.10198631  8.26e-10  3.44e-08     fc1.bias[4]
  03  1.06721917  1.06721928  1.08e-07  1.04e-07     fc2.bias[1]
  04  1.03404225  1.03404258  3.24e-07  1.34e-07     fc2.bias[1]
  05  1.00236574  1.00236615  4.06e-07  1.58e-07     fc2.bias[1]
  06  0.97519991  0.97520032  4.13e-07  1.93e-07     fc1.weight[28]
  07  0.94985895  0.94985943  4.71e-07  2.49e-07     fc1.weight[28]
  08  0.92572048  0.92572106  5.74e-07  2.95e-07     fc1.weight[28]
  09  0.90282000  0.90282063  6.32e-07  3.45e-07     fc1.weight[28]
  10  0.88104737  0.88104807  7.01e-07  3.78e-07     fc1.weight[28]

f64 里仍然差 3.78e-7,和 f32 时差不多大。所以这不是张量运算的舍入,是两边每一步的更新量本身就不一样。差在 AdaptiveMomentum::transform 算偏差修正的这两行:

        let time = state.time as i32;
        let bias_correction2_sqrt = (1.0 - self.beta_2.powi(time)).sqrt();
        let combined_factor = bias_correction2_sqrt / (1.0 - self.beta_1.powi(time));

beta_1、beta_2 是 f32 字段,这两个标量全程在 f32 里算,张量是 F64 也一样。1 − β₂ᵗ 在前几步只有 0.001 到 0.01,而 β₂ᵗ 本身接近 1,它在 f32 里的舍入误差(1 附近一个 ulp 是 6e-8)落到 1 − β₂ᵗ 上就成了 1e-5 量级的相对误差。PyTorch 在 Python 的 float(f64)里算同样的标量。bin factors 照抄上面三行(beta 用 black_box 包住,免得编译器在编译期把 powi 算掉),和 f64 的结果比:

 t  burn f32       f64            rel err
 1  0.316225648  0.316225655  -2.23e-08
 2  0.235314354  0.235315158  -3.42e-06
 3  0.202007860  0.202009255  -6.91e-06
 4  0.183767155  0.183767705  -3.00e-06
 5  0.172497287  0.172497706  -2.43e-06
 6  0.165106505  0.165107063  -3.38e-06
 7  0.160128638  0.160129522  -5.52e-06
 8  0.156769529  0.156770224  -4.44e-06
 9  0.154555887  0.154556630  -4.81e-06
10  0.153187588  0.153188071  -3.15e-06

第 2 步起 burn 的修正因子每一步都小 2 到 7 ppm,更新量就小同样的比例;学习率 0.01 时每个参数每步至多少走 2e-8 到 7e-8,方向一致,所以差距随步数累加,不像舍入那样有正有负。验证:在 PyTorch 里用 float64 手写 Adam,m、v 的更新照常,步长用上表 burn 那一列的 f32 因子(adam10_emul.py),结果和 burn 的 F64 运行比:

step  loss_a       loss_b       |dloss|   max|dparam|  where
  01  1.14005747  1.14005747  0.00e+00  6.94e-18     fc1.bias[4]
  02  1.10198631  1.10198631  2.22e-16  5.55e-17     fc2.weight[8]
  03  1.06721928  1.06721928  0.00e+00  5.55e-17     fc2.weight[8]
  ...
  10  0.88104807  0.88104807  0.00e+00  5.55e-17     fc1.weight[14]

十步都只差 f64 的舍入(5.55e-17)。同一个手写 Adam,因子改成在 f64 里算,就和 torch.optim.Adam 的 f64 运行十步都在 1.11e-16 以内。两边的 Adam 是同一个公式,差别就是这两个 f32 标量。回到 f32:PyTorch 离 f64 参考 1.12e-7,burn 离 4.29e-7,多出来的大部分来自这里。10 步内仍在 1e-6 以内;步数多了要按步数放宽容差,或者把这个差别算进去。

📝NOTE
冷知识:combined_factor 是加 AMSGrad 时顺手改的

v0.20 的 transform 是教科书写法:moment_1 和 moment_2 各自 div_scalar(1f32 - beta.powi(time)) 得到 m̂、v̂,再算 m̂ / (√v̂ + ε),张量上两次除标量。PR #4388(「add AMSgrad support for Adam/AdamW」,首次发布 v0.21.0)给 Adam 和 AdamW 加 AMSGrad 时,把两个修正合成一个标量 combined_factor 乘到 moment_1 上,ε 也乘上 bias_correction2_sqrt,数学上不变,张量上省掉一次运算。提交说明只写了加 AMSGrad;这个改写只在 diff 里。改写前后,修正因子都是 1f32 - ...powi(time) 这样在 f32 里算的,上面那 ppm 级的偏差在 #4388 之前就有。origin/main 上这一段没有再改。

状态存在哪

两边的 Adam 都把每个参数的两个矩和步数存在优化器里,不在模型里。区别是用什么当键。PyTorch:

st = opt.state[model.fc2.bias]
print("state keys:", list(st.keys()), "step =", st["step"], file=sys.stderr)
print("state_dict()['state'] keys:", list(opt.state_dict()["state"].keys()), file=sys.stderr)
state keys: ['step', 'exp_avg', 'exp_avg_sq'] step = tensor(10.)
state_dict()['state'] keys: [0, 1, 2, 3]

运行时,opt.state 的键是 Parameter 对象本身;state_dict() 存盘时换成序号,按 model.parameters() 的顺序编号。burn 的 ModuleOptimizer 里是一张 HashMap<ParamId, OptimizationContext>,存盘的记录(to_record)也按 ParamId 命名。train-adam 把记录转成 burnpack 字节再读回来,打印 fc2.bias 的那几项:

let bytes = optim.into_bytes().unwrap_or_else(|e| panic!("into_bytes: {e:?}"));
let reader = burn_pack::Reader::from_bytes(bytes).unwrap_or_else(|e| panic!("reader: {e:?}"));
record: 8 tensors, 8 scalars
  scalar 3733423016304035671.__rank = UInt(1)
  scalar 3733423016304035671.momentum.time = UInt(10)

四个参数各两个矩({ParamId}.momentum.moment_1、moment_2)是 8 个张量,各一个 __rank 和一个 time 是 8 个标量。moment_1 对 exp_avg、moment_2 对 exp_avg_sq、time 对 step,上一节的 compare.py 比的就是这两组矩,最大差 1.12e-7。记录的格式、__rank 的用途见 burn 卷的优化器。

两种键在「模型换了一份」时表现不同。下面在第 5 步之后把模型按当前数值重建一份,再训练 5 步。PyTorch 侧 copy.deepcopy 得到新的 Parameter 对象,建新优化器,把旧优化器的 state_dict 读进来:

if step == 6 and mode > 0:
  sd = opt.state_dict()
  m = copy.deepcopy(m)                           # 新的 Parameter 对象
  opt = torch.optim.Adam(m.parameters(), lr=0.01)
  if mode == 1:
    opt.load_state_dict(sd)                      # 按编号 0..3 对上

burn 侧每个参数用 Param::from_tensor 重新装一遍(数值不变,Param::from_tensor 给一个新的 ParamId),优化器沿用原来那个:

fn fresh_ids(l: &Linear) -> Linear {
  Linear {
    weight: Param::from_tensor(l.weight.val()),
    bias: l.bias.as_ref().map(|b| Param::from_tensor(b.val())),
  }
}
❗IMPORTANT
动脑筋:burn 侧重建之后第 7 步的 loss,等于「连续训练」那一列,还是等于「第 6 步换一个新 Adam」那一列?优化器记录里最后有几个张量?两边 ε 都是 1e-8。先想好再看本页末尾的答案。

梯度裁剪:burn 按参数,不按全局

PyTorch 最常用的裁剪是 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm),它的 docstring 写着:

The norm is computed over the norms of the individual gradients of all parameters,
as if the norms of the individual gradients were concatenated into a single vector.

所有参数的梯度合成一个向量算一个范数,超了就一起按同一个比例缩。burn 的裁剪配在优化器上(SgdConfig::with_gradient_clipping、AdamConfig::with_grad_clipping),由 map_float 在更新每个参数之前对这一个参数的梯度调 clip_gradient,clip_by_norm 算的是这个张量自己的 L2 范数。A1 一步 SGD,范数上限 0.1:

let mut optim = SgdConfig::new()
  .with_gradient_clipping(Some(GradientClippingConfig::Norm(0.1)))
  .init();
torch.nn.utils.clip_grad_norm_(m.parameters(), 0.1)      # 全局
for p in m.parameters():                                  # 逐个参数
  torch.nn.utils.clip_grad_norm_([p], 0.1)
total norm: 0.5932876467704773
per-param norms: {'fc1.weight': 0.3349, 'fc1.bias': 0.3487, 'fc2.weight': 0.2845, 'fc2.bias': 0.1932}

burn 对全局裁剪:

fc1.weight               n= 32  max|diff|=1.51e-03
fc1.bias                 n=  8  max|diff|=2.21e-03
fc2.weight               n= 24  max|diff|=2.75e-03
fc2.bias                 n=  3  max|diff|=5.30e-03
worst=5.30e-03 tol=1e-06 MISMATCH

burn 对逐个参数裁剪:

fc1.weight               n= 32  max|diff|=2.98e-08
fc1.bias                 n=  8  max|diff|=1.49e-08
fc2.weight               n= 24  max|diff|=2.98e-08
fc2.bias                 n=  3  max|diff|=4.47e-08
worst=4.47e-08 tol=1e-06 OK

全局范数 0.593,四个参数的梯度都缩到 0.1/0.593 ≈ 0.169 倍;按参数裁剪时每个张量各缩到范数 0.1,fc2.bias(范数 0.193)缩到 0.52 倍,fc1.bias(0.349)缩到 0.29 倍。更新方向就变了,差到 5.3e-3。burn 0.22.1 里没有全局范数裁剪的配置;要和 clip_grad_norm_ 一样,得在 GradientsParams 上自己算总范数、自己缩放(GradientsParams::get / register 可以逐个取出再放回)。

学习率调度

PyTorch 的调度器握着优化器,sched.step() 去改 opt.param_groups[i]["lr"]。burn 的调度器不碰优化器:LrScheduler 的 step 返回这一步的学习率,由你交给 optim.step。指数衰减,初值 0.1,γ = 0.9,配 SGD 训练 A1 五步:

opt = torch.optim.SGD(model.parameters(), lr=0.1)
sched = torch.optim.lr_scheduler.ExponentialLR(opt, gamma=0.9)
for step in range(1, 6):
  loss = nn.functional.cross_entropy(model(x), y)
  opt.zero_grad()
  loss.backward()
  print(f"step {step} lr {opt.param_groups[0]['lr']!r}", file=sys.stderr)
  opt.step()
  sched.step()
let mut sched = match ExponentialLrSchedulerConfig::new(0.1, 0.9).init() {
  Ok(s) => s,
  Err(e) => panic!("scheduler: {e}"),
};
for step in 1..=5 {
  let loss = ce.forward(model.forward(x.clone()), y.clone());
  let grads = GradientsParams::from_grads(loss.backward(), &model);
  let lr = sched.step();
  eprintln!("step {step} lr {:?}", lr.base());
  model = optim.step(lr, model, grads);
}

两边打印的学习率逐位相同:

step 1 lr 0.1
step 2 lr 0.09000000000000001
step 3 lr 0.08100000000000002
step 4 lr 0.07290000000000002
step 5 lr 0.06561000000000002

五步的参数最大差 2.98e-8,loss 最大差 1.19e-7,compare.py 通过。顺序要注意:burn 的调度器第一次 step() 就交出初值(ExponentialLrScheduler 把内部状态初始化成 initial_lr / gamma,step 每次先乘再返回),所以「先 sched.step() 再 optim.step」对应 PyTorch 的「先 opt.step() 再 sched.step()」。Learner 也是每批先调度、再更新(见 burn 卷的 Learner 与数据),而且是每个迭代调一次,不是每个 epoch。init() 返回的是 ModuleLrScheduler,它的 step 交出一个按参数组分的 ModuleLearningRate,可以直接传给 optim.step。

还有一处校验 PyTorch 没有:

initial_lr 2.0: Some("Initial learning rate must be greater than 0 and at most 1")
lr=2.0: accepted

burn 的指数调度器要求初值在 (0, 1],init() 返回 Err;PyTorch 接受 2.0。

默认值不同的几处

defaults 打印 burn 的几个配置,PyTorch 的取自 opt.defaults:

SgdConfig { weight_decay: None, momentum: None, gradient_clipping: None }
MomentumConfig { momentum: 0.9, dampening: 0.1, nesterov: false }
AdamConfig { beta_1: 0.9, beta_2: 0.999, epsilon: 1e-5, amsgrad: false, weight_decay: None, grad_clipping: None }
AdamWConfig { beta_1: 0.9, beta_2: 0.999, epsilon: 1e-5, weight_decay: 0.0001, cautious_weight_decay: false, amsgrad: false, grad_clipping: None }
SGD {'lr': 0.1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}
Adam {'lr': 0.001, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0, 'amsgrad': False}
AdamW {'lr': 0.001, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0.01, 'amsgrad': False}
SGD momentum=0.9 0
项PyTorchburn
Adam / AdamW 的 ε1e-81e-5
AdamW 的权重衰减0.011e-4
带动量的 SGD 的 dampening0(SGD(momentum=0.9) 时也是 0)MomentumConfig::new() 是 0.1
学习率构造时给,有默认值每次 step 时给,没有默认值

dampening 的作用见 Momentum::transform:从第二步起新梯度乘 1 - dampening 再加进速度,0.1 意味着每步的梯度只进去九成。从 PyTorch 搬 SGD(momentum=0.9),要写 MomentumConfig::new().with_dampening(0.0)。

动脑筋答案

PyTorch(rebuild.py):

step  continuous  rebuilt+load_state_dict  new optim
   5  1.00236571  1.00236571              1.00236571
   6  0.97519988  0.97519988              0.97519988
   7  0.94985896  0.94985896              0.95010257
   8  0.92572045  0.92572045              0.92367375
   9  0.90281999  0.90281999              0.89935386
  10  0.88104737  0.88104737              0.87664640
rebuilt+load == continuous, bit for bit: True

burn(bin rebuild):

mode 0: optimizer record holds 8 tensors
mode 1: optimizer record holds 16 tensors
mode 2: optimizer record holds 8 tensors
step  continuous  rebuilt     new optim
   5  1.00236607  1.00236607  1.00236607
   6  0.97520036  0.97520036  0.97520036
   7  0.94985944  0.95010293  0.95010293
   8  0.92572105  0.92367423  0.92367423
   9  0.90282059  0.89935452  0.89935452
  10  0.88104808  0.87664700  0.87664700
rebuilt == new optimizer, bit for bit: true
  • burn 重建之后,第 7 步起等于「新 Adam」那一列,而且逐位相同。新参数的 ParamId 在状态表里查不到,map_float 当成第一次见到它:矩从零开始,time 回到 1,偏差修正也从头算。没有报错,也没有警告。
  • 第 6 步的 loss 三列相同:它是第 6 步更新之前算的,那时三份参数一样。
  • 记录里有 16 个张量:旧 ParamId 的 8 个矩还留在表里,不会再被用到,也不会被清掉;新 ParamId 又建了 8 个。
  • PyTorch 的 state_dict 按序号存,新模型的参数顺序不变,load_state_dict 就能接上,和连续训练逐位相同。burn 的记录按 ParamId 存,要接上得让参数保留原来的 id。clone() 保留 id;用 burn-store 加载时,map_float(burn-store 的加载器)优先用文件里存的 ParamId,注释写的就是为了让按 ParamId 存的优化器状态挺过存取,文件里没有 id 时(比如从 safetensors 读)才保留目标模型自己的 id(存取本身见保存与加载)。用 Param::from_tensor 重新装、或者重新 init 的模型,状态就接不上了。

小结

  • Adam 的矩和步数两边都在优化器里:PyTorch 运行时按 Parameter 对象、存盘按序号;burn 按 ParamId,记录里每项以 ParamId 开头。
  • 默认 ε 不同(1e-8 对 1e-5),A1 上 Adam 第一步就差 9.44e-5,10 步差到 3.01e-4。对齐后 10 步最大差 4.77e-7(容差 1e-6),两组矩差 1.12e-7 和 1.35e-9。
  • 对齐后的差每步累加,主要来自 burn 在 f32 里算偏差修正因子(偏小 2 到 7 ppm);在 f64 里照抄这些因子能把 burn 逐步复现到 5.55e-17。
  • 模型换成新 ParamId 后继续用旧优化器,Adam 静默从头开始,旧状态留在表里;PyTorch 按序号 load_state_dict 能接上。
  • 梯度范数裁剪 burn 按每个参数单独算,clip_grad_norm_ 按全局;A1 上两者差 5.3e-3,和逐参数裁剪的 PyTorch 对得上(4.47e-8)。
  • 调度器返回学习率交给 optim.step,第一次就返回初值;指数调度器拒绝大于 1 的初值。AdamW 的权重衰减(1e-4 对 0.01)和动量的 dampening(0.1 对 0)默认值也不同。

💬 关联讨论

发起讨论
加载中...