Rust in Everything
基于 candle 0.11.0

训练一步

主线位置:训练主线的整条线:forward → loss → backward → 优化器一步,再重复 100 次。本页先对一步,再看两边的差怎么随步数变化。
版本:PyTorch 2.15.0(CPU)、candle 0.11.0(CPU,accelerate;A3 另在 Metal 上跑)
flowchart TB
  fwd["forward"] --> loss["mse 或交叉熵"]
  loss --> bw["backward"]
  bw --> gs["GradStore"]
  gs --> st["AdamW step"]
  st -->|"Var set"| fwd
  st --> cmp["逐步对 PyTorch"]
  cmp --> r32["f32 舍入"]
  cmp --> f64["f64 运行 拆来源"]

实验代码:PyTorch 侧 trace_a3.py(A3 按步记录)、burnset.py(burn 卷的两个设置),比较脚本 perstep.py、split.py;candle 侧 crate autodiff-exp 的 bin a3(模板里的 train)、trace、burnset、convgrad、convnc、conv2grad。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。PyTorch 固定 torch.set_num_threads(1)。优化器内部(Optimizer trait、Var::set、VarMap)见 candle 卷的优化器与训练循环。

两份训练循环

训练主线的 A3:两层 MLP(8→16→4,ReLU),MSE,batch 32,初值和数据在 f64 里生成、舍入成 f32 一次,两边读同一个 safetensors 文件。AdamW 学习率 1e-2、betas (0.9, 0.999)、eps 1e-8、weight_decay 0.01。PyTorch:

p = {n: r[n].to(dtype).clone().requires_grad_() for n in names}
opt = torch.optim.AdamW(p.values(), lr=1e-2, betas=(0.9, 0.999), eps=1e-8, weight_decay=wd)
for step in range(1, 101):
  loss = torch.nn.functional.mse_loss(model(x), y)
  opt.zero_grad()
  loss.backward()
  opt.step()

candle:

let p: Vec<Var> = NAMES.iter().map(|n| Ok(Var::from_tensor(&get(n)?)?)).collect::<Result<_>>()?;
let mut opt = AdamW::new(p.clone(), ParamsAdamW { lr: 1e-2, beta1: 0.9, beta2: 0.999, eps: 1e-8, weight_decay: wd })?;
for step in 1..=100 {
  let loss = candle_nn::loss::mse(&forward(&p, &x)?, &y)?;
  let grads = loss.backward()?;
  opt.step(&grads)?;
}

forward 同 Var 与梯度。逐行对过去:

PyTorchcandle差别
AdamW(params, lr=..., ...)AdamW::new(vars, ParamsAdamW { .. })优化器构造时拿一组 Var,同 PyTorch
opt.zero_grad()没有梯度不挂在参数上,每次 backward 一张新表
loss.backward()let grads = loss.backward()?梯度作为返回值交出来
opt.step()opt.step(&grads)?把那张表交给优化器;两步合起来是 backward_step
model.parameters()varmap.all_vars()这里四个 Var 是手建的;用 candle_nn::linear 建层时从 VarMap 取

opt.step 把新值用 Var::set 复制进参数原来的存储,模型下一次前向直接看到,参数的 id 不变。这和 PyTorch 原地改 p.data 是同一个效果;和 burn 把模型按值交给优化器、换回一个新模型不同(一步)。

A3 的结果(bin a3:从初值做一步 SGD,学习率 0.1;再从初值起训练 100 步 AdamW;前面梯度那几行见 Var 与梯度),CPU:

...
  sgd.fc1.weight: 2.9802322e-8
  sgd.fc1.bias: 0e0
  sgd.fc2.weight: 3.7252903e-9
  sgd.fc2.bias: 4.656613e-9
sgd worst 2.9802322e-8
  adamw.fc1.weight: 2.9802322e-7
  adamw.fc1.bias: 2.682209e-7
  adamw.fc2.weight: 2.682209e-7
  adamw.fc2.bias: 1.1920929e-7
adamw worst 2.9802322e-7
adamw loss diff 2.9802322e-8

Metal 上 SGD 一步 2.98e-8,AdamW 100 步 2.98e-7,loss 差 1.49e-8。参数的最大绝对值是 1.13,2.98e-7 是两三个 f32 ulp,容差 1e-6 通过。这个容差挡得住真错误:candle 侧把 weight_decay 改成 0(WD=0)、PyTorch 侧仍是 0.01,同样的比较变成

