【RustyML入门】7.3. 性能调优与并行

7.3. 性能调优与并行

RustyML 的并行只依赖 rayon,不依赖别的:既不链接 BLAS,也不用 OpenMP。crate 里的每个内核都跑在 rayon 的全局线程池上,只有一个例外。

这个例外就是矩阵乘积后端 gemmkit。gemmkit 常驻着少数几个私有 rayon 线程池,每个的宽度都是机器宽度的某个分数。它会装上仍然装得下这次乘积所需全部 worker 的那个最小档位,好让 fork-join 步骤看不到任何空闲余量。

这些依然是 rayon 池,只是构建一次、之后热复用。gemmkit 发现自己已经跑在别的 rayon 池上时,会绕开自己的这些池;当没有哪个档位够宽时,它会退回到环境全局池。

所以"只靠 rayon"这句话依然成立,"没有自建线程池"这句话则不成立。每一个能铺到多核上的热循环都铺开了,但没有一个会不经检查就去 fork rayon。

每个并行内核背后都守着一道阈值门 :把一份工作量估计拿去和校准好的阈值比较。小输入始终留在单核上跑。tuning 模块在运行期挪动这些阈值门。随包发布的默认值可能不适合你的 CPU。不靠猜就能重新校准它们。

如果你熟悉 scikit-learn 的 n_jobs 或者 Keras 的 intra_op_parallelism_threads,要留意这里的模型不太一样:RustyML 没有一个总开关,因为"串行更快"和"rayon 更快"之间的临界点,会随内核、随元素位宽而变。

7.3.1. 并行模型:阈值门背后的 rayon

rayon 不是免费的。把一个任务交给线程池,要付出一次 fork、一次 join,归约还要搭上一些收集的开销。对一次大矩阵乘积,或者上百万元素的 exp 映射,这点开销淹没在计算量里根本看不见。但对 1 个 LSTM 时间步里的 32x32 GEMM,或者几千个激活值上的 ReLU,fork/join 步骤本身就是全部运行时间,这时并行版反而输给一个跑在内存带宽上的单核。

所以每个带门的内核都先估计自己的工作量:卷积算 FLOP,映射和归约数元素个数,树遍历数节点访问次数,池化数窗口抽头(window tap)数。只有这个估计越过按临界点校准的阈值,内核才会去用 rayon。

矩阵乘积本身,RustyML 并不自己把门:那里的调度完全属于 gemmkit6.2. 矩阵乘法 讲了这个后端,以及本 crate 给它添的那一点点东西。gemmkit 决策的形状和 RustyML 自己那些门不一样。

首先是一道工作量门 parallel_threshold,默认值 48 * 48 * 256 = 589_824。它比较的是 m * n * k 这个乘积,不是 FLOP 数,所以没有那个 2 倍系数要记。低于这道门,无论调用方要了多少 worker,乘积都在单线程上跑。

越过这道门之后,worker 数是随总工作量、而不是随某一个维度往上爬的:gemmkit 每 par_mnk_per_worker(默认 2,000,000)份 m * n * k 配一个 worker,下限为 1,上限由核心数和任务数共同封顶。具体的临界点因机器而异:可以通过 tuning::matmul::backend 查看当前值,也可以用 gemmkit-tune 自动调参器在你自己的机器上实测(见 7.3.5)。

爬出来的这个宽度,随后会被吸附到前面说的某个常驻池档位上。rayon 的 fork/join 税是随池子的空转余量------池宽减去真正在干活的 worker 数------增长的,所以一个 8 worker 的乘积跑在恰好 8 宽的池子里,会大幅胜过同一个乘积跑在 32 宽的全局池里。

matvec 形状(m == 1n == 1)会离开这条路径,走一条专门的、受带宽限制的路:低于一个由机器 L2 缓存大小推算出来的字节下限时保持串行,越过之后再按一个按内存带宽定的 worker 上限切分。

