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}