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

调优旋钮

gemmkit 里的每一个启发式都是一个带随库默认值的具名阈值,而不是写死的常量。这涵盖了何时并行、何时打包某个操作数、一个形状从哪里起不再算“小”这类问题。少数几个旋钮按架构做了拆分,aarch64 需要和其余架构不同的值。这些默认值在大多数硬件上都表现良好。当某个默认值不适合你的机器时,你有三种途径能够到它,都不需要改动源码。

解析顺序

一个旋钮在被读取的那一刻解析,取以下第一个已设置的层次:

  1. 每次调用的参数。 凡是有调用点等价物的旋钮,它直接胜出。最清楚的例子是并行度:你传给 gemmParallelism 参数会覆盖任何全局线程策略。这一层位于 API 里,不在 tuning 里。
  2. 编程式 setter。 gemmkit::tuning::set_*(v) 会无条件地存下一个值。一旦设置,之后的读取就再也不查环境变量。这是给那种在代码里自我调优的应用准备的,它理应压过部署环境所提供的任何东西。
  3. 环境变量。 GEMMKIT_*。这是部署层。source 一份配置,比如 gemmkit-tune 生成的那种,就能为某台主机重调一个已经构建好的二进制,不用重新编译。
  4. 编译期默认值。 那个标定好的常量,需要时按架构拆分。

setter 压过环境变量是有意为之的。调用了 setter 的应用,就是选择不理会环境变量。想让部署配置生效的应用,只要不去调那些 setter 即可。

环境变量只在对该旋钮的首次访问时读取一次,随后缓存为一个原子量。在某个旋钮首次被读之后再设置的值会被忽略,所以要在进程启动之前就导出配置。一个已设置但无法解析为非负整数的 GEMMKIT_* 变量会被当作拼写错误,而不是无声的空操作。gemmkit 会在 stderr 上告警,并回退到默认值。由于回退结果随后会被缓存,这个告警每个旋钮只会触发一次。它绝不会 panic:一个性能旋钮的拼写错误不应该让进程崩溃。

旋钮清单

下表是覆盖所有 feature 与目标配置的完整清单。内部的 tuning::knob_env_names 注册表是这份清单的唯一真实来源。其中两个旋钮受 feature 或目标门控,只有编译进来时才存在。每个 getter 都有对应的 set_*。环境变量名就是 getter 名的大写形式,加上 GEMMKIT_ 前缀。

串行 / 并行门槛

环境变量Setter默认值控制什么
GEMMKIT_PARALLEL_THRESHOLDset_parallel_threshold4848256m*n*k 低于此值时,工作会被强制放到单线程上。这是串行到并行的盈亏平衡点。如果你的线程池 fork 代价高,就调高它。如果你的线程廉价,且有值得拆分的小乘积,就调低它。

打包门槛与步长

