Skip to main content

oxiblas_core/
parallel.rs

1//! Parallelization primitives for OxiBLAS.
2//!
3//! This module provides:
4//! - Parallel execution modes
5//! - Work partitioning utilities
6//! - Thread-local accumulation patterns
7
8#[cfg(not(feature = "std"))]
9use alloc::vec;
10#[cfg(not(feature = "std"))]
11use alloc::vec::Vec;
12
13use core::sync::atomic::{AtomicBool, Ordering};
14
15#[cfg(feature = "parallel")]
16use rayon::prelude::*;
17
18/// Global flag to disable parallelism.
19static PARALLELISM_DISABLED: AtomicBool = AtomicBool::new(false);
20
21/// Disables global parallelism.
22///
23/// This can be useful for debugging or when running in environments
24/// where threading is problematic.
25pub fn disable_global_parallelism() {
26    PARALLELISM_DISABLED.store(true, Ordering::SeqCst);
27}
28
29/// Enables global parallelism.
30pub fn enable_global_parallelism() {
31    PARALLELISM_DISABLED.store(false, Ordering::SeqCst);
32}
33
34/// Returns true if parallelism is enabled.
35pub fn is_parallelism_enabled() -> bool {
36    !PARALLELISM_DISABLED.load(Ordering::SeqCst)
37}
38
39/// Parallelization mode.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Par {
42    /// Sequential execution.
43    Seq,
44    /// Parallel execution with the default thread pool.
45    #[cfg(feature = "parallel")]
46    Rayon,
47    /// Parallel execution with a specific number of threads.
48    #[cfg(feature = "parallel")]
49    RayonWith(usize),
50}
51
52// Manual impl because the default variant depends on feature flags
53// (Rayon when "parallel" is enabled, Seq otherwise)
54#[allow(clippy::derivable_impls)]
55impl Default for Par {
56    fn default() -> Self {
57        #[cfg(feature = "parallel")]
58        {
59            Par::Rayon
60        }
61        #[cfg(not(feature = "parallel"))]
62        {
63            Par::Seq
64        }
65    }
66}
67
68impl Par {
69    /// Returns true if this mode is sequential.
70    #[inline]
71    pub fn is_sequential(&self) -> bool {
72        match self {
73            Par::Seq => true,
74            #[cfg(feature = "parallel")]
75            _ => !is_parallelism_enabled(),
76        }
77    }
78
79    /// Returns the number of threads to use.
80    #[cfg(feature = "parallel")]
81    pub fn num_threads(&self) -> usize {
82        if !is_parallelism_enabled() {
83            return 1;
84        }
85
86        match self {
87            Par::Seq => 1,
88            Par::Rayon => rayon::current_num_threads(),
89            Par::RayonWith(n) => *n,
90        }
91    }
92
93    /// Returns the number of threads to use (always 1 without parallel feature).
94    #[cfg(not(feature = "parallel"))]
95    pub fn num_threads(&self) -> usize {
96        1
97    }
98}
99
100/// Threshold configuration for parallel operations.
101#[derive(Debug, Clone, Copy)]
102pub struct ParThreshold {
103    /// Minimum number of elements for parallelization.
104    pub min_elements: usize,
105    /// Minimum work per thread (elements).
106    pub min_work_per_thread: usize,
107}
108
109impl Default for ParThreshold {
110    fn default() -> Self {
111        ParThreshold {
112            min_elements: 4096,
113            min_work_per_thread: 256,
114        }
115    }
116}
117
118impl ParThreshold {
119    /// Creates a new threshold configuration.
120    pub const fn new(min_elements: usize, min_work_per_thread: usize) -> Self {
121        ParThreshold {
122            min_elements,
123            min_work_per_thread,
124        }
125    }
126
127    /// Returns true if parallelization should be used for the given work size.
128    #[inline]
129    pub fn should_parallelize(&self, total_work: usize, par: Par) -> bool {
130        if par.is_sequential() {
131            return false;
132        }
133
134        if total_work < self.min_elements {
135            return false;
136        }
137
138        let threads = par.num_threads();
139        if threads <= 1 {
140            return false;
141        }
142
143        total_work / threads >= self.min_work_per_thread
144    }
145}
146
147/// Work range for parallel iteration.
148#[derive(Debug, Clone, Copy)]
149pub struct WorkRange {
150    /// Start index (inclusive).
151    pub start: usize,
152    /// End index (exclusive).
153    pub end: usize,
154}
155
156impl WorkRange {
157    /// Creates a new work range.
158    #[inline]
159    pub const fn new(start: usize, end: usize) -> Self {
160        WorkRange { start, end }
161    }
162
163    /// Returns the length of the range.
164    #[inline]
165    pub const fn len(&self) -> usize {
166        self.end - self.start
167    }
168
169    /// Returns true if the range is empty.
170    #[inline]
171    pub const fn is_empty(&self) -> bool {
172        self.start >= self.end
173    }
174}
175
176/// Partitions work into chunks for parallel execution.
177pub fn partition_work(total: usize, num_threads: usize) -> Vec<WorkRange> {
178    if num_threads == 0 || total == 0 {
179        return vec![];
180    }
181
182    if num_threads == 1 {
183        return vec![WorkRange::new(0, total)];
184    }
185
186    let chunk_size = total.div_ceil(num_threads);
187    let mut ranges = Vec::with_capacity(num_threads);
188
189    let mut start = 0;
190    while start < total {
191        let end = (start + chunk_size).min(total);
192        ranges.push(WorkRange::new(start, end));
193        start = end;
194    }
195
196    ranges
197}
198
199/// Executes a closure in parallel over work ranges.
200///
201/// If parallelism is disabled or the work is too small, executes sequentially.
202#[inline]
203pub fn for_each_range<F>(total: usize, par: Par, threshold: &ParThreshold, f: F)
204where
205    F: Fn(WorkRange) + Send + Sync,
206{
207    if !threshold.should_parallelize(total, par) {
208        f(WorkRange::new(0, total));
209        return;
210    }
211
212    #[cfg(feature = "parallel")]
213    {
214        let ranges = partition_work(total, dispatch_thread_count(par));
215        run_in_pool(par, move || {
216            ranges.into_par_iter().for_each(f);
217        });
218    }
219
220    #[cfg(not(feature = "parallel"))]
221    {
222        f(WorkRange::new(0, total));
223    }
224}
225
226/// Parallel map-reduce operation.
227///
228/// Maps each work range to a value, then reduces all values.
229#[allow(unused_variables)]
230pub fn map_reduce<T, Map, Reduce>(
231    total: usize,
232    par: Par,
233    threshold: &ParThreshold,
234    identity: T,
235    map: Map,
236    reduce: Reduce,
237) -> T
238where
239    T: Clone + Send + Sync,
240    Map: Fn(WorkRange) -> T + Send + Sync,
241    Reduce: Fn(T, T) -> T + Send + Sync,
242{
243    if !threshold.should_parallelize(total, par) {
244        return map(WorkRange::new(0, total));
245    }
246
247    #[cfg(feature = "parallel")]
248    {
249        let ranges = partition_work(total, dispatch_thread_count(par));
250        run_in_pool(par, move || {
251            ranges
252                .into_par_iter()
253                .map(map)
254                .reduce(|| identity.clone(), reduce)
255        })
256    }
257
258    #[cfg(not(feature = "parallel"))]
259    {
260        map(WorkRange::new(0, total))
261    }
262}
263
264/// Parallel for_each with index.
265pub fn for_each_indexed<F>(total: usize, par: Par, threshold: &ParThreshold, f: F)
266where
267    F: Fn(usize) + Send + Sync,
268{
269    if !threshold.should_parallelize(total, par) {
270        for i in 0..total {
271            f(i);
272        }
273        return;
274    }
275
276    #[cfg(feature = "parallel")]
277    {
278        run_in_pool(par, move || {
279            (0..total).into_par_iter().for_each(f);
280        });
281    }
282
283    #[cfg(not(feature = "parallel"))]
284    {
285        for i in 0..total {
286            f(i);
287        }
288    }
289}
290
291// =============================================================================
292// Custom thread pool support
293// =============================================================================
294
295/// Trait for custom thread pool implementations.
296///
297/// This allows using thread pools other than rayon's global pool.
298pub trait ThreadPool: Send + Sync {
299    /// Returns the number of threads in the pool.
300    fn num_threads(&self) -> usize;
301
302    /// Executes a closure on the thread pool.
303    fn execute<F>(&self, f: F)
304    where
305        F: FnOnce() + Send + 'static;
306
307    /// Joins two closures, executing them potentially in parallel.
308    fn join<A, B, RA, RB>(&self, a: A, b: B) -> (RA, RB)
309    where
310        A: FnOnce() -> RA + Send,
311        B: FnOnce() -> RB + Send,
312        RA: Send,
313        RB: Send;
314
315    /// Parallel for_each over a range.
316    fn for_each<F>(&self, range: core::ops::Range<usize>, f: F)
317    where
318        F: Fn(usize) + Send + Sync;
319
320    /// Parallel map-reduce over a range.
321    fn map_reduce<T, Map, Reduce>(
322        &self,
323        range: core::ops::Range<usize>,
324        identity: T,
325        map: Map,
326        reduce: Reduce,
327    ) -> T
328    where
329        T: Clone + Send + Sync,
330        Map: Fn(usize) -> T + Send + Sync,
331        Reduce: Fn(T, T) -> T + Send + Sync;
332}
333
334/// A single-threaded "pool" for sequential execution.
335#[derive(Debug, Clone, Copy, Default)]
336pub struct SequentialPool;
337
338impl ThreadPool for SequentialPool {
339    #[inline]
340    fn num_threads(&self) -> usize {
341        1
342    }
343
344    #[inline]
345    fn execute<F>(&self, f: F)
346    where
347        F: FnOnce() + Send + 'static,
348    {
349        f();
350    }
351
352    #[inline]
353    fn join<A, B, RA, RB>(&self, a: A, b: B) -> (RA, RB)
354    where
355        A: FnOnce() -> RA + Send,
356        B: FnOnce() -> RB + Send,
357        RA: Send,
358        RB: Send,
359    {
360        (a(), b())
361    }
362
363    fn for_each<F>(&self, range: core::ops::Range<usize>, f: F)
364    where
365        F: Fn(usize) + Send + Sync,
366    {
367        for i in range {
368            f(i);
369        }
370    }
371
372    fn map_reduce<T, Map, Reduce>(
373        &self,
374        range: core::ops::Range<usize>,
375        identity: T,
376        map: Map,
377        reduce: Reduce,
378    ) -> T
379    where
380        T: Clone + Send + Sync,
381        Map: Fn(usize) -> T + Send + Sync,
382        Reduce: Fn(T, T) -> T + Send + Sync,
383    {
384        let mut acc = identity;
385        for i in range {
386            acc = reduce(acc, map(i));
387        }
388        acc
389    }
390}
391
392/// Wrapper for rayon's global thread pool.
393#[cfg(feature = "parallel")]
394#[derive(Debug, Clone, Copy, Default)]
395pub struct RayonGlobalPool;
396
397#[cfg(feature = "parallel")]
398impl ThreadPool for RayonGlobalPool {
399    #[inline]
400    fn num_threads(&self) -> usize {
401        rayon::current_num_threads()
402    }
403
404    #[inline]
405    fn execute<F>(&self, f: F)
406    where
407        F: FnOnce() + Send + 'static,
408    {
409        rayon::spawn(f);
410    }
411
412    #[inline]
413    fn join<A, B, RA, RB>(&self, a: A, b: B) -> (RA, RB)
414    where
415        A: FnOnce() -> RA + Send,
416        B: FnOnce() -> RB + Send,
417        RA: Send,
418        RB: Send,
419    {
420        rayon::join(a, b)
421    }
422
423    fn for_each<F>(&self, range: core::ops::Range<usize>, f: F)
424    where
425        F: Fn(usize) + Send + Sync,
426    {
427        range.into_par_iter().for_each(f);
428    }
429
430    fn map_reduce<T, Map, Reduce>(
431        &self,
432        range: core::ops::Range<usize>,
433        identity: T,
434        map: Map,
435        reduce: Reduce,
436    ) -> T
437    where
438        T: Clone + Send + Sync,
439        Map: Fn(usize) -> T + Send + Sync,
440        Reduce: Fn(T, T) -> T + Send + Sync,
441    {
442        range
443            .into_par_iter()
444            .map(map)
445            .reduce(|| identity.clone(), reduce)
446    }
447}
448
449/// Wrapper for a custom rayon thread pool.
450#[cfg(feature = "parallel")]
451pub struct CustomRayonPool {
452    pool: rayon::ThreadPool,
453}
454
455#[cfg(feature = "parallel")]
456impl CustomRayonPool {
457    /// Creates a new custom rayon pool with the specified number of threads.
458    pub fn new(num_threads: usize) -> Result<Self, rayon::ThreadPoolBuildError> {
459        let pool = rayon::ThreadPoolBuilder::new()
460            .num_threads(num_threads)
461            .build()?;
462        Ok(CustomRayonPool { pool })
463    }
464
465    /// Creates a new custom rayon pool with the specified number of threads.
466    ///
467    /// This is an alias for [`CustomRayonPool::new`] that matches the naming
468    /// convention used in the `OxiblasThreadConfig` builder API.
469    pub fn with_num_threads(n: usize) -> Result<Self, rayon::ThreadPoolBuildError> {
470        Self::new(n)
471    }
472
473    /// Creates a new custom rayon pool with builder configuration.
474    pub fn with_builder<F>(configure: F) -> Result<Self, rayon::ThreadPoolBuildError>
475    where
476        F: FnOnce(rayon::ThreadPoolBuilder) -> rayon::ThreadPoolBuilder,
477    {
478        let builder = rayon::ThreadPoolBuilder::new();
479        let pool = configure(builder).build()?;
480        Ok(CustomRayonPool { pool })
481    }
482
483    /// Installs this pool for the duration of the closure.
484    pub fn install<R, F>(&self, f: F) -> R
485    where
486        F: FnOnce() -> R + Send,
487        R: Send,
488    {
489        self.pool.install(f)
490    }
491}
492
493#[cfg(feature = "parallel")]
494impl ThreadPool for CustomRayonPool {
495    #[inline]
496    fn num_threads(&self) -> usize {
497        self.pool.current_num_threads()
498    }
499
500    #[inline]
501    fn execute<F>(&self, f: F)
502    where
503        F: FnOnce() + Send + 'static,
504    {
505        self.pool.spawn(f);
506    }
507
508    #[inline]
509    fn join<A, B, RA, RB>(&self, a: A, b: B) -> (RA, RB)
510    where
511        A: FnOnce() -> RA + Send,
512        B: FnOnce() -> RB + Send,
513        RA: Send,
514        RB: Send,
515    {
516        self.pool.join(a, b)
517    }
518
519    fn for_each<F>(&self, range: core::ops::Range<usize>, f: F)
520    where
521        F: Fn(usize) + Send + Sync,
522    {
523        self.pool.install(|| {
524            range.into_par_iter().for_each(f);
525        });
526    }
527
528    fn map_reduce<T, Map, Reduce>(
529        &self,
530        range: core::ops::Range<usize>,
531        identity: T,
532        map: Map,
533        reduce: Reduce,
534    ) -> T
535    where
536        T: Clone + Send + Sync,
537        Map: Fn(usize) -> T + Send + Sync,
538        Reduce: Fn(T, T) -> T + Send + Sync,
539    {
540        self.pool.install(|| {
541            range
542                .into_par_iter()
543                .map(map)
544                .reduce(|| identity.clone(), reduce)
545        })
546    }
547}
548
549/// Scoped execution context for a thread pool.
550///
551/// This provides a convenient way to run operations with a specific thread pool.
552pub struct PoolScope<'a, P: ThreadPool> {
553    pool: &'a P,
554    threshold: ParThreshold,
555}
556
557impl<'a, P: ThreadPool> PoolScope<'a, P> {
558    /// Creates a new pool scope with default threshold.
559    pub fn new(pool: &'a P) -> Self {
560        PoolScope {
561            pool,
562            threshold: ParThreshold::default(),
563        }
564    }
565
566    /// Creates a new pool scope with a custom threshold.
567    pub fn with_threshold(pool: &'a P, threshold: ParThreshold) -> Self {
568        PoolScope { pool, threshold }
569    }
570
571    /// Returns the number of threads in the pool.
572    #[inline]
573    pub fn num_threads(&self) -> usize {
574        self.pool.num_threads()
575    }
576
577    /// Joins two closures.
578    #[inline]
579    pub fn join<A, B, RA, RB>(&self, a: A, b: B) -> (RA, RB)
580    where
581        A: FnOnce() -> RA + Send,
582        B: FnOnce() -> RB + Send,
583        RA: Send,
584        RB: Send,
585    {
586        self.pool.join(a, b)
587    }
588
589    /// Parallel for_each over a range.
590    pub fn for_each<F>(&self, total: usize, f: F)
591    where
592        F: Fn(usize) + Send + Sync,
593    {
594        if total < self.threshold.min_elements || self.pool.num_threads() <= 1 {
595            for i in 0..total {
596                f(i);
597            }
598        } else {
599            self.pool.for_each(0..total, f);
600        }
601    }
602
603    /// Parallel for_each over work ranges.
604    pub fn for_each_range<F>(&self, total: usize, f: F)
605    where
606        F: Fn(WorkRange) + Send + Sync,
607    {
608        if total < self.threshold.min_elements || self.pool.num_threads() <= 1 {
609            f(WorkRange::new(0, total));
610        } else {
611            // Genuinely dispatch the ranges across the pool's workers. Each
612            // range is addressed by index and driven through
613            // `ThreadPool::for_each`, which every concrete pool implements in
614            // parallel (rayon `into_par_iter`). The previous `for range in
615            // ranges` loop executed every range on the *caller* thread, so the
616            // "parallel" branch silently ran sequentially.
617            let ranges = partition_work(total, self.pool.num_threads());
618            let num_ranges = ranges.len();
619            self.pool.for_each(0..num_ranges, |idx| f(ranges[idx]));
620        }
621    }
622
623    /// Parallel map-reduce operation.
624    pub fn map_reduce<T, Map, Reduce>(
625        &self,
626        total: usize,
627        identity: T,
628        map: Map,
629        reduce: Reduce,
630    ) -> T
631    where
632        T: Clone + Send + Sync,
633        Map: Fn(usize) -> T + Send + Sync,
634        Reduce: Fn(T, T) -> T + Send + Sync,
635    {
636        if total < self.threshold.min_elements || self.pool.num_threads() <= 1 {
637            let mut acc = identity;
638            for i in 0..total {
639                acc = reduce(acc, map(i));
640            }
641            acc
642        } else {
643            self.pool.map_reduce(0..total, identity, map, reduce)
644        }
645    }
646}
647
648/// Gets the default thread pool based on feature flags.
649#[cfg(feature = "parallel")]
650pub fn default_pool() -> RayonGlobalPool {
651    RayonGlobalPool
652}
653
654/// Gets the default thread pool (sequential without parallel feature).
655#[cfg(not(feature = "parallel"))]
656pub fn default_pool() -> SequentialPool {
657    SequentialPool
658}
659
660/// Executes work with the default pool.
661///
662/// This is a convenience wrapper that creates a PoolScope with the default pool.
663#[cfg(feature = "parallel")]
664pub fn with_default_pool<R, F>(f: F) -> R
665where
666    F: FnOnce(PoolScope<'_, RayonGlobalPool>) -> R,
667{
668    let pool = RayonGlobalPool;
669    f(PoolScope::new(&pool))
670}
671
672/// Executes work with the default pool (sequential version).
673#[cfg(not(feature = "parallel"))]
674pub fn with_default_pool<R, F>(f: F) -> R
675where
676    F: FnOnce(PoolScope<'_, SequentialPool>) -> R,
677{
678    let pool = SequentialPool;
679    f(PoolScope::new(&pool))
680}
681
682// =============================================================================
683// Global thread pool management
684// =============================================================================
685
686/// Configuration for the OxiBLAS thread pool.
687///
688/// `OxiblasThreadConfig` gathers all knobs that influence how OxiBLAS
689/// chooses threads for parallel operations.  Build one with the fluent
690/// builder methods, then apply it via [`set_global_thread_pool`] or
691/// [`with_thread_count`].
692///
693/// # Example
694///
695/// ```rust
696/// use oxiblas_core::parallel::OxiblasThreadConfig;
697///
698/// let cfg = OxiblasThreadConfig::new()
699///     .num_threads(4)
700///     .stack_size(2 * 1024 * 1024);
701/// println!("threads: {}", cfg.num_threads);
702/// ```
703///
704/// This type is only available with the `std` feature: it owns a `String`
705/// thread-name and its [`effective_threads`](OxiblasThreadConfig::effective_threads)
706/// query relies on `std::thread::available_parallelism`, neither of which
707/// exist in a `no_std` build (where there are no OS threads to configure).
708#[cfg(feature = "std")]
709#[derive(Debug, Clone, Default)]
710pub struct OxiblasThreadConfig {
711    /// Number of worker threads.  `0` means "use all logical CPUs".
712    pub num_threads: usize,
713    /// Per-thread stack size in bytes.  `0` means "use OS default".
714    pub stack_size: usize,
715    /// Human-readable name prefix for spawned threads.
716    pub thread_name: Option<String>,
717}
718
719#[cfg(feature = "std")]
720impl OxiblasThreadConfig {
721    /// Creates a new configuration with all defaults.
722    pub fn new() -> Self {
723        Self::default()
724    }
725
726    /// Sets the desired thread count.  Pass `0` for "all CPUs".
727    pub fn num_threads(mut self, n: usize) -> Self {
728        self.num_threads = n;
729        self
730    }
731
732    /// Sets the per-thread stack size.  Pass `0` for the OS default.
733    pub fn stack_size(mut self, bytes: usize) -> Self {
734        self.stack_size = bytes;
735        self
736    }
737
738    /// Sets a human-readable name prefix for spawned threads.
739    pub fn thread_name(mut self, name: impl Into<String>) -> Self {
740        self.thread_name = Some(name.into());
741        self
742    }
743
744    /// Returns the effective thread count, substituting the available
745    /// logical CPU count when `num_threads` is `0`.
746    pub fn effective_threads(&self) -> usize {
747        if self.num_threads == 0 {
748            std::thread::available_parallelism()
749                .map(|n| n.get())
750                .unwrap_or(1)
751        } else {
752            self.num_threads
753        }
754    }
755
756    /// Builds a [`CustomRayonPool`] from this configuration.
757    ///
758    /// Returns an error if rayon fails to construct the pool.
759    #[cfg(feature = "parallel")]
760    pub fn build_pool(&self) -> Result<CustomRayonPool, rayon::ThreadPoolBuildError> {
761        let mut builder = rayon::ThreadPoolBuilder::new().num_threads(self.effective_threads());
762        if self.stack_size > 0 {
763            builder = builder.stack_size(self.stack_size);
764        }
765        if let Some(name) = &self.thread_name {
766            let name = name.clone();
767            builder = builder.thread_name(move |i| format!("{name}-{i}"));
768        }
769        let pool = builder.build()?;
770        Ok(CustomRayonPool { pool })
771    }
772}
773
774// ---------------------------------------------------------------------------
775// Global pool registry
776// ---------------------------------------------------------------------------
777
778/// The process-wide thread-pool registry.
779///
780/// The pool is stored as a **concrete** type rather than a `dyn` trait object.
781/// A trait object cannot expose a generic `install<R, Op>()` (generic methods
782/// are not object-safe), which is exactly the operation needed to make a
783/// registered pool *actually execute* work. Storing the concrete
784/// [`CustomRayonPool`] lets [`run_in_pool`] call its properly `Send`-bounded
785/// `install`, so `Par::Rayon` work runs on the registered pool instead of the
786/// registry being an inert store (the pre-fix behaviour).
787#[cfg(feature = "parallel")]
788static GLOBAL_POOL: std::sync::OnceLock<CustomRayonPool> = std::sync::OnceLock::new();
789
790/// Sequential registry used on `std` builds without the `parallel` feature,
791/// where there is nothing to install onto.
792#[cfg(all(feature = "std", not(feature = "parallel")))]
793static GLOBAL_POOL: std::sync::OnceLock<SequentialPool> = std::sync::OnceLock::new();
794
795// ---------------------------------------------------------------------------
796// Pool dispatch helpers (parallel feature only)
797// ---------------------------------------------------------------------------
798
799/// Number of worker threads a given [`Par`] mode will actually dispatch across.
800///
801/// For [`Par::Rayon`] this honours a pool registered via
802/// [`set_global_thread_pool`], falling back to rayon's ambient global pool; for
803/// [`Par::RayonWith`] it is the requested count. Feeding this (rather than the
804/// ambient count) to [`partition_work`] keeps the number of chunks aligned with
805/// the pool that will run them.
806#[cfg(feature = "parallel")]
807fn dispatch_thread_count(par: Par) -> usize {
808    if !is_parallelism_enabled() {
809        return 1;
810    }
811    match par {
812        Par::Seq => 1,
813        Par::RayonWith(n) => n.max(1),
814        Par::Rayon => GLOBAL_POOL
815            .get()
816            .map(|p| p.num_threads())
817            .unwrap_or_else(rayon::current_num_threads),
818    }
819}
820
821/// Returns (building on first use) a memoised rayon pool of exactly `n` threads.
822///
823/// [`Par::RayonWith(n)`] must run on a pool of *exactly* `n` threads. Building a
824/// fresh pool per call would spawn `n` OS threads every invocation, so one pool
825/// per distinct `n` is cached behind an `Arc` and reused. Returns `None` if the
826/// pool cannot be built, so the caller can fall back rather than fail the whole
827/// computation.
828#[cfg(feature = "parallel")]
829fn cached_pool(n: usize) -> Option<std::sync::Arc<rayon::ThreadPool>> {
830    use std::collections::HashMap;
831    use std::sync::{Arc, Mutex, OnceLock};
832
833    static CACHE: OnceLock<Mutex<HashMap<usize, Arc<rayon::ThreadPool>>>> = OnceLock::new();
834    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
835    // Poisoning only means a *previous* builder panicked; the map itself stays
836    // structurally consistent, so recover the guard instead of propagating a
837    // panic into an unrelated caller.
838    let mut guard = cache
839        .lock()
840        .unwrap_or_else(|poisoned| poisoned.into_inner());
841    if let Some(pool) = guard.get(&n) {
842        return Some(Arc::clone(pool));
843    }
844    match rayon::ThreadPoolBuilder::new().num_threads(n).build() {
845        Ok(pool) => {
846            let pool = Arc::new(pool);
847            guard.insert(n, Arc::clone(&pool));
848            Some(pool)
849        }
850        Err(_) => None,
851    }
852}
853
854/// Runs `op` on the thread pool selected by `par`, so that any rayon parallel
855/// iterator constructed *inside* `op` executes on that pool's worker threads.
856///
857/// * [`Par::RayonWith(n)`] installs the cached `n`-thread pool.
858/// * Otherwise a pool registered via [`set_global_thread_pool`] is installed if
859///   present; failing that, `op` runs on rayon's ambient global pool.
860///
861/// If a fixed-size pool cannot be built, `op` is run directly rather than
862/// failing the caller's computation (correctness is preserved; only the
863/// requested thread count is not honoured in that degraded case).
864#[cfg(feature = "parallel")]
865fn run_in_pool<R, Op>(par: Par, op: Op) -> R
866where
867    Op: FnOnce() -> R + Send,
868    R: Send,
869{
870    match par {
871        Par::RayonWith(n) => match cached_pool(n) {
872            Some(pool) => pool.install(op),
873            None => op(),
874        },
875        _ => match GLOBAL_POOL.get() {
876            Some(pool) => pool.install(op),
877            None => op(),
878        },
879    }
880}
881
882/// Sets the global OxiBLAS thread pool.
883///
884/// The pool is stored in a `OnceLock` so it can only be set **once** per
885/// process.  Subsequent calls are silently ignored (the first writer wins).
886///
887/// # Arguments
888///
889/// * `pool` – Any value that implements [`ThreadPool`] and is
890///   `'static + Send + Sync`.  Typically a [`CustomRayonPool`] built via
891///   [`OxiblasThreadConfig::build_pool`] or
892///   [`CustomRayonPool::with_num_threads`].
893///
894/// # Example
895///
896/// ```rust
897/// # #[cfg(feature = "parallel")]
898/// # {
899/// use oxiblas_core::parallel::{CustomRayonPool, set_global_thread_pool};
900/// let pool = CustomRayonPool::with_num_threads(4).expect("build pool");
901/// set_global_thread_pool(pool);
902/// # }
903/// ```
904#[cfg(all(feature = "std", feature = "parallel"))]
905pub fn set_global_thread_pool(pool: CustomRayonPool) {
906    let _ = GLOBAL_POOL.set(pool);
907}
908
909/// Sets the global OxiBLAS thread pool to a sequential (single-threaded)
910/// pool (available without the `parallel` feature).
911#[cfg(all(feature = "std", not(feature = "parallel")))]
912pub fn set_global_thread_pool(pool: SequentialPool) {
913    let _ = GLOBAL_POOL.set(pool);
914}
915
916/// Returns the number of threads in the global pool, or `1` if no pool has
917/// been registered.
918#[cfg(feature = "parallel")]
919pub fn global_num_threads() -> usize {
920    GLOBAL_POOL.get().map(|p| p.num_threads()).unwrap_or(1)
921}
922
923/// Returns the number of threads in the global pool, or `1` if no pool has
924/// been registered (always `1` on `std` builds without the `parallel`
925/// feature, where the registry can only hold a sequential pool).
926#[cfg(all(feature = "std", not(feature = "parallel")))]
927pub fn global_num_threads() -> usize {
928    GLOBAL_POOL.get().map_or(1, |p| p.num_threads())
929}
930
931/// Executes `f` inside a temporary rayon pool with exactly `n` threads.
932///
933/// This is useful for benchmarks or tests that need deterministic
934/// parallelism without replacing the global pool.  On platforms without
935/// the `parallel` feature the closure is called directly on the current
936/// thread.
937///
938/// # Example
939///
940/// ```rust
941/// use oxiblas_core::parallel::with_thread_count;
942///
943/// with_thread_count(2, || {
944///     // work here runs with (up to) 2 rayon threads
945/// });
946/// ```
947#[cfg(feature = "parallel")]
948pub fn with_thread_count(n: usize, f: impl FnOnce() + Send) {
949    let pool = rayon::ThreadPoolBuilder::new().num_threads(n).build();
950    match pool {
951        Ok(p) => p.install(f),
952        Err(_) => f(), // fallback: run sequentially if build fails
953    }
954}
955
956/// Sequential fallback when the `parallel` feature is disabled.
957#[cfg(not(feature = "parallel"))]
958pub fn with_thread_count(_n: usize, f: impl FnOnce()) {
959    f();
960}
961
962// =============================================================================
963// Thread-local accumulation
964// =============================================================================
965
966/// Sharded accumulator for parallel reduction.
967///
968/// Despite the historical "thread-local" framing, this is a **sharded**
969/// accumulator, not a strictly one-slot-per-thread one. It allocates one
970/// mutex-guarded shard per rayon worker ([`rayon::current_num_threads`] at
971/// construction) and routes each caller to a shard via its
972/// [`rayon::current_thread_index`]. Callers with no rayon index (the invoking
973/// thread, or a thread belonging to a *different* pool) — and any index `>=`
974/// the shard count — fold onto an existing shard via modulo. That aliasing only
975/// ever adds mutex contention: because every shard is mutex-guarded it can
976/// never cause a data race or a lost update, so the final [`reduce`] is always
977/// correct; it simply is not lock-free in the aliased case. Prefer it for
978/// per-worker accumulation on the pool that created it, and rely on the
979/// mutexes for correctness everywhere else.
980///
981/// Requires the `parallel` feature (which implies `std`).
982///
983/// [`reduce`]: ThreadLocalAccum::reduce
984#[cfg(feature = "parallel")]
985pub struct ThreadLocalAccum<T> {
986    values: Vec<std::sync::Mutex<T>>,
987}
988
989#[cfg(feature = "parallel")]
990impl<T: Clone + Send> ThreadLocalAccum<T> {
991    /// Creates a new sharded accumulator with one shard per rayon worker.
992    pub fn new(identity: T) -> Self {
993        // At least one shard is always allocated. `rayon::current_num_threads`
994        // is documented to return `>= 1`, but the explicit `.max(1)` makes the
995        // non-empty invariant that `reduce` relies on hold *by construction*,
996        // independent of that external guarantee.
997        let num_threads = rayon::current_num_threads().max(1);
998        let values = (0..num_threads)
999            .map(|_| std::sync::Mutex::new(identity.clone()))
1000            .collect();
1001        ThreadLocalAccum { values }
1002    }
1003
1004    /// Gets or initializes the accumulator for the current thread.
1005    pub fn get(&self) -> std::sync::MutexGuard<'_, T> {
1006        let thread_idx = rayon::current_thread_index().unwrap_or(0) % self.values.len();
1007        self.values[thread_idx]
1008            .lock()
1009            .unwrap_or_else(|poisoned| poisoned.into_inner())
1010    }
1011
1012    /// Folds every shard into a single result using `f`.
1013    ///
1014    /// `f` must be an associative combiner over the accumulated values (the
1015    /// same monoid whose identity was passed to [`new`](Self::new)); the shards
1016    /// are combined left-to-right in shard order.
1017    pub fn reduce<F>(self, f: F) -> T
1018    where
1019        F: Fn(T, T) -> T,
1020    {
1021        let mut acc: Option<T> = None;
1022        for shard in self.values {
1023            let value = shard
1024                .into_inner()
1025                .unwrap_or_else(|poisoned| poisoned.into_inner());
1026            acc = Some(match acc {
1027                Some(previous) => f(previous, value),
1028                None => value,
1029            });
1030        }
1031        // Infallible by construction: `new` allocates `num_threads.max(1) >= 1`
1032        // shards, so the loop above runs at least once and `acc` is always
1033        // `Some` here. This `.expect` therefore cannot panic; it documents the
1034        // invariant rather than guarding a reachable failure.
1035        acc.expect("ThreadLocalAccum always holds at least one shard")
1036    }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041    use super::*;
1042
1043    #[test]
1044    fn test_partition_work() {
1045        let ranges = partition_work(100, 4);
1046        assert_eq!(ranges.len(), 4);
1047
1048        // Check that ranges cover everything
1049        let mut covered = [false; 100];
1050        for range in &ranges {
1051            for (offset, covered_elem) in covered[range.start..range.end].iter_mut().enumerate() {
1052                let i = range.start + offset;
1053                assert!(!*covered_elem, "Overlap at {}", i);
1054                *covered_elem = true;
1055            }
1056        }
1057        assert!(covered.iter().all(|&x| x), "Not all elements covered");
1058    }
1059
1060    #[test]
1061    fn test_partition_work_uneven() {
1062        let ranges = partition_work(10, 4);
1063
1064        // Total should equal original
1065        let total: usize = ranges.iter().map(|r| r.len()).sum();
1066        assert_eq!(total, 10);
1067    }
1068
1069    #[test]
1070    fn test_partition_work_single() {
1071        let ranges = partition_work(100, 1);
1072        assert_eq!(ranges.len(), 1);
1073        assert_eq!(ranges[0].start, 0);
1074        assert_eq!(ranges[0].end, 100);
1075    }
1076
1077    #[test]
1078    fn test_threshold() {
1079        let threshold = ParThreshold::new(100, 10);
1080
1081        assert!(!threshold.should_parallelize(50, Par::Seq));
1082        assert!(!threshold.should_parallelize(50, Par::default()));
1083
1084        #[cfg(feature = "parallel")]
1085        {
1086            // Only tests with parallel feature
1087            assert!(threshold.should_parallelize(1000, Par::Rayon));
1088        }
1089    }
1090
1091    #[test]
1092    fn test_global_parallelism() {
1093        // Save current state
1094        let was_enabled = is_parallelism_enabled();
1095
1096        disable_global_parallelism();
1097        assert!(!is_parallelism_enabled());
1098
1099        enable_global_parallelism();
1100        assert!(is_parallelism_enabled());
1101
1102        // Restore
1103        if !was_enabled {
1104            disable_global_parallelism();
1105        }
1106    }
1107
1108    #[test]
1109    fn test_sequential_map_reduce() {
1110        let result = map_reduce(
1111            100,
1112            Par::Seq,
1113            &ParThreshold::default(),
1114            0usize,
1115            |range| range.len(),
1116            |a, b| a + b,
1117        );
1118        assert_eq!(result, 100);
1119    }
1120
1121    // Thread pool tests
1122    #[test]
1123    fn test_sequential_pool() {
1124        let pool = SequentialPool;
1125
1126        assert_eq!(pool.num_threads(), 1);
1127
1128        // Test join
1129        let (a, b) = pool.join(|| 1 + 1, || 2 + 2);
1130        assert_eq!(a, 2);
1131        assert_eq!(b, 4);
1132
1133        // Test for_each
1134        let sum = core::sync::atomic::AtomicUsize::new(0);
1135        pool.for_each(0..10, |i| {
1136            sum.fetch_add(i, core::sync::atomic::Ordering::SeqCst);
1137        });
1138        assert_eq!(sum.load(core::sync::atomic::Ordering::SeqCst), 45);
1139
1140        // Test map_reduce
1141        let result = pool.map_reduce(0..10, 0, |i| i, |a, b| a + b);
1142        assert_eq!(result, 45);
1143    }
1144
1145    #[test]
1146    fn test_pool_scope() {
1147        let pool = SequentialPool;
1148        let scope = PoolScope::new(&pool);
1149
1150        assert_eq!(scope.num_threads(), 1);
1151
1152        // Test map_reduce
1153        let result = scope.map_reduce(100, 0usize, |i| i, |a, b| a + b);
1154        assert_eq!(result, (0..100).sum::<usize>());
1155
1156        // Test for_each
1157        let sum = core::sync::atomic::AtomicUsize::new(0);
1158        scope.for_each(10, |i| {
1159            sum.fetch_add(i, core::sync::atomic::Ordering::SeqCst);
1160        });
1161        assert_eq!(sum.load(core::sync::atomic::Ordering::SeqCst), 45);
1162    }
1163
1164    #[test]
1165    fn test_pool_scope_with_threshold() {
1166        let pool = SequentialPool;
1167        let threshold = ParThreshold::new(50, 10);
1168        let scope = PoolScope::with_threshold(&pool, threshold);
1169
1170        // Should work the same for sequential pool
1171        let result = scope.map_reduce(100, 0usize, |i| i, |a, b| a + b);
1172        assert_eq!(result, (0..100).sum::<usize>());
1173    }
1174
1175    #[test]
1176    fn test_default_pool() {
1177        let pool = default_pool();
1178        // Should have at least 1 thread
1179        assert!(pool.num_threads() >= 1);
1180    }
1181
1182    #[test]
1183    fn test_with_default_pool() {
1184        let result = with_default_pool(|scope| scope.num_threads());
1185        assert!(result >= 1);
1186    }
1187
1188    #[cfg(feature = "parallel")]
1189    #[test]
1190    fn test_rayon_global_pool() {
1191        let pool = RayonGlobalPool;
1192
1193        // Should have multiple threads on most systems
1194        assert!(pool.num_threads() >= 1);
1195
1196        // Test join
1197        let (a, b) = pool.join(|| 1 + 1, || 2 + 2);
1198        assert_eq!(a, 2);
1199        assert_eq!(b, 4);
1200
1201        // Test map_reduce
1202        let result = pool.map_reduce(0..100, 0, |i| i, |a, b| a + b);
1203        assert_eq!(result, (0..100).sum::<usize>());
1204    }
1205
1206    #[cfg(feature = "parallel")]
1207    #[test]
1208    fn test_custom_rayon_pool() {
1209        let pool = CustomRayonPool::new(2).expect("Failed to create pool");
1210
1211        assert_eq!(pool.num_threads(), 2);
1212
1213        // Test map_reduce
1214        let result = pool.map_reduce(0..100, 0, |i| i, |a, b| a + b);
1215        assert_eq!(result, (0..100).sum::<usize>());
1216
1217        // Test install
1218        let result = pool.install(|| (0..100).into_par_iter().sum::<usize>());
1219        assert_eq!(result, (0..100).sum());
1220    }
1221
1222    // ---- OxiblasThreadConfig tests ------------------------------------------
1223
1224    #[cfg(feature = "std")]
1225    #[test]
1226    fn test_thread_config_default() {
1227        let cfg = OxiblasThreadConfig::default();
1228        assert_eq!(cfg.num_threads, 0);
1229        assert_eq!(cfg.stack_size, 0);
1230        assert!(cfg.thread_name.is_none());
1231    }
1232
1233    #[cfg(feature = "std")]
1234    #[test]
1235    fn test_thread_config_builder() {
1236        let cfg = OxiblasThreadConfig::new()
1237            .num_threads(4)
1238            .stack_size(1024 * 1024)
1239            .thread_name("oxiblas-worker");
1240        assert_eq!(cfg.num_threads, 4);
1241        assert_eq!(cfg.stack_size, 1024 * 1024);
1242        assert_eq!(cfg.thread_name.as_deref(), Some("oxiblas-worker"));
1243    }
1244
1245    #[cfg(feature = "std")]
1246    #[test]
1247    fn test_thread_config_effective_threads_zero() {
1248        let cfg = OxiblasThreadConfig::new().num_threads(0);
1249        // effective_threads should fall back to available parallelism (>= 1)
1250        assert!(cfg.effective_threads() >= 1);
1251    }
1252
1253    #[cfg(feature = "std")]
1254    #[test]
1255    fn test_thread_config_effective_threads_explicit() {
1256        let cfg = OxiblasThreadConfig::new().num_threads(3);
1257        assert_eq!(cfg.effective_threads(), 3);
1258    }
1259
1260    #[cfg(feature = "parallel")]
1261    #[test]
1262    fn test_custom_rayon_pool_with_num_threads() {
1263        let pool = CustomRayonPool::with_num_threads(2).expect("build pool");
1264        assert_eq!(pool.num_threads(), 2);
1265        let sum: usize = pool.map_reduce(0..50, 0, |i| i, |a, b| a + b);
1266        assert_eq!(sum, (0..50).sum::<usize>());
1267    }
1268
1269    #[cfg(feature = "parallel")]
1270    #[test]
1271    fn test_oxiblas_thread_config_build_pool() {
1272        let cfg = OxiblasThreadConfig::new().num_threads(2);
1273        let pool = cfg.build_pool().expect("build pool");
1274        assert_eq!(pool.num_threads(), 2);
1275    }
1276
1277    #[cfg(feature = "parallel")]
1278    #[test]
1279    fn test_with_thread_count() {
1280        // Run inside a 2-thread pool and verify rayon sees 2 threads.
1281        with_thread_count(2, || {
1282            assert_eq!(rayon::current_num_threads(), 2);
1283        });
1284    }
1285
1286    #[cfg(not(feature = "parallel"))]
1287    #[test]
1288    fn test_with_thread_count_sequential() {
1289        // Without parallel feature, should just call the closure directly.
1290        let mut called = false;
1291        with_thread_count(4, || {
1292            called = true;
1293        });
1294        assert!(called);
1295    }
1296
1297    #[cfg(feature = "std")]
1298    #[test]
1299    fn test_global_num_threads_default() {
1300        // Before any pool is registered the answer must be at least 1.
1301        // (May be > 1 if a sibling test already set the global pool.)
1302        assert!(global_num_threads() >= 1);
1303    }
1304
1305    // ---- Real-parallelism regression tests ----------------------------------
1306    //
1307    // These deliberately do NOT rely on numeric results (those were already
1308    // correct while parallelism was silently absent). Instead they observe
1309    // *where* work runs — the installed pool's thread count or its distinctive
1310    // thread-name prefix — which is what actually regressed.
1311
1312    /// Finding 4: `Par::RayonWith(n)` must install a pool of exactly `n`
1313    /// threads. Inside that pool `rayon::current_num_threads()` reports `n`;
1314    /// the pre-fix code ran on the ambient global pool and reported its size.
1315    #[cfg(feature = "parallel")]
1316    #[test]
1317    fn test_rayon_with_installs_n_thread_pool() {
1318        let observed3 = run_in_pool(Par::RayonWith(3), rayon::current_num_threads);
1319        assert_eq!(observed3, 3, "RayonWith(3) did not install a 3-thread pool");
1320        let observed5 = run_in_pool(Par::RayonWith(5), rayon::current_num_threads);
1321        assert_eq!(observed5, 5, "RayonWith(5) did not install a 5-thread pool");
1322        // Cached: a second request for 3 threads reuses the same-sized pool.
1323        let again = run_in_pool(Par::RayonWith(3), rayon::current_num_threads);
1324        assert_eq!(again, 3, "cached RayonWith(3) pool changed size");
1325    }
1326
1327    /// Finding 4 (public path): `for_each_range` with `RayonWith` must tile the
1328    /// whole domain exactly once regardless of how the ranges are dispatched.
1329    #[cfg(feature = "parallel")]
1330    #[test]
1331    fn test_for_each_range_rayon_with_covers_domain() {
1332        enable_global_parallelism();
1333        let covered = core::sync::atomic::AtomicUsize::new(0);
1334        let low = ParThreshold::new(1, 1);
1335        for_each_range(4_096, Par::RayonWith(4), &low, |range| {
1336            covered.fetch_add(range.len(), core::sync::atomic::Ordering::SeqCst);
1337        });
1338        assert_eq!(
1339            covered.load(core::sync::atomic::Ordering::SeqCst),
1340            4_096,
1341            "RayonWith ranges did not tile the domain"
1342        );
1343    }
1344
1345    /// Finding 3: `set_global_thread_pool` must make `Par::Rayon` actually run
1346    /// on the registered pool (pre-fix it was stored but never used). This is
1347    /// the ONLY test that registers a global pool, so its `set` always wins the
1348    /// process-wide `OnceLock` regardless of test ordering. The registered pool
1349    /// carries a distinctive thread-name prefix; the ambient rayon pool's
1350    /// threads are unnamed, so a name match proves the work ran on our pool.
1351    #[cfg(feature = "parallel")]
1352    #[test]
1353    fn test_global_pool_executes_on_registered_pool() {
1354        let pool = OxiblasThreadConfig::new()
1355            .num_threads(3)
1356            .thread_name("oxiblas-global-test")
1357            .build_pool()
1358            .expect("build named global pool");
1359        assert_eq!(pool.num_threads(), 3);
1360        set_global_thread_pool(pool);
1361        assert_eq!(
1362            global_num_threads(),
1363            3,
1364            "registered pool size not reflected"
1365        );
1366
1367        let names = std::sync::Mutex::new(std::collections::HashSet::new());
1368        run_in_pool(Par::Rayon, || {
1369            (0..4_096usize).into_par_iter().for_each(|_| {
1370                if let Some(name) = std::thread::current().name() {
1371                    names
1372                        .lock()
1373                        .unwrap_or_else(|poisoned| poisoned.into_inner())
1374                        .insert(name.to_string());
1375                }
1376            });
1377        });
1378        let names = names
1379            .into_inner()
1380            .unwrap_or_else(|poisoned| poisoned.into_inner());
1381        assert!(
1382            !names.is_empty(),
1383            "no named worker observed; work did not run on the registered pool"
1384        );
1385        assert!(
1386            names.iter().all(|n| n.starts_with("oxiblas-global-test")),
1387            "Par::Rayon ran on unexpected threads: {names:?}"
1388        );
1389    }
1390
1391    /// Finding 5: `PoolScope::for_each_range`'s parallel branch must dispatch
1392    /// across the pool, not loop on the caller thread. The pool's workers carry
1393    /// a distinctive name prefix; the calling (test) thread does not, so an
1394    /// all-prefixed observation proves off-caller execution.
1395    #[cfg(feature = "parallel")]
1396    #[test]
1397    fn test_pool_scope_for_each_range_runs_on_pool() {
1398        let pool = OxiblasThreadConfig::new()
1399            .num_threads(3)
1400            .thread_name("oxiblas-scope-test")
1401            .build_pool()
1402            .expect("build named scope pool");
1403        let scope = PoolScope::with_threshold(&pool, ParThreshold::new(1, 1));
1404
1405        let names = std::sync::Mutex::new(std::collections::HashSet::new());
1406        let covered = core::sync::atomic::AtomicUsize::new(0);
1407        let total = 96usize;
1408        scope.for_each_range(total, |range| {
1409            covered.fetch_add(range.len(), core::sync::atomic::Ordering::SeqCst);
1410            let name = std::thread::current().name().map(str::to_string);
1411            names
1412                .lock()
1413                .unwrap_or_else(|poisoned| poisoned.into_inner())
1414                .insert(name);
1415        });
1416        let names = names
1417            .into_inner()
1418            .unwrap_or_else(|poisoned| poisoned.into_inner());
1419        assert!(
1420            names.iter().all(|n| n
1421                .as_deref()
1422                .is_some_and(|n| n.starts_with("oxiblas-scope-test"))),
1423            "ranges executed off-pool (likely on the caller thread): {names:?}"
1424        );
1425        assert_eq!(
1426            covered.load(core::sync::atomic::Ordering::SeqCst),
1427            total,
1428            "PoolScope ranges did not tile the domain"
1429        );
1430    }
1431
1432    /// Finding 6: `ThreadLocalAccum::reduce` must be infallible by construction
1433    /// (never the pre-fix `.expect` on a possibly-empty iterator) and must
1434    /// recombine the per-worker shards correctly.
1435    #[cfg(feature = "parallel")]
1436    #[test]
1437    fn test_thread_local_accum_reduce_infallible() {
1438        // Identity 0, no contributions: reduce over >= 1 shard yields 0 and
1439        // never panics.
1440        let empty = ThreadLocalAccum::new(0i64);
1441        assert_eq!(empty.reduce(|a, b| a + b), 0);
1442
1443        // Parallel accumulation via per-worker shards recombines to the total.
1444        let accum = ThreadLocalAccum::new(0i64);
1445        (0..1_000i64).into_par_iter().for_each(|x| {
1446            let mut shard = accum.get();
1447            *shard += x;
1448        });
1449        assert_eq!(accum.reduce(|a, b| a + b), (0..1_000i64).sum::<i64>());
1450    }
1451}