Rust in Everything
基于 burn 0.22.1

优化器默认值和 PyTorch 不同

主线位置:训练主线的 optimizer step。公式两边一样,构造优化器时没写出来的那几个超参数不一样。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备,autodiff)
flowchart TB
  pt["PyTorch 只给 lr"] --> port["burn 只给 lr"]
  port --> cfg["XxxConfig::new 默认值"]
  cfg --> rms["RmsProp 动量 0.9"]
  cfg --> eps["eps 1e-5"]
  cfg --> wd["AdamW 衰减 1e-4"]
  cfg --> damp["dampening 0.1"]
  rms --> diff["轨迹对不上"]
  eps --> diff
  wd --> diff
  damp --> diff

实验代码:PyTorch 侧 optdef.py、clip.py,burn 侧 crate pitb-opt 的 bin optdef、clip(feature std、flex、autodiff、optim,release)。模型、权重、数据同 A1(见一步),每种优化器在同一批数据上训练 10 步,记下每步更新前的 loss 和更新后的 67 个参数。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

构造优化器时通常只写学习率,其他靠默认值:

opt = {
  "sgdm": lambda: torch.optim.SGD(p, lr=0.1, momentum=0.9),
  "adamw": lambda: torch.optim.AdamW(p, lr=0.01),
  "rmsprop": lambda: torch.optim.RMSprop(p, lr=0.01),
  "adagrad": lambda: torch.optim.Adagrad(p, lr=0.1),
}[name]()

opt.defaults 里和下面有关的几项:

sgdm {'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0}
adamw {'lr': 0.01, 'eps': 1e-08, 'weight_decay': 0.01}
rmsprop {'lr': 0.01, 'momentum': 0, 'alpha': 0.99, 'eps': 1e-08, 'centered': False, 'weight_decay': 0}
adagrad {'lr': 0.1, 'lr_decay': 0, 'eps': 1e-10, 'weight_decay': 0, 'initial_accumulator_value': 0}

搬到 burn 的现象

逐个换成 burn 的配置,同样只给学习率(学习率在 optim.step(lr, ..) 里传):

("sgdm", "") => (0.1, SgdConfig::new().with_momentum(Some(MomentumConfig::new())).init()),
("adamw", "") => (0.01, AdamWConfig::new().init()),
("rmsprop", "") => (0.01, RmsPropConfig::new().init()),
("adagrad", "") => (0.1, AdaGradConfig::new().init()),

10 步之后两边对比,compare.py(容差 1e-6)四个全部 MISMATCH、退出码 1:

优化器第 1 步参数最大差第 10 步参数最大差第 10 步 loss 差
SGD + 动量 0.91.49e-84.00e-21.23e-2
AdamW7.34e-56.60e-41.60e-4
RMSprop8.70e-31.74e+002.58e-1
Adagrad9.45e-49.45e-42.51e-5

RMSprop 最严重,逐步看:

step  loss_pt     loss_burn   |dloss|   max|dparam|
  01  1.14005756  1.14005744  1.19e-07  8.70e-03
  02  0.87987846  0.88012022  2.42e-04  9.09e-02
  03  0.76520562  0.69574779  6.95e-02  2.74e-01
  04  0.69703907  0.59628969  1.01e-01  4.62e-01
  05  0.64605319  0.48461652  1.61e-01  6.89e-01
  06  0.60856223  0.50372267  1.05e-01  9.25e-01
  07  0.57667828  0.39845836  1.78e-01  1.16e+00
  08  0.55500638  0.41367429  1.41e-01  1.37e+00
  09  0.53064752  0.40139112  1.29e-01  1.57e+00
  10  0.51148975  0.25379312  2.58e-01  1.74e+00

burn 这边 loss 降得更快,而且不单调(第 6、8 步回升),一个参数差到 1.74。这不是数值误差,两边跑的是两个不同的优化器。SGD 动量那一行也有特点:第 1 步两边只差 1 ulp,从第 2 步开始分开(第 2 步 1.69e-3,之后每步涨 3e-3 到 6e-3)。

原因

burn 打印出的配置:

MomentumConfig { momentum: 0.9, dampening: 0.1, nesterov: false }
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 }
RmsPropConfig { alpha: 0.99, momentum: 0.9, epsilon: 1e-5, centered: false, weight_decay: None, grad_clipping: None }
AdaGradConfig { lr_decay: 0.0, epsilon: 1e-5, weight_decay: None, grad_clipping: None }
项PyTorchburn 0.22.1出处
RMSprop 动量00.9RmsPropConfig
RMSprop ε1e-81e-5同上
Adagrad ε1e-101e-5AdaGradConfig
Adam / AdamW ε1e-81e-5AdamConfig、AdamWConfig
AdamW 权重衰减0.011e-4AdamWConfig
SGD 动量的 dampening00.1MomentumConfig

