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 rayon::prelude::*;
47
48use crate::cpu_pool::CpuPool;
49
50pub mod policy;
51
52pub use policy::{backend, macs_per_row, with_op_work};
53
54/// Which scheduler CPU parallel regions use.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum Backend {
57    /// A rayon fork-join per region. The default.
58    Rayon,
59    /// A persistent pool of workers parked on a spin-then-park barrier.
60    Spin,
61}
62
63/// How many tasks per worker the spin arm aims for.
64///
65/// Tasks are handed out by one atomic cursor, so more of them means
66/// better load balancing and more contention on that cursor. Eight is
67/// the same order as llama.cpp's `4 * n_threads` chunk floor, with room
68/// for the uneven per-task cost that causal masking gives attention.
69const TASKS_PER_THREAD: usize = 8;
70
71/// The process-wide persistent pool, built on first use with
72/// [`crate::threads::resolve_cpu_threads`] workers -- the same width
73/// [`crate::threads::init_cpu_pool`] gives rayon, so the two backends
74/// are the same number of threads and a comparison is not confounded.
75///
76/// A `static` is never dropped, so the workers live to process exit.
77/// That is deliberate and it is also what rayon's global pool does.
78fn pool() -> &'static CpuPool {
79    use std::sync::OnceLock;
80    static POOL: OnceLock<CpuPool> = OnceLock::new();
81    POOL.get_or_init(|| CpuPool::new(crate::threads::resolve_cpu_threads()))
82}
83
84/// Worker count of the active backend.
85///
86/// Call this instead of `rayon::current_num_threads` anywhere a task
87/// decomposition is being sized: `rayon::current_num_threads` *builds*
88/// the global rayon pool as a side effect, so asking it under the spin
89/// backend spawns a second set of threads that would never run anything.
90pub fn num_threads() -> usize {
91    match backend() {
92        Backend::Rayon => rayon::current_num_threads().max(1),
93        Backend::Spin => pool().num_threads(),
94    }
95}
96
97/// How many tasks the spin arm splits `n_items` into.
98///
99/// Never more than one task per item, never zero, and never more than
100/// the pool can usefully chase. No work threshold appears here; see the
101/// module docs on `MIN_TASK_MACS`.
102pub fn task_count(n_items: usize) -> usize {
103    if n_items == 0 {
104        return 0;
105    }
106    n_items.min(num_threads().saturating_mul(TASKS_PER_THREAD).max(1))
107}
108
109/// `(items_per_task, n_tasks)` for a contiguous split of `n_items`.
110fn split(n_items: usize) -> (usize, usize) {
111    let n_tasks = task_count(n_items);
112    if n_tasks == 0 {
113        return (0, 0);
114    }
115    (n_items.div_ceil(n_tasks), n_tasks)
116}
117
118/// A raw pointer that may cross into worker threads.
119///
120/// Only ever used to hand each task a *disjoint* sub-slice of one
121/// allocation the submitter borrows mutably for the whole region.
122struct SendPtr<T>(*mut T);
123
124// Hand-written rather than derived: `#[derive(Copy)]` would add a
125// `T: Copy` bound, and the element types here are `f32` today but a
126// `Q8Activations` tomorrow.
127impl<T> Clone for SendPtr<T> {
128    fn clone(&self) -> Self {
129        *self
130    }
131}
132impl<T> Copy for SendPtr<T> {}
133
134impl<T> SendPtr<T> {
135    /// The element pointer at `offset`.
136    ///
137    /// A method rather than a field read at the call sites, because
138    /// closure capture is per *field*: reading `base.0` inside a task
139    /// captures the bare `*mut T`, which is not `Sync`, and the whole
140    /// point of this wrapper is the `unsafe impl` above.
141    ///
142    /// # Safety
143    /// `offset` must be within the allocation this was built from.
144    unsafe fn at(self, offset: usize) -> *mut T {
145        // SAFETY: the caller's invariant.
146        unsafe { self.0.add(offset) }
147    }
148}
149
150// SAFETY: the pointer comes from a `&mut [T]` the submitter holds for
151// the duration of the region, and each task derives a sub-slice from a
152// half-open index range that no other task's range overlaps. `T: Send`
153// is required at every call site, which is what makes moving those
154// sub-slices onto worker threads sound.
155unsafe impl<T: Send> Send for SendPtr<T> {}
156unsafe impl<T: Send> Sync for SendPtr<T> {}
157
158/// Run `f(index)` for every `index` in `0..n`.
159pub fn indices<F>(n: usize, min_len: usize, f: F)
160where
161    F: Fn(usize) + Send + Sync,
162{
163    if n == 0 {
164        return;
165    }
166    if backend() == Backend::Spin {
167        let (per, n_tasks) = split(n);
168        let task = |t: usize| {
169            let lo = t * per;
170            let hi = ((t + 1) * per).min(n);
171            for i in lo..hi {
172                f(i);
173            }
174        };
175        if pool().run(n_tasks, &task) {
176            return;
177        }
178    }
179    (0..n)
180        .into_par_iter()
181        .with_min_len(min_len.max(1))
182        .for_each(&f);
183}
184
185/// [`indices`] with a per-task scratch value, the shape rayon spells
186/// `for_each_init`. One `S` is created per task, not per index.
187pub fn indices_init<S, I, F>(n: usize, min_len: usize, init: I, f: F)
188where
189    S: Send,
190    I: Fn() -> S + Send + Sync,
191    F: Fn(&mut S, usize) + Send + Sync,
192{
193    if n == 0 {
194        return;
195    }
196    if backend() == Backend::Spin {
197        let (per, n_tasks) = split(n);
198        let task = |t: usize| {
199            let lo = t * per;
200            let hi = ((t + 1) * per).min(n);
201            if lo >= hi {
202                return;
203            }
204            let mut state = init();
205            for i in lo..hi {
206                f(&mut state, i);
207            }
208        };
209        if pool().run(n_tasks, &task) {
210            return;
211        }
212    }
213    (0..n)
214        .into_par_iter()
215        .with_min_len(min_len.max(1))
216        .for_each_init(&init, |state, i| f(state, i));
217}
218
219/// Run `f(index, &mut item)` over `data`, the shape rayon spells
220/// `par_iter_mut().with_min_len(..).enumerate()`.
221pub fn items_mut<T, F>(data: &mut [T], min_len: usize, f: F)
222where
223    T: Send,
224    F: Fn(usize, &mut T) + Send + Sync,
225{
226    let n = data.len();
227    if n == 0 {
228        return;
229    }
230    if backend() == Backend::Spin {
231        let base = SendPtr(data.as_mut_ptr());
232        let (per, n_tasks) = split(n);
233        let task = |t: usize| {
234            let lo = t * per;
235            let hi = ((t + 1) * per).min(n);
236            for i in lo..hi {
237                // SAFETY: `base` points at `data`, borrowed mutably for
238                // the whole call and outliving the region. Index `i` is
239                // inside `0..n` and belongs to exactly one task, so no
240                // two of these `&mut T` overlap.
241                f(i, unsafe { &mut *base.at(i) });
242            }
243        };
244        if pool().run(n_tasks, &task) {
245            return;
246        }
247    }
248    data.par_iter_mut()
249        .with_min_len(min_len.max(1))
250        .enumerate()
251        .for_each(|(i, slot)| f(i, slot));
252}
253
254/// [`chunks_mut`] over two slices of the same length at once, the shape
255/// rayon spells `a.par_chunks_mut(k).zip(b.par_chunks_mut(k))`.
256///
257/// Exists because the MoE decode path computes a gate row and an up row
258/// from one shared activation: splitting that into two regions would
259/// double the region count, which is the thing this whole module is
260/// trying to reduce.
261pub fn chunks_mut2<T, U, F>(a: &mut [T], b: &mut [U], chunk_len: usize, min_len: usize, f: F)
262where
263    T: Send,
264    U: Send,
265    F: Fn(usize, &mut [T], &mut [U]) + Send + Sync,
266{
267    assert!(chunk_len > 0, "chunk length must be positive");
268    assert_eq!(a.len(), b.len(), "zipped slices must be the same length");
269    let len = a.len();
270    if len == 0 {
271        return;
272    }
273    let n_chunks = len.div_ceil(chunk_len);
274    if backend() == Backend::Spin {
275        let base_a = SendPtr(a.as_mut_ptr());
276        let base_b = SendPtr(b.as_mut_ptr());
277        let (per, n_tasks) = split(n_chunks);
278        let task = |t: usize| {
279            let lo = t * per;
280            let hi = ((t + 1) * per).min(n_chunks);
281            for c in lo..hi {
282                // SAFETY: both pointers come from slices the caller
283                // borrows mutably for the whole call, of equal length,
284                // and chunk `c` of each is visited by exactly one task.
285                unsafe {
286                    f(
287                        c,
288                        chunk_of(base_a, len, chunk_len, c),
289                        chunk_of(base_b, len, chunk_len, c),
290                    );
291                }
292            }
293        };
294        if pool().run(n_tasks, &task) {
295            return;
296        }
297    }
298    a.par_chunks_mut(chunk_len)
299        .zip(b.par_chunks_mut(chunk_len))
300        .with_min_len(min_len.max(1))
301        .enumerate()
302        .for_each(|(c, (ca, cb))| f(c, ca, cb));
303}
304
305/// Run `f(chunk_index, &mut chunk)` over `data` split into runs of
306/// `chunk_len`, the shape rayon spells `par_chunks_mut(chunk_len)`.
307///
308/// A trailing partial chunk is delivered short, exactly as
309/// `par_chunks_mut` does.
310pub fn chunks_mut<T, F>(data: &mut [T], chunk_len: usize, min_len: usize, f: F)
311where
312    T: Send,
313    F: Fn(usize, &mut [T]) + Send + Sync,
314{
315    assert!(chunk_len > 0, "chunk length must be positive");
316    let len = data.len();
317    if len == 0 {
318        return;
319    }
320    let n_chunks = len.div_ceil(chunk_len);
321    if backend() == Backend::Spin {
322        let base = SendPtr(data.as_mut_ptr());
323        let (per, n_tasks) = split(n_chunks);
324        let task = |t: usize| {
325            let lo = t * per;
326            let hi = ((t + 1) * per).min(n_chunks);
327            for c in lo..hi {
328                // SAFETY: see `chunks_mut_init`; the ranges are the same
329                // disjoint half-open chunks of one live borrow.
330                f(c, unsafe { chunk_of(base, len, chunk_len, c) });
331            }
332        };
333        if pool().run(n_tasks, &task) {
334            return;
335        }
336    }
337    data.par_chunks_mut(chunk_len)
338        .with_min_len(min_len.max(1))
339        .enumerate()
340        .for_each(|(c, chunk)| f(c, chunk));
341}
342
343/// The `c`-th `chunk_len`-sized chunk of the `len`-element allocation at
344/// `base`, delivered short when it is the trailing one.
345///
346/// # Safety
347/// `base` must point at a live allocation of at least `len` elements
348/// that outlives the returned slice, and the caller must guarantee that
349/// no other live slice covers chunk `c` -- which the callers do by
350/// visiting each chunk index from exactly one task.
351unsafe fn chunk_of<'a, T>(base: SendPtr<T>, len: usize, chunk_len: usize, c: usize) -> &'a mut [T] {
352    let start = c * chunk_len;
353    let end = ((c + 1) * chunk_len).min(len);
354    debug_assert!(start < end && end <= len);
355    // SAFETY: the caller's invariants, plus `start..end` being inside
356    // `0..len` by construction of `c < len.div_ceil(chunk_len)`.
357    unsafe { std::slice::from_raw_parts_mut(base.at(start), end - start) }
358}
359
360/// [`chunks_mut`] with a per-task scratch value.
361pub fn chunks_mut_init<T, S, I, F>(data: &mut [T], chunk_len: usize, min_len: usize, init: I, f: F)
362where
363    T: Send,
364    S: Send,
365    I: Fn() -> S + Send + Sync,
366    F: Fn(&mut S, usize, &mut [T]) + Send + Sync,
367{
368    assert!(chunk_len > 0, "chunk length must be positive");
369    let len = data.len();
370    if len == 0 {
371        return;
372    }
373    let n_chunks = len.div_ceil(chunk_len);
374    if backend() == Backend::Spin {
375        let base = SendPtr(data.as_mut_ptr());
376        let (per, n_tasks) = split(n_chunks);
377        let task = |t: usize| {
378            let lo = t * per;
379            let hi = ((t + 1) * per).min(n_chunks);
380            if lo >= hi {
381                return;
382            }
383            let mut state = init();
384            for c in lo..hi {
385                // SAFETY: `base` points at `data`, which the caller
386                // borrows mutably for this whole call and which outlives
387                // the region (`CpuPool::run` does not return until every
388                // worker has stopped touching the closure). Chunk index
389                // `c` is visited by exactly one task, so no two of these
390                // slices overlap.
391                f(&mut state, c, unsafe { chunk_of(base, len, chunk_len, c) });
392            }
393        };
394        if pool().run(n_tasks, &task) {
395            return;
396        }
397    }
398    data.par_chunks_mut(chunk_len)
399        .with_min_len(min_len.max(1))
400        .enumerate()
401        .for_each_init(&init, |state, (c, chunk)| f(state, c, chunk));
402}
403
404/// Two independent pieces of work.
405///
406/// The rayon arm forks; the spin arm runs them one after the other,
407/// because each half already spreads across the whole pool internally
408/// and nesting a region inside a region is the one thing
409/// [`CpuPool::run`] cannot parallelize. That is llama.cpp's shape too:
410/// its threadpool runs one graph node at a time, full width.
411pub fn join2<A, B, RA, RB>(a: A, b: B) -> (RA, RB)
412where
413    A: FnOnce() -> RA + Send,
414    B: FnOnce() -> RB + Send,
415    RA: Send,
416    RB: Send,
417{
418    match backend() {
419        Backend::Rayon => rayon::join(a, b),
420        Backend::Spin => (a(), b()),
421    }
422}
423
424/// Three independent pieces of work; see [`join2`].
425pub fn join3<A, B, C, RA, RB, RC>(a: A, b: B, c: C) -> (RA, RB, RC)
426where
427    A: FnOnce() -> RA + Send,
428    B: FnOnce() -> RB + Send,
429    C: FnOnce() -> RC + Send,
430    RA: Send,
431    RB: Send,
432    RC: Send,
433{
434    match backend() {
435        Backend::Rayon => {
436            let (ra, (rb, rc)) = rayon::join(a, || rayon::join(b, c));
437            (ra, rb, rc)
438        }
439        Backend::Spin => (a(), b(), c()),
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::sync::atomic::{AtomicU32, Ordering};
447
448    /// With nothing published and nothing pinned, the helpers fork with
449    /// rayon -- the behaviour every caller that has not opted into the
450    /// size rule keeps.
451    #[test]
452    fn an_unpublished_region_forks_with_rayon() {
453        if policy::pinned().is_none() {
454            assert_eq!(backend(), Backend::Rayon);
455        }
456    }
457
458    /// **Every rayon-versus-spin choice in this crate goes through one
459    /// predicate**, and this is what says so.
460    ///
461    /// The alternative is the shape this repo keeps shipping: a second
462    /// site that decides for itself and then drifts. `weight_matrix`
463    /// had four copies of one GPU-router eligibility test that tested
464    /// three conditions, two, and none.
465    ///
466    /// Two halves, because a helper can drift in two directions:
467    /// reaching the pool without asking, and asking the environment
468    /// instead of asking the predicate.
469    ///
470    /// Sabotage: inline `pool().run(..)` into a helper without its
471    /// `if backend() == Backend::Spin` guard, or read the environment
472    /// variable in a second place, and this goes red.
473    #[test]
474    fn every_scheduler_choice_in_this_crate_goes_through_the_one_predicate() {
475        // The needles are assembled rather than written out, because
476        // this file is one of the files being searched and a literal
477        // would count itself.
478        let call = format!("{}()", "backend");
479        let guard = format!("if {call} == Backend::Spin {{");
480        let dispatch = format!("match {call} {{");
481        let enters_pool = format!("if {}().run(", "pool");
482
483        let src = include_str!("par.rs");
484        let guarded = src.matches(&guard).count();
485        assert!(guarded >= 6, "expected one guard per region helper");
486        assert_eq!(
487            src.matches(&enters_pool).count(),
488            guarded,
489            "a helper reached the persistent pool without asking the predicate"
490        );
491        assert_eq!(
492            src.matches(&dispatch).count(),
493            3,
494            "num_threads, join2 and join3 dispatch on the predicate"
495        );
496
497        // And the environment is consulted in exactly one place, so the
498        // override cannot come to mean two things. The needle is the
499        // variable's name up to its closing quote, which is what keeps
500        // `FERROX_CPU_POOL_SPIN_US` (a different knob, in `cpu_pool`)
501        // out of the answer.
502        let needle = format!("FERROX_CPU_POOL{}", '"');
503        let mut readers = Vec::new();
504        let mut stack = vec![std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src")];
505        while let Some(dir) = stack.pop() {
506            for entry in std::fs::read_dir(&dir).expect("crate source is readable") {
507                let path = entry.expect("readable entry").path();
508                if path.is_dir() {
509                    stack.push(path);
510                } else if path.extension().is_some_and(|e| e == "rs")
511                    && std::fs::read_to_string(&path)
512                        .expect("source file is UTF-8")
513                        .lines()
514                        .any(|l| l.contains(&needle) && !l.trim_start().starts_with("//"))
515                {
516                    readers.push(path);
517                }
518            }
519        }
520        assert_eq!(
521            readers.len(),
522            1,
523            "`FERROX_CPU_POOL` must be read only by `par::policy::pinned`, found {readers:?}"
524        );
525        assert!(readers[0].ends_with("par/policy.rs"), "{readers:?}");
526    }
527
528    /// The spin arm's chunking is a function of pool width and item
529    /// count and nothing else. If a MAC threshold ever creeps back onto
530    /// this path it has to change this signature to do it.
531    #[test]
532    fn the_spin_arm_chunks_by_pool_width_with_no_work_threshold() {
533        assert_eq!(task_count(0), 0);
534        assert_eq!(task_count(1), 1);
535        assert_eq!(task_count(3), 3);
536        let wide = task_count(1_000_000);
537        assert_eq!(wide, num_threads() * TASKS_PER_THREAD);
538        // A one-element-per-row matrix and a 4096-element-per-row matrix
539        // decompose identically: work per item is not an input.
540        assert_eq!(task_count(4096), task_count(4096));
541        let (per, n) = split(1000);
542        assert_eq!(n, task_count(1000));
543        assert!(per * n >= 1000 && (per - 1) * n < 1000);
544    }
545
546    /// Both arms must visit every index exactly once and produce the
547    /// same answer, whichever one the env var picked -- that is the
548    /// property the whole switch rests on.
549    #[test]
550    fn indices_visits_every_index_exactly_once() {
551        for n in [0usize, 1, 7, 64, 5000] {
552            let hits: Vec<AtomicU32> = (0..n).map(|_| AtomicU32::new(0)).collect();
553            indices(n, 8, |i| {
554                hits[i].fetch_add(1, Ordering::Relaxed);
555            });
556            assert!(hits.iter().all(|h| h.load(Ordering::Relaxed) == 1), "n={n}");
557        }
558    }
559
560    #[test]
561    fn items_mut_writes_every_slot_with_its_own_index() {
562        for n in [0usize, 1, 9, 257] {
563            let mut data = vec![0u32; n];
564            items_mut(&mut data, 4, |i, slot| *slot = i as u32 + 1);
565            assert_eq!(data, (1..=n as u32).collect::<Vec<_>>(), "n={n}");
566        }
567    }
568
569    /// The trailing partial chunk is the easy thing to lose, and losing
570    /// it silently drops the last rows of a matvec.
571    #[test]
572    fn chunks_mut_delivers_a_short_trailing_chunk() {
573        let mut data = vec![0u32; 10];
574        let seen: std::sync::Mutex<Vec<(usize, usize)>> = std::sync::Mutex::new(Vec::new());
575        chunks_mut(&mut data, 4, 1, |c, chunk| {
576            seen.lock().unwrap().push((c, chunk.len()));
577            for (i, slot) in chunk.iter_mut().enumerate() {
578                *slot = (c * 4 + i) as u32;
579            }
580        });
581        let mut seen = seen.into_inner().unwrap();
582        seen.sort_unstable();
583        assert_eq!(seen, vec![(0, 4), (1, 4), (2, 2)]);
584        assert_eq!(data, (0..10).collect::<Vec<u32>>());
585    }
586
587    /// Per-task scratch is created per task, never shared between two
588    /// tasks that might run at the same time.
589    #[test]
590    fn chunks_mut_init_gives_each_task_its_own_scratch() {
591        let mut data = vec![0u64; 512];
592        chunks_mut_init(
593            &mut data,
594            8,
595            1,
596            || Vec::<u64>::with_capacity(8),
597            |scratch: &mut Vec<u64>, c, chunk| {
598                scratch.clear();
599                scratch.extend(chunk.iter().map(|_| c as u64));
600                chunk.copy_from_slice(scratch);
601            },
602        );
603        for (c, chunk) in data.chunks(8).enumerate() {
604            assert!(chunk.iter().all(|&v| v == c as u64));
605        }
606    }
607
608    #[test]
609    fn joins_return_every_result_in_order() {
610        assert_eq!(join2(|| 1u8, || 2u8), (1, 2));
611        assert_eq!(join3(|| 1u8, || 2u8, || 3u8), (1, 2, 3));
612    }
613
614    /// The two arms are not allowed to disagree. This runs each helper
615    /// through the spin pool directly and through rayon directly, in one
616    /// process, and compares -- because the env var can only select one
617    /// of them per run, and "they agree" is the claim the PR makes.
618    #[test]
619    fn the_spin_arm_and_the_rayon_arm_produce_identical_results() {
620        let pool = CpuPool::new(4);
621        for n in [1usize, 5, 63, 1024] {
622            let mut spun = vec![0f32; n];
623            let base = SendPtr(spun.as_mut_ptr());
624            let (per, n_tasks) = split(n);
625            let task = |t: usize| {
626                let lo = t * per;
627                let hi = ((t + 1) * per).min(n);
628                for i in lo..hi {
629                    // SAFETY: disjoint single-element writes; index `i`
630                    // belongs to exactly one task.
631                    unsafe { *base.at(i) = (i as f32) * 0.5 + 1.0 };
632                }
633            };
634            assert!(pool.run(n_tasks, &task));
635
636            let mut forked = vec![0f32; n];
637            forked
638                .par_iter_mut()
639                .with_min_len(8)
640                .enumerate()
641                .for_each(|(i, slot)| *slot = (i as f32) * 0.5 + 1.0);
642
643            assert_eq!(spun, forked, "n={n}");
644        }
645    }
646}