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}