Rust in Everything
基于 rayon 1.12.0

同一个数组,par_iter().sum::<f32>() 每次结果不同

主线位置:主线 2 的归约。sum 怎么合并在 fold、reduce 与 sum,树形怎么来在 bridge 与自适应切分。

flowchart TB
  r1["运行 1:叶子 A、B、C"] --> s1["(A + B) + C"]
  r2["运行 2:B 被偷后又切成 B1、B2"] --> s2["(A + (B1 + B2)) + C"]
  s1 --> d["f32 舍入不同:结果在最后几位上不同"]
  s2 --> d

现象

同一份输入、同一个程序,v.par_iter().sum::<f32>() 的结果在最后几位上每次不一样;和 v.iter().sum::<f32>() 也对不上。快照测试、跨机器对比结果、按结果做哈希的缓存因此时好时坏。

复现

E8 第二部分(exp-rayon/collect-paths/):10⁶ 个 [0, 1) 的 f32(固定种子),每种方式重复 100 次,数不同结果的个数(fold、reduce 与 sum):

方式不同结果数结果
顺序 iter().sum::<f32>()—499845.13
par_iter().sum::<f32>(),12 线程,从 main(四次运行)5 / 4 / 5 / 4499845.7–499845.8 之间
同上,在 1 线程池的 install 里1499846.13
par_iter().with_min_len(len).sum::<f32>()1499845.13,与顺序版逐位相同
par_iter().map(|&x| x as f64).sum::<f64>(),12 线程1—

1 线程是反向验证:没有窃取,树形确定,结果恒定;但池内 1 线程也切成 2 片,所以它和顺序版不相等。「确定」不等于「等于顺序结果」。

从哪来

sum 的叶子内部是顺序的 std Iterator::sum,叶子之间在回溯时用 [left, right].into_iter().sum() 两两相加(sum.rs:15-17 的 add,69-104 的 Reducer 与 Folder)。树切多深、在哪里切,由 Splitter 根据「是否被偷」临时决定,每次运行不同(E2:2²⁰ 个元素的叶子数在各轮里从约 1100 到约 3000)。浮点加法不满足结合律,(a + b) + c 和 a + (b + c) 的舍入不同,树一变结果就变。

这不是 rayon 的缺陷,reduce 的文档明确要求 op 满足结合律;整数加法满足,浮点加法不满足。f64 那一行只有 1 个结果,是因为这组数据的误差被 f64 的精度吸收了,不说明 f64 求和一般是确定的。

怎么改

  • 固定树形:with_min_len(m) 让 len / 2 < m 之后不再切;选一个让各分支都会先被它截住的 m(比如 len / 线程数 量级),叶子的位置和个数就和窃取无关(E2:10⁶ 个元素 with_min_len(10⁵) 在 12 线程下每次都恰好 8 片)。with_min_len(len) 就是顺序求和。
  • 按固定块求部分和:v.par_chunks(K).map(|c| c.iter().sum::<f32>()).collect::<Vec<_>>(),再对部分和顺序求和。块的边界只由 K 决定,collect 保序,结果可复现(但不等于顺序结果)。
  • 提高精度:在 f64 里累加最后再转回 f32;或者每片内部用补偿求和(Kahan)。这只是把误差变小,不保证逐位一致。
  • 测试里比较浮点结果用容差,不要用 ==。

小结

  • 并行求和的树形由窃取决定,浮点加法不满足结合律,所以 f32 求和每次可能不同(E8:100 次 4–5 个结果)。
  • 1 线程结果恒定,但不等于顺序结果;单叶子(with_min_len(len))与顺序逐位相同。
  • 要可复现:固定树形(with_min_len、按固定块求部分和)或在更高精度里累加,测试用容差比较。

💬 关联讨论

发起讨论
加载中...