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