小形状与GEMV
gemmkit 核心的寄存器分块 driver,需要每个输出 tile 有足够的工作量。它要靠这些工作量去摊销打包、缓存分块,以及一整个 MR x NR 累加器的开销。
有些形状彻底打破了这个前提。矩阵-向量乘完全没有 tile 复用。k = 4 的收缩在打包收回成本之前就算完了。一个 8 x 8 x 100000 的乘积会让 driver 把大部分精力都花在乘填充上。对这类形状,driver 是错的工具,于是引擎会悄悄绕开它。
这里没有什么需要你去开启。同一个 gemm 入口(以及 gemm_i8、gemm_fused、gemm_map)会在分发的最顶端检查形状与步长。遇到合适的形状,它就改道到一个特殊内核;否则就落回通用 driver。每一次改道都藏在同一个公开入口之后。
每次改道都遵守库的可复现性契约:同一次调用,在同一台机器上、使用同一份配置,返回同一个结果。每次改道也都由一个调优旋钮门控,你无需重新编译就能移动或关闭它。你从不直接调用这些路径。你只是把形状写得自然,而不必手搓点积循环,就享受到了它们的好处。
gemv:受内存带宽支配的边界
m == 1 或 n == 1 的形状是一个矩阵-向量乘。每个输出元素只需要 2k 次浮点运算,但读取它却要用到 k 个矩阵元素。算术本身是琐碎的,所以整个问题就变成了尽量减少 DRAM 流量。专用的 gemv 路径用同一个核心例程处理这两种情形。它把矩阵看作 rows x k 乘一个 k 向量,m == 1 时先转置矩阵。这条路径对每种布局都正确,并且对连续布局做了向量化。
这也覆盖了退化的 m == n == 1 点积。1 x k 的视图行跨度与列跨度都是 1,所以它同时符合列主序与行主序两种策略。列主序策略沿输出行向量化,可这里只有一个输出行,填不满一个 SIMD 寄存器。于是路由把任何短于一个寄存器的扫描都交给沿 k 向量化的那一支。这里没有什么需要开启,也没有什么需要知道。用 MatRef::from_col_major(a, 1, k) 构造的点积,也就是列主序库交给你的那种形状,现在走的是和行主序写法一样的快路径。
有两条性质对调用者要紧。其一,gemv 遵守库的一般可复现性契约:同一次调用,在同一台机器上、使用同一份配置,总是返回同一个结果。每个输出元素都由一个 worker 在一趟 k 扫描里归约完成。
把行分给多个 worker,改变的只是哪个 worker 做这份工作,而不是这份工作怎么做。库并不把 gemv 的这条保证延伸到不同 worker 数之间的逐位一致。worker 数是配置的一部分,这和 gemmkit 里其他地方一样。
其二,gemv 有自己的一套并行策略。它受带宽支配,所以 worker 数来自一个带宽模型,而不是通用 driver 用的那条计算爬坡。过了那几个能让 DRAM 饱和的核心之后,更多 worker 就不再有用了,只会增加 fork/join 开销和共享缓存争用。这个数遵循一道架在“本次调用触碰多少字节”之上的阶梯。在某个下限之下,矩阵还装得进单核的私有缓存,所以调用保持串行。过了那个下限,宽度就按台阶逐级上升,而且永远到不了整机宽度。
有四个旋钮暴露这套策略:
gemv_parallel_bytes设定保持单线程的字节下限。gemv_tier_step设定阶梯的台阶之间隔多少字节。gemv_thread_cap设定一个固定宽度,直接取代整道阶梯。gemv_threshold与前面三个并列。由于 gemv 形状总满足min(m, n) == 1,这个旋钮实际上起的是开关作用,而不是一个可分级的上限。
小 k 路径
收缩 k 也可能小到打包不划算。阈值是 small_k_threshold,x86 默认 16,aarch64 默认 8。在这个深度及以下,整个乘积就是单个深度面板,每个打包元素本来也只会被读一次。打包无从摊销,于是 driver 的打包步骤就变成了纯粹的开销。
小 k 路径覆盖这些瘦长、低深度的形状:gevv、rank-k 更新,以及高瘦乘积。它直接用家族的微内核计算 C,就地读取 A 和 B,不打包,一趟算完。这条路径免费继承了家族的加宽、偏置、共轭与舍入行为,并且对任意 worker 数都与串行运行逐位一致。它需要列主序的 A,也就是行单位步长(rsa == 1)。当 A 不是这种布局时,在这么小的 k 下打包本来也很少能摊销。这时路径就转而退回通用 driver,仍然算出正确的结果。
小 m,n 路径
镜像的情形是一个很小的输出配上一个很长的收缩。m 和 n 都远低于微 tile,处在 small_mn_dim 及以下(x86 默认 16,aarch64 默认 32),而 k 很长。driver 会把很小的行 tile 和列 tile 填充到一整个微 tile。然后它会把大部分工作都花在这些填充上。
这条路径改为把每个输出算成一个水平点积,C[i,j] = alpha * <A[i,:], B[:,j]> + beta * C[i,j]。它沿收缩方向流式跑 SIMD,没有任何分块或取向机制。它还对这个小输出网格做寄存器分块,让好几条独立的 FMA 链同时在飞。
这个水平内核需要两个操作数都沿 k 单位步长。也就是说,A 的行必须连续(csa == 1,即行主序 A),B 的列必须连续(rsb == 1,即列主序 B)。两者都成立时,该路径就地零拷贝地读取 A 和 B。这就是快路径。
两种最常见的布局各自恰好缺一边。全行主序缺 rsb,全列主序缺 csa。遇到这种情况,一次内部预打包会把仅那个不达标的操作数拷进 k 连续的暂存区一次,然后在其上跑同一个水平点积。这次拷贝读取大约 m*k 个元素,或者对另一个操作数是 n*k 个。
相对乘积本身 m*n*k 的工作量,这大约是 1/n(或 1/m)的一个零头。它的代价远小于水平路径省下的开销,所以一个带步长的小 m,n 形状仍然胜过落回 driver 的填充微 tile。
不过只按浮点运算量算会低估它:这次拷贝每搬一个字节做零次算术,而点积每字节约做两次,所以它能用来掩盖内存延迟的东西更少,占用的时间份额远大于它占用的工作量份额。因此它本身也被并行化了,而且与点积各自独立地决定宽度:小 m, n 留给点积去切的输出网格极小,而拷贝可切的深度却很长。这不需要任何配置。列主序、深收缩的小 m,n 形状在参考机上快了 1.1-3.1×(f32,自动宽度),其中收益较大的是被打包操作数仍能驻留缓存的那一段。
这个预打包档在 k 越过它自己的旋钮 small_mn_pack_min_k(默认 16)时启用,这个旋钮与零拷贝档所用的 small_k_threshold 是分开的。这条路径同样对任意 worker 数都与串行运行逐位一致。它把每个输出算成在一块不相交 tile 上的一次定序归约。
实用建议
优先用现成的入口,而不是手写点积循环。你的问题可能是一个矩阵-向量乘、一次 rank-k 更新,或者长收缩之上的一格小输出。不管哪种情况,gemm 都已经带着为那个形状调好的内核。它自带带宽感知的线程策略,以及手写循环得从头再造的那些可复现性保证。
布局是你手里唯一的杠杆。对水平的小 m,n 路径,行主序 A 加列主序 B 沿 k 单位步长流动,能命中零拷贝快路径。对小 k 路径,列主序 A 会留在就地路径上。其他任何布局仍然可用,只是要么付小 m,n 的预打包拷贝,要么落回通用 driver。
本章提到的每个阈值都是一个可调旋钮。每一个都按每次调用,依次从一个参数、一个程序化 setter、一个 GEMMKIT_* 环境变量,或一个校准过的编译期默认值中解析。如果某次改道对你的机器失准,或者你想把某个形状强制送上通用 driver,请参阅调优旋钮一章。它完整讲解了 gemv_threshold、small_k_threshold、small_mn_dim、small_mn_pack_min_k、gemv_parallel_bytes、gemv_tier_step、gemv_thread_cap,以及 k_stream_max。至于特殊路径的内部机制,也就是每个内核为何长成那样,请参阅架构一章的特殊路径。