Skip to main content

ferrox_core/
par.rs

1//! The single seam every CPU parallel region in this crate goes through.
2//!
3//! There used to be about fifty spellings of "run this over rows in
4//! parallel" scattered through [`crate::weight_matrix`] alone, each one
5//! an inline rayon iterator chain. That is the shape this repo has been
6//! burned by before: many copies of one decision, with nothing making
7//! them agree. Routing them all through the handful of functions below
8//! means the choice of *how* work is scheduled is made in one place.
9//!
10//! Which is exactly what issue #27 needs, because it wants that choice
11//! changed: rayon forks and joins per operation, per layer, per token,
12//! and llama.cpp instead hands work to a pool that is already awake.
13//! [`Backend::Spin`] is that pool ([`crate::cpu_pool`]).
14//!
15//! # The switch
16//!
17//! `FERROX_CPU_POOL`:
18//!
19//! - unset, `rayon`, `0`, `off` — **the default**: today's rayon
20//!   fork-join, expression for expression unchanged.
21//! - `spin`, `1`, `on`, `persistent` — the persistent pool.
22//!
23//! Read once, cached for the process. It defaults to rayon on purpose:
24//! nobody has measured the new path yet (an agent may not benchmark on a
25//! loaded host), so a before/after is one environment variable rather
26//! than two builds, and a revert is unsetting it.
27//!
28//! # `min_len`, and where `MIN_TASK_MACS` went
29//!
30//! Every helper takes a `min_len`. On the rayon arm it is passed
31//! straight to `with_min_len`, which is what the call sites did by hand
32//! before, so the default path's task decomposition is bit-for-bit what
33//! it was.
34//!
35//! On the spin arm it is **ignored**. `MIN_TASK_MACS` existed to stop
36//! rayon splitting a matvec into tasks too small to pay for their own
37//! fork-join; when a region costs a cache-line transfer instead of a
38//! futex there is nothing to pay for, so the spin arm chunks purely by
39//! pool width ([`task_count`]) the way `ggml_compute_forward_mul_mat`
40//! does. That is the deletion issue #27 asks for, and it is a deletion
41//! rather than a retune: no MAC threshold is consulted on this path at
42//! all. It survives on the rayon arm because the rayon arm is still the
43//! default and removing it there re-opens the measured 13-16x
44//! small-model regression documented on
45//! [`crate::weight_matrix::WeightMatrix::min_rows_per_task`].
46
47use rayon::prelude::*;
48
49use crate::cpu_pool::CpuPool;
50
51/// Which scheduler CPU parallel regions use.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Backend {
54    /// A rayon fork-join per region. The default.
55    Rayon,
56    /// A persistent pool of workers parked on a spin-then-park barrier.
57    Spin,
58}
59
60/// How many tasks per worker the spin arm aims for.
61///
62/// Tasks are handed out by one atomic cursor, so more of them means
63/// better load balancing and more contention on that cursor. Eight is
64/// the same order as llama.cpp's `4 * n_threads` chunk floor, with room
65/// for the uneven per-task cost that causal masking gives attention.
66const TASKS_PER_THREAD: usize = 8;
67
68/// The backend this process uses, from `FERROX_CPU_POOL`. Cached.
69pub fn backend() -> Backend {
70    use std::sync::OnceLock;
71    static BACKEND: OnceLock<Backend> = OnceLock::new();
72    *BACKEND.get_or_init(|| {
73        match std::env::var("FERROX_CPU_POOL")
74            .ok()
75            .map(|v| v.trim().to_ascii_lowercase())
76            .as_deref()
77        {
78            Some("spin") | Some("persistent") | Some("1") | Some("on") | Some("true") => {
79                Backend::Spin
80            }
81            _ => Backend::Rayon,
82        }
83    })
84}
85
86/// The process-wide persistent pool, built on first use with
87/// [`crate::threads::resolve_cpu_threads`] workers -- the same width
88/// [`crate::threads::init_cpu_pool`] gives rayon, so the two backends
89/// are the same number of threads and a comparison is not confounded.
90///
91/// A `static` is never dropped, so the workers live to process exit.
92/// That is deliberate and it is also what rayon's global pool does.
93fn pool() -> &'static CpuPool {
94    use std::sync::OnceLock;
95    static POOL: OnceLock<CpuPool> = OnceLock::new();
96    POOL.get_or_init(|| CpuPool::new(crate::threads::resolve_cpu_threads()))
97}
98
99/// Worker count of the active backend.
100///
101/// Call this instead of `rayon::current_num_threads` anywhere a task
102/// decomposition is being sized: `rayon::current_num_threads` *builds*
103/// the global rayon pool as a side effect, so asking it under the spin
104/// backend spawns a second set of threads that would never run anything.
105pub fn num_threads() -> usize {
106    match backend() {
107        Backend::Rayon => rayon::current_num_threads().max(1),
108        Backend::Spin => pool().num_threads(),
109    }
110}
111
112/// How many tasks the spin arm splits `n_items` into.
113///
114/// Never more than one task per item, never zero, and never more than
115/// the pool can usefully chase. No work threshold appears here; see the
116/// module docs on `MIN_TASK_MACS`.
117pub fn task_count(n_items: usize) -> usize {
118    if n_items == 0 {
119        return 0;
120    }
121    n_items.min(num_threads().saturating_mul(TASKS_PER_THREAD).max(1))
122}
123
124/// `(items_per_task, n_tasks)` for a contiguous split of `n_items`.
125fn split(n_items: usize) -> (usize, usize) {
126    let n_tasks = task_count(n_items);
127    if n_tasks == 0 {
128        return (0, 0);
129    }
130    (n_items.div_ceil(n_tasks), n_tasks)
131}
132
133/// A raw pointer that may cross into worker threads.
134///
135/// Only ever used to hand each task a *disjoint* sub-slice of one
136/// allocation the submitter borrows mutably for the whole region.
137struct SendPtr<T>(*mut T);
138
139// Hand-written rather than derived: `#[derive(Copy)]` would add a
140// `T: Copy` bound, and the element types here are `f32` today but a
141// `Q8Activations` tomorrow.
142impl<T> Clone for SendPtr<T> {
143    fn clone(&self) -> Self {
144        *self
145    }
146}
147impl<T> Copy for SendPtr<T> {}
148
149impl<T> SendPtr<T> {
150    /// The element pointer at `offset`.
151    ///
152    /// A method rather than a field read at the call sites, because
153    /// closure capture is per *field*: reading `base.0` inside a task
154    /// captures the bare `*mut T`, which is not `Sync`, and the whole
155    /// point of this wrapper is the `unsafe impl` above.
156    ///
157    /// # Safety
158    /// `offset` must be within the allocation this was built from.
159    unsafe fn at(self, offset: usize) -> *mut T {
160        // SAFETY: the caller's invariant.
161        unsafe { self.0.add(offset) }
162    }
163}
164
165// SAFETY: the pointer comes from a `&mut [T]` the submitter holds for
166// the duration of the region, and each task derives a sub-slice from a
167// half-open index range that no other task's range overlaps. `T: Send`
168// is required at every call site, which is what makes moving those
169// sub-slices onto worker threads sound.
170unsafe impl<T: Send> Send for SendPtr<T> {}
171unsafe impl<T: Send> Sync for SendPtr<T> {}
172
173/// Run `f(index)` for every `index` in `0..n`.
174pub fn indices<F>(n: usize, min_len: usize, f: F)
175where
176    F: Fn(usize) + Send + Sync,
177{
178    if n == 0 {
179        return;
180    }
181    if backend() == Backend::Spin {
182        let (per, n_tasks) = split(n);
183        let task = |t: usize| {
184            let lo = t * per;
185            let hi = ((t + 1) * per).min(n);
186            for i in lo..hi {
187                f(i);
188            }
189        };
190        if pool().run(n_tasks, &task) {
191            return;
192        }
193    }
194    (0..n)
195        .into_par_iter()
196        .with_min_len(min_len.max(1))
197        .for_each(&f);
198}
199
200/// [`indices`] with a per-task scratch value, the shape rayon spells
201/// `for_each_init`. One `S` is created per task, not per index.
202pub fn indices_init<S, I, F>(n: usize, min_len: usize, init: I, f: F)
203where
204    S: Send,
205    I: Fn() -> S + Send + Sync,
206    F: Fn(&mut S, usize) + Send + Sync,
207{
208    if n == 0 {
209        return;
210    }
211    if backend() == Backend::Spin {
212        let (per, n_tasks) = split(n);
213        let task = |t: usize| {
214            let lo = t * per;
215            let hi = ((t + 1) * per).min(n);
216            if lo >= hi {
217                return;
218            }
219            let mut state = init();
220            for i in lo..hi {
221                f(&mut state, i);
222            }
223        };
224        if pool().run(n_tasks, &task) {
225            return;
226        }
227    }
228    (0..n)
229        .into_par_iter()
230        .with_min_len(min_len.max(1))
231        .for_each_init(&init, |state, i| f(state, i));
232}
233
234/// Run `f(index, &mut item)` over `data`, the shape rayon spells
235/// `par_iter_mut().with_min_len(..).enumerate()`.
236pub fn items_mut<T, F>(data: &mut [T], min_len: usize, f: F)
237where
238    T: Send,
239    F: Fn(usize, &mut T) + Send + Sync,
240{
241    let n = data.len();
242    if n == 0 {
243        return;
244    }
245    if backend() == Backend::Spin {
246        let base = SendPtr(data.as_mut_ptr());
247        let (per, n_tasks) = split(n);
248        let task = |t: usize| {
249            let lo = t * per;
250            let hi = ((t + 1) * per).min(n);
251            for i in lo..hi {
252                // SAFETY: `base` points at `data`, borrowed mutably for
253                // the whole call and outliving the region. Index `i` is
254                // inside `0..n` and belongs to exactly one task, so no
255                // two of these `&mut T` overlap.
256                f(i, unsafe { &mut *base.at(i) });
257            }
258        };
259        if pool().run(n_tasks, &task) {
260            return;
261        }
262    }
263    data.par_iter_mut()
264        .with_min_len(min_len.max(1))
265        .enumerate()
266        .for_each(|(i, slot)| f(i, slot));
267}
268
269/// [`chunks_mut`] over two slices of the same length at once, the shape
270/// rayon spells `a.par_chunks_mut(k).zip(b.par_chunks_mut(k))`.
271///
272/// Exists because the MoE decode path computes a gate row and an up row
273/// from one shared activation: splitting that into two regions would
274/// double the region count, which is the thing this whole module is
275/// trying to reduce.
276pub fn chunks_mut2<T, U, F>(a: &mut [T], b: &mut [U], chunk_len: usize, min_len: usize, f: F)
277where
278    T: Send,
279    U: Send,
280    F: Fn(usize, &mut [T], &mut [U]) + Send + Sync,
281{
282    assert!(chunk_len > 0, "chunk length must be positive");
283    assert_eq!(a.len(), b.len(), "zipped slices must be the same length");
284    let len = a.len();
285    if len == 0 {
286        return;
287    }
288    let n_chunks = len.div_ceil(chunk_len);
289    if backend() == Backend::Spin {
290        let base_a = SendPtr(a.as_mut_ptr());
291        let base_b = SendPtr(b.as_mut_ptr());
292        let (per, n_tasks) = split(n_chunks);
293        let task = |t: usize| {
294            let lo = t * per;
295            let hi = ((t + 1) * per).min(n_chunks);
296            for c in lo..hi {
297                // SAFETY: both pointers come from slices the caller
298                // borrows mutably for the whole call, of equal length,
299                // and chunk `c` of each is visited by exactly one task.
300                unsafe {
301                    f(
302                        c,
303                        chunk_of(base_a, len, chunk_len, c),
304                        chunk_of(base_b, len, chunk_len, c),
305                    );
306                }
307            }
308        };
309        if pool().run(n_tasks, &task) {
310            return;
311        }
312    }
313    a.par_chunks_mut(chunk_len)
314        .zip(b.par_chunks_mut(chunk_len))
315        .with_min_len(min_len.max(1))
316        .enumerate()
317        .for_each(|(c, (ca, cb))| f(c, ca, cb));
318}
319
320/// Run `f(chunk_index, &mut chunk)` over `data` split into runs of
321/// `chunk_len`, the shape rayon spells `par_chunks_mut(chunk_len)`.
322///
323/// A trailing partial chunk is delivered short, exactly as
324/// `par_chunks_mut` does.
325pub fn chunks_mut<T, F>(data: &mut [T], chunk_len: usize, min_len: usize, f: F)
326where
327    T: Send,
328    F: Fn(usize, &mut [T]) + Send + Sync,
329{
330    assert!(chunk_len > 0, "chunk length must be positive");
331    let len = data.len();
332    if len == 0 {
333        return;
334    }
335    let n_chunks = len.div_ceil(chunk_len);
336    if backend() == Backend::Spin {
337        let base = SendPtr(data.as_mut_ptr());
338        let (per, n_tasks) = split(n_chunks);
339        let task = |t: usize| {
340            let lo = t * per;
341            let hi = ((t + 1) * per).min(n_chunks);
342            for c in lo..hi {
343                // SAFETY: see `chunks_mut_init`; the ranges are the same
344                // disjoint half-open chunks of one live borrow.
345                f(c, unsafe { chunk_of(base, len, chunk_len, c) });
346            }
347        };
348        if pool().run(n_tasks, &task) {
349            return;
350        }
351    }
352    data.par_chunks_mut(chunk_len)
353        .with_min_len(min_len.max(1))
354        .enumerate()
355        .for_each(|(c, chunk)| f(c, chunk));
356}
357
358/// The `c`-th `chunk_len`-sized chunk of the `len`-element allocation at
359/// `base`, delivered short when it is the trailing one.
360///
361/// # Safety
362/// `base` must point at a live allocation of at least `len` elements
363/// that outlives the returned slice, and the caller must guarantee that
364/// no other live slice covers chunk `c` -- which the callers do by
365/// visiting each chunk index from exactly one task.
366unsafe fn chunk_of<'a, T>(base: SendPtr<T>, len: usize, chunk_len: usize, c: usize) -> &'a mut [T] {
367    let start = c * chunk_len;
368    let end = ((c + 1) * chunk_len).min(len);
369    debug_assert!(start < end && end <= len);
370    // SAFETY: the caller's invariants, plus `start..end` being inside
371    // `0..len` by construction of `c < len.div_ceil(chunk_len)`.
372    unsafe { std::slice::from_raw_parts_mut(base.at(start), end - start) }
373}
374
375/// [`chunks_mut`] with a per-task scratch value.
376pub fn chunks_mut_init<T, S, I, F>(data: &mut [T], chunk_len: usize, min_len: usize, init: I, f: F)
377where
378    T: Send,
379    S: Send,
380    I: Fn() -> S + Send + Sync,
381    F: Fn(&mut S, usize, &mut [T]) + Send + Sync,
382{
383    assert!(chunk_len > 0, "chunk length must be positive");
384    let len = data.len();
385    if len == 0 {
386        return;
387    }
388    let n_chunks = len.div_ceil(chunk_len);
389    if backend() == Backend::Spin {
390        let base = SendPtr(data.as_mut_ptr());
391        let (per, n_tasks) = split(n_chunks);
392        let task = |t: usize| {
393            let lo = t * per;
394            let hi = ((t + 1) * per).min(n_chunks);
395            if lo >= hi {
396                return;
397            }
398            let mut state = init();
399            for c in lo..hi {
400                // SAFETY: `base` points at `data`, which the caller
401                // borrows mutably for this whole call and which outlives
402                // the region (`CpuPool::run` does not return until every
403                // worker has stopped touching the closure). Chunk index
404                // `c` is visited by exactly one task, so no two of these
405                // slices overlap.
406                f(&mut state, c, unsafe { chunk_of(base, len, chunk_len, c) });
407            }
408        };
409        if pool().run(n_tasks, &task) {
410            return;
411        }
412    }
413    data.par_chunks_mut(chunk_len)
414        .with_min_len(min_len.max(1))
415        .enumerate()
416        .for_each_init(&init, |state, (c, chunk)| f(state, c, chunk));
417}
418
419/// Two independent pieces of work.
420///
421/// The rayon arm forks; the spin arm runs them one after the other,
422/// because each half already spreads across the whole pool internally
423/// and nesting a region inside a region is the one thing
424/// [`CpuPool::run`] cannot parallelize. That is llama.cpp's shape too:
425/// its threadpool runs one graph node at a time, full width.
426pub fn join2<A, B, RA, RB>(a: A, b: B) -> (RA, RB)
427where
428    A: FnOnce() -> RA + Send,
429    B: FnOnce() -> RB + Send,
430    RA: Send,
431    RB: Send,
432{
433    match backend() {
434        Backend::Rayon => rayon::join(a, b),
435        Backend::Spin => (a(), b()),
436    }
437}
438
439/// Three independent pieces of work; see [`join2`].
440pub fn join3<A, B, C, RA, RB, RC>(a: A, b: B, c: C) -> (RA, RB, RC)
441where
442    A: FnOnce() -> RA + Send,
443    B: FnOnce() -> RB + Send,
444    C: FnOnce() -> RC + Send,
445    RA: Send,
446    RB: Send,
447    RC: Send,
448{
449    match backend() {
450        Backend::Rayon => {
451            let (ra, (rb, rc)) = rayon::join(a, || rayon::join(b, c));
452            (ra, rb, rc)
453        }
454        Backend::Spin => (a(), b(), c()),
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::sync::atomic::{AtomicU32, Ordering};
462
463    /// The env switch decides, and it decides once. Anything else and a
464    /// before/after measurement is measuring two different processes.
465    #[test]
466    fn the_backend_is_read_from_one_env_var_and_defaults_to_rayon() {
467        // The process-wide cache means this can only assert the mapping
468        // when nothing has pinned it, which is the CI case.
469        if std::env::var_os("FERROX_CPU_POOL").is_none() {
470            assert_eq!(backend(), Backend::Rayon);
471        }
472    }
473
474    /// The spin arm's chunking is a function of pool width and item
475    /// count and nothing else. If a MAC threshold ever creeps back onto
476    /// this path it has to change this signature to do it.
477    #[test]
478    fn the_spin_arm_chunks_by_pool_width_with_no_work_threshold() {
479        assert_eq!(task_count(0), 0);
480        assert_eq!(task_count(1), 1);
481        assert_eq!(task_count(3), 3);
482        let wide = task_count(1_000_000);
483        assert_eq!(wide, num_threads() * TASKS_PER_THREAD);
484        // A one-element-per-row matrix and a 4096-element-per-row matrix
485        // decompose identically: work per item is not an input.
486        assert_eq!(task_count(4096), task_count(4096));
487        let (per, n) = split(1000);
488        assert_eq!(n, task_count(1000));
489        assert!(per * n >= 1000 && (per - 1) * n < 1000);
490    }
491
492    /// Both arms must visit every index exactly once and produce the
493    /// same answer, whichever one the env var picked -- that is the
494    /// property the whole switch rests on.
495    #[test]
496    fn indices_visits_every_index_exactly_once() {
497        for n in [0usize, 1, 7, 64, 5000] {
498            let hits: Vec<AtomicU32> = (0..n).map(|_| AtomicU32::new(0)).collect();
499            indices(n, 8, |i| {
500                hits[i].fetch_add(1, Ordering::Relaxed);
501            });
502            assert!(hits.iter().all(|h| h.load(Ordering::Relaxed) == 1), "n={n}");
503        }
504    }
505
506    #[test]
507    fn items_mut_writes_every_slot_with_its_own_index() {
508        for n in [0usize, 1, 9, 257] {
509            let mut data = vec![0u32; n];
510            items_mut(&mut data, 4, |i, slot| *slot = i as u32 + 1);
511            assert_eq!(data, (1..=n as u32).collect::<Vec<_>>(), "n={n}");
512        }
513    }
514
515    /// The trailing partial chunk is the easy thing to lose, and losing
516    /// it silently drops the last rows of a matvec.
517    #[test]
518    fn chunks_mut_delivers_a_short_trailing_chunk() {
519        let mut data = vec![0u32; 10];
520        let seen: std::sync::Mutex<Vec<(usize, usize)>> = std::sync::Mutex::new(Vec::new());
521        chunks_mut(&mut data, 4, 1, |c, chunk| {
522            seen.lock().unwrap().push((c, chunk.len()));
523            for (i, slot) in chunk.iter_mut().enumerate() {
524                *slot = (c * 4 + i) as u32;
525            }
526        });
527        let mut seen = seen.into_inner().unwrap();
528        seen.sort_unstable();
529        assert_eq!(seen, vec![(0, 4), (1, 4), (2, 2)]);
530        assert_eq!(data, (0..10).collect::<Vec<u32>>());
531    }
532
533    /// Per-task scratch is created per task, never shared between two
534    /// tasks that might run at the same time.
535    #[test]
536    fn chunks_mut_init_gives_each_task_its_own_scratch() {
537        let mut data = vec![0u64; 512];
538        chunks_mut_init(
539            &mut data,
540            8,
541            1,
542            || Vec::<u64>::with_capacity(8),
543            |scratch: &mut Vec<u64>, c, chunk| {
544                scratch.clear();
545                scratch.extend(chunk.iter().map(|_| c as u64));
546                chunk.copy_from_slice(scratch);
547            },
548        );
549        for (c, chunk) in data.chunks(8).enumerate() {
550            assert!(chunk.iter().all(|&v| v == c as u64));
551        }
552    }
553
554    #[test]
555    fn joins_return_every_result_in_order() {
556        assert_eq!(join2(|| 1u8, || 2u8), (1, 2));
557        assert_eq!(join3(|| 1u8, || 2u8, || 3u8), (1, 2, 3));
558    }
559
560    /// The two arms are not allowed to disagree. This runs each helper
561    /// through the spin pool directly and through rayon directly, in one
562    /// process, and compares -- because the env var can only select one
563    /// of them per run, and "they agree" is the claim the PR makes.
564    #[test]
565    fn the_spin_arm_and_the_rayon_arm_produce_identical_results() {
566        let pool = CpuPool::new(4);
567        for n in [1usize, 5, 63, 1024] {
568            let mut spun = vec![0f32; n];
569            let base = SendPtr(spun.as_mut_ptr());
570            let (per, n_tasks) = split(n);
571            let task = |t: usize| {
572                let lo = t * per;
573                let hi = ((t + 1) * per).min(n);
574                for i in lo..hi {
575                    // SAFETY: disjoint single-element writes; index `i`
576                    // belongs to exactly one task.
577                    unsafe { *base.at(i) = (i as f32) * 0.5 + 1.0 };
578                }
579            };
580            assert!(pool.run(n_tasks, &task));
581
582            let mut forked = vec![0f32; n];
583            forked
584                .par_iter_mut()
585                .with_min_len(8)
586                .enumerate()
587                .for_each(|(i, slot)| *slot = (i as f32) * 0.5 + 1.0);
588
589            assert_eq!(spun, forked, "n={n}");
590        }
591    }
592}