Skip to main content

ferrox_core/
cpu_pool.rs

1//! A persistent CPU worker pool parked on a spin-then-park barrier.
2//!
3//! # Why this exists
4//!
5//! Decode opens a parallel region per weight matrix, per layer, per
6//! token -- roughly seven per layer. Rayon answers each of those with a
7//! fork-join: a job is pushed onto the global injector, sleeping workers
8//! are woken through a mutex and a condvar, and the caller then waits on
9//! a latch. That is a fixed per-region cost, so the smaller the model
10//! the larger its share: measured at ~75% of decode wall time at 135M
11//! parameters and ~9% at 8B (issue #27).
12//!
13//! llama.cpp does not fork. `ggml_threadpool` starts N workers once and
14//! parks them on a spin barrier; a graph node is published by bumping a
15//! counter the workers are already watching, and each worker pulls
16//! chunks off one shared atomic until the work is gone. Waking a
17//! spinning thread costs a cache-line transfer instead of a futex.
18//!
19//! This module is that shape, in safe-by-construction Rust:
20//!
21//! - [`CpuPool::new`] spawns the workers once and keeps them.
22//! - [`CpuPool::run`] publishes `n_tasks` and a type-erased closure,
23//!   bumps the epoch, then *participates* in draining the task counter
24//!   alongside the workers.
25//! - Workers spin for [`spin_window`] and then park on a condvar, so an
26//!   idle `ferrox-server` does not burn a core per worker. That bound is
27//!   the whole reason this is not a plain spin barrier.
28//!
29//! # What makes it sound
30//!
31//! The one dangerous thing here is that workers dereference a pointer to
32//! a closure the submitter owns. Two rules keep that from being a
33//! use-after-free, and both are enforced in [`CpuPool::run`]:
34//!
35//! 1. **A region ends only when every worker has checked out.** `active`
36//!    is set to the worker count before the epoch bump and decremented
37//!    by each worker after its last touch of the job; `run` does not
38//!    return until it reads zero. So the closure outlives every use.
39//! 2. **Only one region at a time.** `submit` is a mutex, and `run`
40//!    *tries* it rather than blocking: a second thread that arrives
41//!    while a region is in flight is told `false` and falls back to
42//!    rayon rather than queueing behind it.
43//!
44//! Re-entrancy is the third hazard -- a task closure that itself opens a
45//! region would deadlock against rule 2 -- so [`CpuPool::run`] runs
46//! nested regions inline on the calling thread.
47//!
48//! # What this module is NOT
49//!
50//! It is not a general work-stealing runtime. There is no task graph, no
51//! nested parallelism, no `join`. It runs one flat `0..n_tasks` loop at a
52//! time, because that is the entire shape of a quantized matvec and the
53//! shape llama.cpp's threadpool has.
54
55use std::any::Any;
56use std::cell::Cell;
57use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
58use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
59use std::sync::{Arc, Condvar, Mutex};
60use std::time::{Duration, Instant};
61
62/// How long a worker keeps spinning after a region ends before it parks.
63///
64/// Decode submits regions microseconds apart, so a window of tens of
65/// microseconds keeps the workers hot right through a token while still
66/// letting them sleep when generation stops. A pool that never parked
67/// would burn a core per worker on an idle server, which is why this is
68/// a window and not a flag.
69///
70/// `FERROX_CPU_POOL_SPIN_US` overrides it; `0` parks immediately, which
71/// is the configuration that proves the park/wake path is exercised.
72fn spin_window() -> Duration {
73    use std::sync::OnceLock;
74    static US: OnceLock<u64> = OnceLock::new();
75    Duration::from_micros(*US.get_or_init(|| {
76        std::env::var("FERROX_CPU_POOL_SPIN_US")
77            .ok()
78            .and_then(|v| v.trim().parse::<u64>().ok())
79            .unwrap_or(100)
80    }))
81}
82
83/// A parked worker is woken by a broadcast, but a lost wakeup would hang
84/// a region forever, so the wait is also bounded. The protocol in
85/// [`Shared::wait_for_job`] is designed not to lose one; this timeout is
86/// the belt to that pair of braces.
87const PARK_TIMEOUT: Duration = Duration::from_millis(20);
88
89/// The type-erased closure a region runs, plus its task count.
90///
91/// `ptr` is a `*const F` for the `F` the submitter still owns on its own
92/// stack, and `call` is a monomorphized shim that casts it back. This is
93/// how a fat `dyn Fn` pointer is avoided: a plain thin pointer and a
94/// function pointer both fit in a `Copy` struct.
95#[derive(Clone, Copy)]
96struct Job {
97    ptr: *const (),
98    call: unsafe fn(*const (), usize),
99    n_tasks: usize,
100}
101
102impl Default for Job {
103    fn default() -> Self {
104        // A job nobody will ever run: zero tasks, and a callee that is
105        // never reached because the task loop exits first.
106        unsafe fn never(_: *const (), _: usize) {}
107        Self {
108            ptr: std::ptr::null(),
109            call: never,
110            n_tasks: 0,
111        }
112    }
113}
114
115/// The shim `Job::call` points at for a concrete closure type.
116///
117/// # Safety
118/// `ptr` must be a live `*const F` that outlives every call, and `F`
119/// must be `Sync` because several workers call it at once.
120unsafe fn call_shim<F: Fn(usize) + Sync>(ptr: *const (), task: usize) {
121    // SAFETY: the caller guarantees `ptr` was produced from a live
122    // `&F` (see `CpuPool::run`, which does not return until every
123    // worker has finished calling this).
124    let f = unsafe { &*(ptr as *const F) };
125    f(task)
126}
127
128/// State shared by the submitter and every worker.
129struct Shared {
130    /// Bumped once per region. This is the *only* channel that publishes
131    /// a job: the `Release` on this store orders every non-atomic write
132    /// to `job` before the `Acquire` load a worker does.
133    epoch: AtomicU64,
134    /// Written by the submitter while no region is in flight, read by
135    /// workers after they observe a new `epoch`.
136    job: std::cell::UnsafeCell<Job>,
137    /// The shared task cursor. `fetch_add` is the whole scheduler.
138    next: AtomicUsize,
139    /// Set when a task panicked, so the remaining tasks are abandoned
140    /// instead of each thread running into the same panic.
141    aborted: AtomicBool,
142    /// Workers that have not yet checked out of the current region.
143    active: AtomicUsize,
144    /// Asks every worker to leave its loop. Only [`CpuPool::drop`] sets it.
145    shutdown: AtomicBool,
146    /// Number of workers currently blocked in `cv`. Guarded by the mutex
147    /// so the submitter can decide whether a broadcast is needed without
148    /// racing a worker that is about to sleep.
149    parked: Mutex<usize>,
150    cv: Condvar,
151    /// The payload of the first task panic in the current region.
152    panic: Mutex<Option<Box<dyn Any + Send + 'static>>>,
153}
154
155// SAFETY: `Shared` is only ever reached through an `Arc`, and the two
156// non-`Sync` fields are disciplined by the epoch protocol:
157//   - `job` is written by the submitter *before* `epoch` is bumped and
158//     read by workers *after* they observe that bump, and the submitter
159//     does not write again until `active` has drained to zero. So writer
160//     and readers never overlap.
161//   - `Job::ptr` is a pointer to a closure that, per `CpuPool::run`,
162//     outlives every worker's use of it.
163unsafe impl Sync for Shared {}
164unsafe impl Send for Shared {}
165
166impl Shared {
167    /// Block until the epoch differs from `seen` (a new region) or
168    /// shutdown is requested, and return the epoch observed.
169    ///
170    /// Spin first, park second. The park path takes `parked` before it
171    /// re-checks the epoch, and the submitter bumps the epoch while
172    /// holding that same mutex, so there is no window in which a worker
173    /// decides to sleep on a region that has already been published.
174    fn wait_for_job(&self, seen: u64) -> u64 {
175        let deadline = Instant::now() + spin_window();
176        loop {
177            let epoch = self.epoch.load(Ordering::Acquire);
178            if epoch != seen {
179                return epoch;
180            }
181            for _ in 0..64 {
182                std::hint::spin_loop();
183            }
184            if Instant::now() >= deadline {
185                break;
186            }
187        }
188        let mut parked = self.parked.lock().unwrap_or_else(|e| e.into_inner());
189        loop {
190            let epoch = self.epoch.load(Ordering::Acquire);
191            if epoch != seen {
192                return epoch;
193            }
194            *parked += 1;
195            let (guard, _) = self
196                .cv
197                .wait_timeout(parked, PARK_TIMEOUT)
198                .unwrap_or_else(|e| e.into_inner());
199            parked = guard;
200            *parked -= 1;
201        }
202    }
203
204    /// Drain the task cursor, running each task index exactly once.
205    ///
206    /// A panic in a task is caught, recorded, and turned into an abort
207    /// for the rest of the region: without that, a worker would unwind
208    /// out of its loop, never decrement `active`, and hang the submitter
209    /// forever. The payload is re-raised on the submitter in
210    /// [`CpuPool::run`], which is where rayon would have raised it too.
211    fn drain(&self, job: Job) {
212        let outcome = catch_unwind(AssertUnwindSafe(|| {
213            loop {
214                if self.aborted.load(Ordering::Relaxed) {
215                    break;
216                }
217                let task = self.next.fetch_add(1, Ordering::Relaxed);
218                if task >= job.n_tasks {
219                    break;
220                }
221                // SAFETY: `job` was published by `CpuPool::run`, which
222                // does not return -- and so does not drop the closure --
223                // until `active` reaches zero, which happens strictly
224                // after this call returns.
225                unsafe { (job.call)(job.ptr, task) };
226            }
227        }));
228        if let Err(payload) = outcome {
229            self.aborted.store(true, Ordering::Relaxed);
230            let mut slot = self.panic.lock().unwrap_or_else(|e| e.into_inner());
231            if slot.is_none() {
232                *slot = Some(payload);
233            }
234        }
235    }
236}
237
238thread_local! {
239    /// Set while this thread is executing tasks for a region. A task
240    /// that opens its own region must not try to take the pool: it
241    /// would deadlock against the submit mutex it is already inside of.
242    static IN_REGION: Cell<bool> = const { Cell::new(false) };
243}
244
245/// Whether the calling thread is currently running pool tasks.
246pub fn in_region() -> bool {
247    IN_REGION.with(|c| c.get())
248}
249
250/// A persistent pool of parked workers.
251///
252/// Dropping the pool asks every worker to leave and joins it, so a pool
253/// never outlives its threads. The process-wide pool is a `static` and
254/// is therefore never dropped, exactly like rayon's global pool.
255pub struct CpuPool {
256    shared: Arc<Shared>,
257    workers: Vec<std::thread::JoinHandle<()>>,
258    submit: Mutex<()>,
259}
260
261impl CpuPool {
262    /// Spawn `threads` workers, minus the submitter: the thread that
263    /// calls [`Self::run`] is a worker too, which is why a one-thread
264    /// pool spawns nothing at all and still works.
265    pub fn new(threads: usize) -> Self {
266        let threads = threads.max(1);
267        let shared = Arc::new(Shared {
268            epoch: AtomicU64::new(0),
269            job: std::cell::UnsafeCell::new(Job::default()),
270            next: AtomicUsize::new(0),
271            aborted: AtomicBool::new(false),
272            active: AtomicUsize::new(0),
273            shutdown: AtomicBool::new(false),
274            parked: Mutex::new(0),
275            cv: Condvar::new(),
276            panic: Mutex::new(None),
277        });
278        let mut workers = Vec::with_capacity(threads - 1);
279        for idx in 0..threads - 1 {
280            let shared = Arc::clone(&shared);
281            let handle = std::thread::Builder::new()
282                .name(format!("ferrox-cpu-{idx}"))
283                .spawn(move || worker_loop(&shared))
284                .expect("ferrox: cannot spawn CPU pool worker");
285            workers.push(handle);
286        }
287        Self {
288            shared,
289            workers,
290            submit: Mutex::new(()),
291        }
292    }
293
294    /// Total width including the submitting thread.
295    pub fn num_threads(&self) -> usize {
296        self.workers.len() + 1
297    }
298
299    /// Run `f(task)` for every `task` in `0..n_tasks`.
300    ///
301    /// Returns `false` without running anything if another thread is
302    /// already inside a region -- the caller is expected to fall back to
303    /// rayon rather than serialize behind it. Returns `true` when the
304    /// work is complete, including the nested case, where the tasks run
305    /// inline on the calling thread.
306    ///
307    /// A panic in `f` is re-raised here, on the submitting thread.
308    pub fn run<F: Fn(usize) + Sync>(&self, n_tasks: usize, f: &F) -> bool {
309        if n_tasks == 0 {
310            return true;
311        }
312        if in_region() {
313            // Nested region: the pool is already saturated by the outer
314            // one, and taking it again would deadlock.
315            for task in 0..n_tasks {
316                f(task);
317            }
318            return true;
319        }
320        let guard = match self.submit.try_lock() {
321            Ok(guard) => guard,
322            // A previous submitter unwound while holding the lock. The
323            // region it was running had already drained (`drain` catches
324            // task panics), so the pool state is intact and poisoning
325            // here would silently retire the pool for the process.
326            Err(std::sync::TryLockError::Poisoned(guard)) => guard.into_inner(),
327            Err(std::sync::TryLockError::WouldBlock) => return false,
328        };
329        let job = Job {
330            ptr: std::ptr::from_ref(f) as *const (),
331            call: call_shim::<F>,
332            n_tasks,
333        };
334        // SAFETY: `submit` is held, and the previous region drained
335        // `active` to zero before releasing it, so no worker is reading
336        // `job` right now. The write is published by the `Release` in
337        // the `fetch_add` on `epoch` below.
338        unsafe { *self.shared.job.get() = job };
339        self.shared.next.store(0, Ordering::Relaxed);
340        self.shared.aborted.store(false, Ordering::Relaxed);
341        self.shared
342            .active
343            .store(self.workers.len(), Ordering::Relaxed);
344        let parked = {
345            let parked = self.shared.parked.lock().unwrap_or_else(|e| e.into_inner());
346            self.shared.epoch.fetch_add(1, Ordering::Release);
347            *parked
348        };
349        if parked > 0 {
350            self.shared.cv.notify_all();
351        }
352
353        IN_REGION.with(|c| c.set(true));
354        self.shared.drain(job);
355        IN_REGION.with(|c| c.set(false));
356
357        let mut spins = 0u32;
358        while self.shared.active.load(Ordering::Acquire) != 0 {
359            spins += 1;
360            if spins.is_multiple_of(512) {
361                std::thread::yield_now();
362            } else {
363                std::hint::spin_loop();
364            }
365        }
366        let payload = self
367            .shared
368            .panic
369            .lock()
370            .unwrap_or_else(|e| e.into_inner())
371            .take();
372        // Released before re-raising: unwinding out of `run` while
373        // holding it would poison the submit mutex, and a poisoned
374        // submit mutex is a pool that refuses every future region.
375        drop(guard);
376        if let Some(payload) = payload {
377            resume_unwind(payload);
378        }
379        true
380    }
381}
382
383impl Drop for CpuPool {
384    fn drop(&mut self) {
385        {
386            let _parked = self.shared.parked.lock().unwrap_or_else(|e| e.into_inner());
387            self.shared.shutdown.store(true, Ordering::Release);
388            self.shared.epoch.fetch_add(1, Ordering::Release);
389        }
390        self.shared.cv.notify_all();
391        for handle in self.workers.drain(..) {
392            let _ = handle.join();
393        }
394    }
395}
396
397fn worker_loop(shared: &Shared) {
398    crate::threads::set_user_interactive_qos();
399    let mut seen = 0u64;
400    loop {
401        seen = shared.wait_for_job(seen);
402        if shared.shutdown.load(Ordering::Acquire) {
403            return;
404        }
405        // SAFETY: the `Acquire` on `epoch` inside `wait_for_job` pairs
406        // with the submitter's `Release`, so this read sees the fully
407        // written `Job` and nothing else is writing it (see the
408        // `unsafe impl Sync for Shared` above).
409        let job = unsafe { *shared.job.get() };
410        IN_REGION.with(|c| c.set(true));
411        shared.drain(job);
412        IN_REGION.with(|c| c.set(false));
413        shared.active.fetch_sub(1, Ordering::Release);
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use std::sync::atomic::AtomicU32;
421
422    /// Every task index runs exactly once, on any width, and `run`
423    /// does not return before they all have. Sabotage: change `drain`'s
424    /// `fetch_add` to `load` and this reports duplicates.
425    #[test]
426    fn every_task_runs_exactly_once() {
427        for threads in [1usize, 2, 4, 8] {
428            let pool = CpuPool::new(threads);
429            for n in [1usize, 3, 17, 1000] {
430                let counts: Vec<AtomicU32> = (0..n).map(|_| AtomicU32::new(0)).collect();
431                assert!(pool.run(n, &|task: usize| {
432                    counts[task].fetch_add(1, Ordering::Relaxed);
433                }));
434                for (task, count) in counts.iter().enumerate() {
435                    assert_eq!(
436                        count.load(Ordering::Relaxed),
437                        1,
438                        "task {task} of {n} on {threads} threads"
439                    );
440                }
441            }
442        }
443    }
444
445    /// A pool that never parks burns a core per worker, and a pool whose
446    /// wakeup can be lost hangs. Forcing the spin window to zero makes
447    /// every single region go through the park/notify path, so this test
448    /// exercises the branch a hot loop would never reach.
449    ///
450    /// Sabotage: move the `epoch.fetch_add` in `run` outside the
451    /// `parked` mutex and this deadlocks or takes `PARK_TIMEOUT` per
452    /// round instead of finishing promptly.
453    #[test]
454    fn regions_still_complete_when_every_worker_has_to_be_woken_from_a_park() {
455        // The spin window is process-wide and cached, so drive the park
456        // path by sleeping longer than it instead of changing it.
457        let pool = CpuPool::new(4);
458        for round in 0..20 {
459            std::thread::sleep(spin_window() * 3);
460            let seen = AtomicU32::new(0);
461            assert!(pool.run(64, &|_| {
462                seen.fetch_add(1, Ordering::Relaxed);
463            }));
464            assert_eq!(seen.load(Ordering::Relaxed), 64, "round {round}");
465        }
466    }
467
468    /// The submitter must not return while a worker can still touch the
469    /// closure. Each task writes through a borrow of a local, and the
470    /// local is read immediately after `run` returns; a pool that let a
471    /// worker outlive the region would be writing to a dead stack slot,
472    /// which miri and ASan see and a plain run usually does not -- so
473    /// this also asserts the *values*, which a late write corrupts.
474    #[test]
475    fn no_worker_touches_the_closure_after_run_returns() {
476        let pool = CpuPool::new(4);
477        for _ in 0..50 {
478            let cells: Vec<AtomicU32> = (0..256).map(|_| AtomicU32::new(0)).collect();
479            let local = 7u32;
480            assert!(pool.run(256, &|task: usize| {
481                cells[task].store(local + task as u32, Ordering::Relaxed);
482            }));
483            for (task, cell) in cells.iter().enumerate() {
484                assert_eq!(cell.load(Ordering::Relaxed), 7 + task as u32);
485            }
486        }
487    }
488
489    /// A task that opens its own region would deadlock against the
490    /// submit mutex. It runs inline instead, and still runs every task.
491    #[test]
492    fn a_nested_region_runs_inline_instead_of_deadlocking() {
493        let pool = CpuPool::new(4);
494        let inner_total = AtomicU32::new(0);
495        assert!(pool.run(8, &|_outer: usize| {
496            assert!(in_region());
497            assert!(pool.run(5, &|_inner: usize| {
498                inner_total.fetch_add(1, Ordering::Relaxed);
499            }));
500        }));
501        assert_eq!(inner_total.load(Ordering::Relaxed), 40);
502    }
503
504    /// A second submitter is refused rather than queued, so a caller can
505    /// fall back instead of blocking a whole request behind another.
506    #[test]
507    fn a_concurrent_submitter_is_refused_rather_than_serialized() {
508        let pool = Arc::new(CpuPool::new(2));
509        let refused = AtomicU32::new(0);
510        let started = Arc::new(AtomicBool::new(false));
511        std::thread::scope(|scope| {
512            let held = Arc::clone(&pool);
513            let started_w = Arc::clone(&started);
514            scope.spawn(move || {
515                held.run(1, &|_| {
516                    started_w.store(true, Ordering::Release);
517                    std::thread::sleep(Duration::from_millis(150));
518                });
519            });
520            while !started.load(Ordering::Acquire) {
521                std::hint::spin_loop();
522            }
523            if !pool.run(4, &|_| {}) {
524                refused.fetch_add(1, Ordering::Relaxed);
525            }
526        });
527        assert_eq!(
528            refused.load(Ordering::Relaxed),
529            1,
530            "the pool must report a busy region rather than block"
531        );
532    }
533
534    /// A panic on a worker must reach the submitter, not hang it. The
535    /// bug this guards is specific: an unwinding worker never decrements
536    /// `active`, so `run` spins forever.
537    ///
538    /// Sabotage: delete the `catch_unwind` in `drain` and this test
539    /// hangs instead of failing.
540    #[test]
541    fn a_panicking_task_is_re_raised_on_the_submitter() {
542        let pool = CpuPool::new(4);
543        let outcome = catch_unwind(AssertUnwindSafe(|| {
544            pool.run(64, &|task: usize| {
545                if task == 33 {
546                    panic!("ferrox test panic");
547                }
548            });
549        }));
550        assert!(outcome.is_err(), "the panic must not be swallowed");
551        // And the pool is still usable afterwards.
552        let ran = AtomicU32::new(0);
553        assert!(pool.run(10, &|_| {
554            ran.fetch_add(1, Ordering::Relaxed);
555        }));
556        assert_eq!(ran.load(Ordering::Relaxed), 10);
557    }
558
559    /// Dropping the pool joins every worker. If shutdown did not reach a
560    /// parked worker this test hangs, which is the failure mode of a
561    /// pool that outlives the data it was built from.
562    #[test]
563    fn dropping_the_pool_joins_every_worker() {
564        for threads in [1usize, 2, 6] {
565            let pool = CpuPool::new(threads);
566            assert!(pool.run(4, &|_| {}));
567            std::thread::sleep(spin_window() * 2);
568            drop(pool);
569        }
570    }
571
572    #[test]
573    fn a_single_thread_pool_runs_everything_on_the_submitter() {
574        let pool = CpuPool::new(1);
575        assert_eq!(pool.num_threads(), 1);
576        let here = std::thread::current().id();
577        let same = AtomicU32::new(0);
578        assert!(pool.run(32, &|_| {
579            if std::thread::current().id() == here {
580                same.fetch_add(1, Ordering::Relaxed);
581            }
582        }));
583        assert_eq!(same.load(Ordering::Relaxed), 32);
584    }
585}