因为 RustyML 自己的内核跑的都是全局 rayon 线程池,嵌套时它们能安全组合。一个并行的外层循环调进一道带门的卷积或归约,不会让机器超额订阅:内层工作会嵌进同一个池子,而不是再掀起一波线程。

矩阵乘积从外面看行为也一样:gemmkit 会检查自己是不是已经跑在某个 rayon worker 上,如果是,就留在调用方的池子里,而不去启用私有档位。这也是为什么 crate 的内部调用点,在已经身处并行区域时,可以强制某个乘积走串行(Parallelism::Serial):这么选是为了不把 rayon fork 两遍,跟正确性无关。

7.3.2. 阈值门改变什么,又绝不改变什么

下面这条来自 tuning 模块文档的契约,让调参变得安全:

阈值门只挑选执行策略,绝不改变代码算出来的是什么。elementwise 门和归约门无论走串行还是并行都给出相同结果。矩阵乘积的调度住在 gemmkit 后端里(见 matmul 子模块)。在同一台机器、同一套配置下,它的结果与 worker 数无关地可复现。留在这里的 matmul 门只塑造调用方一侧的分块。重新调门不会改变任何结果。

这一段话里藏着两条不同的保证。elementwise 映射(ReLU、sigmoid、缩放、归一化)是易并行的:每个输出元素彼此独立,串行和并行给出逐位相同的结果,挪动门连一个 bit 都动不了。

归约(平方和、Welford 矩、按全局范数裁剪)就微妙一些:浮点加法不满足结合律,朴素的 rayon sum 会按 work-stealing 的方式给部分和分组,每次运行的舍入都不一样。RustyML 用 crate::math::reduction 里的确定性分块折叠绕开了这个问题:输入被切成固定大小的块,分组只取决于编译期的块大小,从不取决于线程数或阈值门。

于是归约门以上,并行路径依然与串行结果吻合,挪动门也永远不会改变它。

矩阵乘积从前给的承诺弱一些:老的手写切行会让块高随线程数变化,从而改变求和顺序。现在不会了。gemmkit 的分块和任务顺序都与 worker 数无关,所以在同一台机器、同一套配置下,同一个乘积无论由多少线程跑出来,都复现出逐位相同的结果,重复运行也是确定的。

crate 的测试验证了这一点,覆盖范围如下:f64 这边,把一个强制串行的乘积按 to_bits() 与同一乘积强制跑在 2、4、8、16、32 个 worker 上的结果相比,形状覆盖方阵、浅 k 和深 k 三种;f32 这边扫得窄一些,只有 2、4、32 三档、两种形状。

matvec 的检查更窄:只在一种形状上比较强制串行与自动调度,完全没有 worker 扫描。另有一个测试把融合了 bias+ReLU 尾算子的乘积,与"先做普通乘积、再做同一个标量映射"的结果逐位相比。

合起来看,这些测试是支持这条保证的有力证据,但不是对它的穷尽证明,而且这条保证本身属于后端,不是靠这些测试单独确立的。matmul 路径已经不再是薄弱环节:在同一台机器上,它和归约一样可复现。

需要交代的保留条件是那句"同一台机器、同一套配置":换一颗 CPU 会挑到不同的 SIMD 宽度,改一个后端旋钮也可能改变分块,所以跨机器的逐位一致仍然不作承诺。

6.3 里那些确定性归约在种类上依旧是更强的保证:它们是靠构造给出同样的答案,不依赖后端在两台机器上恰好按同样的方式分块。

落到实处的结论没变:**你可以随意重调任何一道门,模型的输出都不会挪动。**门是性能旋钮,绝不是数值旋钮。如果你追求的是跨机器的精确可复现,那属于 7.1. 可复现性与随机种子 的范畴,不在这里。

7.3.3. tuning 模块:运行期覆盖入口

每道门都是一个进程全局的 AtomicUsize,初始化为它校准好的默认值。tuning 模块是让每道门都便于查阅的门面:它为每道门暴露一个 set_*(usize) 函数和一个 get_*() -> usize 函数,按内核族分进各个子模块。setter 和 getter 都是朴素函数。像这样就能读出你这份构建随包附带的默认值:

