Skip to main content

strided_basic/
threading.rs

1//! Parallel execution for strided operations.
2//!
3//! Faithfully ports Julia Strided.jl's `_mapreduce_threaded!` recursive
4//! dimension-splitting strategy. Every split goes through
5//! [`strided_exec::Exec::fork`] or [`strided_exec::Exec::for_ranges`]: the
6//! token carries the budget and the pool, so this module holds no placement
7//! state of its own.
8
9use crate::inline::InlineVec;
10use crate::kernel::for_each_inner_block_preordered;
11use crate::Result;
12use core::num::NonZeroUsize;
13use std::ops::Range;
14use strided_exec::Exec;
15
16/// Stack-allocated Vec for dims/offsets in recursive threading.
17/// 8 elements covers up to 8-dimensional arrays (after fusion, typically 2-4).
18type SVec<T> = InlineVec<T>;
19
20const ONE: NonZeroUsize = NonZeroUsize::MIN;
21
22/// A raw pointer wrapper that is `Send` + `Sync`.
23///
24/// # Safety
25/// The caller must guarantee that the pointed-to data is valid for the
26/// lifetime of any parallel operation and that no data races occur
27/// (e.g., different threads write to disjoint regions).
28/// Raw address transport for scoped kernel workers, without dereference or ownership.
29/// Storage liveness, aliasing and cross-thread access must be established at use.
30///
31/// # Examples
32/// ```
33/// use strided_basic::execution::SendPtr;
34/// let mut value = 1_i32;
35/// let address = &mut value as *mut i32;
36/// assert_eq!(SendPtr(address).as_ptr(), address);
37/// ```
38pub struct SendPtr<T>(pub *mut T);
39
40impl<T> Clone for SendPtr<T> {
41    fn clone(&self) -> Self {
42        *self
43    }
44}
45
46impl<T> Copy for SendPtr<T> {}
47
48unsafe impl<T> Send for SendPtr<T> {}
49unsafe impl<T> Sync for SendPtr<T> {}
50
51impl<T> SendPtr<T> {
52    pub fn as_ptr(self) -> *mut T {
53        self.0
54    }
55
56    pub fn as_const(self) -> *const T {
57        self.0 as *const T
58    }
59}
60
61impl<T> core::fmt::Debug for SendPtr<T> {
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        f.debug_tuple("SendPtr").field(&self.0).finish()
64    }
65}
66
67/// Minimum number of elements to justify multi-threaded execution.
68///
69/// Defined once, in `strided-perm`; see [`strided_perm::MINTHREADLENGTH`].
70pub use strided_perm::MINTHREADLENGTH;
71
72/// Block sizes handed to the parallel splitter.
73///
74/// [`split_axis`] declines to split a dimension that fits in one cache block
75/// (`dims[i] <= blocks[i].min(1024)`), so a region whose block covers a small
76/// whole tensor (for example `1024x64` with `block == dims`) never opens even
77/// at four threads. Halving such a block lets the splitter open; the leaf
78/// merely walks two tiles instead of one. Dimensions that already exceed the
79/// guard are left unchanged so other shapes keep their tiles.
80fn scheduler_blocks(dims: &[usize], blocks: &[usize]) -> SVec<usize> {
81    dims.iter()
82        .zip(blocks)
83        .map(|(&dim, &block)| {
84            if dim > 1 && dim <= block.min(1024) {
85                (dim / 2).max(1)
86            } else {
87                block
88            }
89        })
90        .collect()
91}
92
93/// Number of tasks a loop of `len` elements may use out of `budget` slots.
94///
95/// A pure function of its arguments: `1` up to [`MINTHREADLENGTH`] elements,
96/// `budget` above. Entries apply it with `Exec::limit`.
97///
98/// # Examples
99///
100/// ```
101/// use strided_basic::execution::{parallel_threads_for_len, MINTHREADLENGTH};
102///
103/// assert_eq!(parallel_threads_for_len(MINTHREADLENGTH, 4), 1);
104/// assert_eq!(parallel_threads_for_len(MINTHREADLENGTH + 1, 4), 4);
105/// ```
106pub fn parallel_threads_for_len(len: usize, budget: usize) -> usize {
107    if len <= MINTHREADLENGTH {
108        1
109    } else {
110        budget.max(1)
111    }
112}
113
114/// Narrows `exec` to the width `len` elements justify.
115pub(crate) fn for_len(exec: Exec<'_>, len: usize) -> Exec<'_> {
116    let width = parallel_threads_for_len(len, exec.budget().get());
117    exec.limit(NonZeroUsize::new(width).unwrap_or(ONE))
118}
119
120/// Whether `len` elements are above the threshold and `exec` has a second slot.
121pub(crate) fn is_parallel(len: usize, exec: &Exec<'_>) -> bool {
122    len > MINTHREADLENGTH && !exec.is_serial()
123}
124
125/// Runs `left` and `right` as the two halves of `exec` and returns both results.
126///
127/// Each half receives a child token carrying its share of the budget.
128fn fork2<R: Send>(
129    exec: Exec<'_>,
130    left: impl Fn(Exec<'_>) -> R + Sync,
131    right: impl Fn(Exec<'_>) -> R + Sync,
132) -> (R, R) {
133    // Mutex-free result slots: a `Mutex` allocates its lock on first use on
134    // some platforms, which made every call allocate.
135    struct Slot<T>(core::cell::UnsafeCell<Option<T>>);
136    // SAFETY: job `i` is the only writer of slot `i`, and the slots are read only
137    // after `fork` has returned, which waits for every job.
138    unsafe impl<T: Send> Sync for Slot<T> {}
139    let slots = [Slot(Default::default()), Slot(Default::default())];
140    exec.fork(2, |i, child| {
141        let value = if i == 0 { left(child) } else { right(child) };
142        // SAFETY: see `Slot`; each index is written by exactly one job.
143        unsafe { *slots[i].0.get() = Some(value) };
144    });
145    let [left, right] = slots.map(|slot| {
146        slot.0
147            .into_inner()
148            .expect("every fork job stores its result")
149    });
150    (left, right)
151}
152
153/// Recursive halving map-reduce over `range` within the budget of `exec`.
154///
155/// `map` runs on the leaf ranges and `reduce` combines two neighbouring
156/// results left to right, so the combination order depends only on the budget.
157/// A serial token, a budget of one or a single-element range runs `map` on the
158/// whole range on the calling thread.
159///
160/// # Examples
161///
162/// ```
163/// use strided_basic::execution::parallel_map_reduce;
164/// use strided_basic::Exec;
165///
166/// let sum = parallel_map_reduce(0..100, Exec::serial(), &|r| r.sum::<usize>(), &|a, b| a + b);
167/// assert_eq!(sum, 4950);
168/// ```
169pub fn parallel_map_reduce<R, Map, Reduce>(
170    range: Range<usize>,
171    exec: Exec<'_>,
172    map: &Map,
173    reduce: &Reduce,
174) -> R
175where
176    R: Send,
177    Map: Fn(Range<usize>) -> R + Sync,
178    Reduce: Fn(R, R) -> R + Sync,
179{
180    let len = range.end - range.start;
181    let budget = exec.budget().get();
182    if budget <= 1 || len <= 1 {
183        return map(range);
184    }
185
186    let left_threads = budget.div_ceil(2);
187    let left_len = len * left_threads / budget;
188    let middle = range.start + left_len.max(1).min(len - 1);
189    let left_range = range.start..middle;
190    let right_range = middle..range.end;
191
192    let (left, right) = fork2(
193        exec,
194        |child| parallel_map_reduce(left_range.clone(), child, map, reduce),
195        |child| parallel_map_reduce(right_range.clone(), child, map, reduce),
196    );
197    reduce(left, right)
198}
199
200/// Runs `operation` over contiguous sub-ranges of `range`, one per slot of
201/// `exec` at most.
202pub(crate) fn parallel_for_each<F>(range: Range<usize>, exec: Exec<'_>, operation: &F)
203where
204    F: Fn(Range<usize>) + Sync,
205{
206    let start = range.start;
207    exec.for_ranges(range.end - range.start, ONE, |chunk| {
208        operation(start + chunk.start..start + chunk.end)
209    });
210}
211
212/// Copy `len` contiguous elements, chunked across the budget of `exec` above
213/// the repository threshold ([`MINTHREADLENGTH`]) and as one direct
214/// `copy_nonoverlapping` otherwise.
215///
216/// # Safety
217///
218/// `src..src + len` must be readable and `dst..dst + len` writable for the
219/// whole call, the two ranges must not overlap, and no other thread may
220/// access the destination range concurrently.
221pub(crate) unsafe fn copy_contiguous<T: Copy + Send + Sync>(
222    src: *const T,
223    dst: *mut T,
224    len: usize,
225    exec: Exec<'_>,
226) {
227    if is_parallel(len, &exec) {
228        let src = SendPtr(src as *mut T);
229        let dst = SendPtr(dst);
230        parallel_for_each(0..len, exec, &|range| {
231            // SAFETY: `range` lies inside `0..len`, and worker ranges are
232            // disjoint, so each worker copies an exclusive destination
233            // chunk from the caller-validated source range.
234            unsafe {
235                core::ptr::copy_nonoverlapping(
236                    src.as_const().add(range.start),
237                    dst.as_ptr().add(range.start),
238                    range.len(),
239                );
240            }
241        });
242        return;
243    }
244    // SAFETY: the caller guarantees both ranges are valid and disjoint.
245    unsafe { core::ptr::copy_nonoverlapping(src, dst, len) };
246}
247
248/// Fill a contiguous slice, chunked across the budget of `exec` above the
249/// repository threshold and as one serial `fill` otherwise.
250pub(crate) fn fill_contiguous<T: Copy + Send + Sync>(dst: &mut [T], value: T, exec: Exec<'_>) {
251    let len = dst.len();
252    if is_parallel(len, &exec) {
253        let dst = SendPtr(dst.as_mut_ptr());
254        parallel_for_each(0..len, exec, &|range| {
255            // SAFETY: `range` lies inside the exclusively borrowed slice
256            // and worker ranges are disjoint.
257            let chunk = unsafe {
258                core::slice::from_raw_parts_mut(dst.as_ptr().add(range.start), range.len())
259            };
260            chunk.fill(value);
261        });
262        return;
263    }
264    dst.fill(value);
265}
266
267/// Fill `len` contiguous destination elements that may be uninitialized.
268///
269/// [`fill_contiguous`] forms a `&mut [T]`, which is undefined behaviour over
270/// uninitialized storage (for example a fresh `bool` buffer). This sibling
271/// views the same span as `[MaybeUninit<T>]`, which has no validity invariant,
272/// and reuses the existing chunking.
273///
274/// # Safety
275///
276/// `dst..dst + len` must be writable for the whole call and no other thread
277/// may access the destination range concurrently.
278pub(crate) unsafe fn fill_contiguous_uninit<T: Copy + Send + Sync>(
279    dst: *mut T,
280    len: usize,
281    value: T,
282    exec: Exec<'_>,
283) {
284    // SAFETY: `MaybeUninit<T>` has no validity invariant, so the span is a
285    // valid `[MaybeUninit<T>]`; `fill_contiguous` writes every element.
286    let span =
287        unsafe { core::slice::from_raw_parts_mut(dst.cast::<core::mem::MaybeUninit<T>>(), len) };
288    fill_contiguous(span, core::mem::MaybeUninit::new(value), exec);
289}
290
291/// Permuting copy into a destination of any layout, split across `exec`
292/// above the repository threshold.
293///
294/// The permutation engine honours the budget it is given, so this is the only
295/// route: a serial token (or a copy at or below the threshold) takes its
296/// calling-thread path.
297pub(crate) fn copy_permuted<T: Copy + Send + Sync + 'static>(
298    dest: &mut crate::StridedViewMut<T>,
299    src: &crate::StridedView<T>,
300    exec: Exec<'_>,
301) -> crate::Result<()> {
302    let total = crate::kernel::total_len(dest.dims())?;
303    strided_perm::copy_into(dest, src, for_len(exec, total))
304}
305
306/// [`copy_permuted`] into an uninitialized destination.
307///
308/// The permutation engine is entered on the source element type, so its
309/// native `f32`/`f64` paths apply: a destination reinterpreted as
310/// `MaybeUninit<T>` falls to the generic element path, measured 2.5x slower
311/// on a 4096x4096 `f64` transpose.
312pub(crate) fn copy_permuted_uninit<T: Copy + Send + Sync + 'static>(
313    dest: &mut crate::StridedViewMut<core::mem::MaybeUninit<T>>,
314    src: &crate::StridedView<T>,
315    exec: Exec<'_>,
316) -> crate::Result<()> {
317    let total = crate::kernel::total_len(dest.dims())?;
318    strided_perm::copy_into_uninit(dest, src, for_len(exec, total))
319}
320
321/// Dimension to halve: `_lastargmax((dims .- 1) .* costs)`, or `None` when
322/// that dimension is not worth splitting.
323///
324/// Ties break towards the last index, matching Julia's `_lastargmax`. A
325/// dimension is not split when its cost is zero or it fits in one block
326/// (`dims[i] <= min(blocks[i], 1024)`).
327fn split_axis(dims: &[usize], blocks: &[usize], costs: &[isize]) -> Option<usize> {
328    let (i, _) = dims.iter().zip(costs.iter()).enumerate().fold(
329        (0, isize::MIN),
330        |(best_i, best_v), (idx, (&d, &c))| {
331            let score = (d as isize - 1) * c;
332            if score >= best_v {
333                (idx, score)
334            } else {
335                (best_i, best_v)
336            }
337        },
338    );
339    (costs[i] != 0 && dims[i] > blocks[i].min(1024)).then_some(i)
340}
341
342/// The two halves of `dims` split at axis `i`: left dims, right dims and the
343/// right offsets advanced by the left extent.
344fn halves(
345    dims: &[usize],
346    offsets: &[isize],
347    strides_list: &[&[isize]],
348    i: usize,
349) -> (SVec<usize>, SVec<usize>, SVec<isize>) {
350    let di = dims[i];
351    let ndi = di / 2;
352    let mut left_dims: SVec<usize> = dims.iter().copied().collect();
353    left_dims[i] = ndi;
354    let mut right_dims: SVec<usize> = dims.iter().copied().collect();
355    right_dims[i] = di - ndi;
356    let mut right_offsets: SVec<isize> = offsets.iter().copied().collect();
357    for (k, strides) in strides_list.iter().enumerate() {
358        right_offsets[k] += ndi as isize * strides[i];
359    }
360    (left_dims, right_dims, right_offsets)
361}
362
363/// Recursive dimension-splitting parallel execution.
364///
365/// Faithfully ports Julia's `_mapreduce_threaded!` (mapreduce.jl L195-227).
366///
367/// Parameters:
368/// - `dims`: Ordered dimensions (after fuse/order/block)
369/// - `blocks`: Block sizes per dimension
370/// - `strides_list`: Per-array strides, ordered by plan
371/// - `offsets`: Per-array byte offsets into the data
372/// - `costs`: Per-dimension splitting costs
373/// - `exec`: Placement token; its budget is the number of slots the whole
374///   region may occupy, divided between the halves at every split
375/// - `f`: Leaf function — called when we've reached a single-slot region
376///
377/// The leaf function `f` receives `(dims, blocks, strides_list, offsets)` describing
378/// the sub-region to process.
379pub(crate) fn mapreduce_threaded<S: AsRef<[isize]>, F>(
380    dims: &[usize],
381    blocks: &[usize],
382    strides_list: &[S],
383    offsets: &[isize],
384    costs: &[isize],
385    exec: Exec<'_>,
386    f: &F,
387) -> Result<()>
388where
389    F: Fn(&[usize], &[usize], &[&[isize]], &[isize]) -> Result<()> + Sync,
390{
391    // The recursive splitter is compiled once; the leaf is `dyn`, called once
392    // per leaf region. Borrowed per-operand stride slices are built once here
393    // so the splitter and its leaves share them without owning a
394    // `Vec<Vec<isize>>` (#306 P1).
395    let refs: SVec<&[isize]> = strided_slices(strides_list);
396    mapreduce_threaded_dyn(dims, blocks, &refs, offsets, costs, exec, f)
397}
398
399fn strided_slices<S: AsRef<[isize]>>(strides_list: &[S]) -> SVec<&[isize]> {
400    strides_list.iter().map(|s| s.as_ref()).collect()
401}
402
403/// The leaf callback of a threaded region.
404type LeafFn<'a> = &'a (dyn Fn(&[usize], &[usize], &[&[isize]], &[isize]) -> Result<()> + Sync);
405
406#[inline(never)]
407fn mapreduce_threaded_dyn(
408    dims: &[usize],
409    blocks: &[usize],
410    strides_list: &[&[isize]],
411    offsets: &[isize],
412    costs: &[isize],
413    exec: Exec<'_>,
414    f: LeafFn<'_>,
415) -> Result<()> {
416    // Normalize before the splitter: the guard in `split_axis` uses these tile
417    // sizes, so a block that covers its whole dimension would otherwise
418    // suppress the split. The shared layer applies this for every map, zip and
419    // fused caller.
420    let blocks = scheduler_blocks(dims, blocks);
421    ThreadedMapReduce {
422        blocks: &blocks,
423        strides_list,
424        costs,
425        operation: f,
426    }
427    .run(dims, offsets, exec)
428}
429
430struct ThreadedMapReduce<'a> {
431    blocks: &'a [usize],
432    strides_list: &'a [&'a [isize]],
433    costs: &'a [isize],
434    operation: LeafFn<'a>,
435}
436
437impl ThreadedMapReduce<'_> {
438    fn run(&self, dims: &[usize], offsets: &[isize], exec: Exec<'_>) -> Result<()> {
439        let total = crate::kernel::total_len(dims)?;
440
441        // Base case: a single slot or below the threshold.
442        if exec.is_serial() || total <= MINTHREADLENGTH {
443            return (self.operation)(dims, self.blocks, self.strides_list, offsets);
444        }
445        let Some(i) = split_axis(dims, self.blocks, self.costs) else {
446            return (self.operation)(dims, self.blocks, self.strides_list, offsets);
447        };
448
449        let (left_dims, right_dims, right_offsets) = halves(dims, offsets, self.strides_list, i);
450        let (r1, r2) = fork2(
451            exec,
452            |child| self.run(&left_dims, offsets, child),
453            |child| self.run(&right_dims, &right_offsets, child),
454        );
455        r1?;
456        r2?;
457        Ok(())
458    }
459}
460
461/// Recursive dimension-splitting parallel fold.
462///
463/// Like [`mapreduce_threaded`], but the leaf returns a partial result and each
464/// split combines the two children with `combine`. A full reduction therefore
465/// never needs a worker-count-sized output-slot vector (#306 scope addition).
466/// `f` must reduce the whole sub-region it is handed, using the supplied
467/// `for_each_inner_block_with_offsets` traversal.
468#[allow(clippy::too_many_arguments)]
469pub(crate) fn mapreduce_threaded_fold<S, F, C, R>(
470    dims: &[usize],
471    blocks: &[usize],
472    strides_list: &[S],
473    offsets: &[isize],
474    costs: &[isize],
475    exec: Exec<'_>,
476    f: &F,
477    combine: &C,
478) -> Result<R>
479where
480    S: AsRef<[isize]>,
481    F: Fn(&[usize], &[usize], &[&[isize]], &[isize]) -> Result<R> + Sync,
482    C: Fn(R, R) -> R + Sync,
483    R: Send,
484{
485    let refs: SVec<&[isize]> = strided_slices(strides_list);
486    // Normalize whole-dimension blocks before splitting, matching the
487    // `mapreduce_threaded` path (#313): a block that covers its dimension would
488    // otherwise suppress the split for a small whole tensor.
489    let blocks = scheduler_blocks(dims, blocks);
490    FoldMapReduce {
491        blocks: &blocks,
492        strides_list: &refs,
493        costs,
494        f,
495        combine,
496    }
497    .run(dims, offsets, exec)
498}
499
500struct FoldMapReduce<'a, F, C> {
501    blocks: &'a [usize],
502    strides_list: &'a [&'a [isize]],
503    costs: &'a [isize],
504    f: &'a F,
505    combine: &'a C,
506}
507
508impl<F, C> FoldMapReduce<'_, F, C> {
509    fn run<R: Send>(&self, dims: &[usize], offsets: &[isize], exec: Exec<'_>) -> Result<R>
510    where
511        F: Fn(&[usize], &[usize], &[&[isize]], &[isize]) -> Result<R> + Sync,
512        C: Fn(R, R) -> R + Sync,
513    {
514        let total = crate::kernel::total_len(dims)?;
515
516        // Base case: a single slot or below the threshold.
517        if exec.is_serial() || total <= MINTHREADLENGTH {
518            return (self.f)(dims, self.blocks, self.strides_list, offsets);
519        }
520        let Some(i) = split_axis(dims, self.blocks, self.costs) else {
521            return (self.f)(dims, self.blocks, self.strides_list, offsets);
522        };
523
524        // Split dimension i in half and combine the two partial results.
525        let (left_dims, right_dims, right_offsets) = halves(dims, offsets, self.strides_list, i);
526        let (r1, r2) = fork2(
527            exec,
528            |child| self.run(&left_dims, offsets, child),
529            |child| self.run(&right_dims, &right_offsets, child),
530        );
531        Ok((self.combine)(r1?, r2?))
532    }
533}
534
535/// Execute the kernel on a sub-region defined by initial offsets.
536///
537/// Delegates to `for_each_inner_block_preordered` which directly calls
538/// kernel functions with the initial offsets, avoiding redundant re-ordering
539/// and per-callback `Vec` allocation.
540pub(crate) fn for_each_inner_block_with_offsets<S: AsRef<[isize]>, F>(
541    dims: &[usize],
542    blocks: &[usize],
543    strides_list: &[S],
544    initial_offsets: &[isize],
545    f: F,
546) -> Result<()>
547where
548    F: FnMut(&[isize], usize, &[isize]) -> Result<()>,
549{
550    for_each_inner_block_preordered(dims, blocks, strides_list, initial_offsets, f)
551}
552
553/// A pool of `threads` workers for unit tests.
554#[cfg(test)]
555pub(crate) fn test_pool(threads: usize) -> strided_exec::Pool {
556    strided_exec::Pool::builder()
557        .threads(threads)
558        .build()
559        .unwrap()
560}
561
562/// Run `operation` once with a serial token and once with a four-slot token of
563/// a four-worker pool, and return both results.
564///
565/// The parallel run asserts that the repository gate actually opens for a
566/// length above [`MINTHREADLENGTH`], so equivalence tests with such sizes
567/// exercise the parallel branches. Without the `parallel` feature the pool has
568/// width one and both runs are serial.
569#[cfg(test)]
570pub(crate) fn run_serial_and_parallel<R: Send>(operation: impl Fn(Exec<'_>) -> R + Sync) -> (R, R) {
571    let serial = operation(Exec::serial());
572    let pool = test_pool(4);
573    let exec = Exec::new(&pool, 4).unwrap();
574    assert_eq!(
575        parallel_threads_for_len(MINTHREADLENGTH + 1, exec.budget().get()),
576        exec.budget().get()
577    );
578    assert_eq!(parallel_threads_for_len(MINTHREADLENGTH, 4), 1);
579    let parallel = operation(exec);
580    (serial, parallel)
581}
582
583#[cfg(test)]
584#[path = "threading/tests/tests.rs"]
585mod tests;