都是 #[config(default = ...)] 写死的,origin/main 上这几个文件没有改。RMSprop 的 momentum: 0.9 和 epsilon: 1e-5 从它在 PR #607(首次发布 v0.9.0)加进来时就是这样。

各项分别占多少,把其中一项对齐、其余保持 burn 默认再跑一次:

== adamw only eps aligned
  01  1.14005756  1.14005744  1.19e-07  4.95e-05
  10  0.88125193  0.88105011  2.02e-04  5.38e-04
== adamw only wd aligned
  01  1.14005756  1.14005744  1.19e-07  9.44e-05
  10  0.88125193  0.88129407  4.21e-05  3.01e-04
== rmsprop only eps aligned
  01  1.14005756  1.14005744  1.19e-07  5.96e-08
  10  0.51148975  0.25250885  2.59e-01  1.76e+00
== rmsprop only mom aligned
  01  1.14005756  1.14005744  1.19e-07  8.70e-03
  10  0.51148975  0.51183987  3.50e-04  1.44e-01
  • RMSprop:大头是动量。RmsPropMomentum::transform 在 momentum > 0 时把 grad / (√avg + ε) 累进一个速度 buf = momentum · buf + grad,更新用 buf。第一步 buf 就是归一化后的梯度,所以动量不影响第 1 步;第 1 步的 8.70e-3 全来自 ε:RMSprop 第一步的分母是 √(0.01·g²) = 0.1·|g|,梯度在 1e-3 量级时,1e-5 的 ε 占分母的一成,这一步就少走一成。
  • AdamW:两项都有份。第 1 步 ε 对齐后剩下的 4.95e-5 是衰减的差:AdamW 先把参数乘 1 - lr · wd(AdamW::step,和 PyTorch 的解耦衰减同一个公式),绝对值 0.5 左右的权重,lr 0.01 下两边衰减差 0.5 × 0.01 × (0.01 − 0.0001) ≈ 4.95e-5。ε 的影响见优化器状态里 Adam 那一段的「没有蠢问题」。
  • SGD 动量:Momentum::transform 第一步直接用梯度当速度,从第二步起新梯度乘 1 - dampening 再加进速度。dampening 0.1 让每步的新梯度只进去九成,所以第 1 步相同、之后越差越多。
  • Adagrad:ε 加在 √sum 上,sum 只增不减,ε 在第一步分量最大。参数最大差第 1 步就是 9.45e-4,10 步里一直停在这个数,loss 差在 2.5e-5 到 6.4e-5 之间。

梯度范数裁剪也不是一回事

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm) 把所有参数的梯度当成一个向量算范数;burn 的 GradientClippingConfig::Norm(max_norm) 在 map_float 里对每个参数单独调 clip_by_norm。名字一样,默认就是按参数。A1 一步 SGD、上限 0.1,PyTorch 全局对 burn Norm:

== pt global vs burn norm
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

和 PyTorch 逐个参数各裁一次比,burn 的 Norm 是对得上的(worst=4.47e-08 tol=1e-06 OK)。机制和各参数的范数见优化器状态的「梯度裁剪」。0.22.1 里没有全局范数裁剪的配置项,origin/main 也没有。

改法

把 PyTorch 的默认值写出来:

("sgdm", "aligned") => (0.1, SgdConfig::new().with_momentum(Some(MomentumConfig::new().with_dampening(0.0))).init()),
("adamw", "aligned") => (0.01, AdamWConfig::new().with_weight_decay(0.01).with_epsilon(1e-8).init()),
("rmsprop", "aligned") => (0.01, RmsPropConfig::new().with_momentum(0.0).with_epsilon(1e-8).init()),
("adagrad", "aligned") => (0.1, AdaGradConfig::new().with_epsilon(1e-10).init()),

10 步全部对上,compare.py 容差 1e-6、退出码 0:

