Rust in Everything
基于 burn 0.22.1

同一个小模型训练到收敛

主线位置:训练主线整条走 100 遍:forward → loss → backward → 优化器一步。本页看的是两边的差别怎么随步数累积。
版本:PyTorch 2.15.0(CPU)、burn 0.22.1(Flex 设备)
flowchart TB
  gen["f64 生成 舍入一次"] --> pt32["PyTorch f32"]
  gen --> bn32["burn f32"]
  pt32 --> gap["逐步的差"]
  bn32 --> gap
  gen --> pt64["PyTorch f64"]
  gen --> bn64["burn F64"]
  pt64 --> split["拆出来源"]
  bn64 --> split
  gap --> split
  split --> round["各自的 f32 舍入"]
  split --> algo["算法本身的差"]

实验代码:发布的一对程序是 train_both.py 和 crate handson-train,全文在本页末尾。分析用的是 trace.py(同样的模型和数据,每步记下全部参数,可以切到 float64)和 crate handson-trace(同一个程序加上每步参数和 F64 开关,另有 bin factors、grad1)。burn 侧 feature 同模板(std、flex、autodiff、optim),release 构建。平台 M2 Max、macOS 27、rustc 1.99.0。

任务

训练一步和训练循环用的都是 A1 那个两层 MLP。这里换一个稍大的模型,把卷积也带上:

  • 模型:Conv1d(1→4, kernel 5) → ReLU → 展平成 48 维 → Linear(48→3),共 171 个参数。
  • 数据:96 条长度 16 的序列,三类。第 k 条属于类 c = k % 3,第 t 个值是 sin(0.3(c+1)·t + 0.37k) + 0.3·sin(2.3t + 1.1k):后一项和类别无关,当作噪声。两边都先在 f64 里算,最后舍入成 f32 一次,所以输入逐位相同(原因见怎么读这一卷)。
  • 初值:按下标 i 生成,conv.weight 是 0.4·sin(i+1),fc.weight 是 0.15·cos(i+1),conv.bias 是 0.02·o − 0.03,fc.bias 是 0。
  • 训练:每批 16 条,不打乱,6 个批轮流用,共 100 步(约 17 个 epoch)。SGD 学习率 0.5;Adam 学习率 0.01,ε 两边都设成 1e-8(burn 默认是 1e-5,见优化器状态)。
  • 记录:每步更新前的 loss;100 步后的全部参数;最后在全部 96 条上的 loss 和答对的条数。

模型的两种写法:

class Cnn(nn.Module):
  def __init__(self):
    super().__init__()
    self.conv = nn.Conv1d(1, CH, K)
    self.fc = nn.Linear(FLAT, CLASSES)

  def forward(self, x):
    return self.fc(torch.relu(self.conv(x)).flatten(1))
#[derive(Module, Debug)]
struct Cnn {
  conv: Conv1d,
  fc: Linear,
  relu: Relu,
}

impl Cnn {
  fn forward(&self, x: Tensor<3>) -> Tensor<2> {
    self.fc.forward(self.relu.forward(self.conv.forward(x)).flatten(1, 2))
  }
}

和 A1 比,新出现的只有两处:

  • 卷积权重的布局两边相同。burn 的 Conv1d 权重是 [channels_out, channels_in / groups, kernel_size],和 nn.Conv1d.weight 一样,按同样的下标生成就行,不用转置;Linear 的权重仍然是 [in, out],要转置(见定义模型)。输入两边都是 [batch, channels, length]。
  • flatten(1) 在 burn 里写成 flatten(1, 2):起止维都要写,结果的秩由返回类型 Tensor<2> 给出(见创建与形状)。两边都按行优先展平,第 c 个通道的第 t 个位置落在第 12c + t 列。

训练循环逐行对得上,和训练一步一样。取一批是 x[i:i + BATCH] 对 x.clone().slice(s![i..i + BATCH])(见切片与索引),评估是 torch.no_grad() 对 model.valid() 加 device.inner() 上的数据(见不求导的区域)。

结果

