Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

设计目标与总体图景

gemmkit 是一个纯 Rust 的 GEMM 引擎。它在带显式步长的 &[T] 切片上计算 C <- alpha*A*B + beta*C,也可以直接在带 isize 步长的裸指针上计算。运行时,它会选出 这台机器支持的最优指令集:x86-64 上是 AVX-512 或 FMA/AVX2,并配有专门的 VNNI 与 BF16 点积内核;aarch64 上是 NEON;wasm32 上是 simd128。其余平台一律有可移植的标量路径兜底。 整个工作空间使用 edition 2024、rust-version 1.89,许可证是 MIT OR Apache-2.0。

API 表面由三类调用者共同决定。应用代码走安全的切片入口,例如 gemmgemm_fusedgemm_i8,这些入口会在任何 unsafe 代码运行之前完成全部校验。线性代数库走 *_unchecked 层,这一类包括随仓库发布的 ndarraynalgebrafaer 适配器,也包括任何按同样方式 构建的库。*_unchecked 层信任调用方自己的不变量,因而能接受安全层无法表达的内存布局。 受限的部署场景则得到一个可以 #![no_std] 构建、零强制依赖的核心,一路可以支持到带编译期 SIMD 的 wasm32。本章其余内容都可以从四条设计信条推导出来。 ARCHITECTURE.md 在 “Goals and constraints” 一节里紧凑地列出了它们,本章则展开每一条背后的理由。

边界上的安全性

各个受检入口都会在触碰任何 unsafe 代码之前运行 validate_gemm_viewsgemmkit/src/api.rs)。它的 panic 清单刻意做到详尽:

  • 形状不匹配:gemmkit 会检查 A.cols != B.rowsA.rows != C.rowsB.cols != C.cols。每条 panic 消息都会写明两个不一致的数字。
  • 视图寻址越出切片:对 A、B、C 各自,gemmkit 都会算出步长能到达的最高偏移 (extent),并与切片长度比对。一个需要的元素数超过切片实际长度的视图,会带着确切的 差额 panic。
  • 负步长:安全层会直接拒绝,消息会指向 gemm_unchecked。带负步长的 &[T] 视图必然 要寻址到切片起点之前,安全层基于切片长度的推导没法为这样的地址担保。
  • 自混叠的输出C 上的某个步长可能把两个不同的 (i, j) 映射到同一个偏移,零步长 就是最常见的情形。这在 AB 上没问题,因为广播输入只会被读取。但在 C 上会 panic,因为并行驱动假设各个输出 tile 互不重叠。通过这样的视图写入会造成一个纯安全代码 就能触发的数据竞争。
  • CAB 重叠:gemmkit 按字节区间来检查,因此即便 C(i32)与 A/B (i8)的元素大小不同,检查依然精确。融合入口还会额外检查偏置长度(PerRow 为 m、 PerCol 为 n)以及偏置与 C 是否不相交。
  • 问题大到无法计量:广播步长允许逻辑维度逼近 isize::MAX,这会让内部打包缓冲区的 尺寸计算溢出 usize。所有这类乘积都会在元素数到字节数的必经关口(Workspace::regions) 以“失败即封闭“的方式 panic,而不是回绕后分配不足。

panic 的措辞本身也是一份被测试的契约。正确性测试套件会断言这些字符串的精确内容。因此 修改一条错误消息是一个刻意且可见的动作。

*_unchecked 层之所以存在,是因为这套校验只在一个边界上才有意义。适配器直接从 ndarraynalgebrafaer 的类型里取出指针和步长,这些类型自身的不变量已经保证了 有效性,重复检查纯粹是浪费。而且基于切片的检查根本无法表达适配器需要的东西:比如一个 反转的 ndarray 视图会有负步长,基指针也会落在分配区的中间,这两者对裸引擎来说都是合法 且健全的。于是安全成本恰好只需付一次,要么由 gemmkit 的校验器来付,要么由调用方的类型 系统来付,绝不会重复支付。

unchecked 入口都是带有明确文档契约的普通 unsafe fn。使用方式见 Unchecked 层

可复现、而非逐位相同的并行结果

gemmkit 承诺可复现的并行结果:在固定的输入、固定的环境和固定的配置下,输出不依赖工作 线程数。这个承诺由三个机制支撑。

第一个机制是分块尺寸。KCNC 只由缓存模型计算得出,从不依赖线程数。MC 至多只会 按 MR 对齐地重新分组。因此每次运行对每个输出元素的归约顺序都完全相同。

