Skip to main content

cortiq_engine/
pool.rs

1//! Persistent worker pool for row-parallel matvecs.
2//!
3//! Threads are spawned once and parked between calls — vmfcore measured
4//! spawn-per-matvec at ~+27% decode cost versus a persistent pool.
5//! Parallelism is by disjoint row ranges, so results are bit-identical
6//! to the serial path (each row's dot product is computed the same way).
7//!
8//! `CMF_THREADS` env: 0/1 = serial, N = worker count
9//! (default: available_parallelism − 1, capped at 8).
10
11use std::sync::atomic::{AtomicUsize, Ordering};
12use std::sync::mpsc::{channel, Sender};
13use std::sync::{Arc, Condvar, Mutex};
14
15/// A `*const dyn Fn` that may cross a thread boundary. Safety is
16/// provided by `Pool::run`: the caller blocks until every worker has
17/// finished, so the borrow outlives all uses.
18struct TaskPtr(*const (dyn Fn(usize, usize) + Sync));
19unsafe impl Send for TaskPtr {}
20
21struct Latch {
22    remaining: AtomicUsize,
23    lock: Mutex<()>,
24    cv: Condvar,
25}
26
27impl Latch {
28    fn new(n: usize) -> Arc<Self> {
29        Arc::new(Self {
30            remaining: AtomicUsize::new(n),
31            lock: Mutex::new(()),
32            cv: Condvar::new(),
33        })
34    }
35
36    fn count_down(&self) {
37        if self.remaining.fetch_sub(1, Ordering::AcqRel) == 1 {
38            let _g = self.lock.lock().unwrap();
39            self.cv.notify_all();
40        }
41    }
42
43    fn wait(&self) {
44        let mut g = self.lock.lock().unwrap();
45        while self.remaining.load(Ordering::Acquire) != 0 {
46            g = self.cv.wait(g).unwrap();
47        }
48    }
49}
50
51struct Job {
52    task: TaskPtr,
53    worker_idx: usize,
54    n_workers: usize,
55    latch: Arc<Latch>,
56}
57
58/// Persistent thread pool. Workers park on a channel between jobs.
59pub struct Pool {
60    txs: Vec<Sender<Job>>,
61}
62
63impl Pool {
64    pub fn new(n_workers: usize) -> Self {
65        let mut txs = Vec::with_capacity(n_workers);
66        for w in 0..n_workers {
67            let (tx, rx) = channel::<Job>();
68            std::thread::Builder::new()
69                .name(format!("cmf-pool-{w}"))
70                .spawn(move || {
71                    while let Ok(job) = rx.recv() {
72                        // SAFETY: Pool::run blocks on the latch until this
73                        // call returns, keeping the closure borrow alive.
74                        let f = unsafe { &*job.task.0 };
75                        f(job.worker_idx, job.n_workers);
76                        job.latch.count_down();
77                    }
78                })
79                .expect("spawn pool worker");
80            txs.push(tx);
81        }
82        Self { txs }
83    }
84
85    /// Pool sized from `CMF_THREADS` (see module docs). `None` = serial.
86    pub fn from_env() -> Option<Arc<Self>> {
87        let n = match std::env::var("CMF_THREADS") {
88            Ok(v) => v.parse::<usize>().unwrap_or(0),
89            Err(_) => {
90                let avail = std::thread::available_parallelism()
91                    .map(|n| n.get())
92                    .unwrap_or(1);
93                avail.saturating_sub(1).min(8)
94            }
95        };
96        if n <= 1 {
97            None
98        } else {
99            Some(Arc::new(Self::new(n)))
100        }
101    }
102
103    pub fn n_workers(&self) -> usize {
104        self.txs.len()
105    }
106
107    /// Run `f(row_start, row_end)` over `0..rows`, self-balancing.
108    ///
109    /// One dispatch, but workers pull row-ranges from a shared cursor
110    /// instead of each taking a fixed 1/n slice. On a heterogeneous CPU
111    /// (Apple Silicon: 4 P-cores + 6 E-cores here) a static split makes
112    /// every matvec end at the SLOWEST core's pace while the fast ones
113    /// idle at the latch; pulling by grain lets a P-core take several
114    /// chunks for each one an E-core takes, so skew collapses to a
115    /// single grain. Row ranges stay disjoint and each row's dot is
116    /// computed exactly as in the serial path → bit-identical output.
117    pub fn run_rows(&self, rows: usize, f: &(dyn Fn(usize, usize) + Sync)) {
118        // Enough chunks to balance, large enough to keep the SDOT inner
119        // loop and the hardware prefetcher in their stride.
120        let grain = (rows / (self.txs.len() * 8)).max(32);
121        let next = AtomicUsize::new(0);
122        self.run(&|_w, _n| loop {
123            let start = next.fetch_add(grain, Ordering::Relaxed);
124            if start >= rows {
125                break;
126            }
127            f(start, (start + grain).min(rows));
128        });
129    }
130
131    /// Run `f(worker_idx, n_workers)` on every worker; blocks until all
132    /// have finished.
133    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
134        let n = self.txs.len();
135        let latch = Latch::new(n);
136        // SAFETY: `wait()` below blocks until every worker is done, so
137        // extending the borrow to 'static never outlives the call.
138        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
139        let ptr: *const (dyn Fn(usize, usize) + Sync + 'static) =
140            unsafe { std::mem::transmute(ptr) };
141        for (i, tx) in self.txs.iter().enumerate() {
142            let job = Job {
143                task: TaskPtr(ptr),
144                worker_idx: i,
145                n_workers: n,
146                latch: latch.clone(),
147            };
148            tx.send(job).expect("pool worker died");
149        }
150        latch.wait();
151    }
152}
153
154/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
155/// Bit-identical to the serial loop (row order does not change math).
156pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
157    let in_dim = x.len();
158    let out_dim = out.len();
159    debug_assert!(w.len() >= out_dim * in_dim);
160
161    let row_dot = |o: usize| -> f32 {
162        let row = &w[o * in_dim..(o + 1) * in_dim];
163        let mut sum = 0.0f32;
164        for j in 0..in_dim {
165            sum += row[j] * x[j];
166        }
167        sum
168    };
169
170    match pool {
171        // Small outputs are not worth the latch round-trip.
172        Some(pool) if out_dim >= 256 => {
173            let out_addr = SendMut(out.as_mut_ptr());
174            pool.run(&move |widx, n| {
175                let chunk = out_dim.div_ceil(n);
176                let start = widx * chunk;
177                let end = (start + chunk).min(out_dim);
178                for o in start..end {
179                    // SAFETY: workers write disjoint index ranges.
180                    unsafe { *out_addr.at(o) = row_dot(o) };
181                }
182            });
183        }
184        _ => {
185            for (o, dst) in out.iter_mut().enumerate() {
186                *dst = row_dot(o);
187            }
188        }
189    }
190}
191
192/// Two-input row matvec: one pass over the weight rows serves BOTH
193/// inputs — CPU decode is memory-bound, so the second position costs a
194/// fraction of the first (this is where MTP speculative verify wins).
195/// Per-output accumulation order matches the single-input path exactly
196/// → bit-identical results.
197pub fn matvec_rows2(
198    pool: Option<&Pool>,
199    w: &[f32],
200    x1: &[f32],
201    x2: &[f32],
202    out1: &mut [f32],
203    out2: &mut [f32],
204) {
205    let in_dim = x1.len();
206    debug_assert_eq!(x2.len(), in_dim);
207    let out_dim = out1.len();
208    debug_assert_eq!(out2.len(), out_dim);
209    debug_assert!(w.len() >= out_dim * in_dim);
210
211    let row_dots = |o: usize| -> (f32, f32) {
212        let row = &w[o * in_dim..(o + 1) * in_dim];
213        let (mut s1, mut s2) = (0.0f32, 0.0f32);
214        for j in 0..in_dim {
215            s1 += row[j] * x1[j];
216            s2 += row[j] * x2[j];
217        }
218        (s1, s2)
219    };
220
221    match pool {
222        Some(pool) if out_dim >= 256 => {
223            let o1 = SendMut(out1.as_mut_ptr());
224            let o2 = SendMut(out2.as_mut_ptr());
225            pool.run(&move |widx, n| {
226                let chunk = out_dim.div_ceil(n);
227                let start = widx * chunk;
228                let end = (start + chunk).min(out_dim);
229                for o in start..end {
230                    let (s1, s2) = row_dots(o);
231                    // SAFETY: workers write disjoint index ranges.
232                    unsafe {
233                        *o1.at(o) = s1;
234                        *o2.at(o) = s2;
235                    }
236                }
237            });
238        }
239        _ => {
240            for o in 0..out_dim {
241                let (s1, s2) = row_dots(o);
242                out1[o] = s1;
243                out2[o] = s2;
244            }
245        }
246    }
247}
248
249#[derive(Clone, Copy)]
250struct SendMut(*mut f32);
251unsafe impl Send for SendMut {}
252unsafe impl Sync for SendMut {}
253
254impl SendMut {
255    /// Method receiver forces the closure to capture the whole (Sync)
256    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
257    #[inline]
258    fn at(self, i: usize) -> *mut f32 {
259        unsafe { self.0.add(i) }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn parallel_matvec_equals_serial_bitexact() {
269        let (out_dim, in_dim) = (512, 64);
270        let w: Vec<f32> = (0..out_dim * in_dim).map(|i| (i as f32 * 0.013).sin()).collect();
271        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
272
273        let mut serial = vec![0.0f32; out_dim];
274        matvec_rows(None, &w, &x, &mut serial);
275
276        let pool = Pool::new(4);
277        let mut parallel = vec![0.0f32; out_dim];
278        matvec_rows(Some(&pool), &w, &x, &mut parallel);
279
280        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
281    }
282
283    #[test]
284    fn fused_pair_equals_two_singles_bitexact() {
285        let (out_dim, in_dim) = (300, 48);
286        let w: Vec<f32> = (0..out_dim * in_dim).map(|i| (i as f32 * 0.011).sin()).collect();
287        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
288        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
289
290        let mut a1 = vec![0.0f32; out_dim];
291        let mut a2 = vec![0.0f32; out_dim];
292        matvec_rows(None, &w, &x1, &mut a1);
293        matvec_rows(None, &w, &x2, &mut a2);
294
295        for pool in [None, Some(Pool::new(3))] {
296            let mut b1 = vec![0.0f32; out_dim];
297            let mut b2 = vec![0.0f32; out_dim];
298            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
299            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
300            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
301        }
302    }
303
304    #[test]
305    fn pool_survives_many_runs() {
306        let pool = Pool::new(3);
307        let counter = AtomicUsize::new(0);
308        for _ in 0..100 {
309            pool.run(&|_, _| {
310                counter.fetch_add(1, Ordering::Relaxed);
311            });
312        }
313        assert_eq!(counter.load(Ordering::Relaxed), 300);
314    }
315}