形状与广播
主线位置:张量主线的中间一步「计算」:两个形状不同的张量怎么逐元素运算、怎么做矩阵乘,以及改形状、换维度这些只动布局的操作。
版本:PyTorch 2.15.0(CPU)、candle 0.11.0(CPU,accelerate;
expand之后的 matmul 另测了不开 accelerate 的 CPU 和 Metal)
flowchart TB
op["a 加 row"] --> same{"形状完全相同"}
same -->|否| err["shape mismatch"]
same -->|是| run["逐元素计算"]
bc["broadcast_add"] --> bs["算广播形状"]
bs --> view["broadcast_as 视图"]
view --> run
mm["matmul"] --> rank{"秩相同且至少 2"}
rank -->|否| err
rank -->|是| mmrun["按批相乘"]
bmm["broadcast_matmul"] --> copy["广播一边并复制"]
copy --> mmrun
本页的对照:PyTorch 侧是实验代码里的 shape.py 和 linear_ref.py(torch.set_num_threads(1)),candle 侧是实验 crate tensora-t 的 shape、linear、expand_mm 和几个只用来看编译错误的 ce_*(features metal accelerate,release),以及去掉 accelerate 的同样代码 tensora-noacc。平台 M2 Max、macOS 27、rustc 1.99.0。编译错误里 cargo 缓存的路径前缀用 ... 略去。布局、步长、视图在 candle 内部怎么实现,见源码卷的布局与视图。下面的 a 是 arange(6) 排成的 [2, 3] 浮点矩阵,row 是 [10, 20, 30],col 是 [[100], [200]]。
+ 不广播
a + row; a + col; col + row; a + torch.ones(2, 2)
&a + &row;
a.broadcast_add(&row); a.broadcast_add(&col); col.broadcast_add(&row);
a.broadcast_add(&Tensor::ones((2, 2), DType::F32, &dev)?);
a + row [2, 3] [10.0, 21.0, 32.0, 13.0, 24.0, 35.0]
a + col [2, 3] [100.0, 101.0, 102.0, 203.0, 204.0, 205.0]
col + row [2, 3] [110.0, 120.0, 130.0, 210.0, 220.0, 230.0]
a + ones(2, 2) RuntimeError: The size of tensor a (3) must match the size of tensor b (2) at non-singleton dimension 1
a + row Err: shape mismatch in add, lhs: [2, 3], rhs: [3]
a.broadcast_add(&row) [2, 3] [10.0, 21.0, 32.0, 13.0, 24.0, 35.0]
a.broadcast_add(&col) [2, 3] [100.0, 101.0, 102.0, 203.0, 204.0, 205.0]
col.broadcast_add(&row) [2, 3] [110.0, 120.0, 130.0, 210.0, 220.0, 230.0]
a.broadcast_add(ones(2, 2)) Err: shape mismatch in broadcast_add, lhs: [2, 3], rhs: [2, 2]
广播规则两边相同(从最后一维对齐,每一维相等或有一边是 1,秩不够的在前面补 1),candle 只是不在运算符里做。+ - * / 和 add、mul 这些方法要求两边形状完全相同,[2, 3] 和 [2, 1] 也不行;要广播就换成 broadcast_add、broadcast_sub、broadcast_mul、broadcast_div,以及 broadcast_maximum、broadcast_eq 这类。它们由 broadcast_binary_op! 生成:先用 Shape::broadcast_shape_binary_op 算出目标形状,再把需要扩展的一边 broadcast_as 成视图,最后调普通的 add。错误信息里的 op 名是一个线索:add 说明漏了 broadcast_,broadcast_add 才是形状真的不兼容。完整的报错分析见源码卷的坑 shape mismatch in add。
burn 的 + 会广播,但要求两边秩相同(见 burn 卷的归约与广播);candle 正好反过来,秩不同可以(broadcast_add 自己补 1),只是要换方法名。
和标量运算不受限制:&a + 1.0 走 affine,见创建与 dtype。0 维张量不是标量,a + Tensor::new(5f32, ..) 同样要 broadcast_add。
expand 与 broadcast_as
row.expand(2, 3); row.expand(-1, 3); col.expand(-1, 3); row.expand(3, 2)
row.expand(2, 3).stride()
row.broadcast_as((2, 3)); row.expand((2, 3)); col.broadcast_as((2, 3));
row.broadcast_as((3, 2)); row.broadcast_left(2);
row.broadcast_as((2, 3))?.stride();
row.expand(2, 3) [2, 3] [10.0, 20.0, 30.0, 10.0, 20.0, 30.0]
row.expand(-1, 3) RuntimeError: The expanded size of the tensor (-1) isn't allowed in a leading, non-existing dimension 0
col.expand(-1, 3) [2, 3] [100.0, 100.0, 100.0, 200.0, 200.0, 200.0]
row.expand(3, 2) RuntimeError: The expanded size of the tensor (2) must match the existing size (3) at non-singleton dimension 1. Target sizes: [3, 2]. Tensor sizes: [3]
row.expand(2, 3) layout dims=[2, 3] stride=[0, 1] contiguous=False
row.broadcast_as((2, 3)) [2, 3] [10.0, 20.0, 30.0, 10.0, 20.0, 30.0]
row.expand((2, 3)) [2, 3] [10.0, 20.0, 30.0, 10.0, 20.0, 30.0]
col.broadcast_as((2, 3)) [2, 3] [100.0, 100.0, 100.0, 200.0, 200.0, 200.0]
row.broadcast_as((3, 2)) Err: cannot broadcast [3] to [3, 2]
row.broadcast_left(2) [2, 3] [10.0, 20.0, 30.0, 10.0, 20.0, 30.0]
row.broadcast_as((2, 3)) layout dims=[2, 3] stride=[0, 1] contiguous=false
expand 就是 broadcast_as 的别名,结果同样是步长为 0 的视图,不复制。区别在形状参数:candle 的形状是 usize,没有 PyTorch 的「-1 表示这一维不变」,目标形状要写全,col.expand(-1, 3) 写成 col.broadcast_as((col.dim(0)?, 3))。只在左边加维度时用 broadcast_left,参数是要加在前面的那几维。
matmul:不接一维,不广播批维
v = torch.ones(3)
w = torch.arange(12, dtype=torch.float32).reshape(3, 4)
a @ w; a @ v; v @ w
h = torch.ones(4, 2, 3)
h @ w; h @ torch.ones(1, 3, 4)
let v = Tensor::new(&[1f32, 1., 1.], &dev)?;
let w = Tensor::arange(0f32, 12., &dev)?.reshape((3, 4))?;
a.matmul(&w); a.matmul(&v); a.matmul(&v.unsqueeze(1)?)?.squeeze(1); v.matmul(&w);
let h = Tensor::ones((4, 2, 3), DType::F32, &dev)?;
h.matmul(&w); h.broadcast_matmul(&w);
let w3 = Tensor::ones((1, 3, 4), DType::F32, &dev)?;
h.matmul(&w3); h.broadcast_matmul(&w3);
a @ w [2, 4] [20.0, 23.0, 26.0, 29.0, 56.0, 68.0, 80.0, 92.0]
a @ v [2] [3.0, 12.0]
v @ w [4] [12.0, 15.0, 18.0, 21.0]
h(4,2,3) @ w(3,4) [4, 2, 4]
h @ w(1,3,4) [4, 2, 4]
a.matmul(&w) [2, 4] [20.0, 23.0, 26.0, 29.0, 56.0, 68.0, 80.0, 92.0]
a.matmul(&v) Err: shape mismatch in matmul, lhs: [2, 3], rhs: [3]
a.matmul(&v.unsqueeze(1)) [2, 1] [3.0, 12.0]
..squeeze(1) [2] [3.0, 12.0]
v.matmul(&w) Err: shape mismatch in matmul, lhs: [3], rhs: [3, 4]
h(4,2,3).matmul(&w(3,4)) Err: shape mismatch in matmul, lhs: [4, 2, 3], rhs: [3, 4]
h.broadcast_matmul(&w) [4, 2, 4]
h.matmul(&w(1,3,4)) Err: shape mismatch in matmul, lhs: [4, 2, 3], rhs: [1, 3, 4]
h.broadcast_matmul(&w(1,3,4)) [4, 2, 4]
PyTorch 的 @ 有三条额外规则:一维的一边先补成矩阵、乘完再去掉;秩不同时在前面补 1;批维按广播规则对齐。Tensor::matmul 一条都没有:两边秩必须相同且至少为 2,批维只比乘积((2, 3) 对 (3, 2) 不报错,见源码卷的坑 shape mismatch in add)。对应关系:
| PyTorch | candle |
|---|---|
a @ v(v 一维) | a.matmul(&v.unsqueeze(1)?)?.squeeze(1) |
v @ w | v.unsqueeze(0)?.matmul(&w)?.squeeze(0) |
h @ w([b, s, i] 乘 [i, o]) | h.broadcast_matmul(&w),或 h.reshape(((), i))?.matmul(&w)?.reshape((b, s, ())) |
批维广播 [4, 2, 3] @ [1, 3, 4] | broadcast_matmul |
broadcast_matmul 把被广播的一边 broadcast_as 之后再 contiguous(),真的复制出 b 份(源码里留着「TODO: Avoid concretising the broadcasted matrixes via contiguous.」)。第二种写法把批维并进行,一次二维 matmul,不复制,candle_nn::Linear 对连续输入就是这样做的。
expand 之后直接 matmul
从 PyTorch 搬过来,一个很自然的省事写法是先 expand 出批维再乘,也就是 lhs.expand(b, m, k) @ rhs。candle 里写成 broadcast_as 加 matmul,形状检查能过(秩相同、批维乘积相同):
let (b, m, k, n) = (4, 2, 3, 5);
let lhs = Tensor::arange(0f32, (m * k) as f32, &dev)?.reshape((1, m, k))?;
let rhs = Tensor::arange(0f32, (b * k * n) as f32, &dev)?.reshape((b, k, n))?.affine(0.01, 0.)?;
let view = lhs.broadcast_as((b, m, k))?;
let fast = view.matmul(&rhs)?;
let copied = view.contiguous()?.matmul(&rhs)?;
let bm = lhs.broadcast_matmul(&rhs)?;
开 accelerate 的 CPU(tensora-t)和 Metal 上,三种写法结果相同;不开 accelerate 的 CPU(tensora-noacc,走 gemm crate,Linux 上的默认情况):
Cpu lhs view stride [0, 3, 1]
expand+matmul vs contiguous: max|diff| = 6.5799994
broadcast_matmul vs contiguous: max|diff| = 0
expand+matmul batch 3 = [[0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0]]
contiguous batch 3 = [[1.6, 1.63, 1.66, 1.6899999, 1.7199999], [6.1, 6.22, 6.3399997, 6.46, 6.5799994]]
PyTorch 对同一个 expand 视图做 @,和先 contiguous() 的结果差 0.0。candle 这边没有报错,最后一批全是 0。原因在 gemm 那一支的 MatMul 里合并批维的几行:
let (b, m, n, k) = if b_skip == 0 && a_skip == m * k {
// a_skip and c_skip should be updated but step is always 0 so
// it wouldn't matter.
(1, b * m, n, k)
} else if a_skip == 0 && b_skip == n * k {
(1, m, b * n, k)
} else {
(b, m, n, k)
};
第一支(右边的批步长为 0)把批并进行,这是对的:左边各批的行在内存里本来就首尾相接。第二支(左边的批步长为 0,正是 expand 出来的情况)把批并进列,按一个 m × (b·n) 的矩阵算一次;但右边是 [b, k, n] 按批排的,各批的列在内存里并不相邻,输出的行步长也还是 n,算出来的数互相覆盖,没写到的位置保持初始的 0。这是 0.11.0 的上游 bug。accelerate 和 Metal 的实现没有这条合并,所以不受影响。在 gemm 后端上要广播左边的批维,用 broadcast_matmul(它先复制)或者自己 contiguous()。
reshape:() 就是 -1
x = torch.arange(24, dtype=torch.float32)
x.reshape(2, -1, 4); x.reshape(-1, 6); x.reshape(2, -1, -1)
x.reshape(5, -1); x.reshape(5, 5); x.reshape(2, 2, 2, 3, 1, -1)
e = torch.zeros(0)
e.reshape(0, -1); e.reshape(-1, 3)
let x = Tensor::arange(0f32, 24., &dev)?;
x.reshape((2, 3, 4)); x.reshape((2, (), 4)); x.reshape(((), 6)); x.reshape(((),));
x.reshape((5, ())); x.reshape((5, 5)); x.reshape(vec![2usize, 12]);
let empty = Tensor::zeros(0, DType::F32, &dev)?;
empty.reshape((0, ())); empty.reshape(((), 3));
x.reshape(2, -1, 4) [2, 3, 4]
x.reshape(-1, 6) [4, 6]
x.reshape(2, -1, -1) RuntimeError: only one dimension can be inferred
x.reshape(5, -1) RuntimeError: shape '[5, -1]' is invalid for input of size 24
x.reshape(5, 5) RuntimeError: shape '[5, 5]' is invalid for input of size 24
x.reshape(2,2,2,3,1,-1) [2, 2, 2, 3, 1, 1]
empty.reshape(0, -1) RuntimeError: cannot reshape tensor of 0 elements into shape [0, -1] because the unspecified dimension size -1 can be any value and is ambiguous
empty.reshape(-1, 3) [0, 3]
x.reshape((2, 3, 4)) [2, 3, 4]
x.reshape((2, (), 4)) [2, 3, 4]
x.reshape(((), 6)) [4, 6]
x.reshape(((),)) [24]
x.reshape((5, ())) Err: cannot reshape tensor with 24 elements to (5, ())
x.reshape((5, 5)) Err: shape mismatch in reshape, lhs: [24], rhs: [5, 5]
x.reshape(vec![2, 12]) [2, 12]
empty.reshape((0, ())) Err: cannot reshape tensor of 0 elements to (0, ())
empty.reshape(((), 3)) [0, 3]
candle 的「自动推断这一维」写成元组里的一个 ()(单元类型)。reshape 的参数类型是 ShapeWithOneHole:普通形状((2, 3, 4)、vec![2, 12],凡是 Into<Shape> 的)原样通过;对 ((),)、(usize, ())、((), usize, usize) 这些元组,shape.rs 一个个手写了实现,「洞」的大小由 hole_size 算出:总元素数除以其余各维的乘积,除不尽报 cannot reshape tensor with ...,其余维乘积为 0 报 cannot reshape tensor of 0 elements ...(和 PyTorch 拒绝 reshape(0, -1) 是同一个理由)。
和 PyTorch 的 -1 有三点不同,都在编译期:
只能有一个洞,因为两个洞的元组没有实现。PyTorch 是运行期报
only one dimension can be inferred,candle 是编译错误:error[E0277]: the trait bound `Shape: From<({integer}, (), ())>` is not satisfied --> src/bin/ce_twohole.rs:5:21 | 5 | let b = a.reshape((2, (), ()))?; | ------- ^^^^^^^^^^^ the trait `From<({integer}, (), ())>` is not implemented for `Shape` | | | required by a bound introduced by this call ... = note: required for `({integer}, (), ())` to implement `Into<Shape>` = note: required for `({integer}, (), ())` to implement `ShapeWithOneHole` note: required by a bound in `candle_core::Tensor::reshape` --> .../candle-core-0.11.0/src/tensor.rs:2523:23编译器没找到带洞的实现,退回去试「普通形状」那条
Into<Shape>,报的是Shape: From<..>不满足,不提「洞」。带洞的元组最多五维。
(2, 2, 2, 2, 2, ())报的是同一种错误(Shape: From<({integer}, {integer}, {integer}, {integer}, {integer}, ())>);六维以上要把形状算好,写成vec![..]。写
-1本身不行。形状的元素是usize,编译器报usize: std::ops::Neg不满足,并建议写成-1isize,照做也不会通过,因为形状不接受isize:error[E0277]: the trait bound `usize: std::ops::Neg` is not satisfied --> src/bin/ce_minus1.rs:5:25 | 5 | let b = a.reshape((3, -1))?; | ^^ the trait `std::ops::Neg` is not implemented for `usize` | help: consider specifying an integer type that can be negative
D::Minus1 也不能用在这里,它只用于「第几维」这种参数(sum(D::Minus1)、transpose(D::Minus2, D::Minus1))。
#770「Shape with holes」之前,reshape 的签名是 pub fn reshape<S: Into<Shape>>(&self, shape: S),文档注释上面挂着一行 // TODO: Do we want to allow target shape using -1 on some dimensions?。这个 PR 删掉了这行 TODO,把参数换成 ShapeWithOneHole,在 shape.rs 里加了 168 行:这个 trait 和一到五维各种洞的位置的实现,每个实现自己检查整除(报 tensor number of elements {el_count} is not divisible by {d1};共用的 hole_size 和现在的报错文字是之后 #1096「Improve the reshape error messages.」改的);文档注释加了一句「the behavior is the same as when using -1 in PyTorch」和例子 a.reshape((2, (), 1))。提交日期是 2023-09-08,早于本地仓库最早的 tag,git tag --contains 只能查到 0.5.1。0.9.0 的 #2905 把同一个 trait 用到了 from_vec 上,顺带丢了元素个数检查,见源码卷错误的冷知识。
reshape 会悄悄复制,没有 view
m = x.reshape(4, 6)
m.t(); m.t().reshape(24); m.t().reshape(24)[:6]; m.t().view(24)
let m = x.reshape((4, 6))?;
let mt = m.t()?;
let r = mt.reshape(24)?;
m.reshape((6, 4))?;
m.t() dims=[6, 4] stride=[1, 6] contiguous=False
m.t().reshape(24) dims=[24] stride=[1] contiguous=True
m.t().reshape(24) first 6 [0.0, 6.0, 12.0, 18.0, 1.0, 7.0]
m.t().view(24) RuntimeError: view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces). Use .reshape(...) instead.
m.t() dims=[6, 4] stride=[1, 6] contiguous=false
m.t().reshape(24) dims=[24] stride=[1] contiguous=true
m.t().reshape(24) first 6 [0.0, 6.0, 12.0, 18.0, 1.0, 7.0]
m.reshape((6, 4)) (view) dims=[6, 4] stride=[4, 1] contiguous=true
两边的 reshape 语义相同:按行主序的逻辑顺序重排,输入连续时是视图,不连续时复制一份。candle 没有 view,也就没有「不能视图就报错」这一档;想确认没有复制,看输入的 is_contiguous()。flatten_all() 对应 flatten(),flatten(1, 2) 对应 flatten(1, 2)。
transpose、permute、unsqueeze
x3 = x.reshape(2, 3, 4)
row.t(); x3.transpose(-1, -2); x3.permute(2, 0)
x3.squeeze(0); x3[None]; x3.transpose(0, 3)
let x3 = x.reshape((2, 3, 4))?;
row.t(); x3.transpose(D::Minus1, D::Minus2); x3.permute((2, 0, 1)); x3.permute((2, 0));
x3.unsqueeze(0); x3.unsqueeze(D::Minus1); x3.squeeze(0); x3.transpose(0, 3);
row.t() [3] [10.0, 20.0, 30.0]
x3.transpose(-1, -2) [2, 4, 3]
x3.permute(2, 0) RuntimeError: permute(sparse_coo): number of dimensions in the tensor input does not match the length of the desired ordering of dimensions i.e. input.dim() = 3 is not equal to len(dims) = 2
x3.squeeze(0) [2, 3, 4]
x3[None] [1, 2, 3, 4]
x3.transpose(0, 3) IndexError: Dimension out of range (expected to be in range of [-3, 2], but got 3)
row.t() Err: unexpected rank, expected: 2, got: 1 ([3])
x3.transpose(D::Minus1, D::Minus2) [2, 4, 3]
x3.permute((2, 0, 1)) [4, 2, 3]
x3.permute((2, 0)) Err: dimension mismatch in permute, tensor [2, 3, 4], dims: [2, 0]
x3.unsqueeze(0) [1, 2, 3, 4]
x3.unsqueeze(D::Minus1) [2, 3, 4, 1]
x3.squeeze(0) [2, 3, 4]
x3.transpose(0, 3) Err: transpose: dimension index 3 out of range for shape [2, 3, 4]
transpose、permute、squeeze、unsqueeze 两边是同样的视图操作,越界、不是排列时两边都报错。写法上有三点要改:
- 负的维度下标写成
D::Minus1、D::Minus2、D::Minus(n)(D),因为维度参数也是usize。 x[None]、x[:, None]没有对应的索引写法,用unsqueeze。squeeze(dim)和 PyTorch 一样在那一维长度不为 1 时原样返回;没有不带参数、去掉所有长度为 1 的维的版本。- 一维向量的
t():PyTorch 原样返回,candle 报unexpected rank。t()在两边的定义并不相同,先看下面的动脑筋。
x3 = torch.arange(24.0).reshape(2, 3, 4)
x3.t()
let x3 = Tensor::arange(0f32, 24., &dev)?.reshape((2, 3, 4))?;
x3.t()?
转置之后的张量不连续,这一点两边相同:
x3.transpose(1, 2) dims=[2, 4, 3] stride=[12, 1, 4] contiguous=false
x3.transpose(1, 2).contiguous() dims=[2, 4, 3] stride=[12, 3, 1] contiguous=true
不同的是后果。PyTorch 的算子几乎都接受任意步长,contiguous() 主要是为了 view 和性能;candle 有几处会直接报错:开 accelerate 的 CPU 和 Metal 上的 matmul 只接受行主序和转置两种步长,softmax_last_dim、KvCache::append 要求连续输入。哪些步长会出错、出什么错,见源码卷的坑 Invalid matmul arguments;单纯的 w.t() 交给 matmul 不用先 contiguous()。
对得上的数字
最后用一个带广播的计算把两边对一遍:y = x @ w.T + b,x 是 [4, 7, k],w 是 [32, k],b 是 [32],元素是 sin(n)。输入只在 PyTorch 侧生成,写进 JSON,candle 侧读同一份:
y = x @ w.T + b
// form "bcast"
let y = x.broadcast_matmul(&w.t()?)?.broadcast_add(&b)?;
// form "fold": fold batch and seq into rows
let y = x.reshape(((), i_))?.matmul(&w.t()?)?.reshape((b_, s_, ()))?.broadcast_add(&b)?;
k = 1024,最大 |y| 是 512.6。两种 candle 写法的结果相同;开不开 accelerate 不同:
| candle CPU | 对 PyTorch 的最大绝对误差 |
|---|---|
accelerate | 0 |
gemm(不开 accelerate) | 7.02e-4 |
gemm 那一行,compare.py 容差 2e-3:
y n=896 max|diff|=7.02e-04
worst=7.02e-04 tol=2e-03 OK
开 accelerate 时差是 0,因为两边调的是同一个 BLAS:这份 PyTorch 的 torch.__config__.show() 写着 BLAS_INFO=accelerate,candle 的 accelerate feature 调 Accelerate 的 sgemm,同一个库、同样的输入,求和顺序相同。gemm crate 用自己的分块顺序,结果在 f32 的舍入范围内:两边离 f64 参考分别是 5.92e-4(PyTorch)和 3.17e-4(candle gemm)。每个输出是 1024 个乘积之和,最大的 Σ|x·w| 是 511.8,它乘上 f32 的 ε 再乘 √k 约 1.95e-3,容差 2e-3 由此而来。反向验证:把 PyTorch 结果的一个元素加 0.01,compare.py 报 max|diff|=9.94e-03、MISMATCH,退出码 1。k = 64 时两种 CPU 后端都和 PyTorch 差 0。
动脑筋答案
x3.t() RuntimeError: t() expects a tensor with <= 2 dimensions, but self is 3D
x3.t() [2, 4, 3]
PyTorch 报错,candle 成功,得到 [2, 4, 3],也就是 PyTorch 的 x3.mT 或 x3.transpose(-2, -1)。candle 的 t() 是 self.transpose(rank - 2, rank - 1),只在秩小于 2 时报错。反过来,PyTorch 里对一维向量 t() 原样返回,candle 报 unexpected rank, expected: 2, got: 1。搬代码时:二维权重的 w.t() 两边相同;注意力里的 k.transpose(-2, -1) 在 candle 里可以直接写成 k.t(),PyTorch 的 .mT 也对应它。
小结
+ - * /要求形状完全相同;广播用broadcast_add等,规则和 PyTorch 一样,秩不同也行。expand是broadcast_as的别名,形状要写全,没有-1;broadcast_left只在前面加维。matmul两边秩相同且至少二维,不接一维、不广播批维;用unsqueeze/squeeze、broadcast_matmul(复制)或折叠批维(不复制)。- 0.11.0 的
gemm后端上,左边是expand出来的批维时matmul静默算错,最后几批是 0;accelerate和 Metal 不受影响。 reshape的-1写成():只能一个、最多五维,违反时是编译错误;不连续输入会被复制,没有view。t()转置最后两维(PyTorch 的mT);负的维度下标写D::Minus1。x @ w.T + b(k = 1024):开accelerate时和 PyTorch 差 0(同一个 BLAS),gemm差 7.02e-4,最大 |y| 512.6。