Rust in Everything
基于 rayon-core 1.13.0

唤醒策略与实测

主线位置:一个线程睡下又醒来的后半。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:叫醒几个

sleep/mod.rs 第 240–272 行:

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 号开始

sleep/mod.rs 第 274–286 行:

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 次
10 号 / {0}41、41、41
20 号 / {0, 1}41、41、41
30 号 / {0, 1, 2}41、41、41
120 号 / {0, 1, 2}41、41、41

每个被叫醒线程的 CPU 时间中位数(µs):

池大小第一组第二组第三组
1w0 19w0 54w0 60
2w0 22,w1 14w0 71,w1 48w0 75,w1 47
3w0 126,w1 144,w2 114w0 80,w1 57,w2 51w0 78,w1 54,w2 54
12w0 92,w1 67,w2 64w0 100,w1 73,w2 67w0 95,w1 69,w2 62

和代码推出来的一模一样:

  1. inject → new_injected_jobs:注入队列原来为空、醒着的空闲线程数 0 < 1,叫醒 1 个;wake_any_threads 从 0 号找起,叫醒 0 号。
  2. 0 号醒来,find_work 从注入队列拿到任务,work_found:此时还有 N−1 个在睡,叫醒 min(N−1, 2) 个,又从 0 号找起,0 号醒着跳过,叫醒 1 号、2 号。
  3. 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 个任务。两次运行每个线程执行的任务数:

线程012345678–11
第一次1642011901646812010
第二次178209184154655320

轻负载下活几乎全落在编号最小的五个线程上,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
1211.9 / 14.8 / 27.0 ms35.5 / 45.5 / 238.9 ms2.0 / 1.8 / 0.2 ms
14.5 / 5.9 / 13.4 ms5.4 / 7.2 / 14.8 ms12 / 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 线程 p901 线程,中位数(三组)1 线程 p90
02.4–8.7 µs11–22 µs0.6–2.4 µs3.6–6.7 µs
10 µs2.1–11.0 µs7–20 µs3.4–5.3 µs6.8–8.7 µs
100 µs3.5–9.1 µs7–11 µs3.3–6.4 µs4.4–11 µs
1 ms4.3–14.6 µs6–21 µs4.3–8.6 µs5.4–16 µs
10 ms10.3–29.3 µs20–35 µs15.3–19.5 µs23–26 µs
100 ms24.5–36.0 µs44–50 µs14.8–21.9 µs33–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
1210.5 / 10.6 / 35.3 µs42.4 / 38.6 / 40.8 µs
14.2 / 7.9 / 7.3 µs26.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。

用这一部分的代码和数字可以把这几十微秒拆开(各项的量级来自上面的实验,加总是推算):

  1. 主线程不是 worker,par_chunks 最外层的 join_context 走 in_worker_cold:任务注入全局队列,主线程睡在线程本地的 LockLatch 上——就是那个 __psynch_cvwait。
  2. 两次 softmax 之间主线程做别的事,池里的线程转完 33 轮已经睡了。注入叫醒 0 号;0 号醒来、拿到任务,再叫醒 1、2 号;切分出的子任务被偷走时,偷到活的线程又各叫醒两个。每一级都是一次「锁 + notify_one + 对方被调度」。
  3. 任务做完,最外层 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「几十微秒」的来源。

💬 关联讨论

发起讨论
加载中...