Skip to main content

cortiq_engine/
pool.rs

1//! Persistent worker pool for row-parallel matvecs.
2//!
3//! Threads are spawned once and spin-then-park between calls — vmfcore
4//! measured spawn-per-matvec at ~+27% decode cost versus a persistent
5//! pool. Parallelism is by disjoint row ranges, so results are
6//! bit-identical to the serial path (each row's dot product is computed
7//! the same way).
8//!
9//! Dispatch is 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 0 (park immediately): on Apple Silicon unpark is cheap and
17//! measured A/B showed spinning workers stealing cycles from the
18//! caller's serial sections (q8 decode 299–345 tok/s at spin=6000 vs
19//! 406–412 at spin=0 on the 50M bench model). Set CMF_POOL_SPIN>0 to
20//! experiment on platforms with slower futex wakeups.
21//!
22//! `CMF_THREADS` env: 0/1 = serial, N = worker count
23//! (default: available_parallelism − 1, capped at 8).
24
25use std::cell::UnsafeCell;
26use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
27use std::sync::Arc;
28
29/// A `*const dyn Fn` that may cross a thread boundary. Safety is
30/// provided by `Pool::run`: the caller blocks until every worker has
31/// finished, so the borrow outlives all uses.
32#[derive(Clone, Copy)]
33struct TaskPtr(*const (dyn Fn(usize, usize) + Sync));
34unsafe impl Send for TaskPtr {}
35
36struct Inner {
37    /// Bumped once per published job; workers watch it.
38    epoch: AtomicUsize,
39    /// Workers still running the current job (excludes the caller).
40    remaining: AtomicUsize,
41    /// The published job: closure pointer + total participant count.
42    /// Written by the caller BEFORE the epoch bump, read by workers
43    /// AFTER they observe the new epoch (acquire/release pairing).
44    slot: UnsafeCell<Option<(TaskPtr, usize)>>,
45    shutdown: AtomicBool,
46    /// Spin iterations before a worker parks (0 = park immediately).
47    spin_budget: usize,
48    /// Per-worker "I am parked" flags — lets the caller skip the unpark
49    /// syscall for workers that are still spinning.
50    parked: Box<[AtomicBool]>,
51}
52
53// SAFETY: `slot` is only written while no job is in flight (run()
54// returns after `remaining` hits 0) and only read after the epoch
55// publication that follows the write.
56unsafe impl Sync for Inner {}
57
58/// Process-wide dispatch counter (roadmap §3 P0 «измерения»): one tick
59/// per published job. `bench --json` reports dispatches/token from it.
60static DISPATCHES: AtomicUsize = AtomicUsize::new(0);
61
62/// Total pool jobs published since process start (all pools).
63pub fn dispatch_count() -> usize {
64    DISPATCHES.load(Ordering::Relaxed)
65}
66
67/// Persistent thread pool: shared job slot, epoch dispatch, caller
68/// participation.
69pub struct Pool {
70    inner: Arc<Inner>,
71    /// Thread handles for `unpark` (same order as `parked`).
72    threads: Vec<std::thread::Thread>,
73    joins: Vec<std::thread::JoinHandle<()>>,
74}
75
76fn spin_budget_from_env() -> usize {
77    std::env::var("CMF_POOL_SPIN")
78        .ok()
79        .and_then(|v| v.parse::<usize>().ok())
80        .unwrap_or(0)
81}
82
83impl Pool {
84    pub fn new(n_workers: usize) -> Self {
85        Self::with_spin(n_workers, spin_budget_from_env())
86    }
87
88    /// Explicit spin budget (tests pin it without touching the env).
89    pub fn with_spin(n_workers: usize, spin_budget: usize) -> Self {
90        let inner = Arc::new(Inner {
91            epoch: AtomicUsize::new(0),
92            remaining: AtomicUsize::new(0),
93            slot: UnsafeCell::new(None),
94            shutdown: AtomicBool::new(false),
95            spin_budget,
96            parked: (0..n_workers).map(|_| AtomicBool::new(false)).collect(),
97        });
98        let mut joins = Vec::with_capacity(n_workers);
99        for w in 0..n_workers {
100            let inner = inner.clone();
101            let h = std::thread::Builder::new()
102                .name(format!("cmf-pool-{w}"))
103                .spawn(move || worker_loop(&inner, w))
104                .expect("spawn pool worker");
105            joins.push(h);
106        }
107        let threads = joins.iter().map(|h| h.thread().clone()).collect();
108        Self { inner, threads, joins }
109    }
110
111    /// Pool sized from `CMF_THREADS` (see module docs). `None` = serial.
112    pub fn from_env() -> Option<Arc<Self>> {
113        let n = match std::env::var("CMF_THREADS") {
114            Ok(v) => v.parse::<usize>().unwrap_or(0),
115            Err(_) => {
116                let avail = std::thread::available_parallelism()
117                    .map(|n| n.get())
118                    .unwrap_or(1);
119                avail.saturating_sub(1).min(8)
120            }
121        };
122        if n <= 1 {
123            None
124        } else {
125            Some(Arc::new(Self::new(n)))
126        }
127    }
128
129    /// Spawned worker threads (the caller joins each job on top).
130    pub fn n_workers(&self) -> usize {
131        self.threads.len()
132    }
133
134    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
135    ///
136    /// One dispatch, but workers pull row-ranges from a shared cursor
137    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
138    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
139    /// every matvec end at the SLOWEST core's pace while the fast ones
140    /// idle at the barrier; pulling by grain lets a P-core take several
141    /// chunks for each one an E-core takes, so skew collapses to a
142    /// single grain. Row ranges stay disjoint and each row's dot is
143    /// computed exactly as in the serial path → bit-identical output.
144    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
145        // Enough chunks to balance, large enough to keep the SDOT inner
146        // loop and the hardware prefetcher in their stride.
147        let grain = (rows / ((self.threads.len() + 1) * 8)).max(32);
148        let next = AtomicUsize::new(0);
149        self.run(&|_w, _n| loop {
150            let start = next.fetch_add(grain, Ordering::Relaxed);
151            if start >= rows {
152                break;
153            }
154            f(start, (start + grain).min(rows));
155        });
156    }
157
158    /// Multi-matrix job: one dispatch serves SEVERAL row spaces
159    /// (roadmap §3 P0 — «одна внешняя публикация job на слой»). Parts
160    /// are laid out back-to-back in a virtual row space and pulled by
161    /// grain from one shared cursor, so QKV or gate+up cost a single
162    /// barrier instead of one each. Each part's `f(start, end)` sees its
163    /// OWN row indices — per-row math and outputs are bit-identical to
164    /// separate `run_rows` calls.
165    pub fn run_many(&self, parts: &[(usize, &(dyn Fn(usize, usize) + Sync))]) {
166        let total: usize = parts.iter().map(|p| p.0).sum();
167        if total == 0 {
168            return;
169        }
170        let grain = (total / ((self.threads.len() + 1) * 8)).max(32);
171        let next = AtomicUsize::new(0);
172        self.run(&|_w, _n| loop {
173            let s = next.fetch_add(grain, Ordering::Relaxed);
174            if s >= total {
175                break;
176            }
177            let e = (s + grain).min(total);
178            let mut base = 0usize;
179            for &(rows, f) in parts {
180                let a = s.max(base);
181                let b = e.min(base + rows);
182                if a < b {
183                    f(a - base, b - base);
184                }
185                base += rows;
186                if base >= e {
187                    break;
188                }
189            }
190        });
191    }
192
193    /// Run `f(worker_idx, n_participants)` on every worker AND the
194    /// calling thread (`worker_idx = n_workers()` for the caller);
195    /// returns when all participants have finished.
196    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
197        DISPATCHES.fetch_add(1, Ordering::Relaxed);
198        let nw = self.threads.len();
199        let n = nw + 1; // caller participates
200        // SAFETY: the wait loop below blocks until every worker is done,
201        // so extending the borrow to 'static never outlives the call.
202        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
203        let ptr: *const (dyn Fn(usize, usize) + Sync + 'static) =
204            unsafe { std::mem::transmute(ptr) };
205        // SAFETY: no job in flight (previous run() drained `remaining`),
206        // so the slot is not being read.
207        unsafe { *self.inner.slot.get() = Some((TaskPtr(ptr), n)) };
208        self.inner.remaining.store(nw, Ordering::Relaxed);
209        self.inner.epoch.fetch_add(1, Ordering::SeqCst);
210        for (i, t) in self.threads.iter().enumerate() {
211            if self.inner.parked[i].load(Ordering::SeqCst) {
212                t.unpark();
213            }
214        }
215
216        // The caller's share — the barrier costs nothing while there is
217        // real work to do.
218        f(nw, n);
219
220        // Wait for the stragglers (bounded by one worker's chunk).
221        let mut spins = 0usize;
222        while self.inner.remaining.load(Ordering::Acquire) != 0 {
223            spins += 1;
224            if spins < 10_000 {
225                std::hint::spin_loop();
226            } else {
227                std::thread::yield_now();
228            }
229        }
230    }
231}
232
233impl Drop for Pool {
234    fn drop(&mut self) {
235        self.inner.shutdown.store(true, Ordering::SeqCst);
236        for t in &self.threads {
237            t.unpark();
238        }
239        for h in self.joins.drain(..) {
240            let _ = h.join();
241        }
242    }
243}
244
245fn worker_loop(inner: &Inner, idx: usize) {
246    // The pool is created at epoch 0; baseline MUST be 0, not a fresh
247    // epoch read — if the caller publishes a job before the OS actually
248    // starts this thread, reading the live epoch would adopt that job's
249    // epoch as "already seen", skip it, and deadlock the caller's wait.
250    let mut seen = 0usize;
251    loop {
252        // Wait for a new epoch: spin first (decode publishes the next
253        // matvec within microseconds), park only when idle for real.
254        let mut spins = 0usize;
255        loop {
256            let e = inner.epoch.load(Ordering::Acquire);
257            if e != seen {
258                seen = e;
259                break;
260            }
261            if inner.shutdown.load(Ordering::Relaxed) {
262                return;
263            }
264            if spins < inner.spin_budget {
265                spins += 1;
266                std::hint::spin_loop();
267            } else {
268                inner.parked[idx].store(true, Ordering::SeqCst);
269                // Re-check under SeqCst: the caller bumps the epoch
270                // BEFORE reading `parked`, so either it sees our flag
271                // (and unparks) or we see its epoch here — a missed
272                // wakeup is impossible. Spurious unparks just loop.
273                if inner.epoch.load(Ordering::SeqCst) == seen
274                    && !inner.shutdown.load(Ordering::Relaxed)
275                {
276                    std::thread::park();
277                }
278                inner.parked[idx].store(false, Ordering::SeqCst);
279            }
280        }
281        // SAFETY: the slot was written before the epoch bump we just
282        // observed (release/acquire), and stays valid until `remaining`
283        // drops to zero — which happens only after `f` returns below.
284        let (task, n) = unsafe { (*inner.slot.get()).expect("job published with epoch") };
285        let f = unsafe { &*task.0 };
286        f(idx, n);
287        inner.remaining.fetch_sub(1, Ordering::AcqRel);
288    }
289}
290
291/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
292/// Bit-identical to the serial loop (row order does not change math).
293pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
294    let in_dim = x.len();
295    let out_dim = out.len();
296    debug_assert!(w.len() >= out_dim * in_dim);
297
298    let row_dot = |o: usize| -> f32 {
299        let row = &w[o * in_dim..(o + 1) * in_dim];
300        let mut sum = 0.0f32;
301        for j in 0..in_dim {
302            sum += row[j] * x[j];
303        }
304        sum
305    };
306
307    match pool {
308        // Small outputs are not worth the barrier round-trip.
309        Some(pool) if out_dim >= 256 => {
310            let out_addr = SendMut(out.as_mut_ptr());
311            pool.run(&move |widx, n| {
312                let chunk = out_dim.div_ceil(n);
313                let start = widx * chunk;
314                let end = (start + chunk).min(out_dim);
315                for o in start..end {
316                    // SAFETY: workers write disjoint index ranges.
317                    unsafe { *out_addr.at(o) = row_dot(o) };
318                }
319            });
320        }
321        _ => {
322            for (o, dst) in out.iter_mut().enumerate() {
323                *dst = row_dot(o);
324            }
325        }
326    }
327}
328
329/// Two-input row matvec: one pass over the weight rows serves BOTH
330/// inputs — CPU decode is memory-bound, so the second position costs a
331/// fraction of the first (this is where MTP speculative verify wins).
332/// Per-output accumulation order matches the single-input path exactly
333/// → bit-identical results.
334pub fn matvec_rows2(
335    pool: Option<&Pool>,
336    w: &[f32],
337    x1: &[f32],
338    x2: &[f32],
339    out1: &mut [f32],
340    out2: &mut [f32],
341) {
342    let in_dim = x1.len();
343    debug_assert_eq!(x2.len(), in_dim);
344    let out_dim = out1.len();
345    debug_assert_eq!(out2.len(), out_dim);
346    debug_assert!(w.len() >= out_dim * in_dim);
347
348    let row_dots = |o: usize| -> (f32, f32) {
349        let row = &w[o * in_dim..(o + 1) * in_dim];
350        let (mut s1, mut s2) = (0.0f32, 0.0f32);
351        for j in 0..in_dim {
352            s1 += row[j] * x1[j];
353            s2 += row[j] * x2[j];
354        }
355        (s1, s2)
356    };
357
358    match pool {
359        Some(pool) if out_dim >= 256 => {
360            let o1 = SendMut(out1.as_mut_ptr());
361            let o2 = SendMut(out2.as_mut_ptr());
362            pool.run(&move |widx, n| {
363                let chunk = out_dim.div_ceil(n);
364                let start = widx * chunk;
365                let end = (start + chunk).min(out_dim);
366                for o in start..end {
367                    let (s1, s2) = row_dots(o);
368                    // SAFETY: workers write disjoint index ranges.
369                    unsafe {
370                        *o1.at(o) = s1;
371                        *o2.at(o) = s2;
372                    }
373                }
374            });
375        }
376        _ => {
377            for o in 0..out_dim {
378                let (s1, s2) = row_dots(o);
379                out1[o] = s1;
380                out2[o] = s2;
381            }
382        }
383    }
384}
385
386#[derive(Clone, Copy)]
387struct SendMut(*mut f32);
388unsafe impl Send for SendMut {}
389unsafe impl Sync for SendMut {}
390
391impl SendMut {
392    /// Method receiver forces the closure to capture the whole (Sync)
393    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
394    #[inline]
395    fn at(self, i: usize) -> *mut f32 {
396        unsafe { self.0.add(i) }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn parallel_matvec_equals_serial_bitexact() {
406        let (out_dim, in_dim) = (512, 64);
407        let w: Vec<f32> = (0..out_dim * in_dim).map(|i| (i as f32 * 0.013).sin()).collect();
408        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
409
410        let mut serial = vec![0.0f32; out_dim];
411        matvec_rows(None, &w, &x, &mut serial);
412
413        let pool = Pool::new(4);
414        let mut parallel = vec![0.0f32; out_dim];
415        matvec_rows(Some(&pool), &w, &x, &mut parallel);
416
417        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
418    }
419
420    #[test]
421    fn fused_pair_equals_two_singles_bitexact() {
422        let (out_dim, in_dim) = (300, 48);
423        let w: Vec<f32> = (0..out_dim * in_dim).map(|i| (i as f32 * 0.011).sin()).collect();
424        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
425        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
426
427        let mut a1 = vec![0.0f32; out_dim];
428        let mut a2 = vec![0.0f32; out_dim];
429        matvec_rows(None, &w, &x1, &mut a1);
430        matvec_rows(None, &w, &x2, &mut a2);
431
432        for pool in [None, Some(Pool::new(3))] {
433            let mut b1 = vec![0.0f32; out_dim];
434            let mut b2 = vec![0.0f32; out_dim];
435            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
436            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
437            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
438        }
439    }
440
441    #[test]
442    fn pool_survives_many_runs() {
443        let pool = Pool::new(3);
444        let counter = AtomicUsize::new(0);
445        for _ in 0..100 {
446            pool.run(&|_, _| {
447                counter.fetch_add(1, Ordering::Relaxed);
448            });
449        }
450        // 3 workers + the participating caller = 4 executions per run.
451        assert_eq!(counter.load(Ordering::Relaxed), 400);
452    }
453
454    #[test]
455    fn pool_wakes_after_park() {
456        // Force immediate parking (no spin) — the epoch/parked handshake
457        // must still never miss a wakeup.
458        let pool = Pool::with_spin(2, 0);
459        let counter = AtomicUsize::new(0);
460        for _ in 0..50 {
461            pool.run(&|_, _| {
462                counter.fetch_add(1, Ordering::Relaxed);
463            });
464            // Give workers time to actually park between jobs.
465            std::thread::sleep(std::time::Duration::from_micros(200));
466        }
467        assert_eq!(counter.load(Ordering::Relaxed), 150);
468    }
469
470    #[test]
471    fn worker_indices_are_distinct_and_cover_range() {
472        let pool = Pool::new(3);
473        let hits: Vec<AtomicUsize> = (0..4).map(|_| AtomicUsize::new(0)).collect();
474        for _ in 0..20 {
475            pool.run(&|widx, n| {
476                assert_eq!(n, 4);
477                hits[widx].fetch_add(1, Ordering::Relaxed);
478            });
479        }
480        for (i, h) in hits.iter().enumerate() {
481            assert_eq!(h.load(Ordering::Relaxed), 20, "participant {i} missed runs");
482        }
483    }
484}