最小自适应 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:
| 线程 | 调用处 | mini | rayon |
|---|---|---|---|
| 1 | 池外 | 8 ×5 | 8 ×5 |
| 1 | 池内 | 2 ×5 | 2 ×5 |
| 2 | 池外 | 16 ×5 | 16 ×5 |
| 2 | 池内 | 4 ×5 | 4 ×5 |
| 4 | 池外 | 66–262 | 40–188 |
| 4 | 池内 | 8 ×3、140、337 | 8 ×2、34–212 |
| 12 | 池外 | 1227–1741 | 64–1725 |
| 12 | 池内 | 1060–2633 | 16–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 ×5 | 8 ×5 |
| 1 | 池内 | 2 ×5 | 2 ×5 |
| 2 | 池外 / 池内 | 4 ×5 / 4 ×5 | 16 ×4、20 / 4 ×5 |
| 4 | 池外 / 池内 | 8 ×5 / 8 ×5 | 32–146 / 8 ×4、58 |
| 12 | 池外 / 池内 | 16 ×5 / 16 ×5 | 64–1173 / 16–565 |
| 12,n = 1000 | 池外 / 池内 | 16 ×5 / 16 ×5 | 64–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 / consumer | Producer::split_at 切数据,Consumer::split_at 切结果的去处并给出 Reducer,回溯时合并(plumbing);这里叶子闭包自己往原子变量里加 |
with_min_len / with_max_len | LengthSplitter 的 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 片),池外那两层也没了;计时见上表。