环境变量Setter默认值控制什么
GEMMKIT_RHS_PACK_THRESHOLDset_rhs_pack_threshold2048只有当 m(有多少行块会复用它)超过此值时,才打包 RHS 宏面板。低于它时,B 就地读取。
GEMMKIT_LHS_PACK_THRESHOLDset_lhs_pack_threshold1024(aarch64:256)只有当每个工作线程的列复用超过此值时,才打包 LHS。打包在 aarch64 上更便宜,所以那里从更低的复用起就划算。
GEMMKIT_LHS_PACK_STRIDEset_lhs_pack_stride0(自动)这是加在列主序深度步长 csa * sizeof(Lhs) 上的字节门槛。一旦步长达到这么多字节,A 就会被打包,以避开对 TLB 和缓存都不友好的跨步读取,这与复用无关。0 表示从操作系统页大小推导。这道门槛与下面的跨度门槛、复用门槛取“与”:只有步长、跨度、复用三者同时成立,强制打包才会触发。
GEMMKIT_LHS_PACK_SPANset_lhs_pack_span0(自动)这是上面那道步长门槛的地址跨度搭档。只有当整条深度切片的遍历(csa * sizeof(Lhs) * kc)也达到这么多字节时,页级步长才会强制打包列主序的 A。在这个跨度以下,遍历仍驻留在缓存里,只是重读温热的缓存行,所以就地读取比它本要付的打包更快。0 表示自动(4 MiB)。
GEMMKIT_LHS_PACK_REUSEset_lhs_pack_reuse128(aarch64:4)这是一道复用下限,为强制打包的收益定价,而不是为它的成本定价。上面的步长门槛与跨度门槛,只有在超过这道复用下限时才会触发。这道下限以复用每个打包面板的 nr 宽列 tile 数来衡量(min(n, nc) / nr,向上取整)。一个瘦高形状(m 远大于 n)跨度巨大,但列 tile 很少,把一次昂贵的打包摊到过少的复用上并不划算,这道下限就是用来拦住这种情况的。0 会去掉这道下限,只让步长加跨度这一对来决定。在 aarch64 上,这笔账几乎反过来了:打包在那里很便宜,而就地遍历要跨很小的页。所以 aarch64 的默认值从远低得多的复用量起就开始打包。
GEMMKIT_SHARED_LHS_MNKset_shared_lhs_mnk8e9(aarch64:6e6,32 位:禁用)这是并行打包路径上共享 A 预处理的 m*n*k 门槛。这道预处理会消除各工作线程的冗余打包,代价是一次 fork-join 屏障。这个临界点权衡的是屏障的代价与它省下的打包开销。打包在 aarch64 上代价相对更低,所以那里的门槛远低于 x86。与这道门槛无关,预处理还会在工作线程数达到 16 起开启,因为在那个宽度上,各自打包的冗余代价必定压过屏障。
GEMMKIT_PACK_TRANSPOSE_TILEset_pack_transpose_tile16这是打包某个跨步操作数时所用的缓存分块转置的条带长度,把逐元素的 gather 变成分块拷贝。实数与复数打包器都靠它支撑。

特殊路径阈值

环境变量Setter默认值控制什么
GEMMKIT_GEMV_THRESHOLDset_gemv_threshold无上限当另一维为 1 时,为专用 gemv 路径的 min(m, n) 设上限。触发 gemv 的是形状而不是规模,这个旋钮只是给它封顶。
GEMMKIT_SMALL_K_THRESHOLDset_small_k_threshold16(aarch64:8)k 在此值及以下时,形状会走通用的 small-k 路线(单个深度面板,不打包),而不是寄存器分块驱动器。
GEMMKIT_SMALL_MN_DIMset_small_mn_dim16(aarch64:32)mn 都在此值及以下(且 k 较长)时,会走横向内积路线,每个输出都是一次 SIMD 归约点积。0 会关闭这条路线。寄存器分块驱动器则会把小的行、列 tile 填充到一个完整的微 tile,把大量工作花在这些填充上。驱动器开始占优的那个点因机器而异,这就是为什么 aarch64 的上限高于 x86。
GEMMKIT_SMALL_MN_PACK_MIN_Kset_small_mn_pack_min_k16这是 small-m,n 打包层的 k 门槛:只有当 k 超过此值时,一个跨步的小形状才会把不满足条件的操作数拷贝进 k 连续的暂存区。
GEMMKIT_GEMV_PARALLEL_BYTESset_gemv_parallel_bytes0(自动)这是带宽受限的 gemv/gevv 保持单线程的字节下限。低于它时,矩阵装得进单核的私有缓存,该核已经独自打满了它,拆分只会白白吃亏。0 表示从探测到的缓存推导:在有 L3 的部件上,取单核私有 L2;在没有 L3 的 aarch64 部件上,取共享簇 L2 的八分之一。
GEMMKIT_GEMV_TIER_STEPset_gemv_tier_step0(自动)这是自动 gemv/gevv 工作线程阶梯各级之间的字节间距。从上面那道字节下限起,触碰字节每增长这么多倍,宽度就往上跳一个精确适配的池档位。0 表示取 8。1 会把整道阶梯压缩到它的顶档。活跃池档位少于 2 个时,这个旋钮不起作用,所以在单档位的 aarch64 默认设置下它什么也不做。
GEMMKIT_GEMV_AXPY_PAR_MIN_ROWSset_gemv_axpy_par_min_rows16384(x86)、1024(aarch64)这是输出行数的下限,低于它时,列主序 gemv 会保持串行,不再拆分它的行。对列主序矩阵而言,输出行这个轴是内层内存轴。一旦拆分,就会让每个工作线程都要跨步走遍整个矩阵,而串行路径只需一趟顺序扫描。只有当每个工作线程分到的那段跑得足够长时,拆分才划算,才配得上它放弃的那种连续性。0 会关闭这道下限。两个默认值相差一个数量级,因为 aarch64 上的临界点低得多。在该平台上,临界点跟随的是单列的字节数而非行数,所以以 f64 为主的负载应取默认值的一半。行主序 gemv 与 half 混合孪生版本永远不受此限制:两者在任何尺寸下拆分都能很好地扩展。
GEMMKIT_GEMV_THREAD_CAPset_gemv_thread_cap0(自动)这是带宽受限的 gemv/gevv 的一个固定工作线程数,直接取代上面那道阶梯。它就是“我实测过这台机器,就用这个数”的覆盖开关。0 会保留那道阶梯,它的顶端是逻辑核心数的一半,因为 gemv 远在用满全部宽度之前就已经打满了自身带宽。
GEMMKIT_K_STREAM_MAXset_k_stream_max32这是 k 的上限,axpy 形状的 gemv 在整个深度扫描过程中,会把输出面板一直留在寄存器里,直到超过这个上限。超过之后,普通的列外层形式更快。
GEMMKIT_SEQ_INTERNAL_BYTES_PER_WORKERset_seq_internal_bytes_per_worker128 KiB这是 aarch64 批量 GEMM 的一个临界点:一旦某个批元素分摊到每个工作线程的字节份额超过此值,它就会跨机器拆分,而不是一线程一个、缓存热运行。只在 aarch64 上被查询。
GEMMKIT_I8_VNNI_MIN_PAR_MNKset_i8_vnni_min_par_mnk768^3m*n*k 低于此值时,自动选中的 VNNI i8 内核会转而把一个多线程问题交给加宽兜底。VNNI 强制要求的 RHS 打包屏障,在一个小规模的并行问题上并不划算。它与 VNNI 逐位一致。需要 int8 feature。

