Rust in Everything
基于 candle 0.11.0

conv1d 的卷积核梯度不对

主线位置:训练主线的「backward」一步。模型里有 nn.Conv1d(音频前端、Mamba / Hyena 的短卷积、TCN)。

版本:PyTorch 2.15.0(CPU)、candle 0.11.0(CPU,accelerate;Metal)
flowchart TB
  f["conv1d 前向"] --> l["loss 对"]
  l --> b["backward"]
  b --> k["内部再调 conv1d"]
  k --> nc["卷积核不连续"]
  nc --> bad["w.grad 错"]
  w2["CPU 换 conv2d"] --> ok1["对"]
  w3["im2col 加 matmul"] --> ok2["CPU 和 Metal 都对"]

本页的对照:PyTorch 侧是实验代码里的 p12.py 和 p12_big.py,candle 侧是实验 crate pita-t 的 p12_conv1d 和 p12_big(features metal accelerate,release)。平台 M2 Max、macOS 27、rustc 1.99.0。

PyTorch 的写法

batch 2、1 个输入通道、2 个输出通道、卷积核长 3,loss = sum(conv1d(x, w) * c):

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)
loss = (F.conv1d(x, w) * c).sum(); loss.backward()
loss -84.0 w.grad [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]

搬到 candle 的现象

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()?;
== cpu
conv1d         loss -84 w.grad [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]
== metal
conv1d         loss -84 w.grad [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]

loss 和 PyTorch 相同,梯度六个数全错,CPU 和 Metal 错得一样。换一组更像真实模型的随机数据(batch 4、输入 3 通道、输出 5 通道、长度 32、卷积核 5),梯度最大绝对值 25.9,candle 和 PyTorch 差:

cpu conv1d: w.grad max|diff| vs torch = 3.630e1
metal conv1d: w.grad max|diff| vs torch = 3.630e1

差比梯度本身还大。不报错,训练能跑;训练一步里的小 CNN 用 conv1d 训 100 步,loss 停在 0.414,PyTorch 是 0.073。

原因

Op::Conv1D 的反向规则用一次 conv1d 算卷积核的梯度,传进去的卷积核是 grad.transpose(0, 1)。batch 和输出通道都大于 1 时,这个转置不连续。前向 conv1d 处理不连续卷积核的那一枝把它复制成连续的 kernel_c,却仍用原来的 kernel 去做矩阵乘,并按连续布局去读,于是读错。Metal 的 conv1d 是同样的写法。代码和逐步的验证(只要卷积核先 contiguous() 就对)见训练一步的「顺带:conv1d 的卷积核梯度是错的」。

这是 0.11.0 的上游 bug,origin/main(2026-10-29)上 CPU 和 Metal 两处都没有改。推理一般不受影响:从文件加载的卷积核是连续的,只有反向(或者自己把卷积核转置、切片后再传进去)才走到那一枝。

改法

两种绕法,都在同一个程序里跑过:

// CPU only: a 1xK conv2d, whose CPU implementation is separate and correct
let y2 = x.unsqueeze(2)?.conv2d(&w.as_tensor().unsqueeze(2)?, 0, 1, 1, 1)?.squeeze(2)?;

// CPU and Metal: im2col by hand (stride 1, no padding, dilation 1)
fn conv1d_im2col(x: &Tensor, w: &Tensor) -> candle_core::Result<Tensor> {
  let (b, c_in, l) = x.dims3()?;
  let (c_out, _, k) = w.dims3()?;
  let l_out = l - k + 1;
  let taps: Vec<Tensor> = (0..k).map(|j| x.narrow(2, j, l_out)).collect::<candle_core::Result<_>>()?;
  let cols = Tensor::stack(&taps, 2)?.reshape((b, c_in * k, l_out))?; // [b, c_in*k, l_out]
  w.reshape((c_out, c_in * k))?.broadcast_matmul(&cols)             // [b, c_out, l_out]
}
== cpu
im2col         loss -84 w.grad [340.0, 392.0, 444.0, 484.0, 568.0, 652.0] (forward max|diff| vs conv1d 0)
conv2d (1xK)   loss -84 w.grad [340.0, 392.0, 444.0, 484.0, 568.0, 652.0]
== metal
im2col         loss -84 w.grad [340.0, 392.0, 444.0, 484.0, 568.0, 652.0] (forward max|diff| vs conv1d 0)
conv2d (1xK)   loss -84 w.grad [220.0, 256.0, 292.0, 508.0, 608.0, 708.0]

随机数据那一组:

cpu im2col: w.grad max|diff| vs torch = 0.000e0
metal im2col: w.grad max|diff| vs torch = 5.722e-6
  • conv2d 在 CPU 上已经换成另一份实现,梯度对;Metal 的 conv2d 有同样的写法,梯度同样错(上面最后一行)。
  • im2col 版本只用 narrow、stack、reshape、broadcast_matmul,反向全走这些算子各自的规则,不经过卷积内核。前向和 conv1d 差 0;随机数据上梯度和 PyTorch 在 CPU 上差 0,在 Metal 上差 5.7e-6(梯度最大 25.9,f32 的舍入)。
  • 代价是内存:cols 是输入的 k 倍。带 padding 时先 x.pad_with_zeros(2, p, p)?;stride 大于 1 时每个 narrow 之后再按步长 index_select。这两种变体本页没有跑。

怎么检查

  • 训练里用到卷积的,对一个 batch 大于 1、输出通道大于 1 的小例子和 PyTorch 比一次梯度。只比 loss 看不出来:前向是对的。
  • batch 1 时两边相同,用 batch 1 做的梯度测试会通过。
  • 怀疑某个算子的反向时,把卷积核先 contiguous() 再前向一次,结果变了就是这个问题。

小结

  • candle 0.11.0 的 conv1d 反向给出错误的卷积核梯度(batch > 1 且输出通道 > 1,CPU 和 Metal),前向和 loss 都对,不报错;main 未修。
  • 原因:反向把不连续的 grad.transpose(0, 1) 当卷积核传进 conv1d,而 conv1d 的不连续分支复制了却没用上副本。
  • 改法:CPU 上用 1×K 的 conv2d;CPU 和 Metal 都能用 narrow + stack + broadcast_matmul 的 im2col,和 PyTorch 差 0 / 5.7e-6。

💬 关联讨论

发起讨论
加载中...