同一个数组,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 / 4 | 499845.7–499845.8 之间 |
同上,在 1 线程池的 install 里 | 1 | 499846.13 |
par_iter().with_min_len(len).sum::<f32>() | 1 | 499845.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、按固定块求部分和)或在更高精度里累加,测试用容差比较。
💬 关联讨论
发起讨论加载中...