调度粒度

环境变量Setter默认值控制什么
GEMMKIT_PARALLEL_OVERSAMPLEset_parallel_oversample8并行驱动器的目标,是让每个工作线程分到这么多个工作块,从一个共享游标上按需领取。调得更高,负载均衡更细、尾部更小,代价是原子领取次数更多。调得更低则更粗糙,但开销更小。
GEMMKIT_PAR_MNK_PER_WORKERset_par_mnk_per_worker2000000(线程化 wasm:262144)这是自动工作线程数的粒度。自动路径以 m*n*k 除以每个工作线程这么多工作量为目标,再用核心数与作业数给结果封顶,下限为 1。这样一来,数量就随总 flops 扩展,而不是随线性尺寸扩展。一个 wasm 工作线程的启用成本远低于一个原生线程,因此 wasm 的下限更低。0 的行为等同于 1(永远用满全部宽度)。
GEMMKIT_PACKED_OVERSAMPLEset_packed_oversample2这是打包 LHS 路径自己的拆分目标,和上面那个通用粒度不是一回事。拆得更狠,会让 A 被重新打包得太频繁,反而拖累性能,所以这个最优值更低。
GEMMKIT_POOL_CLASSESset_pool_classes2(aarch64:1,其余:0)这是满机器宽度以下的减半档位数:先是一半,再是四分之一。对每个活跃档位,gemmkit 都会保留一个私有的、持久化的 rayon 池,首次用到时才惰性构建,此后不再重建。自动路径会把它的工作线程数精确卡在某个档位上,让 fork/join 屏障处没有线程闲置。显式的 Rayon(n) 仍然恰好拿到 n 个工作线程,只是会跑在能容纳它的最小档位池里。0 会关闭档位池,让每次调用都留在环境池上。上限为 3。x86_64 默认 2 档,aarch64 默认 1 档,其余每个目标默认 0(关闭)。
GEMMKIT_FULL_WIDTH_MNKset_full_width_mnk0(自动)m*n*k 高于此值时,自动路径才会离开它最大的那个档位池,转向全部机器宽度。低于它时,即便存在更多核心,自动模式也会停留在它最大的档位上,因为这时那些额外的全宽工作线程,还赚不回它们额外付出的 fork/join 代价。0 表示按架构推导:x86 上是 110_000_000,aarch64 上是 14_000_000,在 aarch64 上,包含 E 核在内的全部宽度,在更小的问题规模下就已经划算。MAX 会让自动路径无条件钉死在最大档位上,全部宽度就永远不会介入。