rust 复制代码
use rustyml::tuning;

fn main() {
    // RustyML 自己的 matmul 旋钮:调用方一侧的分块策略,仅此而已。
    println!("chunk elems:   {}", tuning::matmul::get_chunk_elems());
    println!("cache bytes:   {}", tuning::matmul::get_cache_resident_max_bytes());

    // 乘积的调度旋钮属于后端,经由别名去够。
    println!("mnk gate:      {}", tuning::matmul::backend::parallel_threshold());
    println!("mnk/worker:    {}", tuning::matmul::backend::par_mnk_per_worker());
    println!("pool tiers:    {}", tuning::matmul::backend::pool_classes());

    // elementwise 映射与确定性归约(元素个数)。
    println!("exp map f32:   {}", tuning::elementwise::get_exp_map_f32());
    println!("cheap map f64: {}", tuning::elementwise::get_cheap_map_f64());
    println!("sum f64:       {}", tuning::reduction::get_sum_f64());
    println!("exp reduce:    {}", tuning::reduction::get_exp_reduce());

    // 树遍历、conv/pool 引擎、归一化、指标。
    println!("tree visits:   {}", tuning::tree::get_traversal_min_visits());
    println!("conv flops:    {}", tuning::conv::get_parallel_min_flops());
    println!("pool ops:      {}", tuning::pool::get_parallel_min_ops());
    println!("gn param grad: {}", tuning::norm::get_gn_param_grad());
    println!("silhouette:    {}", tuning::metrics::get_silhouette());
}

matmul 子模块的形状不太一样:它自己有两道门,另外还有 backend,那是一句 pub use gemmkit_ndarray::tuning 的重新导出,于是每个 GEMMKIT_* 旋钮都有一对 set_*/getter 可以够到,而不必在你的 Cargo.toml 里加一条直接指向 gemmkit 的依赖。这个重导出特意经过适配器:这些旋钮是进程全局的原子量,在另一份单独解析出来的 gemmkit 上调 set_*,写到的会是适配器从不读取的那份副本。backend 下面的 getter 用的是光秃秃的名字,比如 parallel_threshold(),不是 get_ 前缀的风格:那是 gemmkit 自己的命名,不是 RustyML 门面的命名。

完整的入口一览,附随包默认值和每道门用来比较的单位:

