ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

【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. 并行模型阈值门背后的 rayonrayon 不是免费的。把一个任务交给线程池要付出一次 fork、一次 join归约还要搭上一些收集的开销。对一次大矩阵乘积或者上百万元素的exp映射这点开销淹没在计算量里根本看不见。但对 1 个 LSTM 时间步里的 32x32 GEMM或者几千个激活值上的 ReLUfork/join 步骤本身就是全部运行时间这时并行版反而输给一个跑在内存带宽上的单核。所以每个带门的内核都先估计自己的工作量卷积算 FLOP映射和归约数元素个数树遍历数节点访问次数池化数窗口抽头window tap数。只有这个估计越过按临界点校准的阈值内核才会去用 rayon。矩阵乘积本身RustyML 并不自己把门那里的调度完全属于 gemmkit。6.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 1或n 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 矩、按全局范数裁剪就微妙一些浮点加法不满足结合律朴素的 rayonsum会按 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 扫描。另有一个测试把融合了 biasReLU 尾算子的乘积与“先做普通乘积、再做同一个标量映射”的结果逐位相比。合起来看这些测试是支持这条保证的有力证据但不是对它的穷尽证明而且这条保证本身属于后端不是靠这些测试单独确立的。matmul 路径已经不再是薄弱环节在同一台机器上它和归约一样可复现。需要交代的保留条件是那句“同一台机器、同一套配置”换一颗 CPU 会挑到不同的 SIMD 宽度改一个后端旋钮也可能改变分块所以跨机器的逐位一致仍然不作承诺。6.3 里那些确定性归约在种类上依旧是更强的保证它们是靠构造给出同样的答案不依赖后端在两台机器上恰好按同样的方式分块。落到实处的结论没变**你可以随意重调任何一道门模型的输出都不会挪动。**门是性能旋钮绝不是数值旋钮。如果你追求的是跨机器的精确可复现那属于 7.1. 可复现性与随机种子 的范畴不在这里。7.3.3. tuning 模块运行期覆盖入口每道门都是一个进程全局的AtomicUsize初始化为它校准好的默认值。tuning模块是让每道门都便于查阅的门面它为每道门暴露一个set_*(usize)函数和一个get_*() - usize函数按内核族分进各个子模块。setter 和 getter 都是朴素函数。像这样就能读出你这份构建随包附带的默认值userustyml::tuning;fnmain(){// 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_elems33_554_432分块乘积中一个行块的元素预算KNN、t-SNE、MeanShiftset_/get_cache_resident_max_bytes67_108_864共享 L3 大小字节用于“逐行 GEMV 群”与“分块 GEMM”之间的取舍backend::*见 gemmkit整个GEMMKIT_*接口的重新导出乘积的工作量门、worker 爬坡、池档位、打包与分块旋钮tuning::elementwise::默认值门限依据set_/get_cheap_map_f324_000_000f32 受内存限制的映射ReLU、dropout 掩码元素个数set_/get_exp_map_f32131_072f32 以 exp 为主的映射sigmoid、tanh、softmaxset_/get_spatial_dropout_scale4_194_304spatial-dropout 的逐通道缩放set_/get_fused_slice1_000_000融合的多切片优化器更新set_/get_cheap_map_f644_000_000f64 受内存限制的映射中心化、缩放、归一化set_/get_exp_map_f6465_536f64 以 exp 为主的映射logistic sigmoid、RBF/Sigmoid 核tuning::reduction::默认值门限依据set_/get_sq_sum_f3265_536f32 平方和按全局范数裁剪元素个数set_/get_sum_f64262_144f64 求和类归约平方和、Welfordset_/get_scan_f64262_144f64 短行扫描KMeans arg-min、LDA、DBSCAN/MeanShift 距离扫描扫描的元素总数set_/get_exp_reduce32_768logistic 损失的 exp 归约tuning::tree::默认值门限依据set_/get_traversal_min_visits262_144DecisionTree/IsolationForest 预测节点访问总数set_/get_sort_scan_min_elems8_192DecisionTree 划分搜索排序元素总数node_samples * featurestuning::conv::/tuning::pool::默认值门限依据conv::set_/get_parallel_min_flops4_000_000im2colGEMM 卷积引擎估计 FLOPconv::set_/get_naive_parallel_min_flops1_000_000朴素 depthwise/separable 卷积估计 FLOPpool::set_/get_parallel_min_ops12_000池化引擎估计元素运算数归一化层在tuning::norm::下又添了 7 道门set_/get_batch_norm、set_/get_bn_col_stats、set_/get_bn_plane_stats、set_/get_ln_row、set_/get_ln_col_stats、set_/get_gn_row、set_/get_gn_param_grad这 7 道全都是 262_144。聚类指标再添一道tuning::metrics::set_/get_silhouette同样是 262_144。某道门对应的 feature 没编进来这道门就干脆不存在tuning::conv、tuning::pool和tuning::norm需要neural_networktuning::tree需要machine_learningtuning::metrics需要metricstuning::matmul和reduction::exp_reduce需要math。elementwise那几道门分成两半f32 那一半归neural_networkf64 那一半归machine_learning或utils。只编译你用得上的模块见 1.2. 安装与Feature配置 和 7.4. 按需裁剪与模块化集成无关的旋钮自然就消失了。这张清单之所以短是有意为之RustyML 拥有两个 matmul 旋钮和二十来道内核门每一道都是一个工作量估计上的阈值。这里再没有按 dtype 分的矩阵乘积门了f32 和 f64 的 GEMM 从前在这儿各有各的临界点现在没有了因为后端是按m * n * k把门的元素位宽是后端的事不是你的事。如果某个矩阵乘积在你的机器上跑出了不对的并行度去backend里找不在这张表里。7.3.4. 为什么随包默认值可能不适合你的机器这里大多数默认值都是在维护者的硬件上量出来的。tuning模块文档记录了这台机器一颗 AMD Ryzen 9 9950X16 核、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,000pool_classes在 x86 上默认 2 档。这两个默认值都是在同一颗 Zen5 9950X 上标定的32 个硬件线程、16 个物理核这里的 2 档是 width/48 个 worker和 width/216 个 worker也就是物理核数。pool_classes的 aarch64 那一支默认改成 1 档是在一台 M4 Max14 核10 大 4 小无 SMT上量出来的这里的 1 档是 width/27 个 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 的校准基准测试重调。要重调乘积在目标机器上装上并跑一遍后端的扫参工具cargoinstallgemmkit-tune gemmkit-tune这个扫参工具在它所针对的机器上运行吐出一份可以直接source的GEMMKIT_*环境变量配置。这就是部署路径而且是条好路径环境变量能给一个已经编译好的二进制重新调参不必重新编译于是同一个产物可以给每台主机带上不同的配置。程序里的等价手段是tuning::matmul::backend::set_parallel_threshold(..)及其同类但用它之前要先看清优先级规则。一个旋钮的解析顺序是单次调用的参数然后是程序里的set_*然后是GEMMKIT_*变量最后是编译期默认值。set_*调用是无条件写入的所以**一旦进程里有任何代码调用过 setter对应的那个环境变量在这个进程剩下的生命周期里就形同废纸。**RustyML 从不替你调 setter正是为了这个原因那样做会悄悄盖掉你 source 进来的配置。一个解析不了的GEMMKIT_*值也不会被默默忽略第一次访问时会在 stderr 上警告一次然后回退到默认值绝不 panic。至于其余部分RustyML 随包附带了维护者用过的那套校准基准测试。它跑在cargo bench下、harness false直接把结果打到标准输出# elementwise / 归约 / 树 / conv / pool / 归一化的临界点。# 打印这些表并重写 benches/calibrations/RESULTS.md。cargobench--benchparallel_gates它需要machine_learning和neural_network两个 feature子模块覆盖卷积引擎、池化、elementwise 与归约内核、树遍历以及归一化层。对每一类内核它把串行实现和并行实现在该类那道门的两侧各自强制跑一遍走完一整梯子的形状逐级报出加速比。下面是随包benches/calibrations/RESULTS.md里的一段真实摘录2026-07-26 在那台 32 rayon 线程的 9950X 上重新生成## 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 亏损。**校准输出是证据不是指令。**在按某个区间去挪一个常量之前先看清你的负载究竟落在这道梯子的哪几级上。在程序启动时、任何真实工作触及某道门之前就把这些数值应用上这些原子量是进程全局的每个内核都实时读取它们usendarray::{Array1,Array2};userustyml::machine_learning::LinearRegression;userustyml::tuning;fnmain(){// RustyML 自己的门取自这台机器上一次 parallel_gates 的运行结果。tuning::matmul::set_cache_resident_max_bytes(32*1024*1024);// 这颗 CPU 真实的 L3tuning::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同样的结果——变的只是串行/并行策略。letxArray2::from_shape_vec((5,1),vec![1.0,2.0,3.0,4.0,5.0]).unwrap();letyArray1::from_vec(vec![3.0,5.0,7.0,9.0,11.0]);letmutmodelLinearRegression::new(true);model.fit(x,y).unwrap();letpredsmodel.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环境变量在线程池第一次被触及时读取RAYON_NUM_THREADS8./my_program想在代码里控制就在任何 rustyml 调用触及线程池之前把全局池构建一次。rustyml 没有重新导出 rayon所以你得把它作为自己的依赖加进来rayon 1fn 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_THREADS8并不会让 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. 什么不该调以及一套靠谱的流程这些门是最后才该动的东西不是第一个。碰它们之前先端到端地测给你真正在意的那个fit、predict或训练循环计时找出时间花在哪。十有八九答案不是某道门设错了。更常见的是一个 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 或归一化门。一次只改一道门重新测那个端到端数字只有当它真的有帮助才保留这次改动。重新调门从不改变你的结果所以一次误判唯一的代价就是花在调一道从来都不是瓶颈的门上的时间。
返回列表