布局与视图
主线位置:算子主线的第三、四步之间。
Storage::matmul(&rhs, (b, m, n, k), lhs_layout, rhs_layout)除了两块数据,还收到两个Layout;后端要靠它们知道数据从哪开始、每一维跨几个元素。本页讲Layout的三个字段、哪些操作只改它、后端拿到一个不连续的布局时怎么遍历;后端本身见 CPU 后端 和 Metal 后端。
flowchart TB
view["视图操作"] --> lay["新 Layout"]
lay --> q{"is_contiguous"}
q -->|是| fast["按切片整段处理"]
q -->|否| sb["strided_blocks"]
sb --> single["SingleBlock"]
sb --> uni["UniformBlocks"]
sb --> multi["MultipleBlocks"]
multi --> idx["StridedIndex 逐块"]
bin["二元运算"] --> nd["NdIter 合并维度"]
实验 crate:exp-candle/tensor-exp(features metal accelerate)和 exp-candle/tensor-noaccel(同样的代码,去掉 accelerate);M2 Max、macOS 27、rustc 1.99.0,计时都是 release。
三个字段
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Layout {
shape: Shape,
// The strides are given in number of elements and not in bytes.
stride: Vec<usize>,
start_offset: usize,
}
第 (i0, i1, ..) 个元素在 storage 里的位置是 start_offset + Σ ik * stride[k]。步长以元素计,不以字节计;步长是 usize,不能为负,所以没有「翻转」视图,flip 用 index_select 复制;广播出来的维度步长为 0。Shape 是 Vec<usize> 的包装。新张量都从 Layout::contiguous 开始,行主序(row major),最后一维步长为 1。
只改布局的操作
视图操作在 Tensor 一层的写法都一样(见 Tensor 的结构),区别全在 Layout 上对应的方法,每个都只做几行算术:
narrow:那一维的长度换成len,start_offset加上stride[dim] * start,步长不变。transpose:交换两维的长度和步长。permute是一般情况。broadcast_as:左边补出来的维度、以及原来长度为 1 被拉长的维度,步长设为 0;其余维度必须长度相等,否则报cannot broadcast。
layout 实验对一个 (2, 3, 4) 的张量打印几个视图的布局:
x (2,3,4): dims=[2, 3, 4] stride=[12, 4, 1] off=0 contiguous=true -> ...
x.narrow(2,1,2): dims=[2, 3, 2] stride=[12, 4, 1] off=1 contiguous=false -> ...
x.narrow(1,1,2): dims=[2, 2, 4] stride=[12, 4, 1] off=4 contiguous=false -> ...
x.transpose(1,2): dims=[2, 4, 3] stride=[12, 1, 4] off=0 contiguous=false -> ...
x.i(0).t(): dims=[4, 3] stride=[1, 4] off=0 contiguous=false -> ...
x.narrow(0,1,1).narrow(1,1,1): dims=[1, 1, 4] stride=[12, 4, 1] off=16 contiguous=true -> ...
bcast (1,4)->(3,4): dims=[3, 4] stride=[0, 1] off=0 contiguous=false -> ...
(每行 -> 之后的 StridedBlocks 下面再看。)reshape 不在这个清单里:只有输入连续时它才是视图,不连续时会复制,见 Tensor 的结构。
is_contiguous 与 contiguous()
Layout::is_contiguous 转给 Shape::is_contiguous:
pub fn is_contiguous(&self, stride: &[usize]) -> bool {
if self.0.len() != stride.len() {
return false;
}
let mut acc = 1;
for (&stride, &dim) in stride.iter().zip(self.0.iter()).rev() {
if dim > 1 && stride != acc {
return false;
}
acc *= dim;
}
true
}
从最后一维往前,要求每一维的步长等于它右边所有维度长度的乘积;长度为 1 的维度跳过,它的步长是多少都不影响寻址。它不看 start_offset,所以上面 x.narrow(0,1,1).narrow(1,1,1) 偏移 16 仍然算连续。contiguous_offsets 在连续时返回 (start_offset, start_offset + elem_count),后端拿它直接切出一段切片。
Tensor::contiguous 连续时原样返回 self.clone(),否则分配一块未初始化的新 storage,让源 storage 按布局拷进去:
pub fn contiguous(&self) -> Result<Tensor> {
if self.is_contiguous() {
Ok(self.clone())
} else {
let shape = self.shape();
let mut storage = unsafe { self.device().alloc_uninit(shape, self.dtype())? };
self.storage()
.copy_strided_src(&mut storage, 0, self.layout())?;
let op = BackpropOp::new1(self, Op::Copy);
Ok(from_storage(storage, shape.clone(), op, false))
}
}
force_contiguous 是不做判断、总是复制的版本。
let odd = Tensor::zeros((4, 1), DType::F32, &Device::Cpu)?.t()?;
println!("{:?} {:?} {}", odd.dims(), odd.stride(), odd.is_contiguous());
转置后形状是 (1, 4)。步长是多少?is_contiguous() 返回 true 还是 false?
后端怎么遍历
StridedIndex:逐个元素
StridedIndex 是一个迭代器,按行主序的逻辑顺序吐出每个元素在 storage 里的位置。它记着一个多维下标 multi_index,next 像里程表一样从最后一维加一、满了进位:
for ((multi_i, max_i), stride_i) in self
.multi_index
.iter_mut()
.zip(self.dims.iter())
.zip(self.stride.iter())
.rev()
{
let next_i = *multi_i + 1;
if next_i < *max_i {
*multi_i = next_i;
updated = true;
next_storage_index += stride_i;
break;
} else {
next_storage_index -= *multi_i * stride_i;
*multi_i = 0
}
}
位置是增量维护的:进一位加一个步长,归零时减回去,不做乘法。x.transpose(1,2) 的完整序列:
strided_index of x.transpose(1,2): [0, 4, 8, 1, 5, 9, 2, 6, 10, 3, 7, 11, 12, 16, 20, 13, 17, 21, 14, 18, 22, 15, 19, 23]
每吐一个位置都要走一遍这个循环,元素多时这是主要开销。
StridedBlocks:按连续块
多数非连续布局并不是每个元素都跳着走,最右边几维往往还是连续的。Layout::strided_blocks 从右往左数出连续的维度,把它们合成一个块,只对左边剩下的维度做下标迭代:
for (&stride, &dim) in self.stride().iter().zip(self.dims().iter()).rev() {
// Size-1 dimensions are trivially contiguous regardless of their stride.
if dim == 1 {
contiguous_dims += 1;
continue;
}
if stride != block_len {
break;
}
block_len *= dim;
contiguous_dims += 1;
}
let index_dims = self.dims().len() - contiguous_dims;
match index_dims {
0 => crate::StridedBlocks::SingleBlock {
start_offset: self.start_offset,
len: block_len,
},
1 => crate::StridedBlocks::UniformBlocks {
start_offset: self.start_offset,
block_len,
count: self.dims()[0],
src_stride: self.stride[0],
},
_ => {
let block_start_index = crate::StridedIndex::new(
&self.dims()[..index_dims],
&self.stride[..index_dims],
self.start_offset,
);
crate::StridedBlocks::MultipleBlocks {
block_start_index,
block_len,
}
}
}
StridedBlocks 的三个变体:整个张量就是一段(SingleBlock);只剩一维要迭代,块的起点是等差数列(UniformBlocks,起点 start_offset + i * src_stride);要迭代两维以上,块起点交给一个 StridedIndex(MultipleBlocks)。上面那组布局对应的结果:
x (2,3,4): ... -> SingleBlock { start_offset: 0, len: 24 }
x.narrow(2,1,2): ... -> MultipleBlocks { block_len: 2, first starts: [1, 5, 9, 13, 17, 21] }
x.narrow(1,1,2): ... -> UniformBlocks { start_offset: 4, block_len: 8, count: 2, src_stride: 12 }
x.transpose(1,2): ... -> MultipleBlocks { block_len: 1, first starts: [0, 4, 8, 1, 5, 9] }
x.i(0).t(): ... -> MultipleBlocks { block_len: 1, first starts: [0, 4, 8, 1, 5, 9] }
x.narrow(0,1,1).narrow(1,1,1): ... -> SingleBlock { start_offset: 16, len: 4 }
bcast (1,4)->(3,4): ... -> UniformBlocks { start_offset: 0, block_len: 4, count: 3, src_stride: 0 }
在中间一维上 narrow,后两维合成长 8 的块,只剩第 0 维要迭代,得到 UniformBlocks;在最后一维上 narrow,块长 2,要迭代前两维,是 MultipleBlocks。转置把步长 1 换到了中间,最后一维步长 4,一个连续维度都没有,块长为 1,退化成逐元素的 StridedIndex。
CPU 后端的一元运算(整数类型走 unary_map,浮点类型走带向量化版本的 unary_map_vec)按这三种情况各写一个循环:SingleBlock 整段处理,另两种逐块处理,块长为 1 时逐元素调用标量函数。复制(contiguous 背后的 copy_strided_src_)在 SingleBlock 上是一次 copy_from_slice,UniformBlocks 交给二维复制 copy2d_,MultipleBlocks 每块一次 copy_from_slice,块长为 1 时逐元素赋值。Metal 后端的 copy_strided_src 在 dtype 相同、最后一维步长为 1、布局是 UniformBlocks 时改用 copy2d 内核(步长恰好等于块长时是一次 blit),其余情况走通用的 strided copy 内核。
PR #3495(「Introduce new StridedBlocks type UniformBlocks」,首次进入 0.11.0)之前,strided_blocks 只返回 SingleBlock 和 MultipleBlocks 两种:只要有一维要迭代,就建一个 StridedIndex,每个块起点都走一遍上面那个里程表循环。这个 PR 加了 UniformBlocks,把「只剩一维」的情况(例如 narrow 一个中间维、把一行广播成多行)变成等差数列,CPU 的复制和一元运算、Tensor::cat、Metal 的 strided copy 都加了这一支;同时在扫描连续维度时跳过长度为 1 的维度,以前长度为 1、步长不规整的维度会让扫描提前停下。PR 一起加了 cat 和 contiguous 的基准(基准注释写的场景是「representative of transformer KV-cache growth」)。
二元运算:NdIter
二元运算要同时按两个布局走,而且两边可能一个转置、一个广播。binary_map 现在用 NdIter(PR #3579,0.11.0):把相邻、步长允许合并的维度合成一维,每次 next 给出两边各自的起点,内层按 inner_strides 走 inner_size 个元素;在 binary_map_vec 里,两边内层步长都是 1 时交给向量化的 f_vec,左边 1、右边 0(右边是广播的标量)时交给 f_scalar_vec,其余组合逐元素算。
NdIter 的维度数组是定长的:
/// Maximum tensor rank supported by [`NdIter`].
pub const MAX_DIMS: usize = 8;
秩超过 8 的张量,合并之后仍超过 8 维就会越界。check 把一个全 2 的 8 维、9 维张量按维度反序 permute(一维也合不了)再做加法;check2 对一个连续的 9 维张量做加法。debug 下:
rank 8 permuted add: Ok(Ok([2, 2, 2, 2, 2, 2, 2, 2]))
thread 'main' (3689907) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/nditer.rs:47:9:
rank 9 exceeds MAX_DIMS=8
check2 的连续 9 维张量在 debug 下是同一个位置、同一句 panic。release 下:
rank 8 permuted add: Ok(Ok([2, 2, 2, 2, 2, 2, 2, 2]))
thread 'main' (3690356) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/candle-core-0.11.0/src/nditer.rs:94:21:
index out of bounds: the len is 8 but the index is 8
而 check2 在 release 下打印 rank 9 contiguous add: Ok(9)。
debug 下 NdIter::new 开头的 debug_assert! 按原始秩判断,连续的 9 维张量也 panic;release 下 debug_assert! 不存在,连续张量合并成一维后能算,不能合并的在数组下标处 panic。两种情况都是 panic,不是 Err。
问: matmul 吃转置张量不用先 contiguous(),为什么 exp 吃转置张量会慢好几倍?两者不都是「按步长读」吗?
答: 读法不一样。matmul 把步长整个交给矩阵乘实现:gemm crate 接收任意的行步长和列步长,Accelerate 的 BLAS 和 Metal 的 MLX 内核接收「转置」标志,它们都按块读进缓存或线程组内存,访问顺序由矩阵乘自己安排。exp 走的是 unary_map_vec:转置后块长为 1,每个元素都要经过一次 StridedIndex::next,一次读一个元素、调一次标量 exp;块内的向量化版本(开 accelerate 时是 vForce 的 vs_exp)只在块长大于 1 时用得上。
matmul 接受哪些步长
三个 matmul 实现对布局的要求不同:
| 实现 | 最后两维 | 批维 |
|---|---|---|
CPU,gemm crate(不开 accelerate) | 任意行步长、列步长 | ab_skip 要求批维能用一个步长描述,否则报 MatMulUnexpectedStriding |
CPU,accelerate | 只有「行主序」和「转置」两种(BLAS 的 N / T) | 同上 |
Metal,call_mlx_gemm | 只有 N / T 两种,否则 MatMulNonContiguous | 只取倒数第三维的步长,见下 |
layout 实验里三种输入:转置的 w.t();从 (16, 32) 里 narrow 出前 16 列(行步长 32、列步长 1,既不是行主序也不是转置);一个 (2, 3, 4, 5) 的张量 permute((1, 0, 2, 3)) 之后做批量 matmul。
tensor-exp(开 accelerate):
== Cpu
a @ w.t(): ok [8, 32]
a @ w.narrow(1,0,16)... (16 cols of 32x32): Err: MatMulUnexpectedStriding { lhs_l: Layout { shape: [8, 16], stride: [16, 1], start_offset: 0 }, rhs_l: Layout { shape: [16, 16], stride: [32, 1], start_offset: 0 }, bmnk: (1, 8, 16, 16), msg: "non-contiguous rhs" }
permuted batch matmul: Err: MatMulUnexpectedStriding { lhs_l: Layout { shape: [3, 2, 4, 5], stride: [20, 60, 5, 1], start_offset: 0 }, rhs_l: Layout { shape: [3, 2, 5, 6], stride: [60, 30, 6, 1], start_offset: 0 }, bmnk: (6, 4, 6, 5), msg: "non-contiguous lhs" }
== Metal(MetalDevice(DeviceId(1)))
a @ w.t(): ok [8, 32]
a @ w.narrow(1,0,16)... (16 cols of 32x32): Err: Metal error Invalid matmul arguments [16, 1] [32, 1] (8, 16, 16)
permuted batch matmul: ok [3, 2, 4, 6]
tensor-noaccel(CPU 走 gemm)的 CPU 部分:
== Cpu
a @ w.t(): ok [8, 32]
a @ w.narrow(1,0,16)... (16 cols of 32x32): ok [8, 16]
permuted batch matmul: Err: MatMulUnexpectedStriding { ... msg: "non-contiguous lhs" }
同一段代码,换一个 feature 就从能算变成报错。Metal 上的批量 matmul 「ok」更值得怀疑:check 把同样的计算在 CPU 上先 contiguous() 再乘,和 Metal 的结果比(release):
metal permuted batch matmul, max abs diff vs cpu = 6.7067194
结果是错的。原因在 call_mlx_gemm 算批步长的地方:
let a_stride = if lhs_stride.len() > 2 {
lhs_stride[lhs_stride.len() - 3] as isize
} else {
(m * k) as isize
};
所有批维被压成一个批维 b,第 i 个矩阵的起点按 i * stride[-3] 算,前面几个批维自己的步长被丢掉了。CPU 的 ab_skip 检查的正是这一点,不满足就报错;Metal 没有检查。check2 换成更常见的写法,对 (2, 4, 3, 5) 在第 1 维 narrow(1, 0, 2)(步长 [60, 15, 5, 1]),逐个批比较(debug):
metal lhs stride [60, 15, 5, 1]
batch (0,0) max abs diff = 0.00000011920929
batch (0,1) max abs diff = 0.00000023841858
batch (1,0) max abs diff = 7.059169
batch (1,1) max abs diff = 5.521973
前两个矩阵对,后两个读到了错误的位置。在 Metal 上做四维以上的批量 matmul 前,如果输入可能来自 narrow 或 permute,要先 contiguous()。
实测:转置前后
1024×1024 f32,b.t() 不连续,btc = b.t().contiguous();每项先跑一次记作「首次」,再预热 3 次,取 20 次平均;Metal 每次计时前后 synchronize()。
| 操作(ms) | CPU accelerate | CPU gemm | Metal |
|---|---|---|---|
a.matmul(&btc) | 0.871 | 4.757 | 0.689 |
a.matmul(&b.t()) | 0.936 | 4.710 | 0.486 |
a.matmul(&b.t().contiguous()) | 3.208 | 7.069 | 0.696 |
&a + &btc | 0.073 | 0.118 | 0.071 |
&a + &b.t() | 1.078 | 0.973 | 0.484 |
btc.exp() | 0.458 | 1.596 | 0.027 |
b.t().exp() | 2.356 | 2.405 | 0.352 |
b.t().contiguous() | 2.176 | 2.172 | 0.334 |
b.narrow(1, 0, 512).contiguous() | 0.043 | 0.059 | 0.037 |
(Metal 一列取自 tensor-exp 那次运行。)几点:
- matmul 吃转置不吃亏:三个实现都直接按转置读,CPU 上和连续输入差别在 10% 以内,Metal 上转置输入反而更快。先
contiguous()再乘是净损失,CPU 上多出的 2.3 ms 和单独一次转置复制(2.2 ms)相当。 - 逐元素运算吃转置吃亏:加法慢 7 到 15 倍,
exp在 CPU 上慢 1.5 到 5 倍,在 Metal 上慢 13 倍。如果同一个转置张量要参加多次逐元素运算,先contiguous()一次更划算。 contiguous()的代价取决于块长:转置是块长 1 的MultipleBlocks,CPU 上 4 MB 要 2.2 ms;在最后一维narrow一半是UniformBlocks,块长 512,同样复制 2 MB 只要 0.04 ms。- Metal 的「首次」差别很大:同一个
&a + &btc,tensor-exp那次首次是 585.93 ms,tensor-noaccel那次是 3.02 ms。前者是这个进程里第一次用到二元运算的内核,要现场编译;为什么第二个进程快得多,见 Metal 后端。
动脑筋答案
(4,1).t(): dims=[1, 4] stride=[1, 1] contiguous=true
(4, 1) 的步长是 [1, 1](最后一维长度 1,步长按规则算出 1;第 0 维步长是右边长度之积 1)。转置交换两维,形状 (1, 4)、步长还是 [1, 1]。按行主序,(1, 4) 的连续步长应是 [4, 1],第 0 维的步长对不上;但 is_contiguous 跳过长度为 1 的维度,只检查第 1 维的步长 1,所以是连续的。这也是对的:长度为 1 的维度只有下标 0,步长乘上 0,从不影响寻址。
小结
Layout是形状、按元素计的usize步长、起始偏移;narrow改长度和偏移,transpose/permute换步长,broadcast_as把步长设为 0,都不碰数据。is_contiguous只看步长、忽略长度为 1 的维度和偏移;contiguous()不连续时分配新 storage 并按布局复制。- 后端按
strided_blocks的三种结果遍历:一整段、等差的块(0.11.0 新增的UniformBlocks)、StridedIndex驱动的块;二元运算用NdIter合并维度,秩超过 8 会 panic。 - matmul 的三个实现都直接接受转置,不用先
contiguous();其他步长 CPU 的gemm能吃、accelerate和 Metal 报错。Metal 的批量 matmul 只用倒数第三维的步长,批维来自narrow/permute时结果是错的。 - 逐元素运算对转置输入慢 1.5 到 15 倍;
contiguous()的成本由块长决定。