优化器10 步里最大的差(loss 或参数)
SGD + 动量 0.91.19e-7
AdamW8.34e-7
RMSprop4.77e-7
Adagrad1.19e-7

都不是 0:SGD 和 Adagrad 是 f32 的一两个 ulp(求和顺序,见怎么读这一卷)。AdamW 最大,而且随步数增长:它在 AdaptiveMomentumW::transform 里同样用 f32 算 1 - beta.powi(time) 的偏差修正,和 Adam 那段 ppm 级的漂移是同一个来源(优化器状态);步数多了要按步数放宽容差。

要全局范数裁剪,在 GradientsParams 上自己做。两个访问者:一个累加所有梯度的平方和,一个把每个梯度乘同一个系数,系数和 PyTorch 一样是 max_norm / (total + 1e-6),只在小于 1 时缩放:

struct SquaredNorm<'a> {
  grads: &'a GradientsParams,
  sum: f64,
}

impl ModuleVisitor for SquaredNorm<'_> {
  fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
    if let Some(g) = self.grads.get::<D>(param.id) {
      self.sum += g.powi_scalar(2).sum().into_scalar::<f64>();
    }
  }
}

struct Scale<'a> {
  grads: &'a mut GradientsParams,
  coef: f64,
}

impl ModuleVisitor for Scale<'_> {
  fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
    if let Some(g) = self.grads.remove::<D>(param.id) {
      self.grads.register(param.id, g.mul_scalar(self.coef));
    }
  }
}

fn clip_grad_norm<M: Module>(model: &M, grads: &mut GradientsParams, max_norm: f64) -> f64 {
  let mut sq = SquaredNorm { grads, sum: 0.0 };
  model.visit(&mut sq);
  let total = sq.sum.sqrt();
  let coef = max_norm / (total + 1e-6);
  if coef < 1.0 {
    model.visit(&mut Scale { grads, coef });
  }
  total
}

在 from_grads 之后、optim.step 之前调一次,优化器本身不配裁剪:

total norm: 0.5932876467704773
total norm: 0.5932876455662033
== pt global vs burn global
fc1.weight               n= 32  max|diff|=0.00e+00
fc1.bias                 n=  8  max|diff|=0.00e+00
fc2.weight               n= 24  max|diff|=0.00e+00
fc2.bias                 n=  3  max|diff|=1.16e-10
worst=1.16e-10 tol=1e-06 OK

第一行是 PyTorch 返回的总范数,第二行是 burn 侧(f32 梯度在 f64 里累加,所以低位不同)。更新后的参数最大差 1.16e-10。访问者只看要梯度的参数(GradientsParams 里只有它们),冻结的参数和 BatchNorm 的运行统计不会被算进去。

怎么检查

  • 启动时把配置打出来:burn 的配置都派生了 Debug,println!("{:?}", RmsPropConfig::new()) 一行就是上面那种输出;和 PyTorch 侧的 opt.defaults 逐项对。
  • 能跑两边的,先只跑一步对比参数。第 1 步就差的是 ε、权重衰减这类每步都起作用的项;第 1 步对得上、第 2 步才分开的,查动量、dampening 这类带状态的项。
  • 搬代码时把 PyTorch 的默认值一项项写成 with_*,不要依赖 Config::new() 的默认。
  • clip_grad_norm_ 一律换成全局版本(上面的函数),不要换成 with_gradient_clipping(Some(GradientClippingConfig::Norm(..)))。

小结

  • burn 的优化器默认值和 PyTorch 有六处不同:RMSprop 动量 0.9(PyTorch 0)、RMSprop / Adam / AdamW / Adagrad 的 ε 1e-5(PyTorch 1e-8,Adagrad 1e-10)、AdamW 权重衰减 1e-4(0.01)、动量 dampening 0.1(0)。
  • A1 上只给学习率训练 10 步:RMSprop 参数差到 1.74、SGD 动量 4.0e-2、Adagrad 9.45e-4、AdamW 6.6e-4;对齐之后都在 1e-6 以内,AdamW 的 8.34e-7 随步数增长,来自 f32 的偏差修正。
  • GradientClippingConfig::Norm 按每个参数裁剪,clip_grad_norm_ 按全局;两个访问者就能在 GradientsParams 上做全局裁剪,和 PyTorch 差 1.16e-10。

💬 关联讨论

发起讨论
加载中...