tuning::matmul:: 默认值 门限依据
set_/get_chunk_elems 33_554_432 分块乘积中一个行块的元素预算(KNN、t-SNE、MeanShift)
set_/get_cache_resident_max_bytes 67_108_864 共享 L3 大小(字节),用于"逐行 GEMV 群"与"分块 GEMM"之间的取舍
backend::* 见 gemmkit 整个 GEMMKIT_* 接口的重新导出:乘积的工作量门、worker 爬坡、池档位、打包与分块旋钮
tuning::elementwise:: 默认值 门限依据
set_/get_cheap_map_f32 4_000_000 f32 受内存限制的映射(ReLU、dropout 掩码),元素个数
set_/get_exp_map_f32 131_072 f32 以 exp 为主的映射(sigmoid、tanh、softmax)
set_/get_spatial_dropout_scale 4_194_304 spatial-dropout 的逐通道缩放
set_/get_fused_slice 1_000_000 融合的多切片优化器更新
set_/get_cheap_map_f64 4_000_000 f64 受内存限制的映射(中心化、缩放、归一化)
set_/get_exp_map_f64 65_536 f64 以 exp 为主的映射(logistic sigmoid、RBF/Sigmoid 核)
tuning::reduction:: 默认值 门限依据
set_/get_sq_sum_f32 65_536 f32 平方和(按全局范数裁剪),元素个数
set_/get_sum_f64 262_144 f64 求和类归约(平方和、Welford)
set_/get_scan_f64 262_144 f64 短行扫描(KMeans arg-min、LDA、DBSCAN/MeanShift 距离扫描),扫描的元素总数
set_/get_exp_reduce 32_768 logistic 损失的 exp 归约
tuning::tree:: 默认值 门限依据
set_/get_traversal_min_visits 262_144 DecisionTree/IsolationForest 预测,节点访问总数
set_/get_sort_scan_min_elems 8_192 DecisionTree 划分搜索,排序元素总数(node_samples * features
tuning::conv:: / tuning::pool:: 默认值 门限依据
conv::set_/get_parallel_min_flops 4_000_000 im2col+GEMM 卷积引擎,估计 FLOP
conv::set_/get_naive_parallel_min_flops 1_000_000 朴素 depthwise/separable 卷积,估计 FLOP
pool::set_/get_parallel_min_ops 12_000 池化引擎,估计元素运算数

归一化层在 tuning::norm:: 下又添了 7 道门:set_/get_batch_normset_/get_bn_col_statsset_/get_bn_plane_statsset_/get_ln_rowset_/get_ln_col_statsset_/get_gn_rowset_/get_gn_param_grad,这 7 道全都是 262_144。聚类指标再添一道 tuning::metrics::set_/get_silhouette,同样是 262_144。

某道门对应的 feature 没编进来,这道门就干脆不存在:tuning::convtuning::pooltuning::norm 需要 neural_networktuning::tree 需要 machine_learningtuning::metrics 需要 metricstuning::matmulreduction::exp_reduce 需要 math

elementwise 那几道门分成两半:f32 那一半归 neural_network,f64 那一半归 machine_learningutils。只编译你用得上的模块(见 1.2. 安装与Feature配置7.4. 按需裁剪与模块化集成),无关的旋钮自然就消失了。

这张清单之所以短,是有意为之:RustyML 拥有两个 matmul 旋钮和二十来道内核门,每一道都是一个工作量估计上的阈值。

这里再没有按 dtype 分的矩阵乘积门了:f32 和 f64 的 GEMM 从前在这儿各有各的临界点,现在没有了,因为后端是按 m * n * k 把门的,元素位宽是后端的事,不是你的事。如果某个矩阵乘积在你的机器上跑出了不对的并行度,去 backend 里找,不在这张表里。

7.3.4. 为什么随包默认值可能不适合你的机器

这里大多数默认值都是在维护者的硬件上量出来的。tuning 模块文档记录了这台机器:一颗 AMD Ryzen 9 9950X,16 核、32 线程、64 MiB L3。没在那上面量过的少数几个只会更糟,不会更好:cache_resident_max_bytes 是一个有依据的猜测,pool_parallel_min_ops 则是被刻意停在它实测区间之外的,原因见 7.3.5

串行/并行的临界点不是普适常数,而是一个比值:1 个核跑这个内核有多快,对上 rayon 加了多少 fork/join 开销。这两端都随机器变:核越多,固定问题分摊到每个核上的份额越小,于是并行这一侧需要更大的问题才能打平;单线程 SIMD 越快,串行基线越高;L3 越大,能常驻的矩阵越多,"分块 GEMM 对 GEMV 群"的取舍翻转点也随之移动。

最明显绑死在特定机器上的一道门是 cache_resident_max_bytes。源码里的说法是:把它设成机器实际的共享 L3 大小。默认的 64 MiB 只是对典型 L3 的一个猜测,它周围那一带还没有校准过。

后端的旋钮往下一层背着同样的保留条件,而 gemmkit 对此相当坦白:par_mnk_per_worker 默认 2,000,000,pool_classes 在 x86 上默认 2 档。这两个默认值都是在同一颗 Zen5 9950X 上标定的,32 个硬件线程、16 个物理核,这里的 2 档是 width/4(8 个 worker)和 width/2(16 个 worker,也就是物理核数)。

pool_classes 的 aarch64 那一支默认改成 1 档,是在一台 M4 Max(14 核,10 大 4 小,无 SMT)上量出来的,这里的 1 档是 width/2(7 个 worker)。

这是两台真实的机器,不是你那台机器的模型。凡是临界点依赖架构的旋钮,都带一个按 cfg(target_arch) 分支的默认值,每一支在各自的参考机器上标定;在任何第三种架构上,池档位默认关闭,等着在真机上验证。用 gemmkit-tune 自动调参器在你自己的机器上实测(见 7.3.5),再把结果套成一份 GEMMKIT_* 配置。

你的 CPU 可能和这两台参考机器差别很大,比如 8 核笔记本对 32 核工作站、L3 只有一半的机器、Apple 芯片的 NEON 目标对 AVX-512。这种情况下,默认值会落在大致正确的邻域里,但并非最优。

对大多数工作负载,这点差别很小:elementwise 门和归约门定得那么靠外,在常见规模下那些内核反正都走串行,后端那些旋钮只有在矩阵乘积主导你的运行时间时才要紧。等你量出确实如此,再去重新校准,不要只凭原则去调。

7.3.5. 重新校准:两套工具,然后才是 setter

重新校准也沿着旋钮的那条分界线一分为二,两半用的是不同的工具。矩阵乘积属于 gemmkit,所以用 gemmkit 的自动调参器重调;其余都属于 RustyML,所以用 RustyML 的校准基准测试重调。

要重调乘积,在目标机器上装上并跑一遍后端的扫参工具:

bash 复制代码
cargo install gemmkit-tune
gemmkit-tune

这个扫参工具在它所针对的机器上运行,吐出一份可以直接 sourceGEMMKIT_* 环境变量配置。这就是部署路径,而且是条好路径:环境变量能给一个已经编译好的二进制重新调参,不必重新编译,于是同一个产物可以给每台主机带上不同的配置。

程序里的等价手段是 tuning::matmul::backend::set_parallel_threshold(..) 及其同类,但用它之前要先看清优先级规则。一个旋钮的解析顺序是:单次调用的参数,然后是程序里的 set_*,然后是 GEMMKIT_* 变量,最后是编译期默认值。set_* 调用是无条件写入的,所以**一旦进程里有任何代码调用过 setter,对应的那个环境变量在这个进程剩下的生命周期里就形同废纸。**RustyML 从不替你调 setter,正是为了这个原因:那样做会悄悄盖掉你 source 进来的配置。

一个解析不了的 GEMMKIT_* 值也不会被默默忽略:第一次访问时会在 stderr 上警告一次,然后回退到默认值,绝不 panic。

至于其余部分,RustyML 随包附带了维护者用过的那套校准基准测试。它跑在 cargo bench 下、harness = false,直接把结果打到标准输出:

bash 复制代码
# elementwise / 归约 / 树 / conv / pool / 归一化的临界点。
# 打印这些表,并重写 benches/calibrations/RESULTS.md。
cargo bench --bench parallel_gates

它需要 machine_learningneural_network 两个 feature,子模块覆盖卷积引擎、池化、elementwise 与归约内核、树遍历,以及归一化层。对每一类内核,它把串行实现和并行实现在该类那道门的两侧各自强制跑一遍,走完一整梯子的形状,逐级报出加速比。

下面是随包 benches/calibrations/RESULTS.md 里的一段真实摘录,2026-07-26 在那台 32 rayon 线程的 9950X 上重新生成:

text 复制代码
## conv engine FLOPs gate (CONV_PARALLEL_MIN_FLOPS), batch == 1

| shape | work (FLOPs) | serial (us) | parallel (us) | speedup |
|---|---:|---:|---:|---:|
| conv 3c->8f 16px k3   |     84672 |   12.1 |  25.2 | 0.48x |
| conv 8c->16f 32px k3  |   2073600 |   83.2 |  82.3 | 1.01x |
| conv 16c->32f 32px k3 |   8294400 |  109.2 |  85.0 | 1.29x |
| conv 32c->64f 64px k3 | 141705216 | 1437.6 | 298.2 | 4.82x |

**Takeaway:** crossover between 2073600 and 8294400 FLOPs.

按那行 takeaway 的方式去读这张表:门的常量应该落在临界点上,并留一点朝串行一侧的安全余量。为了在中等规模上多赚 5%,却在小张量上付出 2 倍的代价,是笔糟糕的买卖,而这道梯子把这种不对称摆到了明面上:在 84,672 FLOP 处,并行路径的速度只有串行的一半;在 830 万 FLOP 处,它也只快了 1.29 倍。

这里有个陷阱。基准测试对每一级只报一个工作量数字,但一个内核的串行代价并不总是这个数字的函数。POOL_PARALLEL_MIN_OPS 被有意留在 12,000,尽管工具自己给它报出的区间是 25K 到 49K 个窗口抽头。

串行池化的速度强烈依赖通道数:窗口几何形状的开销可以摊到各个通道上。所以 25,088 抽头的 1x28x28x32 那一级串行只要 14.4 us,而更小 的 16,384 抽头的 64x16x16x1 那一级却要 76.7 us。光看抽头数预测不了串行代价。

照着那个区间去改,会把那个 16K 抽头的形状推回串行,白白丢掉一笔实测 45 us 的节省,只为了避开 12,288 抽头的 1x64x64x3 那种形状目前付的 7 us 亏损。**校准输出是证据,不是指令。**在按某个区间去挪一个常量之前,先看清你的负载究竟落在这道梯子的哪几级上。

在程序启动时、任何真实工作触及某道门之前,就把这些数值应用上:这些原子量是进程全局的,每个内核都实时读取它们:

rust 复制代码
use ndarray::{Array1, Array2};
use rustyml::machine_learning::LinearRegression;
use rustyml::tuning;

fn main() {
    // RustyML 自己的门,取自这台机器上一次 `parallel_gates` 的运行结果。
    tuning::matmul::set_cache_resident_max_bytes(32 * 1024 * 1024); // 这颗 CPU 真实的 L3
    tuning::conv::set_parallel_min_flops(6_000_000);
    tuning::pool::set_parallel_min_ops(30_000);
    tuning::reduction::set_scan_f64(131_072);

    // 后端的调度旋钮,仅当你并不打算随包发一份 GEMMKIT_* 配置时才这么设:
    // 一次 setter 调用会在整个进程剩下的时间里遮蔽掉对应的环境变量。
    tuning::matmul::backend::set_parallel_threshold(2_000_000);
    tuning::matmul::backend::set_par_mnk_per_worker(4_000_000);

    // 一次 store 就是一次朴素的原子写入;读回来确认它生效了。
    assert_eq!(tuning::conv::get_parallel_min_flops(), 6_000_000);
    assert_eq!(tuning::matmul::backend::parallel_threshold(), 2_000_000);

    // 同样的 API,同样的结果------变的只是串行/并行策略。
    let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
    let y = Array1::from_vec(vec![3.0, 5.0, 7.0, 9.0, 11.0]);
    let mut model = LinearRegression::new(true);
    model.fit(&x, &y).unwrap();
    let preds = model.predict(&x).unwrap();
    println!("prediction: {:.3}", preds[0]);
}

RustyML 自己 的那些门没有配置文件,也没有环境变量:覆盖 API 就是那里的全部机制,GEMMKIT_* 那套变量只够得着后端。setter 是全局的,在整个进程生命周期里一直有效,所以在 main(或一个由 OnceLock 守护的初始化)里调用一次就够,不必对每个模型都调一遍。

如果你想在动手之前,先看看并行在你的机器上究竟哪里划算,跑一遍 cargo bench --bench matmul_kernels 就知道了。

7.3.6. 掌控 rayon 本身

阈值门决定要不要 并行,rayon 决定铺多宽。RustyML 自己的内核用的是 rayon 的全局池,从不自建,所以 rayon 标准的那套控制手段对它们原样适用,例外是后端的私有档位池,往下数两段就会讲到。

最简单的控制手段是 RAYON_NUM_THREADS 环境变量,在线程池第一次被触及时读取:

bash 复制代码
RAYON_NUM_THREADS=8 ./my_program

想在代码里控制,就在任何 rustyml 调用触及线程池之前,把全局池构建一次。rustyml 没有重新导出 rayon,所以你得把它作为自己的依赖加进来(rayon = "1"):

rust,ignore 复制代码
fn main() {
    rayon::ThreadPoolBuilder::new()
        .num_threads(8)
        .build_global()
        .unwrap(); // 如果线程池已经初始化过,build_global 会失败

    // ...... 现在 rustyml 的调用都跑在一个 8 线程的池子上 ......
}

这里有一个相互作用最容易让人猝不及防。缩小环境线程池不会 让矩阵乘积跟着缩放。gemmkit 是从工作量算出自己的 worker 数的------m * n * k 除以 par_mnk_per_worker------再拿机器的核心数封顶;这个核心数它只从 std::thread::available_parallelism() 读一次,然后按进程缓存下来,从不去问 rayon::current_num_threads()

gemmkit 的池档位也是从这同一个缓存下来的机器宽度推出来的。所以在一台 32 线程的机器上设 RAYON_NUM_THREADS=8,并不会让 gemmkit 变窄:它依然往 32 上爬,依然吸附到 8 宽和 16 宽的档位上,在它自己的私有池里跑,完全不理会你那个 8 宽的全局池。

你的池子只在区间顶端才重新说得上话:一旦某次乘积大到想要全部 32 个 worker,就没有哪个档位装得下它,工作会退回到环境池,比如 32 份任务挤在你的 8 个线程上。想让乘积窄一点,该调的是 backend::set_par_mnk_per_worker(调高它,要求每个 worker 扛更多工作量),或者换一份 GEMMKIT_* 配置,不要去改 rayon 池的大小。

RustyML 自己那些门有一个镜像般的毛病:它们是固定数字,在某一个线程数下校准出来(RESULTS.md 里是 9950X 上的 32),而且它们压根不读线程池的任何信息,只是拿工作量估计跟一个常量比。

如果你把线程池砍半,并行开始占优的那个临界点实际上会上移:每个剩下的核如今扛的份额更大,fork/join 开销要更晚才摊得平,可 conv::parallel_min_flops 仍然守着它原来的值。门没有错,只是不再最优。

在你打算部署时用的线程池大小下跑 parallel_gates 重新校准,或者接受默认值假定的是校准机器的线程数。两边合起来落出的规矩是:线程池大小是一个资源上限,不是一个调优参数,改动它之后没有任何东西会自己跟着缩放。

要盯防的失败模式是超额订阅。RustyML 的内核都嵌进同一个全局池,而 gemmkit 一旦发现自己已经身处某个 rayon worker 上,就会有意留在调用方的池子里,不去启用某个档位。所以 rustyml 在 rayon 区域内的调用是安全的。

危险在于在那个池之外 再造出两个 并行来源:一个是 OS 线程池,一个是 std::thread::spawn 的扇出,还有一个是用 ThreadPoolBuilder::build() 而非 build_global 建出来的第二个 rayon 池。不管哪种情况,每个 worker 都各自调进 rustyml。此时每个 worker 的带门内核都各自去填满那个全局池,你就得到 threads * threads 的争用。

如果你已经在应用层做了并行,要么给 rustyml 一个更小的池,要么让外层扇出对每个条目保持串行,交给 rustyml 自己的门去铺开单个条目的工作。别把池子摞起来。

7.3.7. 为什么读门是免费的:relaxed 原子

热路径上每次读门都是一次 relaxed 原子加载,每次调 setter 都是一次 relaxed 存储:没有锁,没有屏障,没有争用。这是有意为之,正是它让运行期可调的门,比起它们取代的那些老编译期常量,几乎不多花什么代价。

理由来自生成它们的那个宏:门只挑选策略,从不改变结果,所以不需要更强的内存序。relaxed 加载没有任何要满足的顺序义务,在每种主流架构上它都编译成一次普通加载、不带屏障。于是一个在每次卷积前检查 flops >= conv_parallel_min_flops() 的内核,为这层间接几乎不付出任何代价。

Relaxed 的另一面是,一次 set_* 调用不会和正在飞行中的内核同步。假设你在一个线程上改门,而另一个线程正算到一半:无法保证它的哪些读取看到旧值、哪些看到新值。这没有害处,因为门只在串行和并行之间挑选,两条路径算出同样的结果;运行中途翻转,最坏也不过让某个内核选了个略微次优的策略,绝不会给出错误答案。

尽管如此,还是在启动时设置门,而不要在负载下改动它们,这样行为可预测,每个内核看到的都是同一套一致的策略。

7.3.8. 什么不该调,以及一套靠谱的流程

这些门是最后才该动的东西,不是第一个。碰它们之前,先端到端地测:给你真正在意的那个 fitpredict 或训练循环计时,找出时间花在哪。

十有八九,答案不是某道门设错了。更常见的是:一个 feature 构建带进了比你需要更多的模块,拖进了你从不调用的代码;或者是一条 f64 的流水线,换成 f32 就能把内存流量砍半;或者一个模型本可只拟合一次,却在循环里反复重拟合;又或者问题小到按设计就走串行,改任何门都够不着它。

elementwise 门和归约门定得尤其靠外:在寻常的预处理和层规模下,那些内核不管你怎么设都走串行。对一次 1 万行、20 个特征的标准化去挪 cheap_map_f64 毫无用处,因为那是 20 万个元素,仍比它 400 万的门低了一个数量级。

当性能剖析确实指向并行时,按这个顺序调。

第一,用 rayon::current_num_threads() 确认线程池是你以为的那个大小。一个设错的 RAYON_NUM_THREADS,或者一个意外冒出来的第二个池,会让任何门的影响都相形见绌。

第二,如果矩阵乘积占主导,就在部署机器上跑一遍 cargo install gemmkit-tune,把它吐出的 GEMMKIT_* 配置 source 进去。对大多数经典机器学习和稠密网络的活儿,这一步收益最大,而且不需要重新编译。除非你有理由把数值硬编进去,否则就用环境变量配置的形式设置这些值,而不要用 backend::set_* 调用:一次 setter 会永久遮蔽环境变量,把这个部署旋钮从跑这个二进制的人手里夺走。

第三,如果你依赖分块乘积那条路径(KNN、t-SNE、MeanShift),把 cache_resident_max_bytes 设成你 CPU 真实的共享 L3 大小。

先做完这 3 步。只有到这时候,再去看 parallel_gates 是否显示你的内核在跟随包默认值不同的规模上翻转。如果是,才去调 elementwise、归约、树、conv/pool 或归一化门。一次只改一道门,重新测那个端到端数字,只有当它真的有帮助才保留这次改动。重新调门从不改变你的结果,所以一次误判唯一的代价,就是花在调一道从来都不是瓶颈的门上的时间。

相关推荐
手握风云-1 小时前
Spring Cloud:分布式系统的“粘合剂”(六)
后端·spring·spring cloud
余额瞒着我当琳1 小时前
C++STL--list底层实现,迭代器分类,模拟list的迭代器封装、实现
java·开发语言·c++
小陈的进阶之路1 小时前
Claude Code辅助测试:API测试与pytest自动化
android·开发语言·kotlin
denggun123451 小时前
Python两套原生信号量与swift对比
开发语言·python·swift
苏灿烤鱼1 小时前
连庄+2,729,空降榜眼只+440
rust·openai·agent
kyle~1 小时前
计算机系统 --- 缓存一致性
开发语言·c++·缓存·计算机系统
BingoGo1 小时前
NativePHP v4 让 Blade 构建原生 iOS 与 Android 界面
后端·php
程序员爱钓鱼1 小时前
Go 编程实战:闭包 Closure——函数如何记住外部变量
后端·google·go
JaguarJack1 小时前
NativePHP v4 让 Blade 构建原生 iOS 与 Android 界面
后端·php·服务端