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(worker_idx, n_workers)` on every worker; blocks until all
108    /// have finished.
109    pub fn run(&self, f: &(dyn Fn(usize, usize) + Sync)) {
110        let n = self.txs.len();
111        let latch = Latch::new(n);
112        // SAFETY: `wait()` below blocks until every worker is done, so
113        // extending the borrow to 'static never outlives the call.
114        let ptr: *const (dyn Fn(usize, usize) + Sync) = f;
115        let ptr: *const (dyn Fn(usize, usize) + Sync + 'static) =
116            unsafe { std::mem::transmute(ptr) };
117        for (i, tx) in self.txs.iter().enumerate() {
118            let job = Job {
119                task: TaskPtr(ptr),
120                worker_idx: i,
121                n_workers: n,
122                latch: latch.clone(),
123            };
124            tx.send(job).expect("pool worker died");
125        }
126        latch.wait();
127    }
128}
129
130/// Row-parallel dense matvec: `out[o] = Σ_j w[o·in + j]·x[j]`.
131/// Bit-identical to the serial loop (row order does not change math).
132pub fn matvec_rows(pool: Option<&Pool>, w: &[f32], x: &[f32], out: &mut [f32]) {
133    let in_dim = x.len();
134    let out_dim = out.len();
135    debug_assert!(w.len() >= out_dim * in_dim);
136
137    let row_dot = |o: usize| -> f32 {
138        let row = &w[o * in_dim..(o + 1) * in_dim];
139        let mut sum = 0.0f32;
140        for j in 0..in_dim {
141            sum += row[j] * x[j];
142        }
143        sum
144    };
145
146    match pool {
147        // Small outputs are not worth the latch round-trip.
148        Some(pool) if out_dim >= 256 => {
149            let out_addr = SendMut(out.as_mut_ptr());
150            pool.run(&move |widx, n| {
151                let chunk = out_dim.div_ceil(n);
152                let start = widx * chunk;
153                let end = (start + chunk).min(out_dim);
154                for o in start..end {
155                    // SAFETY: workers write disjoint index ranges.
156                    unsafe { *out_addr.at(o) = row_dot(o) };
157                }
158            });
159        }
160        _ => {
161            for (o, dst) in out.iter_mut().enumerate() {
162                *dst = row_dot(o);
163            }
164        }
165    }
166}
167
168/// Two-input row matvec: one pass over the weight rows serves BOTH
169/// inputs — CPU decode is memory-bound, so the second position costs a
170/// fraction of the first (this is where MTP speculative verify wins).
171/// Per-output accumulation order matches the single-input path exactly
172/// → bit-identical results.
173pub fn matvec_rows2(
174    pool: Option<&Pool>,
175    w: &[f32],
176    x1: &[f32],
177    x2: &[f32],
178    out1: &mut [f32],
179    out2: &mut [f32],
180) {
181    let in_dim = x1.len();
182    debug_assert_eq!(x2.len(), in_dim);
183    let out_dim = out1.len();
184    debug_assert_eq!(out2.len(), out_dim);
185    debug_assert!(w.len() >= out_dim * in_dim);
186
187    let row_dots = |o: usize| -> (f32, f32) {
188        let row = &w[o * in_dim..(o + 1) * in_dim];
189        let (mut s1, mut s2) = (0.0f32, 0.0f32);
190        for j in 0..in_dim {
191            s1 += row[j] * x1[j];
192            s2 += row[j] * x2[j];
193        }
194        (s1, s2)
195    };
196
197    match pool {
198        Some(pool) if out_dim >= 256 => {
199            let o1 = SendMut(out1.as_mut_ptr());
200            let o2 = SendMut(out2.as_mut_ptr());
201            pool.run(&move |widx, n| {
202                let chunk = out_dim.div_ceil(n);
203                let start = widx * chunk;
204                let end = (start + chunk).min(out_dim);
205                for o in start..end {
206                    let (s1, s2) = row_dots(o);
207                    // SAFETY: workers write disjoint index ranges.
208                    unsafe {
209                        *o1.at(o) = s1;
210                        *o2.at(o) = s2;
211                    }
212                }
213            });
214        }
215        _ => {
216            for o in 0..out_dim {
217                let (s1, s2) = row_dots(o);
218                out1[o] = s1;
219                out2[o] = s2;
220            }
221        }
222    }
223}
224
225#[derive(Clone, Copy)]
226struct SendMut(*mut f32);
227unsafe impl Send for SendMut {}
228unsafe impl Sync for SendMut {}
229
230impl SendMut {
231    /// Method receiver forces the closure to capture the whole (Sync)
232    /// wrapper, not the bare `*mut f32` field (edition-2021 precise capture).
233    #[inline]
234    fn at(self, i: usize) -> *mut f32 {
235        unsafe { self.0.add(i) }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parallel_matvec_equals_serial_bitexact() {
245        let (out_dim, in_dim) = (512, 64);
246        let w: Vec<f32> = (0..out_dim * in_dim).map(|i| (i as f32 * 0.013).sin()).collect();
247        let x: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.07).cos()).collect();
248
249        let mut serial = vec![0.0f32; out_dim];
250        matvec_rows(None, &w, &x, &mut serial);
251
252        let pool = Pool::new(4);
253        let mut parallel = vec![0.0f32; out_dim];
254        matvec_rows(Some(&pool), &w, &x, &mut parallel);
255
256        assert_eq!(serial, parallel, "row-parallel must be bit-identical");
257    }
258
259    #[test]
260    fn fused_pair_equals_two_singles_bitexact() {
261        let (out_dim, in_dim) = (300, 48);
262        let w: Vec<f32> = (0..out_dim * in_dim).map(|i| (i as f32 * 0.011).sin()).collect();
263        let x1: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.03).cos()).collect();
264        let x2: Vec<f32> = (0..in_dim).map(|i| (i as f32 * 0.09).sin()).collect();
265
266        let mut a1 = vec![0.0f32; out_dim];
267        let mut a2 = vec![0.0f32; out_dim];
268        matvec_rows(None, &w, &x1, &mut a1);
269        matvec_rows(None, &w, &x2, &mut a2);
270
271        for pool in [None, Some(Pool::new(3))] {
272            let mut b1 = vec![0.0f32; out_dim];
273            let mut b2 = vec![0.0f32; out_dim];
274            matvec_rows2(pool.as_ref(), &w, &x1, &x2, &mut b1, &mut b2);
275            assert_eq!(a1, b1, "fused lane 1 must be bit-identical");
276            assert_eq!(a2, b2, "fused lane 2 must be bit-identical");
277        }
278    }
279
280    #[test]
281    fn pool_survives_many_runs() {
282        let pool = Pool::new(3);
283        let counter = AtomicUsize::new(0);
284        for _ in 0..100 {
285            pool.run(&|_, _| {
286                counter.fetch_add(1, Ordering::Relaxed);
287            });
288        }
289        assert_eq!(counter.load(Ordering::Relaxed), 300);
290    }
291}