adamw worst 9.816974e-3
adamw loss diff 1.5181154e-3

优化器:有什么,默认值是什么

candle-nn 0.11.0 只有两个优化器。SGD 的文档注释写明「Contrary to the PyTorch implementation of SGD, this version does not support momentum」,配置就是一个学习率;AdamW 的参数是 ParamsAdamW。两边打印出来的默认值:

ParamsAdamW { lr: 0.001, beta1: 0.9, beta2: 0.999, eps: 1e-8, weight_decay: 0.01 }
SGD {'lr': 0.001, 'momentum': 0, 'dampening': 0, 'nesterov': False, 'weight_decay': 0}
Adam {'lr': 0.001, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0}
AdamW {'lr': 0.001, 'betas': (0.9, 0.999), 'eps': 1e-08, 'weight_decay': 0.01}
PyTorchcandle 0.11.0
SGD(params, lr)SGD::new(vars, lr),学习率必须给
SGD(momentum=0.9)、nesterov、weight_decay没有,要自己写
AdamW() 默认值ParamsAdamW::default(),五个数相同
Adam(params, ...)AdamW 配 weight_decay: 0.0
Adam(weight_decay=λ)(L2,加到梯度上)没有;AdamW 的衰减是解耦的
学习率调度器每步自己调 opt.set_learning_rate(lr)
clip_grad_norm_没有;在 GradStore 上自己算
参数组(每组不同 lr)没有;建两个优化器,各拿一部分 Var

和 burn 不同,candle 的 AdamW 默认值和 PyTorch 一致(burn 的 ε 默认 1e-5,权重衰减默认 1e-4,见优化器状态)。

Adam 写成 weight_decay 为 0 的 AdamW,是因为 PyTorch 自己的 Adam 和 AdamW(weight_decay=0) 逐位相同:

step  loss_a       loss_b       |dloss|   max|dparam|  where
   1  2.20907664  2.20907664  0.00e+00  0.00e+00     fc1.weight[0]
 100  0.18017162  0.18017162  0.00e+00  0.00e+00     fc1.weight[0]

(A3 上 torch.optim.Adam 对 torch.optim.AdamW(weight_decay=0),100 步。)candle 的 AdamW(weight_decay: 0.0) 对 torch.optim.Adam,100 步后参数最大差 2.09e-7。PyTorch 的 Adam(weight_decay=λ) 是把 λθ 加进梯度(虚拟环境里 torch 2.15.0 的 _single_tensor_adam 是 grad = grad.add(param, alpha=weight_decay)),再走 Adam 的归一化,和解耦衰减不是一回事,candle 没有它。

逐步对 100 步

A3 的 AdamW,每步记下更新前的 loss 和更新后的 212 个参数,按步比:

step  loss_a       loss_b       |dloss|   max|dparam|  where
   1  2.20907664  2.20907640  2.38e-07  1.49e-08     fc1.weight[7]
   2  2.02169561  2.02169561  0.00e+00  2.98e-08     fc1.weight[79]
   3  1.86176085  1.86176074  1.19e-07  2.98e-08     fc1.weight[79]
   5  1.60629380  1.60629380  0.00e+00  5.96e-08     fc1.weight[53]
  10  1.23957324  1.23957324  0.00e+00  5.96e-08     fc1.weight[42]
  20  0.94048572  0.94048566  5.96e-08  1.19e-07     fc1.weight[29]
  30  0.73502350  0.73502362  1.19e-07  1.19e-07     fc2.weight[62]
  40  0.57866812  0.57866812  0.00e+00  1.79e-07     fc2.weight[62]
  50  0.46574855  0.46574861  5.96e-08  1.19e-07     fc1.weight[17]
  60  0.38534674  0.38534677  2.98e-08  1.79e-07     fc1.weight[33]
  70  0.31379634  0.31379640  5.96e-08  2.38e-07     fc2.weight[52]
  80  0.25856650  0.25856653  2.98e-08  2.98e-07     fc1.weight[33]
  90  0.21576177  0.21576181  4.47e-08  2.38e-07     fc1.weight[33]
 100  0.18168497  0.18168503  5.96e-08  2.98e-07     fc1.weight[33]