第二个机制是归约顺序。每个输出元素都由同一个工作线程沿完整深度从头归约到尾,引擎中不 存在被拆分到多个线程上的归约。

第三个机制是按需调度。打包出来的字节不依赖是谁做的打包,所以任何线程都可以接手任何 tile。哪个线程计算哪个 tile 因运行而异,结果却始终不变。

同样重要的是 gemmkit 没有承诺什么。串行与并行逐位相同并不在契约之内。它今天之所以 在驱动路径上成立,是因为串行和并行跑的是同一套内核、同一套分块,但没有任何东西把它钉死。 跨配置的逐位一致同样明确不在承诺内:改一个调优旋钮,分块就会跟着改变,浮点求和顺序也就 可能合法地随之改变。

同一类型不同内核之间的逐位一致也不在承诺内。bf16 的 vdpbf16ps 点积内核相对于拓宽再 FMA 的路径重塑了累加的舍入方式,gemmkit 对它只要求落在容差之内,而不要求精确相等。

为什么把线画在这里?若永久承诺串行与并行逐位相同,就会挡住真正有价值的工程手段:既挡住 把相邻深度对融合进一条指令的点积内核,也挡住会考虑并行度的分块选择。这样的承诺换不来任何 用户能跨机器、跨版本依赖的东西。

“感知并行度的分块“已经不再是设想。驱动器已经有一个作业深度下限,会随工作线程数收缩 MC,好让并行作业列表足够深。它之所以仍然逐位可复现,恰恰是因为这条较弱的契约给它留了 余地:MC 依然是 MR 的整数倍,所以微 tile 的集合以及每个元素由 KC 决定的累加顺序都 不受影响。

固定配置下的可复现性,才是测试能够断言、部署能够依赖、引擎又能在演进中一直守住的性质。 在能以低成本给出更强保证的地方,gemmkit 就会给出更强保证:gemv 按输出行划分工作,跨任意 线程数都逐位一致;i8 整数路径是精确算术,所以它的 VNNI 点积内核与拓宽内核逐位一致。

变化点上无宏、无 transmute

引擎沿三条轴变化:指令集、元素类型、运算家族。每条轴都是一个普通的 trait。SimdSimdOps 覆盖 ISA。Scalar 覆盖元素类型。KernelFamily 覆盖运算家族。

分发槽是缓存在 OnceLock 里的带类型函数指针。微 tile 的几何形状是一对在分发点选定的 const 泛型参数。一个“内核变体“实际长什么样,看 gemmkit/src/dispatch/float.rs 就知道:

#![allow(unused)]
fn main() {
unsafe fn gemm_f32_fma(t: Task<f32>, par: Parallelism, ws: &mut Workspace) {
    // MR = 2*8 = 16, NR = 6 -> 12 acc + 2 lhs + 1 rhs = 15 of 16 YMM
    unsafe { run_typed::<f32, Fma, 2, 6>(Fma, t, par, ws) }
}

unsafe fn gemm_f32_avx512f(t: Task<f32>, par: Parallelism, ws: &mut Workspace) {
    // MR = 2*16 = 32, NR = 12 -> 24 acc + 2 lhs + 1 rhs = 27 of 32 ZMM
    unsafe { run_typed::<f32, Avx512F, 2, 12>(Avx512F, t, par, ws) }
}
}

这就是每个(类型,ISA)组合的全部专属代码:一个点名令牌和 tile 的包装函数。另一条路, 也就是 C 语言 BLAS 传统里那种宏展开或手抄的每 ISA 内核,因为审阅成本和扩展成本太高而 被否决了。

trait 加 const 泛型,让全库只剩一个泛型微内核需要阅读、单步调试、修复。一次调度改进落地 一处,所有 ISA 就都跟着受益。编译器会对每个单态化实例做完整类型检查。OnceLock 槽里放的 是带类型的函数指针,不是类型擦除的指针,所以签名漂移是编译错误,不是潜伏的 transmute 缺陷。

扩展遵循同样的形状。新增一个 ISA,只需要一个零大小令牌、它的 SimdOps 实现,以及每个 选择阶梯里的一条分支。新增一个元素类型,只需要一个 Scalar 实现、一个家族(或者通过 拓宽/收窄接缝复用现有家族),再加一个分发槽。驱动、打包、分块永远不用改动。一个测试 (gemmkit/tests/open_closed.rs)通过用第二个平凡家族驱动驱动器来验证这一点。后续两页 SIMD 令牌与 ISA 分发标量与内核家族会 逐条展开每条接缝。

