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