唤醒策略与实测
主线位置:一个线程睡下又醒来的后半。
push/inject→new_jobs:JEC 是偶数就加一;有人在睡时,按「队列原来是否为空」和「醒着的空闲线程数」决定叫醒几个 →wake_any_threads从 0 号线程起逐个wake_specific_thread→ 被叫醒的线程找到活时work_found,再叫醒至多两个。
flowchart TB
pub["push / inject 一个任务"] --> jec["JEC 偶数则 +1;读 sleeping、inactive"]
jec --> any{"sleeping == 0?"}
any -->|"是"| done["不叫"]
any -->|"否"| qe{"队列原来为空?"}
qe -->|"否"| w1["叫醒 min(新任务数, sleeping) 个"]
qe -->|"是"| idle{"醒着的空闲线程数 小于 新任务数?"}
idle -->|"否"| done
idle -->|"是"| w2["叫醒 min(差额, sleeping) 个"]
w1 --> scan["wake_any_threads:从 0 号起找 is_blocked 的线程"]
w2 --> scan
scan --> found["被叫醒的线程找到活:work_found"]
found --> chain["sleeping 大于 0 就再叫醒至多 2 个"]
chain --> scan
握手一章回答了「不丢唤醒」;这一章回答「叫醒几个、叫醒谁」,然后用实验看这些规则在本机上的效果。这里的规则只影响性能:叫多了浪费 CPU,叫少了少几个帮手,都不影响正确性。
new_jobs:叫醒几个
fn new_jobs(&self, num_jobs: u32, queue_was_empty: bool) {
// Read the counters and -- if sleepy workers have announced themselves
// -- announce that there is now work available. The final value of `counters`
// with which we exit the loop thus corresponds to a state when
let counters = self
.counters
.increment_jobs_event_counter_if(JobsEventCounter::is_sleepy);
let num_awake_but_idle = counters.awake_but_idle_threads();
let num_sleepers = counters.sleeping_threads();
if num_sleepers == 0 {
// nobody to wake
return;
}
// Promote from u16 to u32 so we can interoperate with
// num_jobs more easily.
let num_awake_but_idle = num_awake_but_idle as u32;
let num_sleepers = num_sleepers as u32;
// If the queue is non-empty, then we always wake up a worker
// -- clearly the existing idle jobs aren't enough. Otherwise,
// check to see if we have enough idle workers.
if !queue_was_empty {
let num_to_wake = Ord::min(num_jobs, num_sleepers);
self.wake_any_threads(num_to_wake);
} else if num_awake_but_idle < num_jobs {
let num_to_wake = Ord::min(num_jobs - num_awake_but_idle, num_sleepers);
self.wake_any_threads(num_to_wake);
}
}
(第一段注释原文就断在「corresponds to a state when」。)
两个调用方传进来的 num_jobs 都是 1,queue_was_empty 是推之前队列是否为空:本地 push 看的是推的那个线程自己的 deque,inject 看的是全局注入队列。
| 情况 | 叫醒 | 理由(注释) |
|---|---|---|
| 没有线程在睡 | 0 | 没人可叫 |
| 队列原来非空 | 1 个 | 「队列里本来就有活没人拿,现有的空闲线程显然不够」 |
| 队列原来为空,有醒着的空闲线程 | 0 | 那个空闲线程还在转,会找到它 |
| 队列原来为空,没有醒着的空闲线程 | 1 个 | 差额 = 1 − 0 |
对 join 来说:一个忙碌的 worker 推 B 时,如果池里还有线程在转(idle),就不叫任何人,让转着的那个去偷;如果大家都睡了,叫醒一个。忙碌的池里几乎没有人在睡,new_jobs 读到 sleeping == 0 就返回,这是每次 join 的常态成本:一次 SeqCst 读,JEC 是偶数时再加一次 CAS。
wake_any_threads:从 0 号开始
fn wake_any_threads(&self, mut num_to_wake: u32) {
if num_to_wake > 0 {
for i in 0..self.worker_sleep_states.len() {
if self.wake_specific_thread(i) {
num_to_wake -= 1;
if num_to_wake == 0 {
return;
}
}
}
}
}
从 0 号线程开始,逐个拿 is_blocked 锁看是不是在睡,叫醒够数就停。没有随机、没有轮转:总是编号最小的那个睡着的线程先被叫醒。最坏情况下(只有最后一个线程在睡)要拿 N 把锁;不过前面醒着的线程的锁通常没人争,拿一次很便宜。
work_found:链式扩散
sleep/mod.rs 第 79–85 行 与 counters.rs 第 145–169 行:
pub(super) fn work_found(&self) {
// If we were the last idle thread and other threads are still sleeping,
// then we should wake up another thread.
let threads_to_wake = self.counters.sub_inactive_thread();
self.wake_any_threads(threads_to_wake as u32);
}
pub(super) fn sub_inactive_thread(&self) -> usize {
let old_value = Counters::new(self.value.fetch_sub(ONE_INACTIVE, Ordering::SeqCst));
// ...
// Current heuristic: whenever an inactive thread goes away, if
// there are any sleeping threads, wake 'em up.
let sleeping_threads = old_value.sleeping_threads();
Ord::min(sleeping_threads, 2)
}
线程每次从空闲变成忙碌(找到活,或者等的 latch 被设置),只要还有线程在睡,就再叫醒至多两个。work_found 上的注释说「如果我们是最后一个空闲线程」,代码并没有检查这个条件;counters.rs 里的注释(「只要有一个 inactive 线程离开、还有线程在睡,就叫醒它们」)和代码一致。
意图是让唤醒沿着任务扩散:一个大任务刚开始时只有一个线程醒来,它开始干活(work_found),叫醒两个;这两个偷到活,又各叫醒两个……几轮之后整个池都醒了,而不需要发布任务的一方一次叫醒所有人。代价也很明显:被叫醒的线程未必有活干,没找到就空转 33 轮再睡回去。下面的实验直接量了这一点。
notify_worker_latch_is_set:点名叫醒
sleep/mod.rs 第 196–202 行 就是 wake_specific_thread(target),不看计数器、不算差额。用在三处:SpinLatch / CountLatch 设置时旧值是 SLEEPING(叫醒等这个 latch 的主人)、OnceLatch 终止信号、inject_broadcast 对每个线程各叫一次。这是 2020 年新调度器发布说明里的「对被偷任务完成的通知使用定向唤醒」:旧方案里任何 latch 设置都 tickle 整个池(notify_all)。
实测
exp-rayon/sleep-idle/,M2 Max(8P + 4E),macOS。每个模式启动时用 start_handler 记下每个 worker 的 mach 线程端口,可以从外面按线程读 CPU 时间(thread_info,微秒精度);broadcast 一次让所有线程起来,再睡 200 ms 让它们全部睡下。所有计时都在 1 分钟负载 1.7–2.6、5 分钟负载 2.7–3.0 时取得(20:48–20:55 之间)。
一次注入叫醒了谁(wakers)
池完全睡着后,从 main 里 rayon::spawn 一个空任务,等它执行完再等 50 ms,比较前后每个 worker 的 CPU 时间,动过的就是这次被叫醒过的。每种线程数 41 次,跑三组:
| 池大小 | 「任务跑在哪 / 哪些线程的 CPU 时间动了」 | 三组各 41 次 |
|---|---|---|
| 1 | 0 号 / {0} | 41、41、41 |
| 2 | 0 号 / {0, 1} | 41、41、41 |
| 3 | 0 号 / {0, 1, 2} | 41、41、41 |
| 12 | 0 号 / {0, 1, 2} | 41、41、41 |
每个被叫醒线程的 CPU 时间中位数(µs):
| 池大小 | 第一组 | 第二组 | 第三组 |
|---|---|---|---|
| 1 | w0 19 | w0 54 | w0 60 |
| 2 | w0 22,w1 14 | w0 71,w1 48 | w0 75,w1 47 |
| 3 | w0 126,w1 144,w2 114 | w0 80,w1 57,w2 51 | w0 78,w1 54,w2 54 |
| 12 | w0 92,w1 67,w2 64 | w0 100,w1 73,w2 67 | w0 95,w1 69,w2 62 |
和代码推出来的一模一样:
inject→new_injected_jobs:注入队列原来为空、醒着的空闲线程数 0 < 1,叫醒 1 个;wake_any_threads从 0 号找起,叫醒 0 号。- 0 号醒来,
find_work从注入队列拿到任务,work_found:此时还有 N−1 个在睡,叫醒 min(N−1, 2) 个,又从 0 号找起,0 号醒着跳过,叫醒 1 号、2 号。 - 1 号、2 号醒来,没有活(任务已经被 0 号拿走),转 33 轮后睡回去。它们没找到活,不调
work_found,链条到此为止。
所以 12 线程的池里,一个小任务让 3 个线程醒来,其中 2 个白转一圈。1 号、2 号那一圈的 CPU 时间就是「被叫醒 + 34 次 find_work + 33 次 yield_now + 重新睡下」的总成本,本机在 14–144 µs 之间,多数组是 50–70 µs(组间差别比组内大,推测和线程被调度到性能核还是能效核有关,macOS 不能绑核,无法验证)。其中 33 次 yield_now 只占约 4 µs(握手一章量的单次 113–118 ns),其余是 34 次 find_work(12 线程时每次轮询 11 个别人的 deque 和注入队列)、Condvar 的唤醒与重新睡下。线程数 1、2 时醒来的集合是 {0}、{0, 1},这是反向验证:链式唤醒受「还有几个在睡」限制。
任务落在哪些线程上(who / spread)
who:池睡着(每次之前空闲 10 ms)时单独 spawn 一个任务 200 次,任务全部(200/200)在 0 号线程上执行。反向:不空闲、连续 spawn 200 次(线程还在空转),12 个线程分别执行了 8–25 次,分布大致均匀。
spread:模拟轻负载,每 10 ms 一阵,每阵在 scope 里 spawn 4 个各忙 200 µs 的任务,共 200 阵 800 个任务。两次运行每个线程执行的任务数:
| 线程 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8–11 |
|---|---|---|---|---|---|---|---|---|---|
| 第一次 | 164 | 201 | 190 | 164 | 68 | 12 | 0 | 1 | 0 |
| 第二次 | 178 | 209 | 184 | 154 | 65 | 5 | 3 | 2 | 0 |
轻负载下活几乎全落在编号最小的五个线程上,8–11 号一个任务都没拿到。这是 wake_any_threads 从 0 号找起、work_found 每次只叫醒两个共同的结果:只要并行度用不满整个池,高编号的线程就一直睡着。对 CPU 占用这是好事(没用到的线程不空转);对缓存和调度,任务固定在少数线程上也不是坏事。代价可能出现在异构核上:如果 OS 恰好把 0–4 号线程放在能效核上,rayon 自己不会把活挪到别的线程(推测,未测)。规划时留下的问题「从 0 号起叫醒是否让低编号线程承担更多任务」,答案是:轻负载下是,而且非常明显。
空闲的池花多少 CPU(idle / burst)
idle:预热后让池完全空闲 1 秒,进程 CPU 时间(getrusage,所有线程合计)三次分别 14、10、7 µs,也就是 12 个线程睡着时基本不花 CPU。反向验证:一个任务忙转 1 秒,同样的方法量到 999.96 ms,说明这个量法能看到空转。
burst:从 main 连续调 1000 次空的 rayon::join,三次:
| 池大小 | 墙钟 | 期间 CPU | 之后 1 秒 CPU |
|---|---|---|---|
| 12 | 11.9 / 14.8 / 27.0 ms | 35.5 / 45.5 / 238.9 ms | 2.0 / 1.8 / 0.2 ms |
| 1 | 4.5 / 5.9 / 13.4 ms | 5.4 / 7.2 / 14.8 ms | 12 / 30 / 43 µs |
12 线程时期间的 CPU 是墙钟的 3–9 倍:每次 join 进池都会唤醒线程,而被唤醒的线程在两次 join 之间还没睡下(每次往返约 10 µs,比空转一圈的几十微秒短),一直在转着找活。1 线程时 CPU ≈ 墙钟。最后一组 12 线程的 239 ms 是一个离群值,组间差异大。停下来之后 1 秒内,CPU 回到几乎为零。
唤醒延迟(latency)
从 main 里 rayon::spawn,任务里把「从 spawn 到开始执行」的时间发回来;spawn 前先空闲一段时间,每档 21 次取中位数和 p90,三组:
| 空闲时长 | 12 线程,中位数(三组) | 12 线程 p90 | 1 线程,中位数(三组) | 1 线程 p90 |
|---|---|---|---|---|
| 0 | 2.4–8.7 µs | 11–22 µs | 0.6–2.4 µs | 3.6–6.7 µs |
| 10 µs | 2.1–11.0 µs | 7–20 µs | 3.4–5.3 µs | 6.8–8.7 µs |
| 100 µs | 3.5–9.1 µs | 7–11 µs | 3.3–6.4 µs | 4.4–11 µs |
| 1 ms | 4.3–14.6 µs | 6–21 µs | 4.3–8.6 µs | 5.4–16 µs |
| 10 ms | 10.3–29.3 µs | 20–35 µs | 15.3–19.5 µs | 23–26 µs |
| 100 ms | 24.5–36.0 µs | 44–50 µs | 14.8–21.9 µs | 33–35 µs |
两个台阶:空闲 1 ms 以内,线程大多还在 33 轮空转里(或刚睡下),延迟是几微秒;空闲 10 ms 以上,线程都已经睡在 Condvar 上,延迟升到 15–36 µs,就是一次 Condvar 唤醒加线程被 OS 调度上来的时间。12 线程时更慢一些:任务注入后要叫醒 0 号,0 号醒来还要先叫醒 1、2 号(work_found 在 execute 之前调用)才开始执行任务。
从池外调一次 join(outside-join)
rayon::join(|| 1, || 2) 从 main 调用一次的往返时间(201 次取中位数,三组):
| 池大小 | 连续调用 | 每次之前空闲 10 ms |
|---|---|---|
| 12 | 10.5 / 10.6 / 35.3 µs | 42.4 / 38.6 / 40.8 µs |
| 1 | 4.2 / 7.9 / 7.3 µs | 26.8 / 14.9 / 27.0 µs |
对照 join 一章:池内一次 join 在 1 线程下约 6.6 ns。池外调用要「注入 → 叫醒 worker → worker 执行 → LockLatch::set 叫醒调用线程」,即使线程都醒着也要几微秒到十几微秒;池睡着时是两次真正的 Condvar 唤醒,约 15–40 µs,比池内贵三到四个数量级。
candle 卷里那几十微秒
candle 卷的量化 llama 一章实测:上下文 32 时,softmax_last_dim 只有 9 行、每行 33 个数,却要几十微秒;RAYON_NUM_THREADS=1 时降到 9 µs 左右。pytorch-to-candle 卷的速度一章采样到 217 个样本里 205 个在 rayon 的 in_worker_cold,栈顶是 __psynch_cvwait。
用这一部分的代码和数字可以把这几十微秒拆开(各项的量级来自上面的实验,加总是推算):
- 主线程不是 worker,
par_chunks最外层的join_context走in_worker_cold:任务注入全局队列,主线程睡在线程本地的LockLatch上——就是那个__psynch_cvwait。 - 两次 softmax 之间主线程做别的事,池里的线程转完 33 轮已经睡了。注入叫醒 0 号;0 号醒来、拿到任务,再叫醒 1、2 号;切分出的子任务被偷走时,偷到活的线程又各叫醒两个。每一级都是一次「锁 +
notify_one+ 对方被调度」。 - 任务做完,最外层
StackJob的LockLatch::set叫醒主线程,又是一次Condvar唤醒。
上面「从池外调一次空 join」的往返时间就是 1 和 3 的成本加上一次唤醒;9 行数据的实际计算只有几百纳秒。RAYON_NUM_THREADS=1 时仍有注入和两次 Condvar 唤醒,但没有链式唤醒、没有窃取、没有跨核的缓存行搬运。candle 0.11.0 提供了 Device::with_context:在 CPU 上把整个闭包放进 candle 自己的 rayon 池里执行,闭包里的 par_chunks 由池内线程直接分活(pytorch-to-candle 卷那一章引了它的文档注释「keeping worker threads warm」,实测 decode 16.05 → 13.86 ms)。它绕开的正是第 1、3 步,第 2 步的链式唤醒在两次并行段之间隔得够久(超过线程空转的那几十微秒)时应该仍然存在(推测,未测)。
小结
new_jobs:没人在睡就不叫;队列原来非空叫一个;队列原来为空时,只有醒着的空闲线程不够才叫差额。忙碌的池里每次push只付一次读。wake_any_threads总是从 0 号线程找起;work_found让每个开始干活的线程再叫醒至多两个,唤醒沿任务扩散。- 实测:一个任务注入睡着的 12 线程池,41/41 次恰好 0、1、2 号醒来,任务在 0 号上执行,1、2 号白转一圈。
- latch 设置、终止、广播用
wake_specific_thread点名叫醒,不经过计数器。 - 从池外进池的固定成本是注入 + 两次
Condvar唤醒(+ 链式唤醒),这是 candle 小 softmax「几十微秒」的来源。