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/// Embedder override for the pool size (C ABI `cortiq_set_threads`):
35/// 0 = unset, consult CMF_THREADS / topology as before. Read once at
36/// pool construction, so set it before the load.
37pub static FORCED_THREADS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
38
39/// Kernel thread ids of the CURRENT pool's workers (Android/Linux) —
40/// what ADPF's PerformanceHintManager needs to attribute work to the
41/// governor. Refilled on every pool construction; empty elsewhere.
42pub static WORKER_TIDS: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
43
44/// A `*const dyn Fn` that may cross a thread boundary. Safety is
45/// provided by `Pool::run`: the caller blocks until every worker has
46/// finished, so the borrow outlives all uses.
47#[derive(Clone, Copy)]
48struct TaskPtr(*const (dyn Fn(usize, usize) + Sync));
49unsafe impl Send for TaskPtr {}
50
51struct Inner {
52    /// Bumped once per published job; workers watch it.
53    epoch: AtomicUsize,
54    /// Workers still running the current job (excludes the caller).
55    remaining: AtomicUsize,
56    /// The published job: closure pointer + total participant count.
57    /// Written by the caller BEFORE the epoch bump, read by workers
58    /// AFTER they observe the new epoch (acquire/release pairing).
59    slot: UnsafeCell<Option<(TaskPtr, usize)>>,
60    shutdown: AtomicBool,
61    /// Spin iterations before a worker parks (0 = park immediately).
62    spin_budget: usize,
63    /// Per-worker "I am parked" flags — lets the caller skip the unpark
64    /// syscall for workers that are still spinning.
65    parked: Box<[AtomicBool]>,
66}
67
68// SAFETY: `slot` is only written while no job is in flight (run()
69// returns after `remaining` hits 0) and only read after the epoch
70// publication that follows the write.
71unsafe impl Sync for Inner {}
72
73/// Process-wide dispatch counter (roadmap §3 P0 «измерения»): one tick
74/// per published job. `bench --json` reports dispatches/token from it.
75static DISPATCHES: AtomicUsize = AtomicUsize::new(0);
76
77/// Total pool jobs published since process start (all pools).
78pub fn dispatch_count() -> usize {
79    DISPATCHES.load(Ordering::Relaxed)
80}
81
82/// Persistent thread pool: shared job slot, epoch dispatch, caller
83/// participation.
84pub struct Pool {
85    inner: Arc<Inner>,
86    /// Thread handles for `unpark` (same order as `parked`).
87    threads: Vec<std::thread::Thread>,
88    joins: Vec<std::thread::JoinHandle<()>>,
89}
90
91fn spin_budget_from_env() -> usize {
92    std::env::var("CMF_POOL_SPIN")
93        .ok()
94        .and_then(|v| v.parse::<usize>().ok())
95        .unwrap_or(4000)
96}
97
98/// Rows per chunk: enough chunks to balance, large enough to keep the SDOT
99/// inner loop and the prefetcher in their stride — and never so coarse that
100/// ONE worker takes the whole job.
101///
102/// That last clause was missing. The floor was a flat 32, so any job with
103/// fewer than 32 rows went entirely to whichever worker grabbed the cursor
104/// first while the other 48 were woken, found nothing, and left. The
105/// hyper-connection projection has 24 rows and is called 86 times a token:
106/// it paid the full price of a fan-out and ran single-threaded.
107fn grain_for(rows: usize, workers: usize) -> usize {
108    if rows == 0 || workers <= 1 {
109        return rows.max(1);
110    }
111    let balanced = (rows / (workers * 8)).max(32);
112    // One chunk per worker at the very least.
113    balanced.min(rows.div_ceil(workers)).max(1)
114}
115
116impl Pool {
117    pub fn new(n_workers: usize) -> Self {
118        Self::with_spin(n_workers, spin_budget_from_env())
119    }
120
121    /// Explicit spin budget (tests pin it without touching the env).
122    pub fn with_spin(n_workers: usize, spin_budget: usize) -> Self {
123        let inner = Arc::new(Inner {
124            epoch: AtomicUsize::new(0),
125            remaining: AtomicUsize::new(0),
126            slot: UnsafeCell::new(None),
127            shutdown: AtomicBool::new(false),
128            spin_budget,
129            parked: (0..n_workers).map(|_| AtomicBool::new(false)).collect(),
130        });
131        let mut joins = Vec::with_capacity(n_workers);
132        if let Ok(mut tids) = WORKER_TIDS.lock() {
133            tids.clear();
134        }
135        for w in 0..n_workers {
136            let inner = inner.clone();
137            let h = std::thread::Builder::new()
138                .name(format!("cmf-pool-{w}"))
139                .spawn(move || {
140                    #[cfg(any(target_os = "android", target_os = "linux"))]
141                    if let Ok(mut tids) = WORKER_TIDS.lock() {
142                        tids.push(unsafe { libc::gettid() } as i32);
143                    }
144                    worker_loop(&inner, w)
145                })
146                .expect("spawn pool worker");
147            joins.push(h);
148        }
149        // Registration barrier: `spawn` returns before the closure runs,
150        // and the embedder reads `cortiq_worker_tids` right after load —
151        // on a phone only the first worker had registered by then (the
152        // '· 1 threads' About line that misled the cmfmobile device
153        // investigation twice). Thread start is milliseconds; wait for
154        // every tid before construction returns.
155        #[cfg(any(target_os = "android", target_os = "linux"))]
156        while WORKER_TIDS.lock().map(|t| t.len()).unwrap_or(n_workers) < n_workers {
157            std::thread::yield_now();
158        }
159        let threads = joins.iter().map(|h| h.thread().clone()).collect();
160        Self {
161            inner,
162            threads,
163            joins,
164        }
165    }
166
167    /// Big-core count on heterogeneous ARM (big.LITTLE): the kernel
168    /// exposes per-core capacity on Android and most ARM Linux; efficiency
169    /// cores in the pool DRAG the big ones on our row-parallel jobs (the
170    /// same cliff llama.cpp hits at -t 10 on an M4: 163 → 112 tok/s).
171    /// None = capacities absent or homogeneous.
172    #[cfg(all(
173        target_arch = "aarch64",
174        any(target_os = "linux", target_os = "android")
175    ))]
176    fn big_cores() -> Option<usize> {
177        Self::cores_from_capacities(&core_capacities())
178    }
179
180    /// How many cores the pool should use, from the kernel's per-core
181    /// capacity values. Capacity folds µarch × clock into one number,
182    /// and the two need different treatment: cores of ANOTHER µarch
183    /// (A5xx efficiency cluster next to A7xx/X: capacity ratio ≥ ~2)
184    /// drag row-parallel work down and are excluded; cores of the SAME
185    /// µarch merely clock-binned (JLQ JR510: 8×A55 as 4×2.0 + 4×1.5 GHz,
186    /// ratio 1.33) pull their weight and must ALL be used. The 1.6
187    /// threshold splits the two regimes: on a Snapdragon 8-class part
188    /// it keeps X + A7xx mid cores and drops A5xx.
189    #[cfg_attr(
190        not(all(
191            target_arch = "aarch64",
192            any(target_os = "linux", target_os = "android")
193        )),
194        allow(dead_code)
195    )]
196    fn cores_from_capacities(caps: &[u64]) -> Option<usize> {
197        let max = *caps.iter().max()?;
198        let min = *caps.iter().min()?;
199        if caps.len() < 2 || max == min {
200            return None;
201        }
202        Some(caps.iter().filter(|&&c| c * 8 >= max * 5).count())
203    }
204
205    #[cfg(target_os = "macos")]
206    fn big_cores() -> Option<usize> {
207        unsafe extern "C" {
208            fn sysctlbyname(
209                name: *const std::ffi::c_char,
210                oldp: *mut std::ffi::c_void,
211                oldlenp: *mut usize,
212                newp: *mut std::ffi::c_void,
213                newlen: usize,
214            ) -> std::ffi::c_int;
215        }
216        unsafe {
217            let name = std::ffi::CString::new("hw.perflevel0.physicalcpu").ok()?;
218            let mut count: i32 = 0;
219            let mut size = std::mem::size_of::<i32>();
220            let ret = sysctlbyname(
221                name.as_ptr(),
222                &mut count as *mut i32 as *mut std::ffi::c_void,
223                &mut size,
224                std::ptr::null_mut(),
225                0,
226            );
227            if ret == 0 && count > 0 {
228                Some(count as usize)
229            } else {
230                None
231            }
232        }
233    }
234
235    #[cfg(not(any(
236        all(
237            target_arch = "aarch64",
238            any(target_os = "linux", target_os = "android")
239        ),
240        target_os = "macos"
241    )))]
242    fn big_cores() -> Option<usize> {
243        None
244    }
245
246    /// The thread count `from_env` would use RIGHT NOW: forced (C ABI)
247    /// > CMF_THREADS > big-core topology > available_parallelism−1.
248    /// ≤1 means the model runs serial (no pool). Introspection
249    /// (`execution_mode`, status endpoints) must report THIS, not
250    /// available_parallelism.
251    pub fn effective_threads() -> usize {
252        let forced = FORCED_THREADS.load(std::sync::atomic::Ordering::Relaxed);
253        if forced > 0 {
254            return forced;
255        }
256        match std::env::var("CMF_THREADS") {
257            Ok(v) => v.parse::<usize>().unwrap_or(0),
258            Err(_) => match Self::big_cores() {
259                Some(big) => big,
260                None => {
261                    // The cap was 8, which left big machines idle: on a
262                    // 256-core EPYC, Nanbeige 4.2 decoded at 7.4 tok/s on
263                    // the default 8 threads and 14.8 at 32, with prefill
264                    // 12 -> ~16 over the same move. Past ~32 it falls off
265                    // hard (5.5 at 64, 1.6 at 256) — decode is
266                    // memory-bound and the extra threads only add
267                    // dispatch barriers — so 32 is a ceiling, not a
268                    // target. Machines with 9 cores or fewer are
269                    // unaffected: avail-1 already bounds them.
270                    let avail = std::thread::available_parallelism()
271                        .map(|n| n.get())
272                        .unwrap_or(1);
273                    avail.saturating_sub(1).min(32)
274                }
275            },
276        }
277    }
278
279    /// Pool sized from `CMF_THREADS` (see module docs). `None` = serial.
280    /// Without the env, heterogeneous ARM defaults to its BIG cores.
281    pub fn from_env() -> Option<Arc<Self>> {
282        let n = Self::effective_threads();
283        if n <= 1 {
284            None
285        } else {
286            Some(Arc::new(Self::new(n)))
287        }
288    }
289
290    /// Spawned worker threads (the caller joins each job on top).
291    pub fn n_workers(&self) -> usize {
292        self.threads.len()
293    }
294
295    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
296    ///
297    /// One dispatch, but workers pull row-ranges from a shared cursor
298    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
299    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
300    /// every matvec end at the SLOWEST core's pace while the fast ones
301    /// idle at the barrier; pulling by grain lets a P-core take several
302    /// chunks for each one an E-core takes, so skew collapses to a
303    /// single grain. Row ranges stay disjoint and each row's dot is
304    /// computed exactly as in the serial path → bit-identical output.
305    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
306        let grain = grain_for(rows, self.threads.len() + 1);
307        let next = AtomicUsize::new(0);
308        self.run(&|_w, _n| loop {
309            let start = next.fetch_add(grain, Ordering::Relaxed);
310            if start >= rows {
311                break;
312            }
313            f(start, (start + grain).min(rows));
314        });
315    }
316
317    /// Multi-matrix job: one dispatch serves SEVERAL row spaces
318    /// (roadmap §3 P0 — «одна внешняя публикация job на слой»). Parts
319    /// are laid out back-to-back in a virtual row space and pulled by
320    /// grain from one shared cursor, so QKV or gate+up cost a single
321    /// barrier instead of one each. Each part's `f(start, end)` sees its
322    /// OWN row indices — per-row math and outputs are bit-identical to
323    /// separate `run_rows` calls.
324    pub fn run_many(&self, parts: &[(usize, &(dyn Fn(usize, usize) + Sync))]) {
325        let total: usize = parts.iter().map(|p| p.0).sum();
326        if total == 0 {
327            return;
328        }
329        let grain = grain_for(total, self.threads.len() + 1);
330        let next = AtomicUsize::new(0);
331        self.run(&|_w, _n| loop {
332            let s = next.fetch_add(grain, Ordering::Relaxed);
333            if s >= total {
334                break;
335            }
336            let e = (s + grain).min(total);
337            let mut base = 0usize;
338            for &(rows, f) in parts {
339                let a = s.max(base);
340                let b = e.min(base + rows);
341                if a < b {
342                    f(a - base, b - base);
343                }
344                base += rows;
345                if base >= e {
346                    break;
347                }
348            }
349        });
350    }
351
352    /// Run `f(worker_idx, n_participants)` on every worker AND the
353    /// calling thread (`worker_idx = n_workers()` for the caller);
354    /// returns when all participants have finished.
355    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
356        DISPATCHES.fetch_add(1, Ordering::Relaxed);
357        let nw = self.threads.len();
358        let n = nw + 1; // caller participates
359        // SAFETY: the wait loop below blocks until every worker is done,
360        // so extending the borrow to 'static never outlives the call.
361        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
362        let ptr: *const (dyn Fn(usize, usize) + Sync + 'static) =
363            unsafe { std::mem::transmute(ptr) };
364        // SAFETY: no job in flight (previous run() drained `remaining`),
365        // so the slot is not being read.
366        unsafe { *self.inner.slot.get() = Some((TaskPtr(ptr), n)) };
367        self.inner.remaining.store(nw, Ordering::Relaxed);
368        self.inner.epoch.fetch_add(1, Ordering::SeqCst);
369        for (i, t) in self.threads.iter().enumerate() {
370            if self.inner.parked[i].load(Ordering::SeqCst) {
371                t.unpark();
372            }
373        }
374
375        // The caller's share — the barrier costs nothing while there is
376        // real work to do.
377        f(nw, n);
378
379        // Wait for the stragglers (bounded by one worker's chunk).
380        let mut spins = 0usize;
381        while self.inner.remaining.load(Ordering::Acquire) != 0 {
382            spins += 1;
383            if spins < 10_000 {
384                std::hint::spin_loop();
385            } else {
386                std::thread::yield_now();
387            }
388        }
389    }
390}
391
392impl Drop for Pool {
393    fn drop(&mut self) {
394        self.inner.shutdown.store(true, Ordering::SeqCst);
395        for t in &self.threads {
396            t.unpark();
397        }
398        for h in self.joins.drain(..) {
399            let _ = h.join();
400        }
401    }
402}
403
404/// Per-core capacity: the kernel's `cpu_capacity` (µarch × clock) when
405/// EAS exposes it, else `cpufreq/cpuinfo_max_freq` — same cluster
406/// ordering, so the 62.5% big-core rule keeps working on EAS-less
407/// kernels (TUNING.md open item: pinning silently did nothing there).
408#[cfg(any(
409    target_os = "android",
410    all(target_arch = "aarch64", target_os = "linux")
411))]
412fn core_capacities() -> Vec<u64> {
413    let read_all = |leaf: &str| -> Vec<u64> {
414        let mut vals = Vec::new();
415        for cpu in 0.. {
416            let path = format!("/sys/devices/system/cpu/cpu{cpu}/{leaf}");
417            match std::fs::read_to_string(&path) {
418                Ok(v) => match v.trim().parse() {
419                    Ok(x) => vals.push(x),
420                    Err(_) => break,
421                },
422                Err(_) => break,
423            }
424        }
425        vals
426    };
427    let caps = read_all("cpu_capacity");
428    if caps.len() >= 2 {
429        return caps;
430    }
431    read_all("cpufreq/cpuinfo_max_freq")
432}
433
434#[cfg(target_os = "android")]
435fn pin_thread_to_big_cores() {
436    use std::mem;
437    let caps = core_capacities();
438    let max = caps.iter().copied().max().unwrap_or(0);
439    let min = caps.iter().copied().min().unwrap_or(0);
440
441    // Only pin if heterogeneous
442    if caps.len() < 2 || max == min {
443        return;
444    }
445
446    unsafe {
447        let mut set: libc::cpu_set_t = mem::zeroed();
448        for (i, &c) in caps.iter().enumerate() {
449            if c * 8 >= max * 5 {
450                libc::CPU_SET(i, &mut set);
451            }
452        }
453        libc::sched_setaffinity(0, mem::size_of::<libc::cpu_set_t>(), &set);
454    }
455}
456
457fn worker_loop(inner: &Inner, idx: usize) {
458    #[cfg(target_os = "android")]
459    pin_thread_to_big_cores();
460
461    // The pool is created at epoch 0; baseline MUST be 0, not a fresh
462    // epoch read — if the caller publishes a job before the OS actually
463    // starts this thread, reading the live epoch would adopt that job's
464    // epoch as "already seen", skip it, and deadlock the caller's wait.
465    let mut seen = 0usize;
466    loop {
467        // Wait for a new epoch: spin first (decode publishes the next
468        // matvec within microseconds), park only when idle for real.
469        let mut spins = 0usize;
470        loop {
471            let e = inner.epoch.load(Ordering::Acquire);
472            if e != seen {
473                seen = e;
474                break;
475            }
476            if inner.shutdown.load(Ordering::Relaxed) {
477                return;
478            }
479            if spins < inner.spin_budget {
480                spins += 1;
481                std::hint::spin_loop();
482            } else {
483                inner.parked[idx].store(true, Ordering::SeqCst);
484                // Re-check under SeqCst: the caller bumps the epoch
485                // BEFORE reading `parked`, so either it sees our flag
486                // (and unparks) or we see its epoch here — a missed
487                // wakeup is impossible. Spurious unparks just loop.
488                if inner.epoch.load(Ordering::SeqCst) == seen
489                    && !inner.shutdown.load(Ordering::Relaxed)
490                {
491                    std::thread::park();
492                }
493                inner.parked[idx].store(false, Ordering::SeqCst);
494            }
495        }
496        // SAFETY: the slot was written before the epoch bump we just
497        // observed (release/acquire), and stays valid until `remaining`
498        // drops to zero — which happens only after `f` returns below.
499        let (task, n) = unsafe { (*inner.slot.get()).expect("job published with epoch") };
500        let f = unsafe { &*task.0 };
501        f(idx, n);
502        inner.remaining.fetch_sub(1, Ordering::AcqRel);
503    }
504}
505
506/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
507/// Bit-identical to the serial loop (row order does not change math).
508pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
509    let in_dim = x.len();
510    let out_dim = out.len();
511    debug_assert!(w.len() >= out_dim * in_dim);
512
513    let row_dot = |o: usize| -> f32 {
514        let row = &w[o * in_dim..(o + 1) * in_dim];
515        let mut sum = 0.0f32;
516        for j in 0..in_dim {
517            sum += row[j] * x[j];
518        }
519        sum
520    };
521
522    match pool {
523        Some(pool) if out_dim >= 256 => {
524            let out_addr = SendMut(out.as_mut_ptr());
525            let run_range = move |start: usize, end: usize| {
526                for o in start..end {
527                    unsafe { *out_addr.at(o) = row_dot(o) };
528                }
529            };
530            pool.run_rows(out_dim, &run_range);
531        }
532        _ => {
533            for (o, dst) in out.iter_mut().enumerate() {
534                *dst = row_dot(o);
535            }
536        }
537    }
538}
539
540/// Two-input row matvec: one pass over the weight rows serves BOTH
541/// inputs — CPU decode is memory-bound, so the second position costs a
542/// fraction of the first (this is where MTP speculative verify wins).
543/// Per-output accumulation order matches the single-input path exactly
544/// → bit-identical results.
545pub fn matvec_rows2(
546    pool: Option<&Pool>,
547    w: &[f32],
548    x1: &[f32],
549    x2: &[f32],
550    out1: &mut [f32],
551    out2: &mut [f32],
552) {
553    let in_dim = x1.len();
554    debug_assert_eq!(x2.len(), in_dim);
555    let out_dim = out1.len();
556    debug_assert_eq!(out2.len(), out_dim);
557    debug_assert!(w.len() >= out_dim * in_dim);
558
559    let row_dots = |o: usize| -> (f32, f32) {
560        let row = &w[o * in_dim..(o + 1) * in_dim];
561        let (mut s1, mut s2) = (0.0f32, 0.0f32);
562        for j in 0..in_dim {
563            s1 += row[j] * x1[j];
564            s2 += row[j] * x2[j];
565        }
566        (s1, s2)
567    };
568
569    match pool {
570        Some(pool) if out_dim >= 256 => {
571            let o1 = SendMut(out1.as_mut_ptr());
572            let o2 = SendMut(out2.as_mut_ptr());
573            let run_range = move |start: usize, end: usize| {
574                for o in start..end {
575                    let (s1, s2) = row_dots(o);
576                    unsafe {
577                        *o1.at(o) = s1;
578                        *o2.at(o) = s2;
579                    }
580                }
581            };
582            pool.run_rows(out_dim, &run_range);
583        }
584        _ => {
585            for o in 0..out_dim {
586                let (s1, s2) = row_dots(o);
587                out1[o] = s1;
588                out2[o] = s2;
589            }
590        }
591    }
592}
593
594#[derive(Clone, Copy)]
595pub(crate) struct SendMut(*mut f32);
596unsafe impl Send for SendMut {}
597unsafe impl Sync for SendMut {}
598
599impl SendMut {
600    /// The caller promises the threads it hands this to write disjoint
601    /// indices, and that the pointee outlives them.
602    #[inline]
603    pub(crate) fn new(p: *mut f32) -> Self {
604        Self(p)
605    }
606
607    /// Method receiver forces the closure to capture the whole (Sync)
608    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
609    #[inline]
610    pub(crate) fn at(self, i: usize) -> *mut f32 {
611        unsafe { self.0.add(i) }
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    #[test]
618    #[cfg(any(target_os = "android", target_os = "linux"))]
619    fn worker_tids_registered_before_new_returns() {
620        // WORKER_TIDS is a process-global registry, and the test harness
621        // runs suites in parallel — other tests' pools add their tids to
622        // the same list (19 showed up on a 48-core box where the old
623        // `== 3` held on a laptop by timing luck). Assert on the DELTA:
624        // our pool's three workers must be there the moment new returns.
625        // Counting LENGTHS raced: a parallel suite dropping its pool
626        // shrinks the same registry between the two reads, and the delta
627        // goes negative through no fault of ours (this flake failed two
628        // releases). Compare SETS instead — removals elsewhere cannot
629        // take away tids that were not there before.
630        use std::collections::HashSet;
631        let before: HashSet<_> = super::WORKER_TIDS.lock().unwrap().iter().copied().collect();
632        let _p = super::Pool::new(3);
633        let after: HashSet<_> = super::WORKER_TIDS.lock().unwrap().iter().copied().collect();
634        let fresh = after.difference(&before).count();
635        assert!(
636            fresh >= 3,
637            "all worker tids must be visible the moment the pool exists \
638             (fresh {fresh}, before {}, after {})",
639            before.len(),
640            after.len()
641        );
642    }
643
644    #[test]
645    fn forced_threads_overrides_env_and_topology() {
646        use std::sync::atomic::Ordering;
647        super::FORCED_THREADS.store(3, Ordering::Relaxed);
648        let pool = super::Pool::from_env().expect("forced 3 → pool");
649        assert_eq!(pool.n_workers(), 3);
650        super::FORCED_THREADS.store(1, Ordering::Relaxed);
651        assert!(super::Pool::from_env().is_none(), "forced 1 → serial");
652        super::FORCED_THREADS.store(0, Ordering::Relaxed);
653    }
654
655    #[test]
656    fn capacity_split_clock_bins_vs_microarch() {
657        type P = super::Pool;
658        // JR510: all-A55, two clock bins — use every core.
659        assert_eq!(
660            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 768, 768, 768, 768]),
661            Some(8)
662        );
663        // Classic big.LITTLE (A78 + A55) — big only.
664        assert_eq!(
665            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 350, 350, 350, 350]),
666            Some(4)
667        );
668        // Three-tier flagship: X + A7xx mids stay, A5xx littles go.
669        assert_eq!(
670            P::cores_from_capacities(&[1024, 800, 800, 800, 800, 300, 300, 300]),
671            Some(5)
672        );
673        // Uniform: no signal, caller falls back.
674        assert_eq!(P::cores_from_capacities(&[1024; 8]), None);
675        assert_eq!(P::cores_from_capacities(&[]), None);
676    }
677
678    use super::*;
679
680    #[test]
681    fn parallel_matvec_equals_serial_bitexact() {
682        let (out_dim, in_dim) = (512, 64);
683        let w: Vec<f32> = (0..out_dim * in_dim)
684            .map(|i| (i as f32 * 0.013).sin())
685            .collect();
686        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
687
688        let mut serial = vec![0.0f32; out_dim];
689        matvec_rows(None, &w, &x, &mut serial);
690
691        let pool = Pool::new(4);
692        let mut parallel = vec![0.0f32; out_dim];
693        matvec_rows(Some(&pool), &w, &x, &mut parallel);
694
695        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
696    }
697
698    #[test]
699    fn fused_pair_equals_two_singles_bitexact() {
700        let (out_dim, in_dim) = (300, 48);
701        let w: Vec<f32> = (0..out_dim * in_dim)
702            .map(|i| (i as f32 * 0.011).sin())
703            .collect();
704        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
705        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
706
707        let mut a1 = vec![0.0f32; out_dim];
708        let mut a2 = vec![0.0f32; out_dim];
709        matvec_rows(None, &w, &x1, &mut a1);
710        matvec_rows(None, &w, &x2, &mut a2);
711
712        for pool in [None, Some(Pool::new(3))] {
713            let mut b1 = vec![0.0f32; out_dim];
714            let mut b2 = vec![0.0f32; out_dim];
715            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
716            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
717            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
718        }
719    }
720
721    #[test]
722    fn pool_survives_many_runs() {
723        let pool = Pool::new(3);
724        let counter = AtomicUsize::new(0);
725        for _ in 0..100 {
726            pool.run(&|_, _| {
727                counter.fetch_add(1, Ordering::Relaxed);
728            });
729        }
730        // 3 workers + the participating caller = 4 executions per run.
731        assert_eq!(counter.load(Ordering::Relaxed), 400);
732    }
733
734    #[test]
735    fn pool_wakes_after_park() {
736        // Force immediate parking (no spin) — the epoch/parked handshake
737        // must still never miss a wakeup.
738        let pool = Pool::with_spin(2, 0);
739        let counter = AtomicUsize::new(0);
740        for _ in 0..50 {
741            pool.run(&|_, _| {
742                counter.fetch_add(1, Ordering::Relaxed);
743            });
744            // Give workers time to actually park between jobs.
745            std::thread::sleep(std::time::Duration::from_micros(200));
746        }
747        assert_eq!(counter.load(Ordering::Relaxed), 150);
748    }
749
750    #[test]
751    fn worker_indices_are_distinct_and_cover_range() {
752        let pool = Pool::new(3);
753        let hits: Vec<AtomicUsize> = (0..4).map(|_| AtomicUsize::new(0)).collect();
754        for _ in 0..20 {
755            pool.run(&|widx, n| {
756                assert_eq!(n, 4);
757                hits[widx].fetch_add(1, Ordering::Relaxed);
758            });
759        }
760        for (i, h) in hits.iter().enumerate() {
761            assert_eq!(h.load(Ordering::Relaxed), 20, "participant {i} missed runs");
762        }
763    }
764}
765
766#[cfg(test)]
767mod grain_tests {
768    use super::grain_for;
769
770    #[test]
771    fn a_short_job_still_reaches_every_worker() {
772        // 24 rows, 49 workers: the old flat floor of 32 handed all 24 to the
773        // first worker and woke the rest for nothing.
774        assert_eq!(grain_for(24, 49), 1);
775        // Wide jobs keep the stride the SDOT loop wants.
776        assert_eq!(grain_for(4096, 49), 32);
777        assert_eq!(grain_for(32768, 49), 83);
778        // Degenerate shapes must not divide by zero or return zero.
779        assert_eq!(grain_for(0, 49), 1);
780        assert_eq!(grain_for(7, 1), 7);
781        assert!(grain_for(1, 49) >= 1);
782    }
783}