Skip to main content

cortiq_engine/
pool.rs

1//! Persistent worker pool for row-parallel matvecs.
2//!
3//! Threads are spawned once and spin-then-park between calls — vmfcore
4//! measured spawn-per-matvec at ~+27% decode cost versus a persistent
5//! pool. Parallelism is by disjoint row ranges, so results are
6//! bit-identical to the serial path (each row's dot product is computed
7//! the same way).
8//!
9//! Dispatch is a single shared job slot + atomic epoch (roadmap §3 P0):
10//! the caller publishes one pointer, bumps the epoch and JOINS THE WORK
11//! as the extra worker instead of blocking on a latch. The previous
12//! design allocated an `Arc<Latch>` and pushed a message into every
13//! worker's mpsc channel for every matvec (~200 dispatches/token) —
14//! with decode-grade matvecs that synchronization was its own budget.
15//! Workers spin for `CMF_POOL_SPIN` iterations before parking.
16//! Default 4000: at ~39 dispatches/token, park-immediately pays the
17//! unpark syscall on every worker for every dispatch — measured on an
18//! M4 (interleaved A/B, current epoch dispatch + parked-flag design):
19//! Qwen-0.5B q8 decode 101→115 tok/s, q4t 117→149, the 50M bench model
20//! 549→954 at spin=4000 vs spin=0. An early measurement that showed
21//! spinning LOSING (−25% on q8) predates the parked-flag skip and the
22//! multi-matrix dispatch cuts; it no longer reproduces. Over-spinning
23//! still hurts (200k: −15% vs 4k — spinners steal the caller's serial
24//! cycles), so the budget stays bounded. `CMF_POOL_SPIN=0` restores
25//! park-immediately for share-the-box serving.
26//!
27//! `CMF_THREADS` env: 0/1 = serial, N = worker count
28//! (default: available_parallelism − 1, capped at 8).
29
30use std::cell::UnsafeCell;
31use std::sync::Arc;
32use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33
34/// A `*const dyn Fn` that may cross a thread boundary. Safety is
35/// provided by `Pool::run`: the caller blocks until every worker has
36/// finished, so the borrow outlives all uses.
37#[derive(Clone, Copy)]
38struct TaskPtr(*const (dyn Fn(usize, usize) + Sync));
39unsafe impl Send for TaskPtr {}
40
41struct Inner {
42    /// Bumped once per published job; workers watch it.
43    epoch: AtomicUsize,
44    /// Workers still running the current job (excludes the caller).
45    remaining: AtomicUsize,
46    /// The published job: closure pointer + total participant count.
47    /// Written by the caller BEFORE the epoch bump, read by workers
48    /// AFTER they observe the new epoch (acquire/release pairing).
49    slot: UnsafeCell<Option<(TaskPtr, usize)>>,
50    shutdown: AtomicBool,
51    /// Spin iterations before a worker parks (0 = park immediately).
52    spin_budget: usize,
53    /// Per-worker "I am parked" flags — lets the caller skip the unpark
54    /// syscall for workers that are still spinning.
55    parked: Box<[AtomicBool]>,
56}
57
58// SAFETY: `slot` is only written while no job is in flight (run()
59// returns after `remaining` hits 0) and only read after the epoch
60// publication that follows the write.
61unsafe impl Sync for Inner {}
62
63/// Process-wide dispatch counter (roadmap §3 P0 «измерения»): one tick
64/// per published job. `bench --json` reports dispatches/token from it.
65static DISPATCHES: AtomicUsize = AtomicUsize::new(0);
66
67/// Total pool jobs published since process start (all pools).
68pub fn dispatch_count() -> usize {
69    DISPATCHES.load(Ordering::Relaxed)
70}
71
72/// Persistent thread pool: shared job slot, epoch dispatch, caller
73/// participation.
74pub struct Pool {
75    inner: Arc<Inner>,
76    /// Thread handles for `unpark` (same order as `parked`).
77    threads: Vec<std::thread::Thread>,
78    joins: Vec<std::thread::JoinHandle<()>>,
79}
80
81fn spin_budget_from_env() -> usize {
82    std::env::var("CMF_POOL_SPIN")
83        .ok()
84        .and_then(|v| v.parse::<usize>().ok())
85        .unwrap_or(4000)
86}
87
88impl Pool {
89    pub fn new(n_workers: usize) -> Self {
90        Self::with_spin(n_workers, spin_budget_from_env())
91    }
92
93    /// Explicit spin budget (tests pin it without touching the env).
94    pub fn with_spin(n_workers: usize, spin_budget: usize) -> Self {
95        let inner = Arc::new(Inner {
96            epoch: AtomicUsize::new(0),
97            remaining: AtomicUsize::new(0),
98            slot: UnsafeCell::new(None),
99            shutdown: AtomicBool::new(false),
100            spin_budget,
101            parked: (0..n_workers).map(|_| AtomicBool::new(false)).collect(),
102        });
103        let mut joins = Vec::with_capacity(n_workers);
104        for w in 0..n_workers {
105            let inner = inner.clone();
106            let h = std::thread::Builder::new()
107                .name(format!("cmf-pool-{w}"))
108                .spawn(move || worker_loop(&inner, w))
109                .expect("spawn pool worker");
110            joins.push(h);
111        }
112        let threads = joins.iter().map(|h| h.thread().clone()).collect();
113        Self {
114            inner,
115            threads,
116            joins,
117        }
118    }
119
120    /// Big-core count on heterogeneous ARM (big.LITTLE): the kernel
121    /// exposes per-core capacity on Android and most ARM Linux; efficiency
122    /// cores in the pool DRAG the big ones on our row-parallel jobs (the
123    /// same cliff llama.cpp hits at -t 10 on an M4: 163 → 112 tok/s).
124    /// None = capacities absent or homogeneous.
125    #[cfg(all(
126        target_arch = "aarch64",
127        any(target_os = "linux", target_os = "android")
128    ))]
129    fn big_cores() -> Option<usize> {
130        let mut caps: Vec<u64> = Vec::new();
131        for cpu in 0.. {
132            let path = format!("/sys/devices/system/cpu/cpu{cpu}/cpu_capacity");
133            match std::fs::read_to_string(&path) {
134                Ok(v) => caps.push(v.trim().parse().ok()?),
135                Err(_) => break,
136            }
137        }
138        Self::cores_from_capacities(&caps)
139    }
140
141    /// How many cores the pool should use, from the kernel's per-core
142    /// capacity values. Capacity folds µarch × clock into one number,
143    /// and the two need different treatment: cores of ANOTHER µarch
144    /// (A5xx efficiency cluster next to A7xx/X: capacity ratio ≥ ~2)
145    /// drag row-parallel work down and are excluded; cores of the SAME
146    /// µarch merely clock-binned (JLQ JR510: 8×A55 as 4×2.0 + 4×1.5 GHz,
147    /// ratio 1.33) pull their weight and must ALL be used. The 1.6
148    /// threshold splits the two regimes: on a Snapdragon 8-class part
149    /// it keeps X + A7xx mid cores and drops A5xx.
150    #[cfg_attr(
151        not(all(
152            target_arch = "aarch64",
153            any(target_os = "linux", target_os = "android")
154        )),
155        allow(dead_code)
156    )]
157    fn cores_from_capacities(caps: &[u64]) -> Option<usize> {
158        let max = *caps.iter().max()?;
159        let min = *caps.iter().min()?;
160        if caps.len() < 2 || max == min {
161            return None;
162        }
163        Some(caps.iter().filter(|&&c| c * 8 >= max * 5).count())
164    }
165
166    #[cfg(target_os = "macos")]
167    fn big_cores() -> Option<usize> {
168        unsafe extern "C" {
169            fn sysctlbyname(
170                name: *const std::ffi::c_char,
171                oldp: *mut std::ffi::c_void,
172                oldlenp: *mut usize,
173                newp: *mut std::ffi::c_void,
174                newlen: usize,
175            ) -> std::ffi::c_int;
176        }
177        unsafe {
178            let name = std::ffi::CString::new("hw.perflevel0.physicalcpu").ok()?;
179            let mut count: i32 = 0;
180            let mut size = std::mem::size_of::<i32>();
181            let ret = sysctlbyname(
182                name.as_ptr(),
183                &mut count as *mut i32 as *mut std::ffi::c_void,
184                &mut size,
185                std::ptr::null_mut(),
186                0,
187            );
188            if ret == 0 && count > 0 {
189                Some(count as usize)
190            } else {
191                None
192            }
193        }
194    }
195
196    #[cfg(not(any(
197        all(
198            target_arch = "aarch64",
199            any(target_os = "linux", target_os = "android")
200        ),
201        target_os = "macos"
202    )))]
203    fn big_cores() -> Option<usize> {
204        None
205    }
206
207    /// Pool sized from `CMF_THREADS` (see module docs). `None` = serial.
208    /// Without the env, heterogeneous ARM defaults to its BIG cores.
209    pub fn from_env() -> Option<Arc<Self>> {
210        let n = match std::env::var("CMF_THREADS") {
211            Ok(v) => v.parse::<usize>().unwrap_or(0),
212            Err(_) => match Self::big_cores() {
213                Some(big) => big,
214                None => {
215                    let avail = std::thread::available_parallelism()
216                        .map(|n| n.get())
217                        .unwrap_or(1);
218                    avail.saturating_sub(1).min(8)
219                }
220            },
221        };
222        if n <= 1 {
223            None
224        } else {
225            Some(Arc::new(Self::new(n)))
226        }
227    }
228
229    /// Spawned worker threads (the caller joins each job on top).
230    pub fn n_workers(&self) -> usize {
231        self.threads.len()
232    }
233
234    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
235    ///
236    /// One dispatch, but workers pull row-ranges from a shared cursor
237    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
238    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
239    /// every matvec end at the SLOWEST core's pace while the fast ones
240    /// idle at the barrier; pulling by grain lets a P-core take several
241    /// chunks for each one an E-core takes, so skew collapses to a
242    /// single grain. Row ranges stay disjoint and each row's dot is
243    /// computed exactly as in the serial path → bit-identical output.
244    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
245        // Enough chunks to balance, large enough to keep the SDOT inner
246        // loop and the hardware prefetcher in their stride.
247        let grain = (rows / ((self.threads.len() + 1) * 8)).max(32);
248        let next = AtomicUsize::new(0);
249        self.run(&|_w, _n| loop {
250            let start = next.fetch_add(grain, Ordering::Relaxed);
251            if start >= rows {
252                break;
253            }
254            f(start, (start + grain).min(rows));
255        });
256    }
257
258    /// Multi-matrix job: one dispatch serves SEVERAL row spaces
259    /// (roadmap §3 P0 — «одна внешняя публикация job на слой»). Parts
260    /// are laid out back-to-back in a virtual row space and pulled by
261    /// grain from one shared cursor, so QKV or gate+up cost a single
262    /// barrier instead of one each. Each part's `f(start, end)` sees its
263    /// OWN row indices — per-row math and outputs are bit-identical to
264    /// separate `run_rows` calls.
265    pub fn run_many(&self, parts: &[(usize, &(dyn Fn(usize, usize) + Sync))]) {
266        let total: usize = parts.iter().map(|p| p.0).sum();
267        if total == 0 {
268            return;
269        }
270        let grain = (total / ((self.threads.len() + 1) * 8)).max(32);
271        let next = AtomicUsize::new(0);
272        self.run(&|_w, _n| loop {
273            let s = next.fetch_add(grain, Ordering::Relaxed);
274            if s >= total {
275                break;
276            }
277            let e = (s + grain).min(total);
278            let mut base = 0usize;
279            for &(rows, f) in parts {
280                let a = s.max(base);
281                let b = e.min(base + rows);
282                if a < b {
283                    f(a - base, b - base);
284                }
285                base += rows;
286                if base >= e {
287                    break;
288                }
289            }
290        });
291    }
292
293    /// Run `f(worker_idx, n_participants)` on every worker AND the
294    /// calling thread (`worker_idx = n_workers()` for the caller);
295    /// returns when all participants have finished.
296    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
297        DISPATCHES.fetch_add(1, Ordering::Relaxed);
298        let nw = self.threads.len();
299        let n = nw + 1; // caller participates
300        // SAFETY: the wait loop below blocks until every worker is done,
301        // so extending the borrow to 'static never outlives the call.
302        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
303        let ptr: *const (dyn Fn(usize, usize) + Sync + 'static) =
304            unsafe { std::mem::transmute(ptr) };
305        // SAFETY: no job in flight (previous run() drained `remaining`),
306        // so the slot is not being read.
307        unsafe { *self.inner.slot.get() = Some((TaskPtr(ptr), n)) };
308        self.inner.remaining.store(nw, Ordering::Relaxed);
309        self.inner.epoch.fetch_add(1, Ordering::SeqCst);
310        for (i, t) in self.threads.iter().enumerate() {
311            if self.inner.parked[i].load(Ordering::SeqCst) {
312                t.unpark();
313            }
314        }
315
316        // The caller's share — the barrier costs nothing while there is
317        // real work to do.
318        f(nw, n);
319
320        // Wait for the stragglers (bounded by one worker's chunk).
321        let mut spins = 0usize;
322        while self.inner.remaining.load(Ordering::Acquire) != 0 {
323            spins += 1;
324            if spins < 10_000 {
325                std::hint::spin_loop();
326            } else {
327                std::thread::yield_now();
328            }
329        }
330    }
331}
332
333impl Drop for Pool {
334    fn drop(&mut self) {
335        self.inner.shutdown.store(true, Ordering::SeqCst);
336        for t in &self.threads {
337            t.unpark();
338        }
339        for h in self.joins.drain(..) {
340            let _ = h.join();
341        }
342    }
343}
344
345#[cfg(target_os = "android")]
346fn pin_thread_to_big_cores() {
347    use std::mem;
348    let mut caps: Vec<u64> = Vec::new();
349    for cpu in 0.. {
350        let path = format!("/sys/devices/system/cpu/cpu{cpu}/cpu_capacity");
351        match std::fs::read_to_string(&path) {
352            Ok(v) => {
353                if let Ok(cap) = v.trim().parse() {
354                    caps.push(cap);
355                } else {
356                    break;
357                }
358            }
359            Err(_) => break,
360        }
361    }
362    let max = caps.iter().copied().max().unwrap_or(0);
363    let min = caps.iter().copied().min().unwrap_or(0);
364
365    // Only pin if heterogeneous
366    if caps.len() < 2 || max == min {
367        return;
368    }
369
370    unsafe {
371        let mut set: libc::cpu_set_t = mem::zeroed();
372        for (i, &c) in caps.iter().enumerate() {
373            if c * 8 >= max * 5 {
374                libc::CPU_SET(i, &mut set);
375            }
376        }
377        libc::sched_setaffinity(0, mem::size_of::<libc::cpu_set_t>(), &set);
378    }
379}
380
381fn worker_loop(inner: &Inner, idx: usize) {
382    #[cfg(target_os = "android")]
383    pin_thread_to_big_cores();
384
385    // The pool is created at epoch 0; baseline MUST be 0, not a fresh
386    // epoch read — if the caller publishes a job before the OS actually
387    // starts this thread, reading the live epoch would adopt that job's
388    // epoch as "already seen", skip it, and deadlock the caller's wait.
389    let mut seen = 0usize;
390    loop {
391        // Wait for a new epoch: spin first (decode publishes the next
392        // matvec within microseconds), park only when idle for real.
393        let mut spins = 0usize;
394        loop {
395            let e = inner.epoch.load(Ordering::Acquire);
396            if e != seen {
397                seen = e;
398                break;
399            }
400            if inner.shutdown.load(Ordering::Relaxed) {
401                return;
402            }
403            if spins < inner.spin_budget {
404                spins += 1;
405                std::hint::spin_loop();
406            } else {
407                inner.parked[idx].store(true, Ordering::SeqCst);
408                // Re-check under SeqCst: the caller bumps the epoch
409                // BEFORE reading `parked`, so either it sees our flag
410                // (and unparks) or we see its epoch here — a missed
411                // wakeup is impossible. Spurious unparks just loop.
412                if inner.epoch.load(Ordering::SeqCst) == seen
413                    && !inner.shutdown.load(Ordering::Relaxed)
414                {
415                    std::thread::park();
416                }
417                inner.parked[idx].store(false, Ordering::SeqCst);
418            }
419        }
420        // SAFETY: the slot was written before the epoch bump we just
421        // observed (release/acquire), and stays valid until `remaining`
422        // drops to zero — which happens only after `f` returns below.
423        let (task, n) = unsafe { (*inner.slot.get()).expect("job published with epoch") };
424        let f = unsafe { &*task.0 };
425        f(idx, n);
426        inner.remaining.fetch_sub(1, Ordering::AcqRel);
427    }
428}
429
430/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
431/// Bit-identical to the serial loop (row order does not change math).
432pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
433    let in_dim = x.len();
434    let out_dim = out.len();
435    debug_assert!(w.len() >= out_dim * in_dim);
436
437    let row_dot = |o: usize| -> f32 {
438        let row = &w[o * in_dim..(o + 1) * in_dim];
439        let mut sum = 0.0f32;
440        for j in 0..in_dim {
441            sum += row[j] * x[j];
442        }
443        sum
444    };
445
446    match pool {
447        Some(pool) if out_dim >= 256 => {
448            let out_addr = SendMut(out.as_mut_ptr());
449            let run_range = move |start: usize, end: usize| {
450                for o in start..end {
451                    unsafe { *out_addr.at(o) = row_dot(o) };
452                }
453            };
454            pool.run_rows(out_dim, &run_range);
455        }
456        _ => {
457            for (o, dst) in out.iter_mut().enumerate() {
458                *dst = row_dot(o);
459            }
460        }
461    }
462}
463
464/// Two-input row matvec: one pass over the weight rows serves BOTH
465/// inputs — CPU decode is memory-bound, so the second position costs a
466/// fraction of the first (this is where MTP speculative verify wins).
467/// Per-output accumulation order matches the single-input path exactly
468/// → bit-identical results.
469pub fn matvec_rows2(
470    pool: Option<&Pool>,
471    w: &[f32],
472    x1: &[f32],
473    x2: &[f32],
474    out1: &mut [f32],
475    out2: &mut [f32],
476) {
477    let in_dim = x1.len();
478    debug_assert_eq!(x2.len(), in_dim);
479    let out_dim = out1.len();
480    debug_assert_eq!(out2.len(), out_dim);
481    debug_assert!(w.len() >= out_dim * in_dim);
482
483    let row_dots = |o: usize| -> (f32, f32) {
484        let row = &w[o * in_dim..(o + 1) * in_dim];
485        let (mut s1, mut s2) = (0.0f32, 0.0f32);
486        for j in 0..in_dim {
487            s1 += row[j] * x1[j];
488            s2 += row[j] * x2[j];
489        }
490        (s1, s2)
491    };
492
493    match pool {
494        Some(pool) if out_dim >= 256 => {
495            let o1 = SendMut(out1.as_mut_ptr());
496            let o2 = SendMut(out2.as_mut_ptr());
497            let run_range = move |start: usize, end: usize| {
498                for o in start..end {
499                    let (s1, s2) = row_dots(o);
500                    unsafe {
501                        *o1.at(o) = s1;
502                        *o2.at(o) = s2;
503                    }
504                }
505            };
506            pool.run_rows(out_dim, &run_range);
507        }
508        _ => {
509            for o in 0..out_dim {
510                let (s1, s2) = row_dots(o);
511                out1[o] = s1;
512                out2[o] = s2;
513            }
514        }
515    }
516}
517
518#[derive(Clone, Copy)]
519struct SendMut(*mut f32);
520unsafe impl Send for SendMut {}
521unsafe impl Sync for SendMut {}
522
523impl SendMut {
524    /// Method receiver forces the closure to capture the whole (Sync)
525    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
526    #[inline]
527    fn at(self, i: usize) -> *mut f32 {
528        unsafe { self.0.add(i) }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    #[test]
535    fn capacity_split_clock_bins_vs_microarch() {
536        type P = super::Pool;
537        // JR510: all-A55, two clock bins — use every core.
538        assert_eq!(
539            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 768, 768, 768, 768]),
540            Some(8)
541        );
542        // Classic big.LITTLE (A78 + A55) — big only.
543        assert_eq!(
544            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 350, 350, 350, 350]),
545            Some(4)
546        );
547        // Three-tier flagship: X + A7xx mids stay, A5xx littles go.
548        assert_eq!(
549            P::cores_from_capacities(&[1024, 800, 800, 800, 800, 300, 300, 300]),
550            Some(5)
551        );
552        // Uniform: no signal, caller falls back.
553        assert_eq!(P::cores_from_capacities(&[1024; 8]), None);
554        assert_eq!(P::cores_from_capacities(&[]), None);
555    }
556
557    use super::*;
558
559    #[test]
560    fn parallel_matvec_equals_serial_bitexact() {
561        let (out_dim, in_dim) = (512, 64);
562        let w: Vec<f32> = (0..out_dim * in_dim)
563            .map(|i| (i as f32 * 0.013).sin())
564            .collect();
565        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
566
567        let mut serial = vec![0.0f32; out_dim];
568        matvec_rows(None, &w, &x, &mut serial);
569
570        let pool = Pool::new(4);
571        let mut parallel = vec![0.0f32; out_dim];
572        matvec_rows(Some(&pool), &w, &x, &mut parallel);
573
574        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
575    }
576
577    #[test]
578    fn fused_pair_equals_two_singles_bitexact() {
579        let (out_dim, in_dim) = (300, 48);
580        let w: Vec<f32> = (0..out_dim * in_dim)
581            .map(|i| (i as f32 * 0.011).sin())
582            .collect();
583        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
584        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
585
586        let mut a1 = vec![0.0f32; out_dim];
587        let mut a2 = vec![0.0f32; out_dim];
588        matvec_rows(None, &w, &x1, &mut a1);
589        matvec_rows(None, &w, &x2, &mut a2);
590
591        for pool in [None, Some(Pool::new(3))] {
592            let mut b1 = vec![0.0f32; out_dim];
593            let mut b2 = vec![0.0f32; out_dim];
594            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
595            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
596            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
597        }
598    }
599
600    #[test]
601    fn pool_survives_many_runs() {
602        let pool = Pool::new(3);
603        let counter = AtomicUsize::new(0);
604        for _ in 0..100 {
605            pool.run(&|_, _| {
606                counter.fetch_add(1, Ordering::Relaxed);
607            });
608        }
609        // 3 workers + the participating caller = 4 executions per run.
610        assert_eq!(counter.load(Ordering::Relaxed), 400);
611    }
612
613    #[test]
614    fn pool_wakes_after_park() {
615        // Force immediate parking (no spin) — the epoch/parked handshake
616        // must still never miss a wakeup.
617        let pool = Pool::with_spin(2, 0);
618        let counter = AtomicUsize::new(0);
619        for _ in 0..50 {
620            pool.run(&|_, _| {
621                counter.fetch_add(1, Ordering::Relaxed);
622            });
623            // Give workers time to actually park between jobs.
624            std::thread::sleep(std::time::Duration::from_micros(200));
625        }
626        assert_eq!(counter.load(Ordering::Relaxed), 150);
627    }
628
629    #[test]
630    fn worker_indices_are_distinct_and_cover_range() {
631        let pool = Pool::new(3);
632        let hits: Vec<AtomicUsize> = (0..4).map(|_| AtomicUsize::new(0)).collect();
633        for _ in 0..20 {
634            pool.run(&|widx, n| {
635                assert_eq!(n, 4);
636                hits[widx].fetch_add(1, Ordering::Relaxed);
637            });
638        }
639        for (i, h) in hits.iter().enumerate() {
640            assert_eq!(h.load(Ordering::Relaxed), 20, "participant {i} missed runs");
641        }
642    }
643}