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

预打包操作数

在微内核触碰 AB 之前,引擎会先把它们各自拷贝成一种对缓存友好的微面板(micropanel)布局。这种布局由一块块连续的 tile 组成,微内核以单位步长逐块扫过。对于一次性的乘积,这次拷贝纯粹是准备工作,引擎付出一次代价之后就再也用不上它了。

很多工作负载会反复乘同一个矩阵。一个线性层会用同一个固定的权重矩阵去乘一串激活批次,一个求解器会用同一个固定的算子去乘许多右端项。如果每次调用都重新打包这个固定操作数,就等于把引擎已经做过的工作白白扔掉。预打包操作数 API 让你把打包的代价只付一次,然后在所有共享该操作数的乘积之间复用这个结果。

打包右操作数

最常见的情形是固定 B(权重),再配上一串大小各异的 A(激活)。调用一次 prepack_rhs,把 k x nB 变成一个 PackedRhs 句柄,之后每次乘积都把这个句柄交给 gemm_packed_b

#![allow(unused)]
fn main() {
use gemmkit::{prepack_rhs, gemm_packed_b, MatRef, MatMut, Parallelism};

// 固定权重:一个 k x n 矩阵,被许多激活批次复用
let (k, n) = (512, 256);
let weights = vec![0.0f32; k * n];
let packed = prepack_rhs(MatRef::from_col_major(&weights, k, n));

// 每个激活批次:一个 m x k 输入,共享已打包的权重
let mut c = vec![0.0f32; m * n];
gemm_packed_b(
    1.0,
    MatRef::from_row_major(&input, m, k),
    &packed,
    0.0,
    MatMut::from_col_major(&mut c, m, n),
    Parallelism::Rayon(0),
);
}

prepack_rhs 接受任意布局的 B,并通过其步长来读取它。行主序、列主序或转置视图打包出来的结果都一样。打包只在 prepack_rhs 内部单线程地跑一次,之后每次调用 gemm_packed_b 都会跳过它。

这块缓冲区记录了它构建时所用的分块几何:nrkcnc。之后每次消费调用都会原样读回这套几何,所以一个面板永远是按它自己的 tiling 来解读的。该缓冲区在整个 GEMM 期间只读,引擎在打包之后不会再写它。因此一个 PackedRhs 可以安全地跨线程、跨并发调用共享,无需任何额外同步。PackedRhs::rows() 返回原始的 kPackedRhs::cols() 返回原始的 n

这个句柄对任何 (k, n) 与所打包 B 相符的乘积都有效,前提是输出 C 是列主序倾向的(|csc| >= |rsc|)。这条约束是这套 API 里唯一的意外之处。一个行主序的 C 会迫使引擎在内部交换 AB,以保持存储连续。而预打包的 B 无法挪到 A 的角色上,所以 gemm_packed_b 遇到行主序的 C 会 panic,并提示你对那种布局改用普通的 gemm。被这样钉住的只有 CA 的布局不受约束。

在固定配置下,gemm_packed_b 会复现普通的 gemm,且结果对 worker 数是确定的。这里有一个很窄的例外。它出现在小乘积上,即 mn 都在 small_mn_dim 旋钮(默认 16,aarch64 上为 32)及以下的情形。它也出现在 gemv 形状的乘积上,即 m == 1n == 1 的情形。在这两种情形下,两次调用的结果可能在最后一个 ULP 上不同。原因在于路由,而不是误差:普通 gemm 会把这些形状改道到某条特殊路径,而预打包入口始终驱动通用的打包内核。两个答案都是正确的,只是恰好在那些本会被特殊路径接管的形状上,求和的次序略有不同。

左操作数的镜像

对称的情形是固定 A,再配上一串变化的 Bprepack_lhs 产生一个 PackedLhs 句柄,由 gemm_packed_a 消费。它与 RHS 那一对完全镜像,只是把轴换了标签:PackedLhs::rows() 是原始的 mPackedLhs::cols() 是共享的 k

在内部,LHS 打包并不是一条独立的代码路径。由引擎的 A/B 对称性可知,一个预打包的 A 恰好就是转置乘积 C^T = B^T A^T 的预打包 B。所以 prepack_lhs 铺下的是完全相同的微面板缓冲区,只是把维度记成了 LHS 的说法。

