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