treams_core/numerics/
parallel.rs

1//! Serial or Rayon-parallel evaluation of independent items. treams-rs extension.
2//!
3//! Four forms decide when work runs on the Rayon pool:
4//!
5//! - [`Parallel`] is the policy of the elementwise evaluations in
6//!   [`broadcast`](super::broadcast): in parallel from a number of elements
7//!   ([`Parallel::AtLeast`]), or in about four chunks per thread for cheap, uniform
8//!   elements ([`Parallel::Chunked`]).
9//! - [`try_map`], [`try_fill_chunks`] and [`try_fold_ordered`] take `parallel: bool`,
10//!   which callers compute from a size and [`PARALLEL_ITEMS`] or [`PARALLEL_ENTRIES`],
11//!   or set for items that are expensive on their own.
12//! - LU factorizations and solves pass faer a worker count computed from the matrix
13//!   size (`linalg::lu_threads`).
14//! - The S-matrix illumination in `smatrix::illuminate` compares a row count with a
15//!   constant and calls Rayon directly.
16//!
17//! [`Parallel`], [`try_map`] and [`try_fill_chunks`] keep every output in index order,
18//! and [`try_fold_ordered`] adds in an order set by the number of items, so the thread
19//! count never changes their results.
20//! The three `try_*` helpers guard serial work and parallel callbacks, including
21//! callbacks on foreign or fallback workers. Ordered reductions also guard the
22//! final combine on the caller.
23//!
24//! | Module | Threshold | Value | Runs in parallel |
25//! |---|---|---|---|
26//! | `numerics::parallel` | `PARALLEL_ITEMS` | 1024 items | plane-wave permutations (`pw::permute`), chirality densities (`smatrix::chirality`) |
27//! | `numerics::parallel` | `PARALLEL_ENTRIES` | 4096 entries | plane-wave phases and fields, and their pullbacks (`pw::field`) |
28//! | `special::bessel` | `PARALLEL` | `AtLeast(64)` | Bessel arrays and their pullbacks |
29//! | `special::legendre` | `PARALLEL` | `AtLeast(1024)` | angular-function arrays and their pullbacks |
30//! | `special::wigner` | `PARALLEL` | `AtLeast(1024)` | Wigner D arrays and their pullbacks |
31//! | `special::integrals` | `PARALLEL` | `AtLeast(1024)` | incomplete gamma and Kambe arrays and their pullbacks |
32//! | `vectorwaves` | `PARALLEL` | `AtLeast(1024)` | vector-wave arrays and their pullbacks |
33//! | `lattice::batch` | `PARALLEL` | `AtLeast(8)` | batched lattice sums and their pullbacks, per output |
34//! | `sw::polar` | `PARALLEL` | `AtLeast(1024)` | spherical translation arrays and their pullbacks |
35//! | `sw::polar` | `PLANS` | `AtLeast(64)` | coupling plans, per distinct mode pair |
36//! | `cw::polar` | literal | `Chunked(512)` regular, `Chunked(64)` singular | cylindrical translation arrays |
37//! | `cw::polar` | literal | `Chunked(64)` | their pullback |
38//! | `cw::expansion` | `PARALLEL` | `AtLeast(64)` | expansion coefficients, per distinct coupling |
39//! | `smatrix::illuminate` | `PARALLEL_SCAN_ROWS` | 256 rows | the finite check of illumination inputs |
40//! | `smatrix::illuminate` | `PARALLEL_SNAPSHOT_ROWS` | 128 rows | copies of the recorded blocks, with more than one thread |
41//! | `smatrix::illuminate` | `WORKER_SNAPSHOT_ROWS` | 512 rows | copies of the recorded blocks on the worker, with one thread |
42//! | `linalg` | `lu_threads` | `max(min(rows / 512, 4), rows / 2048)` workers, at most `columns / 16`; the whole pool up to 4 threads | LU factorizations and solves (faer) |
43//!
44//! The ufunc loops of the bindings use their own tiers (`parallel_from` in the
45//! `treams-py` file `ufunc/kinds.rs`). Other Rayon calls fill matrix columns or map
46//! independent items at any size, also in index order.
47//!
48//! # Reductions
49//!
50//! Pullbacks that add gradients over many items reduce with [`try_fold_ordered`]. It
51//! splits the items into at most [`REDUCTION_CHUNKS`] consecutive chunks, with
52//! boundaries set by the number of items alone, folds each chunk in index order and
53//! adds the chunk results in chunk order, on the pool or on the calling thread alike,
54//! so the thread count sets how many chunks run at once but never the order of the
55//! additions. A gradient that belongs to one item, such as the `q` gradient of one
56//! column, travels with that item as a mutable slot and is written in place; a
57//! partial sum holds only the gradients that all items share.
58//!
59//! | Module | Pullback | Items | Written in place |
60//! |---|---|---|---|
61//! | `channels` | `SphericalChannelsResidual::pullback`, `CylindricalChannelsResidual::pullback` | plane-mode columns | `q` gradients |
62//! | `cluster::iterative` | `IterativeSphereCluster::pair_gradients` | particles `i`, each with its pairs `(i, j)` | - |
63//! | `cw::to_sw` | `ToSwResidual::pullback` | cylindrical source modes | - |
64//! | `fields` | `WaveSet::pullback` | samples | point gradients |
65//! | `pw::expand` | `ExpansionResidual::pullback` | plane modes | wavevector gradients |
66//! | `pw::field` | `FieldResidual::pullback` | at most 32 blocks of points or of modes, whichever axis is longer | the gradients of that axis |
67//! | `smatrix::layers` | `LayerStackResidual::pullback` | transverse wavevectors | `q` gradients |
68//! | `sw::periodic_to_cw` | `PeriodicToCwResidual::pullback` | spherical source modes | - |
69//! | `sw::plan` | `TranslationPlan::pullback_periodic` | harmonics, whose gradients are collected and then added in order | - |
70//!
71//! Each item is expensive on its own (a column of entries, the pair blocks of a
72//! particle, a layer stack or a lattice sum), so these pullbacks pass `parallel: true`,
73//! except `pw::field`, which runs its blocks in parallel from [`PARALLEL_ENTRIES`] as
74//! its forward does. Its blocks are those of `pw::PhasesResidual::pullback`, which maps
75//! them with [`try_map`] and adds the partial gradients in block order.
76
77use rayon::prelude::*;
78
79/// Items (modes, columns) from which independent per-item work runs in parallel.
80pub(crate) const PARALLEL_ITEMS: usize = 1024;
81
82/// Matrix entries from which independent per-entry work runs in parallel.
83pub(crate) const PARALLEL_ENTRIES: usize = 4096;
84
85/// How an elementwise evaluation uses the rayon pool.
86#[derive(Clone, Copy, Debug)]
87pub(crate) enum Parallel {
88    /// Split adaptively from this many elements.
89    AtLeast(usize),
90    /// For cheap, uniform elements: from this many elements and with more than one
91    /// thread, split into about four chunks per thread to bound scheduling overhead.
92    Chunked(usize),
93}
94
95impl Parallel {
96    /// The minimum chunk length of a parallel evaluation of `size` elements, or
97    /// `None` when they run in order on the calling thread: below the threshold,
98    /// and with a one-thread budget. Reading the budget never starts the pool.
99    pub(super) fn chunk(self, size: usize) -> Option<usize> {
100        let threads = crate::threads::current_num_threads();
101        match self {
102            Self::AtLeast(threshold) if size >= threshold && threads > 1 => Some(1),
103            Self::Chunked(threshold) if size >= threshold && threads > 1 => {
104                Some((size / threads.saturating_mul(4)).max(1))
105            }
106            Self::AtLeast(_) | Self::Chunked(_) => None,
107        }
108    }
109}
110
111/// Fill `data` chunk by chunk with `fill(index, chunk)`, stopping at the first error.
112pub(crate) fn try_fill_chunks<T: Send, E: Send>(
113    data: &mut [T],
114    size: usize,
115    parallel: bool,
116    fill: impl Fn(usize, &mut [T]) -> Result<(), E> + Sync + Send,
117) -> Result<(), E> {
118    if parallel && crate::threads::current_num_threads() > 1 {
119        crate::threads::install(|| {
120            data.par_chunks_mut(size)
121                .enumerate()
122                .try_for_each(|(i, chunk)| crate::fpenv::ieee(|| fill(i, chunk)))
123        })
124    } else {
125        crate::fpenv::ieee(|| {
126            data.chunks_mut(size)
127                .enumerate()
128                .try_for_each(|(i, chunk)| fill(i, chunk))
129        })
130    }
131}
132
133/// Chunks of a deterministic reduction: [`try_fold_ordered`] splits its items into at
134/// most this many consecutive chunks, which bounds both the parallelism of a
135/// reduction and the partial results alive at once.
136pub(crate) const REDUCTION_CHUNKS: usize = 64;
137
138/// Fold `items` into one result whose bits do not depend on the thread count.
139///
140/// The items are split into at most [`REDUCTION_CHUNKS`] consecutive chunks, with
141/// boundaries set by the number of items alone. Each chunk folds its items in index
142/// order from `identity()`, with `fold(partial, index, item)`, and the chunk results
143/// are combined in chunk order with `combine(left, right)`, starting from the first
144/// chunk's result. The calling thread runs the same chunks when `parallel` is false
145/// or the budget is one thread, so every schedule adds the same partial results in
146/// the same order; one chunk reproduces a plain sequential fold.
147///
148/// The error is that of the first failing item in index order, as in a sequential
149/// loop.
150pub(crate) fn try_fold_ordered<T: Send, A: Send, E: Send>(
151    items: Vec<T>,
152    parallel: bool,
153    identity: impl Fn() -> A + Sync + Send,
154    fold: impl Fn(A, usize, T) -> Result<A, E> + Sync + Send,
155    combine: impl Fn(A, A) -> A + Sync + Send,
156) -> Result<A, E> {
157    crate::fpenv::ieee(|| {
158        let chunk = items.len().div_ceil(REDUCTION_CHUNKS).max(1);
159        let mut chunks: Vec<Vec<(usize, T)>> = Vec::with_capacity(items.len().div_ceil(chunk));
160        let mut indexed = items.into_iter().enumerate().peekable();
161        while indexed.peek().is_some() {
162            chunks.push(indexed.by_ref().take(chunk).collect());
163        }
164        let fold_chunk = |chunk: Vec<(usize, T)>| {
165            chunk
166                .into_iter()
167                .try_fold(identity(), |partial, (index, item)| {
168                    fold(partial, index, item)
169                })
170        };
171        if parallel && chunks.len() > 1 && crate::threads::current_num_threads() > 1 {
172            // ponytail: up to 64 partial gradients stay live; combine fixed batches
173            // if their memory becomes the limiting cost.
174            let partials: Vec<Result<A, E>> = crate::threads::install(|| {
175                chunks
176                    .into_par_iter()
177                    .map(|chunk| crate::fpenv::ieee(|| fold_chunk(chunk)))
178                    .collect()
179            });
180            let mut partials = partials.into_iter();
181            let first = partials.next().unwrap_or_else(|| Ok(identity()))?;
182            partials.try_fold(first, |total, partial| Ok(combine(total, partial?)))
183        } else {
184            let mut chunks = chunks.into_iter();
185            let first = chunks.next().map_or_else(|| Ok(identity()), fold_chunk)?;
186            chunks.try_fold(first, |total, chunk| Ok(combine(total, fold_chunk(chunk)?)))
187        }
188    })
189}
190
191/// `map` over `0..count`, collected in index order and stopping at the first error.
192pub(crate) fn try_map<T: Send, E: Send>(
193    count: usize,
194    parallel: bool,
195    map: impl Fn(usize) -> Result<T, E> + Sync + Send,
196) -> Result<Vec<T>, E> {
197    if parallel && crate::threads::current_num_threads() > 1 {
198        crate::threads::install(|| {
199            (0..count)
200                .into_par_iter()
201                .map(|i| crate::fpenv::ieee(|| map(i)))
202                .collect()
203        })
204    } else {
205        crate::fpenv::ieee(|| (0..count).map(map).collect())
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use proptest::prelude::*;
212
213    use super::try_fold_ordered;
214    use crate::test_support::DEFAULT_CASES;
215
216    /// `items` summed with `try_fold_ordered` on a pool of `threads` workers.
217    fn sum(items: &[f64], threads: usize, parallel: bool) -> Result<f64, usize> {
218        let pool = rayon::ThreadPoolBuilder::new()
219            .num_threads(threads)
220            .build()
221            .map_err(|_| usize::MAX)?;
222        pool.install(|| {
223            try_fold_ordered(
224                items.to_vec(),
225                parallel,
226                || 0.0,
227                |total, index, item| {
228                    if item.is_nan() {
229                        Err(index)
230                    } else {
231                        Ok(total + item)
232                    }
233                },
234                |left, right| left + right,
235            )
236        })
237    }
238
239    proptest! {
240        #![proptest_config(ProptestConfig::with_cases(DEFAULT_CASES))]
241
242        /// Sums whose rounding depends on the order of additions repeat bit for bit
243        /// on every pool size and on the calling thread.
244        #[test]
245        fn ordered_folds_do_not_depend_on_the_thread_count(
246            items in prop::collection::vec(
247                (-1e3_f64..1e3, -20_i32..20).prop_map(|(x, e)| x * 10_f64.powi(e)),
248                0..600,
249            ),
250        ) {
251            let reference = sum(&items, 1, false).unwrap_or_default().to_bits();
252            for threads in [1, 2, 3, 4] {
253                for parallel in [false, true] {
254                    let total = sum(&items, threads, parallel).unwrap_or_default();
255                    prop_assert_eq!(total.to_bits(), reference, "{} threads", threads);
256                }
257            }
258            // A single chunk is a plain sequential fold.
259            if items.len() <= super::REDUCTION_CHUNKS {
260                let sequential = items.iter().fold(0.0, |total, &item| total + item);
261                prop_assert_eq!(reference, sequential.to_bits());
262            }
263        }
264
265        /// The error is that of the first failing item, as in a sequential loop.
266        #[test]
267        fn ordered_folds_report_the_first_failing_item(
268            len in 1_usize..600,
269            failing in prop::collection::btree_set(0_usize..600, 1..4),
270        ) {
271            let failing: Vec<_> = failing.into_iter().filter(|&i| i < len).collect();
272            let mut items = vec![1.0; len];
273            for &i in &failing {
274                items[i] = f64::NAN;
275            }
276            for threads in [1, 3] {
277                let expected = failing
278                    .first()
279                    .map_or_else(|| Ok(f64::from(u16::try_from(len).unwrap_or_default())), |&i| Err(i));
280                prop_assert_eq!(sum(&items, threads, true), expected);
281            }
282        }
283    }
284}