Skip to main content

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}