loss 的差没有随步数变大(第 1 步的 2.38e-7 是 2 附近的一个 ulp)。参数的差从 1.49e-8 涨到 2.98e-7,涨得很慢,而且有升有降(第 50、90 步比前一行小)。Metal 上同一张表第 100 步也是 2.98e-7。

涨的是什么?两边都换成 f64 再跑一遍(PyTorch 侧初值和数据 .to(torch.float64),candle 侧 to_dtype(DType::F64),都从同一组 f32 值出发)。下表第一列是上面那一列,后三列是 PyTorch f32 离自己的 f64 运行、candle f32 离自己的 F64 运行、两份 f64 运行之间的最大参数差:

step  max|dparam|  torch-f64  candle-f64  f64-f64
   1  1.49e-08     2.83e-08   2.83e-08    5.55e-17
   2  2.98e-08     5.93e-08   5.93e-08    5.55e-17
   3  2.98e-08     7.62e-08   7.62e-08    5.55e-17
   5  5.96e-08     1.59e-07   1.59e-07    1.11e-16
  10  5.96e-08     2.71e-07   2.71e-07    4.44e-16
  20  1.19e-07     3.41e-07   3.41e-07    8.33e-16
  30  1.19e-07     4.77e-07   4.77e-07    1.33e-15
  40  1.79e-07     6.81e-07   7.11e-07    1.89e-15
  50  1.19e-07     7.36e-07   7.92e-07    2.11e-15
  60  1.79e-07     8.23e-07   8.59e-07    2.72e-15
  70  2.38e-07     1.01e-06   9.48e-07    3.11e-15
  80  2.98e-07     1.31e-06   1.31e-06    2.78e-15
  90  2.38e-07     1.59e-06   1.59e-06    2.89e-15
 100  2.98e-07     1.74e-06   1.74e-06    2.89e-15
  • 两份 f64 运行 100 步只差 2.89e-15:两边的 AdamW 是同一个公式,每一步的更新量相同。
  • 每一边的 f32 运行离自己的 f64 解都涨到 1.74e-6,这是 f32 舍入随步数的累积。
  • 两边 f32 之间却只差 2.98e-7。表里前 30 步「torch-f64」和「candle-f64」两列相同:逐个参数查,两边离 f64 最远的是同一个参数,带符号的误差到第 30 步仍然相同到 1e-15(第 1 步都是 fc2.weight[20] 的 -2.828273576449547e-08)。两边的 f32 运行在大部分运算上做了同样的舍入,彼此之间的差只是这份共同误差之外的那一点。

SGD(学习率 0.1)同样拆一遍,第 100 步是 1.19e-7、3.80e-7、4.39e-7、2.22e-16,结论相同。

和 burn 卷比:同一个设置

burn 卷里 Adam 对 PyTorch 的差更大,而且是系统性的:A1 的 MLP 训练 10 步差 4.77e-7,一维小 CNN 训练 100 步差 3.52e-6,两份 f64 运行之间也差 3.78e-7 和 3.71e-6。原因是 burn 在 f32 里算偏差修正因子(优化器状态、同一个小模型训练到收敛)。A3 和 burn 卷的模型、损失、优化器都不同,数字不能直接比。所以这里把 burn 卷的两个设置原样在 candle 上跑:同样的模型、初值、数据、批次顺序,交叉熵,Adam 学习率 0.01、betas (0.9, 0.999)、ε 1e-8,candle 侧是 AdamW 配 weight_decay: 0.0。PyTorch 侧打印的 loss 和 burn 卷那两页 PyTorch 一列相同(例如 A1 十步从 1.14005756 到 0.88104737,CNN 第 100 步 0.07264095)。

A1,10 步:

step  loss_a       loss_b       |dloss|   max|dparam|  where
   1  1.14005756  1.14005744  1.19e-07  1.49e-08     fc1.weight[5]
   2  1.10198629  1.10198629  0.00e+00  2.98e-08     fc1.weight[10]
   3  1.06721914  1.06721926  1.19e-07  2.98e-08     fc1.weight[10]
   4  1.03404224  1.03404224  0.00e+00  2.98e-08     fc1.weight[10]
   5  1.00236571  1.00236583  1.19e-07  2.98e-08     fc1.weight[10]
   6  0.97519988  0.97519994  5.96e-08  5.96e-08     fc2.weight[19]
   7  0.94985896  0.94985902  5.96e-08  5.96e-08     fc2.weight[19]
   8  0.92572045  0.92572045  0.00e+00  5.96e-08     fc2.weight[19]
   9  0.90281999  0.90281993  5.96e-08  5.96e-08     fc1.weight[13]
  10  0.88104737  0.88104743  5.96e-08  5.96e-08     fc1.weight[13]

