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

并行实践

每个 GEMM 入口都把 Parallelism 作为最后一个参数。它是一个只有三种实用模式的小枚举。用好它其实只在于两件事:弄清楚 auto 模式替你做了哪些决定,以及知道什么时候该自己接管。

三种模式

#![allow(unused)]
fn main() {
pub enum Parallelism {
    Serial,       // single-threaded
    Rayon(usize), // rayon with at most n threads; Rayon(0) auto-detects
}
}

Serial 把整次调用跑在调用线程上。Rayon(n) 请求至多 n 个工作线程。Rayon(0) 是自动模式,也是 Default,因此 Parallelism::default() 就是自动模式。Rayon(n) 里的 n 是分区数的上限,不是把它们全部用满的承诺。工作量不足 n 个块、或核心数少于 n 的问题,会分得更少。

自动模式究竟做了什么

自动模式不等于“用满所有核心”,而是依据问题规模做出两个决定。

首先是一道工作量闸门。当 m*n*k 总工作量低于某个阈值(GEMMKIT_PARALLEL_THRESHOLD 旋钮,默认 48*48*256)时,调用无论如何都保持串行。在这么小的矩阵上,fork/join 的开销会淹没任何收益。这道闸门先于其余一切执行,因此哪怕显式传入 Rayon(n) 也逃不过它:低于闸门时,Rayon(8) 仍然跑在单线程上。

越过闸门后,自动模式会让工作线程数随总工作量扩展,而不是直接跳到满核心数。它以 m*n*k 除以 GEMMKIT_PAR_MNK_PER_WORKER(默认 2_000_000,即每这么多工作量配一个工作线程)为目标。然后它用机器的核心数和可用作业块数给结果封顶,下限为一。这个数量按工作量而非维度计算,因为最优工作线程数跟随的是总 flops,而不是线性尺寸,线性维度上的任何单一步长都覆盖不了这么大的跨度。小乘积只用少数几个工作线程,大乘积则用很多个。把 GEMMKIT_PAR_MNK_PER_WORKER 设为 0(其行为等同于 1)会让任何越过串行闸门的问题都强制用满全部宽度。

显式数量

n > 0Rayon(n) 会绕开爬升启发式,直接请求恰好 n 个分区。出于安全考虑,这仍然会被机器的核心数(available_parallelism)和问题实际切分出的作业块数封顶。因此在一台 16 核机器上计算一个小乘积时,Rayon(1000) 不会导致过度订阅,它会坍缩到机器与工作量所能吸收的程度。正是这种精确性,让测试套件与扩展诊断都使用显式数量:Rayon(4) 在有那么多工作量、那么多核心时会给你四路分区,而不是一个启发式猜测。当你已经测量过自己的工作负载、知道甜点区在哪里时,就用显式数量。想要跨运行可复现的分区以做基准测试时,也用它。

gemmkit 如何使用 rayon 池

gemmkit 不需要你交给它一个 rayon 池。如果你把一次调用包在自己那个池的 install 里,这次 GEMM 的工作线程就会跑在那个池上,不会去别处。

#![allow(unused)]
fn main() {
let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap();
pool.install(|| {
    gemm(1.0, a, b, 0.0, c, Parallelism::Rayon(0)); // runs on `pool`
});
}

gemmkit 选择的工作线程数量仍然受 available_parallelism(整台机器)约束。rayon 的工作窃取调度器会把这些分区分布到当前池所拥有的线程上,所以一个更小的自定义池只是把同样的分区跑在更少的线程上。一次调用内部的工作分配是按需驱动的:工作线程从一个共享的无锁游标上领取连续的块。在一个异构部件上(比如混有 P 核与 E 核的布局),更快的核心会比别的核心多领一些块,而不是所有人都等最慢的那个。

如果一次调用没有跑在你自己安装的池里,gemmkit 会转而使用它自己的某个池。这一行为在原生目标上默认开启,也就是 x86_64 与 aarch64。gemmkit 最多会保留 GEMMKIT_POOL_CLASSES(x86_64 默认 2,aarch64 默认 1)个私有的、持久化的池,每一个都恰好卡在机器宽度的某个减半档位上。在一台 32 线程的机器上,那就是 16 线程和 8 线程两档。在一台 14 核的 M4 Max 上,则是单独一个 7 宽的档位。每个池都在首次用到时才惰性构建,此后不再重建。

