Skip to main content

rlx_cpu/
pool.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Rayon-backed parallel for: `par_for(total, grain, |off, cnt| …)`.
6//!
7//! Replaces the old per-worker Condvar pool with Rayon's work-stealing
8//! scheduler. Same `(offset, count)` chunk API so all existing call
9//! sites (BLAS tiling, SDPA, LayerNorm, …) pick up Rayon without
10//! changes.
11
12#[cfg(not(target_arch = "wasm32"))]
13use rayon::prelude::*;
14#[cfg(not(target_arch = "wasm32"))]
15use std::sync::Once;
16
17#[cfg(not(target_arch = "wasm32"))]
18static POOL_INIT: Once = Once::new();
19
20#[cfg(not(target_arch = "wasm32"))]
21fn ensure_pool() {
22    POOL_INIT.call_once(|| {
23        let cfg = crate::config::RuntimeConfig::global();
24        let n = cfg.pool_workers.max(1);
25        let _ = rayon::ThreadPoolBuilder::new()
26            .num_threads(n)
27            .thread_name(|i| format!("rlx-rayon-{i}"))
28            .build_global();
29        // Pin OpenBLAS/MKL/Accelerate to 1 thread — Rayon owns outer
30        // parallelism (par_sgemm splits). Nested N² threads are catastrophic.
31        crate::blas::limit_inner_threads();
32    });
33}
34
35/// Total Rayon worker count (configured from [`RuntimeConfig::pool_workers`]).
36///
37/// On wasm there is no thread pool — the browser is single-threaded — so
38/// this is always 1.
39#[cfg(not(target_arch = "wasm32"))]
40pub fn num_threads() -> usize {
41    ensure_pool();
42    rayon::current_num_threads()
43}
44
45#[cfg(target_arch = "wasm32")]
46pub fn num_threads() -> usize {
47    1
48}
49
50/// Parallel for: split `total` items across threads. `f(off, cnt)` is
51/// called once per chunk with disjoint regions.
52///
53/// SAFETY: caller must ensure `f` accesses disjoint memory regions for
54/// different `(offset, count)` pairs.
55/// Parallel `for i in 0..n` over an index range, one task per index.
56/// Serial on wasm (no thread pool). The closure must be `Sync + Send`
57/// because tasks may run on Rayon workers natively.
58#[inline]
59pub fn par_range<F: Fn(usize) + Sync + Send>(n: usize, f: F) {
60    #[cfg(target_arch = "wasm32")]
61    {
62        (0..n).for_each(f);
63    }
64    #[cfg(not(target_arch = "wasm32"))]
65    {
66        (0..n).into_par_iter().for_each(f);
67    }
68}
69
70/// Minimum elements that make threading an element-wise kernel worthwhile.
71/// Below this the rayon hand-off costs more than the work it saves. Kept in
72/// one place so conv/transpose/pool/relu/region kernels share a single,
73/// tunable cutover instead of sprinkling magic constants at each call site.
74pub const ELEMENTWISE_PAR_FLOOR: usize = 4096;
75
76/// Should an op over `work` elements run in parallel? True only with >1 worker
77/// and enough work to keep them busy past the amortization floor. Use this at
78/// every data-parallel kernel call site instead of a hand-picked threshold.
79#[inline]
80pub fn should_parallelize(work: usize) -> bool {
81    num_threads() > 1 && work >= ELEMENTWISE_PAR_FLOOR * 2
82}
83
84/// `par_for` chunk floor for a flat range of `total` elements: aim for one
85/// chunk per worker, never below the amortization floor.
86#[inline]
87pub fn chunk_floor(total: usize) -> usize {
88    (total / num_threads().max(1)).max(ELEMENTWISE_PAR_FLOOR)
89}
90
91/// `par_for` chunk floor for an *outer* loop of `units` independent items
92/// (e.g. (n,c) planes): one chunk per worker, at least one unit.
93#[inline]
94pub fn outer_chunk(units: usize) -> usize {
95    (units / num_threads().max(1)).max(1)
96}
97
98#[inline]
99pub fn par_for<F: Fn(usize, usize) + Sync>(total: usize, min_per_thread: usize, f: &F) {
100    if total == 0 {
101        return;
102    }
103    // wasm is single-threaded: run the whole range inline. (Rayon's
104    // work-stealing scheduler needs OS threads, which the browser lacks.)
105    #[cfg(target_arch = "wasm32")]
106    {
107        let _ = min_per_thread;
108        f(0, total);
109    }
110    #[cfg(not(target_arch = "wasm32"))]
111    {
112        ensure_pool();
113        let grain = min_per_thread.max(1);
114        let n_threads = (total / grain).max(1).min(num_threads());
115        if n_threads <= 1 {
116            f(0, total);
117            return;
118        }
119        // Pin BLAS to 1 before Rayon fan-out so a preceding huge MT GEMM
120        // cannot oversubscribe with worker-local sgemm / elementwise work.
121        crate::blas::ensure_blas_threads(1);
122        let chunk = total.div_ceil(n_threads);
123        (0..n_threads).into_par_iter().for_each(|t| {
124            let off = t * chunk;
125            if off < total {
126                f(off, (off + chunk).min(total) - off);
127            }
128        });
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::sync::atomic::{AtomicU64, Ordering};
136
137    #[test]
138    fn par_for_sums_correctly() {
139        let data = vec![1.0f32; 10_000];
140        let total = AtomicU64::new(0);
141
142        par_for(data.len(), 100, &|off, cnt| {
143            let partial: f32 = data[off..off + cnt].iter().sum();
144            total.fetch_add(partial.to_bits() as u64, Ordering::Relaxed);
145        });
146
147        assert!(total.load(Ordering::Relaxed) > 0);
148    }
149
150    #[test]
151    fn par_for_small_is_sequential() {
152        let sum = std::sync::atomic::AtomicUsize::new(0);
153        par_for(10, 100, &|off, cnt| {
154            sum.fetch_add(cnt, Ordering::Relaxed);
155            assert_eq!(off + cnt, 10);
156        });
157        assert_eq!(sum.load(Ordering::Relaxed), 10);
158    }
159
160    #[test]
161    fn par_for_exact_sum_many_dispatches() {
162        for &n in &[256usize, 1024, 4097] {
163            let sum = std::sync::atomic::AtomicUsize::new(0);
164            par_for(n, 256, &|off, cnt| {
165                sum.fetch_add(cnt, Ordering::Relaxed);
166                assert!(off + cnt <= n);
167            });
168            assert_eq!(sum.load(Ordering::Relaxed), n);
169        }
170    }
171
172    #[test]
173    fn par_for_concurrent_callers_isolated() {
174        std::thread::scope(|s| {
175            for t in 0..4 {
176                s.spawn(move || {
177                    let n = 4096 + t * 17;
178                    let sum = std::sync::atomic::AtomicUsize::new(0);
179                    par_for(n, 128, &|off, cnt| {
180                        sum.fetch_add(cnt, Ordering::Relaxed);
181                        assert!(off + cnt <= n);
182                    });
183                    assert_eq!(sum.load(Ordering::Relaxed), n);
184                });
185            }
186        });
187    }
188}