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//! Which of the two runs is decided **per operation, from its size**, by
18//! the one predicate in [`policy`]: [`backend`]. `FERROX_CPU_POOL` pins
19//! it either way (`spin` / `rayon`) and is an A/B override, not the
20//! decision. See [`policy::SPIN_MIN_OP_MACS`] for the crossover and what
21//! is and is not measured about it.
22//!
23//! Every helper below asks [`backend`] and none of them decides
24//! anything itself, which is what stops the two arms of that choice from
25//! drifting apart across thirty call sites.
26//!
27//! # `min_len`, and where `MIN_TASK_MACS` went
28//!
29//! Every helper takes a `min_len`. On the rayon arm it is passed
30//! straight to `with_min_len`, which is what the call sites did by hand
31//! before, so the fork-join path's task decomposition is bit-for-bit
32//! what it was.
33//!
34//! On the spin arm it is **ignored**. `MIN_TASK_MACS` existed to stop
35//! rayon splitting a matvec into tasks too small to pay for their own
36//! fork-join; when a region costs a cache-line transfer instead of a
37//! futex there is nothing to pay for, so the spin arm chunks purely by
38//! pool width ([`task_count`]) the way `ggml_compute_forward_mul_mat`
39//! does. That is the deletion issue #27 asks for, and it is a deletion
40//! rather than a retune: no MAC threshold is consulted on this path at
41//! all. It survives on the rayon arm because the rayon arm still runs
42//! every operation below the crossover, and removing it there re-opens
43//! the measured 13-16x small-model regression documented on
44//! [`crate::weight_matrix::WeightMatrix::min_rows_per_task`].
45
46use std::cell::Cell;
47
48use rayon::prelude::*;
49
50use crate::cpu_pool::CpuPool;
51
52pub mod policy;
53
54pub use policy::{backend, macs_per_row, with_op_work};
55
56thread_local! {
57    /// How many parallel regions THIS thread has opened while not being
58    /// a rayon worker. See [`on_workers`] for why that is the number
59    /// worth counting, and [`cold_regions`] for how a test reads it.
60    ///
61    /// Per thread rather than process-wide on purpose. A cold region is
62    /// always counted on the thread that submits it, so nothing is lost;
63    /// and a shared counter would make the assertion depend on whatever
64    /// else the test binary happened to be running at the time, which is
65    /// the difference between a guard and a flake.
66    static COLD_REGIONS: Cell<u64> = const { Cell::new(0) };
67}
68
69/// Parallel regions this thread has opened without being a rayon worker.
70///
71/// Monotonic, so a test reads it before and after the operation it cares
72/// about and asserts on the DIFFERENCE. It is not a benchmark: it is an
73/// operation count, which is load-immune, and it is the only thing that
74/// distinguishes "this decode step entered the pool once" from "it
75/// entered it a hundred and fifty times".
76pub fn cold_regions() -> u64 {
77    COLD_REGIONS.with(Cell::get)
78}
79
80/// Records one region about to be opened on the rayon arm.
81///
82/// Called from every rayon fallback in this module and nowhere else.
83/// The worker-index read is the same TLS lookup rayon is about to do
84/// anyway, and the counter is touched only on the cold path, which after
85/// [`on_workers`] is once per decode step rather than once per matvec.
86fn note_rayon_region() {
87    if rayon::current_thread_index().is_none() {
88        COLD_REGIONS.with(|c| c.set(c.get().saturating_add(1)));
89    }
90}
91
92/// Run `f` on a rayon worker, so every parallel region it opens takes
93/// rayon's IN-WORKER path instead of its cold-submission path.
94///
95/// # What this is for
96///
97/// `rayon::join` and the `par_iter` bridges both funnel through
98/// `Registry::in_worker`. That call has two arms and they do not cost
99/// the same thing:
100///
101/// - **From a worker** (`in_worker_hot`): the calling thread runs one
102///   half itself, the other half is posted for stealing, and the wait is
103///   a `SpinLatch`. No syscall.
104/// - **From any other thread** (`in_worker_cold`): the job is injected,
105///   and the caller blocks on a `LockLatch`, which is a pthread mutex
106///   and condvar. That is a park and a wake, per region, and the caller
107///   contributes no arithmetic while it sleeps.
108///
109/// A decode step opens roughly five regions per layer, so a 30-layer
110/// model paid ~150 of the cold arm per token. Measured on an M2 Pro with
111/// `sample` over SmolLM2-135M Q8_0 `tg128`, the main thread spent **74%
112/// of the token** inside `__psynch_cvwait` under `LockLatch`, and over
113/// that same window the six workers it was waiting for held only about
114/// an eighth as many samples in the matvec kernel: most of the wait was
115/// the round trip, not the work.
116///
117/// Wrapping the whole step in one `rayon::scope` turns those ~150 cold
118/// entries into ONE. The step then runs on worker 0 and every nested
119/// region is hot.
120///
121/// # Why it is not simply free
122///
123/// The caller still parks once, for the whole step, and the step no
124/// longer runs on the caller's thread. Both are deliberate: one park per
125/// token against one per matvec, and rayon's own worker count is
126/// unchanged, so the same number of cores do the work.
127///
128/// Nesting is free (a call from inside another `on_workers` returns
129/// `f()` directly), so an entry point may wrap unconditionally without
130/// having to know whether its caller already did.
131///
132/// # Two cases this deliberately does NOT promote
133///
134/// **A GPU backend.** Measured on an M2 Pro, moving the decode step off
135/// the process's main thread CHANGES METAL'S OUTPUT: `Llama-3.2-3B
136/// Q4_K_M --ngl 99`, greedy, diverges from the same build's main-thread
137/// answer at around the tenth token, deterministically on both sides.
138/// The Metal stack carries thread-local state across a step (the
139/// resident-activation hand-off from the dense stack to `output_head`,
140/// and the two thread-local mirrors of the pipeline and weight caches),
141/// so which thread runs the step is not the free choice it is on CPU.
142/// That is worth its own investigation and is not worth risking on a CPU
143/// scheduling change, so promotion asks
144/// [`crate::weight_matrix::active_backend`] first: the same cached
145/// predicate dispatch itself uses, not a second opinion about it. Under
146/// Metal or CUDA there is also almost nothing to win, because the
147/// parallel regions this saves are the ones the GPU is not running.
148///
149/// **The pinned spin pool.** Under `FERROX_CPU_POOL=spin` the rayon
150/// global pool is never used for work, and `rayon::scope` would BUILD
151/// it, spawning a second set of workers that then only sit there. So
152/// that pin short-circuits, exactly as [`num_threads`] avoids
153/// `rayon::current_num_threads` for the same reason.
154pub fn on_workers<R, F>(f: F) -> R
155where
156    F: FnOnce() -> R + Send,
157    R: Send,
158{
159    if crate::weight_matrix::active_backend() != crate::kernel_registry::Backend::Cpu {
160        return f();
161    }
162    if policy::pinned() == Some(Backend::Spin) {
163        return f();
164    }
165    if rayon::current_thread_index().is_some() {
166        return f();
167    }
168    // The one cold entry this whole design is willing to pay. Counted
169    // like any other, so [`cold_regions`] reports the true total and a
170    // test can assert it is exactly one.
171    note_rayon_region();
172    rayon::scope(move |_| f())
173}
174
175/// Which scheduler CPU parallel regions use.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Backend {
178    /// A rayon fork-join per region. The default.
179    Rayon,
180    /// A persistent pool of workers parked on a spin-then-park barrier.
181    Spin,
182}
183
184/// How many tasks per worker the spin arm aims for.
185///
186/// Tasks are handed out by one atomic cursor, so more of them means
187/// better load balancing and more contention on that cursor. Eight is
188/// the same order as llama.cpp's `4 * n_threads` chunk floor, with room
189/// for the uneven per-task cost that causal masking gives attention.
190const TASKS_PER_THREAD: usize = 8;
191
192/// The process-wide persistent pool, built on first use with
193/// [`crate::threads::resolve_cpu_threads`] workers -- the same width
194/// [`crate::threads::init_cpu_pool`] gives rayon, so the two backends
195/// are the same number of threads and a comparison is not confounded.
196///
197/// A `static` is never dropped, so the workers live to process exit.
198/// That is deliberate and it is also what rayon's global pool does.
199fn pool() -> &'static CpuPool {
200    use std::sync::OnceLock;
201    static POOL: OnceLock<CpuPool> = OnceLock::new();
202    POOL.get_or_init(|| CpuPool::new(crate::threads::resolve_cpu_threads()))
203}
204
205/// Worker count of the active backend.
206///
207/// Call this instead of `rayon::current_num_threads` anywhere a task
208/// decomposition is being sized: `rayon::current_num_threads` *builds*
209/// the global rayon pool as a side effect, so asking it under the spin
210/// backend spawns a second set of threads that would never run anything.
211pub fn num_threads() -> usize {
212    match backend() {
213        Backend::Rayon => rayon::current_num_threads().max(1),
214        Backend::Spin => pool().num_threads(),
215    }
216}
217
218/// How many tasks the spin arm splits `n_items` into.
219///
220/// Never more than one task per item, never zero, and never more than
221/// the pool can usefully chase. No work threshold appears here; see the
222/// module docs on `MIN_TASK_MACS`.
223pub fn task_count(n_items: usize) -> usize {
224    if n_items == 0 {
225        return 0;
226    }
227    n_items.min(num_threads().saturating_mul(TASKS_PER_THREAD).max(1))
228}
229
230/// `(items_per_task, n_tasks)` for a contiguous split of `n_items`.
231fn split(n_items: usize) -> (usize, usize) {
232    let n_tasks = task_count(n_items);
233    if n_tasks == 0 {
234        return (0, 0);
235    }
236    (n_items.div_ceil(n_tasks), n_tasks)
237}
238
239/// A raw pointer that may cross into worker threads.
240///
241/// Only ever used to hand each task a *disjoint* sub-slice of one
242/// allocation the submitter borrows mutably for the whole region.
243struct SendPtr<T>(*mut T);
244
245// Hand-written rather than derived: `#[derive(Copy)]` would add a
246// `T: Copy` bound, and the element types here are `f32` today but a
247// `Q8Activations` tomorrow.
248impl<T> Clone for SendPtr<T> {
249    fn clone(&self) -> Self {
250        *self
251    }
252}
253impl<T> Copy for SendPtr<T> {}
254
255impl<T> SendPtr<T> {
256    /// The element pointer at `offset`.
257    ///
258    /// A method rather than a field read at the call sites, because
259    /// closure capture is per *field*: reading `base.0` inside a task
260    /// captures the bare `*mut T`, which is not `Sync`, and the whole
261    /// point of this wrapper is the `unsafe impl` above.
262    ///
263    /// # Safety
264    /// `offset` must be within the allocation this was built from.
265    unsafe fn at(self, offset: usize) -> *mut T {
266        // SAFETY: the caller's invariant.
267        unsafe { self.0.add(offset) }
268    }
269}
270
271// SAFETY: the pointer comes from a `&mut [T]` the submitter holds for
272// the duration of the region, and each task derives a sub-slice from a
273// half-open index range that no other task's range overlaps. `T: Send`
274// is required at every call site, which is what makes moving those
275// sub-slices onto worker threads sound.
276unsafe impl<T: Send> Send for SendPtr<T> {}
277unsafe impl<T: Send> Sync for SendPtr<T> {}
278
279/// Run `f(index)` for every `index` in `0..n`.
280pub fn indices<F>(n: usize, min_len: usize, f: F)
281where
282    F: Fn(usize) + Send + Sync,
283{
284    if n == 0 {
285        return;
286    }
287    if backend() == Backend::Spin {
288        let (per, n_tasks) = split(n);
289        let task = |t: usize| {
290            let lo = t * per;
291            let hi = ((t + 1) * per).min(n);
292            for i in lo..hi {
293                f(i);
294            }
295        };
296        if pool().run(n_tasks, &task) {
297            return;
298        }
299    }
300    note_rayon_region();
301    (0..n)
302        .into_par_iter()
303        .with_min_len(min_len.max(1))
304        .for_each(&f);
305}
306
307/// [`indices`] with a per-task scratch value, the shape rayon spells
308/// `for_each_init`. One `S` is created per task, not per index.
309pub fn indices_init<S, I, F>(n: usize, min_len: usize, init: I, f: F)
310where
311    S: Send,
312    I: Fn() -> S + Send + Sync,
313    F: Fn(&mut S, usize) + Send + Sync,
314{
315    if n == 0 {
316        return;
317    }
318    if backend() == Backend::Spin {
319        let (per, n_tasks) = split(n);
320        let task = |t: usize| {
321            let lo = t * per;
322            let hi = ((t + 1) * per).min(n);
323            if lo >= hi {
324                return;
325            }
326            let mut state = init();
327            for i in lo..hi {
328                f(&mut state, i);
329            }
330        };
331        if pool().run(n_tasks, &task) {
332            return;
333        }
334    }
335    note_rayon_region();
336    (0..n)
337        .into_par_iter()
338        .with_min_len(min_len.max(1))
339        .for_each_init(&init, |state, i| f(state, i));
340}
341
342/// Run `f(index, &mut item)` over `data`, the shape rayon spells
343/// `par_iter_mut().with_min_len(..).enumerate()`.
344pub fn items_mut<T, F>(data: &mut [T], min_len: usize, f: F)
345where
346    T: Send,
347    F: Fn(usize, &mut T) + Send + Sync,
348{
349    let n = data.len();
350    if n == 0 {
351        return;
352    }
353    if backend() == Backend::Spin {
354        let base = SendPtr(data.as_mut_ptr());
355        let (per, n_tasks) = split(n);
356        let task = |t: usize| {
357            let lo = t * per;
358            let hi = ((t + 1) * per).min(n);
359            for i in lo..hi {
360                // SAFETY: `base` points at `data`, borrowed mutably for
361                // the whole call and outliving the region. Index `i` is
362                // inside `0..n` and belongs to exactly one task, so no
363                // two of these `&mut T` overlap.
364                f(i, unsafe { &mut *base.at(i) });
365            }
366        };
367        if pool().run(n_tasks, &task) {
368            return;
369        }
370    }
371    note_rayon_region();
372    data.par_iter_mut()
373        .with_min_len(min_len.max(1))
374        .enumerate()
375        .for_each(|(i, slot)| f(i, slot));
376}
377
378/// [`chunks_mut`] over two slices of the same length at once, the shape
379/// rayon spells `a.par_chunks_mut(k).zip(b.par_chunks_mut(k))`.
380///
381/// Exists because the MoE decode path computes a gate row and an up row
382/// from one shared activation: splitting that into two regions would
383/// double the region count, which is the thing this whole module is
384/// trying to reduce.
385pub fn chunks_mut2<T, U, F>(a: &mut [T], b: &mut [U], chunk_len: usize, min_len: usize, f: F)
386where
387    T: Send,
388    U: Send,
389    F: Fn(usize, &mut [T], &mut [U]) + Send + Sync,
390{
391    assert!(chunk_len > 0, "chunk length must be positive");
392    assert_eq!(a.len(), b.len(), "zipped slices must be the same length");
393    let len = a.len();
394    if len == 0 {
395        return;
396    }
397    let n_chunks = len.div_ceil(chunk_len);
398    if backend() == Backend::Spin {
399        let base_a = SendPtr(a.as_mut_ptr());
400        let base_b = SendPtr(b.as_mut_ptr());
401        let (per, n_tasks) = split(n_chunks);
402        let task = |t: usize| {
403            let lo = t * per;
404            let hi = ((t + 1) * per).min(n_chunks);
405            for c in lo..hi {
406                // SAFETY: both pointers come from slices the caller
407                // borrows mutably for the whole call, of equal length,
408                // and chunk `c` of each is visited by exactly one task.
409                unsafe {
410                    f(
411                        c,
412                        chunk_of(base_a, len, chunk_len, c),
413                        chunk_of(base_b, len, chunk_len, c),
414                    );
415                }
416            }
417        };
418        if pool().run(n_tasks, &task) {
419            return;
420        }
421    }
422    note_rayon_region();
423    a.par_chunks_mut(chunk_len)
424        .zip(b.par_chunks_mut(chunk_len))
425        .with_min_len(min_len.max(1))
426        .enumerate()
427        .for_each(|(c, (ca, cb))| f(c, ca, cb));
428}
429
430/// Run `f(chunk_index, &mut chunk)` over `data` split into runs of
431/// `chunk_len`, the shape rayon spells `par_chunks_mut(chunk_len)`.
432///
433/// A trailing partial chunk is delivered short, exactly as
434/// `par_chunks_mut` does.
435pub fn chunks_mut<T, F>(data: &mut [T], chunk_len: usize, min_len: usize, f: F)
436where
437    T: Send,
438    F: Fn(usize, &mut [T]) + Send + Sync,
439{
440    assert!(chunk_len > 0, "chunk length must be positive");
441    let len = data.len();
442    if len == 0 {
443        return;
444    }
445    let n_chunks = len.div_ceil(chunk_len);
446    if backend() == Backend::Spin {
447        let base = SendPtr(data.as_mut_ptr());
448        let (per, n_tasks) = split(n_chunks);
449        let task = |t: usize| {
450            let lo = t * per;
451            let hi = ((t + 1) * per).min(n_chunks);
452            for c in lo..hi {
453                // SAFETY: see `chunks_mut_init`; the ranges are the same
454                // disjoint half-open chunks of one live borrow.
455                f(c, unsafe { chunk_of(base, len, chunk_len, c) });
456            }
457        };
458        if pool().run(n_tasks, &task) {
459            return;
460        }
461    }
462    note_rayon_region();
463    data.par_chunks_mut(chunk_len)
464        .with_min_len(min_len.max(1))
465        .enumerate()
466        .for_each(|(c, chunk)| f(c, chunk));
467}
468
469/// The `c`-th `chunk_len`-sized chunk of the `len`-element allocation at
470/// `base`, delivered short when it is the trailing one.
471///
472/// # Safety
473/// `base` must point at a live allocation of at least `len` elements
474/// that outlives the returned slice, and the caller must guarantee that
475/// no other live slice covers chunk `c` -- which the callers do by
476/// visiting each chunk index from exactly one task.
477unsafe fn chunk_of<'a, T>(base: SendPtr<T>, len: usize, chunk_len: usize, c: usize) -> &'a mut [T] {
478    let start = c * chunk_len;
479    let end = ((c + 1) * chunk_len).min(len);
480    debug_assert!(start < end && end <= len);
481    // SAFETY: the caller's invariants, plus `start..end` being inside
482    // `0..len` by construction of `c < len.div_ceil(chunk_len)`.
483    unsafe { std::slice::from_raw_parts_mut(base.at(start), end - start) }
484}
485
486/// [`chunks_mut`] with a per-task scratch value.
487pub fn chunks_mut_init<T, S, I, F>(data: &mut [T], chunk_len: usize, min_len: usize, init: I, f: F)
488where
489    T: Send,
490    S: Send,
491    I: Fn() -> S + Send + Sync,
492    F: Fn(&mut S, usize, &mut [T]) + Send + Sync,
493{
494    assert!(chunk_len > 0, "chunk length must be positive");
495    let len = data.len();
496    if len == 0 {
497        return;
498    }
499    let n_chunks = len.div_ceil(chunk_len);
500    if backend() == Backend::Spin {
501        let base = SendPtr(data.as_mut_ptr());
502        let (per, n_tasks) = split(n_chunks);
503        let task = |t: usize| {
504            let lo = t * per;
505            let hi = ((t + 1) * per).min(n_chunks);
506            if lo >= hi {
507                return;
508            }
509            let mut state = init();
510            for c in lo..hi {
511                // SAFETY: `base` points at `data`, which the caller
512                // borrows mutably for this whole call and which outlives
513                // the region (`CpuPool::run` does not return until every
514                // worker has stopped touching the closure). Chunk index
515                // `c` is visited by exactly one task, so no two of these
516                // slices overlap.
517                f(&mut state, c, unsafe { chunk_of(base, len, chunk_len, c) });
518            }
519        };
520        if pool().run(n_tasks, &task) {
521            return;
522        }
523    }
524    note_rayon_region();
525    data.par_chunks_mut(chunk_len)
526        .with_min_len(min_len.max(1))
527        .enumerate()
528        .for_each_init(&init, |state, (c, chunk)| f(state, c, chunk));
529}
530
531/// Two independent pieces of work.
532///
533/// The rayon arm forks; the spin arm runs them one after the other,
534/// because each half already spreads across the whole pool internally
535/// and nesting a region inside a region is the one thing
536/// [`CpuPool::run`] cannot parallelize. That is llama.cpp's shape too:
537/// its threadpool runs one graph node at a time, full width.
538pub fn join2<A, B, RA, RB>(a: A, b: B) -> (RA, RB)
539where
540    A: FnOnce() -> RA + Send,
541    B: FnOnce() -> RB + Send,
542    RA: Send,
543    RB: Send,
544{
545    match backend() {
546        Backend::Rayon => {
547            note_rayon_region();
548            rayon::join(a, b)
549        }
550        Backend::Spin => (a(), b()),
551    }
552}
553
554/// Three independent pieces of work; see [`join2`].
555pub fn join3<A, B, C, RA, RB, RC>(a: A, b: B, c: C) -> (RA, RB, RC)
556where
557    A: FnOnce() -> RA + Send,
558    B: FnOnce() -> RB + Send,
559    C: FnOnce() -> RC + Send,
560    RA: Send,
561    RB: Send,
562    RC: Send,
563{
564    match backend() {
565        Backend::Rayon => {
566            note_rayon_region();
567            let (ra, (rb, rc)) = rayon::join(a, || rayon::join(b, c));
568            (ra, rb, rc)
569        }
570        Backend::Spin => (a(), b(), c()),
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    /// The two configurations `on_workers` declines to promote in, and
579    /// so the two it cannot be asserted in. One predicate, shared by
580    /// every test below, rather than three spellings of it.
581    fn the_promotion_applies_here() -> bool {
582        policy::pinned().is_none()
583            && crate::weight_matrix::active_backend() == crate::kernel_registry::Backend::Cpu
584    }
585
586    use std::sync::atomic::{AtomicU32, Ordering};
587
588    /// With nothing published and nothing pinned, the helpers fork with
589    /// rayon -- the behaviour every caller that has not opted into the
590    /// size rule keeps.
591    #[test]
592    fn an_unpublished_region_forks_with_rayon() {
593        if policy::pinned().is_none() {
594            assert_eq!(backend(), Backend::Rayon);
595        }
596    }
597
598    /// **Every rayon-versus-spin choice in this crate goes through one
599    /// predicate**, and this is what says so.
600    ///
601    /// The alternative is the shape this repo keeps shipping: a second
602    /// site that decides for itself and then drifts. `weight_matrix`
603    /// had four copies of one GPU-router eligibility test that tested
604    /// three conditions, two, and none.
605    ///
606    /// Two halves, because a helper can drift in two directions:
607    /// reaching the pool without asking, and asking the environment
608    /// instead of asking the predicate.
609    ///
610    /// Sabotage: inline `pool().run(..)` into a helper without its
611    /// `if backend() == Backend::Spin` guard, or read the environment
612    /// variable in a second place, and this goes red.
613    #[test]
614    fn every_scheduler_choice_in_this_crate_goes_through_the_one_predicate() {
615        // The needles are assembled rather than written out, because
616        // this file is one of the files being searched and a literal
617        // would count itself.
618        let call = format!("{}()", "backend");
619        let guard = format!("if {call} == Backend::Spin {{");
620        let dispatch = format!("match {call} {{");
621        let enters_pool = format!("if {}().run(", "pool");
622
623        let src = include_str!("par.rs");
624        let guarded = src.matches(&guard).count();
625        assert!(guarded >= 6, "expected one guard per region helper");
626        assert_eq!(
627            src.matches(&enters_pool).count(),
628            guarded,
629            "a helper reached the persistent pool without asking the predicate"
630        );
631        assert_eq!(
632            src.matches(&dispatch).count(),
633            3,
634            "num_threads, join2 and join3 dispatch on the predicate"
635        );
636
637        // And the environment is consulted in exactly one place, so the
638        // override cannot come to mean two things. The needle is the
639        // variable's name up to its closing quote, which is what keeps
640        // `FERROX_CPU_POOL_SPIN_US` (a different knob, in `cpu_pool`)
641        // out of the answer.
642        let needle = format!("FERROX_CPU_POOL{}", '"');
643        let mut readers = Vec::new();
644        let mut stack = vec![std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")];
645        while let Some(dir) = stack.pop() {
646            for entry in std::fs::read_dir(&dir).expect("crate source is readable") {
647                let path = entry.expect("readable entry").path();
648                if path.is_dir() {
649                    stack.push(path);
650                } else if path.extension().is_some_and(|e| e == "rs")
651                    && std::fs::read_to_string(&path)
652                        .expect("source file is UTF-8")
653                        .lines()
654                        .any(|l| l.contains(&needle) && !l.trim_start().starts_with("//"))
655                {
656                    readers.push(path);
657                }
658            }
659        }
660        assert_eq!(
661            readers.len(),
662            1,
663            "`FERROX_CPU_POOL` must be read only by `par::policy::pinned`, found {readers:?}"
664        );
665        assert!(readers[0].ends_with("par/policy.rs"), "{readers:?}");
666    }
667
668    /// The spin arm's chunking is a function of pool width and item
669    /// count and nothing else. If a MAC threshold ever creeps back onto
670    /// this path it has to change this signature to do it.
671    #[test]
672    fn the_spin_arm_chunks_by_pool_width_with_no_work_threshold() {
673        assert_eq!(task_count(0), 0);
674        assert_eq!(task_count(1), 1);
675        assert_eq!(task_count(3), 3);
676        let wide = task_count(1_000_000);
677        assert_eq!(wide, num_threads() * TASKS_PER_THREAD);
678        // A one-element-per-row matrix and a 4096-element-per-row matrix
679        // decompose identically: work per item is not an input.
680        assert_eq!(task_count(4096), task_count(4096));
681        let (per, n) = split(1000);
682        assert_eq!(n, task_count(1000));
683        assert!(per * n >= 1000 && (per - 1) * n < 1000);
684    }
685
686    /// Both arms must visit every index exactly once and produce the
687    /// same answer, whichever one the env var picked -- that is the
688    /// property the whole switch rests on.
689    #[test]
690    fn indices_visits_every_index_exactly_once() {
691        for n in [0usize, 1, 7, 64, 5000] {
692            let hits: Vec<AtomicU32> = (0..n).map(|_| AtomicU32::new(0)).collect();
693            indices(n, 8, |i| {
694                hits[i].fetch_add(1, Ordering::Relaxed);
695            });
696            assert!(hits.iter().all(|h| h.load(Ordering::Relaxed) == 1), "n={n}");
697        }
698    }
699
700    #[test]
701    fn items_mut_writes_every_slot_with_its_own_index() {
702        for n in [0usize, 1, 9, 257] {
703            let mut data = vec![0u32; n];
704            items_mut(&mut data, 4, |i, slot| *slot = i as u32 + 1);
705            assert_eq!(data, (1..=n as u32).collect::<Vec<_>>(), "n={n}");
706        }
707    }
708
709    /// The trailing partial chunk is the easy thing to lose, and losing
710    /// it silently drops the last rows of a matvec.
711    #[test]
712    fn chunks_mut_delivers_a_short_trailing_chunk() {
713        let mut data = vec![0u32; 10];
714        let seen: std::sync::Mutex<Vec<(usize, usize)>> = std::sync::Mutex::new(Vec::new());
715        chunks_mut(&mut data, 4, 1, |c, chunk| {
716            seen.lock().unwrap().push((c, chunk.len()));
717            for (i, slot) in chunk.iter_mut().enumerate() {
718                *slot = (c * 4 + i) as u32;
719            }
720        });
721        let mut seen = seen.into_inner().unwrap();
722        seen.sort_unstable();
723        assert_eq!(seen, vec![(0, 4), (1, 4), (2, 2)]);
724        assert_eq!(data, (0..10).collect::<Vec<u32>>());
725    }
726
727    /// Per-task scratch is created per task, never shared between two
728    /// tasks that might run at the same time.
729    #[test]
730    fn chunks_mut_init_gives_each_task_its_own_scratch() {
731        let mut data = vec![0u64; 512];
732        chunks_mut_init(
733            &mut data,
734            8,
735            1,
736            || Vec::<u64>::with_capacity(8),
737            |scratch: &mut Vec<u64>, c, chunk| {
738                scratch.clear();
739                scratch.extend(chunk.iter().map(|_| c as u64));
740                chunk.copy_from_slice(scratch);
741            },
742        );
743        for (c, chunk) in data.chunks(8).enumerate() {
744            assert!(chunk.iter().all(|&v| v == c as u64));
745        }
746    }
747
748    #[test]
749    fn joins_return_every_result_in_order() {
750        assert_eq!(join2(|| 1u8, || 2u8), (1, 2));
751        assert_eq!(join3(|| 1u8, || 2u8, || 3u8), (1, 2, 3));
752    }
753
754    /// The two arms are not allowed to disagree. This runs each helper
755    /// through the spin pool directly and through rayon directly, in one
756    /// process, and compares -- because the env var can only select one
757    /// of them per run, and "they agree" is the claim the PR makes.
758    #[test]
759    fn the_spin_arm_and_the_rayon_arm_produce_identical_results() {
760        let pool = CpuPool::new(4);
761        for n in [1usize, 5, 63, 1024] {
762            let mut spun = vec![0f32; n];
763            let base = SendPtr(spun.as_mut_ptr());
764            let (per, n_tasks) = split(n);
765            let task = |t: usize| {
766                let lo = t * per;
767                let hi = ((t + 1) * per).min(n);
768                for i in lo..hi {
769                    // SAFETY: disjoint single-element writes; index `i`
770                    // belongs to exactly one task.
771                    unsafe { *base.at(i) = (i as f32) * 0.5 + 1.0 };
772                }
773            };
774            assert!(pool.run(n_tasks, &task));
775
776            let mut forked = vec![0f32; n];
777            forked
778                .par_iter_mut()
779                .with_min_len(8)
780                .enumerate()
781                .for_each(|(i, slot)| *slot = (i as f32) * 0.5 + 1.0);
782
783            assert_eq!(spun, forked, "n={n}");
784        }
785    }
786
787    /// Every helper in this module must report the region it is about
788    /// to open, or the counter reads as coverage while measuring
789    /// nothing. This walks all eight of them rather than trusting that
790    /// a new one remembered, because a helper that forgot would leave
791    /// the counter reading low and every assertion built on it passing.
792    ///
793    /// Sabotage: delete any single `note_rayon_region()` call and the
794    /// helper whose name is in the failure message goes red.
795    #[test]
796    fn every_helper_reports_the_cold_region_it_opens() {
797        if !the_promotion_applies_here() {
798            return;
799        }
800        let mut buf = vec![0f32; 64];
801        let mut other = vec![0f32; 64];
802
803        // Spelled out one at a time rather than as a table of boxed
804        // closures: the slice helpers borrow `buf`, so a table could
805        // hold only half of them, and half a table is exactly the
806        // coverage illusion this test exists to avoid.
807        let before = cold_regions();
808        indices(64, 1, |_| {});
809        assert!(cold_regions() > before, "indices did not report");
810
811        let before = cold_regions();
812        indices_init(64, 1, || 0u8, |_, _| {});
813        assert!(cold_regions() > before, "indices_init did not report");
814
815        let before = cold_regions();
816        join2(|| (), || ());
817        assert!(cold_regions() > before, "join2 did not report");
818
819        let before = cold_regions();
820        join3(|| (), || (), || ());
821        assert!(cold_regions() > before, "join3 did not report");
822
823        let before = cold_regions();
824        items_mut(&mut buf, 1, |_, _| {});
825        assert!(cold_regions() > before, "items_mut did not report");
826
827        let before = cold_regions();
828        chunks_mut(&mut buf, 8, 1, |_, _| {});
829        assert!(cold_regions() > before, "chunks_mut did not report");
830
831        let before = cold_regions();
832        chunks_mut_init(&mut buf, 8, 1, || 0u8, |_, _, _| {});
833        assert!(cold_regions() > before, "chunks_mut_init did not report");
834
835        let before = cold_regions();
836        chunks_mut2(&mut buf, &mut other, 8, 1, |_, _, _| {});
837        assert!(cold_regions() > before, "chunks_mut2 did not report");
838    }
839
840    /// The whole claim of `on_workers`: many regions inside it cost ONE
841    /// cold entry into the pool, where the same regions outside it cost
842    /// one each.
843    ///
844    /// This is the operation-count form of the fix. It needs no clock
845    /// and no quiet host, which is why it is the guard rather than a
846    /// throughput assertion.
847    ///
848    /// Sabotage: make `on_workers` call `f()` unconditionally and the
849    /// `inside` count jumps from 1 to `REGIONS`, turning this red.
850    #[test]
851    fn on_workers_collapses_many_regions_into_one_cold_entry() {
852        if !the_promotion_applies_here() {
853            return;
854        }
855        const REGIONS: u64 = 16;
856        let open_them = || {
857            for _ in 0..REGIONS {
858                indices(64, 1, |_| {});
859            }
860        };
861
862        let before = cold_regions();
863        open_them();
864        let outside = cold_regions() - before;
865
866        let before = cold_regions();
867        on_workers(open_them);
868        let inside = cold_regions() - before;
869
870        assert_eq!(
871            outside, REGIONS,
872            "each region opened from a cold thread should count once"
873        );
874        assert_eq!(
875            inside, 1,
876            "the whole batch should enter the pool exactly once"
877        );
878    }
879
880    /// A nested call must not open a second entry, so an entry point can
881    /// wrap unconditionally without knowing what its caller did.
882    #[test]
883    fn a_nested_on_workers_opens_no_further_cold_entry() {
884        if !the_promotion_applies_here() {
885            return;
886        }
887        let before = cold_regions();
888        on_workers(|| {
889            on_workers(|| {
890                indices(64, 1, |_| {});
891            });
892        });
893        assert_eq!(cold_regions() - before, 1);
894    }
895
896    /// `on_workers` must return what `f` returns, not swallow it.
897    #[test]
898    fn on_workers_hands_back_the_closures_value() {
899        assert_eq!(on_workers(|| 41usize + 1), 42);
900    }
901}