no_std 与零强制依赖的核心

关掉默认 feature 后,核心 crate 会以 #![no_std] 构建。它只需要 corealloc, 不再依赖任何其他东西。每个可选 feature 至多引入一个依赖:

Feature引入的依赖换来的能力
std(默认)raw-cpuid(仅 x86/x86-64 目标)运行时缓存与 CPU 特性探测、GEMMKIT_* 环境变量旋钮、线程本地工作区池
parallel(默认)rayonParallelism::Rayon 多线程
halfhalff16/bf16 混合精度 GEMM
complexnum-complexc32/c64 复数 GEMM
int8i8 -> i32 整数 GEMM
epilogue融合偏置/激活/映射 epilogue(重量化还需同时开启 int8
wasm_threadsparallel 外无线程化 wasm 上显式定容的 rayon 池

没有 std 时,编译期 target feature 会取代运行时 CPU 探测。环境变量旋钮会关闭,不过 程序化的 tuning::set_* setter 依然可用,因为它们只是普通的原子操作。线程本地池也会换成 按调用传入的工作区。

位于技术栈这么底层的一个内核,不应该把依赖策略强加给它的宿主。嵌入式或 wasm 部署得到的 是和桌面构建一样的驱动、一样的家族、一样的可复现性契约,只是少了那些真正需要操作系统的 机制。实操指南见 no_std 与 WebAssembly

工作空间地图

五个 crate 以 0.1.2 版本同步发布,另有一个模糊测试 crate,它被有意放在自己独立的工作空间 根目录下:

路径Crate角色
gemmkit/gemmkit核心 GEMM 引擎(本章描述的全部内容)
gemmkit-ndarray/gemmkit-ndarray面向 ndarray(>= 0.17.1)视图的零拷贝适配器
gemmkit-nalgebra/gemmkit-nalgebra面向 nalgebra 0.35 矩阵的零拷贝适配器
gemmkit-faer/gemmkit-faer面向 faer 0.24 矩阵的零拷贝适配器
gemmkit-tune/gemmkit-tune安装期自动调优程序,输出一份 GEMMKIT_* 环境变量配置
gemmkit/fuzz/gemmkit-fuzzcargo-fuzz 目标,仅限 nightly,被排除在稳定工作空间之外

适配器有意做得很薄。每个适配器都直接从宿主库的原生视图里取出矩阵指针和步长:C 序、 F 序、一般步长、反转步长皆可,全程零拷贝。取出之后,适配器就转发给 *_unchecked 引擎,靠宿主类型自身的不变量来完成安全层原本要做的校验。每个适配器还会把同名的 Cargo feature(parallelwasm_threadshalfcomplexint8epilogue)转发给 gemmkit,所以 feature 的行为在各处都保持一致。各适配器章节覆盖了它们完整的表面: ndarraynalgebrafaer

gemmkit-tune 是进程外的校准器。引擎里每个启发式阈值都是一个运行时旋钮(见 调优旋钮)。编译进去的默认值是在某一台机器上校准的, 调优器存在的意义就是在你自己的机器上重做一遍这次校准。

在部署主机上运行一次调优器二进制。它会对每个旋钮在一组探测形状上做扫描,然后写出一份由 export GEMMKIT_*=... 组成的 gemmkit-tune.env 配置文件。在启动应用之前 source 这个 文件即可。整个过程不需要重新编译,也没有构建期的耦合。调优器和库之间唯一的契约就是那份 文档化的环境变量表面。tuning::knob_env_names 注册表守着这份契约的诚实性,因为调优器的 扫描表会与它做断言比对。实操指南见 gemmkit-tune 章节

模糊测试 crate 被有意放在工作空间之外。cargo-fuzz 需要 nightly,因为要用到 build-std 和 AddressSanitizer。排除它可以让 cargo test --workspace 和 MSRV 构建始终留在 stable 上。

本章与 ARCHITECTURE.md 的关系

仓库里的 ARCHITECTURE.md 是紧凑的地图。它给出分层表、调用路径、各条接缝,以及每个 子系统一节的说明,写给那些开着另一个窗口看代码的读者。本书这一章是同一份素材的导览版。 它用的是同一套层号、同一批文件引用,但留出了篇幅讲清理由、被否决的备选方案,以及完整的 示例。两者若有出入,以代码为准,而且这意味着两份文档都有 bug。

接下来请读分层结构,了解静态骨架。再读 一次 GEMM 调用的生命周期,了解动态过程。