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