bridge 与自适应切分
主线位置:主线 2 的核心。源头拿到生产者以后,
bridge_producer_consumer递归地决定「这一段还切不切」:切就join_context两半,不切就在当前线程上顺序跑完。每一层join_context是主线 1(见 join 与 join_context),它告诉切分器的唯一信息是migrated():这一半是不是换了线程。
flowchart TB
start["bridge_producer_consumer(len, producer, consumer)"] --> mk["LengthSplitter::new(min_len, max_len, len):splits = max(线程数, len / max)"]
mk --> h["helper(len, migrated, splitter, ...)"]
h --> full{"consumer.full()?"}
full -->|是| done["into_folder().complete()"]
full -->|否| ts{"len / 2 >= min 且 Splitter 同意?"}
ts -->|"否"| leaf["producer.fold_with(consumer.into_folder()).complete()"]
ts -->|"是"| sp["mid = len / 2;producer.split_at(mid);consumer.split_at(mid)"]
sp --> jc["join_context(左 helper, 右 helper),各自带 context.migrated()"]
jc --> h
jc --> red["reducer.reduce(左结果, 右结果)"]
三个 bridge
plumbing/mod.rs 的后半段只有三个公开函数:
| 函数 | 行 | 给谁用 |
|---|---|---|
bridge(par_iter, consumer) | 346–371 | indexed 迭代器的 drive / drive_unindexed 一行实现:par_iter.with_producer(Callback { len, consumer }),回调里调下一个 |
bridge_producer_consumer(len, producer, consumer) | 385–435 | 真正的递归;LengthSplitter |
bridge_unindexed(producer, consumer) | 438–476 | UnindexedProducer 用;只有 Splitter,没有长度 |
bridge_producer_consumer 的递归体:
fn helper<P, C>(
len: usize,
migrated: bool,
mut splitter: LengthSplitter,
producer: P,
consumer: C,
) -> C::Result
where
P: Producer,
C: Consumer<P::Item>,
{
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()
}
}
几点:
- 永远对半切:
mid = len / 2,不看数据、不看线程忙闲。树的形状只由「在哪一层停」决定。 splitter是Copy的,按值传进两个闭包,左右各拿一份副本、各自往下减。所以切分决策完全是局部的,没有任何共享状态,也没有原子操作。- 根调用
helper(len, false, ...):第一层永远当作「没被偷」。 - 叶子上的
fold_with(...).complete()在当前线程上执行。(0..1).into_par_iter()这种切不动的,或者with_min_len(len),从头到尾不会进入线程池(E2:threads_used=0)。
Splitter:三行规则
Splitter 只有一个字段 splits: usize,注释叫它「thief-splitting」:先切出够所有线程分的任务,此后每当一个任务真的被偷到别的线程,就重置。
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
}
}
- 初值是
current_num_threads():在 worker 上是所在池的线程数,不在任何池里是全局池的线程数。 - 没被偷:
splits > 0就切,并减半。12 线程时依次是 12 → 6 → 3 → 1 → 0,共切 4 层,得到 2⁴ = 16 片。一般地,splits = t时切 ⌊log₂t⌋ + 1 层,片数是大于 t 的最小 2 的幂。 - 被偷(
stolen即migrated()为真):把splits重置为max(t, splits / 2),并且这一层照切。理由写在注释里:任务被偷说明有线程闲着,值得为它们再切出一批。
为什么「被偷」能代表「有人闲着」:join_context 把 B 推进本地 deque,A 在本线程跑;只有别的线程没活干、去偷的时候,B 才会换线程(见 WorkerThread 主循环)。B 被偷,它那一支的 splits 回到线程数,在新线程上再展开一棵小树。负载均匀时几乎不偷,树就停在 16 片;某一支特别慢,空闲线程会不断偷它的后代,那一支就被切得更细。这就是「自适应」:不需要事先知道每个元素的代价。
bridge_unindexed 直接用 Splitter;bridge_producer_consumer 用的是外面包了一层的 LengthSplitter。
LengthSplitter:再加两条
fn new(min: usize, max: usize, len: usize) -> LengthSplitter {
let mut splitter = LengthSplitter {
inner: Splitter::new(),
min: Ord::max(min, 1),
};
// Divide the given length by the max working length to get the minimum
// number of splits we need to get under that max. This rounds down,
// but the splitter actually gives `next_power_of_two()` pieces anyway.
// e.g. len 12345 / max 100 = 123 min_splits -> 128 pieces.
let min_splits = len / Ord::max(max, 1);
// Only update the value if it's not splitting enough already.
if min_splits > splitter.inner.splits {
splitter.inner.splits = min_splits;
}
splitter
}
fn try_split(&mut self, len: usize, stolen: bool) -> bool {
// If splitting wouldn't make us too small, try the inner splitter.
len / 2 >= self.min && self.inner.try_split(stolen)
}
min是硬下限:len / 2 >= min不成立就不切,而且&&短路,内层的splits也不会被消耗。默认min = 1,所以len >= 2才切,n 个元素最多 n 片。max只在开头起作用:splits至少是len / max,于是不被偷时也至少切到每片 ≤max。min/max来自producer.min_len()/max_len(),也就是with_min_len/with_max_len包上去的MinLen/MaxLen。它们的生产者只改这两个方法,split_at原样转发。zip取两边min_len的较大者、max_len的较小者。
两条冲突时 min 赢:with_max_len 的文档举的例子是 min = 10、max = 15,长度 16 的片不会再切(切了就是 8 < 10)。
规划时的发现:从池外调用多切一层
按上面的规则,12 线程应该最少切出 16 片。但从 main 线程调 (0..n).into_par_iter()... 时,最少是 64 片。原因在 rayon-core 里:
main不是 worker。根helper在main上执行,try_split(len, false):12 → 6,切。- 它调
join_context,进registry::in_worker,当前线程不是 worker,走in_worker_cold:把整个join_context的闭包包成StackJob注入全局队列,main在LockLatch上阻塞。某个 worker 取到它,以op(&*worker_thread, true)调用,即injected = true。 join_context里,A 的FnContext用的就是这个injected;B 在本线程被弹出时是job_b.run_inline(injected),被偷时StackJob::execute经JobResult::call传func(true)(job.rs:217-218)。两边的migrated()都是true。- 于是第二层两个
helper都走stolen分支:splits = max(12, 6 / 2) = 12,切,而且splits没有减少。 - 之后每一支从 12 开始再切 4 层。总层数 1 + 1 + 4 = 6,2⁶ = 64 片。
在 worker 上调用(ThreadPool::install 里、rayon::scope 里、或者一个 rayon 任务里嵌套的 par_iter),根 join_context 走 in_worker 的第一条路,injected = false,没有这一层重置,最少 16 片。
一般地,t 个线程时设 L = ⌊log₂t⌋ + 1:池内最少 2ᴸ 片,池外最少 2ᴸ⁺² 片。
| 线程数 t | L | 池内(scope / install)最少 | 池外(main)最少 |
|---|---|---|---|
| 1 | 1 | 2 | 8 |
| 2 | 2 | 4 | 16 |
| 4 | 3 | 8 | 32 |
| 12 | 4 | 16 | 64 |
这是「最少」:只要发生窃取,被偷的那一支就会多切。表里的数字是在没有窃取时才会精确出现的值;1 线程没有别的线程可偷,结果完全确定。
这多出来的一层是否有意为之,代码和注释里都没有说明(in_worker_cold 里 injected 的用途是让 join 的两个闭包知道自己「被换了线程」,这个语义本身是对的:A 确实不在调用者的线程上跑)。效果是从池外发起的并行迭代切得更细,叶子更多;对计算量大的循环影响不大,对只有几十个元素的小循环意味着更多次 join(见常见坑 tiny-tasks)。
实验 E2:数叶子
exp-rayon/split-count/ 自写一个 Producer(Counted,对 0..n 的区间),在 split_at 里计数切分次数、在 fold_with 里计数叶子数并记录每个叶子的长度和执行线程;同一次运行再用 fold(|| (), ...).count() 数一遍(每个叶子的 FoldFolder 恰好产出一个元素,count 就是叶子数)。每次运行都检查两条恒等式:fold().count() == 叶子数,切分次数 + 1 == 叶子数(二叉树)。所有配置、所有重复里两条都成立,所以 fold().count() 这个计数法没有偏差。
「池内」是在 rayon::scope(|_| ...) 的闭包里调用(闭包在全局池的 worker 上执行),「池外」是直接在 main 里调用。线程数用 RAYON_NUM_THREADS 控制。
1 线程(反向验证:没有窃取,必须完全确定):
main n=1 leaves=[1, 1, 1, 1, 1] threads_used=[0, 0, 0, 0, 0]
main n=3 leaves=[3, 3, 3, 3, 3]
inside n=3 leaves=[2, 2, 2, 2, 2]
main n=1048576 leaves=[8, 8, 8, 8, 8] leaf_len=[131072..131072]
inside n=1048576 leaves=[2, 2, 2, 2, 2] leaf_len=[524288..524288]
main n=1000000 min_len=100000 leaves=[8, 8, 8]
main n=1000000 min_len=1000000 leaves=[1, 1, 1] threads_used=[0, 0, 0]
main n=1000000 max_len=10000 leaves=[128, 128, 128] leaf_len=[7812..7813]
main n=1000000 max_len=100 leaves=[16384, 16384, 16384] leaf_len=[61..62]
每个数都能用上面的规则手算出来:n = 3 从 main 调用时根切成 1 + 2,右边被「重置」后再切成 1 + 1;min_len = 100000 时 10⁶ → 5×10⁵ → 2.5×10⁵ → 1.25×10⁵,再切就小于下限,8 片;max_len = 100 使 splits 初值为 10⁶ / 100 = 10000,减半 14 次到 0,2¹⁴ = 16384 片,每片 61–62 个,正是注释里「next_power_of_two() pieces」。threads_used=0 的两行(n = 1、min_len = n)说明没有切分时整个计算都在 main 上完成,根本没进线程池。
2 / 4 线程也符合表里的下限:2 线程池内最少 4 片、池外 16 片,4 线程池外 n = 1000 最少 32 片(一次运行恰好是 32)。
12 线程(受窃取影响,每格 5 次;测前 1 分钟 / 5 分钟负载 2.62 / 2.99):
| 配置 | 池外(main) | 池内(scope) |
|---|---|---|
| n = 16 | 16 ×5 | 16 ×5 |
| n = 17 | 17 ×5 | 16 ×5 |
| n = 1000 | 249、218、184、213、298 | 80、135、16、16、16 |
| n = 2²⁰,每元素 0 次乘法 | 1177–1727 | 1078–1517 |
| n = 2²⁰,每元素 200 次乘法 | 2428–3481 | 3249–5077 |
| n = 10⁷ | 2137–3168 | 1597–2282 |
n = 10⁶,with_min_len(10⁵) | 8 ×3 | 8 ×3 |
n = 10⁶,with_max_len(100) | 17439–17632 | 17496–17593 |
- 池内的下限 16 直接出现了(n = 1000 五次里三次恰好 16:这三次没有发生窃取)。池外这一轮没有出现 64,每次都有窃取。其他几轮里出现过:负载约 7 时池外 n = 1000 有一次恰好 64;本机负载一度超过 100(别的会话误开了大量线程)时,池外 n = 1000 五次里三次恰好 64。这四次 64 的叶子全部在同一个 worker 上执行(
threads_used=1):其他 worker 没被操作系统调度上来,没有窃取,树就停在下限。 - n = 17 池内是 16:12 线程本来只切 4 层,17 个元素切 4 层是 16 片(最后一片 2 个);另一轮里偶尔是 17,那是有窃取把最后一片又切开了。
- 大数组上的叶子数主要由窃取决定,远多于下限,每次运行不同,随负载变化:n = 2²⁰ 在这一轮 1177–1727,另两轮 1258–1877、1133–1731,规划阶段 1460–3032。窃取发生得越多(每元素工作量越重、空闲线程越多),切得越细。
with_min_len(10⁵)在 12 线程下也恰好 8 片:下限让窃取无片可切。with_max_len(100)在 16384 的基础上多出 1000 多片:max只定了「至少切到这么细」,窃取照样会在它下面继续切到min。
实验 E4:min_len / max_len 与耗时
exp-rayon/min-max-len/:n = 10⁶,(0..n).into_par_iter().with_min_len(m)(或 with_max_len(m)).map(work).reduce(|| 0, wrapping_add),从 main 调用,12 线程,5 次取中位数。两档工作量:轻(每元素 1 次乘加,可向量化,顺序版不到 1 ms)、重(每元素 200 次乘加,顺序版 168 ms,每元素约 170 ns)。测前 1 分钟 / 5 分钟负载 2.19 / 2.92;「叶子」是同一配置单独跑一次 fold().count() 的值,只作参考。
| 配置 | 轻:耗时 / 叶子 | 重:耗时 / 加速比 / 叶子 |
|---|---|---|
| 顺序 | 0.844 ms | 167.9 ms |
默认(min_len = 1) | 0.320 ms / 604 | 16.8 ms / 9.97× / 376 |
min_len = 100 | 0.169 ms / 389 | 17.8 ms / 9.43× / 332 |
min_len = 10⁴ | 0.112 ms / 64 | 17.7 ms / 9.51× / 64 |
min_len = 10⁵ | 0.159 ms / 8 | 22.4 ms / 7.51× / 8 |
min_len = 10⁶(= n) | 0.738 ms / 1 | 167.4 ms / 1.00× / 1 |
max_len = 10 | 0.485 ms / 131743 | 18.0 ms / 9.30× / 131452 |
max_len = 100 | 0.175 ms / 17129 | 18.5 ms / 9.09× / 16589 |
max_len = 10⁴ | 0.168 ms / 1092 | 18.6 ms / 9.04× / 708 |
(min_len = 10、1000 与相邻两行同量级,表里省略。轻工作量的顺序基线很不稳:同一程序另一轮是 0.437 ms,这一轮 0.844 ms,推测与主线程被调度到性能核还是能效核有关,所以轻工作量只比较各并行配置之间的耗时,不算加速比。另两轮在 5 分钟负载很高时测过,形状相同:轻工作量最快的都是 min_len = 10⁴,max_len = 10 都是并行配置里最慢的。)
- 反向验证:
min_len = n时只有一个叶子,在main上顺序执行:重工作量 167.4 对 167.9 ms(1.00×);轻工作量 0.738 对 0.844 ms,在顺序基线本身的波动范围内。 - 轻工作量:整个计算不到 1 ms,从
main进池一次就要十几微秒,再加上几百个叶子各自的join。把min_len提到 10²–10⁴ 能减少叶子,耗时从 0.32 ms 降到 0.11 ms;反方向把max_len压到 10,13 万个叶子让它慢到 0.49 ms,在另一轮里比顺序版还慢(0.638 对 0.437 ms)。 - 重工作量:默认已经是最好的(9.97×,12 个核里 4 个是能效核),各种
min_len/max_len在 9.0–9.5× 之间;另一轮里max_len略好于默认,方向不一致,差别在 ±10% 以内。min_len = 10⁵只有 8 片,12 个线程里只有 8 个有活,掉到 7.5×(另一轮 7.0×),接近 8。 - 结论:
with_min_len是给每元素很便宜的循环用的,设到「每片的工作量明显大于一次join」即可;对重计算设得太大,会让片数少于线程数。with_max_len很少需要,除非元素代价极不均匀、又不希望依赖窃取来平衡。
历史
- rayon 0.7(2017-04-06)的发布说明写:「Parallel iterators now allow you to customize the min/max number of items to be processed in a given thread. This mechanism replaces the older
weightmechanism」。对应 #260(合并提交f75e2e6,2017-03-21),其中c339b53「Remove the weight APIs」删掉了旧的「权重」接口,133e0cc改名为with_min_len/with_max_len。 - 那时的
Splitter已经是 thief-splitting,但判断「被偷」的办法是比较线程:Splitter多存一个origin,取一个线程本地变量的地址当线程 ID,try()里发现当前线程和origin不同就重置(f75e2e6版本的src/iter/internal.rs)。 7970d16「Introducejoin_notifywhich tells whether jobs were stolen」(2017-05-31,经 #354 合并)去掉了这个线程本地变量,改由join直接告诉闭包是否被偷;它随 rayon 0.9.0 / rayon-core 1.3.0(2017-11-09)以join_context+FnContext的名字发布。按旧代码推算,从池外调用时根的两个孩子在 worker 上执行、线程 ID 与main不同,同样会重置,所以「池外多一层」在换成migrated()之前就存在(推算,没有运行旧版本验证)。03e3d3a(2019-04-26)把try改名为try_split(try成了保留字)。此后到v1.12.0,plumbing/mod.rs的改动都是语法层面的(Rust 2018 路径、Ord::max代替cmp::max、文档链接),切分规则没有变。
小结
bridge→with_producer→bridge_producer_consumer:永远对半切,LengthSplitter是Copy的,每一支各自决定,没有共享状态。Splitter:初值线程数,不被偷就减半,被偷(migrated())就重置为max(线程数, splits/2)并照切。t 个线程时池内最少 2^(⌊log₂t⌋+1) 片,12 线程是 16。- 从池外调用时根
join经in_worker_cold注入,两个孩子的migrated()都为真,多重置一层:12 线程最少 64 片,1 线程 8 片对 2 片(E2 完全确定)。 - 有窃取时叶子数远大于下限且每次不同(n = 2²⁰ 约 1100–1900,每元素 200 次乘法时 2200–5100,随负载变化)。
- E4:轻工作量时
with_min_len(10⁴)把 0.32 ms 降到 0.11 ms,with_max_len(10)升到 0.49 ms;重工作量时默认已是最好,片数少于线程数(min_len = 10⁵,8 片)掉到 7.5×。 with_min_len是硬下限,能把叶子数钉死(10⁵ → 8 片),设成len就完全不进线程池;with_max_len只保证「至少切到这么细」。