Rust in Everything
基于 rayon 1.12.0

最小自适应 Splitter

主线位置:主线 2「一次 par_iter().sum()」里决定切不切的那一步,对照 bridge 与自适应切分。代码在 exp-rayon/hands-on/src/splitter.rs,跑在前两页的 最小线程池 和 最小 join 上。

flowchart TB
  root["for_each_leaf(pool, 0..n, leaf):splits = 线程数,migrated = false"] --> ts{"try_split(len, migrated)"}
  ts -->|"len / 2 < 1"| leafNode["leaf(range)"]
  ts -->|"migrated"| reset["splits = max(线程数, splits / 2),切"]
  ts -->|"未迁移且 splits > 0"| half["splits /= 2,切"]
  ts -->|"未迁移且 splits = 0"| leafNode
  reset --> jc["pool.join_context(左半, 右半),各自拿到 migrated"]
  half --> jc
  jc --> ts

Splitter 和 helper

#[derive(Clone, Copy)]
pub struct Splitter {
  splits: usize,
  threads: usize,
}

impl Splitter {
  pub fn new(threads: usize) -> Self {
    Splitter { splits: threads, threads }
  }

  /// `len / 2 >= 1` plays the role of `with_min_len(1)`.
  pub fn try_split(&mut self, len: usize, migrated: bool) -> bool {
    if len / 2 < 1 {
      return false;
    }
    #[cfg(not(feature = "ignore_migrated"))]
    if migrated {
      // Someone was idle enough to steal us: worth splitting more.
      self.splits = self.threads.max(self.splits / 2);
      return true;
    }
    #[cfg(feature = "ignore_migrated")]
    let _ = migrated;
    if self.splits > 0 {
      self.splits /= 2;
      true
    } else {
      false
    }
  }
}

fn helper<F>(pool: &Pool, range: Range<usize>, migrated: bool, mut splitter: Splitter, leaf: &F)
where
  F: Fn(Range<usize>) + Sync,
{
  if splitter.try_split(range.len(), migrated) {
    let mid = range.start + range.len() / 2;
    let (left, right) = (range.start..mid, mid..range.end);
    pool.join_context(
      |m| helper(pool, left, m, splitter, leaf),
      |m| helper(pool, right, m, splitter, leaf),
    );
  } else {
    leaf(range);
  }
}

三条规则就是 rayon Splitter::try_split 的原样,len / 2 < 1 是 LengthSplitter 在 min = 1 时的那一条(plumbing/mod.rs:329-332)。helper 是 bridge_producer_consumer 里 helper 的骨架,去掉了 producer / consumer,只剩一个区间和一个叶子闭包;Splitter 是 Copy 的,左右两支各拿一份,各自决定,没有共享状态。

migrated 从哪来,全在上一页的 join_context 里:A 拿到的是 in_worker 给的 injected;B 被主人弹回来时也是 injected,被偷走时经 StackJob::execute 拿到 true。和 rayon 一样,从池外调用时根的 join_context 整个被注入,A、B 两边都是 true。tests/mini.rs 直接检查了这一点:1 线程池,从池外 join_context(|m| m, |m| m) 得到 (true, true),在 install 里得到 (false, false)。

实测:叶子数,和 rayon 并排

src/bin/splits.rs count:同一个进程里,mini 池和 rayon 用同样的线程数(rayon 用 build_global 和另建一个 ThreadPool),对 n = 2²⁰ 各数 5 次叶子。mini 在叶子闭包里计数并核对所有叶子的长度之和等于 n;rayon 用 E2 的 fold(|| (), ..).count()。「池外」从 main 调,「池内」在 install 里调。这组是计数,不计时,跑时 1 / 5 分钟负载 4.02 / 3.70:

线程调用处minirayon
1池外8 ×58 ×5
1池内2 ×52 ×5
2池外16 ×516 ×5
2池内4 ×54 ×5
4池外66–26240–188
4池内8 ×3、140、3378 ×2、34–212
12池外1227–174164–1725
12池内1060–263316–1564
  • 1 线程完全确定,而且和 rayon 一样:池外 8 片、池内 2 片,正是 bridge 与自适应切分 那张表的值。池外多出来的两层来自根 join 被注入、两个孩子都 migrated。tests/mini.rs 把 2²⁰ → 8 / 2、3 → 3 / 2、1 → 1 写成了断言。
  • 2 线程这一轮也恰好是下限(池外 16、池内 4),两边都是:叶子里什么也不做,另一个线程还没来得及偷,树就切完了(推测)。
  • 4、12 线程受窃取影响,每次不同,下限(4 线程池内 8、12 线程池外 64 / 池内 16)偶尔直接出现。mini 的叶子普遍比 rayon 多,推测是 mini 的唤醒更激进:每次 push 只要有人在睡就叫一个,空闲线程更早来偷,偷得越多切得越细;rayon 只在「醒着的空闲线程不够」时才叫(唤醒策略与实测)。没有单独验证。

反向验证:忽略 migrated

--features ignore_migrated:被偷也不重置,只按 splits 减半。同一组计数:

线程调用处mini(忽略 migrated)rayon(同一进程)
1池外2 ×58 ×5
1池内2 ×52 ×5
2池外 / 池内4 ×5 / 4 ×516 ×4、20 / 4 ×5
4池外 / 池内8 ×5 / 8 ×532–146 / 8 ×4、58
12池外 / 池内16 ×5 / 16 ×564–1173 / 16–565
12,n = 1000池外 / 池内16 ×5 / 16 ×564–354 / 16–465

叶子数钉死在 2^(⌊log₂t⌋+1),不随调用处、不随窃取变化:池外那两层消失了(1 线程 2 片),12 线程永远 16 片。这说明 mini 版叶子数里「下限以上的部分」全部来自 migrated 这一条。

只有 16 片时,12 个线程怎么分?计时(splits time,n = 65536,每元素一个循环做 rounds 次乘加,从 main 调用,12 线程,5 次取中位数):

SPLIT_TIMING

省掉了什么

省掉的rayon 怎么做
producer / consumerProducer::split_at 切数据,Consumer::split_at 切结果的去处并给出 Reducer,回溯时合并(plumbing);这里叶子闭包自己往原子变量里加
with_min_len / with_max_lenLengthSplitter 的 min(硬下限)和 max(splits 至少是 len / max)
consumer.full() 短路find_any / any 共享一个 AtomicBool,满了就不再切也不再折叠
unindexed 版本bridge_unindexed:producer 自己决定能不能 split()
current_num_threads()rayon 每次重置时读的是当前池的线程数;这里存在 Splitter 里

小结

  • rayon 的 thief-splitting 就三行:被偷就重置为 max(线程数, splits / 2),否则有余额就减半,没余额就停;再加一条 len / 2 >= min。
  • 关键输入是 join_context 给的 migrated:B 被偷、或者根经注入进池,都是 true。
  • mini 版在 1、2 线程时叶子数与 rayon 逐个相同(池外 8 / 16,池内 2 / 4);多线程时随窃取变化,比 rayon 多。
  • 忽略 migrated:叶子数钉死在 2^(⌊log₂t⌋+1)(12 线程 16 片),池外那两层也没了;计时见上表。

💬 关联讨论

发起讨论
加载中...