两份 f64 运行之间第 10 步差 4.44e-16。

一维 CNN,100 步(Conv1d(1→4, kernel 5) → ReLU → 展平 → Linear(48→3),每批 16 条,6 个批轮流用;卷积写成 conv2d,原因见下一节):

step  loss_a       loss_b       |dloss|   max|dparam|  where
   1  1.13687289  1.13687289  0.00e+00  8.27e-07     fc.weight[67]
   2  1.11771631  1.11771631  0.00e+00  8.49e-07     fc.weight[67]
   5  1.08280766  1.08280766  0.00e+00  8.08e-07     fc.weight[67]
  10  1.03940225  1.03940225  0.00e+00  7.64e-07     fc.weight[67]
  20  0.97808975  0.97808981  5.96e-08  7.72e-07     fc.weight[67]
  30  0.83425587  0.83425605  1.79e-07  7.62e-07     fc.weight[67]
  40  0.69612402  0.69612426  2.38e-07  7.45e-07     fc.weight[67]
  50  0.58540988  0.58541012  2.38e-07  7.53e-07     fc.weight[67]
  60  0.38402307  0.38402340  3.28e-07  7.75e-07     fc.weight[67]
  70  0.24789160  0.24789184  2.38e-07  8.05e-07     fc.weight[67]
  80  0.17158426  0.17158438  1.19e-07  7.60e-07     fc.weight[67]
  90  0.10003939  0.10003950  1.12e-07  7.45e-07     fc.weight[67]
 100  0.07264095  0.07264101  5.96e-08  7.75e-07     fc.weight[47]
step  max|dparam|  torch-f64  candle-f64  f64-f64
   1  8.27e-07     4.91e-07   3.36e-07    2.43e-16
  10  7.64e-07     4.66e-07   2.98e-07    2.78e-16
  50  7.53e-07     4.48e-07   3.04e-07    2.39e-15
 100  7.75e-07     4.80e-07   5.64e-07    3.72e-15

(第二张表只摘了四行,其余各步同一量级。)

burn 对 PyTorchcandle 对 PyTorch
A1 Adam 10 步,f32 参数最大差4.77e-75.96e-8
A1 Adam 10 步,两份 f64 之间3.78e-74.44e-16
CNN Adam 100 步,f32 参数最大差3.52e-67.75e-7
CNN Adam 100 步,两份 f64 之间3.71e-63.72e-15
  • candle 的两份 f64 运行和 PyTorch 只差 f64 的舍入:candle 的 AdamW 每一步的更新量和 PyTorch 相同,没有 burn 那份每步都在攒的系统偏差。
  • CNN 第 1 步就差 8.27e-7,之后不涨。这一份在 burn 卷里也有(第 1 步 4.69e-7):差最大的是 fc.weight[67](PyTorch 布局下的 fc.weight[1, 19]),一个梯度抵消严重的参数,burn 卷那一页的动脑筋拆过它。PyTorch 的 f32 运行在它上面比 f64 解大 4.91e-7,candle 的小 3.36e-7,方向相反,加起来正好是 8.27e-7。它不随步数涨,100 步仍是 7.75e-7,容差 1e-6 通过;burn 在同一处要放宽到 1e-5。

差别在偏差修正因子用什么精度算。AdamW::step 开头:

        self.step_t += 1;
        let lr = self.params.lr;
        let lambda = self.params.weight_decay;
        let lr_lambda = lr * lambda;
        let beta1 = self.params.beta1;
        let beta2 = self.params.beta2;
        let scale_m = 1f64 / (1f64 - beta1.powi(self.step_t as i32));
        let scale_v = 1f64 / (1f64 - beta2.powi(self.step_t as i32));

ParamsAdamW 的字段都是 f64,scale_m、scale_v 在 f64 里算,作为标量乘到张量上时才变成张量的 dtype。PyTorch 在 Python 的 float(f64)里算 1 - beta1 ** step。burn 的 beta_1、beta_2 是 f32 字段,1 − β₂ᵗ 在前几步只有 0.001 到 0.01,f32 里 β₂ᵗ 的舍入落到它上面就成了 ppm 级的相对误差。