python -I train_both.py > pt.json
cargo run --release --offline > burn.json
python -I compare.py pt.json burn.json
sgd.losses               n=100  max|diff|=1.79e-07
sgd.final.loss           n=  1  max|diff|=1.49e-08
sgd.final.correct        n=  1  max|diff|=0.00e+00
sgd.params.conv.weight   n= 20  max|diff|=7.15e-07
sgd.params.conv.bias     n=  4  max|diff|=5.96e-07
sgd.params.fc.weight     n=144  max|diff|=4.77e-07
sgd.params.fc.bias       n=  3  max|diff|=4.92e-07
adam.losses              n=100  max|diff|=2.77e-06
adam.final.loss          n=  1  max|diff|=7.75e-07
adam.final.correct       n=  1  max|diff|=0.00e+00
adam.params.conv.weight  n= 20  max|diff|=3.04e-06
adam.params.conv.bias    n=  4  max|diff|=2.62e-06
adam.params.fc.weight    n=144  max|diff|=3.52e-06
adam.params.fc.bias      n=  3  max|diff|=1.61e-06
worst=3.52e-06 tol=1e-06 MISMATCH

两边都训练到了 96 条全对。最后在全部数据上的 loss:

PyTorchburn
SGD0.058859247714281080.058859262615442276
Adam0.070111677050590520.0701124519109726

SGD 这一半在 1e-6 以内通过,Adam 这一半没有。两边的程序各连跑两次,输出逐字节相同,所以这些差不是随机的。下面按步看差是怎么来的。

为了把差拆开,另外跑了两边的 float64 版本:PyTorch 侧把 f32 的初值和数据转成 float64 再训练(trace.py f64x);burn 侧先 Device::flex().configure(FloatDType::F64)(F64=1)。两份 f64 运行之间的差,就是两边算法本身的差;每一边的 f32 运行离它自己的 f64 运行多远,就是这一边 f32 舍入攒下的量。

SGD:loss 不涨,参数差慢慢涨

按步比,max|dparam| 是那一步更新后 171 个参数的最大差;后三列是 PyTorch f32 离 PyTorch f64、burn f32 离 burn F64、两份 f64 之间的最大参数差:

step  torch loss  burn loss   |dloss|   max|dparam|  torch-f64  burn-f64   f64-f64
   1  1.13687289  1.13687277  1.19e-07  2.98e-08     2.40e-08   1.93e-08  5.55e-17
   2  1.10447657  1.10447657  0.00e+00  2.98e-08     2.84e-08   2.37e-08  5.55e-17
   5  1.06677926  1.06677914  1.19e-07  4.47e-08     3.09e-08   2.89e-08  8.33e-17
  10  0.98627138  0.98627144  5.96e-08  4.47e-08     6.23e-08   3.99e-08  1.39e-16
  20  0.84775448  0.84775442  5.96e-08  8.94e-08     1.12e-07   1.16e-07  8.88e-16
  30  0.64109868  0.64109868  0.00e+00  1.79e-07     2.01e-07   1.63e-07  2.66e-15
  40  0.50108528  0.50108522  5.96e-08  2.98e-07     4.37e-07   2.00e-07  6.33e-15
  50  0.38287219  0.38287210  8.94e-08  4.77e-07     7.14e-07   2.49e-07  1.17e-14
  60  0.22960071  0.22960062  8.94e-08  5.36e-07     8.23e-07   3.72e-07  1.38e-14
  70  0.15450853  0.15450841  1.19e-07  4.77e-07     7.37e-07   3.26e-07  1.53e-14
  80  0.10406791  0.10406786  4.47e-08  4.77e-07     8.16e-07   4.42e-07  1.88e-14
  90  0.07615292  0.07615286  5.96e-08  7.15e-07     9.32e-07   4.99e-07  2.15e-14
 100  0.06210175  0.06210171  3.73e-08  7.15e-07     1.05e-06   5.75e-07  2.32e-14
  • loss 的差 100 步里最大 1.79e-7(第 37、46、48、58、67 步),绝对值没有随步数变大。按 ulp 数是变大了:loss 从 1.14 降到 0.06,ulp 跟着变小,第 1 步的 1.19e-7 是 1 个 ulp,第 100 步的 3.73e-8 是 5 个,100 步里最多 15 个。
  • 参数的差从第 1 步的 2.98e-8 涨到第 100 步的 7.15e-7,涨了 24 倍。
  • 两份 f64 运行 100 步只差 2.32e-14:两边的 SGD、卷积、交叉熵是同一套算法。f32 的差全部来自舍入:PyTorch 离自己的 f64 解 1.05e-6,burn 离自己的 5.75e-7,两边的差 7.15e-7 和这两个量同一个量级。
  • 舍入为什么会攒起来:每一步算出的参数带着一点舍入误差,下一步的梯度就在这组略有不同的参数上算,误差被带进后面每一步。两边的舍入各走各的(求和顺序不同),所以两边之间的差也在攒。同样 100 步、学习率改成 0.1 时(loss 只降到 0.79,96 条对 76 条),参数最大差是 1.19e-7。

