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    /// The odd workers run on a second NUMA node (`Pool::bind_two_nodes`).
131    two_node: AtomicBool,
132    /// The CPUs the pool sat on before `bind_two_nodes` (what `unbind_two_nodes` restores).
133    #[cfg(target_os = "linux")]
134    home_mask: Mutex<Vec<usize>>,
135    /// Per-pool registration state. `WORKER_TIDS` is a process-wide
136    /// snapshot for ADPF and cannot be a construction barrier: another
137    /// pool may clear and republish that snapshot concurrently.
138    #[cfg(any(target_os = "android", target_os = "linux"))]
139    registered: AtomicUsize,
140    #[cfg(any(target_os = "android", target_os = "linux"))]
141    worker_tids: Mutex<Vec<i32>>,
142}
143
144/// Process-wide dispatch counter (roadmap §3 P0 «измерения»): one tick
145/// per published job. `bench --json` reports dispatches/token from it.
146static DISPATCHES: AtomicUsize = AtomicUsize::new(0);
147
148/// Total pool jobs published since process start (all pools).
149pub fn dispatch_count() -> usize {
150    DISPATCHES.load(Ordering::Relaxed)
151}
152
153/// Persistent thread pool: shared job slot, epoch dispatch, caller
154/// participation.
155pub struct Pool {
156    inner: Arc<Inner>,
157    /// Thread handles for `unpark` (same order as `parked`).
158    threads: Vec<std::thread::Thread>,
159    joins: Vec<std::thread::JoinHandle<()>>,
160}
161
162fn spin_budget_from_env() -> usize {
163    std::env::var("CMF_POOL_SPIN")
164        .ok()
165        .and_then(|v| v.parse::<usize>().ok())
166        .unwrap_or(4000)
167}
168
169/// Rows per chunk: enough chunks to balance, large enough to keep the SDOT
170/// inner loop and the prefetcher in their stride — and never so coarse that
171/// ONE worker takes the whole job.
172///
173/// That last clause was missing. The floor was a flat 32, so any job with
174/// fewer than 32 rows went entirely to whichever worker grabbed the cursor
175/// first while the other 48 were woken, found nothing, and left. The
176/// hyper-connection projection has 24 rows and is called 86 times a token:
177/// it paid the full price of a fan-out and ran single-threaded.
178pub(crate) fn grain_for(rows: usize, workers: usize) -> usize {
179    if rows == 0 || workers <= 1 {
180        return rows.max(1);
181    }
182    let balanced = (rows / (workers * 8)).max(32);
183    // One chunk per worker at the very least.
184    balanced.min(rows.div_ceil(workers)).max(1)
185}
186
187impl Pool {
188    pub fn new(n_workers: usize) -> Self {
189        Self::with_spin(n_workers, spin_budget_from_env())
190    }
191
192    /// Explicit spin budget (tests pin it without touching the env).
193    pub fn with_spin(n_workers: usize, spin_budget: usize) -> Self {
194        // Affinity- and cgroup-quota-aware on Linux; read once — the NUMA
195        // bind never narrows the mask below the pool's thread count.
196        let cpus = std::thread::available_parallelism()
197            .map(|n| n.get())
198            .unwrap_or(1);
199        let inner = Arc::new(Inner {
200            epoch: AtomicUsize::new(0),
201            desc_data: AtomicUsize::new(0),
202            desc_vtable: AtomicUsize::new(0),
203            desc_n: AtomicUsize::new(0),
204            desc_dev: AtomicUsize::new(0),
205            desc_float_activations: AtomicBool::new(false),
206            remaining: Padded(AtomicUsize::new(0)),
207            slots: (0..n_workers)
208                .map(|_| Slot {
209                    ticket: AtomicUsize::new(0),
210                    parked: AtomicBool::new(false),
211                })
212                .collect(),
213            shutdown: AtomicBool::new(false),
214            spin_budget: AtomicUsize::new(spin_budget),
215            oversubscribed: n_workers + 1 > cpus,
216            two_node: AtomicBool::new(false),
217            #[cfg(target_os = "linux")]
218            home_mask: Mutex::new(Vec::new()),
219            #[cfg(any(target_os = "android", target_os = "linux"))]
220            registered: AtomicUsize::new(0),
221            #[cfg(any(target_os = "android", target_os = "linux"))]
222            worker_tids: Mutex::new(Vec::with_capacity(n_workers)),
223        });
224        let mut joins = Vec::with_capacity(n_workers);
225        for w in 0..n_workers {
226            let inner = inner.clone();
227            let h = std::thread::Builder::new()
228                .name(format!("cmf-pool-{w}"))
229                .spawn(move || {
230                    #[cfg(any(target_os = "android", target_os = "linux"))]
231                    {
232                        let tid = unsafe { libc::gettid() } as i32;
233                        if let Ok(mut tids) = inner.worker_tids.lock() {
234                            tids.push(tid);
235                        }
236                        inner.registered.fetch_add(1, Ordering::Release);
237                    }
238                    worker_loop(&inner, w)
239                })
240                .expect("spawn pool worker");
241            joins.push(h);
242        }
243        // Per-pool registration barrier: `spawn` returns before the closure runs,
244        // and the embedder reads `cortiq_worker_tids` right after load —
245        // on a phone only the first worker had registered by then (the
246        // '· 1 threads' About line that misled the cmfmobile device
247        // investigation twice). Thread start is milliseconds; wait for
248        // every worker has registered before construction returns.
249        #[cfg(any(target_os = "android", target_os = "linux"))]
250        while inner.registered.load(Ordering::Acquire) < n_workers {
251            std::thread::yield_now();
252        }
253        #[cfg(any(target_os = "android", target_os = "linux"))]
254        if let (Ok(mut global), Ok(local)) = (WORKER_TIDS.lock(), inner.worker_tids.lock()) {
255            *global = local.clone();
256        }
257        let threads = joins.iter().map(|h| h.thread().clone()).collect();
258        Self {
259            inner,
260            threads,
261            joins,
262        }
263    }
264
265    /// Big-core count on heterogeneous ARM (big.LITTLE): the kernel
266    /// exposes per-core capacity on Android and most ARM Linux; efficiency
267    /// cores in the pool DRAG the big ones on our row-parallel jobs (the
268    /// same cliff llama.cpp hits at -t 10 on an M4: 163 → 112 tok/s).
269    /// None = capacities absent or homogeneous.
270    #[cfg(all(
271        target_arch = "aarch64",
272        any(target_os = "linux", target_os = "android")
273    ))]
274    fn big_cores() -> Option<usize> {
275        Self::cores_from_capacities(&core_capacities())
276    }
277
278    /// How many cores the pool should use, from the kernel's per-core
279    /// capacity values. Capacity folds µarch × clock into one number,
280    /// and the two need different treatment: cores of ANOTHER µarch
281    /// (A5xx efficiency cluster next to A7xx/X: capacity ratio ≥ ~2)
282    /// drag row-parallel work down and are excluded; cores of the SAME
283    /// µarch merely clock-binned (JLQ JR510: 8×A55 as 4×2.0 + 4×1.5 GHz,
284    /// ratio 1.33) pull their weight and must ALL be used. The 1.6
285    /// threshold splits the two regimes: on a Snapdragon 8-class part
286    /// it keeps X + A7xx mid cores and drops A5xx.
287    #[cfg_attr(
288        not(all(
289            target_arch = "aarch64",
290            any(target_os = "linux", target_os = "android")
291        )),
292        allow(dead_code)
293    )]
294    fn cores_from_capacities(caps: &[u64]) -> Option<usize> {
295        let max = *caps.iter().max()?;
296        let min = *caps.iter().min()?;
297        if caps.len() < 2 || max == min {
298            return None;
299        }
300        Some(caps.iter().filter(|&&c| c * 8 >= max * 5).count())
301    }
302
303    #[cfg(target_os = "macos")]
304    fn big_cores() -> Option<usize> {
305        // Apple silicon: the P-only default measured WORSE than mixing the
306        // efficiency cores in — the grain-pulling dispatch absorbs the
307        // speed skew exactly as designed, and decode is memory-bound
308        // enough that E-cores add real serviceable work (M4, dense 3B:
309        // 4 threads 8.4 tok/s, 6-9 threads 9.6-10.7). Fall through to
310        // available_parallelism - 1; CMF_THREADS still pins by hand.
311        // The sysctl probe stays for introspection tooling.
312        if true {
313            return None;
314        }
315        #[allow(unreachable_code)]
316        unsafe extern "C" {
317            fn sysctlbyname(
318                name: *const std::ffi::c_char,
319                oldp: *mut std::ffi::c_void,
320                oldlenp: *mut usize,
321                newp: *mut std::ffi::c_void,
322                newlen: usize,
323            ) -> std::ffi::c_int;
324        }
325        unsafe {
326            let name = std::ffi::CString::new("hw.perflevel0.physicalcpu").ok()?;
327            let mut count: i32 = 0;
328            let mut size = std::mem::size_of::<i32>();
329            let ret = sysctlbyname(
330                name.as_ptr(),
331                &mut count as *mut i32 as *mut std::ffi::c_void,
332                &mut size,
333                std::ptr::null_mut(),
334                0,
335            );
336            if ret == 0 && count > 0 {
337                Some(count as usize)
338            } else {
339                None
340            }
341        }
342    }
343
344    #[cfg(not(any(
345        all(
346            target_arch = "aarch64",
347            any(target_os = "linux", target_os = "android")
348        ),
349        target_os = "macos"
350    )))]
351    fn big_cores() -> Option<usize> {
352        None
353    }
354
355    /// The thread count `from_env` would use RIGHT NOW: forced (C ABI)
356    /// > CMF_THREADS > big-core topology > available_parallelism−1.
357    /// > ≤1 means the model runs serial (no pool). Introspection
358    /// > (`execution_mode`, status endpoints) must report THIS, not
359    /// > available_parallelism.
360    pub fn effective_threads() -> usize {
361        let forced = FORCED_THREADS.load(std::sync::atomic::Ordering::Relaxed);
362        if forced > 0 {
363            return forced;
364        }
365        match std::env::var("CMF_THREADS") {
366            Ok(v) => v.parse::<usize>().unwrap_or(0),
367            Err(_) => match Self::big_cores() {
368                Some(big) => big,
369                None => {
370                    // The cap was 8, which left big machines idle: on a
371                    // 256-core EPYC, Nanbeige 4.2 decoded at 7.4 tok/s on
372                    // the default 8 threads and 14.8 at 32, with prefill
373                    // 12 -> ~16 over the same move. Past ~32 it falls off
374                    // hard (5.5 at 64, 1.6 at 256) — decode is
375                    // memory-bound and the extra threads only add
376                    // dispatch barriers — so 32 is a ceiling, not a
377                    // target. Machines with 9 cores or fewer are
378                    // unaffected: avail-1 already bounds them.
379                    let avail = std::thread::available_parallelism()
380                        .map(|n| n.get())
381                        .unwrap_or(1);
382                    avail.saturating_sub(1).min(32)
383                }
384            },
385        }
386    }
387
388    /// Pool sized from `CMF_THREADS` (see module docs). `None` = serial.
389    /// Without the env, heterogeneous ARM defaults to its BIG cores.
390    pub fn from_env() -> Option<Arc<Self>> {
391        let n = Self::effective_threads();
392        if n <= 1 {
393            None
394        } else {
395            Some(Arc::new(Self::new(n)))
396        }
397    }
398
399    /// Spawned worker threads (the caller joins each job on top).
400    pub fn n_workers(&self) -> usize {
401        self.threads.len()
402    }
403
404    /// Keep the pool on the NUMA node that holds `regions` (the model's
405    /// weight bytes). Linux with two or more nodes only; `CMF_NUMA=0`
406    /// turns it off, `CMF_NUMA=node:<n>` forces a node.
407    ///
408    /// WHY: decode streams every weight once per token, and on a
409    /// two-socket host the page cache holds a file on whichever node
410    /// read it. Unpinned, the scheduler spreads the workers over both
411    /// sockets and half the matvec rows cross the socket link. Measured
412    /// on a 2×EPYC 7763 pod with the model's pages all on node 0 (31 CPUs
413    /// of cgroup quota): a STREAM-style read over a node-0 buffer gives
414    /// 42 GB/s from 31 unpinned threads and 74 GB/s from 31 threads kept
415    /// on node 0. The mask is the node's physical cores (first SMT
416    /// sibling) when there are enough of them for the pool, else the
417    /// whole node; never narrower than the pool, so nothing oversubscribes.
418    /// Threads are bound to a SET of cores, not to one core each: the
419    /// scheduler still balances inside the node. The calling thread
420    /// adopts the same mask on its next dispatch.
421    pub fn bind_numa(&self, regions: &[&[u8]]) {
422        #[cfg(target_os = "linux")]
423        {
424            let Some((node, cpus)) = numa::choose(regions, self.threads.len() + 1) else {
425                return;
426            };
427            let mut applied = 0usize;
428            if let Ok(tids) = self.inner.worker_tids.lock() {
429                for &tid in tids.iter() {
430                    if numa::set_affinity(tid, &cpus) {
431                        applied += 1;
432                    }
433                }
434            }
435            numa::publish(cpus.clone());
436            numa::adopt_caller();
437            tracing::info!(
438                "numa: pool bound to node {node} ({} cpus, {applied}/{} workers)",
439                cpus.len(),
440                self.threads.len()
441            );
442            if std::env::var("CMF_NUMA_TRACE").is_ok_and(|v| v != "0") {
443                eprintln!(
444                    "numa: pool bound to node {node}: {} cpus, {applied}/{} workers",
445                    cpus.len(),
446                    self.threads.len()
447                );
448            }
449        }
450        #[cfg(not(target_os = "linux"))]
451        let _ = regions;
452    }
453
454    /// The odd workers run on a second NUMA node (`bind_two_nodes`).
455    pub fn two_nodes(&self) -> bool {
456        self.inner.two_node.load(Ordering::Relaxed)
457    }
458
459    /// Worker `w` of a job runs on the far node of a two-node pool (the
460    /// caller, participant `n_workers()`, never does).
461    pub fn far_worker(&self, w: usize) -> bool {
462        w % 2 == 1 && w < self.threads.len() && self.two_nodes()
463    }
464
465    /// Put the odd workers on a second NUMA node, the even ones and the
466    /// caller staying where they are: `(home node, far node, threads at
467    /// home with the caller, threads on the far node)`. Linux with two or
468    /// more nodes; the home node is the one `bind_numa` chose, or `hint`
469    /// where it chose none (a file spread over the nodes), and the node's
470    /// mask is then published for every pool's callers as `bind_numa` does.
471    ///
472    /// WHY: a job that streams weights is bound by the node's memory bus,
473    /// not by its cores. Measured on 2 x EPYC 9554 under a 31-CPU quota:
474    /// 28 threads on one node read 153-180 GB/s (the node gives 220-230),
475    /// 14 + 14 over both nodes with each half's data on its own node read
476    /// 393-421 GB/s. It only pays when the job gives the far workers data
477    /// that lives on their node, so the caller places it there (the far
478    /// workers read it first) and asks `far_worker` for the side of a
479    /// participant; `unbind_two_nodes` takes it back.
480    pub fn bind_two_nodes(&self, hint: Option<usize>) -> Option<(usize, usize, usize, usize)> {
481        #[cfg(target_os = "linux")]
482        {
483            let nw = self.threads.len();
484            let far_n = nw / 2;
485            if far_n < 2 || self.two_nodes() {
486                return None;
487            }
488            let nodes = numa::nodes();
489            if nodes.len() < 2 {
490                return None;
491            }
492            let before = numa::allowed();
493            let mut home_mask = numa::published();
494            let fresh = home_mask.is_empty();
495            let home = if fresh {
496                let home = hint?;
497                home_mask = numa::node_mask(&nodes, home, nw + 1)?;
498                home
499            } else {
500                nodes.iter().find(|(_, cpus)| home_mask.iter().all(|c| cpus.contains(c)))?.0
501            };
502            // the far node: the other node with the most cores, its first SMT siblings when they are enough
503            let mut best: Option<(usize, Vec<usize>)> = None;
504            for (id, cpus) in nodes.iter().filter(|(id, _)| *id != home) {
505                let prim: Vec<usize> = cpus.iter().copied().filter(|&c| numa::primary(c)).collect();
506                let set = if prim.len() >= far_n { prim } else { cpus.clone() };
507                if set.len() >= far_n && best.as_ref().is_none_or(|b| set.len() > b.1.len()) {
508                    best = Some((*id, set));
509                }
510            }
511            let (far, far_cpus) = best?;
512            // each worker moves itself: the tids were registered in spawn order, not in worker order
513            let moved = AtomicUsize::new(0);
514            self.run(&|w, _n| {
515                if w >= nw {
516                    return;
517                }
518                if w % 2 == 0 {
519                    // a pool made before anything was bound has its workers on every node
520                    numa::set_affinity(0, &home_mask);
521                } else if numa::set_affinity(0, &far_cpus) {
522                    let now = numa::allowed();
523                    if !now.is_empty() && now.iter().all(|c| far_cpus.contains(c)) {
524                        moved.fetch_add(1, Ordering::Relaxed);
525                    }
526                }
527            });
528            if moved.load(Ordering::Relaxed) != far_n {
529                let back = if fresh { &before } else { &home_mask };
530                self.run(&|w, _n| {
531                    if w < nw && (fresh || w % 2 == 1) {
532                        numa::set_affinity(0, back);
533                    }
534                });
535                return None;
536            }
537            if fresh {
538                numa::publish(home_mask.clone());
539                numa::adopt_caller();
540            }
541            if let Ok(mut m) = self.inner.home_mask.lock() {
542                *m = home_mask;
543            }
544            self.inner.two_node.store(true, Ordering::Relaxed);
545            tracing::info!("numa: pool on two nodes: {} threads on node {home}, {far_n} on node {far}", nw - far_n + 1);
546            Some((home, far, nw - far_n + 1, far_n))
547        }
548        #[cfg(not(target_os = "linux"))]
549        None
550    }
551
552    /// Tests: mark the pool as on two nodes without moving a thread (a
553    /// kernel then splits its work by side on any host).
554    #[cfg(test)]
555    pub(crate) fn mark_two_nodes(&self, on: bool) {
556        self.inner.two_node.store(on, Ordering::Relaxed);
557    }
558
559    /// Take the odd workers back to the home node (`bind_two_nodes`).
560    pub fn unbind_two_nodes(&self) {
561        if !self.inner.two_node.swap(false, Ordering::Relaxed) {
562            return;
563        }
564        #[cfg(target_os = "linux")]
565        {
566            let nw = self.threads.len();
567            let home = self.inner.home_mask.lock().map(|m| m.clone()).unwrap_or_default();
568            if !home.is_empty() {
569                self.run(&|w, _n| {
570                    if w % 2 == 1 && w < nw {
571                        numa::set_affinity(0, &home);
572                    }
573                });
574            }
575        }
576    }
577
578    /// Keep the pool's workers on the node of the published mask, or on
579    /// `node` where nothing published one (`bind_numa` chose no node: a
580    /// file spread over them); the caller adopts the same mask.
581    pub fn bind_node(&self, node: usize) {
582        #[cfg(target_os = "linux")]
583        {
584            let mut mask = numa::published();
585            if mask.is_empty() {
586                let Some(m) = numa::node_mask(&numa::nodes(), node, self.threads.len() + 1) else {
587                    return;
588                };
589                numa::publish(m.clone());
590                mask = m;
591            }
592            if let Ok(tids) = self.inner.worker_tids.lock() {
593                for &tid in tids.iter() {
594                    numa::set_affinity(tid, &mask);
595                }
596            }
597            numa::adopt_caller();
598        }
599        #[cfg(not(target_os = "linux"))]
600        let _ = node;
601    }
602
603    /// Put the page-cache pages behind `home` on the home node of a
604    /// two-node pool and the ones behind `far` on its far node (`nodes`,
605    /// as `bind_two_nodes` returned them); both are spans of mapped files.
606    /// `None` where that cannot be done or told: no two-node pool, not
607    /// Linux, spans that are no whole pages of a file this process can
608    /// open.
609    ///
610    /// WHY: a file's pages stay on the node whose thread read them first,
611    /// and a container gets no call that moves them (`move_pages` and
612    /// `mbind` are refused under RunPod's seccomp profile). What it does
613    /// get: drop a span from the page cache (`posix_fadvise(DONTNEED)`)
614    /// and read it again from a thread of the wanted node. Measured on
615    /// 2 x EPYC 9554: a 1 GB range that sat 85-88 % on node 0 came back
616    /// 98.6-99.8 % on node 1, read by 14 threads at 22 GB/s.
617    ///
618    /// A side is read again only when under 90 % of a sample of it sits
619    /// on its node (`on_move` hears the bytes first: on a slow disk it
620    /// takes a while), so a second start moves nothing. Of the first
621    /// `fill` spans of each side, the ones the cache does not hold at all
622    /// are read by their node before anything else places them.
623    pub fn place_file_pages(
624        &self,
625        home: &[&[u8]],
626        far: &[&[u8]],
627        nodes: (usize, usize),
628        fill: usize,
629        on_move: &dyn Fn(usize),
630    ) -> Option<NodePlacement> {
631        #[cfg(target_os = "linux")]
632        {
633            if !self.two_nodes() {
634                return None;
635            }
636            let mut files = Vec::new();
637            let spans = [numa::file_spans(home, &mut files)?, numa::file_spans(far, &mut files)?];
638            let node = [nodes.0, nodes.1];
639            let mut out = NodePlacement::default();
640            // what the cache does not hold: its node reads it first
641            let filled = AtomicUsize::new(0);
642            self.run_sides([spans[0].len().min(fill), spans[1].len().min(fill)], &|s, i| {
643                let sp = &spans[s][i];
644                if !numa::cached(sp) {
645                    numa::read_in(sp);
646                    filled.fetch_add(sp.len, Ordering::Relaxed);
647                }
648            });
649            out.filled = filled.load(Ordering::Relaxed);
650            let mut share = [0.0f64; 2];
651            for s in 0..2 {
652                share[s] = self.side_share(&spans[s], s == 1, node[s], 0)?;
653            }
654            // a side on the wrong node: what the cache holds of it is dropped and read again by its node
655            let todo: [Vec<usize>; 2] = [0, 1].map(|s| {
656                if share[s] >= 0.9 {
657                    return Vec::new();
658                }
659                (0..spans[s].len()).filter(|&i| numa::cached(&spans[s][i])).collect()
660            });
661            out.moved = (0..2).map(|s| todo[s].iter().map(|&i| spans[s][i].len).sum::<usize>()).sum();
662            if out.moved > 0 {
663                on_move(out.moved);
664                for s in 0..2 {
665                    for &i in &todo[s] {
666                        numa::drop_cached(&spans[s][i]);
667                    }
668                }
669                self.run_sides([todo[0].len(), todo[1].len()], &|s, i| numa::read_in(&spans[s][todo[s][i]]));
670                for s in 0..2 {
671                    if !todo[s].is_empty() {
672                        share[s] = self.side_share(&spans[s], s == 1, node[s], 1)?;
673                    }
674                }
675            }
676            (out.home, out.far) = (share[0], share[1]);
677            Some(out)
678        }
679        #[cfg(not(target_os = "linux"))]
680        {
681            let _ = (home, far, nodes, fill, on_move);
682            None
683        }
684    }
685
686    /// `f(side, i)` for every `i < n[side]`, each side (0 home, 1 far) on
687    /// its own threads of a two-node pool.
688    #[cfg(target_os = "linux")]
689    fn run_sides(&self, n: [usize; 2], f: &(dyn Fn(usize, usize) + Sync)) {
690        let cur = [AtomicUsize::new(0), AtomicUsize::new(0)];
691        self.run(&|w, _n| {
692            let s = usize::from(self.far_worker(w));
693            loop {
694                let i = cur[s].fetch_add(1, Ordering::Relaxed);
695                if i >= n[s] {
696                    break;
697                }
698                f(s, i);
699            }
700        });
701    }
702
703    /// Share of a sample of a side's spans on `node`, the sample read by
704    /// the side's own threads (a page the cache does not hold goes to the
705    /// node of its reader).
706    #[cfg(target_os = "linux")]
707    fn side_share(&self, spans: &[numa::Span], far: bool, node: usize, salt: usize) -> Option<f64> {
708        let side = usize::from(far);
709        let by = numa::sample_nodes(spans, salt, &|maps| {
710            let mut n = [0usize; 2];
711            n[side] = maps.len();
712            self.run_sides(n, &|_s, i| numa::touch(maps[i].0, maps[i].1));
713        });
714        let total: usize = by.iter().sum();
715        (total > 0).then(|| by.get(node).copied().unwrap_or(0) as f64 / total as f64)
716    }
717
718    /// Retune an already-created pool for an architecture with a measured
719    /// dispatch cadence. The environment remains the operator override; this
720    /// hook only changes the automatic default after model geometry is known.
721    pub(crate) fn set_spin_budget(&self, spins: usize) {
722        self.inner.spin_budget.store(spins, Ordering::Relaxed);
723    }
724
725    /// One job: write the descriptor, hand a ticket to workers `0..nw`,
726    /// run the caller's share as participant `nw` of `nw + 1`, and return
727    /// once every ticket holder has finished. Only one job is ever in
728    /// flight (this drains `remaining` before it returns), so the caller
729    /// is the single writer of the descriptor, `remaining` and every
730    /// `ticket`; workers without a ticket never touch any of them.
731    fn dispatch(&self, f: &(dyn Fn(usize, usize) + Sync), nw: usize) {
732        let _tot = crate::cpuprof::time(crate::cpuprof::Slot::DispTotal);
733        let mut _setup = crate::cpuprof::time(crate::cpuprof::Slot::DispSetup);
734        let inner = &*self.inner;
735        let n = nw + 1;
736        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
737        // SAFETY: a fat pointer is exactly two words on every supported
738        // target; the halves are only ever reassembled by `worker_loop`.
739        let raw: [usize; 2] = unsafe { std::mem::transmute(ptr) };
740        inner.desc_data.store(raw[0], Ordering::Relaxed);
741        inner.desc_vtable.store(raw[1], Ordering::Relaxed);
742        inner.desc_n.store(n, Ordering::Relaxed);
743        inner
744            .desc_dev
745            .store(crate::gpu::current_device(), Ordering::Relaxed);
746        inner.desc_float_activations.store(
747            crate::qtensor::float_activations_active(),
748            Ordering::Relaxed,
749        );
750        inner.remaining.0.store(nw, Ordering::Relaxed);
751        let e = inner.epoch.load(Ordering::Relaxed);
752        inner.epoch.store(e.wrapping_add(1), Ordering::Relaxed);
753        // Release: a worker that sees its new ticket (Acquire) sees the
754        // descriptor and `remaining` written above.
755        let slots = &inner.slots[..nw];
756        for slot in slots {
757            let t = slot.ticket.load(Ordering::Relaxed).wrapping_add(1);
758            slot.ticket.store(t, Ordering::Release);
759        }
760        // Pairs with the fence a worker issues between raising `parked`
761        // and re-reading its ticket: either that worker sees the ticket
762        // or we see its flag and unpark it — no lost wakeup. One fence
763        // for all the tickets instead of a SeqCst store per worker.
764        std::sync::atomic::fence(Ordering::SeqCst);
765        for (slot, t) in slots.iter().zip(&self.threads) {
766            if slot.parked.load(Ordering::Relaxed) {
767                t.unpark();
768            }
769        }
770        drop(_setup);
771
772        // The caller's share — the barrier costs nothing while there is
773        // real work to do.
774        let _work = crate::cpuprof::time(crate::cpuprof::Slot::DispWork);
775        f(nw, n);
776        drop(_work);
777
778        // Wait for the stragglers (bounded by one worker's chunk). When the
779        // pool has more threads than CPUs, a ticket holder may be
780        // waiting for THIS core: yield it early instead of spinning.
781        let _wait = crate::cpuprof::time(crate::cpuprof::Slot::DispWait);
782        let spin_limit = if inner.oversubscribed { 64 } else { 10_000 };
783        let mut spins = 0usize;
784        while inner.remaining.0.load(Ordering::Acquire) != 0 {
785            spins += 1;
786            if spins < spin_limit {
787                std::hint::spin_loop();
788            } else {
789                std::thread::yield_now();
790            }
791        }
792        drop(_wait);
793        drop(_tot);
794    }
795
796    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
797    ///
798    /// One dispatch, but workers pull row-ranges from a shared cursor
799    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
800    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
801    /// every matvec end at the SLOWEST core's pace while the fast ones
802    /// idle at the barrier; pulling by grain lets a P-core take several
803    /// chunks for each one an E-core takes, so skew collapses to a
804    /// single grain. Row ranges stay disjoint and each row's dot is
805    /// computed exactly as in the serial path → bit-identical output.
806    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
807        let grain = grain_for(rows, self.threads.len() + 1);
808        let chunks = rows.div_ceil(grain.max(1));
809        let next = AtomicUsize::new(0);
810        self.run_limited(chunks, &|_w, _n| loop {
811            let start = next.fetch_add(grain, Ordering::Relaxed);
812            if start >= rows {
813                break;
814            }
815            f(start, (start + grain).min(rows));
816        });
817    }
818
819    /// `run`, with at most `max_workers` workers PARTICIPATING. Same
820    /// grain, same row split, bit-identical results — only the number of
821    /// threads handed the job changes: a job with eight grains has no use
822    /// for three hundred workers, the unpark syscalls and the
823    /// remaining-drain would BE the job (measured: 361 pool dispatches
824    /// per DeepSeek-V4 token, and CMF_THREADS=64 vs 380 was 1.3 vs 2.4
825    /// tok/s with no other change). Only cursor-style closures (which
826    /// ignore their (idx, n) arguments) come through here: the caller
827    /// identifies itself as the capped count, which is NOT `n_workers()`.
828    fn run_limited(&self, max_workers: usize, f: &(dyn Fn(usize, usize) + Sync)) {
829        let nw = self.threads.len().min(max_workers);
830        if nw == self.threads.len() {
831            return self.run(f);
832        }
833        #[cfg(target_os = "linux")]
834        numa::adopt_caller();
835        DISPATCHES.fetch_add(1, Ordering::Relaxed);
836        // Workers `nw..` get no ticket: they neither run nor wait.
837        self.dispatch(f, nw);
838    }
839
840    /// Multi-matrix job: one dispatch serves SEVERAL row spaces
841    /// (roadmap §3 P0 — «одна внешняя публикация job на слой»). Parts
842    /// are laid out back-to-back in a virtual row space and pulled by
843    /// grain from one shared cursor, so QKV or gate+up cost a single
844    /// barrier instead of one each. Each part's `f(start, end)` sees its
845    /// OWN row indices — per-row math and outputs are bit-identical to
846    /// separate `run_rows` calls.
847    pub fn run_many(&self, parts: &[(usize, &(dyn Fn(usize, usize) + Sync))]) {
848        let total: usize = parts.iter().map(|p| p.0).sum();
849        if total == 0 {
850            return;
851        }
852        let grain = grain_for(total, self.threads.len() + 1);
853        let chunks = total.div_ceil(grain.max(1));
854        let next = AtomicUsize::new(0);
855        self.run_limited(chunks, &|_w, _n| loop {
856            let s = next.fetch_add(grain, Ordering::Relaxed);
857            if s >= total {
858                break;
859            }
860            let e = (s + grain).min(total);
861            let mut base = 0usize;
862            for &(rows, f) in parts {
863                let a = s.max(base);
864                let b = e.min(base + rows);
865                if a < b {
866                    f(a - base, b - base);
867                }
868                base += rows;
869                if base >= e {
870                    break;
871                }
872            }
873        });
874    }
875
876    /// Run `f(worker_idx, n_participants)` on every worker AND the
877    /// calling thread (`worker_idx = n_workers()` for the caller);
878    /// returns when all participants have finished.
879    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
880        #[cfg(target_os = "linux")]
881        numa::adopt_caller();
882        DISPATCHES.fetch_add(1, Ordering::Relaxed);
883        self.dispatch(f, self.threads.len());
884    }
885}
886
887impl Drop for Pool {
888    fn drop(&mut self) {
889        self.inner.shutdown.store(true, Ordering::SeqCst);
890        for t in &self.threads {
891            t.unpark();
892        }
893        for h in self.joins.drain(..) {
894            let _ = h.join();
895        }
896    }
897}
898
899/// Where two sets of file spans sit after `Pool::place_file_pages`.
900#[derive(Clone, Copy, Debug, Default)]
901pub struct NodePlacement {
902    /// Share of a sample of the home spans on the home node.
903    pub home: f64,
904    /// Share of a sample of the far spans on the far node.
905    pub far: f64,
906    /// Bytes dropped from the page cache and read again by their node.
907    pub moved: usize,
908    /// Bytes the page cache did not hold, read by their node.
909    pub filled: usize,
910}
911
912/// The NUMA node that holds most of the page-cache pages behind `regions`
913/// (spans of mapped files), from a sample of them; `None` where the host
914/// does not tell.
915pub fn file_pages_node(regions: &[&[u8]]) -> Option<usize> {
916    #[cfg(target_os = "linux")]
917    {
918        let mut files = Vec::new();
919        let spans = numa::file_spans(regions, &mut files)?;
920        let by = numa::sample_nodes(&spans, 0, &|maps| {
921            for &(a, l) in maps {
922                numa::touch(a, l);
923            }
924        });
925        let (node, &pages) = by.iter().enumerate().max_by_key(|(_, c)| **c)?;
926        (pages > 0).then_some(node)
927    }
928    #[cfg(not(target_os = "linux"))]
929    {
930        let _ = regions;
931        None
932    }
933}
934
935/// NUMA placement for the pool (see `Pool::bind_numa`).
936#[cfg(target_os = "linux")]
937mod numa {
938    use std::sync::Mutex;
939    use std::sync::atomic::{AtomicUsize, Ordering};
940
941    /// The published mask; `EPOCH` bumps on every publish so a calling
942    /// thread re-adopts at most once per bind.
943    static MASK: Mutex<Vec<usize>> = Mutex::new(Vec::new());
944    static EPOCH: AtomicUsize = AtomicUsize::new(0);
945    thread_local! {
946        static SEEN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
947    }
948
949    pub(super) fn publish(cpus: Vec<usize>) {
950        if let Ok(mut m) = MASK.lock() {
951            *m = cpus;
952        }
953        EPOCH.fetch_add(1, Ordering::Release);
954    }
955
956    /// One relaxed load + one TLS read per dispatch when nothing changed.
957    #[inline]
958    pub(super) fn adopt_caller() {
959        let e = EPOCH.load(Ordering::Acquire);
960        if e == 0 || SEEN.with(|c| c.get()) == e {
961            return;
962        }
963        SEEN.with(|c| c.set(e));
964        if let Ok(m) = MASK.lock() {
965            if !m.is_empty() {
966                set_affinity(0, &m);
967            }
968        }
969    }
970
971    /// The mask `bind_numa` published (empty when it bound nothing).
972    pub(super) fn published() -> Vec<usize> {
973        MASK.lock().map(|m| m.clone()).unwrap_or_default()
974    }
975
976    pub(super) fn parse_list(s: &str) -> Vec<usize> {
977        let mut out = Vec::new();
978        for part in s.trim().split(',') {
979            let part = part.trim();
980            if part.is_empty() {
981                continue;
982            }
983            match part.split_once('-') {
984                Some((a, b)) => {
985                    if let (Ok(a), Ok(b)) = (a.parse::<usize>(), b.parse::<usize>()) {
986                        out.extend(a..=b);
987                    }
988                }
989                None => {
990                    if let Ok(a) = part.parse() {
991                        out.push(a);
992                    }
993                }
994            }
995        }
996        out
997    }
998
999    pub(super) fn nodes() -> Vec<(usize, Vec<usize>)> {
1000        let mut v = Vec::new();
1001        let Ok(rd) = std::fs::read_dir("/sys/devices/system/node") else {
1002            return v;
1003        };
1004        for e in rd.flatten() {
1005            let name = e.file_name().to_string_lossy().to_string();
1006            let Some(id) = name.strip_prefix("node").and_then(|x| x.parse::<usize>().ok()) else {
1007                continue;
1008            };
1009            if let Ok(l) = std::fs::read_to_string(e.path().join("cpulist")) {
1010                let cpus = parse_list(&l);
1011                if !cpus.is_empty() {
1012                    v.push((id, cpus));
1013                }
1014            }
1015        }
1016        v.sort();
1017        v
1018    }
1019
1020    pub(super) fn allowed() -> Vec<usize> {
1021        // SAFETY: plain syscall into a zeroed, correctly sized set.
1022        unsafe {
1023            let mut set: libc::cpu_set_t = std::mem::zeroed();
1024            if libc::sched_getaffinity(0, std::mem::size_of::<libc::cpu_set_t>(), &mut set) != 0 {
1025                return Vec::new();
1026            }
1027            (0..libc::CPU_SETSIZE as usize)
1028                .filter(|&c| libc::CPU_ISSET(c, &set))
1029                .collect()
1030        }
1031    }
1032
1033    /// First SMT sibling of its core (or no topology info: count it).
1034    pub(super) fn primary(cpu: usize) -> bool {
1035        let p = format!("/sys/devices/system/cpu/cpu{cpu}/topology/thread_siblings_list");
1036        match std::fs::read_to_string(p) {
1037            Ok(l) => parse_list(&l).first().is_none_or(|&f| f == cpu),
1038            Err(_) => true,
1039        }
1040    }
1041
1042    /// Where the weights live: (pages sampled, sampled pages in the page
1043    /// cache, mapped pages per node). The sample is ≤ 4096 pages spread
1044    /// over `regions`; every sampled page that is already cached is mapped
1045    /// here with one read (a minor fault — `mincore` says it is cached, so
1046    /// no disk I/O), because both node queries below only see pages mapped
1047    /// into THIS process. Per-node counts come from `move_pages` in query
1048    /// mode, or — where a container's seccomp profile refuses that syscall
1049    /// (EPERM on the RunPod image) — from `/proc/self/numa_maps` for the
1050    /// mappings that hold the regions.
1051    fn page_nodes(regions: &[&[u8]]) -> (usize, usize, Vec<usize>) {
1052        const PAGE: usize = 4096;
1053        let total: usize = regions.iter().map(|r| r.len() / PAGE).sum();
1054        if total == 0 {
1055            return (0, 0, Vec::new());
1056        }
1057        let stride = total.div_ceil(4096).max(1);
1058        let mut pages: Vec<*mut libc::c_void> = Vec::new();
1059        for r in regions {
1060            let base = (r.as_ptr() as usize).div_ceil(PAGE) * PAGE;
1061            let end = r.as_ptr() as usize + r.len();
1062            let mut a = base;
1063            while a + PAGE <= end {
1064                pages.push(a as *mut libc::c_void);
1065                a += PAGE * stride;
1066            }
1067        }
1068        let mut incore = 0usize;
1069        for &p in &pages {
1070            let mut vec = 0u8;
1071            // SAFETY: `p` is a page-aligned address inside a live mapping.
1072            let cached = unsafe { libc::mincore(p, PAGE, &mut vec) } == 0 && vec & 1 == 1;
1073            if cached {
1074                incore += 1;
1075                // SAFETY: readable mapped byte; volatile so it is not elided.
1076                unsafe { std::ptr::read_volatile(p as *const u8) };
1077            }
1078        }
1079        let mut status = vec![-1i32; pages.len()];
1080        // SAFETY: query-only move_pages on our own mappings; `nodes` is
1081        // NULL so nothing moves, `status` has one slot per page.
1082        let rc = unsafe {
1083            libc::syscall(
1084                libc::SYS_move_pages,
1085                0,
1086                pages.len() as libc::c_ulong,
1087                pages.as_mut_ptr(),
1088                std::ptr::null::<libc::c_int>(),
1089                status.as_mut_ptr(),
1090                0,
1091            )
1092        };
1093        let mut by = Vec::new();
1094        if rc == 0 {
1095            for &st in &status {
1096                if st >= 0 {
1097                    let n = st as usize;
1098                    if by.len() <= n {
1099                        by.resize(n + 1, 0);
1100                    }
1101                    by[n] += 1;
1102                }
1103            }
1104        } else {
1105            by = numa_maps_nodes(regions);
1106        }
1107        (pages.len(), incore, by)
1108    }
1109
1110    /// Mapped pages per node of every mapping that overlaps `regions`,
1111    /// from `/proc/self/maps` (ranges) + `/proc/self/numa_maps` (`N<k>=`).
1112    pub(super) fn numa_maps_nodes(regions: &[&[u8]]) -> Vec<usize> {
1113        let (Ok(maps), Ok(nm)) = (
1114            std::fs::read_to_string("/proc/self/maps"),
1115            std::fs::read_to_string("/proc/self/numa_maps"),
1116        ) else {
1117            return Vec::new();
1118        };
1119        let spans: Vec<(usize, usize)> = regions
1120            .iter()
1121            .map(|r| (r.as_ptr() as usize, r.as_ptr() as usize + r.len()))
1122            .collect();
1123        let mut starts = std::collections::HashSet::new();
1124        for line in maps.lines() {
1125            let Some((range, _)) = line.split_once(' ') else {
1126                continue;
1127            };
1128            let Some((a, b)) = range.split_once('-') else {
1129                continue;
1130            };
1131            let (Ok(a), Ok(b)) = (usize::from_str_radix(a, 16), usize::from_str_radix(b, 16)) else {
1132                continue;
1133            };
1134            if spans.iter().any(|&(s, e)| s < b && a < e) {
1135                starts.insert(a);
1136            }
1137        }
1138        let mut by = Vec::new();
1139        for line in nm.lines() {
1140            let mut it = line.split_whitespace();
1141            let Some(a) = it.next().and_then(|a| usize::from_str_radix(a, 16).ok()) else {
1142                continue;
1143            };
1144            if !starts.contains(&a) {
1145                continue;
1146            }
1147            for f in it {
1148                let Some((k, v)) = f.split_once('=') else {
1149                    continue;
1150                };
1151                let (Some(n), Ok(v)) = (
1152                    k.strip_prefix('N').and_then(|n| n.parse::<usize>().ok()),
1153                    v.parse::<usize>(),
1154                ) else {
1155                    continue;
1156                };
1157                if by.len() <= n {
1158                    by.resize(n + 1, 0);
1159                }
1160                by[n] += v;
1161            }
1162        }
1163        by
1164    }
1165
1166    const PAGE: usize = 4096;
1167
1168    /// Whole pages of a mapped file: where they are mapped, the file's
1169    /// descriptor, where they are in the file.
1170    pub(super) struct Span {
1171        pub addr: usize,
1172        pub len: usize,
1173        fd: i32,
1174        off: i64,
1175    }
1176
1177    /// The whole pages inside each region with the file behind them, from
1178    /// `/proc/self/maps`; a region that is not inside one file mapping is
1179    /// left out. `None` when nothing is left or a file does not read back
1180    /// through its path as it is mapped. `files` keeps the descriptors.
1181    pub(super) fn file_spans(regions: &[&[u8]], files: &mut Vec<(String, std::fs::File)>) -> Option<Vec<Span>> {
1182        use std::os::fd::AsRawFd;
1183        use std::os::unix::fs::FileExt;
1184        let maps = std::fs::read_to_string("/proc/self/maps").ok()?;
1185        // the file mappings (start, end, offset in the file, path), in address order as the kernel lists them
1186        let mut vmas: Vec<(usize, usize, u64, &str)> = Vec::new();
1187        for line in maps.lines() {
1188            let mut it = line.splitn(6, ' ');
1189            let (Some(range), Some(_), Some(off), Some(_), Some(_), Some(path)) =
1190                (it.next(), it.next(), it.next(), it.next(), it.next(), it.next())
1191            else {
1192                continue;
1193            };
1194            let path = path.trim();
1195            let Some((a, b)) = range.split_once('-') else {
1196                continue;
1197            };
1198            let (Ok(a), Ok(b), Ok(off)) = (usize::from_str_radix(a, 16), usize::from_str_radix(b, 16), u64::from_str_radix(off, 16))
1199            else {
1200                continue;
1201            };
1202            if path.starts_with('/') {
1203                vmas.push((a, b, off, path));
1204            }
1205        }
1206        let mut out = Vec::with_capacity(regions.len());
1207        for r in regions {
1208            let a = (r.as_ptr() as usize).div_ceil(PAGE) * PAGE;
1209            let e = (r.as_ptr() as usize + r.len()) / PAGE * PAGE;
1210            if e <= a {
1211                continue;
1212            }
1213            let Some(v) = vmas.get(vmas.partition_point(|v| v.1 <= a)).filter(|v| v.0 <= a && e <= v.1) else {
1214                continue;
1215            };
1216            let off = v.2 + (a - v.0) as u64;
1217            let fi = match files.iter().position(|f| f.0 == v.3) {
1218                Some(fi) => fi,
1219                None => {
1220                    let f = std::fs::File::open(v.3).ok()?;
1221                    // the same bytes through the descriptor as through the mapping, or the path names another file now
1222                    let mut page = [0u8; PAGE];
1223                    f.read_exact_at(&mut page, off).ok()?;
1224                    // SAFETY: a whole page inside the caller's live region.
1225                    if page[..] != unsafe { std::slice::from_raw_parts(a as *const u8, PAGE) }[..] {
1226                        return None;
1227                    }
1228                    files.push((v.3.to_string(), f));
1229                    files.len() - 1
1230                }
1231            };
1232            out.push(Span { addr: a, len: e - a, fd: files[fi].1.as_raw_fd(), off: off as i64 });
1233        }
1234        (!out.is_empty()).then_some(out)
1235    }
1236
1237    /// Every page of the span is in the page cache.
1238    pub(super) fn cached(s: &Span) -> bool {
1239        let mut v = vec![0u8; s.len / PAGE];
1240        // SAFETY: whole pages of a live mapping, one byte of `v` a page.
1241        unsafe { libc::mincore(s.addr as *mut libc::c_void, s.len, v.as_mut_ptr()) == 0 && v.iter().all(|b| b & 1 == 1) }
1242    }
1243
1244    /// Out of this process's page table and out of the page cache (pages
1245    /// another process maps or pins stay).
1246    pub(super) fn drop_cached(s: &Span) {
1247        // SAFETY: a read-only file mapping: the pages come back from the file on the next read.
1248        unsafe {
1249            libc::madvise(s.addr as *mut libc::c_void, s.len, libc::MADV_DONTNEED);
1250            libc::posix_fadvise(s.fd, s.off as libc::off_t, s.len as libc::off_t, libc::POSIX_FADV_DONTNEED);
1251        }
1252    }
1253
1254    /// The calling thread reads the span: what the cache does not hold
1255    /// lands on its node.
1256    pub(super) fn read_in(s: &Span) {
1257        // SAFETY: an advice on whole pages of a live mapping.
1258        unsafe { libc::madvise(s.addr as *mut libc::c_void, s.len, libc::MADV_WILLNEED) };
1259        touch(s.addr, s.len);
1260    }
1261
1262    /// One byte of every page.
1263    pub(super) fn touch(addr: usize, len: usize) {
1264        let mut a = addr;
1265        while a < addr + len {
1266            // SAFETY: a readable mapped byte; volatile so it is not elided.
1267            unsafe { std::ptr::read_volatile(a as *const u8) };
1268            a += PAGE;
1269        }
1270    }
1271
1272    /// Pages per node of a sample of `spans` (up to 192 of them, `salt`
1273    /// shifts which). The sample is mapped a second time, since the node
1274    /// counts of `/proc/self/numa_maps` are per mapping and cover only
1275    /// the pages this process has read through it; `touch_all` reads them
1276    /// (address, bytes of each).
1277    pub(super) fn sample_nodes(spans: &[Span], salt: usize, touch_all: &dyn Fn(&[(usize, usize)])) -> Vec<usize> {
1278        let step = spans.len().div_ceil(192).max(1);
1279        let mut maps: Vec<(usize, usize)> = Vec::new();
1280        for s in spans.iter().skip(salt * (step / 2) % step).step_by(step) {
1281            // SAFETY: a new read-only mapping of an open file, unmapped below.
1282            let p = unsafe { libc::mmap(std::ptr::null_mut(), s.len, libc::PROT_READ, libc::MAP_SHARED, s.fd, s.off as libc::off_t) };
1283            if p != libc::MAP_FAILED {
1284                maps.push((p as usize, s.len));
1285            }
1286        }
1287        touch_all(&maps);
1288        // SAFETY: the mappings made above, live until the unmap below.
1289        let views: Vec<&[u8]> = maps.iter().map(|&(a, l)| unsafe { std::slice::from_raw_parts(a as *const u8, l) }).collect();
1290        let by = numa_maps_nodes(&views);
1291        drop(views);
1292        for &(a, l) in &maps {
1293            // SAFETY: a mapping made above that nothing refers to any more.
1294            unsafe { libc::munmap(a as *mut libc::c_void, l) };
1295        }
1296        by
1297    }
1298
1299    /// The cores of `node` this process may use for `threads`
1300    /// participants: its first SMT siblings when they are enough, else
1301    /// all of them, else None.
1302    pub(super) fn node_mask(nodes: &[(usize, Vec<usize>)], node: usize, threads: usize) -> Option<Vec<usize>> {
1303        let cpus = &nodes.iter().find(|(id, _)| *id == node)?.1;
1304        let allowed = allowed();
1305        let usable: Vec<usize> = cpus.iter().copied().filter(|c| allowed.contains(c)).collect();
1306        let prim: Vec<usize> = usable.iter().copied().filter(|&c| primary(c)).collect();
1307        if prim.len() >= threads {
1308            Some(prim)
1309        } else if usable.len() >= threads {
1310            Some(usable)
1311        } else {
1312            None
1313        }
1314    }
1315
1316    /// (node, cpu mask) for a pool of `threads` participants, or None.
1317    pub(super) fn choose(regions: &[&[u8]], threads: usize) -> Option<(usize, Vec<usize>)> {
1318        let env = std::env::var("CMF_NUMA").ok();
1319        if matches!(env.as_deref(), Some("0") | Some("off")) {
1320            return None;
1321        }
1322        let trace = std::env::var("CMF_NUMA_TRACE").is_ok_and(|v| v != "0");
1323        let nodes = nodes();
1324        if nodes.len() < 2 {
1325            if trace {
1326                eprintln!("numa: {} node(s) visible — nothing to bind", nodes.len());
1327            }
1328            return None;
1329        }
1330        // `CMF_NUMA=node:<n>` forces a node (plain "0" means OFF).
1331        let forced = env
1332            .as_deref()
1333            .and_then(|v| v.strip_prefix("node:"))
1334            .and_then(|v| v.parse::<usize>().ok());
1335        let node = match forced {
1336            Some(n) => n,
1337            None => {
1338                // Auto: only when the weights already sit on ONE node
1339                // (≥ 90% of the resident sample, and most of the sample
1340                // resident). A file spread over both nodes is better
1341                // served by both sockets; a cold file has no home yet.
1342                let (sampled, incore, by) = page_nodes(regions);
1343                let resident: usize = by.iter().sum();
1344                if trace {
1345                    eprintln!(
1346                        "numa: sampled {sampled} weight pages, {incore} cached, mapped by node {by:?}"
1347                    );
1348                }
1349                if sampled == 0 || incore * 2 < sampled || resident == 0 {
1350                    return None;
1351                }
1352                let (n, &cnt) = by.iter().enumerate().max_by_key(|(_, c)| **c)?;
1353                if cnt * 10 < resident * 9 {
1354                    return None;
1355                }
1356                n
1357            }
1358        };
1359        Some((node, node_mask(&nodes, node, threads)?))
1360    }
1361
1362    /// Bind thread `tid` (0 = the calling thread) to `cpus`.
1363    pub(super) fn set_affinity(tid: i32, cpus: &[usize]) -> bool {
1364        // SAFETY: plain syscall with a zeroed, correctly sized set.
1365        unsafe {
1366            let mut set: libc::cpu_set_t = std::mem::zeroed();
1367            for &c in cpus {
1368                if c < libc::CPU_SETSIZE as usize {
1369                    libc::CPU_SET(c, &mut set);
1370                }
1371            }
1372            libc::sched_setaffinity(tid, std::mem::size_of::<libc::cpu_set_t>(), &set) == 0
1373        }
1374    }
1375}
1376
1377/// Per-core capacity: the kernel's `cpu_capacity` (µarch × clock) when
1378/// EAS exposes it, else `cpufreq/cpuinfo_max_freq` — same cluster
1379/// ordering, so the 62.5% big-core rule keeps working on EAS-less
1380/// kernels (TUNING.md open item: pinning silently did nothing there).
1381#[cfg(any(
1382    target_os = "android",
1383    all(target_arch = "aarch64", target_os = "linux")
1384))]
1385fn core_capacities() -> Vec<u64> {
1386    let read_all = |leaf: &str| -> Vec<u64> {
1387        let mut vals = Vec::new();
1388        for cpu in 0.. {
1389            let path = format!("/sys/devices/system/cpu/cpu{cpu}/{leaf}");
1390            match std::fs::read_to_string(&path) {
1391                Ok(v) => match v.trim().parse() {
1392                    Ok(x) => vals.push(x),
1393                    Err(_) => break,
1394                },
1395                Err(_) => break,
1396            }
1397        }
1398        vals
1399    };
1400    let caps = read_all("cpu_capacity");
1401    if caps.len() >= 2 {
1402        return caps;
1403    }
1404    read_all("cpufreq/cpuinfo_max_freq")
1405}
1406
1407#[cfg(target_os = "android")]
1408fn pin_thread_to_big_cores() {
1409    use std::mem;
1410    let caps = core_capacities();
1411    let max = caps.iter().copied().max().unwrap_or(0);
1412    let min = caps.iter().copied().min().unwrap_or(0);
1413
1414    // Only pin if heterogeneous
1415    if caps.len() < 2 || max == min {
1416        return;
1417    }
1418
1419    unsafe {
1420        let mut set: libc::cpu_set_t = mem::zeroed();
1421        for (i, &c) in caps.iter().enumerate() {
1422            if c * 8 >= max * 5 {
1423                libc::CPU_SET(i, &mut set);
1424            }
1425        }
1426        libc::sched_setaffinity(0, mem::size_of::<libc::cpu_set_t>(), &set);
1427    }
1428}
1429
1430fn worker_loop(inner: &Inner, idx: usize) {
1431    #[cfg(target_os = "android")]
1432    pin_thread_to_big_cores();
1433    // Apple silicon: ask for the performance cores. Threads spawned
1434    // without a QoS class land on the efficiency cores when the
1435    // scheduler feels like it — a user's video-VAE encode on an M4 sat
1436    // on the E-cores at 100% with the P-cores asleep for 140 s (HF
1437    // discussion #4). USER_INITIATED is the class an interactive tool's
1438    // work belongs to; the ~4 P-cores then take the pool's grains.
1439    #[cfg(target_os = "macos")]
1440    unsafe {
1441        libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INITIATED, 0);
1442    }
1443
1444    let slot = &inner.slots[idx];
1445    // Baselines are the construction values (0), never a fresh read: if
1446    // the caller hands out a ticket before the OS actually starts this
1447    // thread, adopting the live value as "already seen" would skip that
1448    // job and deadlock the caller's wait.
1449    let mut seen = 0usize;
1450    let mut seen_epoch = 0usize;
1451    loop {
1452        // Wait for a ticket: spin first (decode publishes the next matvec
1453        // within microseconds), park only when idle for real.
1454        let mut spins = 0usize;
1455        loop {
1456            let t = slot.ticket.load(Ordering::Acquire);
1457            if t != seen {
1458                seen = t;
1459                break;
1460            }
1461            if inner.shutdown.load(Ordering::Relaxed) {
1462                return;
1463            }
1464            if !inner.oversubscribed {
1465                // Any job — even one this worker sits out — means the
1466                // pool is busy: stay hot for the next one.
1467                let e = inner.epoch.load(Ordering::Relaxed);
1468                if e != seen_epoch {
1469                    seen_epoch = e;
1470                    spins = 0;
1471                }
1472            }
1473            if spins < inner.spin_budget.load(Ordering::Relaxed) {
1474                spins += 1;
1475                if inner.oversubscribed && spins.is_multiple_of(64) {
1476                    std::thread::yield_now();
1477                } else {
1478                    std::hint::spin_loop();
1479                }
1480            } else {
1481                slot.parked.store(true, Ordering::Relaxed);
1482                // Pairs with the caller's fence between writing tickets
1483                // and reading `parked`: either it sees our flag (and
1484                // unparks) or we see its ticket here — a missed wakeup is
1485                // impossible. Spurious unparks just loop.
1486                std::sync::atomic::fence(Ordering::SeqCst);
1487                if slot.ticket.load(Ordering::Relaxed) == seen
1488                    && !inner.shutdown.load(Ordering::Relaxed)
1489                {
1490                    std::thread::park();
1491                }
1492                slot.parked.store(false, Ordering::Relaxed);
1493            }
1494        }
1495        // The ticket's Acquire made the descriptor visible, and the caller
1496        // cannot rewrite it before our decrement below: it waits for
1497        // `remaining`, which counts this ticket.
1498        let data = inner.desc_data.load(Ordering::Relaxed);
1499        let vtable = inner.desc_vtable.load(Ordering::Relaxed);
1500        let n = inner.desc_n.load(Ordering::Relaxed);
1501        let dev = inner.desc_dev.load(Ordering::Relaxed);
1502        let float_activations = inner.desc_float_activations.load(Ordering::Relaxed);
1503        // SAFETY: the two words are the fat pointer `dispatch` split, and
1504        // the caller of this job is blocked on our decrement below, so the
1505        // closure it borrows is alive for the whole call.
1506        let task: *const (dyn Fn(usize, usize) + Sync + 'static) =
1507            unsafe { std::mem::transmute([data, vtable]) };
1508        let f = unsafe { &*task };
1509        crate::gpu::set_current_device(dev);
1510        let _float_activations =
1511            float_activations.then(crate::qtensor::enter_float_activations_scope);
1512        f(idx, n);
1513        inner.remaining.0.fetch_sub(1, Ordering::AcqRel);
1514    }
1515}
1516
1517/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
1518/// Bit-identical to the serial loop (row order does not change math).
1519pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
1520    let in_dim = x.len();
1521    let out_dim = out.len();
1522    debug_assert!(w.len() >= out_dim * in_dim);
1523
1524    let row_dot = |o: usize| -> f32 {
1525        let row = &w[o * in_dim..(o + 1) * in_dim];
1526        let mut sum = 0.0f32;
1527        for j in 0..in_dim {
1528            sum += row[j] * x[j];
1529        }
1530        sum
1531    };
1532
1533    let out_addr = SendMut(out.as_mut_ptr());
1534    let run_range = move |start: usize, end: usize| {
1535        let mut o = start;
1536        // Four independent reduction chains hide add latency and reuse x.
1537        // Each row still sums j=0..in_dim in exactly the scalar order: no
1538        // horizontal SIMD reduction, FMA, or quantization approximation.
1539        while end - o >= 4 {
1540            let base = o * in_dim;
1541            let w0 = &w[base..base + in_dim];
1542            let w1 = &w[base + in_dim..base + 2 * in_dim];
1543            let w2 = &w[base + 2 * in_dim..base + 3 * in_dim];
1544            let w3 = &w[base + 3 * in_dim..base + 4 * in_dim];
1545            let (mut a, mut b, mut c, mut d) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
1546            for j in 0..in_dim {
1547                let v = x[j];
1548                a += w0[j] * v;
1549                b += w1[j] * v;
1550                c += w2[j] * v;
1551                d += w3[j] * v;
1552            }
1553            // run_rows assigns disjoint ranges within 0..out_dim.
1554            unsafe {
1555                *out_addr.at(o) = a;
1556                *out_addr.at(o + 1) = b;
1557                *out_addr.at(o + 2) = c;
1558                *out_addr.at(o + 3) = d;
1559            }
1560            o += 4;
1561        }
1562        for o in o..end {
1563            unsafe { *out_addr.at(o) = row_dot(o) };
1564        }
1565    };
1566    match pool {
1567        Some(pool) if out_dim >= 256 => pool.run_rows(out_dim, &run_range),
1568        _ => run_range(0, out_dim),
1569    }
1570}
1571
1572/// Two-input row matvec: one pass over the weight rows serves BOTH
1573/// inputs — CPU decode is memory-bound, so the second position costs a
1574/// fraction of the first (this is where MTP speculative verify wins).
1575/// Per-output accumulation order matches the single-input path exactly
1576/// → bit-identical results.
1577pub fn matvec_rows2(
1578    pool: Option<&Pool>,
1579    w: &[f32],
1580    x1: &[f32],
1581    x2: &[f32],
1582    out1: &mut [f32],
1583    out2: &mut [f32],
1584) {
1585    let in_dim = x1.len();
1586    debug_assert_eq!(x2.len(), in_dim);
1587    let out_dim = out1.len();
1588    debug_assert_eq!(out2.len(), out_dim);
1589    debug_assert!(w.len() >= out_dim * in_dim);
1590
1591    let row_dots = |o: usize| -> (f32, f32) {
1592        let row = &w[o * in_dim..(o + 1) * in_dim];
1593        let (mut s1, mut s2) = (0.0f32, 0.0f32);
1594        for j in 0..in_dim {
1595            s1 += row[j] * x1[j];
1596            s2 += row[j] * x2[j];
1597        }
1598        (s1, s2)
1599    };
1600
1601    match pool {
1602        Some(pool) if out_dim >= 256 => {
1603            let o1 = SendMut(out1.as_mut_ptr());
1604            let o2 = SendMut(out2.as_mut_ptr());
1605            let run_range = move |start: usize, end: usize| {
1606                for o in start..end {
1607                    let (s1, s2) = row_dots(o);
1608                    unsafe {
1609                        *o1.at(o) = s1;
1610                        *o2.at(o) = s2;
1611                    }
1612                }
1613            };
1614            pool.run_rows(out_dim, &run_range);
1615        }
1616        _ => {
1617            for o in 0..out_dim {
1618                let (s1, s2) = row_dots(o);
1619                out1[o] = s1;
1620                out2[o] = s2;
1621            }
1622        }
1623    }
1624}
1625
1626/// `SendMut` for any element type — the sampler's sparse chain writes
1627/// per-grain candidate lists.
1628pub(crate) struct SendMutT<T>(*mut T);
1629unsafe impl<T> Send for SendMutT<T> {}
1630unsafe impl<T> Sync for SendMutT<T> {}
1631impl<T> Clone for SendMutT<T> {
1632    fn clone(&self) -> Self {
1633        *self
1634    }
1635}
1636impl<T> Copy for SendMutT<T> {}
1637impl<T> SendMutT<T> {
1638    #[inline]
1639    pub(crate) fn new(p: *mut T) -> Self {
1640        Self(p)
1641    }
1642    /// Same contract as `SendMut::at`: disjoint indices, pointee outlives
1643    /// the joined dispatch.
1644    #[inline]
1645    pub(crate) fn at(self, i: usize) -> *mut T {
1646        unsafe { self.0.add(i) }
1647    }
1648}
1649
1650#[derive(Clone, Copy)]
1651pub(crate) struct SendMut(*mut f32);
1652unsafe impl Send for SendMut {}
1653unsafe impl Sync for SendMut {}
1654
1655impl SendMut {
1656    /// The caller promises the threads it hands this to write disjoint
1657    /// indices, and that the pointee outlives them.
1658    #[inline]
1659    pub(crate) fn new(p: *mut f32) -> Self {
1660        Self(p)
1661    }
1662
1663    /// Method receiver forces the closure to capture the whole (Sync)
1664    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
1665    #[inline]
1666    pub(crate) fn at(self, i: usize) -> *mut f32 {
1667        unsafe { self.0.add(i) }
1668    }
1669}
1670
1671#[cfg(test)]
1672mod tests {
1673    /// A persistent worker keeps its own TLS between jobs. An exact caller
1674    /// scope must therefore ride with the descriptor, and must not leak into
1675    /// the next unscoped job.
1676    #[test]
1677    fn float_activation_scope_follows_dispatch_and_is_restored() {
1678        let pool = Pool::with_spin(2, 0);
1679        let active = AtomicUsize::new(0);
1680        let inactive = AtomicUsize::new(0);
1681        crate::qtensor::float_activations_scope(|| {
1682            pool.run(&|_w, _n| {
1683                if crate::qtensor::float_activations_active() {
1684                    active.fetch_add(1, Ordering::Relaxed);
1685                } else {
1686                    inactive.fetch_add(1, Ordering::Relaxed);
1687                }
1688            });
1689        });
1690        assert_eq!(active.load(Ordering::Relaxed), 3);
1691        assert_eq!(inactive.load(Ordering::Relaxed), 0);
1692
1693        pool.run(&|_w, _n| {
1694            if crate::qtensor::float_activations_active() {
1695                active.fetch_add(1, Ordering::Relaxed);
1696            } else {
1697                inactive.fetch_add(1, Ordering::Relaxed);
1698            }
1699        });
1700        assert_eq!(active.load(Ordering::Relaxed), 3);
1701        assert_eq!(inactive.load(Ordering::Relaxed), 3);
1702    }
1703
1704    /// A worker NOT invited to a limited job is not waited for; if it is
1705    /// preempted around the moment the job is published, the caller may
1706    /// already be on the next job. The first protocol then ran that next
1707    /// job twice and wrapped `remaining` — the caller spun forever.
1708    /// Oversubscribe the machine with spinning workers, hammer limited
1709    /// dispatches, and count every closure entry: each job must run
1710    /// exactly (limit + 1) times, and the loop must finish (a watchdog
1711    /// turns a hang into a failure). On a 2–4-CPU box the second protocol
1712    /// ran ~100 dispatches/s here (every spinner woke for every job and
1713    /// held the CPUs the invited worker needed) and hit the watchdog at
1714    /// half the jobs — slow, not wrong, but a pool that needs a time
1715    /// slice per dispatch is broken too.
1716    #[test]
1717    fn uninvited_straggler_never_runs_a_job_twice_or_wraps_the_barrier() {
1718        use std::sync::atomic::AtomicUsize;
1719        let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
1720        let workers = (cores * 4).clamp(8, 64);
1721        let pool = Arc::new(Pool::with_spin(workers, 1_000_000));
1722        let entries = Arc::new(AtomicUsize::new(0));
1723        let expected = Arc::new(AtomicUsize::new(0));
1724        let progress = Arc::new(AtomicUsize::new(0));
1725        let done = Arc::new(AtomicBool::new(false));
1726        let iters = 20_000usize;
1727        let p = pool.clone();
1728        let (en, ex, pr, dn) = (
1729            entries.clone(),
1730            expected.clone(),
1731            progress.clone(),
1732            done.clone(),
1733        );
1734        let driver = std::thread::spawn(move || {
1735            for i in 0..iters {
1736                pr.store(i, Ordering::Relaxed);
1737                // Few grains → `run_limited` with limit < workers: most
1738                // workers are uninvited.  Alternate with a full `run`.
1739                let limit = 1 + i % 3;
1740                let f = |_w: usize, _n: usize| {
1741                    en.fetch_add(1, Ordering::Relaxed);
1742                };
1743                p.run_limited(limit, &f);
1744                ex.fetch_add(limit.min(p.n_workers()) + 1, Ordering::Relaxed);
1745                if i % 97 == 0 {
1746                    p.run(&f);
1747                    ex.fetch_add(p.n_workers() + 1, Ordering::Relaxed);
1748                }
1749            }
1750            dn.store(true, Ordering::SeqCst);
1751        });
1752        let t0 = std::time::Instant::now();
1753        while !done.load(Ordering::SeqCst) {
1754            assert!(
1755                t0.elapsed() < std::time::Duration::from_secs(120),
1756                "pool dispatch loop did not finish: at iteration {} of {}, entries {} \
1757                 expected {} (the difference is the job in flight), remaining {}",
1758                progress.load(Ordering::Relaxed),
1759                iters,
1760                entries.load(Ordering::Relaxed),
1761                expected.load(Ordering::Relaxed),
1762                pool.inner.remaining.0.load(Ordering::Relaxed)
1763            );
1764            std::thread::sleep(std::time::Duration::from_millis(20));
1765        }
1766        driver.join().unwrap();
1767        // A late straggler could still be inside its (single) job; one
1768        // more full barrier drains it.
1769        pool.run(&|_w, _n| {});
1770        assert_eq!(
1771            entries.load(Ordering::Relaxed),
1772            expected.load(Ordering::Relaxed),
1773            "some job ran a closure more or fewer times than its participants"
1774        );
1775    }
1776
1777    /// Participation is decided by the ticket alone: a limited job hands
1778    /// one ticket to each of workers `0..limit` and to nobody else, a full
1779    /// `run` one to every worker. Spin 0 also drives the park/unpark path.
1780    #[test]
1781    fn limited_dispatch_hands_tickets_only_to_invited_workers() {
1782        let pool = Pool::with_spin(6, 0);
1783        let hits: Vec<AtomicUsize> = (0..7).map(|_| AtomicUsize::new(0)).collect();
1784        let bad = AtomicUsize::new(0);
1785        // No panics inside the job: a worker that dies never decrements.
1786        let f = |w: usize, n: usize| match hits.get(w) {
1787            Some(h) if w < n && (n == 3 || n == 7) => {
1788                h.fetch_add(1, Ordering::Relaxed);
1789            }
1790            _ => {
1791                bad.fetch_add(1, Ordering::Relaxed);
1792            }
1793        };
1794        let tickets = |p: &Pool| -> Vec<usize> {
1795            p.inner
1796                .slots
1797                .iter()
1798                .map(|s| s.ticket.load(Ordering::Acquire))
1799                .collect()
1800        };
1801        pool.run_limited(2, &f);
1802        assert_eq!(tickets(&pool), [1, 1, 0, 0, 0, 0]);
1803        pool.run(&f);
1804        assert_eq!(tickets(&pool), [2, 2, 1, 1, 1, 1]);
1805        assert_eq!(
1806            bad.load(Ordering::Relaxed),
1807            0,
1808            "participant index or count off"
1809        );
1810        let hits: Vec<usize> = hits.iter().map(|h| h.load(Ordering::Relaxed)).collect();
1811        // Index 2 is the caller of the limited job and worker 2 of the run.
1812        assert_eq!(hits, [2, 2, 2, 1, 1, 1, 1]);
1813    }
1814
1815    #[test]
1816    fn f32_four_row_matvec_matches_scalar_bits_and_preserves_tail() {
1817        let pool = super::Pool::new(3);
1818        for rows in [0, 1, 3, 4, 7, 255, 256, 259, 1024] {
1819            for cols in [0, 1, 3, 32, 65, 384] {
1820                let w: Vec<f32> = (0..rows * cols)
1821                    .map(|i| (i as f32 * 0.173).sin() * [1e-3, 1.0, 1e3][i % 3])
1822                    .collect();
1823                let x: Vec<f32> = (0..cols).map(|i| (i as f32 * 0.41).cos()).collect();
1824                let want: Vec<u32> = (0..rows)
1825                    .map(|r| {
1826                        let mut sum = 0.0f32;
1827                        for j in 0..cols {
1828                            sum += w[r * cols + j] * x[j];
1829                        }
1830                        sum.to_bits()
1831                    })
1832                    .collect();
1833                for workers in [None, Some(&pool)] {
1834                    let mut out = vec![17.0f32; rows + 5];
1835                    super::matvec_rows(workers, &w, &x, &mut out[..rows]);
1836                    let bits: Vec<u32> = out[..rows].iter().map(|v| v.to_bits()).collect();
1837                    assert_eq!(bits, want, "shape {rows}x{cols}");
1838                    assert_eq!(&out[rows..], &[17.0; 5]);
1839                }
1840                // A short output is an intentional public matvec contract.
1841                let mut prefix = vec![0.0f32; rows / 2];
1842                super::matvec_rows(None, &w, &x, &mut prefix);
1843                assert_eq!(
1844                    prefix.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
1845                    want[..rows / 2]
1846                );
1847            }
1848        }
1849    }
1850
1851    #[test]
1852    #[cfg(target_os = "linux")]
1853    fn numa_cpulist_parses_ranges_and_singles() {
1854        assert_eq!(super::numa::parse_list("0-3,8,10-11\n"), vec![0, 1, 2, 3, 8, 10, 11]);
1855        assert_eq!(super::numa::parse_list(""), Vec::<usize>::new());
1856    }
1857
1858    #[test]
1859    #[cfg(any(target_os = "android", target_os = "linux"))]
1860    fn worker_tids_registered_before_new_returns() {
1861        // WORKER_TIDS is only the last completed pool's process-wide
1862        // snapshot; another test can publish a different valid snapshot
1863        // immediately after `new` returns. Check this pool's private
1864        // registration state instead.
1865        use std::collections::HashSet;
1866        let p = super::Pool::new(3);
1867        let local: Vec<_> = p.inner.worker_tids.lock().unwrap().clone();
1868        let registered = p.inner.registered.load(Ordering::Acquire);
1869        let unique: HashSet<_> = local.iter().copied().collect();
1870        assert!(
1871            registered == 3
1872                && local.len() == 3
1873                && unique.len() == 3
1874                && local.iter().all(|&tid| tid > 0),
1875            "all worker tids must be privately registered before new returns \
1876             (registered {registered}, local {}, unique {})",
1877            local.len(),
1878            unique.len()
1879        );
1880    }
1881
1882    #[test]
1883    fn forced_threads_overrides_env_and_topology() {
1884        use std::sync::atomic::Ordering;
1885        super::FORCED_THREADS.store(3, Ordering::Relaxed);
1886        let pool = super::Pool::from_env().expect("forced 3 → pool");
1887        assert_eq!(pool.n_workers(), 3);
1888        super::FORCED_THREADS.store(1, Ordering::Relaxed);
1889        assert!(super::Pool::from_env().is_none(), "forced 1 → serial");
1890        super::FORCED_THREADS.store(0, Ordering::Relaxed);
1891    }
1892
1893    #[test]
1894    #[cfg(any(target_os = "android", target_os = "linux"))]
1895    fn concurrent_pool_constructors_complete_without_registration_race() {
1896        // WORKER_TIDS is a process-wide publication target. Before the
1897        // per-pool counter, a larger constructor could have all its workers
1898        // append, then a concurrent one could clear that vector; the larger
1899        // constructor would wait forever for a length that could never return.
1900        // Start unlike-sized constructors together so that regression is
1901        // exercised without relying on the test harness' scheduling.
1902        use std::sync::{Barrier, mpsc};
1903        use std::time::Duration;
1904
1905        for round in 0..16 {
1906            let start = Arc::new(Barrier::new(3));
1907            let (done_tx, done_rx) = mpsc::channel();
1908            let mut joins = Vec::new();
1909            for workers in [8usize, 1usize] {
1910                let start = start.clone();
1911                let done_tx = done_tx.clone();
1912                joins.push(std::thread::spawn(move || {
1913                    start.wait();
1914                    let pool = Pool::with_spin(workers, 0);
1915                    done_tx.send(pool.n_workers()).unwrap();
1916                }));
1917            }
1918            drop(done_tx);
1919            start.wait();
1920            let mut sizes = Vec::with_capacity(2);
1921            for _ in 0..2 {
1922                sizes.push(
1923                    done_rx
1924                        .recv_timeout(Duration::from_secs(10))
1925                        .unwrap_or_else(|_| panic!("pool constructor stalled in round {round}")),
1926                );
1927            }
1928            sizes.sort_unstable();
1929            assert_eq!(sizes, [1, 8]);
1930            for join in joins {
1931                join.join().unwrap();
1932            }
1933        }
1934    }
1935
1936    #[test]
1937    fn capacity_split_clock_bins_vs_microarch() {
1938        type P = super::Pool;
1939        // JR510: all-A55, two clock bins — use every core.
1940        assert_eq!(
1941            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 768, 768, 768, 768]),
1942            Some(8)
1943        );
1944        // Classic big.LITTLE (A78 + A55) — big only.
1945        assert_eq!(
1946            P::cores_from_capacities(&[1024, 1024, 1024, 1024, 350, 350, 350, 350]),
1947            Some(4)
1948        );
1949        // Three-tier flagship: X + A7xx mids stay, A5xx littles go.
1950        assert_eq!(
1951            P::cores_from_capacities(&[1024, 800, 800, 800, 800, 300, 300, 300]),
1952            Some(5)
1953        );
1954        // Uniform: no signal, caller falls back.
1955        assert_eq!(P::cores_from_capacities(&[1024; 8]), None);
1956        assert_eq!(P::cores_from_capacities(&[]), None);
1957    }
1958
1959    use super::*;
1960
1961    #[test]
1962    fn parallel_matvec_equals_serial_bitexact() {
1963        let (out_dim, in_dim) = (512, 64);
1964        let w: Vec<f32> = (0..out_dim * in_dim)
1965            .map(|i| (i as f32 * 0.013).sin())
1966            .collect();
1967        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
1968
1969        let mut serial = vec![0.0f32; out_dim];
1970        matvec_rows(None, &w, &x, &mut serial);
1971
1972        let pool = Pool::new(4);
1973        let mut parallel = vec![0.0f32; out_dim];
1974        matvec_rows(Some(&pool), &w, &x, &mut parallel);
1975
1976        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
1977    }
1978
1979    #[test]
1980    fn fused_pair_equals_two_singles_bitexact() {
1981        let (out_dim, in_dim) = (300, 48);
1982        let w: Vec<f32> = (0..out_dim * in_dim)
1983            .map(|i| (i as f32 * 0.011).sin())
1984            .collect();
1985        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
1986        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
1987
1988        let mut a1 = vec![0.0f32; out_dim];
1989        let mut a2 = vec![0.0f32; out_dim];
1990        matvec_rows(None, &w, &x1, &mut a1);
1991        matvec_rows(None, &w, &x2, &mut a2);
1992
1993        for pool in [None, Some(Pool::new(3))] {
1994            let mut b1 = vec![0.0f32; out_dim];
1995            let mut b2 = vec![0.0f32; out_dim];
1996            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
1997            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
1998            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
1999        }
2000    }
2001
2002    #[test]
2003    fn pool_survives_many_runs() {
2004        let pool = Pool::new(3);
2005        let counter = AtomicUsize::new(0);
2006        for _ in 0..100 {
2007            pool.run(&|_, _| {
2008                counter.fetch_add(1, Ordering::Relaxed);
2009            });
2010        }
2011        // 3 workers + the participating caller = 4 executions per run.
2012        assert_eq!(counter.load(Ordering::Relaxed), 400);
2013    }
2014
2015    #[test]
2016    fn pool_wakes_after_park() {
2017        // Force immediate parking (no spin) — the epoch/parked handshake
2018        // must still never miss a wakeup.
2019        let pool = Pool::with_spin(2, 0);
2020        let counter = AtomicUsize::new(0);
2021        for _ in 0..50 {
2022            pool.run(&|_, _| {
2023                counter.fetch_add(1, Ordering::Relaxed);
2024            });
2025            // Give workers time to actually park between jobs.
2026            std::thread::sleep(std::time::Duration::from_micros(200));
2027        }
2028        assert_eq!(counter.load(Ordering::Relaxed), 150);
2029    }
2030
2031    #[test]
2032    fn worker_indices_are_distinct_and_cover_range() {
2033        let pool = Pool::new(3);
2034        let hits: Vec<AtomicUsize> = (0..4).map(|_| AtomicUsize::new(0)).collect();
2035        for _ in 0..20 {
2036            pool.run(&|widx, n| {
2037                assert_eq!(n, 4);
2038                hits[widx].fetch_add(1, Ordering::Relaxed);
2039            });
2040        }
2041        for (i, h) in hits.iter().enumerate() {
2042            assert_eq!(h.load(Ordering::Relaxed), 20, "participant {i} missed runs");
2043        }
2044    }
2045}
2046
2047#[cfg(test)]
2048mod grain_tests {
2049    use super::grain_for;
2050
2051    #[test]
2052    fn a_short_job_still_reaches_every_worker() {
2053        // 24 rows, 49 workers: the old flat floor of 32 handed all 24 to the
2054        // first worker and woke the rest for nothing.
2055        assert_eq!(grain_for(24, 49), 1);
2056        // Wide jobs keep the stride the SDOT loop wants.
2057        assert_eq!(grain_for(4096, 49), 32);
2058        assert_eq!(grain_for(32768, 49), 83);
2059        // Degenerate shapes must not divide by zero or return zero.
2060        assert_eq!(grain_for(0, 49), 1);
2061        assert_eq!(grain_for(7, 1), 7);
2062        assert!(grain_for(1, 49) >= 1);
2063    }
2064}