tract_linalg/multithread.rs
1use std::cell::RefCell;
2#[cfg(feature = "multithread-mm")]
3use std::sync::atomic::{AtomicUsize, Ordering};
4#[allow(unused_imports)]
5use std::sync::{Arc, Mutex};
6
7#[cfg(feature = "multithread-mm")]
8use rayon::{ThreadPool, ThreadPoolBuilder};
9
10#[cfg(feature = "multithread-mm")]
11use tract_data::internal::vector_size;
12use tract_data::internal::{Tensor, TensorView, TractResult, ensure};
13
14use crate::LinalgFn;
15
16#[derive(Debug, Clone, Default)]
17pub enum Executor {
18 #[default]
19 SingleThread,
20 #[cfg(feature = "multithread-mm")]
21 MultiThread(Arc<ThreadPool>),
22 /// Use rayon's GLOBAL thread pool — the one set up by
23 /// `wasm_bindgen_rayon::init_thread_pool` on `wasm32-unknown-unknown`,
24 /// or rayon's auto-initialised default on native.
25 ///
26 /// Exists because `Arc<rayon::ThreadPool>` cannot be constructed on
27 /// `wasm32-unknown-unknown`: rayon's default `spawn_handler` calls
28 /// `std::thread::spawn`, which is unsupported there. The only working
29 /// route is rayon's global pool, accessed via `into_par_iter` directly.
30 #[cfg(feature = "multithread-mm")]
31 RayonGlobal,
32}
33
34impl Executor {
35 #[cfg(feature = "multithread-mm")]
36 pub fn multithread(n: usize) -> Executor {
37 Executor::multithread_with_name(n, "tract-default")
38 }
39
40 #[cfg(feature = "multithread-mm")]
41 pub fn multithread_with_name(n: usize, name: &str) -> Executor {
42 let name = name.to_string();
43 let pool = ThreadPoolBuilder::new()
44 .thread_name(move |n| format!("{name}-{n}"))
45 .num_threads(n)
46 .build()
47 .unwrap();
48 Executor::MultiThread(Arc::new(pool))
49 }
50}
51
52static DEFAULT_EXECUTOR: Mutex<Executor> = Mutex::new(Executor::SingleThread);
53
54thread_local! {
55 static TLS_EXECUTOR_OVERRIDE: RefCell<Option<Executor>> = Default::default();
56}
57
58pub fn current_tract_executor() -> Executor {
59 if let Some(over_ride) = TLS_EXECUTOR_OVERRIDE.with_borrow(|tls| tls.clone()) {
60 over_ride
61 } else {
62 DEFAULT_EXECUTOR.lock().unwrap().clone()
63 }
64}
65
66pub fn set_default_executor(executor: Executor) {
67 *DEFAULT_EXECUTOR.lock().unwrap() = executor;
68}
69
70pub fn multithread_tract_scope<R, F: FnOnce() -> R>(pool: Executor, f: F) -> R {
71 let previous = TLS_EXECUTOR_OVERRIDE.replace(Some(pool));
72 let result = f();
73 TLS_EXECUTOR_OVERRIDE.set(previous);
74 result
75}
76
77/// Threshold (in panels) below which the rayon MMM dispatcher skips
78/// parallelism and runs inline single-threaded. Below this size,
79/// per-call dispatch overhead (~5 µs native, ~50 µs wasm-bindgen-rayon
80/// worker) exceeds the parallel speedup.
81///
82/// Default `64`. Tune higher for many-small-MMM workloads (mobile vision,
83/// streaming RNN) or lower for transformer-class workloads where every MMM
84/// is large. `0` disables the gate entirely (always thread).
85#[cfg(feature = "multithread-mm")]
86static THREADING_PANEL_THRESHOLD: AtomicUsize = AtomicUsize::new(64);
87
88/// Read the current MMM panel-count threshold for the rayon path.
89#[cfg(feature = "multithread-mm")]
90pub fn current_threading_panel_threshold() -> usize {
91 THREADING_PANEL_THRESHOLD.load(Ordering::Relaxed)
92}
93
94/// Set the MMM panel-count threshold for the rayon path. Default is `64`.
95/// Pass `0` to thread regardless of size.
96#[cfg(feature = "multithread-mm")]
97pub fn set_threading_panel_threshold(panels: usize) {
98 THREADING_PANEL_THRESHOLD.store(panels, Ordering::Relaxed);
99}
100
101/// Threshold (in tensor elements) below which [`par_chunks_mut`] skips
102/// parallelism and runs its body inline single-threaded. Below this much work,
103/// per-dispatch overhead exceeds the parallel speedup. Distinct from
104/// `THREADING_PANEL_THRESHOLD`: this counts elements of work, not MMM panels.
105///
106/// Default `32768`. `0` disables the gate entirely (always thread).
107#[cfg(feature = "multithread-mm")]
108static THREADING_ELEMENT_THRESHOLD: AtomicUsize = AtomicUsize::new(32768);
109
110/// Read the current element-count threshold for the row-parallel path.
111#[cfg(feature = "multithread-mm")]
112pub fn current_threading_element_threshold() -> usize {
113 THREADING_ELEMENT_THRESHOLD.load(Ordering::Relaxed)
114}
115
116/// Set the element-count threshold for the row-parallel path. Default is
117/// `32768`. Pass `0` to thread regardless of size.
118#[cfg(feature = "multithread-mm")]
119pub fn set_threading_element_threshold(elements: usize) {
120 THREADING_ELEMENT_THRESHOLD.store(elements, Ordering::Relaxed);
121}
122
123/// Process `out` in parallel over its outer (row) axis, dispatching across the
124/// executor installed by [`multithread_tract_scope`]. Falls back to a single
125/// inline `f(0, out)` when the executor is single-threaded (including a
126/// one-thread pool), when there are fewer than two rows, or when `total_elems`
127/// is below [`current_threading_element_threshold`].
128///
129/// `out` is viewed as `out.len() / row_len` contiguous rows of `row_len`
130/// elements (`row_len` must divide `out.len()`). Work is split only on row
131/// boundaries, never inside a row, so any per-row reduction the closure runs
132/// keeps its accumulation order and the output is bit-identical to the inline
133/// path regardless of thread count.
134///
135/// The closure receives `(first_row, chunk)`: `chunk` is a contiguous block of
136/// whole rows and `first_row` is the index of its first row within `out`, used
137/// to index sibling buffers captured from the caller (e.g. an out-of-place
138/// reduce whose input row is `reduced_dim` wide while `out` rows are width 1).
139/// For such callers `total_elems` is the size of the data actually read, which
140/// can exceed `out.len()`.
141///
142/// The signature is identical with or without the `multithread-mm` feature so
143/// callers compile unchanged; without the feature the body is just `f(0, out)`.
144pub fn par_chunks_mut<T: Send>(
145 out: &mut [T],
146 row_len: usize,
147 total_elems: usize,
148 f: impl Fn(usize, &mut [T]) -> TractResult<()> + Sync + Send,
149) -> TractResult<()> {
150 #[cfg(feature = "multithread-mm")]
151 {
152 use rayon::prelude::*;
153 debug_assert!(row_len >= 1 && out.len() % row_len == 0);
154 let n_rows = out.len() / row_len;
155 if n_rows < 2 || total_elems < current_threading_element_threshold() {
156 return f(0, out);
157 }
158 let run = |out: &mut [T]| -> TractResult<()> {
159 let n_chunks = (4 * rayon::current_num_threads()).min(n_rows);
160 let chunk_rows = n_rows.div_ceil(n_chunks);
161 out.par_chunks_mut(chunk_rows * row_len)
162 .enumerate()
163 .try_for_each(|(i, chunk)| f(i * chunk_rows, chunk))
164 };
165 match current_tract_executor() {
166 Executor::MultiThread(pool) if pool.current_num_threads() > 1 => {
167 pool.install(|| run(out))
168 }
169 Executor::RayonGlobal => run(out),
170 // SingleThread, or a one-thread MultiThread pool, runs inline serially.
171 _ => f(0, out),
172 }
173 }
174 #[cfg(not(feature = "multithread-mm"))]
175 {
176 let _ = (row_len, total_elems);
177 f(0, out)
178 }
179}
180
181/// How `b` maps onto the blocks [`par_bin`] splits `a` into.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum BShare {
184 /// `b` is one `period`-element row that every block of `a` consumes in
185 /// lockstep, as the unicast kernels do. Requires `b.len() == period`.
186 Lockstep,
187 /// `b` holds one scalar per block of `a`, as the by-scalar kernels do:
188 /// block `i` reads `b[i]`. Requires `b.len() == a.len() / period`.
189 PerBlock,
190}
191
192/// Apply the linalg binary kernel `eval_fn` to `a` in place, splitting the work
193/// across the executor installed by [`multithread_tract_scope`].
194///
195/// `a` is `a.len() / period` blocks of `period` elements, `period` being how many
196/// `a` elements one kernel call covers; `share` says how `b` lines up with those
197/// blocks. A block is the coarsest unit a single call may span, because the
198/// unicast kernels walk `b` in lockstep and index past its end once `a` runs
199/// beyond one period. Inside a block the split lands on `vector_size()`-element
200/// boundaries, which keeps `a`'s and `b`'s offsets congruent modulo the kernel
201/// alignment — `unicast_with_alignment` hard-asserts that congruence.
202/// `OptBinUnicast::check_b_alignement` is what guarantees `period` itself is such
203/// a multiple whenever there is more than one block.
204///
205/// An empty `a` is a no-op: the kernels cannot take one, as `as_slice_mut` would
206/// build a slice from the null data pointer.
207///
208/// Both tensors must have natural C-order strides, which the callers' stride
209/// guards establish, and plain storage, which holds because no linalg binary
210/// kernel is registered for a datum type that lacks it. The byte offsets computed
211/// here are only correct under both.
212///
213/// These kernels are pure elementwise, so no chunk boundary can change a result:
214/// the output is bit-identical to the serial path at any thread count.
215pub fn par_bin(
216 eval_fn: &LinalgFn,
217 a: &mut Tensor,
218 b: &Tensor,
219 period: usize,
220 share: BShare,
221) -> TractResult<()> {
222 if a.len() == 0 {
223 return Ok(());
224 }
225 ensure!(
226 a.len().is_multiple_of(period),
227 "par_bin: period {period} does not divide a.len() {}",
228 a.len()
229 );
230 let n_blocks = a.len() / period;
231 match share {
232 BShare::Lockstep => ensure!(
233 b.len() == period,
234 "par_bin: Lockstep wants b.len() {} == period {period}",
235 b.len()
236 ),
237 BShare::PerBlock => ensure!(
238 b.len() == n_blocks,
239 "par_bin: PerBlock wants b.len() {} == {n_blocks} blocks",
240 b.len()
241 ),
242 }
243
244 let a_item = a.datum_type().size_of() as isize;
245 let b_item = b.datum_type().size_of() as isize;
246 let a = &*a;
247 // `len` elements of block `block`, starting `offset` elements into it. Both
248 // tensors are naturally strided, so a's flat element index is
249 // `block * period + offset` and b's is `offset` (Lockstep) or `block`
250 // (PerBlock).
251 let call = |block: usize, offset: usize, len: usize| -> TractResult<()> {
252 static STRIDES: [isize; 1] = [1];
253 let a_shape = [len];
254 let (b_offset, b_shape) = match share {
255 BShare::Lockstep => (offset as isize * b_item, [len]),
256 BShare::PerBlock => (block as isize * b_item, [1]),
257 };
258 let a_offset = (block * period + offset) as isize * a_item;
259 // `offset + len <= period` and blocks are disjoint, so the byte range
260 // `[a_offset, a_offset + len * a_item)` is disjoint across every
261 // (block, offset) the dispatch below enumerates. That is what makes the
262 // concurrent writes through these views non-aliasing; keep it true if the
263 // chunk arithmetic changes.
264 unsafe {
265 let mut a_chunk = TensorView::from_bytes(a, a_offset, &a_shape, &STRIDES);
266 let b_chunk = TensorView::from_bytes(b, b_offset, &b_shape, &STRIDES);
267 eval_fn(&mut a_chunk, &b_chunk)
268 }
269 };
270
271 #[cfg(feature = "multithread-mm")]
272 {
273 use rayon::prelude::*;
274 // Threshold first: reading the executor takes a global lock, and a graph
275 // has hundreds of these nodes sitting below the threshold.
276 if a.len() >= current_threading_element_threshold() {
277 let executor = current_tract_executor();
278 let nth = match &executor {
279 Executor::MultiThread(pool) => pool.current_num_threads(),
280 Executor::RayonGlobal => rayon::current_num_threads(),
281 Executor::SingleThread => 1,
282 };
283 if nth > 1 {
284 let per_block = (4 * nth).div_ceil(n_blocks).max(1);
285 let chunk = period.div_ceil(per_block).next_multiple_of(vector_size()).min(period);
286 let per_block = period.div_ceil(chunk);
287 if n_blocks * per_block > 1 {
288 let run = || {
289 (0..n_blocks * per_block).into_par_iter().try_for_each(|i| {
290 let offset = (i % per_block) * chunk;
291 call(i / per_block, offset, chunk.min(period - offset))
292 })
293 };
294 return match executor {
295 Executor::MultiThread(pool) => pool.install(run),
296 _ => run(),
297 };
298 }
299 }
300 }
301 }
302
303 // A single block is the whole tensor, so hand the kernel the natural view
304 // rather than a rank-1 one: fewer shapes for a future kernel to have to
305 // tolerate on the overwhelmingly common path.
306 if n_blocks == 1 {
307 return eval_fn(&mut a.view(), &b.view());
308 }
309 (0..n_blocks).try_for_each(|block| call(block, 0, period))
310}