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