矩阵视图与内存布局
gemmkit 的每一次调用都以视图的形式接收操作数:一个切片、一个形状,加上 2 个步长。MatRef<'a, T> 是不可变的输入视图,MatMut<'a, T> 是可变的输出视图。两者都不拥有自己的数据,都只是借用你已经持有的切片。
库里整套布局词汇都体现在这 2 个步长数字上:行主序、列主序、转置、子矩阵、广播。因此同一段缓冲可以被十几种方式读取,而从不发生拷贝。
两个步长
视图中元素 (i, j) 位于切片偏移 i*rs + j*cs 处,其中 rs 是行步长,cs 是列步长。步长以元素计,而非字节。 rs 为 4 表示下一行在切片中再往后 4 个元素。那一条偏移公式就是整个模型,其余一切都只是 rs 与 cs 的取值选择。
3 个构造器覆盖了常见情形,MatRef 和 MatMut 上都有:
#![allow(unused)]
fn main() {
use gemmkit::MatRef;
let data = [0.0_f32; 12];
let row_major = MatRef::from_row_major(&data, 3, 4); // rs = cols = 4, cs = 1
let col_major = MatRef::from_col_major(&data, 3, 4); // rs = 1, cs = rows = 3
let general = MatRef::new(&data, 3, 4, 4, 1); // explicit rs, cs (here == row-major)
}
from_row_major(data, rows, cols) 设定 rs = cols, cs = 1:行是连续的,即经典的 C 序。from_col_major(data, rows, cols) 设定 rs = 1, cs = rows:列是连续的,即 Fortran 序。
new(data, rows, cols, rs, cs) 原样接收步长。当两种规范布局都不匹配时就用它,比如一个子矩阵,或者前导维(leading dimension)不等于逻辑宽度的视图。MatRef 与 MatMut 还提供 .rows() 和 .cols()。
转置就是交换步长
由于 (i, j) 经 i*rs + j*cs 映射,交换两个步长(以及两个维度)的角色,就在原地转置了视图。设 a 以行主序持有一个 m x k 矩阵(rs = k, cs = 1)。它的转置是那个 k x m 矩阵,其 (i, j) 即原矩阵的 (j, i),位于偏移 j*k + i。这个偏移恰好就是同一切片上的 rs = 1, cs = k:
#![allow(unused)]
fn main() {
use gemmkit::MatRef;
// `a` is m x k row-major
let (m, k) = (2, 3);
let a = [1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let a_rowmajor = MatRef::from_row_major(&a, m, k); // m x k
let a_transposed = MatRef::from_col_major(&a, k, m); // k x m, same bytes, no copy
}
于是在行主序缓冲上的 from_col_major 就是转置,反过来也一样成立。把 rs/cs 对调的 new 对任意布局做同样的事。因此转置操作数在 API 层面毫无代价:内核就按你给出的步长去走。这正是无需实体化转置就能喂入 A^T * B 或 A * B^T 的方式。
子矩阵与带步长视图
子矩阵是这样一种视图:它的前导维(相邻行或列之间的距离)大于其逻辑范围。构造方法是对缓冲做切片,让子块的左上角元素落在切片起点,再交出父矩阵的步长。下面取一个 4 x 4 行主序矩阵中、从第 1 行第 1 列开始的左上 2 x 2 块:
#![allow(unused)]
fn main() {
use gemmkit::MatRef;
let parent = [0.0_f32; 16]; // 4x4 row-major, leading dimension 4
let block = MatRef::new(&parent[1 * 4 + 1..], 2, 2, 4, 1); // rs stays 4, cs stays 1
}
行步长仍是 4,也就是父矩阵的宽度。于是子块的相邻行会跳过你排除掉的那些列。切片从偏移 5 开始,也就是子块的 (0, 0)。安全 API 会核验这段尾切片足够长,能触及子块的远端角落。
同一机制也能表达广播输入:步长为 0 会让某一维重复同一个元素。一个 1 x n 的行向下广播 m 行,就是 MatRef::new(row, m, n, 0, 1)。每一逻辑行都读取同一段存储。gemmkit 允许只读输入 A 和 B 使用广播,但对输出永不允许,下一节会解释原因。
安全 API 接受什么、拒绝什么
安全入口(gemm、gemm_i8、gemm_cplx,以及各融合变体)只接受非负步长,其中包括广播输入用的 0。负步长超出了 &[T] 视图所能安全描述的范围。一个落在缓冲内部、而非指向元素 (0, 0) 的基指针同样如此。这些情形都属于Unchecked 层,也就是适配器用来表达任意布局的裸指针引擎。
在任何算术之前,安全入口对 (A, B, C) 三元组跑一段校验前奏,每一处失败都是一次 panic,抛出在首个 unsafe 操作之前:
- 形状相符。
A.cols == B.rows、A.rows == C.rows、B.cols == C.cols。不符会带着出错的那一对 panic,例如gemmkit: A.cols (3) != B.rows (4)。 - 视图在界内。 对每个视图,引擎会计算它将触及的最高切片偏移,并与切片长度比对。切片太小会 panic:
gemmkit: A view of 3x4 (strides 4,1) needs 12 elements but slice has 8。若某视图步长为负,或大到寻址溢出usize,则会 panic:... has negative strides or is too large to address; use gemm_unchecked。 C唯一寻址每个元素。 gemmkit 要写入输出,因此 2 个不同的(i, j)绝不能落在同一偏移。一个自别名的C,比如零行或零列步长,或者相互碰撞的步长,在并行模式下会变成数据竞争。这种情形从完全安全的代码即可触及,因此会 panic:gemmkit: C view aliases itself (...); C must address each (i,j) uniquely。正因如此,广播步长对A/B(只读)没问题,但对C禁止。C不与A或B重叠。 输出的字节范围必须与每个输入的不相交。gemmkit 按字节范围比较,而非按元素个数,这样异构整数 API(i8输入、i32输出)也能精确无误。重叠会 panic:gemmkit: C aliases A or B。在纯安全 Rust 里,借用检查器本就禁止重叠的&mut/&对,这道检查是同时覆盖裸指针降级路径的防御性兜底。
这些信息是稳定的,正确性测试套件会断言它们的措辞,因此你可以在测试中依赖它们。
零尺寸维度
带有零维度的视图是合法的,并且能顺利通过校验。0 x k、m x 0 或 m x n x (k = 0) 这些形状,gemmkit 都接受。任何切片,哪怕是空的,都能满足在界检查,因为这样的视图不寻址任何东西。
若 m == 0 或 n == 0,调用是一次空操作:没有输出要写。若仅 k == 0,收缩为空,调用退化为 C <- beta*C。这也就是 alpha == 0 所走的那条只缩放路径。那条退化规则见快速上手。
接下来读什么
- 元素类型:把同样的视图用在
f16/bf16、i8与复数数据上。 - Unchecked 层:负步长、内部基指针,以及裸指针引擎。
- 适配器(ndarray、nalgebra、faer)会从各库的原生矩阵类型替你构造这些视图。