这带来一个可见的后果:C 的布局约束翻转了。gemm_packed_a 要求行主序倾向的 C|csc| <= |rsc|),与 RHS 入口正好相反。列主序的 C 会让 A 保持在真正的 LHS 角色上,而一块转置 RHS 缓冲区填不了这个角色。C 是行主序时选打包 A 的入口,C 是列主序时选打包 B 的入口。两者合起来覆盖了这两种取向。

融合变体

每个打包入口都有一个融合孪生,位于 epilogue feature 之后。gemm_packed_b_fusedgemm_packed_a_fused 会在打包内核本就要执行的那次存储里,加上逐行或逐列的偏置,外加一个可选的激活。偏置与激活的类型见融合 Epilogue

同一个 PackedRhsPackedLhs 句柄同时服务普通入口和融合入口。epilogue 只作用于存储那一步,从不触碰打包,所以你只需构建一次缓冲区,每次调用再决定是否融合。

有两点是打包路径特有的。其一,与普通 gemm_fused 不同,打包融合入口从不改道到 gemv、小 m,n 或小 k 内核,它们始终驱动通用打包内核,这与普通打包入口所记录的分歧一致。其二,gemmkit 始终以自然的用户坐标系给出逐行或逐列偏置:gemm_packed_a_fused 替你处理了内部转置,所以无论调用哪个入口,PerRow 偏置的长度都是 A.rows

预打包 i8 权重

int8 feature 之下,同样的模式延伸到量化推理。prepack_rhs_i8 把固定的 i8 权重矩阵打包成 PackedRhs<i8>,由 gemm_i8_packed_b 消费。它接受 i8 输入,产生 i32 输出。

预打包在这里比对浮点收益更大,原因是结构性的。AVX-512 VNNI 内核(vpdpbusd)从一种 k-四元组交错布局中读取它的 RHS。引擎无法就地生成这种布局,因此这个内核的 RHS 打包在每次调用时都是强制的。在小 m 下,这个逐调用的 O(k*n) 打包很容易压过 O(m*k*n) 的计算量。预打包把它彻底从热循环里删掉。

这块打包缓冲区还钉住了内核的选择:它是按进程分发所选定的那个整数内核铺设的,要么是 VNNI 交错布局,要么是加宽内核的普通面板。gemm_i8_packed_b 始终跑同一个家族,所以缓冲区绝不会被错读。

整数累加是精确的,且不依赖 ISA。因此对每个合法形状,打包路径与普通路径都逐位一致,完全没有小形状的例外。

#![allow(unused)]
fn main() {
use gemmkit::{prepack_rhs_i8, gemm_i8_packed_b, MatRef, MatMut, Parallelism};

let packed = prepack_rhs_i8(MatRef::from_col_major(&weights_i8, k, n));
let mut c = vec![0i32; m * n];
gemm_i8_packed_b(
    1,
    MatRef::from_row_major(&input_i8, m, k),
    &packed,
    0,
    MatMut::from_col_major(&mut c, m, n),
    Parallelism::Rayon(0),
);
}

什么时候值得预打包

预打包用一次前置的 O(k*n) 拷贝,换取此后针对该操作数的每次乘积都省下一次重打包。只有当这个操作数被复用得足够多、能摊销这次拷贝时,它才划算。对于一次性的乘积,或者每次调用都变化的操作数,预打包一无所获,此时打包纯属额外开销,普通 gemm 才是正确的选择。

还要留意,浮点路径本就不一定会先打包 B。在小 m 下,普通 gemm 会就地读取未打包的 B,这个选择由 rhs_pack_threshold 旋钮控制。所以给一个复用不多的浮点 B 做预打包,甚至可能反而更慢。

最清晰的收益出现在这套 API 得名于的固定权重推理循环上,尤其是 i8 VNNI 路径,它的 RHS 打包本来每一次调用都躲不掉。拿不准的时候,就把循环的两种写法都测一测。交叉点取决于你的复用次数和你的机器,而不是某条固定规则。

裸指针形式(prepack_rhs_uncheckedgemm_packed_b_unchecked,以及它们的 _with、LHS 与 i8 对应版本)是为自行校验输入的适配器和 FFI 而设的。见 Unchecked 层