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。