测试与验证
一个把“这里逐位一致、那里按容差、处处可复现“当作头条承诺的库,成败全在于它的测试能多精确地钉住这些字眼。gemmkit 的测试套件都放在 gemmkit/tests/ 下。第一个结构性的决定是:什么不算测试。性能 harness 是测量工具,它们从不给 CI 把关。
tests/perf/ 是详尽的内部调查套件。它在一套取 9 次中位数的 harness 上跑 #[ignore] 基准测试,并且串行在一把共享锁之后,因为每一个基准都会打满所有核心。只有在某次改动需要拿到具体数字时,才会有人手动跑它。gemmkit/benches/gemm_bench.rs 则是精选出来的公开 cargo bench 界面。它包含五个头条 criterion 基准组:sgemm、dtypes、gemv、prepacked、batched,用于配合 --save-baseline 对 gemm crate 和 matrixmultiply 做回归追踪。这两套东西都不能让合并失败,因为在共享的 CI runner 上做性能断言,断言到的大多是噪声。
正确性、性质、一致性、模糊测试
正确性套件(tests/correctness/)针对形状、布局、alpha/beta 组合做扫描,对照一个独立的 f64 参考 GEMM 实现。这份参考实现,连同它周围的精度机制,只存在一份,放在 tests/oracle_common/ 里,包括元素 trait、确定性填充、f64 参考本身,以及每种元素类型各自的相对 Frobenius 精度门限。正确性套件和性质测试套件都用 #[path] 引入这个模块,所以值得信任的 oracle 只有一份。
在这些 oracle 扫描之上,还叠加了好几层检查。对外部 gemm crate 的交叉核对,能抓住仓库内参考实现抓不到的共同盲区错误,因为它是一份独立的实现。并行逐位一致性测试覆盖了做出这项承诺的那些路线。逐 ISA 的内核运行都经由泛型 driver 完成。还有容易被低估的一点:安全 API 精确的 panic 措辞,由 #[should_panic(expected = ...)] 里的子串(比如 "A.cols" 和 "aliases itself")把关,这样一条校验信息就不会在悄无声息中退化成没那么有用的版本。
性质测试把这些扫描进一步推广,三套测试都用 proptest 在形状、步长和旋钮值上做驱动。tests/props_api.rs 覆盖 oracle 精度、跨运行的位级确定性、串行与并行结果一致、beta == 0 的覆写语义、广播步长、批量调用,以及 panic 保证。tests/props_packed.rs 覆盖一般情形下预打包与普通调用的逐位一致性,以及在文档化的 tiny/gemv 例外集合上按容差校验。tests/props_knobs.rs 覆盖随机旋钮设置下的行为。
再往下一层,tests/simd_conformance.rs 检查的是 L0 词汇表本身。它直接构造宿主支持的每一个 ISA token,绕开分发逻辑,把每个 SimdOps 原语、同质的 KernelSimd blanket 实现,以及可移植的 fma_bvec 回退实现,逐 lane 拿去和标量参考对照。产品内核很少用到的原语,比如整数 reduce_sum、fnma,以及拓宽接缝,正是在这里才得到覆盖。模块内部的扫描,比如 gemmkit/src/simd.rs 里 requant_store 的逐位相等测试,对向量化重量化契约做的是同一件事。这套测试不依赖 proptest,因此也能在 wasm 上运行,并对编译期的 simd128 token 做一致性测试。
模糊测试位于 gemmkit/fuzz/,这是一个仅限 nightly 的 cargo-fuzz 子工作空间,拥有自己的 workspace 根目录,被排除在稳定版构建之外。它包含六个 libFuzzer 目标。fuzz_gemm 生成构造即合法的问题实例,与朴素参考实现做差分检查,所以那里出现任何 panic 都是库本身的缺陷。fuzz_batched 对批量调用做同样的事。fuzz_prepack 与 fuzz_prepack_i8 在预打包 API 上做往返测试,其中 i8 那个按位精确地把关。fuzz_api_validation 把对抗性的几何形状扔给带校验的入口,一个文档化的 "gemmkit:" panic 算作可接受的结果,其他任何情况都算作一处校验缺口。fuzz_knobs 在每次运行之前,把所有进程级的调优旋钮都设成对抗性的值,正是它以机械的方式找出了分块模型里那些算术溢出的问题类别。
隔离纪律
有两类全局状态,会让朴素的测试组织方式产生竞争。整套测试的结构正是围绕这两类状态来设计的。
调优旋钮是进程级的原子变量。每一个会改动旋钮的测试,都住在自己专属的二进制里:tests/tuning.rs 放 setter,tests/env.rs 负责环境变量解析,还有 tests/props_knobs.rs,以及负责拨动 GEMMKIT_DEEP_KC_BYTES、强制走各条深 K 路线的 tests/deep_k_narrow.rs。tests/env.rs 里只放了恰好一个测试,所以它对环境变量的访问从构造上就是单线程的。独立的二进制就是独立的进程,不可能和另一个二进制里的旋钮状态产生竞争。
不过在同一个二进制内部,libtest 仍然会并发地跑测试。所以每一个会碰旋钮的测试,都要在该二进制专属的 KNOB_LOCK 互斥锁下串行执行,并且在释放这把锁之前,把自己改动过的值恢复原状。性质测试的二进制在此之上又加了一层 RAII 守卫,专门用来在 proptest 内部的 catch_unwind 中也能存活下来。
GEMMKIT_REQUIRE_ISA 更黏一些,因为分发逻辑每个进程只对它做一次记忆化。于是每个取值都有自己专属的钉住二进制:tests/env_isa_avx512f.rs、_vnni、_bf16、_scalar、_neon、_wasm,再加上负责断言未知取值会 panic 的 env_isa_garbage.rs。每个二进制都让自己的所有测试,经由 tests/env_isa_common/ 里共享的一个 Once,在任何分发逻辑真正解析之前,完成那一次 set_var 调用。因为同一个二进制里的所有测试都钉住同一个值,所以究竟是哪个测试赢得了运行这个 Once 的先手,并不重要。这次写入会刻意覆盖继承来的 GEMMKIT_REQUIRE_ISA,正是这一点,让下面用 SDE 钉住的那些 CI 任务能够运行这些同样的二进制,同时依然走的是真实的、逐 ISA 的路线。
Miri 补上了模糊测试的 sanitizer 覆盖不到的那部分内存安全故事。CI 在 Miri 之下运行标量路径的正确性套件(miri_scalar_path),以及复数负步长的 unchecked 入口,由 Miri 直接解释打包和 microkernel 路径上那些真实的 unsafe 指针算术。cfg(miri) 的绕行分支,只在 Miri 无法解释某个硬件转换时才会出现,绝不是为了跳过逻辑本身。
CI 矩阵
.github/workflows/ci.yml 把这套钉住机制,变成了对 runner 本身并不具备的那些内核的覆盖:
| 任务 | 覆盖的内容 |
|---|---|
test | 先用默认 feature,再用 --all-features,再关掉 parallel。此外还有关掉 std 的 4 种 feature 组合构建,呈现 no_std 风格。 |
kernel-scalar / kernel-fma | 把 GEMMKIT_REQUIRE_ISA 钉在每个原生可用的内核上,跑完整套件。 |
avx512f_test / avx512vnni_test / avx512bf16_test | 在 Intel SDE(sde64 -spr)之下,钉住 AVX-512F、VNNI 点积、BF16 点积三种内核跑套件。SDE 仿真的是硅片,代码路径依然真实。 |
kernel-neon | 先在一台 arm64 macOS runner 上原生跑一遍整个工作空间,再钉住 neon 重跑一遍。 |
wasm_simd128 / wasm_simd128_threads | 在 wasmtime 之下、于 wasm32-wasip1 上钉住 simd128,跑正确性与一致性测试。threads 任务在 wasm32-wasip1-threads 上跑真实的 8 路并行。 |
no_std | 为 x86_64-unknown-none、aarch64-unknown-none、wasm32-unknown-unknown 构建。 |
i686_check / msrv / lint / miri / coverage | 32 位检查、在 Rust 1.89.0(受支持的最低版本)上构建、fmt 加 clippy -D warnings、上面提到的 Miri 任务,以及仅报告结果的 cargo-llvm-cov,它钉住了一份固定的 ISA 列表,这样报告出来的百分比就不会随 runner 池的变化而波动。 |
SDE 仿真跑起来比原生执行慢得多,这正是 GEMMKIT_FAST_TEST 派上用场的地方。它是一个只属于测试套件的开关,只在 tests/fast_test_common/ 里实现一次,再由各个 harness 引入,库本身从不读取它。这个开关把确定性的维度和系数扫描收缩成每个冗余组合只保留一个代表,同时依然会走到每一个分支和每一类路径。SDE 任务会打开这个开关,并配合 PROPTEST_CASES=16。原生任务则保留完整的扫描。把这个开关挡在库本身之外,就意味着一个为测试图方便而设的开关,永远不可能改变发布出去的行为。
最终效果把整章串了起来:前面几页做出的每一条断言,都有一个可以指认的测试把关,而这个测试所在二进制的隔离规则,让它的结果值得信任。这既包括特殊路径里的逐位一致性保证,也包括 Epilogue 融合里“gemm 再映射“的等价性,还有扩展点里的开闭性质,以及每个被钉住的内核,在项目自己并不拥有的硬件上的正确性。