Rust in Everything
基于 rayon 1.12.0

一次 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)

逐步:

  1. into_par_iter:Range<usize> 的 IntoParallelIterator 只是把区间包进 range::Iter{range}(range.rs:50-60)。usize 区间是 Indexed 的:长度已知,可以在任意位置切开。
  2. sum:ParallelIterator::sum 调 sum::sum(self),后者就一行 pi.drive_unindexed(SumConsumer::new())(iter/sum.rs:7-13)。并行迭代器没有 next();终结操作都是造一个 Consumer,交给迭代器去「驱动」。
  3. drive_unindexed → bridge:整数区间的 drive_unindexed 直接 bridge(iter, consumer)(range.rs:158-163)。bridge 取 len,然后 par_iter.with_producer(Callback{len, consumer})。
  4. with_producer 回调:区间把自己变成 IterProducer{range} 交给回调(range.rs:184-189)。这种「把 producer 传进回调」的写法是因为 producer 的具体类型要到最内层才确定,层层包装的适配器(map、zip…)都在回调里再包一层;两个 trait 细讲。
  5. 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()
        }
  1. 叶子:不再切时,IterProducer 变回普通的 Range<usize>,SumFolder::consume_iter 对它调用 std 的 Iterator::sum(iter/sum.rs:94-101)。叶子里是纯串行代码,编译器照常向量化。
  2. 回溯: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 里)1212 → 66 → 33 → 1 → 02⁴ = 16
main1212 → 6被注入,重置为 max(12, 3) = 1212 → 6 → 3 → 1 → 02⁶ = 64
池内11 → 0停—2
main11 → 0被注入,重置为 max(1, 0) = 11 → 02³ = 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]
main12[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_lenbridge 与自适应切分
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 片),实测与推算一致。

💬 关联讨论

发起讨论
加载中...