分块上限

环境变量Setter默认值控制什么
GEMMKIT_MC_REG_PANELSset_mc_reg_panels8A 的宏面板被限制为这么多个微 tile 行(this * MR),遵循 BLIS 的规则:MC 始终是 MR 的一个小倍数。
GEMMKIT_NC_NO_L3_PANELSset_nc_no_l3_panels512没有 L3 时(比如 Apple Silicon 这类部件),列块是 min(this * NR, N)。存在 L3 时,这个旋钮不起作用。
GEMMKIT_TINY_BLOCK_DIMset_tiny_block_dim64mn 都在此值及以下的形状,会跳过完整的 BLIS 分块模型,只是把 A、B 面板留在 L2 里。
GEMMKIT_KCset_kc2048(aarch64:16384)这是微小矩阵捷径里的深度块:k 会被钳到此值。该计数以 4 字节元素为单位,更窄的元素会去除它,从而保住打包后的面板字节数。在 x86 上更宽的元素同样会去除它。在 aarch64 上更宽的元素保留完整深度,因为在那里做除法会让切片数成倍增加,而每个切片都要多付一次 worker fork。另外在 aarch64 上,更深的切片能一直领先到比 x86 更远,所以 aarch64 上的这条捷径几乎是单切片运行。
GEMMKIT_KC_MINset_kc_min512这是主模型的 kc 下限:那个贴合 L1 的深度估计值会被抬高到至少这个数,好让一个很小的 L1 缓存也不会让微内核的深度扫描挨饿。

深收缩与 wasm

环境变量Setter默认值控制什么
GEMMKIT_DEEP_KC_BYTESset_deep_kc_bytes0(自动)这是深收缩路径的启用门槛,以字节计。一个窄输出家族(f16/bf16)通常会把整个收缩当作单个深度面板来跑。一旦它的 RHS 微面板(nr * k * sizeof(N))超出此值,它就会切到一个 f32 输出的多切片孪生版本上。0 表示从探测到的 L2 的一半推导。
GEMMKIT_PREFETCH_MIN_BYTESset_prefetch_min_bytes0(自动)这是驱动器 C tile 软件预取的启用门槛,以字节计。一旦一次调用的工作集(A + B + C 字节)超过此值,输出微 tile 就会从 LLC 之外流入。驱动器接着会在每个微 tile 对应的微内核调用之前,为它发出一次 T0 预取,掩盖读-改-写的延迟。低于此值时,tile 都驻留在缓存里,这个提示反而纯属开销。0 表示从单核可达的 LLC 推导(有 L3 就取 L3,否则取 L2)。非零值就是字节门槛本身,所以 usize::MAX 会关闭预取,1 会强制开启它。这个旋钮只在 x86_64 上生效,在其他目标上是空操作,所以 aarch64 与 wasm 都不受影响。它对数值也不可见:开或关都逐位一致。
GEMMKIT_WASM_THREADSset_wasm_threads8这是带线程的 wasm 构建的工作线程数,因为 wasm 没有 available_parallelism 可查。它给 gemmkit 的 wasm rayon 池定尺寸。只在带 wasm_threads feature 的 wasm32 上存在。

关于 GEMMKIT_FAST_TEST

你可能会在测试框架里看到 GEMMKIT_FAST_TEST。它会把正确性扫描收窄,好让测试跑得更快,是一个仅测试套件使用的开关。库本身从不读取它,设置它对生产环境中的 GEMM 没有任何影响。

超越手工调参

手动设置旋钮,适用于你已经知道该动哪一个的场合。要为某台具体机器标定整套旋钮,就运行自动调优器。它会在一组探测形状上扫描每个旋钮,并写出一份 GEMMKIT_* 配置,你在运行前 source 它即可,不用重新编译。这是 gemmkit-tune 一章的主题。