Invalid matmul arguments
主线位置:算子主线的后端一步。
Tensor::matmul只看形状,把两边的Layout(形状、步长、偏移)原样交给后端;CpuStorage::matmul和MetalStorage::matmul各自决定接受哪些步长。布局和视图的来龙去脉见布局与视图。
flowchart TB
v["narrow 或 transpose"] --> lay["非连续 Layout"]
lay --> be{"哪个后端"}
be -->|"CPU gemm"| ok["能算"]
be -->|"CPU accelerate"| e1["报 Striding 错"]
be -->|Metal| e2["Invalid matmul"]
fix["contiguous"] --> cl["连续 Layout"]
cl --> ok2["三个后端都能算"]
实验 crate exp-candle/pa-pit(features metal accelerate)和 exp-candle/pa-noacc(只开 metal),bin p04_contig,debug;M2 Max、macOS 27、rustc 1.99.0。
现象
一个融合的 QKV 投影输出 (seq, 3 * d),沿最后一维切出 Q 再乘一个矩阵:
let qkv = Tensor::randn(0f32, 1., (8, 3 * 16), &dev)?;
let w = Tensor::randn(0f32, 1., (16, 16), &dev)?;
let q = qkv.narrow(1, 0, 16)?;
show("q.matmul(&w)", q.matmul(&w));
开 accelerate 的 CPU 和 Metal:
== Cpu
q dims [8, 16] stride [48, 1] contiguous false
q.matmul(&w): Err(MatMulUnexpectedStriding { lhs_l: Layout { shape: [8, 16], stride: [48, 1], start_offset: 0 }, rhs_l: Layout { shape: [16, 16], stride: [16, 1], start_offset: 0 }, bmnk: (1, 8, 16, 16), msg: "non-contiguous lhs" })
q.contiguous()?.matmul(&w): Ok [8, 16]
w.matmul(&q.t()): Err(MatMulUnexpectedStriding { lhs_l: Layout { shape: [16, 16], stride: [16, 1], start_offset: 0 }, rhs_l: Layout { shape: [16, 8], stride: [1, 48], start_offset: 0 }, bmnk: (1, 16, 8, 16), msg: "non-contiguous rhs" })
q.t().matmul(&q): Err(MatMulUnexpectedStriding { lhs_l: Layout { shape: [16, 8], stride: [1, 48], start_offset: 0 }, rhs_l: Layout { shape: [8, 16], stride: [48, 1], start_offset: 0 }, bmnk: (1, 16, 16, 8), msg: "non-contiguous rhs" })
...
== Metal { gpu_id: 4294968354 }
q dims [8, 16] stride [48, 1] contiguous false
q.matmul(&w): Err(Metal error Invalid matmul arguments [48, 1] [16, 1] (8, 16, 16))
q.contiguous()?.matmul(&w): Ok [8, 16]
w.matmul(&q.t()): Err(Metal error Invalid matmul arguments [16, 1] [1, 48] (16, 8, 16))
q.t().matmul(&q): Err(Metal error Invalid matmul arguments [1, 48] [48, 1] (16, 16, 8))
不开 accelerate,同一段代码在 CPU 上全部成功:
== Cpu
q dims [8, 16] stride [48, 1] contiguous false
q.matmul(&w): Ok [8, 16]
q.contiguous()?.matmul(&w): Ok [8, 16]
w.matmul(&q.t()): Ok [16, 8]
q.t().matmul(&q): Ok [16, 16]
在 Linux 上(gemm)开发、到 Mac 上开 accelerate 或换 Metal,才第一次看到这个错误。
不只是 matmul。同一个程序里另外两个常用算子:
softmax_last_dim(s.t()): Err(input has to be contiguous)
softmax(s.t(), D::Minus1): Ok [6, 4]
softmax_last_dim(s.t().contiguous()): Ok [6, 4]
cache.append(k) [5 tokens]: Err(slice-set only supports contiguous tensors)
1 token transposed: contiguous true
cache.append(k1) [1 token]: Ok [1, 3, 1, 64]
cache.append(k.contiguous()): Ok [1, 3, 5, 64]
(这是 CPU 的输出;Metal 上 softmax_last_dim 报的是 Non contiguous softmax-last-dim is not implemented,append 的两行相同。)k 是常见的 (batch, seq, heads, d) 转成 (batch, heads, seq, d):
let k = Tensor::ones((1, 5, 3, 64), DType::F32, &dev)?.transpose(1, 2)?;
let mut cache = KvCache::new(2, 16);
cache.append(&k, &k)
prefill(5 个 token)报错,decode(1 个 token)却没事:seq 为 1 时,长度为 1 的维度不参与连续性判断,转置后仍算连续。于是只测了单 token 的代码,上线后一个多 token 的 prompt 就失败。
出处
三个 matmul 实现对最后两维的要求(布局与视图一页有完整的表):
gemm那一支把行步长、列步长都传给gemmcrate,任意步长都行。accelerate那一支要映射到 BLAS 的N/T:要么行步长等于列数、列步长为 1,要么反过来;[48, 1]的8 × 16矩阵两样都不是(行步长 48 ≠ 16),报MatMulUnexpectedStriding,msg说明是哪一边。- Metal 的
call_mlx_gemm做同样的判断:
let (lda, a_trans) = if (lhs_m1 == 1 || k == 1) && (lhs_m2 == k || m == 1) {
(k as i32, false)
} else if (lhs_m1 == m || k == 1) && (lhs_m2 == 1 || m == 1) {
(m as i32, true)
} else {
return Err(MetalKernelError::MatMulNonContiguous {
lhs_stride: lhs_stride.to_vec(),
rhs_stride: rhs_stride.to_vec(),
mnk: (m, n, k),
}
.bt())?;
};
MatMulNonContiguous 的 Display 就是 Invalid matmul arguments {lhs_stride:?} {rhs_stride:?} {mnk:?},三项依次是左边的步长、右边的步长、(m, n, k)。
softmax_last_dim 是融合算子 SoftmaxLastDim,CPU 实现取 layout.contiguous_offsets(),拿不到就 bail!("input has to be contiguous");Metal 实现检查 is_contiguous()。拼出来的 ops::softmax 没有这个要求(但慢,见常用层)。
KvCache::append 把新的 k、v 用 slice_set 写进预分配的缓冲区,slice_set 的第一项检查就是 if !self.is_contiguous() || !src.is_contiguous()(缓存本身见 KV 缓存与注意力)。
修法
在交给这几个算子之前 contiguous():
let q = qkv.narrow(1, 0, 16)?.contiguous()?;
let k = k.transpose(1, 2)?.contiguous()?;
contiguous() 对已经连续的张量只是 clone(不复制数据),所以放在可能不连续的地方没有额外代价;真正不连续时它复制一次,这正是后端需要的。
几个经验:
- 转置本身不用
contiguous():w.t()是标准的T形式,accelerate和 Metal 都接受(上面三个后端里都能直接a.matmul(&w.t()?))。要处理的是narrow出来的列、permute过的批维、转置后又narrow的组合。 - 喂
KvCache::append之前固定写.contiguous()?,不要依赖「decode 时碰巧连续」。 - 要在 Linux 和 Mac 上都能跑的代码,在 Mac 上开
accelerate和 Metal 各测一遍;只在gemm上测过的步长组合不说明问题。
怎么确认没中招
- 报错里有
non-contiguous、Invalid matmul arguments、has to be contiguous、only supports contiguous之一:打印输入的stride()和is_contiguous(),找出产生它的narrow/transpose/permute。 - KV 缓存的测试至少包含一次多 token 的 prefill。
- 批维不连续是另一种情况:Metal 不报错而是算错,见 Metal 上批量 matmul 结果不对。
小结
- matmul 对最后两维的步长:
gemm全收,accelerate和 Metal 只收行主序和转置两种;narrow出来的列两样都不是。 softmax_last_dim、KvCache::append(经slice_set)要求连续输入;单 token 时转置后的张量碰巧连续,问题只在 prefill 暴露。- 修法都是
contiguous();已经连续时它不复制。