所以 SGD 训练 100 步,容差 1e-6 刚好够;步数再多,参数要按步数放宽,loss 不用。

Adam:第一步就大一个量级

同样的表,Adam:

step  torch loss  burn loss   |dloss|   max|dparam|  torch-f64  burn-f64   f64-f64
   1  1.13687289  1.13687277  1.19e-07  4.69e-07     4.91e-07   2.15e-08  2.23e-10
   2  1.11771631  1.11771631  0.00e+00  5.07e-07     5.05e-07   2.38e-08  3.44e-08
   5  1.08280766  1.08280778  1.19e-07  4.21e-07     4.82e-07   3.74e-08  1.58e-07
  10  1.03940225  1.03940248  2.38e-07  3.87e-07     4.66e-07   5.62e-08  3.91e-07
  20  0.97808975  0.97809017  4.17e-07  7.60e-07     4.95e-07   8.15e-08  7.85e-07
  30  0.83425587  0.83425671  8.34e-07  1.19e-06     4.84e-07   1.19e-07  1.23e-06
  40  0.69612402  0.69612581  1.79e-06  1.65e-06     4.64e-07   1.06e-07  1.69e-06
  50  0.58540988  0.58541220  2.32e-06  2.32e-06     4.48e-07   1.92e-07  2.35e-06
  60  0.38402307  0.38402560  2.53e-06  2.92e-06     4.61e-07   1.76e-07  2.93e-06
  70  0.24789160  0.24789384  2.24e-06  3.46e-06     4.66e-07   2.21e-07  3.36e-06
  80  0.17158426  0.17158614  1.88e-06  3.46e-06     4.36e-07   3.06e-07  3.66e-06
  90  0.10003939  0.10004054  1.15e-06  3.52e-06     4.45e-07   3.25e-07  3.70e-06
 100  0.07264095  0.07264175  7.97e-07  3.52e-06     4.80e-07   3.55e-07  3.71e-06

这张表里有两个现象,来源不同。

第一个:第 1 步参数就差 4.69e-7,是 SGD 第 1 步(2.98e-8)的 16 倍。这时两份 f64 运行只差 2.23e-10,算法还没分开;差几乎全在 torch-f64 那一列(4.91e-7),也就是 PyTorch 的 f32 运行离 f64 解远了。差最大的参数是 fc.weight[67],即 PyTorch 布局下的 fc.weight[1, 19]。它第 1 步的更新量,PyTorch f32 是 -0.009901359677314758,f64 是 -0.009901850779581708。

❗IMPORTANT
动脑筋:第 1 步 fc.weight[1, 19] 的更新量在 f32 和 f64 之间差了 4.91e-7,约是更新量的 5e-5。这个参数第 1 步的梯度,PyTorch f32 和 f64 之间的相对差是多少:1e-7 量级(几个 f32 ulp),还是更大?

用同一批数据(第一批 16 条)、同样的初值,两边分别取这一个梯度:PyTorch 侧 m.fc.weight.grad[1, 19],burn 侧 model.fc.weight.val().grad(&grads) 的 [19, 1](burn 的布局是 [in, out])。先想好再看本页末尾的答案。

第二个:之后两边一路分开,第 100 步差 3.52e-6。这次 torch-f64 和 burn-f64 两列都没有涨多少(各自不超过 5.1e-7),涨的是 f64-f64 那一列:两份 f64 运行之间的差从 2.23e-10 涨到 3.71e-6,和 f32 下的 3.52e-6 几乎一样大。所以这部分不是舍入,是两边每一步的更新量本来就不一样。

