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 _tot = crate::cpuprof::time(crate::cpuprof::Slot::DispTotal);
461        let mut _setup = crate::cpuprof::time(crate::cpuprof::Slot::DispSetup);
462        let inner = &*self.inner;
463        let n = nw + 1;
464        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
465        // SAFETY: a fat pointer is exactly two words on every supported
466        // target; the halves are only ever reassembled by `worker_loop`.
467        let raw: [usize; 2] = unsafe { std::mem::transmute(ptr) };
468        inner.desc_data.store(raw[0], Ordering::Relaxed);
469        inner.desc_vtable.store(raw[1], Ordering::Relaxed);
470        inner.desc_n.store(n, Ordering::Relaxed);
471        inner
472            .desc_dev
473            .store(crate::gpu::current_device(), Ordering::Relaxed);
474        inner.desc_float_activations.store(
475            crate::qtensor::float_activations_active(),
476            Ordering::Relaxed,
477        );
478        inner.remaining.0.store(nw, Ordering::Relaxed);
479        let e = inner.epoch.load(Ordering::Relaxed);
480        inner.epoch.store(e.wrapping_add(1), Ordering::Relaxed);
481        // Release: a worker that sees its new ticket (Acquire) sees the
482        // descriptor and `remaining` written above.
483        let slots = &inner.slots[..nw];
484        for slot in slots {
485            let t = slot.ticket.load(Ordering::Relaxed).wrapping_add(1);
486            slot.ticket.store(t, Ordering::Release);
487        }
488        // Pairs with the fence a worker issues between raising `parked`
489        // and re-reading its ticket: either that worker sees the ticket
490        // or we see its flag and unpark it — no lost wakeup. One fence
491        // for all the tickets instead of a SeqCst store per worker.
492        std::sync::atomic::fence(Ordering::SeqCst);
493        for (slot, t) in slots.iter().zip(&self.threads) {
494            if slot.parked.load(Ordering::Relaxed) {
495                t.unpark();
496            }
497        }
498        drop(_setup);
499
500        // The caller's share — the barrier costs nothing while there is
501        // real work to do.
502        let _work = crate::cpuprof::time(crate::cpuprof::Slot::DispWork);
503        f(nw, n);
504        drop(_work);
505
506        // Wait for the stragglers (bounded by one worker's chunk). When the
507        // pool has more threads than CPUs, a ticket holder may be
508        // waiting for THIS core: yield it early instead of spinning.
509        let _wait = crate::cpuprof::time(crate::cpuprof::Slot::DispWait);
510        let spin_limit = if inner.oversubscribed { 64 } else { 10_000 };
511        let mut spins = 0usize;
512        while inner.remaining.0.load(Ordering::Acquire) != 0 {
513            spins += 1;
514            if spins < spin_limit {
515                std::hint::spin_loop();
516            } else {
517                std::thread::yield_now();
518            }
519        }
520        drop(_wait);
521        drop(_tot);
522    }
523
524    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
525    ///
526    /// One dispatch, but workers pull row-ranges from a shared cursor
527    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
528    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
529    /// every matvec end at the SLOWEST core's pace while the fast ones
530    /// idle at the barrier; pulling by grain lets a P-core take several
531    /// chunks for each one an E-core takes, so skew collapses to a
532    /// single grain. Row ranges stay disjoint and each row's dot is
533    /// computed exactly as in the serial path → bit-identical output.
534    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
535        let grain = grain_for(rows, self.threads.len() + 1);
536        let chunks = rows.div_ceil(grain.max(1));
537        let next = AtomicUsize::new(0);
538        self.run_limited(chunks, &|_w, _n| loop {
539            let start = next.fetch_add(grain, Ordering::Relaxed);
540            if start >= rows {
541                break;
542            }
543            f(start, (start + grain).min(rows));
544        });
545    }
546
547    /// `run`, with at most `max_workers` workers PARTICIPATING. Same
548    /// grain, same row split, bit-identical results — only the number of
549    /// threads handed the job changes: a job with eight grains has no use
550    /// for three hundred workers, the unpark syscalls and the
551    /// remaining-drain would BE the job (measured: 361 pool dispatches
552    /// per DeepSeek-V4 token, and CMF_THREADS=64 vs 380 was 1.3 vs 2.4
553    /// tok/s with no other change). Only cursor-style closures (which
554    /// ignore their (idx, n) arguments) come through here: the caller
555    /// identifies itself as the capped count, which is NOT `n_workers()`.
556    fn run_limited(&self, max_workers: usize, f: &(dyn Fn(usize, usize) + Sync)) {
557        let nw = self.threads.len().min(max_workers);
558        if nw == self.threads.len() {
559            return self.run(f);
560        }
561        #[cfg(target_os = "linux")]
562        numa::adopt_caller();
563        DISPATCHES.fetch_add(1, Ordering::Relaxed);
564        // Workers `nw..` get no ticket: they neither run nor wait.
565        self.dispatch(f, nw);
566    }
567
568    /// Multi-matrix job: one dispatch serves SEVERAL row spaces
569    /// (roadmap §3 P0 — «одна внешняя публикация job на слой»). Parts
570    /// are laid out back-to-back in a virtual row space and pulled by
571    /// grain from one shared cursor, so QKV or gate+up cost a single
572    /// barrier instead of one each. Each part's `f(start, end)` sees its
573    /// OWN row indices — per-row math and outputs are bit-identical to
574    /// separate `run_rows` calls.
575    pub fn run_many(&self, parts: &[(usize, &(dyn Fn(usize, usize) + Sync))]) {
576        let total: usize = parts.iter().map(|p| p.0).sum();
577        if total == 0 {
578            return;
579        }
580        let grain = grain_for(total, self.threads.len() + 1);
581        let chunks = total.div_ceil(grain.max(1));
582        let next = AtomicUsize::new(0);
583        self.run_limited(chunks, &|_w, _n| loop {
584            let s = next.fetch_add(grain, Ordering::Relaxed);
585            if s >= total {
586                break;
587            }
588            let e = (s + grain).min(total);
589            let mut base = 0usize;
590            for &(rows, f) in parts {
591                let a = s.max(base);
592                let b = e.min(base + rows);
593                if a < b {
594                    f(a - base, b - base);
595                }
596                base += rows;
597                if base >= e {
598                    break;
599                }
600            }
601        });
602    }
603
604    /// Run `f(worker_idx, n_participants)` on every worker AND the
605    /// calling thread (`worker_idx = n_workers()` for the caller);
606    /// returns when all participants have finished.
607    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
608        #[cfg(target_os = "linux")]
609        numa::adopt_caller();
610        DISPATCHES.fetch_add(1, Ordering::Relaxed);
611        self.dispatch(f, self.threads.len());
612    }
613}
614
615impl Drop for Pool {
616    fn drop(&mut self) {
617        self.inner.shutdown.store(true, Ordering::SeqCst);
618        for t in &self.threads {
619            t.unpark();
620        }
621        for h in self.joins.drain(..) {
622            let _ = h.join();
623        }
624    }
625}
626
627/// NUMA placement for the pool (see `Pool::bind_numa`).
628#[cfg(target_os = "linux")]
629mod numa {
630    use std::sync::Mutex;
631    use std::sync::atomic::{AtomicUsize, Ordering};
632
633    /// The published mask; `EPOCH` bumps on every publish so a calling
634    /// thread re-adopts at most once per bind.
635    static MASK: Mutex<Vec<usize>> = Mutex::new(Vec::new());
636    static EPOCH: AtomicUsize = AtomicUsize::new(0);
637    thread_local! {
638        static SEEN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
639    }
640
641    pub(super) fn publish(cpus: Vec<usize>) {
642        if let Ok(mut m) = MASK.lock() {
643            *m = cpus;
644        }
645        EPOCH.fetch_add(1, Ordering::Release);
646    }
647
648    /// One relaxed load + one TLS read per dispatch when nothing changed.
649    #[inline]
650    pub(super) fn adopt_caller() {
651        let e = EPOCH.load(Ordering::Acquire);
652        if e == 0 || SEEN.with(|c| c.get()) == e {
653            return;
654        }
655        SEEN.with(|c| c.set(e));
656        if let Ok(m) = MASK.lock() {
657            if !m.is_empty() {
658                set_affinity(0, &m);
659            }
660        }
661    }
662
663    pub(super) fn parse_list(s: &str) -> Vec<usize> {
664        let mut out = Vec::new();
665        for part in s.trim().split(',') {
666            let part = part.trim();
667            if part.is_empty() {
668                continue;
669            }
670            match part.split_once('-') {
671                Some((a, b)) => {
672                    if let (Ok(a), Ok(b)) = (a.parse::<usize>(), b.parse::<usize>()) {
673                        out.extend(a..=b);
674                    }
675                }
676                None => {
677                    if let Ok(a) = part.parse() {
678                        out.push(a);
679                    }
680                }
681            }
682        }
683        out
684    }
685
686    fn nodes() -> Vec<(usize, Vec<usize>)> {
687        let mut v = Vec::new();
688        let Ok(rd) = std::fs::read_dir("/sys/devices/system/node") else {
689            return v;
690        };
691        for e in rd.flatten() {
692            let name = e.file_name().to_string_lossy().to_string();
693            let Some(id) = name.strip_prefix("node").and_then(|x| x.parse::<usize>().ok()) else {
694                continue;
695            };
696            if let Ok(l) = std::fs::read_to_string(e.path().join("cpulist")) {
697                let cpus = parse_list(&l);
698                if !cpus.is_empty() {
699                    v.push((id, cpus));
700                }
701            }
702        }
703        v.sort();
704        v
705    }
706
707    fn allowed() -> Vec<usize> {
708        // SAFETY: plain syscall into a zeroed, correctly sized set.
709        unsafe {
710            let mut set: libc::cpu_set_t = std::mem::zeroed();
711            if libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set) != 0 {
712                return Vec::new();
713            }
714            (0..libc::CPU_SETSIZE as usize)
715                .filter(|&c| libc::CPU_ISSET(c, &set))
716                .collect()
717        }
718    }
719
720    /// First SMT sibling of its core (or no topology info: count it).
721    fn primary(cpu: usize) -> bool {
722        let p = format!("/sys/devices/system/cpu/cpu{cpu}/topology/thread_siblings_list");
723        match std::fs::read_to_string(p) {
724            Ok(l) => parse_list(&l).first().is_none_or(|&f| f == cpu),
725            Err(_) => true,
726        }
727    }
728
729    /// Where the weights live: (pages sampled, sampled pages in the page
730    /// cache, mapped pages per node). The sample is ≤ 4096 pages spread
731    /// over `regions`; every sampled page that is already cached is mapped
732    /// here with one read (a minor fault — `mincore` says it is cached, so
733    /// no disk I/O), because both node queries below only see pages mapped
734    /// into THIS process. Per-node counts come from `move_pages` in query
735    /// mode, or — where a container's seccomp profile refuses that syscall
736    /// (EPERM on the RunPod image) — from `/proc/self/numa_maps` for the
737    /// mappings that hold the regions.
738    fn page_nodes(regions: &[&[u8]]) -> (usize, usize, Vec<usize>) {
739        const PAGE: usize = 4096;
740        let total: usize = regions.iter().map(|r| r.len() / PAGE).sum();
741        if total == 0 {
742            return (0, 0, Vec::new());
743        }
744        let stride = total.div_ceil(4096).max(1);
745        let mut pages: Vec<*mut libc::c_void> = Vec::new();
746        for r in regions {
747            let base = (r.as_ptr() as usize).div_ceil(PAGE) * PAGE;
748            let end = r.as_ptr() as usize + r.len();
749            let mut a = base;
750            while a + PAGE <= end {
751                pages.push(a as *mut libc::c_void);
752                a += PAGE * stride;
753            }
754        }
755        let mut incore = 0usize;
756        for &p in &pages {
757            let mut vec = 0u8;
758            // SAFETY: `p` is a page-aligned address inside a live mapping.
759            let cached = unsafe { libc::mincore(p, PAGE, &mut vec) } == 0 && vec & 1 == 1;
760            if cached {
761                incore += 1;
762                // SAFETY: readable mapped byte; volatile so it is not elided.
763                unsafe { std::ptr::read_volatile(p as *const u8) };
764            }
765        }
766        let mut status = vec![-1i32; pages.len()];
767        // SAFETY: query-only move_pages on our own mappings; `nodes` is
768        // NULL so nothing moves, `status` has one slot per page.
769        let rc = unsafe {
770            libc::syscall(
771                libc::SYS_move_pages,
772                0,
773                pages.len() as libc::c_ulong,
774                pages.as_mut_ptr(),
775                std::ptr::null::<libc::c_int>(),
776                status.as_mut_ptr(),
777                0,
778            )
779        };
780        let mut by = Vec::new();
781        if rc == 0 {
782            for &st in &status {
783                if st >= 0 {
784                    let n = st as usize;
785                    if by.len() <= n {
786                        by.resize(n + 1, 0);
787                    }
788                    by[n] += 1;
789                }
790            }
791        } else {
792            by = numa_maps_nodes(regions);
793        }
794        (pages.len(), incore, by)
795    }
796
797    /// Mapped pages per node of every mapping that overlaps `regions`,
798    /// from `/proc/self/maps` (ranges) + `/proc/self/numa_maps` (`N<k>=`).
799    fn numa_maps_nodes(regions: &[&[u8]]) -> Vec<usize> {
800        let (Ok(maps), Ok(nm)) = (
801            std::fs::read_to_string("/proc/self/maps"),
802            std::fs::read_to_string("/proc/self/numa_maps"),
803        ) else {
804            return Vec::new();
805        };
806        let spans: Vec<(usize, usize)> = regions
807            .iter()
808            .map(|r| (r.as_ptr() as usize, r.as_ptr() as usize + r.len()))
809            .collect();
810        let mut starts = std::collections::HashSet::new();
811        for line in maps.lines() {
812            let Some((range, _)) = line.split_once(' ') else {
813                continue;
814            };
815            let Some((a, b)) = range.split_once('-') else {
816                continue;
817            };
818            let (Ok(a), Ok(b)) = (usize::from_str_radix(a, 16), usize::from_str_radix(b, 16)) else {
819                continue;
820            };
821            if spans.iter().any(|&(s, e)| s < b && a < e) {
822                starts.insert(a);
823            }
824        }
825        let mut by = Vec::new();
826        for line in nm.lines() {
827            let mut it = line.split_whitespace();
828            let Some(a) = it.next().and_then(|a| usize::from_str_radix(a, 16).ok()) else {
829                continue;
830            };
831            if !starts.contains(&a) {
832                continue;
833            }
834            for f in it {
835                let Some((k, v)) = f.split_once('=') else {
836                    continue;
837                };
838                let (Some(n), Ok(v)) = (
839                    k.strip_prefix('N').and_then(|n| n.parse::<usize>().ok()),
840                    v.parse::<usize>(),
841                ) else {
842                    continue;
843                };
844                if by.len() <= n {
845                    by.resize(n + 1, 0);
846                }
847                by[n] += v;
848            }
849        }
850        by
851    }
852
853    /// (node, cpu mask) for a pool of `threads` participants, or None.
854    pub(super) fn choose(regions: &[&[u8]], threads: usize) -> Option<(usize, Vec<usize>)> {
855        let env = std::env::var("CMF_NUMA").ok();
856        if matches!(env.as_deref(), Some("0") | Some("off")) {
857            return None;
858        }
859        let trace = std::env::var("CMF_NUMA_TRACE").is_ok_and(|v| v != "0");
860        let nodes = nodes();
861        if nodes.len() < 2 {
862            if trace {
863                eprintln!("numa: {} node(s) visible — nothing to bind", nodes.len());
864            }
865            return None;
866        }
867        // `CMF_NUMA=node:<n>` forces a node (plain "0" means OFF).
868        let forced = env
869            .as_deref()
870            .and_then(|v| v.strip_prefix("node:"))
871            .and_then(|v| v.parse::<usize>().ok());
872        let node = match forced {
873            Some(n) => n,
874            None => {
875                // Auto: only when the weights already sit on ONE node
876                // (≥ 90% of the resident sample, and most of the sample
877                // resident). A file spread over both nodes is better
878                // served by both sockets; a cold file has no home yet.
879                let (sampled, incore, by) = page_nodes(regions);
880                let resident: usize = by.iter().sum();
881                if trace {
882                    eprintln!(
883                        "numa: sampled {sampled} weight pages, {incore} cached, mapped by node {by:?}"
884                    );
885                }
886                if sampled == 0 || incore * 2 < sampled || resident == 0 {
887                    return None;
888                }
889                let (n, &cnt) = by.iter().enumerate().max_by_key(|(_, c)| **c)?;
890                if cnt * 10 < resident * 9 {
891                    return None;
892                }
893                n
894            }
895        };
896        let cpus = &nodes.iter().find(|(id, _)| *id == node)?.1;
897        let allowed = allowed();
898        let usable: Vec<usize> = cpus.iter().copied().filter(|c| allowed.contains(c)).collect();
899        let prim: Vec<usize> = usable.iter().copied().filter(|&c| primary(c)).collect();
900        if prim.len() >= threads {
901            Some((node, prim))
902        } else if usable.len() >= threads {
903            Some((node, usable))
904        } else {
905            None
906        }
907    }
908
909    /// Bind thread `tid` (0 = the calling thread) to `cpus`.
910    pub(super) fn set_affinity(tid: i32, cpus: &[usize]) -> bool {
911        // SAFETY: plain syscall with a zeroed, correctly sized set.
912        unsafe {
913            let mut set: libc::cpu_set_t = std::mem::zeroed();
914            for &c in cpus {
915                if c < libc::CPU_SETSIZE as usize {
916                    libc::CPU_SET(c, &mut set);
917                }
918            }
919            libc::sched_setaffinity(tid, std::mem::size_of::<libc::cpu_set_t>(), &set) == 0
920        }
921    }
922}
923
924/// Per-core capacity: the kernel's `cpu_capacity` (µarch × clock) when
925/// EAS exposes it, else `cpufreq/cpuinfo_max_freq` — same cluster
926/// ordering, so the 62.5% big-core rule keeps working on EAS-less
927/// kernels (TUNING.md open item: pinning silently did nothing there).
928#[cfg(any(
929    target_os = "android",
930    all(target_arch = "aarch64", target_os = "linux")
931))]
932fn core_capacities() -> Vec<u64> {
933    let read_all = |leaf: &str| -> Vec<u64> {
934        let mut vals = Vec::new();
935        for cpu in 0.. {
936            let path = format!("/sys/devices/system/cpu/cpu{cpu}/{leaf}");
937            match std::fs::read_to_string(&path) {
938                Ok(v) => match v.trim().parse() {
939                    Ok(x) => vals.push(x),
940                    Err(_) => break,
941                },
942                Err(_) => break,
943            }
944        }
945        vals
946    };
947    let caps = read_all("cpu_capacity");
948    if caps.len() >= 2 {
949        return caps;
950    }
951    read_all("cpufreq/cpuinfo_max_freq")
952}
953
954#[cfg(target_os = "android")]
955fn pin_thread_to_big_cores() {
956    use std::mem;
957    let caps = core_capacities();
958    let max = caps.iter().copied().max().unwrap_or(0);
959    let min = caps.iter().copied().min().unwrap_or(0);
960
961    // Only pin if heterogeneous
962    if caps.len() < 2 || max == min {
963        return;
964    }
965
966    unsafe {
967        let mut set: libc::cpu_set_t = mem::zeroed();
968        for (i, &c) in caps.iter().enumerate() {
969            if c * 8 >= max * 5 {
970                libc::CPU_SET(i, &mut set);
971            }
972        }
973        libc::sched_setaffinity(0, mem::size_of::<libc::cpu_set_t>(), &set);
974    }
975}
976
977fn worker_loop(inner: &Inner, idx: usize) {
978    #[cfg(target_os = "android")]
979    pin_thread_to_big_cores();
980    // Apple silicon: ask for the performance cores. Threads spawned
981    // without a QoS class land on the efficiency cores when the
982    // scheduler feels like it — a user's video-VAE encode on an M4 sat
983    // on the E-cores at 100% with the P-cores asleep for 140 s (HF
984    // discussion #4). USER_INITIATED is the class an interactive tool's
985    // work belongs to; the ~4 P-cores then take the pool's grains.
986    #[cfg(target_os = "macos")]
987    unsafe {
988        libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INITIATED, 0);
989    }
990
991    let slot = &inner.slots[idx];
992    // Baselines are the construction values (0), never a fresh read: if
993    // the caller hands out a ticket before the OS actually starts this
994    // thread, adopting the live value as "already seen" would skip that
995    // job and deadlock the caller's wait.
996    let mut seen = 0usize;
997    let mut seen_epoch = 0usize;
998    loop {
999        // Wait for a ticket: spin first (decode publishes the next matvec
1000        // within microseconds), park only when idle for real.
1001        let mut spins = 0usize;
1002        loop {
1003            let t = slot.ticket.load(Ordering::Acquire);
1004            if t != seen {
1005                seen = t;
1006                break;
1007            }
1008            if inner.shutdown.load(Ordering::Relaxed) {
1009                return;
1010            }
1011            if !inner.oversubscribed {
1012                // Any job — even one this worker sits out — means the
1013                // pool is busy: stay hot for the next one.
1014                let e = inner.epoch.load(Ordering::Relaxed);
1015                if e != seen_epoch {
1016                    seen_epoch = e;
1017                    spins = 0;
1018                }
1019            }
1020            if spins < inner.spin_budget.load(Ordering::Relaxed) {
1021                spins += 1;
1022                if inner.oversubscribed && spins.is_multiple_of(64) {
1023                    std::thread::yield_now();
1024                } else {
1025                    std::hint::spin_loop();
1026                }
1027            } else {
1028                slot.parked.store(true, Ordering::Relaxed);
1029                // Pairs with the caller's fence between writing tickets
1030                // and reading `parked`: either it sees our flag (and
1031                // unparks) or we see its ticket here — a missed wakeup is
1032                // impossible. Spurious unparks just loop.
1033                std::sync::atomic::fence(Ordering::SeqCst);
1034                if slot.ticket.load(Ordering::Relaxed) == seen
1035                    && !inner.shutdown.load(Ordering::Relaxed)
1036                {
1037                    std::thread::park();
1038                }
1039                slot.parked.store(false, Ordering::Relaxed);
1040            }
1041        }
1042        // The ticket's Acquire made the descriptor visible, and the caller
1043        // cannot rewrite it before our decrement below: it waits for
1044        // `remaining`, which counts this ticket.
1045        let data = inner.desc_data.load(Ordering::Relaxed);
1046        let vtable = inner.desc_vtable.load(Ordering::Relaxed);
1047        let n = inner.desc_n.load(Ordering::Relaxed);
1048        let dev = inner.desc_dev.load(Ordering::Relaxed);
1049        let float_activations = inner.desc_float_activations.load(Ordering::Relaxed);
1050        // SAFETY: the two words are the fat pointer `dispatch` split, and
1051        // the caller of this job is blocked on our decrement below, so the
1052        // closure it borrows is alive for the whole call.
1053        let task: *const (dyn Fn(usize, usize) + Sync + 'static) =
1054            unsafe { std::mem::transmute([data, vtable]) };
1055        let f = unsafe { &*task };
1056        crate::gpu::set_current_device(dev);
1057        let _float_activations =
1058            float_activations.then(crate::qtensor::enter_float_activations_scope);
1059        f(idx, n);
1060        inner.remaining.0.fetch_sub(1, Ordering::AcqRel);
1061    }
1062}
1063
1064/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
1065/// Bit-identical to the serial loop (row order does not change math).
1066pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
1067    let in_dim = x.len();
1068    let out_dim = out.len();
1069    debug_assert!(w.len() >= out_dim * in_dim);
1070
1071    let row_dot = |o: usize| -> f32 {
1072        let row = &w[o * in_dim..(o + 1) * in_dim];
1073        let mut sum = 0.0f32;
1074        for j in 0..in_dim {
1075            sum += row[j] * x[j];
1076        }
1077        sum
1078    };
1079
1080    let out_addr = SendMut(out.as_mut_ptr());
1081    let run_range = move |start: usize, end: usize| {
1082        let mut o = start;
1083        // Four independent reduction chains hide add latency and reuse x.
1084        // Each row still sums j=0..in_dim in exactly the scalar order: no
1085        // horizontal SIMD reduction, FMA, or quantization approximation.
1086        while end - o >= 4 {
1087            let base = o * in_dim;
1088            let w0 = &w[base..base + in_dim];
1089            let w1 = &w[base + in_dim..base + 2 * in_dim];
1090            let w2 = &w[base + 2 * in_dim..base + 3 * in_dim];
1091            let w3 = &w[base + 3 * in_dim..base + 4 * in_dim];
1092            let (mut a, mut b, mut c, mut d) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
1093            for j in 0..in_dim {
1094                let v = x[j];
1095                a += w0[j] * v;
1096                b += w1[j] * v;
1097                c += w2[j] * v;
1098                d += w3[j] * v;
1099            }
1100            // run_rows assigns disjoint ranges within 0..out_dim.
1101            unsafe {
1102                *out_addr.at(o) = a;
1103                *out_addr.at(o + 1) = b;
1104                *out_addr.at(o + 2) = c;
1105                *out_addr.at(o + 3) = d;
1106            }
1107            o += 4;
1108        }
1109        for o in o..end {
1110            unsafe { *out_addr.at(o) = row_dot(o) };
1111        }
1112    };
1113    match pool {
1114        Some(pool) if out_dim >= 256 => pool.run_rows(out_dim, &run_range),
1115        _ => run_range(0, out_dim),
1116    }
1117}
1118
1119/// Two-input row matvec: one pass over the weight rows serves BOTH
1120/// inputs — CPU decode is memory-bound, so the second position costs a
1121/// fraction of the first (this is where MTP speculative verify wins).
1122/// Per-output accumulation order matches the single-input path exactly
1123/// → bit-identical results.
1124pub fn matvec_rows2(
1125    pool: Option<&Pool>,
1126    w: &[f32],
1127    x1: &[f32],
1128    x2: &[f32],
1129    out1: &mut [f32],
1130    out2: &mut [f32],
1131) {
1132    let in_dim = x1.len();
1133    debug_assert_eq!(x2.len(), in_dim);
1134    let out_dim = out1.len();
1135    debug_assert_eq!(out2.len(), out_dim);
1136    debug_assert!(w.len() >= out_dim * in_dim);
1137
1138    let row_dots = |o: usize| -> (f32, f32) {
1139        let row = &w[o * in_dim..(o + 1) * in_dim];
1140        let (mut s1, mut s2) = (0.0f32, 0.0f32);
1141        for j in 0..in_dim {
1142            s1 += row[j] * x1[j];
1143            s2 += row[j] * x2[j];
1144        }
1145        (s1, s2)
1146    };
1147
1148    match pool {
1149        Some(pool) if out_dim >= 256 => {
1150            let o1 = SendMut(out1.as_mut_ptr());
1151            let o2 = SendMut(out2.as_mut_ptr());
1152            let run_range = move |start: usize, end: usize| {
1153                for o in start..end {
1154                    let (s1, s2) = row_dots(o);
1155                    unsafe {
1156                        *o1.at(o) = s1;
1157                        *o2.at(o) = s2;
1158                    }
1159                }
1160            };
1161            pool.run_rows(out_dim, &run_range);
1162        }
1163        _ => {
1164            for o in 0..out_dim {
1165                let (s1, s2) = row_dots(o);
1166                out1[o] = s1;
1167                out2[o] = s2;
1168            }
1169        }
1170    }
1171}
1172
1173/// `SendMut` for any element type — the sampler's sparse chain writes
1174/// per-grain candidate lists.
1175pub(crate) struct SendMutT<T>(*mut T);
1176unsafe impl<T> Send for SendMutT<T> {}
1177unsafe impl<T> Sync for SendMutT<T> {}
1178impl<T> Clone for SendMutT<T> {
1179    fn clone(&self) -> Self {
1180        *self
1181    }
1182}
1183impl<T> Copy for SendMutT<T> {}
1184impl<T> SendMutT<T> {
1185    #[inline]
1186    pub(crate) fn new(p: *mut T) -> Self {
1187        Self(p)
1188    }
1189    /// Same contract as `SendMut::at`: disjoint indices, pointee outlives
1190    /// the joined dispatch.
1191    #[inline]
1192    pub(crate) fn at(self, i: usize) -> *mut T {
1193        unsafe { self.0.add(i) }
1194    }
1195}
1196
1197#[derive(Clone, Copy)]
1198pub(crate) struct SendMut(*mut f32);
1199unsafe impl Send for SendMut {}
1200unsafe impl Sync for SendMut {}
1201
1202impl SendMut {
1203    /// The caller promises the threads it hands this to write disjoint
1204    /// indices, and that the pointee outlives them.
1205    #[inline]
1206    pub(crate) fn new(p: *mut f32) -> Self {
1207        Self(p)
1208    }
1209
1210    /// Method receiver forces the closure to capture the whole (Sync)
1211    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
1212    #[inline]
1213    pub(crate) fn at(self, i: usize) -> *mut f32 {
1214        unsafe { self.0.add(i) }
1215    }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    /// A persistent worker keeps its own TLS between jobs. An exact caller
1221    /// scope must therefore ride with the descriptor, and must not leak into
1222    /// the next unscoped job.
1223    #[test]
1224    fn float_activation_scope_follows_dispatch_and_is_restored() {
1225        let pool = Pool::with_spin(2, 0);
1226        let active = AtomicUsize::new(0);
1227        let inactive = AtomicUsize::new(0);
1228        crate::qtensor::float_activations_scope(|| {
1229            pool.run(&|_w, _n| {
1230                if crate::qtensor::float_activations_active() {
1231                    active.fetch_add(1, Ordering::Relaxed);
1232                } else {
1233                    inactive.fetch_add(1, Ordering::Relaxed);
1234                }
1235            });
1236        });
1237        assert_eq!(active.load(Ordering::Relaxed), 3);
1238        assert_eq!(inactive.load(Ordering::Relaxed), 0);
1239
1240        pool.run(&|_w, _n| {
1241            if crate::qtensor::float_activations_active() {
1242                active.fetch_add(1, Ordering::Relaxed);
1243            } else {
1244                inactive.fetch_add(1, Ordering::Relaxed);
1245            }
1246        });
1247        assert_eq!(active.load(Ordering::Relaxed), 3);
1248        assert_eq!(inactive.load(Ordering::Relaxed), 3);
1249    }
1250
1251    /// A worker NOT invited to a limited job is not waited for; if it is
1252    /// preempted around the moment the job is published, the caller may
1253    /// already be on the next job. The first protocol then ran that next
1254    /// job twice and wrapped `remaining` — the caller spun forever.
1255    /// Oversubscribe the machine with spinning workers, hammer limited
1256    /// dispatches, and count every closure entry: each job must run
1257    /// exactly (limit + 1) times, and the loop must finish (a watchdog
1258    /// turns a hang into a failure). On a 2–4-CPU box the second protocol
1259    /// ran ~100 dispatches/s here (every spinner woke for every job and
1260    /// held the CPUs the invited worker needed) and hit the watchdog at
1261    /// half the jobs — slow, not wrong, but a pool that needs a time
1262    /// slice per dispatch is broken too.
1263    #[test]
1264    fn uninvited_straggler_never_runs_a_job_twice_or_wraps_the_barrier() {
1265        use std::sync::atomic::AtomicUsize;
1266        let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
1267        let workers = (cores * 4).clamp(8, 64);
1268        let pool = Arc::new(Pool::with_spin(workers, 1_000_000));
1269        let entries = Arc::new(AtomicUsize::new(0));
1270        let expected = Arc::new(AtomicUsize::new(0));
1271        let progress = Arc::new(AtomicUsize::new(0));
1272        let done = Arc::new(AtomicBool::new(false));
1273        let iters = 20_000usize;
1274        let p = pool.clone();
1275        let (en, ex, pr, dn) = (
1276            entries.clone(),
1277            expected.clone(),
1278            progress.clone(),
1279            done.clone(),
1280        );
1281        let driver = std::thread::spawn(move || {
1282            for i in 0..iters {
1283                pr.store(i, Ordering::Relaxed);
1284                // Few grains → `run_limited` with limit < workers: most
1285                // workers are uninvited.  Alternate with a full `run`.
1286                let limit = 1 + i % 3;
1287                let f = |_w: usize, _n: usize| {
1288                    en.fetch_add(1, Ordering::Relaxed);
1289                };
1290                p.run_limited(limit, &f);
1291                ex.fetch_add(limit.min(p.n_workers()) + 1, Ordering::Relaxed);
1292                if i % 97 == 0 {
1293                    p.run(&f);
1294                    ex.fetch_add(p.n_workers() + 1, Ordering::Relaxed);
1295                }
1296            }
1297            dn.store(true, Ordering::SeqCst);
1298        });
1299        let t0 = std::time::Instant::now();
1300        while !done.load(Ordering::SeqCst) {
1301            assert!(
1302                t0.elapsed() < std::time::Duration::from_secs(120),
1303                "pool dispatch loop did not finish: at iteration {} of {}, entries {} \
1304                 expected {} (the difference is the job in flight), remaining {}",
1305                progress.load(Ordering::Relaxed),
1306                iters,
1307                entries.load(Ordering::Relaxed),
1308                expected.load(Ordering::Relaxed),
1309                pool.inner.remaining.0.load(Ordering::Relaxed)
1310            );
1311            std::thread::sleep(std::time::Duration::from_millis(20));
1312        }
1313        driver.join().unwrap();
1314        // A late straggler could still be inside its (single) job; one
1315        // more full barrier drains it.
1316        pool.run(&|_w, _n| {});
1317        assert_eq!(
1318            entries.load(Ordering::Relaxed),
1319            expected.load(Ordering::Relaxed),
1320            "some job ran a closure more or fewer times than its participants"
1321        );
1322    }
1323
1324    /// Participation is decided by the ticket alone: a limited job hands
1325    /// one ticket to each of workers `0..limit` and to nobody else, a full
1326    /// `run` one to every worker. Spin 0 also drives the park/unpark path.
1327    #[test]
1328    fn limited_dispatch_hands_tickets_only_to_invited_workers() {
1329        let pool = Pool::with_spin(6, 0);
1330        let hits: Vec<AtomicUsize> = (0..7).map(|_| AtomicUsize::new(0)).collect();
1331        let bad = AtomicUsize::new(0);
1332        // No panics inside the job: a worker that dies never decrements.
1333        let f = |w: usize, n: usize| match hits.get(w) {
1334            Some(h) if w < n && (n == 3 || n == 7) => {
1335                h.fetch_add(1, Ordering::Relaxed);
1336            }
1337            _ => {
1338                bad.fetch_add(1, Ordering::Relaxed);
1339            }
1340        };
1341        let tickets = |p: &Pool| -> Vec<usize> {
1342            p.inner
1343                .slots
1344                .iter()
1345                .map(|s| s.ticket.load(Ordering::Acquire))
1346                .collect()
1347        };
1348        pool.run_limited(2, &f);
1349        assert_eq!(tickets(&pool), [1, 1, 0, 0, 0, 0]);
1350        pool.run(&f);
1351        assert_eq!(tickets(&pool), [2, 2, 1, 1, 1, 1]);
1352        assert_eq!(
1353            bad.load(Ordering::Relaxed),
1354            0,
1355            "participant index or count off"
1356        );
1357        let hits: Vec<usize> = hits.iter().map(|h| h.load(Ordering::Relaxed)).collect();
1358        // Index 2 is the caller of the limited job and worker 2 of the run.
1359        assert_eq!(hits, [2, 2, 2, 1, 1, 1, 1]);
1360    }
1361
1362    #[test]
1363    fn f32_four_row_matvec_matches_scalar_bits_and_preserves_tail() {
1364        let pool = super::Pool::new(3);
1365        for rows in [0, 1, 3, 4, 7, 255, 256, 259, 1024] {
1366            for cols in [0, 1, 3, 32, 65, 384] {
1367                let w: Vec<f32> = (0..rows * cols)
1368                    .map(|i| (i as f32 * 0.173).sin() * [1e-3, 1.0, 1e3][i % 3])
1369                    .collect();
1370                let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.41).cos()).collect();
1371                let want: Vec<u32> = (0..rows)
1372                    .map(|r| {
1373                        let mut sum = 0.0f32;
1374                        for j in 0..cols {
1375                            sum += w[r * cols + j] * x[j];
1376                        }
1377                        sum.to_bits()
1378                    })
1379                    .collect();
1380                for workers in [None, Some(&pool)] {
1381                    let mut out = vec![17.0f32; rows + 5];
1382                    super::matvec_rows(workers, &w, &x, &mut out[..rows]);
1383                    let bits: Vec<u32> = out[..rows].iter().map(|v| v.to_bits()).collect();
1384                    assert_eq!(bits, want, "shape {rows}x{cols}");
1385                    assert_eq!(&out[rows..], &[17.0; 5]);
1386                }
1387                // A short output is an intentional public matvec contract.
1388                let mut prefix = vec![0.0f32; rows / 2];
1389                super::matvec_rows(None, &w, &x, &mut prefix);
1390                assert_eq!(
1391                    prefix.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1392                    want[..rows / 2]
1393                );
1394            }
1395        }
1396    }
1397
1398    #[test]
1399    #[cfg(target_os = "linux")]
1400    fn numa_cpulist_parses_ranges_and_singles() {
1401        assert_eq!(super::numa::parse_list("0-3,8,10-11\n"), vec![0, 1, 2, 3, 8, 10, 11]);
1402        assert_eq!(super::numa::parse_list(""), Vec::<usize>::new());
1403    }
1404
1405    #[test]
1406    #[cfg(any(target_os = "android", target_os = "linux"))]
1407    fn worker_tids_registered_before_new_returns() {
1408        // WORKER_TIDS is only the last completed pool's process-wide
1409        // snapshot; another test can publish a different valid snapshot
1410        // immediately after `new` returns. Check this pool's private
1411        // registration state instead.
1412        use std::collections::HashSet;
1413        let p = super::Pool::new(3);
1414        let local: Vec<_> = p.inner.worker_tids.lock().unwrap().clone();
1415        let registered = p.inner.registered.load(Ordering::Acquire);
1416        let unique: HashSet<_> = local.iter().copied().collect();
1417        assert!(
1418            registered == 3
1419                && local.len() == 3
1420                && unique.len() == 3
1421                && local.iter().all(|&tid| tid > 0),
1422            "all worker tids must be privately registered before new returns \
1423             (registered {registered}, local {}, unique {})",
1424            local.len(),
1425            unique.len()
1426        );
1427    }
1428
1429    #[test]
1430    fn forced_threads_overrides_env_and_topology() {
1431        use std::sync::atomic::Ordering;
1432        super::FORCED_THREADS.store(3, Ordering::Relaxed);
1433        let pool = super::Pool::from_env().expect("forced 3 → pool");
1434        assert_eq!(pool.n_workers(), 3);
1435        super::FORCED_THREADS.store(1, Ordering::Relaxed);
1436        assert!(super::Pool::from_env().is_none(), "forced 1 → serial");
1437        super::FORCED_THREADS.store(0, Ordering::Relaxed);
1438    }
1439
1440    #[test]
1441    #[cfg(any(target_os = "android", target_os = "linux"))]
1442    fn concurrent_pool_constructors_complete_without_registration_race() {
1443        // WORKER_TIDS is a process-wide publication target. Before the
1444        // per-pool counter, a larger constructor could have all its workers
1445        // append, then a concurrent one could clear that vector; the larger
1446        // constructor would wait forever for a length that could never return.
1447        // Start unlike-sized constructors together so that regression is
1448        // exercised without relying on the test harness' scheduling.
1449        use std::sync::{Barrier, mpsc};
1450        use std::time::Duration;
1451
1452        for round in 0..16 {
1453            let start = Arc::new(Barrier::new(3));
1454            let (done_tx, done_rx) = mpsc::channel();
1455            let mut joins = Vec::new();
1456            for workers in [8usize, 1usize] {
1457                let start = start.clone();
1458                let done_tx = done_tx.clone();
1459                joins.push(std::thread::spawn(move || {
1460                    start.wait();
1461                    let pool = Pool::with_spin(workers, 0);
1462                    done_tx.send(pool.n_workers()).unwrap();
1463                }));
1464            }
1465            drop(done_tx);
1466            start.wait();
1467            let mut sizes = Vec::with_capacity(2);
1468            for _ in 0..2 {
1469                sizes.push(
1470                    done_rx
1471                        .recv_timeout(Duration::from_secs(10))
1472                        .unwrap_or_else(|_| panic!("pool constructor stalled in round {round}")),
1473                );
1474            }
1475            sizes.sort_unstable();
1476            assert_eq!(sizes, [1, 8]);
1477            for join in joins {
1478                join.join().unwrap();
1479            }
1480        }
1481    }
1482
1483    #[test]
1484    fn capacity_split_clock_bins_vs_microarch() {
1485        type P = super::Pool;
1486        // JR510: all-A55, two clock bins — use every core.
1487        assert_eq!(
1488            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 768, 768, 768, 768]),
1489            Some(8)
1490        );
1491        // Classic big.LITTLE (A78 + A55) — big only.
1492        assert_eq!(
1493            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 350, 350, 350, 350]),
1494            Some(4)
1495        );
1496        // Three-tier flagship: X + A7xx mids stay, A5xx littles go.
1497        assert_eq!(
1498            P::cores_from_capacities(&[1024, 800, 800, 800, 800, 300, 300, 300]),
1499            Some(5)
1500        );
1501        // Uniform: no signal, caller falls back.
1502        assert_eq!(P::cores_from_capacities(&[1024; 8]), None);
1503        assert_eq!(P::cores_from_capacities(&[]), None);
1504    }
1505
1506    use super::*;
1507
1508    #[test]
1509    fn parallel_matvec_equals_serial_bitexact() {
1510        let (out_dim, in_dim) = (512, 64);
1511        let w: Vec<f32> = (0..out_dim * in_dim)
1512            .map(|i| (i as f32 * 0.013).sin())
1513            .collect();
1514        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
1515
1516        let mut serial = vec![0.0f32; out_dim];
1517        matvec_rows(None, &w, &x, &mut serial);
1518
1519        let pool = Pool::new(4);
1520        let mut parallel = vec![0.0f32; out_dim];
1521        matvec_rows(Some(&pool), &w, &x, &mut parallel);
1522
1523        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
1524    }
1525
1526    #[test]
1527    fn fused_pair_equals_two_singles_bitexact() {
1528        let (out_dim, in_dim) = (300, 48);
1529        let w: Vec<f32> = (0..out_dim * in_dim)
1530            .map(|i| (i as f32 * 0.011).sin())
1531            .collect();
1532        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
1533        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
1534
1535        let mut a1 = vec![0.0f32; out_dim];
1536        let mut a2 = vec![0.0f32; out_dim];
1537        matvec_rows(None, &w, &x1, &mut a1);
1538        matvec_rows(None, &w, &x2, &mut a2);
1539
1540        for pool in [None, Some(Pool::new(3))] {
1541            let mut b1 = vec![0.0f32; out_dim];
1542            let mut b2 = vec![0.0f32; out_dim];
1543            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
1544            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
1545            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
1546        }
1547    }
1548
1549    #[test]
1550    fn pool_survives_many_runs() {
1551        let pool = Pool::new(3);
1552        let counter = AtomicUsize::new(0);
1553        for _ in 0..100 {
1554            pool.run(&|_, _| {
1555                counter.fetch_add(1, Ordering::Relaxed);
1556            });
1557        }
1558        // 3 workers + the participating caller = 4 executions per run.
1559        assert_eq!(counter.load(Ordering::Relaxed), 400);
1560    }
1561
1562    #[test]
1563    fn pool_wakes_after_park() {
1564        // Force immediate parking (no spin) — the epoch/parked handshake
1565        // must still never miss a wakeup.
1566        let pool = Pool::with_spin(2, 0);
1567        let counter = AtomicUsize::new(0);
1568        for _ in 0..50 {
1569            pool.run(&|_, _| {
1570                counter.fetch_add(1, Ordering::Relaxed);
1571            });
1572            // Give workers time to actually park between jobs.
1573            std::thread::sleep(std::time::Duration::from_micros(200));
1574        }
1575        assert_eq!(counter.load(Ordering::Relaxed), 150);
1576    }
1577
1578    #[test]
1579    fn worker_indices_are_distinct_and_cover_range() {
1580        let pool = Pool::new(3);
1581        let hits: Vec<AtomicUsize> = (0..4).map(|_| AtomicUsize::new(0)).collect();
1582        for _ in 0..20 {
1583            pool.run(&|widx, n| {
1584                assert_eq!(n, 4);
1585                hits[widx].fetch_add(1, Ordering::Relaxed);
1586            });
1587        }
1588        for (i, h) in hits.iter().enumerate() {
1589            assert_eq!(h.load(Ordering::Relaxed), 20, "participant {i} missed runs");
1590        }
1591    }
1592}
1593
1594#[cfg(test)]
1595mod grain_tests {
1596    use super::grain_for;
1597
1598    #[test]
1599    fn a_short_job_still_reaches_every_worker() {
1600        // 24 rows, 49 workers: the old flat floor of 32 handed all 24 to the
1601        // first worker and woke the rest for nothing.
1602        assert_eq!(grain_for(24, 49), 1);
1603        // Wide jobs keep the stride the SDOT loop wants.
1604        assert_eq!(grain_for(4096, 49), 32);
1605        assert_eq!(grain_for(32768, 49), 83);
1606        // Degenerate shapes must not divide by zero or return zero.
1607        assert_eq!(grain_for(0, 49), 1);
1608        assert_eq!(grain_for(7, 1), 7);
1609        assert!(grain_for(1, 49) >= 1);
1610    }
1611}