Rust in Everything
基于 candle-core 0.11.0

shape mismatch in add

主线位置:算子主线的第一步。a.matmul(&b)? 在碰到 Storage 之前,Tensor::matmul 先检查形状;逐元素的 add、mul 也在同一层检查。错误值的结构见错误。

flowchart TB
  op["x + bias"] --> chk{"形状完全相同"}
  chk -->|否| err["shape mismatch in add"]
  chk -->|是| run["后端逐元素计算"]
  fix["broadcast_add"] --> bs["先算广播形状"]
  bs --> view["broadcast_as 视图"]
  view --> run

实验 crate exp-candle/pa-pit,bin p01_shape,CPU,debug;M2 Max、macOS 27、rustc 1.99.0。

现象

从 PyTorch 或 NumPy 过来,最顺手的写法是给每一行加一个偏置:

let x = Tensor::zeros((2, 3), DType::F32, &dev)?;
let bias = Tensor::zeros(3, DType::F32, &dev)?;
show("x + bias", &x + &bias);
x + bias: Err(shape mismatch in add, lhs: [2, 3], rhs: [3])
x.add(&bias): Err(shape mismatch in add, lhs: [2, 3], rhs: [3])
x.broadcast_add(&bias): Ok [2, 3]
x + 1.0: Ok [2, 3]
x - col: Err(shape mismatch in sub, lhs: [2, 3], rhs: [2, 1])
x.broadcast_sub(&col): Ok [2, 3]
x.broadcast_add(&bad): Err(shape mismatch in broadcast_add, lhs: [2, 3], rhs: [2])

col 是 (2, 1),bad 是 (2)。标量可以直接加(x + 1.0),两个张量只要形状不完全相同就报错,哪怕按广播规则合法。最后一行是真正不合法的广播,报错的 op 名换成了 broadcast_add。

matmul 也一样。一批序列 (batch, seq, in) 乘一个二维权重 (in, out):

h.matmul(&w): Err(shape mismatch in matmul, lhs: [4, 7, 3], rhs: [3, 5])
h.broadcast_matmul(&w): Ok [4, 7, 5]
reshape-matmul-reshape: Ok [4, 7, 5]
(2,3,4,5).matmul((3,2,5,6)): Ok [2, 3, 4, 6]
(2,3,4,5).broadcast_matmul((3,2,5,6)): Err(shape mismatch in broadcast_matmul, lhs: [2, 3], rhs: [3, 2])

倒数第二行反过来:批维 (2, 3) 和 (3, 2) 明明不同,matmul 却成功了,结果的批维取左边的 (2, 3)。

出处

逐元素二元运算由 binary_op! 宏生成,第一行就是 same_shape_binary_op:

    pub(crate) fn same_shape_binary_op(&self, rhs: &Self, op: &'static str) -> Result<&Shape> {
        let lhs = self.shape();
        let rhs = rhs.shape();
        if lhs != rhs {
            Err(Error::ShapeMismatchBinaryOp {
                lhs: lhs.clone(),
                rhs: rhs.clone(),
                op,
            }
            .bt())
        } else {
            Ok(lhs)
        }
    }

广播只在 broadcast_* 系列里做:broadcast_binary_op! 先用 Shape::broadcast_shape_binary_op 算出目标形状,再把需要扩展的一边 broadcast_as 成同形状的视图(步长为 0,不复制),最后调普通的 add。x + 1.0 能过,是因为和 f64 相加走的是 affine,不经过形状检查(见 DType 与 Device)。

Tensor::matmul 要求两边秩相同,批维只比较乘积:

        let batching: usize = a_dims[..dim - 2].iter().product();
        let batching_b: usize = b_dims[..dim - 2].iter().product();
        if k != k2 || batching != batching_b {
            Err(Error::ShapeMismatchBinaryOp {
                lhs: self.shape().clone(),
                rhs: rhs.shape().clone(),
                op: "matmul",
            }
            .bt())?
        }

所以 (4, 7, 3) 乘 (3, 5) 因秩不同被拒;(2, 3, ..) 乘 (3, 2, ..) 因 6 == 6 被放行,后端把两边都当成 6 个矩阵按顺序配对,第 i 个配第 i 个。方法的文档写的是「self - A tensor with dimensions b1, b2, ..., bi, m, k」「rhs - ... b1, b2, ..., bi, k, n」,即批维逐个相同;检查比文档宽。

📝NOTE
冷知识:乘积比较本来是「更严格」的检查

批维的这项检查是 PR #93(「Sketch the Falcon model.」)顺手加的,提交说明的最后一条是「Stricter shape checking in matmul.」。之前 matmul 只比较 k != k2,批维完全不看;这次加上了 batching != batching_b,两个乘积都由同一处代码算出,直接拿来比较。0.11.0 里这几行没有变。这个提交早于本地仓库最早的 tag,git tag --contains 只能查到 0.5.1。

修法

逐元素运算换成 broadcast_*:broadcast_add、broadcast_sub、broadcast_mul、broadcast_div,还有 broadcast_maximum、broadcast_minimum、broadcast_eq 这类比较。

(batch, seq, in) 乘二维权重有两种写法,上面的输出里都成功了:

// 1. broadcast_matmul: rhs is broadcast to (4, 5, ..) and made contiguous
let y = h.broadcast_matmul(&w)?;
// 2. fold the batch into rows, one 2-D matmul, unfold
let y = h.reshape((4 * 7, 3))?.matmul(&w)?.reshape((4, 7, ()))?;

broadcast_matmul 会把被广播的一边 contiguous(),真的复制出 batch 份权重;源码里留着「TODO: Avoid concretising the broadcasted matrixes via contiguous.」。第二种写法不复制,candle-nn 的 Linear::forward 对连续输入就是这么做的,注释写着「we avoid using a broadcasted matmul as it is much slower than the standard matmul for the cuda and cpu backends」。直接用 Linear 最省事(权重是 [out, in],见常用层)。

批维 (2, 3) 对 (3, 2) 这种情况不会报错,只能自己防:批维来自不同来源时,乘之前比较 a.dims()[..rank - 2] 和 b.dims()[..rank - 2],或者改用 broadcast_matmul,它逐维计算广播形状,上面的输出里它报了错。

怎么确认没中招

  • shape mismatch in <op> 里的 <op> 是 add / sub / mul / div / matmul(不带 broadcast_)时,先看两边秩是否相同、是否只差一个长度为 1 的维度:这类都是缺了 broadcast_。带 broadcast_ 前缀的才是形状真的不兼容。
  • 批量 matmul 的结果形状只看左边。写一行 assert_eq!(a.dims()[..r - 2], b.dims()[..r - 2]) 比事后查数值便宜。
  • 加载权重时报的 shape mismatch for <名字> 是另一回事(文件里的形状和模型要的不同),见 VarBuilder。

小结

  • candle 的 +、-、*、/ 和 matmul 都要求形状相同,不做隐式广播;要广播就写 broadcast_*。
  • 批量乘二维权重:broadcast_matmul(会复制权重),或 reshape 成二维再乘(Linear 的做法)。
  • matmul 的批维只比较乘积,(2, 3) 对 (3, 2) 不报错,结果形状跟左边。

💬 关联讨论

发起讨论
加载中...