反向验证这个解释:在 candle 里照 AdamW::step 的公式手写一个优化器,只把这两个因子改成在 f32 里算((1f32 / (1f32 - b1.powi(t))) as f64,beta 用 black_box 包住),其余不变。先确认因子用 f64 时它和 AdamW 逐位相同(A3 和 CNN 上 100 步都差 0),再把因子换到 f32,在 CNN 上和 PyTorch 比:

step  max|dparam|  torch-f64  candle-f64  f64-f64
   1  7.64e-07     4.91e-07   3.38e-07    6.63e-08
   2  8.64e-07     5.05e-07   3.49e-07    1.69e-07
   5  6.56e-07     4.82e-07   3.30e-07    5.00e-07
  10  1.06e-06     4.66e-07   2.97e-07    1.09e-06
  20  2.16e-06     4.95e-07   2.87e-07    2.16e-06
  30  3.34e-06     4.84e-07   2.65e-07    3.34e-06
  40  4.66e-06     4.64e-07   2.64e-07    4.59e-06
  50  6.44e-06     4.48e-07   2.73e-07    6.34e-06
  60  8.17e-06     4.61e-07   3.25e-07    7.91e-06
  70  9.24e-06     4.66e-07   3.83e-07    9.14e-06
  80  1.00e-05     4.36e-07   4.02e-07    9.94e-06
  90  1.01e-05     4.45e-07   4.86e-07    1.00e-05
 100  1.02e-05     4.80e-07   6.19e-07    1.01e-05

「f64-f64」一列马上开始涨,100 步到 1.01e-5,f32 的差跟着它涨到 1.02e-5。这不是 burn 的原式(burn 的 β 本身也是 f32,两个因子合并的写法也不同),数值不必和 3.71e-6 相同;能说明的是方向:因子一挪到 f32,系统偏差就出现。A3 上同样的改动,第 99 步差到 8.28e-6,第 100 步跳到 2.32e-3:f64 运行里下标 7 的隐藏单元在第 99 步有 16 个样本处在 ReLU 的正半边,改了因子的那份是 17 个,梯度从这里分叉。系统偏差攒到把一个样本推过 ReLU 的拐点,差就不再是舍入量级。

顺带:conv1d 的卷积核梯度是错的

上面的 CNN 先是用 conv1d 写的,第 1 步的 loss 两边相同,更新后的参数就差了 2.00e-2:

step  loss_a       loss_b       |dloss|   max|dparam|  where
   1  1.13687289  1.13687277  1.19e-07  2.00e-02     conv.weight[9]
   2  1.11771631  1.12217641  4.46e-03  3.79e-02     conv.weight[7]
 100  0.07264095  0.41381985  3.41e-01  1.15e+00     conv.weight[15]

前向对、卷积核的梯度错。缩到最小:batch 2、输出 2 通道,loss = sum(conv1d(x, w) * c):

let x = Tensor::arange(0f32, 12., &dev)?.reshape((2, 1, 6))?;
let w = Var::new(&[[[1f32, -1., 2.]], [[0.5, 0.25, -2.]]], &dev)?;
let c = Tensor::arange(1f32, 17., &dev)?.reshape((2, 2, 4))?;
let loss = (x.conv1d(&w, 0, 1, 1, 1)? * &c)?.sum_all()?;
let g = loss.backward()?;
x = torch.arange(0., 12.).reshape(2, 1, 6)
w = torch.tensor([[[1., -1., 2.]], [[0.5, 0.25, -2.]]], requires_grad=True)
c = torch.arange(1., 17.).reshape(2, 2, 4)
(torch.nn.functional.conv1d(x, w) * c).sum().backward()
loss -84
w.grad Some(Ok([220.0, 256.0, 292.0, 508.0, 608.0, 708.0]))
batch1 w.grad Some(Ok([20.0, 30.0, 40.0, 44.0, 70.0, 96.0]))
loss -84.0
w.grad [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]
batch1 w.grad [20.0, 30.0, 40.0, 44.0, 70.0, 96.0]