原因在优化器状态里已经找到了:burn 的 AdaptiveMomentum::transform 在 f32 里算偏差修正的两个标量,PyTorch 在 Python 的 float(f64)里算。那一页只训练了 10 步;这里把 bin factors(照抄 burn 的三行,打印 t = 1..100)的输出和 f64 里用同样的 f32 betas 算出的值比到第 100 步:

  t  burn f32     f64          rel err
  1  0.316225648  0.316225655  -2.23e-08
  2  0.235314354  0.235315158  -3.42e-06
  5  0.172497287  0.172497706  -2.43e-06
 10  0.153187588  0.153188071  -3.15e-06
 20  0.160230935  0.160231541  -3.79e-06
 30  0.179566771  0.179567461  -3.84e-06
 40  0.201034531  0.201035340  -4.02e-06
 50  0.222036406  0.222037423  -4.58e-06
 60  0.241810858  0.241811856  -4.12e-06
 70  0.260235041  0.260236113  -4.12e-06
 80  0.277403384  0.277404527  -4.12e-06
 90  0.293464452  0.293465549  -3.74e-06
100  0.308562845  0.308564004  -3.76e-06

从第 2 步起,burn 的因子每一步都小 2.4 到 6.9 ppm,100 步里一直是负的。每一步 burn 都比 PyTorch 少走同样比例的一点,方向不变,所以累加起来,不像舍入那样有正有负。在两份 f64 运行里查 100 步后差最大的 fc.weight[89]:它从初值移动了 -0.3817,burn 少移动 3.71e-6,约为移动量的 9.7e-6。全部 171 个参数里,93% 是 burn 移动得少,「burn 减 PyTorch 的差除以移动量」的中位数是 -2.7e-6,和因子的误差同号、同一个量级。比例并不正好等于因子的误差,因为步长一变,后面每一步的梯度也跟着变。

验证这个解释:在 PyTorch 里用 float64 手写 Adam(trace.py emul),矩的更新照 burn 的写法,步长用 bin factors 打印的 f32 因子:

c, e = self.fac[self.t]  # bin factors 打印的 combined_factor 和 epsilon * bias_correction2_sqrt
m = m * self.b1 + g * (1 - self.b1)
v = v * self.b2 + g * g * (1 - self.b2)
p -= (m * c / (v.sqrt() + e)) * self.lr

和 burn 的 F64 运行比:

step  |dloss|   max|dparam|  where
   1  0.00e+00  7.63e-17     fc.weight[67]
  10  0.00e+00  8.33e-17     fc.weight[106]
  50  1.11e-16  2.36e-16     conv.weight[5]
 100  4.16e-17  6.94e-16     fc.weight[0]

100 步都只差 f64 的舍入。两边 Adam 之间 3.71e-6 的差,全部由这几个 f32 标量解释。其中 betas 本身存成 f32(0.9 在 f32 里是 0.8999999761581421)也有一份:PyTorch 的 f64 运行只把 betas 换成这两个 f32 值,100 步后和原来差 1.18e-7,比 3.71e-6 小一个量级多。大头是两个因子在 f32 里的运算本身(1 − β₂ᵗ 为什么对 f32 舍入这么敏感,见优化器状态)。

📝NOTE
没有蠢问题

问: Adam 的 loss 差在第 60 步左右最大(2.53e-6),到第 100 步降回 7.97e-7。是不是两边后来又靠拢了?

答: 没有。参数的差从第 70 步起一直停在 3.5e-6 左右,没有变小。loss 的差约等于梯度和参数差的内积,参数差不变时就跟着梯度变。训练后段 loss 降到 0.07,交叉熵的梯度也小了:PyTorch 侧打印每步的全局梯度范数,第 60 步 0.2788,第 100 步 0.0813,降到约 1/3.4;同一段 loss 的差从 2.53e-6 降到 7.97e-7,降到约 1/3.2。判断两边是不是对得上,要看参数,不能只看 loss。

容差怎么定

f32 下逐个数比较时,容差要按优化器和步数定:

  • SGD:差是两边各自的 f32 舍入,100 步参数最大 7.15e-7,1e-6 够用。
  • Adam:差里有一份每步都在攒的系统偏差,100 步 3.52e-6。compare.py ... 1e-5 通过(worst=3.52e-06 tol=1e-05 OK)。

这个 1e-5 的门要能挡住真的错误。把 PyTorch 侧的 ε 改成 burn 的默认值 1e-5(另一边仍是 1e-8),同样的命令:

adam.losses              n=100  max|diff|=4.25e-04
adam.final.loss          n=  1  max|diff|=1.58e-05
adam.final.correct       n=  1  max|diff|=0.00e+00
adam.params.conv.weight  n= 20  max|diff|=5.67e-03
adam.params.conv.bias    n=  4  max|diff|=3.16e-03
adam.params.fc.weight    n=144  max|diff|=4.57e-03
adam.params.fc.bias      n=  3  max|diff|=4.78e-04
worst=5.67e-03 tol=1e-05 MISMATCH

