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