只用第一个样本(batch1)时两边相同,两个样本时 candle 错。反向规则 Op::Conv1D 本身没错:卷积核的梯度用一次 conv1d 算,输入是 arg.transpose(0, 1),卷积核是 grad.transpose(0, 1)。batch 和输出通道都大于 1 时,后者不连续。错在前向 conv1d 处理不连续卷积核的那一枝。bin convnc 直接用这两个张量做前向:

xt contiguous true  ct contiguous false
both strided                 [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]
input contiguous             [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]
kernel contiguous            [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]
both contiguous              [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]

卷积核先 contiguous() 就对了。CPU 的 conv1d 里,不连续的卷积核复制成连续的 kernel_c,矩阵乘用的却还是原来的 kernel,再按连续布局去读:

        } else {
            // Make the kernel contiguous if not already the case.
            let mut kernel_c = unsafe {
                self.device()
                    .alloc_uninit(kernel_l.shape(), kernel.dtype())?
            };
            kernel.copy_strided_src(&mut kernel_c, 0, kernel_l)?;
            let kernel_l = Layout::contiguous_with_offset((1, n, k), kernel_l.start_offset())
                .transpose(1, 2)?
                .broadcast_as((b, k, n))?;
            col.matmul(kernel, (b, m, n, k), &col_l, &kernel_l)?
        };

Metal 的 conv1d 是同样的写法,Metal 上 convnc 的输出逐字相同。origin/main(2026-10-29)上两处都没有改。conv2d 在 CPU 上已经换成另一份实现。bin conv2grad 做同样的检查(batch 2、输出 2 通道、2×2 卷积核):

cpu   conv2d w.grad Some(Ok([4728.0, 4980.0, 5736.0, 5988.0, 6834.0, 7248.0, 8490.0, 8904.0]))
metal conv2d w.grad Some(Ok([3027.0, 3198.0, 3711.0, 3882.0, 7239.0, 7734.0, 9219.0, 9714.0]))
torch  w.grad [4728.0, 4980.0, 5736.0, 5988.0, 6834.0, 7248.0, 8490.0, 8904.0]

(前两行是 conv2grad cpu 和 conv2grad metal,第三行是 PyTorch。)CPU 和 PyTorch 相同;Metal 的 conv2d 有同样的写法,卷积核梯度也错。所以上面的 CNN 在 CPU 上把卷积写成 conv2d:

// 输入 [b, 1, 1, 16],卷积核 [4, 1, 1, 5]
xb.unsqueeze(2)?.conv2d(&p[0].unsqueeze(2)?, 0, 1, 1, 1)?.squeeze(2)?

推理一般不受影响:从文件加载的卷积核是连续的,只有自己把卷积核转置、切片后再传进去才会走到那一枝。训练一维卷积时,CPU 上可以这样绕过去;Metal 上 conv1d 和 conv2d 的卷积核梯度都错,本页没有试绕法。

小结

  • 训练循环的对应:zero_grad 没有;let grads = loss.backward()?;opt.step(&grads)?(或 backward_step)。优化器构造时拿一组 Var,step 用 Var::set 原地写回,参数 id 不变。A3 上 SGD 一步差 2.98e-8,AdamW 100 步差 2.98e-7(CPU 和 Metal 相同,容差 1e-6)。
  • 只有 SGD(没有动量)和 AdamW;AdamW 的默认值和 PyTorch 相同;Adam 写成 weight_decay: 0.0(PyTorch 的 Adam 和 AdamW(weight_decay=0) 逐位相同)。调度、裁剪、参数组都要自己写。
  • 逐步对 100 步:两份 f64 运行只差 2.89e-15,f32 的差是两边各自的舍入,而两边的舍入大部分相同,所以彼此之间只差 2.98e-7。
  • 在 burn 卷的设置上:A1 十步 5.96e-8(burn 4.77e-7),CNN 百步 7.75e-7(burn 3.52e-6),f64 之间分别 4.44e-16 和 3.72e-15。candle 没有 burn 那份系统偏差,原因是偏差修正因子在 f64 里算;改到 f32 里算,偏差马上出现。
  • candle 0.11.0 的 conv1d 在卷积核不连续时算错(CPU 和 Metal),反向正好传进一个不连续的卷积核,batch 和输出通道都大于 1 时卷积核梯度是错的;CPU 上改用 conv2d 可以绕过。

💬 关联讨论

发起讨论
加载中...