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