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
10use tract_data::internal::num_integer::Integer;
11use tract_data::internal::vector_size;
12use tract_data::internal::{Tensor, TensorView, TractResult, ensure};
13
14use crate::BinFn;
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    /// A private `n`-thread rayon pool.
41    ///
42    /// On wasm targets that cannot spawn threads (`wasm32-wasip1`,
43    /// `wasm32-unknown-unknown`) this quietly returns [`Executor::RayonGlobal`]
44    /// instead: the pool `wasm_bindgen_rayon` set up if there is one, else
45    /// rayon's current-thread fallback, so callers need no wasm special case.
46    #[cfg(feature = "multithread-mm")]
47    pub fn multithread_with_name(n: usize, name: &str) -> Executor {
48        let name = name.to_string();
49        match ThreadPoolBuilder::new()
50            .thread_name(move |n| format!("{name}-{n}"))
51            .num_threads(n)
52            .build()
53        {
54            Ok(pool) => Executor::MultiThread(Arc::new(pool)),
55            Err(_) if cfg!(target_family = "wasm") => Executor::RayonGlobal,
56            Err(e) => panic!("Failed to build a {n}-thread pool: {e}"),
57        }
58    }
59}
60
61static DEFAULT_EXECUTOR: Mutex<Executor> = Mutex::new(Executor::SingleThread);
62
63thread_local! {
64    static TLS_EXECUTOR_OVERRIDE: RefCell<Option<Executor>> = Default::default();
65}
66
67pub fn current_tract_executor() -> Executor {
68    if let Some(over_ride) = TLS_EXECUTOR_OVERRIDE.with_borrow(|tls| tls.clone()) {
69        over_ride
70    } else {
71        DEFAULT_EXECUTOR.lock().unwrap().clone()
72    }
73}
74
75pub fn set_default_executor(executor: Executor) {
76    *DEFAULT_EXECUTOR.lock().unwrap() = executor;
77}
78
79pub fn multithread_tract_scope<R, F: FnOnce() -> R>(pool: Executor, f: F) -> R {
80    let previous = TLS_EXECUTOR_OVERRIDE.replace(Some(pool));
81    let result = f();
82    TLS_EXECUTOR_OVERRIDE.set(previous);
83    result
84}
85
86/// Threshold (in panels) below which the rayon MMM dispatcher skips
87/// parallelism and runs inline single-threaded. Below this size,
88/// per-call dispatch overhead (~5 µs native, ~50 µs wasm-bindgen-rayon
89/// worker) exceeds the parallel speedup.
90///
91/// Default `64`. Tune higher for many-small-MMM workloads (mobile vision,
92/// streaming RNN) or lower for transformer-class workloads where every MMM
93/// is large. `0` disables the gate entirely (always thread).
94#[cfg(feature = "multithread-mm")]
95static THREADING_PANEL_THRESHOLD: AtomicUsize = AtomicUsize::new(64);
96
97/// Read the current MMM panel-count threshold for the rayon path.
98#[cfg(feature = "multithread-mm")]
99pub fn current_threading_panel_threshold() -> usize {
100    THREADING_PANEL_THRESHOLD.load(Ordering::Relaxed)
101}
102
103/// Set the MMM panel-count threshold for the rayon path. Default is `64`.
104/// Pass `0` to thread regardless of size.
105#[cfg(feature = "multithread-mm")]
106pub fn set_threading_panel_threshold(panels: usize) {
107    THREADING_PANEL_THRESHOLD.store(panels, Ordering::Relaxed);
108}
109
110/// Multiply-accumulate count at or above which the rayon MMM dispatcher
111/// threads a matrix-vector product even when it has fewer panels than
112/// [`current_threading_panel_threshold`]: a long-`k` GEMV over few panels
113/// carries enough work per panel to amortise the dispatch. It only ever adds
114/// threading on top of the panel gate, and leaves matrix-matrix products to
115/// the panel gate alone.
116///
117/// Default `2^21` on native targets; `usize::MAX` (off) on wasm, where the
118/// per-dispatch cost is an order of magnitude higher.
119#[cfg(feature = "multithread-mm")]
120static THREADING_MAC_THRESHOLD: AtomicUsize =
121    AtomicUsize::new(if cfg!(target_family = "wasm") { usize::MAX } else { 1 << 21 });
122
123/// Read the current MMM multiply-accumulate threshold for the rayon path.
124#[cfg(feature = "multithread-mm")]
125pub fn current_threading_mac_threshold() -> usize {
126    THREADING_MAC_THRESHOLD.load(Ordering::Relaxed)
127}
128
129/// Set the MMM multiply-accumulate threshold for the rayon path. Pass
130/// `usize::MAX` to leave the decision to the panel gate alone.
131#[cfg(feature = "multithread-mm")]
132pub fn set_threading_mac_threshold(macs: usize) {
133    THREADING_MAC_THRESHOLD.store(macs, Ordering::Relaxed);
134}
135
136/// Threshold (in tensor elements) below which [`par_chunks_mut`] skips
137/// parallelism and runs its body inline single-threaded. Below this much work,
138/// per-dispatch overhead exceeds the parallel speedup. Distinct from
139/// `THREADING_PANEL_THRESHOLD`: this counts elements of work, not MMM panels.
140///
141/// Default `32768`. `0` disables the gate entirely (always thread).
142#[cfg(feature = "multithread-mm")]
143static THREADING_ELEMENT_THRESHOLD: AtomicUsize = AtomicUsize::new(32768);
144
145/// Read the current element-count threshold for the row-parallel path.
146#[cfg(feature = "multithread-mm")]
147pub fn current_threading_element_threshold() -> usize {
148    THREADING_ELEMENT_THRESHOLD.load(Ordering::Relaxed)
149}
150
151/// Set the element-count threshold for the row-parallel path. Default is
152/// `32768`. Pass `0` to thread regardless of size.
153#[cfg(feature = "multithread-mm")]
154pub fn set_threading_element_threshold(elements: usize) {
155    THREADING_ELEMENT_THRESHOLD.store(elements, Ordering::Relaxed);
156}
157
158/// Process `out` in parallel over its outer (row) axis, dispatching across the
159/// executor installed by [`multithread_tract_scope`]. Falls back to a single
160/// inline `f(0, out)` when the executor is single-threaded (including a
161/// one-thread pool), when there are fewer than two rows, or when `total_elems`
162/// is below [`current_threading_element_threshold`].
163///
164/// `out` is viewed as `out.len() / row_len` contiguous rows of `row_len`
165/// elements (`row_len` must divide `out.len()`). Work is split only on row
166/// boundaries, never inside a row, so any per-row reduction the closure runs
167/// keeps its accumulation order and the output is bit-identical to the inline
168/// path regardless of thread count.
169///
170/// The closure receives `(first_row, chunk)`: `chunk` is a contiguous block of
171/// whole rows and `first_row` is the index of its first row within `out`, used
172/// to index sibling buffers captured from the caller (e.g. an out-of-place
173/// reduce whose input row is `reduced_dim` wide while `out` rows are width 1).
174/// For such callers `total_elems` is the size of the data actually read, which
175/// can exceed `out.len()`.
176///
177/// The signature is identical with or without the `multithread-mm` feature so
178/// callers compile unchanged; without the feature the body is just `f(0, out)`.
179pub fn par_chunks_mut<T: Send>(
180    out: &mut [T],
181    row_len: usize,
182    total_elems: usize,
183    f: impl Fn(usize, &mut [T]) -> TractResult<()> + Sync + Send,
184) -> TractResult<()> {
185    #[cfg(feature = "multithread-mm")]
186    {
187        use rayon::prelude::*;
188        debug_assert!(row_len >= 1 && out.len() % row_len == 0);
189        let n_rows = out.len() / row_len;
190        if n_rows < 2 || total_elems < current_threading_element_threshold() {
191            return f(0, out);
192        }
193        let run = |out: &mut [T]| -> TractResult<()> {
194            let n_chunks = (4 * rayon::current_num_threads()).min(n_rows);
195            let chunk_rows = n_rows.div_ceil(n_chunks);
196            out.par_chunks_mut(chunk_rows * row_len)
197                .enumerate()
198                .try_for_each(|(i, chunk)| f(i * chunk_rows, chunk))
199        };
200        match current_tract_executor() {
201            Executor::MultiThread(pool) if pool.current_num_threads() > 1 => {
202                pool.install(|| run(out))
203            }
204            Executor::RayonGlobal => run(out),
205            // SingleThread, or a one-thread MultiThread pool, runs inline serially.
206            _ => f(0, out),
207        }
208    }
209    #[cfg(not(feature = "multithread-mm"))]
210    {
211        let _ = (row_len, total_elems);
212        f(0, out)
213    }
214}
215
216/// How `b` maps onto the blocks [`par_bin`] splits `a` into.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum BShare {
219    /// `b` is one `period`-element row that every block of `a` consumes in
220    /// lockstep, as the unicast kernels do. Requires `b.len() == period`.
221    Lockstep,
222    /// `b` holds one scalar per block of `a`, as the by-scalar kernels do:
223    /// block `i` reads `b[i]`. Requires `b.len() == a.len() / period`.
224    PerBlock,
225}
226
227/// Apply the linalg binary kernel `eval_fn` to `a` in place, splitting the work
228/// across the executor installed by [`multithread_tract_scope`].
229///
230/// `a` is `a.len() / period` blocks of `period` elements, `period` being how many
231/// `a` elements one kernel call covers; `share` says how `b` lines up with those
232/// blocks. A block is the coarsest unit a single call may span, because the
233/// unicast kernels walk `b` in lockstep and index past its end once `a` runs
234/// beyond one period. Inside a block the split lands on `vector_size()`-element
235/// boundaries, which keeps `a`'s and `b`'s offsets congruent modulo the kernel
236/// alignment — `unicast_with_alignment` hard-asserts that congruence.
237/// `OptBinUnicast::check_b_alignement` is what guarantees `period` itself is such
238/// a multiple whenever there is more than one block.
239///
240/// An empty `a` is a no-op: the kernels cannot take one, as `as_slice_mut` would
241/// build a slice from the null data pointer.
242///
243/// Both tensors must have natural C-order strides, which the callers' stride
244/// guards establish, and plain storage, which holds because no linalg binary
245/// kernel is registered for a datum type that lacks it. The byte offsets computed
246/// here are only correct under both.
247///
248/// These kernels are pure elementwise, so no chunk boundary can change a result:
249/// the output is bit-identical to the serial path at any thread count.
250pub fn par_bin(
251    eval_fn: &BinFn,
252    a: &mut Tensor,
253    b: &Tensor,
254    period: usize,
255    share: BShare,
256) -> TractResult<()> {
257    if a.len() == 0 {
258        return Ok(());
259    }
260    ensure!(
261        a.len().is_multiple_of(period),
262        "par_bin: period {period} does not divide a.len() {}",
263        a.len()
264    );
265    let n_blocks = a.len() / period;
266    match share {
267        BShare::Lockstep => ensure!(
268            b.len() == period,
269            "par_bin: Lockstep wants b.len() {} == period {period}",
270            b.len()
271        ),
272        BShare::PerBlock => ensure!(
273            b.len() == n_blocks,
274            "par_bin: PerBlock wants b.len() {} == {n_blocks} blocks",
275            b.len()
276        ),
277    }
278
279    let a_item = a.datum_type().size_of() as isize;
280    let b_item = b.datum_type().size_of() as isize;
281    let a = &*a;
282    // `len` elements of block `block`, starting `offset` elements into it. Both
283    // tensors are naturally strided, so a's flat element index is
284    // `block * period + offset` and b's is `offset` (Lockstep) or `block`
285    // (PerBlock).
286    let call = |block: usize, offset: usize, len: usize| -> TractResult<()> {
287        static STRIDES: [isize; 1] = [1];
288        let a_shape = [len];
289        let (b_offset, b_shape) = match share {
290            BShare::Lockstep => (offset as isize * b_item, [len]),
291            BShare::PerBlock => (block as isize * b_item, [1]),
292        };
293        let a_offset = (block * period + offset) as isize * a_item;
294        // `offset + len <= period` and blocks are disjoint, so the byte range
295        // `[a_offset, a_offset + len * a_item)` is disjoint across every
296        // (block, offset) the dispatch below enumerates. That is what makes the
297        // concurrent writes through these views non-aliasing; keep it true if the
298        // chunk arithmetic changes.
299        unsafe {
300            let mut a_chunk = TensorView::from_bytes(a, a_offset, &a_shape, &STRIDES);
301            let b_chunk = TensorView::from_bytes(b, b_offset, &b_shape, &STRIDES);
302            eval_fn(&mut a_chunk, &b_chunk)
303        }
304    };
305
306    #[cfg(feature = "multithread-mm")]
307    {
308        use rayon::prelude::*;
309        // Threshold first: reading the executor takes a global lock, and a graph
310        // has hundreds of these nodes sitting below the threshold.
311        if a.len() >= current_threading_element_threshold() {
312            let executor = current_tract_executor();
313            let nth = match &executor {
314                Executor::MultiThread(pool) => pool.current_num_threads(),
315                Executor::RayonGlobal => rayon::current_num_threads(),
316                Executor::SingleThread => 1,
317            };
318            if nth > 1 {
319                let per_block = (4 * nth).div_ceil(n_blocks).max(1);
320                let chunk = period.div_ceil(per_block).next_multiple_of(vector_size()).min(period);
321                let per_block = period.div_ceil(chunk);
322                if n_blocks * per_block > 1 {
323                    let run = || {
324                        (0..n_blocks * per_block).into_par_iter().try_for_each(|i| {
325                            let offset = (i % per_block) * chunk;
326                            call(i / per_block, offset, chunk.min(period - offset))
327                        })
328                    };
329                    return match executor {
330                        Executor::MultiThread(pool) => pool.install(run),
331                        _ => run(),
332                    };
333                }
334            }
335        }
336    }
337
338    // A single block is the whole tensor, so hand the kernel the natural view
339    // rather than a rank-1 one: fewer shapes for a future kernel to have to
340    // tolerate on the overwhelmingly common path.
341    if n_blocks == 1 {
342        return eval_fn(&mut a.view(), &b.view());
343    }
344    (0..n_blocks).try_for_each(|block| call(block, 0, period))
345}
346
347/// One kernel application inside a [`par_bin_chain`] walk, with `b`, `period`
348/// and `share` carrying the same contract as the [`par_bin`] arguments.
349pub struct BinChainStep<'a> {
350    pub eval_fn: &'a BinFn,
351    pub b: &'a Tensor,
352    pub period: usize,
353    pub share: BShare,
354}
355
356fn chain_workers(len: usize) -> usize {
357    #[cfg(feature = "multithread-mm")]
358    {
359        // Threshold first: reading the executor takes a global lock.
360        if len < current_threading_element_threshold() {
361            return 1;
362        }
363        match current_tract_executor() {
364            Executor::MultiThread(pool) => pool.current_num_threads(),
365            Executor::RayonGlobal => rayon::current_num_threads(),
366            Executor::SingleThread => 1,
367        }
368    }
369    #[cfg(not(feature = "multithread-mm"))]
370    {
371        let _ = len;
372        1
373    }
374}
375
376/// Apply every step of `steps` to `a` in place, chunk by chunk, so each chunk
377/// of `a` is still cache-hot when the next step's kernel runs over it instead
378/// of streaming the whole tensor once per step. A chunk may not cross any
379/// step's block boundary, so its length must divide every step period; any
380/// `vector_size()` multiple dividing the gcd of the periods works. The walk
381/// picks the largest such chunk under `CHAIN_CHUNK` elements that still
382/// splits `a` into `4 * threads` pieces — giving the same operand mapping
383/// `par_bin` would produce and a bit-identical result.
384///
385/// Falls back to sequential `par_bin` calls when no usable chunk exists, and
386/// launches like `par_bin` does, so a chain is never less parallel than the
387/// steps it replaces.
388pub fn par_bin_chain(steps: &[BinChainStep], a: &mut Tensor) -> TractResult<()> {
389    /// Largest chunk that keeps `a` plus one operand slice inside L1/L2.
390    const CHAIN_CHUNK: usize = 16384;
391    if steps.is_empty() || a.len() == 0 {
392        return Ok(());
393    }
394    let a_len = a.len();
395    let a_item = a.datum_type().size_of() as isize;
396    for s in steps {
397        ensure!(
398            a_len.is_multiple_of(s.period),
399            "par_bin_chain: period {} does not divide a.len() {}",
400            s.period,
401            a_len
402        );
403        match s.share {
404            BShare::Lockstep => ensure!(
405                s.b.len() == s.period,
406                "par_bin_chain: Lockstep wants b.len() {} == period {}",
407                s.b.len(),
408                s.period
409            ),
410            BShare::PerBlock => ensure!(
411                s.b.len() == a_len / s.period,
412                "par_bin_chain: PerBlock wants b.len() {} == {} blocks",
413                s.b.len(),
414                a_len / s.period
415            ),
416        }
417    }
418    let workers = chain_workers(a_len);
419    let max_chunk = if workers > 1 { CHAIN_CHUNK.min(a_len / (4 * workers)) } else { CHAIN_CHUNK }
420        .max(4 * vector_size());
421    // A short Lockstep period (a per-channel operand over channel-last data)
422    // would cap the chunk at one period; tile its `b` so the chunk can span
423    // many periods.
424    let tiled = steps
425        .iter()
426        .map(|s| {
427            if s.share != BShare::Lockstep || s.period * 2 > max_chunk {
428                return Ok(None);
429            }
430            let blocks = a_len / s.period;
431            let k = (1..=blocks.min(max_chunk / s.period))
432                .rev()
433                .find(|k| blocks.is_multiple_of(*k))
434                .unwrap_or(1);
435            if k == 1 {
436                return Ok(None);
437            }
438            Ok(Some(
439                s.b.clone()
440                    .into_shape(&[s.period])?
441                    .broadcast_to_shape(&[k, s.period])?
442                    .into_shape(&[k * s.period])?,
443            ))
444        })
445        .collect::<TractResult<Vec<Option<Tensor>>>>()?;
446    let chain_b = |ix: usize| tiled[ix].as_ref().unwrap_or(steps[ix].b);
447    let chain_period = |ix: usize| tiled[ix].as_ref().map_or(steps[ix].period, |t| t.len());
448    let unit = (0..steps.len()).fold(a_len, |u, ix| u.gcd(&chain_period(ix)));
449    if unit % vector_size() != 0 {
450        for s in steps {
451            par_bin(s.eval_fn, a, s.b, s.period, s.share)?;
452        }
453        return Ok(());
454    }
455    let q = unit / vector_size();
456    // Largest `chunk = vector_size() * t` with `t | q` and `chunk <=
457    // max_chunk`.
458    let mut t = 1;
459    let mut d = 1;
460    while d * d <= q {
461        if q % d == 0 {
462            for cand in [d, q / d] {
463                if cand <= max_chunk / vector_size() {
464                    t = t.max(cand);
465                }
466            }
467        }
468        d += 1;
469    }
470    let chunk = t * vector_size();
471    if chunk < 4 * vector_size() {
472        for s in steps {
473            par_bin(s.eval_fn, a, s.b, s.period, s.share)?;
474        }
475        return Ok(());
476    }
477    let n_chunks = a_len / chunk;
478    let a = &*a;
479    static STRIDES: [isize; 1] = [1];
480    let a_shape = [chunk];
481    // `chunk` divides every step's period, so within a chunk each step's
482    // `g % period` offset and its `chunk` neighbours stay inside one block,
483    // and the disjoint chunks partition `a`: concurrent writes through the
484    // views do not alias.
485    let call = |ix: usize| -> TractResult<()> {
486        let g = ix * chunk;
487        unsafe {
488            let mut a_chunk = TensorView::from_bytes(a, g as isize * a_item, &a_shape, &STRIDES);
489            for (sx, s) in steps.iter().enumerate() {
490                let (b, period) = (chain_b(sx), chain_period(sx));
491                let b_item = b.datum_type().size_of() as isize;
492                let (b_offset, b_shape) = match s.share {
493                    BShare::Lockstep => ((g % period) as isize * b_item, [chunk]),
494                    BShare::PerBlock => ((g / period) as isize * b_item, [1]),
495                };
496                let b_chunk = TensorView::from_bytes(b, b_offset, &b_shape, &STRIDES);
497                (s.eval_fn)(&mut a_chunk, &b_chunk)?;
498            }
499            Ok(())
500        }
501    };
502
503    #[cfg(feature = "multithread-mm")]
504    {
505        use rayon::prelude::*;
506        if a_len >= current_threading_element_threshold() && n_chunks > 1 {
507            let executor = current_tract_executor();
508            let nth = match &executor {
509                Executor::MultiThread(pool) => pool.current_num_threads(),
510                Executor::RayonGlobal => rayon::current_num_threads(),
511                Executor::SingleThread => 1,
512            };
513            if nth > 1 {
514                let per_worker = n_chunks.div_ceil(nth);
515                let n_workers = n_chunks.div_ceil(per_worker);
516                let run = || {
517                    (0..n_workers).into_par_iter().try_for_each(|w| {
518                        let first = w * per_worker;
519                        (first..(first + per_worker).min(n_chunks)).try_for_each(&call)
520                    })
521                };
522                return match executor {
523                    Executor::MultiThread(pool) => pool.install(run),
524                    _ => run(),
525                };
526            }
527        }
528    }
529    (0..n_chunks).try_for_each(call)
530}
531
532// wasi has no thread spawning at all; other wasm targets run the shared rayon
533// global pool instead of a private ThreadPool.
534#[cfg(all(test, feature = "multithread-mm", not(target_os = "wasi")))]
535mod chain_tests {
536    use super::{BShare, BinChainStep, Executor, multithread_tract_scope, par_bin, par_bin_chain};
537    use crate::BinFn;
538    use tract_data::internal::{Tensor, TensorView, TractResult};
539
540    fn add() -> impl Fn(&mut TensorView, &TensorView) -> TractResult<()> + Send + Sync {
541        |a: &mut TensorView, b: &TensorView| {
542            let av = a.as_slice_mut::<f32>()?;
543            let bv = b.as_slice::<f32>()?;
544            if bv.len() == 1 {
545                av.iter_mut().for_each(|x| *x += bv[0]);
546            } else {
547                av.iter_mut().zip(bv).for_each(|(x, y)| *x += *y);
548            }
549            Ok(())
550        }
551    }
552
553    fn mul() -> impl Fn(&mut TensorView, &TensorView) -> TractResult<()> + Send + Sync {
554        |a: &mut TensorView, b: &TensorView| {
555            let av = a.as_slice_mut::<f32>()?;
556            let bv = b.as_slice::<f32>()?;
557            if bv.len() == 1 {
558                av.iter_mut().for_each(|x| *x *= bv[0]);
559            } else {
560                av.iter_mut().zip(bv).for_each(|(x, y)| *x *= *y);
561            }
562            Ok(())
563        }
564    }
565
566    /// Mixed Lockstep and PerBlock steps must produce the same bytes through
567    /// `par_bin_chain` as through sequential `par_bin` calls, inline or on a
568    /// pool, including a gcd of periods that is not vector-aligned.
569    #[test]
570    fn chain_matches_sequential_par_bin() {
571        let add = add();
572        let mul = mul();
573        let len = 12288usize;
574        for periods in [[768usize, 256, 256], [768, 24, 768], [96, 96, 12288]] {
575            let b0 = Tensor::from_shape(&[1, 1, periods[0]], &vec![2f32; periods[0]]).unwrap();
576            let b1 = Tensor::from_shape(&[periods[1]], &vec![3f32; periods[1]]).unwrap();
577            let b2 =
578                Tensor::from_shape(&[len / periods[2]], &vec![0.5f32; len / periods[2]]).unwrap();
579            let steps = vec![
580                BinChainStep {
581                    eval_fn: &add as &BinFn,
582                    b: &b0,
583                    period: periods[0],
584                    share: BShare::Lockstep,
585                },
586                BinChainStep {
587                    eval_fn: &mul as &BinFn,
588                    b: &b1,
589                    period: periods[1],
590                    share: BShare::Lockstep,
591                },
592                BinChainStep {
593                    eval_fn: &add as &BinFn,
594                    b: &b2,
595                    period: periods[2],
596                    share: BShare::PerBlock,
597                },
598            ];
599            let input = Tensor::from_shape(&[len], &(0..len).map(|i| i as f32).collect::<Vec<_>>())
600                .unwrap();
601            let mut reference = input.clone();
602            for s in &steps {
603                par_bin(s.eval_fn, &mut reference, s.b, s.period, s.share).unwrap();
604            }
605            for nth in [0usize, 4] {
606                let mut a = input.clone();
607                if nth == 0 {
608                    par_bin_chain(&steps, &mut a).unwrap();
609                } else {
610                    #[cfg(target_family = "wasm")]
611                    let pool = Executor::RayonGlobal;
612                    #[cfg(not(target_family = "wasm"))]
613                    let pool = Executor::multithread(nth);
614                    multithread_tract_scope(pool, || par_bin_chain(&steps, &mut a).unwrap());
615                }
616                let got = a.view().as_slice::<f32>().unwrap();
617                let want = reference.view().as_slice::<f32>().unwrap();
618                assert_eq!(got, want, "len={len} periods={periods:?} nth={nth}");
619            }
620        }
621    }
622}