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