参数差到 5.67e-3,比 f32 因子造成的差大三个量级,退出码 1。准确率仍然都是 96/96:只看最后的准确率,这种配置上的差别看不出来。

完整程序

PyTorch(train_both.py):

# 一维小 CNN 在合成数据上训练 100 步(SGD 和 Adam 各一次),输出每步 loss 和最后的参数
import json, math, sys
import torch
from torch import nn

N, LEN, CLASSES, BATCH, STEPS = 96, 16, 3, 16, 100
CH, K = 4, 5
FLAT = CH * (LEN - K + 1)  # 4 * 12 = 48

def data():
  # 先在 f64 里算,最后舍入成 f32 一次;burn 侧同样的公式、同样的顺序
  xs, ys = [], []
  for k in range(N):
    c = k % CLASSES
    f = 0.3 * (c + 1)
    xs.append([[math.sin(f * t + 0.37 * k) + 0.3 * math.sin(2.3 * t + 1.1 * k) for t in range(LEN)]])
    ys.append(c)
  return torch.tensor(xs, dtype=torch.float32), torch.tensor(ys)

class Cnn(nn.Module):
  def __init__(self):
    super().__init__()
    self.conv = nn.Conv1d(1, CH, K)
    self.fc = nn.Linear(FLAT, CLASSES)

  def forward(self, x):
    return self.fc(torch.relu(self.conv(x)).flatten(1))

def build():
  m = Cnn()
  with torch.no_grad():
    # conv.weight [out, in, k],fc.weight [out, in]:按下标 i 生成,两边相同
    m.conv.weight.copy_(torch.tensor([0.4 * math.sin(i + 1) for i in range(CH * K)]).reshape(CH, 1, K))
    m.conv.bias.copy_(torch.tensor([0.02 * o - 0.03 for o in range(CH)]))
    m.fc.weight.copy_(torch.tensor([0.15 * math.cos(i + 1) for i in range(CLASSES * FLAT)]).reshape(CLASSES, FLAT))
    m.fc.bias.zero_()
  return m

