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 one shared job descriptor + a per-worker ticket (roadmap
10//! §3 P0): the caller writes the descriptor, hands a ticket to each
11//! worker it invites and JOINS THE WORK as the extra worker instead of
12//! blocking on a latch. The previous design allocated an `Arc<Latch>`
13//! and pushed a message into every worker's mpsc channel for every
14//! matvec (~200 dispatches/token) — with decode-grade matvecs that
15//! synchronization was its own budget. Workers spin for
16//! `CMF_POOL_SPIN` iterations before parking.
17//! Default 4000: at ~39 dispatches/token, park-immediately pays the
18//! unpark syscall on every worker for every dispatch — measured on an
19//! M4 (interleaved A/B, current epoch dispatch + parked-flag design):
20//! Qwen-0.5B q8 decode 101→115 tok/s, q4t 117→149, the 50M bench model
21//! 549→954 at spin=4000 vs spin=0. An early measurement that showed
22//! spinning LOSING (−25% on q8) predates the parked-flag skip and the
23//! multi-matrix dispatch cuts; it no longer reproduces. Over-spinning
24//! still hurts (200k: −15% vs 4k — spinners steal the caller's serial
25//! cycles), so the budget stays bounded. `CMF_POOL_SPIN=0` restores
26//! park-immediately for share-the-box serving.
27//!
28//! `CMF_THREADS` env: 0/1 = serial, N = worker count
29//! (default: available_parallelism − 1, capped at 8).
30
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/// Keeps a word that one thread writes and others poll off everybody
48/// else's cache line (128 bytes: Apple silicon lines, and the adjacent-
49/// line prefetcher pair on x86).
50#[repr(align(128))]
51struct Padded<T>(T);
52
53/// One worker's mailbox, alone on its line: the worker polls it, only the
54/// caller writes `ticket`, only the worker writes `parked`.
55#[repr(align(128))]
56struct Slot {
57    /// Jobs handed to this worker so far. The caller bumps it (Release,
58    /// after the descriptor) once per job the worker is invited to; the
59    /// worker runs exactly one job per bump. The caller issues the next
60    /// bump only after this worker's `remaining` decrement for the
61    /// previous one, so the worker can never miss or double a ticket.
62    ticket: AtomicUsize,
63    /// "I am parked" — lets the caller skip the unpark syscall for a
64    /// worker that is still spinning.
65    parked: AtomicBool,
66}
67
68struct Inner {
69    /// Bumped once per published job, invited or not. Spinning workers
70    /// read it ONLY to re-arm their spin budget (the pool is busy, the
71    /// next job is microseconds away); it never makes a worker run
72    /// anything or read the descriptor.
73    epoch: AtomicUsize,
74    /// The published job: closure (fat-pointer halves), participant
75    /// count, publisher's GPU device. The device rides along because a
76    /// dispatch begun on card 1 must not finish on card 0: worker threads
77    /// have their own thread-locals, and the engine resolves its wgpu
78    /// context through one.
79    ///
80    /// Only workers holding a ticket for the current job read these
81    /// words, and the caller rewrites them only after `remaining` hit 0,
82    /// i.e. after every ticket holder has finished — so a read never
83    /// races a write and there is no torn descriptor to detect.
84    ///
85    /// History, because both earlier protocols failed: (1) one slot read
86    /// by EVERY worker on the epoch bump. A worker not invited to job k
87    /// is not waited for, so it could be preempted between seeing epoch k
88    /// and reading the slot, by which time the slot held job k+1 — it ran
89    /// k+1, decremented `remaining`, then saw epoch k+1 as new and ran it
90    /// AGAIN: `remaining` wrapped to `usize::MAX` and the caller spun
91    /// forever (the 47-minute `cortiq ppl` on the S4 bounded export,
92    /// whose 384-row Embryo matrices make every dispatch a limited one).
93    /// (2) The same words under a seqlock with the epoch inside: correct
94    /// on x86, but no release fence followed the writer's opening
95    /// increment, so the memory model did not order it before the word
96    /// stores (weakly ordered ARM may show a reader new words under an
97    /// old even count), and every limited dispatch still woke every
98    /// spinning worker to read the descriptor and skip it. With more
99    /// spinners than CPUs (the straggler test's 4× pool on a 2–4-CPU CI
100    /// runner) they held the CPUs while the one invited worker waited
101    /// for a time slice: ~10 ms per dispatch, 20 k jobs in ~230 s. A
102    /// worker now learns it is invited from its own ticket and nothing
103    /// else.
104    desc_data: AtomicUsize,
105    desc_vtable: AtomicUsize,
106    desc_n: AtomicUsize,
107    desc_dev: AtomicUsize,
108    /// `FLOAT_ACTIVATIONS` is thread-local, while this pool is persistent.
109    /// Snapshot it with the closure descriptor so an exact caller policy is
110    /// retained by the worker-side row kernels as well.
111    desc_float_activations: AtomicBool,
112    /// Ticket holders still running the current job (excludes the
113    /// caller). Its own line: the caller polls it while workers
114    /// decrement.
115    remaining: Padded<AtomicUsize>,
116    /// One mailbox per worker, same order as `Pool::threads`.
117    slots: Box<[Slot]>,
118    shutdown: AtomicBool,
119    /// Spin iterations before a worker parks (0 = park immediately).
120    spin_budget: AtomicUsize,
121    /// More threads (workers + caller) than CPUs this process may run
122    /// on. Spinning cannot help then: a spinner occupies the CPU the
123    /// thread that has the work needs, and the job waits for the
124    /// scheduler's time slice. So in this mode a worker's spin budget is
125    /// re-armed only by its own tickets (an idle worker parks instead of
126    /// spinning through other workers' jobs), and every spinner — worker
127    /// or waiting caller — yields the CPU between short spin bursts.
128    /// The default size (available_parallelism − 1 workers) never is.
129    oversubscribed: bool,
130    /// Per-pool registration state. `WORKER_TIDS` is a process-wide
131    /// snapshot for ADPF and cannot be a construction barrier: another
132    /// pool may clear and republish that snapshot concurrently.
133    #[cfg(any(target_os = "android", target_os = "linux"))]
134    registered: AtomicUsize,
135    #[cfg(any(target_os = "android", target_os = "linux"))]
136    worker_tids: Mutex<Vec<i32>>,
137}
138
139/// Process-wide dispatch counter (roadmap §3 P0 «измерения»): one tick
140/// per published job. `bench --json` reports dispatches/token from it.
141static DISPATCHES: AtomicUsize = AtomicUsize::new(0);
142
143/// Total pool jobs published since process start (all pools).
144pub fn dispatch_count() -> usize {
145    DISPATCHES.load(Ordering::Relaxed)
146}
147
148/// Persistent thread pool: shared job slot, epoch dispatch, caller
149/// participation.
150pub struct Pool {
151    inner: Arc<Inner>,
152    /// Thread handles for `unpark` (same order as `parked`).
153    threads: Vec<std::thread::Thread>,
154    joins: Vec<std::thread::JoinHandle<()>>,
155}
156
157fn spin_budget_from_env() -> usize {
158    std::env::var("CMF_POOL_SPIN")
159        .ok()
160        .and_then(|v| v.parse::<usize>().ok())
161        .unwrap_or(4000)
162}
163
164/// Rows per chunk: enough chunks to balance, large enough to keep the SDOT
165/// inner loop and the prefetcher in their stride — and never so coarse that
166/// ONE worker takes the whole job.
167///
168/// That last clause was missing. The floor was a flat 32, so any job with
169/// fewer than 32 rows went entirely to whichever worker grabbed the cursor
170/// first while the other 48 were woken, found nothing, and left. The
171/// hyper-connection projection has 24 rows and is called 86 times a token:
172/// it paid the full price of a fan-out and ran single-threaded.
173pub(crate) fn grain_for(rows: usize, workers: usize) -> usize {
174    if rows == 0 || workers <= 1 {
175        return rows.max(1);
176    }
177    let balanced = (rows / (workers * 8)).max(32);
178    // One chunk per worker at the very least.
179    balanced.min(rows.div_ceil(workers)).max(1)
180}
181
182impl Pool {
183    pub fn new(n_workers: usize) -> Self {
184        Self::with_spin(n_workers, spin_budget_from_env())
185    }
186
187    /// Explicit spin budget (tests pin it without touching the env).
188    pub fn with_spin(n_workers: usize, spin_budget: usize) -> Self {
189        // Affinity- and cgroup-quota-aware on Linux; read once — the NUMA
190        // bind never narrows the mask below the pool's thread count.
191        let cpus = std::thread::available_parallelism()
192            .map(|n| n.get())
193            .unwrap_or(1);
194        let inner = Arc::new(Inner {
195            epoch: AtomicUsize::new(0),
196            desc_data: AtomicUsize::new(0),
197            desc_vtable: AtomicUsize::new(0),
198            desc_n: AtomicUsize::new(0),
199            desc_dev: AtomicUsize::new(0),
200            desc_float_activations: AtomicBool::new(false),
201            remaining: Padded(AtomicUsize::new(0)),
202            slots: (0..n_workers)
203                .map(|_| Slot {
204                    ticket: AtomicUsize::new(0),
205                    parked: AtomicBool::new(false),
206                })
207                .collect(),
208            shutdown: AtomicBool::new(false),
209            spin_budget: AtomicUsize::new(spin_budget),
210            oversubscribed: n_workers + 1 > cpus,
211            #[cfg(any(target_os = "android", target_os = "linux"))]
212            registered: AtomicUsize::new(0),
213            #[cfg(any(target_os = "android", target_os = "linux"))]
214            worker_tids: Mutex::new(Vec::with_capacity(n_workers)),
215        });
216        let mut joins = Vec::with_capacity(n_workers);
217        for w in 0..n_workers {
218            let inner = inner.clone();
219            let h = std::thread::Builder::new()
220                .name(format!("cmf-pool-{w}"))
221                .spawn(move || {
222                    #[cfg(any(target_os = "android", target_os = "linux"))]
223                    {
224                        let tid = unsafe { libc::gettid() } as i32;
225                        if let Ok(mut tids) = inner.worker_tids.lock() {
226                            tids.push(tid);
227                        }
228                        inner.registered.fetch_add(1, Ordering::Release);
229                    }
230                    worker_loop(&inner, w)
231                })
232                .expect("spawn pool worker");
233            joins.push(h);
234        }
235        // Per-pool registration barrier: `spawn` returns before the closure runs,
236        // and the embedder reads `cortiq_worker_tids` right after load —
237        // on a phone only the first worker had registered by then (the
238        // '· 1 threads' About line that misled the cmfmobile device
239        // investigation twice). Thread start is milliseconds; wait for
240        // every worker has registered before construction returns.
241        #[cfg(any(target_os = "android", target_os = "linux"))]
242        while inner.registered.load(Ordering::Acquire) < n_workers {
243            std::thread::yield_now();
244        }
245        #[cfg(any(target_os = "android", target_os = "linux"))]
246        if let (Ok(mut global), Ok(local)) = (WORKER_TIDS.lock(), inner.worker_tids.lock()) {
247            *global = local.clone();
248        }
249        let threads = joins.iter().map(|h| h.thread().clone()).collect();
250        Self {
251            inner,
252            threads,
253            joins,
254        }
255    }
256
257    /// Big-core count on heterogeneous ARM (big.LITTLE): the kernel
258    /// exposes per-core capacity on Android and most ARM Linux; efficiency
259    /// cores in the pool DRAG the big ones on our row-parallel jobs (the
260    /// same cliff llama.cpp hits at -t 10 on an M4: 163 → 112 tok/s).
261    /// None = capacities absent or homogeneous.
262    #[cfg(all(
263        target_arch = "aarch64",
264        any(target_os = "linux", target_os = "android")
265    ))]
266    fn big_cores() -> Option<usize> {
267        Self::cores_from_capacities(&core_capacities())
268    }
269
270    /// How many cores the pool should use, from the kernel's per-core
271    /// capacity values. Capacity folds µarch × clock into one number,
272    /// and the two need different treatment: cores of ANOTHER µarch
273    /// (A5xx efficiency cluster next to A7xx/X: capacity ratio ≥ ~2)
274    /// drag row-parallel work down and are excluded; cores of the SAME
275    /// µarch merely clock-binned (JLQ JR510: 8×A55 as 4×2.0 + 4×1.5 GHz,
276    /// ratio 1.33) pull their weight and must ALL be used. The 1.6
277    /// threshold splits the two regimes: on a Snapdragon 8-class part
278    /// it keeps X + A7xx mid cores and drops A5xx.
279    #[cfg_attr(
280        not(all(
281            target_arch = "aarch64",
282            any(target_os = "linux", target_os = "android")
283        )),
284        allow(dead_code)
285    )]
286    fn cores_from_capacities(caps: &[u64]) -> Option<usize> {
287        let max = *caps.iter().max()?;
288        let min = *caps.iter().min()?;
289        if caps.len() < 2 || max == min {
290            return None;
291        }
292        Some(caps.iter().filter(|&&c| c * 8 >= max * 5).count())
293    }
294
295    #[cfg(target_os = "macos")]
296    fn big_cores() -> Option<usize> {
297        // Apple silicon: the P-only default measured WORSE than mixing the
298        // efficiency cores in — the grain-pulling dispatch absorbs the
299        // speed skew exactly as designed, and decode is memory-bound
300        // enough that E-cores add real serviceable work (M4, dense 3B:
301        // 4 threads 8.4 tok/s, 6-9 threads 9.6-10.7). Fall through to
302        // available_parallelism - 1; CMF_THREADS still pins by hand.
303        // The sysctl probe stays for introspection tooling.
304        if true {
305            return None;
306        }
307        #[allow(unreachable_code)]
308        unsafe extern "C" {
309            fn sysctlbyname(
310                name: *const std::ffi::c_char,
311                oldp: *mut std::ffi::c_void,
312                oldlenp: *mut usize,
313                newp: *mut std::ffi::c_void,
314                newlen: usize,
315            ) -> std::ffi::c_int;
316        }
317        unsafe {
318            let name = std::ffi::CString::new("hw.perflevel0.physicalcpu").ok()?;
319            let mut count: i32 = 0;
320            let mut size = std::mem::size_of::<i32>();
321            let ret = sysctlbyname(
322                name.as_ptr(),
323                &mut count as *mut i32 as *mut std::ffi::c_void,
324                &mut size,
325                std::ptr::null_mut(),
326                0,
327            );
328            if ret == 0 && count > 0 {
329                Some(count as usize)
330            } else {
331                None
332            }
333        }
334    }
335
336    #[cfg(not(any(
337        all(
338            target_arch = "aarch64",
339            any(target_os = "linux", target_os = "android")
340        ),
341        target_os = "macos"
342    )))]
343    fn big_cores() -> Option<usize> {
344        None
345    }
346
347    /// The thread count `from_env` would use RIGHT NOW: forced (C ABI)
348    /// > CMF_THREADS > big-core topology > available_parallelism−1.
349    /// > ≤1 means the model runs serial (no pool). Introspection
350    /// > (`execution_mode`, status endpoints) must report THIS, not
351    /// > available_parallelism.
352    pub fn effective_threads() -> usize {
353        let forced = FORCED_THREADS.load(std::sync::atomic::Ordering::Relaxed);
354        if forced > 0 {
355            return forced;
356        }
357        match std::env::var("CMF_THREADS") {
358            Ok(v) => v.parse::<usize>().unwrap_or(0),
359            Err(_) => match Self::big_cores() {
360                Some(big) => big,
361                None => {
362                    // The cap was 8, which left big machines idle: on a
363                    // 256-core EPYC, Nanbeige 4.2 decoded at 7.4 tok/s on
364                    // the default 8 threads and 14.8 at 32, with prefill
365                    // 12 -> ~16 over the same move. Past ~32 it falls off
366                    // hard (5.5 at 64, 1.6 at 256) — decode is
367                    // memory-bound and the extra threads only add
368                    // dispatch barriers — so 32 is a ceiling, not a
369                    // target. Machines with 9 cores or fewer are
370                    // unaffected: avail-1 already bounds them.
371                    let avail = std::thread::available_parallelism()
372                        .map(|n| n.get())
373                        .unwrap_or(1);
374                    avail.saturating_sub(1).min(32)
375                }
376            },
377        }
378    }
379
380    /// Pool sized from `CMF_THREADS` (see module docs). `None` = serial.
381    /// Without the env, heterogeneous ARM defaults to its BIG cores.
382    pub fn from_env() -> Option<Arc<Self>> {
383        let n = Self::effective_threads();
384        if n <= 1 {
385            None
386        } else {
387            Some(Arc::new(Self::new(n)))
388        }
389    }
390
391    /// Spawned worker threads (the caller joins each job on top).
392    pub fn n_workers(&self) -> usize {
393        self.threads.len()
394    }
395
396    /// Keep the pool on the NUMA node that holds `regions` (the model's
397    /// weight bytes). Linux with two or more nodes only; `CMF_NUMA=0`
398    /// turns it off, `CMF_NUMA=node:<n>` forces a node.
399    ///
400    /// WHY: decode streams every weight once per token, and on a
401    /// two-socket host the page cache holds a file on whichever node
402    /// read it. Unpinned, the scheduler spreads the workers over both
403    /// sockets and half the matvec rows cross the socket link. Measured
404    /// on a 2×EPYC 7763 pod with the model's pages all on node 0 (31 CPUs
405    /// of cgroup quota): a STREAM-style read over a node-0 buffer gives
406    /// 42 GB/s from 31 unpinned threads and 74 GB/s from 31 threads kept
407    /// on node 0. The mask is the node's physical cores (first SMT
408    /// sibling) when there are enough of them for the pool, else the
409    /// whole node; never narrower than the pool, so nothing oversubscribes.
410    /// Threads are bound to a SET of cores, not to one core each: the
411    /// scheduler still balances inside the node. The calling thread
412    /// adopts the same mask on its next dispatch.
413    pub fn bind_numa(&self, regions: &[&[u8]]) {
414        #[cfg(target_os = "linux")]
415        {
416            let Some((node, cpus)) = numa::choose(regions, self.threads.len() + 1) else {
417                return;
418            };
419            let mut applied = 0usize;
420            if let Ok(tids) = self.inner.worker_tids.lock() {
421                for &tid in tids.iter() {
422                    if numa::set_affinity(tid, &cpus) {
423                        applied += 1;
424                    }
425                }
426            }
427            numa::publish(cpus.clone());
428            numa::adopt_caller();
429            tracing::info!(
430                "numa: pool bound to node {node} ({} cpus, {applied}/{} workers)",
431                cpus.len(),
432                self.threads.len()
433            );
434            if std::env::var("CMF_NUMA_TRACE").is_ok_and(|v| v != "0") {
435                eprintln!(
436                    "numa: pool bound to node {node}: {} cpus, {applied}/{} workers",
437                    cpus.len(),
438                    self.threads.len()
439                );
440            }
441        }
442        #[cfg(not(target_os = "linux"))]
443        let _ = regions;
444    }
445
446    /// Retune an already-created pool for an architecture with a measured
447    /// dispatch cadence. The environment remains the operator override; this
448    /// hook only changes the automatic default after model geometry is known.
449    pub(crate) fn set_spin_budget(&self, spins: usize) {
450        self.inner.spin_budget.store(spins, Ordering::Relaxed);
451    }
452
453    /// One job: write the descriptor, hand a ticket to workers `0..nw`,
454    /// run the caller's share as participant `nw` of `nw + 1`, and return
455    /// once every ticket holder has finished. Only one job is ever in
456    /// flight (this drains `remaining` before it returns), so the caller
457    /// is the single writer of the descriptor, `remaining` and every
458    /// `ticket`; workers without a ticket never touch any of them.
459    fn dispatch(&self, f: &(dyn Fn(usize, usize) + Sync), nw: usize) {
460        let inner = &*self.inner;
461        let n = nw + 1;
462        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
463        // SAFETY: a fat pointer is exactly two words on every supported
464        // target; the halves are only ever reassembled by `worker_loop`.
465        let raw: [usize; 2] = unsafe { std::mem::transmute(ptr) };
466        inner.desc_data.store(raw[0], Ordering::Relaxed);
467        inner.desc_vtable.store(raw[1], Ordering::Relaxed);
468        inner.desc_n.store(n, Ordering::Relaxed);
469        inner
470            .desc_dev
471            .store(crate::gpu::current_device(), Ordering::Relaxed);
472        inner.desc_float_activations.store(
473            crate::qtensor::float_activations_active(),
474            Ordering::Relaxed,
475        );
476        inner.remaining.0.store(nw, Ordering::Relaxed);
477        let e = inner.epoch.load(Ordering::Relaxed);
478        inner.epoch.store(e.wrapping_add(1), Ordering::Relaxed);
479        // Release: a worker that sees its new ticket (Acquire) sees the
480        // descriptor and `remaining` written above.
481        let slots = &inner.slots[..nw];
482        for slot in slots {
483            let t = slot.ticket.load(Ordering::Relaxed).wrapping_add(1);
484            slot.ticket.store(t, Ordering::Release);
485        }
486        // Pairs with the fence a worker issues between raising `parked`
487        // and re-reading its ticket: either that worker sees the ticket
488        // or we see its flag and unpark it — no lost wakeup. One fence
489        // for all the tickets instead of a SeqCst store per worker.
490        std::sync::atomic::fence(Ordering::SeqCst);
491        for (slot, t) in slots.iter().zip(&self.threads) {
492            if slot.parked.load(Ordering::Relaxed) {
493                t.unpark();
494            }
495        }
496
497        // The caller's share — the barrier costs nothing while there is
498        // real work to do.
499        f(nw, n);
500
501        // Wait for the stragglers (bounded by one worker's chunk). When
502        // the pool has more threads than CPUs, a ticket holder may be
503        // waiting for THIS core: yield it early instead of spinning.
504        let spin_limit = if inner.oversubscribed { 64 } else { 10_000 };
505        let mut spins = 0usize;
506        while inner.remaining.0.load(Ordering::Acquire) != 0 {
507            spins += 1;
508            if spins < spin_limit {
509                std::hint::spin_loop();
510            } else {
511                std::thread::yield_now();
512            }
513        }
514    }
515
516    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
517    ///
518    /// One dispatch, but workers pull row-ranges from a shared cursor
519    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
520    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
521    /// every matvec end at the SLOWEST core's pace while the fast ones
522    /// idle at the barrier; pulling by grain lets a P-core take several
523    /// chunks for each one an E-core takes, so skew collapses to a
524    /// single grain. Row ranges stay disjoint and each row's dot is
525    /// computed exactly as in the serial path → bit-identical output.
526    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
527        let grain = grain_for(rows, self.threads.len() + 1);
528        let chunks = rows.div_ceil(grain.max(1));
529        let next = AtomicUsize::new(0);
530        self.run_limited(chunks, &|_w, _n| loop {
531            let start = next.fetch_add(grain, Ordering::Relaxed);
532            if start >= rows {
533                break;
534            }
535            f(start, (start + grain).min(rows));
536        });
537    }
538
539    /// `run`, with at most `max_workers` workers PARTICIPATING. Same
540    /// grain, same row split, bit-identical results — only the number of
541    /// threads handed the job changes: a job with eight grains has no use
542    /// for three hundred workers, the unpark syscalls and the
543    /// remaining-drain would BE the job (measured: 361 pool dispatches
544    /// per DeepSeek-V4 token, and CMF_THREADS=64 vs 380 was 1.3 vs 2.4
545    /// tok/s with no other change). Only cursor-style closures (which
546    /// ignore their (idx, n) arguments) come through here: the caller
547    /// identifies itself as the capped count, which is NOT `n_workers()`.
548    fn run_limited(&self, max_workers: usize, f: &(dyn Fn(usize, usize) + Sync)) {
549        let nw = self.threads.len().min(max_workers);
550        if nw == self.threads.len() {
551            return self.run(f);
552        }
553        #[cfg(target_os = "linux")]
554        numa::adopt_caller();
555        DISPATCHES.fetch_add(1, Ordering::Relaxed);
556        // Workers `nw..` get no ticket: they neither run nor wait.
557        self.dispatch(f, nw);
558    }
559
560    /// Multi-matrix job: one dispatch serves SEVERAL row spaces
561    /// (roadmap §3 P0 — «одна внешняя публикация job на слой»). Parts
562    /// are laid out back-to-back in a virtual row space and pulled by
563    /// grain from one shared cursor, so QKV or gate+up cost a single
564    /// barrier instead of one each. Each part's `f(start, end)` sees its
565    /// OWN row indices — per-row math and outputs are bit-identical to
566    /// separate `run_rows` calls.
567    pub fn run_many(&self, parts: &[(usize, &(dyn Fn(usize, usize) + Sync))]) {
568        let total: usize = parts.iter().map(|p| p.0).sum();
569        if total == 0 {
570            return;
571        }
572        let grain = grain_for(total, self.threads.len() + 1);
573        let chunks = total.div_ceil(grain.max(1));
574        let next = AtomicUsize::new(0);
575        self.run_limited(chunks, &|_w, _n| loop {
576            let s = next.fetch_add(grain, Ordering::Relaxed);
577            if s >= total {
578                break;
579            }
580            let e = (s + grain).min(total);
581            let mut base = 0usize;
582            for &(rows, f) in parts {
583                let a = s.max(base);
584                let b = e.min(base + rows);
585                if a < b {
586                    f(a - base, b - base);
587                }
588                base += rows;
589                if base >= e {
590                    break;
591                }
592            }
593        });
594    }
595
596    /// Run `f(worker_idx, n_participants)` on every worker AND the
597    /// calling thread (`worker_idx = n_workers()` for the caller);
598    /// returns when all participants have finished.
599    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
600        #[cfg(target_os = "linux")]
601        numa::adopt_caller();
602        DISPATCHES.fetch_add(1, Ordering::Relaxed);
603        self.dispatch(f, self.threads.len());
604    }
605}
606
607impl Drop for Pool {
608    fn drop(&mut self) {
609        self.inner.shutdown.store(true, Ordering::SeqCst);
610        for t in &self.threads {
611            t.unpark();
612        }
613        for h in self.joins.drain(..) {
614            let _ = h.join();
615        }
616    }
617}
618
619/// NUMA placement for the pool (see `Pool::bind_numa`).
620#[cfg(target_os = "linux")]
621mod numa {
622    use std::sync::Mutex;
623    use std::sync::atomic::{AtomicUsize, Ordering};
624
625    /// The published mask; `EPOCH` bumps on every publish so a calling
626    /// thread re-adopts at most once per bind.
627    static MASK: Mutex<Vec<usize>> = Mutex::new(Vec::new());
628    static EPOCH: AtomicUsize = AtomicUsize::new(0);
629    thread_local! {
630        static SEEN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
631    }
632
633    pub(super) fn publish(cpus: Vec<usize>) {
634        if let Ok(mut m) = MASK.lock() {
635            *m = cpus;
636        }
637        EPOCH.fetch_add(1, Ordering::Release);
638    }
639
640    /// One relaxed load + one TLS read per dispatch when nothing changed.
641    #[inline]
642    pub(super) fn adopt_caller() {
643        let e = EPOCH.load(Ordering::Acquire);
644        if e == 0 || SEEN.with(|c| c.get()) == e {
645            return;
646        }
647        SEEN.with(|c| c.set(e));
648        if let Ok(m) = MASK.lock() {
649            if !m.is_empty() {
650                set_affinity(0, &m);
651            }
652        }
653    }
654
655    pub(super) fn parse_list(s: &str) -> Vec<usize> {
656        let mut out = Vec::new();
657        for part in s.trim().split(',') {
658            let part = part.trim();
659            if part.is_empty() {
660                continue;
661            }
662            match part.split_once('-') {
663                Some((a, b)) => {
664                    if let (Ok(a), Ok(b)) = (a.parse::<usize>(), b.parse::<usize>()) {
665                        out.extend(a..=b);
666                    }
667                }
668                None => {
669                    if let Ok(a) = part.parse() {
670                        out.push(a);
671                    }
672                }
673            }
674        }
675        out
676    }
677
678    fn nodes() -> Vec<(usize, Vec<usize>)> {
679        let mut v = Vec::new();
680        let Ok(rd) = std::fs::read_dir("/sys/devices/system/node") else {
681            return v;
682        };
683        for e in rd.flatten() {
684            let name = e.file_name().to_string_lossy().to_string();
685            let Some(id) = name.strip_prefix("node").and_then(|x| x.parse::<usize>().ok()) else {
686                continue;
687            };
688            if let Ok(l) = std::fs::read_to_string(e.path().join("cpulist")) {
689                let cpus = parse_list(&l);
690                if !cpus.is_empty() {
691                    v.push((id, cpus));
692                }
693            }
694        }
695        v.sort();
696        v
697    }
698
699    fn allowed() -> Vec<usize> {
700        // SAFETY: plain syscall into a zeroed, correctly sized set.
701        unsafe {
702            let mut set: libc::cpu_set_t = std::mem::zeroed();
703            if libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set) != 0 {
704                return Vec::new();
705            }
706            (0..libc::CPU_SETSIZE as usize)
707                .filter(|&c| libc::CPU_ISSET(c, &set))
708                .collect()
709        }
710    }
711
712    /// First SMT sibling of its core (or no topology info: count it).
713    fn primary(cpu: usize) -> bool {
714        let p = format!("/sys/devices/system/cpu/cpu{cpu}/topology/thread_siblings_list");
715        match std::fs::read_to_string(p) {
716            Ok(l) => parse_list(&l).first().is_none_or(|&f| f == cpu),
717            Err(_) => true,
718        }
719    }
720
721    /// Where the weights live: (pages sampled, sampled pages in the page
722    /// cache, mapped pages per node). The sample is ≤ 4096 pages spread
723    /// over `regions`; every sampled page that is already cached is mapped
724    /// here with one read (a minor fault — `mincore` says it is cached, so
725    /// no disk I/O), because both node queries below only see pages mapped
726    /// into THIS process. Per-node counts come from `move_pages` in query
727    /// mode, or — where a container's seccomp profile refuses that syscall
728    /// (EPERM on the RunPod image) — from `/proc/self/numa_maps` for the
729    /// mappings that hold the regions.
730    fn page_nodes(regions: &[&[u8]]) -> (usize, usize, Vec<usize>) {
731        const PAGE: usize = 4096;
732        let total: usize = regions.iter().map(|r| r.len() / PAGE).sum();
733        if total == 0 {
734            return (0, 0, Vec::new());
735        }
736        let stride = total.div_ceil(4096).max(1);
737        let mut pages: Vec<*mut libc::c_void> = Vec::new();
738        for r in regions {
739            let base = (r.as_ptr() as usize).div_ceil(PAGE) * PAGE;
740            let end = r.as_ptr() as usize + r.len();
741            let mut a = base;
742            while a + PAGE <= end {
743                pages.push(a as *mut libc::c_void);
744                a += PAGE * stride;
745            }
746        }
747        let mut incore = 0usize;
748        for &p in &pages {
749            let mut vec = 0u8;
750            // SAFETY: `p` is a page-aligned address inside a live mapping.
751            let cached = unsafe { libc::mincore(p, PAGE, &mut vec) } == 0 && vec & 1 == 1;
752            if cached {
753                incore += 1;
754                // SAFETY: readable mapped byte; volatile so it is not elided.
755                unsafe { std::ptr::read_volatile(p as *const u8) };
756            }
757        }
758        let mut status = vec![-1i32; pages.len()];
759        // SAFETY: query-only move_pages on our own mappings; `nodes` is
760        // NULL so nothing moves, `status` has one slot per page.
761        let rc = unsafe {
762            libc::syscall(
763                libc::SYS_move_pages,
764                0,
765                pages.len() as libc::c_ulong,
766                pages.as_mut_ptr(),
767                std::ptr::null::<libc::c_int>(),
768                status.as_mut_ptr(),
769                0,
770            )
771        };
772        let mut by = Vec::new();
773        if rc == 0 {
774            for &st in &status {
775                if st >= 0 {
776                    let n = st as usize;
777                    if by.len() <= n {
778                        by.resize(n + 1, 0);
779                    }
780                    by[n] += 1;
781                }
782            }
783        } else {
784            by = numa_maps_nodes(regions);
785        }
786        (pages.len(), incore, by)
787    }
788
789    /// Mapped pages per node of every mapping that overlaps `regions`,
790    /// from `/proc/self/maps` (ranges) + `/proc/self/numa_maps` (`N<k>=`).
791    fn numa_maps_nodes(regions: &[&[u8]]) -> Vec<usize> {
792        let (Ok(maps), Ok(nm)) = (
793            std::fs::read_to_string("/proc/self/maps"),
794            std::fs::read_to_string("/proc/self/numa_maps"),
795        ) else {
796            return Vec::new();
797        };
798        let spans: Vec<(usize, usize)> = regions
799            .iter()
800            .map(|r| (r.as_ptr() as usize, r.as_ptr() as usize + r.len()))
801            .collect();
802        let mut starts = std::collections::HashSet::new();
803        for line in maps.lines() {
804            let Some((range, _)) = line.split_once(' ') else {
805                continue;
806            };
807            let Some((a, b)) = range.split_once('-') else {
808                continue;
809            };
810            let (Ok(a), Ok(b)) = (usize::from_str_radix(a, 16), usize::from_str_radix(b, 16)) else {
811                continue;
812            };
813            if spans.iter().any(|&(s, e)| s < b && a < e) {
814                starts.insert(a);
815            }
816        }
817        let mut by = Vec::new();
818        for line in nm.lines() {
819            let mut it = line.split_whitespace();
820            let Some(a) = it.next().and_then(|a| usize::from_str_radix(a, 16).ok()) else {
821                continue;
822            };
823            if !starts.contains(&a) {
824                continue;
825            }
826            for f in it {
827                let Some((k, v)) = f.split_once('=') else {
828                    continue;
829                };
830                let (Some(n), Ok(v)) = (
831                    k.strip_prefix('N').and_then(|n| n.parse::<usize>().ok()),
832                    v.parse::<usize>(),
833                ) else {
834                    continue;
835                };
836                if by.len() <= n {
837                    by.resize(n + 1, 0);
838                }
839                by[n] += v;
840            }
841        }
842        by
843    }
844
845    /// (node, cpu mask) for a pool of `threads` participants, or None.
846    pub(super) fn choose(regions: &[&[u8]], threads: usize) -> Option<(usize, Vec<usize>)> {
847        let env = std::env::var("CMF_NUMA").ok();
848        if matches!(env.as_deref(), Some("0") | Some("off")) {
849            return None;
850        }
851        let trace = std::env::var("CMF_NUMA_TRACE").is_ok_and(|v| v != "0");
852        let nodes = nodes();
853        if nodes.len() < 2 {
854            if trace {
855                eprintln!("numa: {} node(s) visible — nothing to bind", nodes.len());
856            }
857            return None;
858        }
859        // `CMF_NUMA=node:<n>` forces a node (plain "0" means OFF).
860        let forced = env
861            .as_deref()
862            .and_then(|v| v.strip_prefix("node:"))
863            .and_then(|v| v.parse::<usize>().ok());
864        let node = match forced {
865            Some(n) => n,
866            None => {
867                // Auto: only when the weights already sit on ONE node
868                // (≥ 90% of the resident sample, and most of the sample
869                // resident). A file spread over both nodes is better
870                // served by both sockets; a cold file has no home yet.
871                let (sampled, incore, by) = page_nodes(regions);
872                let resident: usize = by.iter().sum();
873                if trace {
874                    eprintln!(
875                        "numa: sampled {sampled} weight pages, {incore} cached, mapped by node {by:?}"
876                    );
877                }
878                if sampled == 0 || incore * 2 < sampled || resident == 0 {
879                    return None;
880                }
881                let (n, &cnt) = by.iter().enumerate().max_by_key(|(_, c)| **c)?;
882                if cnt * 10 < resident * 9 {
883                    return None;
884                }
885                n
886            }
887        };
888        let cpus = &nodes.iter().find(|(id, _)| *id == node)?.1;
889        let allowed = allowed();
890        let usable: Vec<usize> = cpus.iter().copied().filter(|c| allowed.contains(c)).collect();
891        let prim: Vec<usize> = usable.iter().copied().filter(|&c| primary(c)).collect();
892        if prim.len() >= threads {
893            Some((node, prim))
894        } else if usable.len() >= threads {
895            Some((node, usable))
896        } else {
897            None
898        }
899    }
900
901    /// Bind thread `tid` (0 = the calling thread) to `cpus`.
902    pub(super) fn set_affinity(tid: i32, cpus: &[usize]) -> bool {
903        // SAFETY: plain syscall with a zeroed, correctly sized set.
904        unsafe {
905            let mut set: libc::cpu_set_t = std::mem::zeroed();
906            for &c in cpus {
907                if c < libc::CPU_SETSIZE as usize {
908                    libc::CPU_SET(c, &mut set);
909                }
910            }
911            libc::sched_setaffinity(tid, std::mem::size_of::<libc::cpu_set_t>(), &set) == 0
912        }
913    }
914}
915
916/// Per-core capacity: the kernel's `cpu_capacity` (µarch × clock) when
917/// EAS exposes it, else `cpufreq/cpuinfo_max_freq` — same cluster
918/// ordering, so the 62.5% big-core rule keeps working on EAS-less
919/// kernels (TUNING.md open item: pinning silently did nothing there).
920#[cfg(any(
921    target_os = "android",
922    all(target_arch = "aarch64", target_os = "linux")
923))]
924fn core_capacities() -> Vec<u64> {
925    let read_all = |leaf: &str| -> Vec<u64> {
926        let mut vals = Vec::new();
927        for cpu in 0.. {
928            let path = format!("/sys/devices/system/cpu/cpu{cpu}/{leaf}");
929            match std::fs::read_to_string(&path) {
930                Ok(v) => match v.trim().parse() {
931                    Ok(x) => vals.push(x),
932                    Err(_) => break,
933                },
934                Err(_) => break,
935            }
936        }
937        vals
938    };
939    let caps = read_all("cpu_capacity");
940    if caps.len() >= 2 {
941        return caps;
942    }
943    read_all("cpufreq/cpuinfo_max_freq")
944}
945
946#[cfg(target_os = "android")]
947fn pin_thread_to_big_cores() {
948    use std::mem;
949    let caps = core_capacities();
950    let max = caps.iter().copied().max().unwrap_or(0);
951    let min = caps.iter().copied().min().unwrap_or(0);
952
953    // Only pin if heterogeneous
954    if caps.len() < 2 || max == min {
955        return;
956    }
957
958    unsafe {
959        let mut set: libc::cpu_set_t = mem::zeroed();
960        for (i, &c) in caps.iter().enumerate() {
961            if c * 8 >= max * 5 {
962                libc::CPU_SET(i, &mut set);
963            }
964        }
965        libc::sched_setaffinity(0, mem::size_of::<libc::cpu_set_t>(), &set);
966    }
967}
968
969fn worker_loop(inner: &Inner, idx: usize) {
970    #[cfg(target_os = "android")]
971    pin_thread_to_big_cores();
972    // Apple silicon: ask for the performance cores. Threads spawned
973    // without a QoS class land on the efficiency cores when the
974    // scheduler feels like it — a user's video-VAE encode on an M4 sat
975    // on the E-cores at 100% with the P-cores asleep for 140 s (HF
976    // discussion #4). USER_INITIATED is the class an interactive tool's
977    // work belongs to; the ~4 P-cores then take the pool's grains.
978    #[cfg(target_os = "macos")]
979    unsafe {
980        libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INITIATED, 0);
981    }
982
983    let slot = &inner.slots[idx];
984    // Baselines are the construction values (0), never a fresh read: if
985    // the caller hands out a ticket before the OS actually starts this
986    // thread, adopting the live value as "already seen" would skip that
987    // job and deadlock the caller's wait.
988    let mut seen = 0usize;
989    let mut seen_epoch = 0usize;
990    loop {
991        // Wait for a ticket: spin first (decode publishes the next matvec
992        // within microseconds), park only when idle for real.
993        let mut spins = 0usize;
994        loop {
995            let t = slot.ticket.load(Ordering::Acquire);
996            if t != seen {
997                seen = t;
998                break;
999            }
1000            if inner.shutdown.load(Ordering::Relaxed) {
1001                return;
1002            }
1003            if !inner.oversubscribed {
1004                // Any job — even one this worker sits out — means the
1005                // pool is busy: stay hot for the next one.
1006                let e = inner.epoch.load(Ordering::Relaxed);
1007                if e != seen_epoch {
1008                    seen_epoch = e;
1009                    spins = 0;
1010                }
1011            }
1012            if spins < inner.spin_budget.load(Ordering::Relaxed) {
1013                spins += 1;
1014                if inner.oversubscribed && spins.is_multiple_of(64) {
1015                    std::thread::yield_now();
1016                } else {
1017                    std::hint::spin_loop();
1018                }
1019            } else {
1020                slot.parked.store(true, Ordering::Relaxed);
1021                // Pairs with the caller's fence between writing tickets
1022                // and reading `parked`: either it sees our flag (and
1023                // unparks) or we see its ticket here — a missed wakeup is
1024                // impossible. Spurious unparks just loop.
1025                std::sync::atomic::fence(Ordering::SeqCst);
1026                if slot.ticket.load(Ordering::Relaxed) == seen
1027                    && !inner.shutdown.load(Ordering::Relaxed)
1028                {
1029                    std::thread::park();
1030                }
1031                slot.parked.store(false, Ordering::Relaxed);
1032            }
1033        }
1034        // The ticket's Acquire made the descriptor visible, and the caller
1035        // cannot rewrite it before our decrement below: it waits for
1036        // `remaining`, which counts this ticket.
1037        let data = inner.desc_data.load(Ordering::Relaxed);
1038        let vtable = inner.desc_vtable.load(Ordering::Relaxed);
1039        let n = inner.desc_n.load(Ordering::Relaxed);
1040        let dev = inner.desc_dev.load(Ordering::Relaxed);
1041        let float_activations = inner.desc_float_activations.load(Ordering::Relaxed);
1042        // SAFETY: the two words are the fat pointer `dispatch` split, and
1043        // the caller of this job is blocked on our decrement below, so the
1044        // closure it borrows is alive for the whole call.
1045        let task: *const (dyn Fn(usize, usize) + Sync + 'static) =
1046            unsafe { std::mem::transmute([data, vtable]) };
1047        let f = unsafe { &*task };
1048        crate::gpu::set_current_device(dev);
1049        let _float_activations =
1050            float_activations.then(crate::qtensor::enter_float_activations_scope);
1051        f(idx, n);
1052        inner.remaining.0.fetch_sub(1, Ordering::AcqRel);
1053    }
1054}
1055
1056/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
1057/// Bit-identical to the serial loop (row order does not change math).
1058pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
1059    let in_dim = x.len();
1060    let out_dim = out.len();
1061    debug_assert!(w.len() >= out_dim * in_dim);
1062
1063    let row_dot = |o: usize| -> f32 {
1064        let row = &w[o * in_dim..(o + 1) * in_dim];
1065        let mut sum = 0.0f32;
1066        for j in 0..in_dim {
1067            sum += row[j] * x[j];
1068        }
1069        sum
1070    };
1071
1072    let out_addr = SendMut(out.as_mut_ptr());
1073    let run_range = move |start: usize, end: usize| {
1074        let mut o = start;
1075        // Four independent reduction chains hide add latency and reuse x.
1076        // Each row still sums j=0..in_dim in exactly the scalar order: no
1077        // horizontal SIMD reduction, FMA, or quantization approximation.
1078        while end - o >= 4 {
1079            let base = o * in_dim;
1080            let w0 = &w[base..base + in_dim];
1081            let w1 = &w[base + in_dim..base + 2 * in_dim];
1082            let w2 = &w[base + 2 * in_dim..base + 3 * in_dim];
1083            let w3 = &w[base + 3 * in_dim..base + 4 * in_dim];
1084            let (mut a, mut b, mut c, mut d) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
1085            for j in 0..in_dim {
1086                let v = x[j];
1087                a += w0[j] * v;
1088                b += w1[j] * v;
1089                c += w2[j] * v;
1090                d += w3[j] * v;
1091            }
1092            // run_rows assigns disjoint ranges within 0..out_dim.
1093            unsafe {
1094                *out_addr.at(o) = a;
1095                *out_addr.at(o + 1) = b;
1096                *out_addr.at(o + 2) = c;
1097                *out_addr.at(o + 3) = d;
1098            }
1099            o += 4;
1100        }
1101        for o in o..end {
1102            unsafe { *out_addr.at(o) = row_dot(o) };
1103        }
1104    };
1105    match pool {
1106        Some(pool) if out_dim >= 256 => pool.run_rows(out_dim, &run_range),
1107        _ => run_range(0, out_dim),
1108    }
1109}
1110
1111/// Two-input row matvec: one pass over the weight rows serves BOTH
1112/// inputs — CPU decode is memory-bound, so the second position costs a
1113/// fraction of the first (this is where MTP speculative verify wins).
1114/// Per-output accumulation order matches the single-input path exactly
1115/// → bit-identical results.
1116pub fn matvec_rows2(
1117    pool: Option<&Pool>,
1118    w: &[f32],
1119    x1: &[f32],
1120    x2: &[f32],
1121    out1: &mut [f32],
1122    out2: &mut [f32],
1123) {
1124    let in_dim = x1.len();
1125    debug_assert_eq!(x2.len(), in_dim);
1126    let out_dim = out1.len();
1127    debug_assert_eq!(out2.len(), out_dim);
1128    debug_assert!(w.len() >= out_dim * in_dim);
1129
1130    let row_dots = |o: usize| -> (f32, f32) {
1131        let row = &w[o * in_dim..(o + 1) * in_dim];
1132        let (mut s1, mut s2) = (0.0f32, 0.0f32);
1133        for j in 0..in_dim {
1134            s1 += row[j] * x1[j];
1135            s2 += row[j] * x2[j];
1136        }
1137        (s1, s2)
1138    };
1139
1140    match pool {
1141        Some(pool) if out_dim >= 256 => {
1142            let o1 = SendMut(out1.as_mut_ptr());
1143            let o2 = SendMut(out2.as_mut_ptr());
1144            let run_range = move |start: usize, end: usize| {
1145                for o in start..end {
1146                    let (s1, s2) = row_dots(o);
1147                    unsafe {
1148                        *o1.at(o) = s1;
1149                        *o2.at(o) = s2;
1150                    }
1151                }
1152            };
1153            pool.run_rows(out_dim, &run_range);
1154        }
1155        _ => {
1156            for o in 0..out_dim {
1157                let (s1, s2) = row_dots(o);
1158                out1[o] = s1;
1159                out2[o] = s2;
1160            }
1161        }
1162    }
1163}
1164
1165/// `SendMut` for any element type — the sampler's sparse chain writes
1166/// per-grain candidate lists.
1167pub(crate) struct SendMutT<T>(*mut T);
1168unsafe impl<T> Send for SendMutT<T> {}
1169unsafe impl<T> Sync for SendMutT<T> {}
1170impl<T> Clone for SendMutT<T> {
1171    fn clone(&self) -> Self {
1172        *self
1173    }
1174}
1175impl<T> Copy for SendMutT<T> {}
1176impl<T> SendMutT<T> {
1177    #[inline]
1178    pub(crate) fn new(p: *mut T) -> Self {
1179        Self(p)
1180    }
1181    /// Same contract as `SendMut::at`: disjoint indices, pointee outlives
1182    /// the joined dispatch.
1183    #[inline]
1184    pub(crate) fn at(self, i: usize) -> *mut T {
1185        unsafe { self.0.add(i) }
1186    }
1187}
1188
1189#[derive(Clone, Copy)]
1190pub(crate) struct SendMut(*mut f32);
1191unsafe impl Send for SendMut {}
1192unsafe impl Sync for SendMut {}
1193
1194impl SendMut {
1195    /// The caller promises the threads it hands this to write disjoint
1196    /// indices, and that the pointee outlives them.
1197    #[inline]
1198    pub(crate) fn new(p: *mut f32) -> Self {
1199        Self(p)
1200    }
1201
1202    /// Method receiver forces the closure to capture the whole (Sync)
1203    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
1204    #[inline]
1205    pub(crate) fn at(self, i: usize) -> *mut f32 {
1206        unsafe { self.0.add(i) }
1207    }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    /// A persistent worker keeps its own TLS between jobs. An exact caller
1213    /// scope must therefore ride with the descriptor, and must not leak into
1214    /// the next unscoped job.
1215    #[test]
1216    fn float_activation_scope_follows_dispatch_and_is_restored() {
1217        let pool = Pool::with_spin(2, 0);
1218        let active = AtomicUsize::new(0);
1219        let inactive = AtomicUsize::new(0);
1220        crate::qtensor::float_activations_scope(|| {
1221            pool.run(&|_w, _n| {
1222                if crate::qtensor::float_activations_active() {
1223                    active.fetch_add(1, Ordering::Relaxed);
1224                } else {
1225                    inactive.fetch_add(1, Ordering::Relaxed);
1226                }
1227            });
1228        });
1229        assert_eq!(active.load(Ordering::Relaxed), 3);
1230        assert_eq!(inactive.load(Ordering::Relaxed), 0);
1231
1232        pool.run(&|_w, _n| {
1233            if crate::qtensor::float_activations_active() {
1234                active.fetch_add(1, Ordering::Relaxed);
1235            } else {
1236                inactive.fetch_add(1, Ordering::Relaxed);
1237            }
1238        });
1239        assert_eq!(active.load(Ordering::Relaxed), 3);
1240        assert_eq!(inactive.load(Ordering::Relaxed), 3);
1241    }
1242
1243    /// A worker NOT invited to a limited job is not waited for; if it is
1244    /// preempted around the moment the job is published, the caller may
1245    /// already be on the next job. The first protocol then ran that next
1246    /// job twice and wrapped `remaining` — the caller spun forever.
1247    /// Oversubscribe the machine with spinning workers, hammer limited
1248    /// dispatches, and count every closure entry: each job must run
1249    /// exactly (limit + 1) times, and the loop must finish (a watchdog
1250    /// turns a hang into a failure). On a 2–4-CPU box the second protocol
1251    /// ran ~100 dispatches/s here (every spinner woke for every job and
1252    /// held the CPUs the invited worker needed) and hit the watchdog at
1253    /// half the jobs — slow, not wrong, but a pool that needs a time
1254    /// slice per dispatch is broken too.
1255    #[test]
1256    fn uninvited_straggler_never_runs_a_job_twice_or_wraps_the_barrier() {
1257        use std::sync::atomic::AtomicUsize;
1258        let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
1259        let workers = (cores * 4).clamp(8, 64);
1260        let pool = Arc::new(Pool::with_spin(workers, 1_000_000));
1261        let entries = Arc::new(AtomicUsize::new(0));
1262        let expected = Arc::new(AtomicUsize::new(0));
1263        let progress = Arc::new(AtomicUsize::new(0));
1264        let done = Arc::new(AtomicBool::new(false));
1265        let iters = 20_000usize;
1266        let p = pool.clone();
1267        let (en, ex, pr, dn) = (
1268            entries.clone(),
1269            expected.clone(),
1270            progress.clone(),
1271            done.clone(),
1272        );
1273        let driver = std::thread::spawn(move || {
1274            for i in 0..iters {
1275                pr.store(i, Ordering::Relaxed);
1276                // Few grains → `run_limited` with limit < workers: most
1277                // workers are uninvited.  Alternate with a full `run`.
1278                let limit = 1 + i % 3;
1279                let f = |_w: usize, _n: usize| {
1280                    en.fetch_add(1, Ordering::Relaxed);
1281                };
1282                p.run_limited(limit, &f);
1283                ex.fetch_add(limit.min(p.n_workers()) + 1, Ordering::Relaxed);
1284                if i % 97 == 0 {
1285                    p.run(&f);
1286                    ex.fetch_add(p.n_workers() + 1, Ordering::Relaxed);
1287                }
1288            }
1289            dn.store(true, Ordering::SeqCst);
1290        });
1291        let t0 = std::time::Instant::now();
1292        while !done.load(Ordering::SeqCst) {
1293            assert!(
1294                t0.elapsed() < std::time::Duration::from_secs(120),
1295                "pool dispatch loop did not finish: at iteration {} of {}, entries {} \
1296                 expected {} (the difference is the job in flight), remaining {}",
1297                progress.load(Ordering::Relaxed),
1298                iters,
1299                entries.load(Ordering::Relaxed),
1300                expected.load(Ordering::Relaxed),
1301                pool.inner.remaining.0.load(Ordering::Relaxed)
1302            );
1303            std::thread::sleep(std::time::Duration::from_millis(20));
1304        }
1305        driver.join().unwrap();
1306        // A late straggler could still be inside its (single) job; one
1307        // more full barrier drains it.
1308        pool.run(&|_w, _n| {});
1309        assert_eq!(
1310            entries.load(Ordering::Relaxed),
1311            expected.load(Ordering::Relaxed),
1312            "some job ran a closure more or fewer times than its participants"
1313        );
1314    }
1315
1316    /// Participation is decided by the ticket alone: a limited job hands
1317    /// one ticket to each of workers `0..limit` and to nobody else, a full
1318    /// `run` one to every worker. Spin 0 also drives the park/unpark path.
1319    #[test]
1320    fn limited_dispatch_hands_tickets_only_to_invited_workers() {
1321        let pool = Pool::with_spin(6, 0);
1322        let hits: Vec<AtomicUsize> = (0..7).map(|_| AtomicUsize::new(0)).collect();
1323        let bad = AtomicUsize::new(0);
1324        // No panics inside the job: a worker that dies never decrements.
1325        let f = |w: usize, n: usize| match hits.get(w) {
1326            Some(h) if w < n && (n == 3 || n == 7) => {
1327                h.fetch_add(1, Ordering::Relaxed);
1328            }
1329            _ => {
1330                bad.fetch_add(1, Ordering::Relaxed);
1331            }
1332        };
1333        let tickets = |p: &Pool| -> Vec<usize> {
1334            p.inner
1335                .slots
1336                .iter()
1337                .map(|s| s.ticket.load(Ordering::Acquire))
1338                .collect()
1339        };
1340        pool.run_limited(2, &f);
1341        assert_eq!(tickets(&pool), [1, 1, 0, 0, 0, 0]);
1342        pool.run(&f);
1343        assert_eq!(tickets(&pool), [2, 2, 1, 1, 1, 1]);
1344        assert_eq!(
1345            bad.load(Ordering::Relaxed),
1346            0,
1347            "participant index or count off"
1348        );
1349        let hits: Vec<usize> = hits.iter().map(|h| h.load(Ordering::Relaxed)).collect();
1350        // Index 2 is the caller of the limited job and worker 2 of the run.
1351        assert_eq!(hits, [2, 2, 2, 1, 1, 1, 1]);
1352    }
1353
1354    #[test]
1355    fn f32_four_row_matvec_matches_scalar_bits_and_preserves_tail() {
1356        let pool = super::Pool::new(3);
1357        for rows in [0, 1, 3, 4, 7, 255, 256, 259, 1024] {
1358            for cols in [0, 1, 3, 32, 65, 384] {
1359                let w: Vec<f32> = (0..rows * cols)
1360                    .map(|i| (i as f32 * 0.173).sin() * [1e-3, 1.0, 1e3][i % 3])
1361                    .collect();
1362                let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.41).cos()).collect();
1363                let want: Vec<u32> = (0..rows)
1364                    .map(|r| {
1365                        let mut sum = 0.0f32;
1366                        for j in 0..cols {
1367                            sum += w[r * cols + j] * x[j];
1368                        }
1369                        sum.to_bits()
1370                    })
1371                    .collect();
1372                for workers in [None, Some(&pool)] {
1373                    let mut out = vec![17.0f32; rows + 5];
1374                    super::matvec_rows(workers, &w, &x, &mut out[..rows]);
1375                    let bits: Vec<u32> = out[..rows].iter().map(|v| v.to_bits()).collect();
1376                    assert_eq!(bits, want, "shape {rows}x{cols}");
1377                    assert_eq!(&out[rows..], &[17.0; 5]);
1378                }
1379                // A short output is an intentional public matvec contract.
1380                let mut prefix = vec![0.0f32; rows / 2];
1381                super::matvec_rows(None, &w, &x, &mut prefix);
1382                assert_eq!(
1383                    prefix.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1384                    want[..rows / 2]
1385                );
1386            }
1387        }
1388    }
1389
1390    #[test]
1391    #[cfg(target_os = "linux")]
1392    fn numa_cpulist_parses_ranges_and_singles() {
1393        assert_eq!(super::numa::parse_list("0-3,8,10-11\n"), vec![0, 1, 2, 3, 8, 10, 11]);
1394        assert_eq!(super::numa::parse_list(""), Vec::<usize>::new());
1395    }
1396
1397    #[test]
1398    #[cfg(any(target_os = "android", target_os = "linux"))]
1399    fn worker_tids_registered_before_new_returns() {
1400        // WORKER_TIDS is only the last completed pool's process-wide
1401        // snapshot; another test can publish a different valid snapshot
1402        // immediately after `new` returns. Check this pool's private
1403        // registration state instead.
1404        use std::collections::HashSet;
1405        let p = super::Pool::new(3);
1406        let local: Vec<_> = p.inner.worker_tids.lock().unwrap().clone();
1407        let registered = p.inner.registered.load(Ordering::Acquire);
1408        let unique: HashSet<_> = local.iter().copied().collect();
1409        assert!(
1410            registered == 3
1411                && local.len() == 3
1412                && unique.len() == 3
1413                && local.iter().all(|&tid| tid > 0),
1414            "all worker tids must be privately registered before new returns \
1415             (registered {registered}, local {}, unique {})",
1416            local.len(),
1417            unique.len()
1418        );
1419    }
1420
1421    #[test]
1422    fn forced_threads_overrides_env_and_topology() {
1423        use std::sync::atomic::Ordering;
1424        super::FORCED_THREADS.store(3, Ordering::Relaxed);
1425        let pool = super::Pool::from_env().expect("forced 3 → pool");
1426        assert_eq!(pool.n_workers(), 3);
1427        super::FORCED_THREADS.store(1, Ordering::Relaxed);
1428        assert!(super::Pool::from_env().is_none(), "forced 1 → serial");
1429        super::FORCED_THREADS.store(0, Ordering::Relaxed);
1430    }
1431
1432    #[test]
1433    #[cfg(any(target_os = "android", target_os = "linux"))]
1434    fn concurrent_pool_constructors_complete_without_registration_race() {
1435        // WORKER_TIDS is a process-wide publication target. Before the
1436        // per-pool counter, a larger constructor could have all its workers
1437        // append, then a concurrent one could clear that vector; the larger
1438        // constructor would wait forever for a length that could never return.
1439        // Start unlike-sized constructors together so that regression is
1440        // exercised without relying on the test harness' scheduling.
1441        use std::sync::{Barrier, mpsc};
1442        use std::time::Duration;
1443
1444        for round in 0..16 {
1445            let start = Arc::new(Barrier::new(3));
1446            let (done_tx, done_rx) = mpsc::channel();
1447            let mut joins = Vec::new();
1448            for workers in [8usize, 1usize] {
1449                let start = start.clone();
1450                let done_tx = done_tx.clone();
1451                joins.push(std::thread::spawn(move || {
1452                    start.wait();
1453                    let pool = Pool::with_spin(workers, 0);
1454                    done_tx.send(pool.n_workers()).unwrap();
1455                }));
1456            }
1457            drop(done_tx);
1458            start.wait();
1459            let mut sizes = Vec::with_capacity(2);
1460            for _ in 0..2 {
1461                sizes.push(
1462                    done_rx
1463                        .recv_timeout(Duration::from_secs(10))
1464                        .unwrap_or_else(|_| panic!("pool constructor stalled in round {round}")),
1465                );
1466            }
1467            sizes.sort_unstable();
1468            assert_eq!(sizes, [1, 8]);
1469            for join in joins {
1470                join.join().unwrap();
1471            }
1472        }
1473    }
1474
1475    #[test]
1476    fn capacity_split_clock_bins_vs_microarch() {
1477        type P = super::Pool;
1478        // JR510: all-A55, two clock bins — use every core.
1479        assert_eq!(
1480            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 768, 768, 768, 768]),
1481            Some(8)
1482        );
1483        // Classic big.LITTLE (A78 + A55) — big only.
1484        assert_eq!(
1485            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 350, 350, 350, 350]),
1486            Some(4)
1487        );
1488        // Three-tier flagship: X + A7xx mids stay, A5xx littles go.
1489        assert_eq!(
1490            P::cores_from_capacities(&[1024, 800, 800, 800, 800, 300, 300, 300]),
1491            Some(5)
1492        );
1493        // Uniform: no signal, caller falls back.
1494        assert_eq!(P::cores_from_capacities(&[1024; 8]), None);
1495        assert_eq!(P::cores_from_capacities(&[]), None);
1496    }
1497
1498    use super::*;
1499
1500    #[test]
1501    fn parallel_matvec_equals_serial_bitexact() {
1502        let (out_dim, in_dim) = (512, 64);
1503        let w: Vec<f32> = (0..out_dim * in_dim)
1504            .map(|i| (i as f32 * 0.013).sin())
1505            .collect();
1506        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
1507
1508        let mut serial = vec![0.0f32; out_dim];
1509        matvec_rows(None, &w, &x, &mut serial);
1510
1511        let pool = Pool::new(4);
1512        let mut parallel = vec![0.0f32; out_dim];
1513        matvec_rows(Some(&pool), &w, &x, &mut parallel);
1514
1515        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
1516    }
1517
1518    #[test]
1519    fn fused_pair_equals_two_singles_bitexact() {
1520        let (out_dim, in_dim) = (300, 48);
1521        let w: Vec<f32> = (0..out_dim * in_dim)
1522            .map(|i| (i as f32 * 0.011).sin())
1523            .collect();
1524        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
1525        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
1526
1527        let mut a1 = vec![0.0f32; out_dim];
1528        let mut a2 = vec![0.0f32; out_dim];
1529        matvec_rows(None, &w, &x1, &mut a1);
1530        matvec_rows(None, &w, &x2, &mut a2);
1531
1532        for pool in [None, Some(Pool::new(3))] {
1533            let mut b1 = vec![0.0f32; out_dim];
1534            let mut b2 = vec![0.0f32; out_dim];
1535            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
1536            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
1537            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
1538        }
1539    }
1540
1541    #[test]
1542    fn pool_survives_many_runs() {
1543        let pool = Pool::new(3);
1544        let counter = AtomicUsize::new(0);
1545        for _ in 0..100 {
1546            pool.run(&|_, _| {
1547                counter.fetch_add(1, Ordering::Relaxed);
1548            });
1549        }
1550        // 3 workers + the participating caller = 4 executions per run.
1551        assert_eq!(counter.load(Ordering::Relaxed), 400);
1552    }
1553
1554    #[test]
1555    fn pool_wakes_after_park() {
1556        // Force immediate parking (no spin) — the epoch/parked handshake
1557        // must still never miss a wakeup.
1558        let pool = Pool::with_spin(2, 0);
1559        let counter = AtomicUsize::new(0);
1560        for _ in 0..50 {
1561            pool.run(&|_, _| {
1562                counter.fetch_add(1, Ordering::Relaxed);
1563            });
1564            // Give workers time to actually park between jobs.
1565            std::thread::sleep(std::time::Duration::from_micros(200));
1566        }
1567        assert_eq!(counter.load(Ordering::Relaxed), 150);
1568    }
1569
1570    #[test]
1571    fn worker_indices_are_distinct_and_cover_range() {
1572        let pool = Pool::new(3);
1573        let hits: Vec<AtomicUsize> = (0..4).map(|_| AtomicUsize::new(0)).collect();
1574        for _ in 0..20 {
1575            pool.run(&|widx, n| {
1576                assert_eq!(n, 4);
1577                hits[widx].fetch_add(1, Ordering::Relaxed);
1578            });
1579        }
1580        for (i, h) in hits.iter().enumerate() {
1581            assert_eq!(h.load(Ordering::Relaxed), 20, "participant {i} missed runs");
1582        }
1583    }
1584}
1585
1586#[cfg(test)]
1587mod grain_tests {
1588    use super::grain_for;
1589
1590    #[test]
1591    fn a_short_job_still_reaches_every_worker() {
1592        // 24 rows, 49 workers: the old flat floor of 32 handed all 24 to the
1593        // first worker and woke the rest for nothing.
1594        assert_eq!(grain_for(24, 49), 1);
1595        // Wide jobs keep the stride the SDOT loop wants.
1596        assert_eq!(grain_for(4096, 49), 32);
1597        assert_eq!(grain_for(32768, 49), 83);
1598        // Degenerate shapes must not divide by zero or return zero.
1599        assert_eq!(grain_for(0, 49), 1);
1600        assert_eq!(grain_for(7, 1), 7);
1601        assert!(grain_for(1, 49) >= 1);
1602    }
1603}