分块与缓存模型
驱动器最底层的微内核负责计算 C 的一个 MR x NR 分块。它把这个分块保存在寄存器中,沿深度方向依次读入 A 的一条微面板和 B 的一条微面板。只有当这两路数据都来自邻近的缓存时,这个循环才能跑到机器峰值。一次 GEMM 触及的数据远远超过任何一级缓存的容量:A 的每个元素要在 n 个输出列上复用,B 的每个元素要在 m 个输出行上复用。
分块(blocking)就是对这种复用的安排。它把问题切分开,让每个操作数的分块只被读入某一级缓存一次,然后被反复读取,直到被逐出。KC 沿深度方向切片,让一次分块乘法所读的两条微面板,在整个分块计算期间都留在 L1 中。MC 决定打包后 A 宏面板的大小,使它在驱动器扫过当前列块的每个列瓦片时始终留在 L2 中。NC 决定打包后 B 宏面板的大小,使它在每个行块轮流扫过时始终留在 L3 中。让面板在整个循环嵌套中保持驻留,正是分块的全部意义所在。没有它,同样的字节就要从 DRAM 中反复读取 m、n 或 k 遍。
许多库按微架构把 (MC, KC, NC) 写死。gemmkit 则在每次调用时,在 CacheTopology::blocking(gemmkit/src/cache.rs,L3 层)里解析地计算这三个值。这个函数遵循 BLIS 模型,根据运行时探测到的缓存几何来推导尺寸。它的输入是微块尺寸 (mr, nr)、单个打包输入元素的字节大小,以及问题形状 (m, n, k)。输出是驱动器循环嵌套所用的 Blocking { mc, kc, nc } 三元组。一次GEMM调用的生命周期展示了每个值落在嵌套的哪个位置。本页说明这些值是如何推导出来的,以及模型为何是这个样子。
三个约束,三个块尺寸
KC:两条微面板同驻 L1 且不自我逐出
每次微块调用都要走 kc 个深度步,每一步读取 mr 个打包后的 A 元素和 nr 个打包后的 B 元素。因此一条 mr x kc 的微面板和一条 nr x kc 的微面板必须在整个分块计算期间同时留在 L1d 中。这里微妙之处在于不自我逐出:缓存不是一个字节池,而是由若干组(set)划分出来的若干路(way)。一条面板如果把太多自己的缓存行映射到同一组上,还没等总大小逼近缓存容量,就已经把自己挤出去了。
模型因此以缓存行和组为单位计算,而不是以字节为单位。它先算出每条微面板每一个深度步占用多少条 L1 缓存行,再选出合并占用不超过 L1 相联度的最大 kc。然后把这个结果抬高到 GEMMKIT_KC_MIN 下限(默认 512),确保容量较小的 L1 也不会饿死微内核的深度扫描,最后把它钳制在 k 以内。最后还有一次再平衡:把 k 切成 ceil(k / kc) 份大小相近的面板,避免最后一个深度切片过于零碎。
MC:A 宏面板占 L2,扣除 B 的份额
在一个行块之内,驱动器要在当前列块的每个列瓦片上复用打包后的 mc x kc A 面板,所以这个面板理应填满 L2。但它不能占满整个 L2,因为同一深度切片的 nr x kc B 微面板也要在每次分块调用时流经 L2。模型先数出这条微面板占用多少 L2 路,预留这些路再加一路备用,剩下的容量才交给 A。
它用这部分剩余容量除以 kc 得到 mc,向下取整到 mr 的倍数,再做一次再平衡使各行块大小均匀。最后,一个 BLIS 式的硬上限 GEMMKIT_MC_REG_PANELS * MR 行(默认 8 个微块行)钳住这个结果。这个上限是一个标定值,而不是从 L2 容量项严格推导出来的边界。实际情况中,这个上限往往先于 L2 容量项生效,所以 MC 通常就是 MR 的一个小倍数,L2 容量项大多只是留出的余量。
NC:B 宏面板占 L3,无 L3 时用面板数上限
有 L3 时,模型为途经的 A 流量预留一路,其余容量预算给打包后的 kc x nc B 宏面板。它用这部分容量除以 kc 得到 nc,向下取整到 nr 的倍数,再对 n 做一次再平衡。
有些机器根本不报告 L3。例如 Apple Silicon 的簇共享 L2 就是整个层级的顶端。这种情况下,模型改为跑满整个 N,直到一个面板数上限为止:nc 取 GEMMKIT_NC_NO_L3_PANELS * nr(默认 512 个面板,也就是 nr = 4 时的 2048 列),并以 n 封顶。既然没有 L3 能让 B 驻留,B 无论如何都要从 DRAM 流入。这个上限只是限制共享的打包 B 缓冲区大小,而不是在为驻留建模。
按打包元素、而非累加器元素计大小
sizeof 参数是单个打包输入元素的大小。驱动器传入的是 size_of::<Fam::Lhs>(),而不是累加器的大小,因为模型预算的面板本来就是以打包后的 Lhs/Rhs 为单位存储的。对 f32 和 f64 而言,这两个大小恰好相同,所以没有区别。
对窄类型来说,这个区分就很关键了。i8 每个元素打包 1 字节,对应的累加器 i32 却是 4 字节。f16 和 bf16 每个元素打包 2 字节,累加器 f32 是 4 字节。如果按累加器计算大小,它们的 kc 和 nc 会被砍到缓存实际可容纳量的四分之一或一半。窄类型因此得到成比例更深的块,这也是它们能在同样的硬件上跑得比 f32 更快的原因之一。预打包入口复用同一个模型,只是用一个哨兵行数,使预打包操作数的几何形状与最终的 m 无关。
把输出 tile 预取到 LLC 之外
这三个块尺寸让 A 和 B 面板保持驻留,却完全没有提到 C。每次微 tile 调用都要对它的 mr x nr 输出 tile 做一次读取、修改、写回。一旦一次调用的工作集(A、B、C 三者字节数之和)超出单核可达的 LLC 容量,这个输出 tile 就不再驻留在缓存里,它的写回就要伸进 DRAM。
驱动器用一次软件预取来应对,这个决策每次调用只做一次。它把工作集与 cache::prefetch_ws_bytes(即 GEMMKIT_PREFETCH_MIN_BYTES 门槛,0 表示自动:取单核可达的 LLC,有 L3 就取 L3,否则取 L2)做比较。一旦超过门槛,驱动器就会在每个输出微 tile 对应的微内核调用之前,对它发出一次 T0 预取。这样可以在微内核仍在计算的时候,把即将被写入的缓存行提前拉进 L1。
这个预取沿着 tile 的单位步长维度,整条整条地拉取 64 字节的缓存行。两个维度都带步长的 tile 没有连续的缓存行可拉,会被跳过。驱动器只在 x86_64 上发出这个提示(prefetcht0,基线 SSE 指令,不需要额外的 feature 门控)。在其他目标上它会退化成空操作,因此 aarch64 和 wasm 都不受影响。这个预取只搬动缓存行,绝不涉及任何算术运算,所以无论门槛是打开、关闭还是被强制指定,结果都逐位一致。门槛以下,tile 本就驻留在缓存中,预取路径不会带来额外开销。
小矩阵捷径
当 m 和 n 都不超过 GEMMKIT_TINY_BLOCK_DIM(默认 64)时,驱动器会跳过完整模型。它把 kc 设为 k,并钳制在 GEMMKIT_KC 上限内(默认 2048,aarch64 上为 16384)。它把 mc 设为在该深度下仍能让面板留在 L2 里的行数,并以 m 本身封顶。它把 nc 设为 n 向上取整到 nr 的倍数。
一个工作集整体就能装进 L2 的问题,从三级驻留分析中得不到任何好处。这个捷径把省下来的运算花在真正要紧的地方:小规模乘积中占主导地位的、每次调用固定不变的那部分开销。
这个上限以 4 字节元素为单位计数,窄元素会去除它。于是 f16 拿到 f32 两倍的深度,int8 拿到 4 倍的深度。保持不变的是字节预算,而硬件限制正是关于字节的。因此一个数就标定了所有元素族,在 f32 上跑出来的调优结果也能迁移到其余元素族。
这个上限决定深度切片的数量,而切片数量牵动两项方向相反的成本。每多一个切片,就要多重读、重写一遍 C,多进入驱动器一次,在并行路径上还要多 fork 一次 worker。这一侧要求上限尽量深。
而切片越深,打包后的 A、B 面板就越大。这些面板必须留在私有 L2 里,所以这一侧要求上限尽量浅。在 x86 上,默认值让面板占到约 1.1 MiB,正好是一块 Zen5 L2。两者之中并行侧的成本更大,所以默认值取在驻留上限处,而不是取在它下方。
宽元素正是这两项成本对“该不该做除法“意见不一的场合。字节预算不等于切片预算。在固定预算下,16 字节元素拿到的切片数是 4 字节元素的 4 倍,于是每切片成本它要多付 4 次。驻留一侧要求做除法,切片数一侧不要求。
哪一侧胜出是机器的属性,所以这个除数带一个按架构分叉的上限。在 x86 上私有 L2 只有 1 MiB,驻留先咬住,除法对所有元素大小都生效。在 aarch64 上有效 L2 大 4 倍,而且统一内存能很好地喂溢出的面板,驻留几乎咬不住,所以宽元素在那里保留完整的上限。在 M4 Max 上实测,对它做除法让 c64 的并行路径损失 21% 到 51%。
探测:一条不会失败的回退链
模型的好坏取决于喂给它的几何数据的质量,而查询缓存几何并没有一种可移植的办法。gemmkit 因此运行一条尽力而为的回退链,其中 #[cfg] 只用来挑选探测方法,从不用来决定数值本身。#[cfg(target_arch)] 分不清 Intel 部件和 AMD 部件,虚拟机或容器又可能屏蔽 CPUID、隐藏 /sys。所以每个后端都返回一个 Option,链条最终落在一个绝不会失败的常量上。
#![allow(unused)]
fn main() {
// gemmkit/src/cache.rs
#[cfg(feature = "std")]
fn detect() -> CacheTopology {
// try the CPUID backend
#[cfg(all(any(target_arch = "x86", target_arch = "x86_64"), not(miri)))]
if let Some(t) = cpuid::detect().filter(plausible) {
return t;
}
// try the sysfs backend
#[cfg(all(target_os = "linux", not(miri)))]
if let Some(t) = sysfs::detect().filter(plausible) {
return t;
}
// try the sysctl backend
#[cfg(all(target_os = "macos", not(miri)))]
if let Some(t) = sysctl::detect().filter(plausible) {
return t;
}
ZEN5_FALLBACK
}
}
各个后端按以下顺序运行。
CPUID(cache/cpuid.rs)是一条指令,因此无论操作系统如何,它在容器和大多数虚拟机里都能工作。CPUID 通过按缓存实例枚举的拓扑叶(Intel 的 04h、AMD 的 0x8000_001D)读取两家厂商的信息,这个叶描述的是执行核实际可达的那一块缓存。在多 die 的部件上,比如双 CCD 的 Ryzen,L3 报出的是一个核真正能命中的那个复合体(9950X 上是 32 MiB),而不是整片封装的总量。这个单核可达的数值,正是每个消费者都想要的语义。没有该叶的 AMD 部件或虚拟化环境会回退到传统的 L1 叶(0x8000_0005)和 L2/L3 叶(0x8000_0006)。在那里,L3 大小以 512 KiB 为单位、按整片总量报告,16 路相联度甚至无法编码。
Linux sysfs(cache/sysfs.rs)用纯 std::fs 解析 /sys/devices/system/cpu/cpu0/cache/index*/。它在 x86 Linux 上是一个回退项,用于应对屏蔽了 CPUID 的虚拟化环境;在没有 CPUID 指令的 aarch64 Linux 上,它则是主要的数据来源。
macOS sysctl(cache/sysctl.rs)通过一段两行的 extern "C" 声明读取 sysctlbyname 键,不引入任何 libc 依赖。它优先读取 Apple Silicon 按性能级划分的键(hw.perflevel0.*,对应 P 核),把扁平的 Intel Mac 键作为回退。sysctl 不暴露相联度信息,所以这个后端采用保守的典型值。这样做是安全的,因为模型只需要相联度的近似值,并用 .max(2) 兜底。
链条的最末端是 ZEN5_FALLBACK,一个在 Ryzen 9950X 开发机上标定的静态默认值。L1d 是 48 KiB、12 路。L2 是 1 MiB、16 路、私有。L3 是 32 MiB、16 路。
两道守卫让这条链不只是“按顺序尝试“那么简单。plausible 会剔除半残的读数:只要有一级小于 4 KiB、缓存行小于 16 字节,或者相联度为零,整个后端就会被判定失败,被屏蔽的叶因此不可能用零值污染分块结果。探测本身每个进程最多只运行一次:Machine::current() 把拓扑数据连同操作系统页大小(getpagesize,校验为 4 KiB 到 2 MiB 之间的 2 的幂)一起用 OnceLock 记忆化。这个页大小驱动着打包与工作区里描述的 LHS 打包步长门槛。no_std 构建完全跳过探测,直接使用 Zen5 回退值和 4 KiB 页大小。
shared_by:对驱动器实际放置的数据的争用
每个 Level 携带 bytes、assoc、line 三个字段,再加上一个推导字段 shared_by,它把该级缓存的容量除成模型实际预算所用的 effective_bytes。把硬件层面的核共享数直接填进这个字段,看起来很自然,但那是错的。shared_by 建模的其实是针对驱动器实际放在该级缓存中的数据的按 worker 争用。驱动器的放置方式是:每个 worker 的 A/B 微面板放在 L1d,每个 worker 私有的 A 宏面板放在 L2,唯一一份共享的 B 宏面板放在 L3。
这个放置方式就决定了取值。L1d 是按核私有的,它的全部容量都服务于一个 worker 的微面板,所以 shared_by = 1。L3 在硬件上被所有核共享,但驱动器放在那里的却是一条所有 worker 共同读取的面板:是同一份字节,而不是每个 worker 一份拷贝。因此整级容量都归这一条面板所有,shared_by 依然是 1。如果按原始核数去除,预算会被无谓地缩水很多倍,把 NC 白白拉低。
只有 L2 真正存放着按 worker 私有的数据,所以只有 L2 使用物理核层面的 L2 共享度。在私有 L2 的部件上,例如主流 x86 和 Neoverse,这个共享度是 1。在核簇共享一个 L2 的部件上,例如 Apple Silicon,它就是簇的大小,因为在那里,多个 worker 私有的 A 面板确实要争抢同一批路。每个后端都必须推导出这个值,而不能直接照抄一个原始计数:sysfs 用 L1d 共享列表读出的 SMT 度去除 L2 原始的 shared_cpu_list 计数,避免把超线程兄弟核重复计入;sysctl 读取 hw.perflevel0.cpusperl2;CPUID 后端则直接把它硬编码为 1,因为 x86 的 L2 本就按物理核私有。
在 x86 和 Graviton 上,整套机制因此都归约成全部为 1。它真正派上用场的是簇共享 L2 的部件:在那里,它决定了模型究竟是按一个 worker 实际独占的 L2 来分块,还是按一个必须与整个核簇共享的 L2 来分块。
线程数能挪动什么,不能挪动什么
blocking 不接收线程数参数。对 KC 和 NC 来说,这个缺席意义重大:两者都只取决于机器本身和问题形状,所以一次串行运行和一次宽度很大的并行运行会推导出完全相同的 KC 和 NC。这就让每次运行都得到相同的深度切片,以及每个输出元素相同的、顺序固定的深度累加链。
MC 是驱动器确实会为并行度调整的那一个分块维度。宽 worker 数有可能让扁平任务列表变得太浅:每个 worker 分不到几个块,运行的尾部就会退化成一群空等最后几块被人拿走的 worker。一旦出现这种情况,驱动器就会缩小 MC,切出更多行块,把列表加深。并行执行详细说明了这个并行作业深度下限。因此,面板边界和扁平任务列表已经不再严格独立于 worker 数了。
尽管如此,逐位一致性依然成立,因为这次缩小本身不涉及任何数值运算。MC 始终保持为 MR 的倍数,所以它产出的微 tile 集合(每个 MR 对齐的行偏移,加上唯一的那一条 m 尾巴)在任何切分方式下都相同;更宽的 worker 数只是把同一批 tile 重新分组成更多、更小的行块。而 KC,这个唯一决定 tile 累加顺序的分块维度,从不随线程数变化。因此,在固定配置下,仅仅改变 worker 数,也不会改变任何输出元素的累加顺序,这正是 gemmkit 可复现契约背后的机制。并行执行拼装了完整的契约内容,并说明了它确切的适用范围。
并行度在其他方面只影响打包决策本身。LHS 打包门槛看的是每个 worker 的列复用程度,共享 A 预打包也只在大型并行问题上才会启用。这些决策只决定驱动器把打包字节放在哪里、由谁写入,从不改变计算出的数值。任务列表如何切分、契约如何端到端成立,见并行执行;本页提到的这些旋钮,以及其他每一个 GEMMKIT_* 阈值,都收录在调优旋钮中。