def train(make_opt):
  x, y = data()
  model = build()
  opt = make_opt(model.parameters())
  losses = []
  for step in range(STEPS):
    i = (step % (N // BATCH)) * BATCH  # 不打乱,6 个批轮流用
    loss = nn.functional.cross_entropy(model(x[i:i + BATCH]), y[i:i + BATCH])
    losses.append(loss.item())
    opt.zero_grad()
    loss.backward()
    opt.step()
  with torch.no_grad():
    out = model(x)
    final = {
      "loss": nn.functional.cross_entropy(out, y).item(),
      "correct": (out.argmax(1) == y).sum().item(),
    }
  params = {n: p.tolist() for n, p in model.named_parameters()}
  return {"losses": losses, "final": final, "params": params}

json.dump({
  "sgd": train(lambda p: torch.optim.SGD(p, lr=0.5)),
  "adam": train(lambda p: torch.optim.Adam(p, lr=0.01, eps=1e-8)),
}, sys.stdout)

burn 的 Cargo.toml:

[package]
name = "handson-train"
version = "0.1.0"
edition = "2024"

[dependencies]
burn = { version = "=0.22.1", default-features = false, features = ["std", "flex", "autodiff", "optim"] }
serde_json = "1"

burn 的 src/main.rs:

//! 和 train_both.py 同一个一维小 CNN、同样的权重和数据,SGD 和 Adam 各训练 100 步,
//! 输出同样结构的 JSON。fc.weight 输出时转回 PyTorch 的 [out, in]。

use burn::module::{Module, Param};
use burn::nn::conv::{Conv1d, Conv1dConfig};
use burn::nn::loss::CrossEntropyLossConfig;
use burn::nn::{Linear, LinearConfig, Relu};
use burn::optim::{AdamConfig, GradientsParams, ModuleOptimizer, SgdConfig};
use burn::tensor::{Device, Int, Tensor, TensorData, s};
use serde_json::{Value, json};

const N: usize = 96;
const LEN: usize = 16;
const CLASSES: usize = 3;
const BATCH: usize = 16;
const STEPS: usize = 100;
const CH: usize = 4;
const K: usize = 5;
const FLAT: usize = CH * (LEN - K + 1); // 4 * 12 = 48

#[derive(Module, Debug)]
struct Cnn {
  conv: Conv1d,
  fc: Linear,
  relu: Relu,
}

impl Cnn {
  fn forward(&self, x: Tensor<3>) -> Tensor<2> {
    self.fc.forward(self.relu.forward(self.conv.forward(x)).flatten(1, 2))
  }
}

/// 先在 f64 里算,最后舍入成 f32 一次,和 Python 逐位相同
fn floats<const D: usize>(v: Vec<f64>, shape: [usize; D], device: &Device) -> Tensor<D> {
  let v: Vec<f32> = v.into_iter().map(|a| a as f32).collect();
  Tensor::from_data(TensorData::new(v, shape), device)
}

fn data(device: &Device) -> (Tensor<3>, Tensor<1, Int>) {
  let mut xs = Vec::with_capacity(N * LEN);
  let mut ys = Vec::with_capacity(N);
  for k in 0..N {
    let c = k % CLASSES;
    let f = 0.3 * (c + 1) as f64;
    for t in 0..LEN {
      let (k, t) = (k as f64, t as f64);
      xs.push((f * t + 0.37 * k).sin() + 0.3 * (2.3 * t + 1.1 * k).sin());
    }
    ys.push(c as i64);
  }
  let y = Tensor::from_data(TensorData::new(ys, [N]), device);
  (floats(xs, [N, 1, LEN], device), y)
}

fn build(device: &Device) -> Cnn {
  let mut m = Cnn {
    conv: Conv1dConfig::new(1, CH, K).init(device),
    fc: LinearConfig::new(FLAT, CLASSES).init(device),
    relu: Relu::new(),
  };
  // conv.weight 和 PyTorch 一样是 [out, in, k];Linear 的权重是 [in, out],按 PyTorch 的 [out, in] 生成再转置
  let w1 = (0..CH * K).map(|i| 0.4 * ((i + 1) as f64).sin()).collect();
  let b1 = (0..CH).map(|o| 0.02 * o as f64 - 0.03).collect();
  let w2 = (0..CLASSES * FLAT).map(|i| 0.15 * ((i + 1) as f64).cos()).collect();
  m.conv.weight = Param::from_tensor(floats(w1, [CH, 1, K], device));
  m.conv.bias = Some(Param::from_tensor(floats(b1, [CH], device)));
  m.fc.weight = Param::from_tensor(floats::<2>(w2, [CLASSES, FLAT], device).transpose());
  m.fc.bias = Some(Param::from_tensor(Tensor::zeros([CLASSES], device)));
  m
}

/// 转成 f64 再写 JSON(f32 转 f64 是精确的),和 Python 的 tolist() 一样
fn values<const D: usize>(t: Tensor<D>) -> Value {
  json!(t.into_data().convert::<f64>().try_to_vec::<f64>().unwrap_or_default())
}

fn params(m: &Cnn) -> Value {
  let bias = |b: &Option<Param<Tensor<1>>>| b.as_ref().map(|b| values(b.val())).unwrap_or(Value::Null);
  json!({
    "conv.weight": values(m.conv.weight.val()),
    "conv.bias": bias(&m.conv.bias),
    "fc.weight": values(m.fc.weight.val().transpose()),
    "fc.bias": bias(&m.fc.bias),
  })
}

fn train(mut optim: ModuleOptimizer, lr: f64) -> Value {
  let device = Device::flex().autodiff();
  let (x, y) = data(&device);
  let ce = CrossEntropyLossConfig::new().init(&device);
  let mut model = build(&device);
  let mut losses = Vec::with_capacity(STEPS);
  for step in 0..STEPS {
    let i = (step % (N / BATCH)) * BATCH; // 不打乱,6 个批轮流用
    let xb = x.clone().slice(s![i..i + BATCH]);
    let yb = y.clone().slice(s![i..i + BATCH]);
    let loss = ce.forward(model.forward(xb), yb);
    losses.push(loss.clone().into_scalar::<f64>());
    let grads = GradientsParams::from_grads(loss.backward(), &model);
    model = optim.step(lr, model, grads);
  }
  // 不带 autodiff 的副本和数据,相当于 model.eval() 加 torch.no_grad()
  let eval = model.valid();
  let (x, y) = data(&device.inner());
  let out = eval.forward(x);
  let loss: f64 = CrossEntropyLossConfig::new().init(&out.device()).forward(out.clone(), y.clone()).into_scalar();
  let correct: i64 = out.argmax(1).squeeze_dim::<1>(1).equal(y).int().sum().into_scalar();
  json!({
    "losses": losses,
    "final": { "loss": loss, "correct": correct },
    "params": params(&model),
  })
}

fn main() {
  let out = json!({
    "sgd": train(SgdConfig::new().init(), 0.5),
    "adam": train(AdamConfig::new().with_epsilon(1e-8).init(), 0.01),
  });
  println!("{out}");
}

几处对应关系:

PyTorchburn见
torch.optim.SGD(p, lr=0.5)SgdConfig::new().init(),0.5 在 step 时给一步
Adam(p, lr=0.01, eps=1e-8)AdamConfig::new().with_epsilon(1e-8)优化器状态
zero_grad / backward / stepbackward → from_grads → model = optim.step(..)一步
argmax(1) 得 [96]argmax(1) 得 [96, 1],再 squeeze_dim::<1>(1)归约与广播
with torch.no_grad()model.valid(),数据放在 device.inner()不求导的区域

compare.py 在怎么读这一卷里。

动脑筋答案

PyTorch(grad1.py,同一批数据和初值,先 f32 再 float64):

torch.float32  grad 1.003785996545048e-06    update -0.009901359677314758
torch.float64  grad 1.0088567935009896e-06   update -0.009901850779581708

burn(bin grad1,先默认 F32,再 F64=1):

fc.weight[1, 19] grad Some([1.0086492920891033e-6])
configure F64: Ok(())
fc.weight[1, 19] grad Some([1.0088567935017457e-6])
  • 相对差远大于几个 ulp。PyTorch f32 的梯度比 f64 小 5.07e-9,相对差 0.5%;burn f32 小 2.08e-10,相对差 2e-4。两份 f64 结果之间的相对差是 7.5e-13。
  • 原因是抵消。这个梯度是 16 个样本的 dlogits[b, 1] · h[b, 19] 之和,cancel.py 在 float64 里把各项列出来:

    sum       1.0088567935027243e-06
    sum |.|   0.09116814735603485
    max |.|   0.023424613166507163
    nonzero   8
    
    8 个非零项的绝对值加起来是 0.091,最大一项 0.023,相加后只剩 1.0e-6,抵消掉了五个数量级。0.023 附近一个 f32 ulp 约 1.9e-9,几项的舍入误差加起来就是几个 1e-9,落到 1e-6 的结果上就是千分之几。PyTorch 和 burn 的求和顺序不同,误差落点不同;这一次 burn 离 f64 更近,不代表它一般更准。
  • SGD 对这种误差不敏感:更新量是 lr · g,g 差 5e-9,参数只差 2.5e-9。Adam 第 1 步的更新量是 lr · g / (|g| + ε),梯度很小时,ε 和 g 比是 1e-8 : 1e-6,g 的相对误差会按 ε / |g| 的比例进到更新量里:0.01 × 0.005 × 1e-8 / 1.009e-6 ≈ 5e-7,和实测的 4.91e-7 对得上。Adam 把梯度归一化,小梯度上的相对误差也就被放大成了参数上的绝对误差。

小结

  • 一维小 CNN 两边一行对一行:Conv1d 权重布局两边相同,不用转置;Linear 照旧转置;flatten(1) 写成 flatten(1, 2)。100 步后两边都 96/96 全对。
  • SGD:loss 的差 100 步都在 1.79e-7 以内;参数差从 2.98e-8 涨到 7.15e-7。两份 f64 运行只差 2.32e-14,f32 下的差全是两边各自的舍入在攒。
  • Adam(ε 对齐成 1e-8):第 1 步就差 4.69e-7,来自一个抵消严重的小梯度被 Adam 归一化放大;之后涨到 3.52e-6,来自 burn 在 f32 里算的偏差修正因子(每步小 2.4 到 6.9 ppm,方向不变),f64 里照抄这些因子能把 burn 逐步复现到 6.94e-16。
  • loss 的差在训练后段会变小,参数的差不会;比两边要看参数。
  • 容差按优化器和步数定:这个任务 SGD 100 步 1e-6,Adam 100 步 1e-5。ε 没对齐时参数差 5.67e-3,1e-5 的门能挡住;只看准确率看不出来。

💬 关联讨论

发起讨论
加载中...