自动模式会把它的工作线程数精确卡在这些档位之一,而不是去 fork 那个满宽度的全局池。一次 fork 的开销跟的是池的空闲余量:也就是池所拥有、却没有实际参与工作的那些线程。一个小规模的 GEMM 一旦落进满宽度的池,就会被这份余量拖累,所以把池的尺寸卡准工作量能避免这个拖累。

以上这些都不会改变你已经知道的东西。一次 install 过的调用依然被完全尊重,绝不会被改道到某个档位池。显式的 Rayon(n) 仍然恰好拿到 n 个工作线程,只是会被路由进能容纳它的最小档位池。真正会变的是闲置内存:默认情况下,一个 x86_64 进程现在会额外常驻大约 24 个线程(16 宽与 8 宽这两个档位池),一个 aarch64 M4 Max 进程会常驻 7 个(它唯一的半宽档位)。这些线程都在休眠,直到某个小规模 GEMM 需要它们。把 GEMMKIT_POOL_CLASSES 设为 0 即可彻底关闭档位池,此后每次调用都回落到环境池。

线程化 wasm 的情况有所不同:那里 gemmkit 总是会为自己定制一个专用池。这种情况见no_std 与 WebAssembly

精确表述可复现承诺

对固定的输入、环境与配置,输出与工作线程数无关地保持一致。这就是这份契约。

它成立有两个原因。第一,kcnc 与固定的深度面板顺序,是唯一塑造每个输出元素求和方式的东西,而 gemmkit 计算这三者时与将由多少线程来运行它们无关。第二,每个输出元素都由单个工作线程沿完整的收缩深度从头归约到尾,因此不存在任何归约拆分,其顺序也就谈不上依赖调度。

扁平作业列表本身在不同工作线程数下并非严格一致:更宽的工作线程数可能会缩小 mc,以保持列表足够深。但 mc 始终是 mr 的倍数,所以微 tile 的集合及其数值都不变。打包出的字节同样不取决于是谁打包的。哪个工作线程计算某个 tile 会因运行而异,但数值结果不会。

被承诺的是 SerialRayon(n) 之间的逐位相同。这一点今天在驱动路径上恰好成立,因为串行与并行跑的是同一个内核。但你应当依据的保证是固定配置下的可复现性,而不是串行对并行的逐位相等。你在这里得不到跨机器或跨配置的逐位相等:浮点 GEMM 对顺序敏感,而配置(ISA、分块、线程上限)正是固定输入的一部分。整数 gemm_i8 是例外:它跨 ISA、跨工作线程数逐位相同,因为 i32 加法与顺序无关。

何时该选 Serial

在三种情形下伸手去用 Serial

  1. 小问题。 低于工作量闸门时,自动模式本来就是串行的。显式传入 Serial 还会彻底跳过 available_parallelism 探测和 fork 机制,这在一串微小 GEMM 的紧循环里更省成本。
  2. 当外层并行由你掌控时。 假设你已经在一个 rayon 池上运行许多独立的 GEMM,或者自己在并行化一个批处理循环。不要再让每次内层调用也跟着扇出,那会过度订阅机器,通常还会拖累性能,而不是帮上忙。让内层调用跑 Serial,把并行度留在外层。对一批乘积,优先用内置的批量 GEMM 入口,它把整批当作一个整体来调度。
  3. 对确定性敏感的调试。 用单线程路径排除掉调度这个变量。

带宽受限的形状有自己的策略

矩阵-向量乘(m == 1n == 1)以及其他内存受限的形状不是计算受限的,所以上面那套按工作量算的工作线程数对它们是错的模型,因此这些路径用一条独立的规则。

在一个由缓存导出的字节下限以下,矩阵装得进单核的私有缓存。该核已经独自打满了缓存,所以拆分只会增加争用,这个路径也就保持串行。越过这个下限后,路径会直接跨到一个按触碰字节选出的宽度。这个宽度的顶端是逻辑核心数的一半,因为 gemv 远在最后一个核心加入之前就已经打满了自身带宽。

这个宽度是一级一级往上跳的,不是平滑增长:每一级都是上文那些精确适配的线程池之一,所以带宽受限的调用也能拿到一个尺寸刚好合适的池。少数几个工作线程是带宽扩展曲线上最糟的一点,所以这个策略会直接跳过那一点,而不是从中爬过去。

整套策略都是自动的。字节下限、级间距,以及一个固定宽度的覆盖值都可以通过 GEMMKIT_GEMV_PARALLEL_BYTESGEMMKIT_GEMV_TIER_STEPGEMMKIT_GEMV_THREAD_CAP 来调。完整论述见小形状与 GEMV

接下来读什么