一次 par_iter
主线位置:主线 2 的全程。本页只画调用链和切分树,细节在 06 部分:两个 trait、plumbing、bridge 与自适应切分、fold、reduce 与 sum。每一次切分都是一次
join_context,即 一次 join。
拿最简单的例子:
let s: usize = (0..n).into_par_iter().sum();
调用链
sequenceDiagram
participant U as 用户
participant I as range::Iter usize
participant B as plumbing
participant H as helper 递归
participant J as rayon-core join_context
U->>I: (0..n).into_par_iter() 得到 Iter{range}
U->>I: sum() 即 drive_unindexed(SumConsumer)
I->>B: bridge(iter, consumer)
B->>I: with_producer(Callback{len, consumer})
I->>B: callback(IterProducer{range})
B->>H: bridge_producer_consumer:LengthSplitter::new(1, MAX, len)
H->>H: try_split(len, migrated)
H->>J: 切:producer.split_at(mid)、consumer.split_at(mid)、join_context
J->>H: 左右各自递归 helper,migrated 取自 context
H->>H: 不切:producer.fold_with(consumer.into_folder()).complete()
H->>U: 回溯:reducer.reduce(left, right)
逐步:
into_par_iter:Range<usize>的IntoParallelIterator只是把区间包进range::Iter{range}(range.rs:50-60)。usize区间是 Indexed 的:长度已知,可以在任意位置切开。sum:ParallelIterator::sum调sum::sum(self),后者就一行pi.drive_unindexed(SumConsumer::new())(iter/sum.rs:7-13)。并行迭代器没有next();终结操作都是造一个 Consumer,交给迭代器去「驱动」。drive_unindexed→bridge:整数区间的drive_unindexed直接bridge(iter, consumer)(range.rs:158-163)。bridge取len,然后par_iter.with_producer(Callback{len, consumer})。with_producer回调:区间把自己变成IterProducer{range}交给回调(range.rs:184-189)。这种「把 producer 传进回调」的写法是因为 producer 的具体类型要到最内层才确定,层层包装的适配器(map、zip…)都在回调里再包一层;两个 trait 细讲。bridge_producer_consumer:建一个LengthSplitter,进入递归的helper(plumbing/mod.rs:385-435):
if consumer.full() {
consumer.into_folder().complete()
} else if splitter.try_split(len, migrated) {
let mid = len / 2;
let (left_producer, right_producer) = producer.split_at(mid);
let (left_consumer, right_consumer, reducer) = consumer.split_at(mid);
let (left_result, right_result) = join_context(
|context| {
helper(
mid,
context.migrated(),
splitter,
left_producer,
left_consumer,
)
},
|context| {
helper(
len - mid,
context.migrated(),
splitter,
right_producer,
right_consumer,
)
},
);
reducer.reduce(left_result, right_result)
} else {
producer.fold_with(consumer.into_folder()).complete()
}
- 叶子:不再切时,
IterProducer变回普通的Range<usize>,SumFolder::consume_iter对它调用 std 的Iterator::sum(iter/sum.rs:94-101)。叶子里是纯串行代码,编译器照常向量化。 - 回溯:
SumConsumer自己就是 Reducer,reduce(left, right)用[left, right].into_iter().sum()合并。
切分树:切几层
切不切由 LengthSplitter::try_split 决定(plumbing/mod.rs:328-332):len / 2 >= min(默认 min = 1),并且内层的 Splitter 同意。Splitter 只有一个数 splits(plumbing/mod.rs:258-284):
fn new() -> Splitter {
Splitter {
splits: crate::current_num_threads(),
}
}
fn try_split(&mut self, stolen: bool) -> bool {
let Splitter { splits } = *self;
if stolen {
// This job was stolen! Reset the number of desired splits to the
// thread count, if that's more than we had remaining anyway.
self.splits = Ord::max(crate::current_num_threads(), self.splits / 2);
true
} else if splits > 0 {
// We have splits remaining, make it so.
self.splits /= 2;
true
} else {
// Not stolen, and no more splits -- we're done!
false
}
}
初值是线程数,每层减半,减到 0 就停;但只要这个子任务是被偷(或被注入)来的,splits 就重置回线程数。被偷说明别的线程闲着,值得再切细一些。splitter 是 Copy 的,按值传给左右两边,每条路径各自计数。
先在池内、没有任何窃取的理想情况下算一遍,12 线程:splits 走 12 → 6 → 3 → 1 → 0,切 4 层,16 片。1 线程:1 → 0,切 1 层,2 片。
从 main 调用多一层。 main 线程不是 worker,最外层的 join_context 经 in_worker_cold 注入全局池(in_worker),injected = true。按 一次 join 里那张表,A 的 migrated() 是 injected,B 不论被不被偷也都是 true。于是根节点下面的两个子任务都按「被偷」处理,splits 重置回线程数,再走一遍减半:
| 调用位置 | 线程数 | 根 | 第 2 层 | 之后 | 最少叶子数 |
|---|---|---|---|---|---|
池内(install 里) | 12 | 12 → 6 | 6 → 3 | 3 → 1 → 0 | 2⁴ = 16 |
main | 12 | 12 → 6 | 被注入,重置为 max(12, 3) = 12 | 12 → 6 → 3 → 1 → 0 | 2⁶ = 64 |
| 池内 | 1 | 1 → 0 | 停 | — | 2 |
main | 1 | 1 → 0 | 被注入,重置为 max(1, 0) = 1 | 1 → 0 | 2³ = 8 |
实测叶子数(E2)
数叶子的方法:每个叶子恰好建一个 Folder,所以 fold(|| (), |_, _| ()).count() 返回的就是叶子数。实验在 exp-rayon/survey/(splits、splits_install 两个程序),每种情况跑 5 次。这是计数类实验,和负载无关,但窃取的多少和调度有关,所以 12 线程的数字每次不同。
n = 2²⁰,每个元素不做事:
| 调用位置 | 线程数 | 5 次的叶子数(两轮) |
|---|---|---|
install 内 | 1 | [2, 2, 2, 2, 2]、[2, 2, 2, 2, 2] |
install 内 | 2 | [4, 4, 4, 4, 4]、[4, 4, 4, 22, 4] |
install 内 | 4 | [8, 8, 8, 8, 8]、[8, 8, 8, 8, 8] |
install 内 | 12 | [412, 16, 16, 814, 364]、[587, 1266, 16, 16, 16] |
main | 12 | [1333, 869, 614, 96, 80]、[681, 917, 585, 777, 768] |
main(RAYON_NUM_THREADS=1) | 1 | [8, 8, 8, 8, 8] |
- 下限和上表的推算完全一致:池内 12 线程最少 16 片,
main最少 64 片(实测最少 80);1 线程池内恒为 2、main恒为 8。 - 2 / 4 线程大多数时候正好是 4 / 8 片,但不是「恒为」:2 线程有一次 22 片。只要发生一次窃取,被偷的那一支就重置
splits再切一轮。 - 12 线程时切多少完全取决于窃取:同样的输入,从 16 片到 1266 片。
反向验证:只有 1 个线程时不可能有窃取,叶子数必须是确定值。实测 RAYON_NUM_THREADS=1 下 main 调用对 n = 15、16、17、1000、10⁶、10⁷ 全是 8 片,install 内全是 2 片,没有一次例外。另一个方向:len / 2 >= 1 这条长度限制在 12 线程、n ≤ 17 时成为瓶颈,n = 1、2、15、16、17 的叶子数恰好等于 n(每片一个元素),5 次全一致。
E2 的完整扫描(with_min_len、with_max_len、每元素加工作量)在 bridge 与自适应切分。
每一步在哪一章
| 步 | 本卷 |
|---|---|
IntoParallelIterator、drive_unindexed / drive / with_producer 三个入口 | 两个 trait |
Producer、Consumer、Folder、Reducer,full() 短路 | plumbing |
Splitter、LengthSplitter、with_min_len / with_max_len | bridge 与自适应切分 |
sum 的折叠与归约、浮点结果为什么会变 | fold、reduce 与 sum |
每层的 join_context | 一次 join |
小结
par_iter().sum()不迭代:它造一个SumConsumer,经bridge拿到 Producer,进入bridge_producer_consumer的二分递归。- 每次切分是一次
join_context;叶子是普通的串行Iterator::sum;回溯时两两归约。 - 切几层由
Splitter动态决定:初值线程数、每层减半、被偷或被注入就重置。理想情况下 12 线程切 16 片。 - 从
main调用时最外层是注入的,两个子任务都算「被迁移」,多切一轮:12 线程最少 64 片,1 线程恒为 8 片(池内恒为 2 片),实测与推算一致。