ogeom_core/parallel.rs
1//! Deterministic parallelism for the kernel's embarrassingly parallel
2//! stages.
3//!
4//! The rule that makes parallelism admissible here at all: **the answer must
5//! be bit-identical at any thread count.** [`map_ordered`] guarantees it
6//! structurally: each item is computed independently from shared read-only
7//! input, results are collected in item order, and nothing about scheduling
8//! can reach the output. A stage that cannot meet that bar stays sequential.
9//!
10//! The thread count comes from [`threads`]: the machine's parallelism by
11//! default, or the `OGEOM_THREADS` environment variable when it holds a
12//! positive count, and overridable process-wide with [`set_threads`],
13//! including down to one, which is also what tiny workloads collapse to on
14//! their own.
15//! Worker threads re-install the caller's progress watch, so cancellation
16//! reaches into the workers.
17//!
18//! Workers are started with `std::thread::scope`. On a target whose
19//! standard library cannot start a thread, a host that has threads there (a
20//! browser's web workers sharing the module's memory) lends them with
21//! [`set_pool`]; once set, every stage runs its workers on that [`Pool`].
22
23use std::any::Any;
24use std::panic::AssertUnwindSafe;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::sync::{Mutex, OnceLock, PoisonError};
27
28use crate::progress;
29
30/// Whether the standard library can start a thread on this target. On
31/// `wasm32-unknown-unknown`, and on WebAssembly without shared memory, a
32/// spawn panics, so without a [`Pool`] every stage runs on the calling
33/// thread there whatever [`set_threads`] asked for.
34const CAN_SPAWN: bool = !cfg!(any(
35 all(target_arch = "wasm32", target_os = "unknown"),
36 all(target_family = "wasm", not(target_feature = "atomics")),
37));
38
39/// 0 means "ask the machine".
40static THREADS: AtomicUsize = AtomicUsize::new(0);
41
42/// Threads a host lends the kernel, where `std::thread` cannot start one.
43///
44/// Once set with [`set_pool`], every parallel stage runs its workers through
45/// [`Pool::broadcast`] instead of starting threads of its own.
46///
47/// The pool's workers must not need the thread that calls into the kernel
48/// to make progress: that thread blocks inside [`Pool::broadcast`] until
49/// every copy has returned. A browser's main thread cannot block, so a
50/// browser host calls the kernel from a worker of its own.
51///
52/// A rayon-backed host:
53///
54/// ```text
55/// struct Rayon;
56/// impl ogeom_core::parallel::Pool for Rayon {
57/// fn workers(&self) -> usize {
58/// rayon::current_num_threads()
59/// }
60/// fn broadcast(&self, copies: usize, job: &(dyn Fn() + Sync)) {
61/// rayon::scope(|s| {
62/// for _ in 0..copies {
63/// s.spawn(|_| job());
64/// }
65/// });
66/// }
67/// }
68/// ```
69pub trait Pool: Sync {
70 /// How many workers the pool runs at once.
71 fn workers(&self) -> usize;
72
73 /// Run `job` on `copies` workers at once and return when every copy has
74 /// returned.
75 ///
76 /// Each copy takes items until none are left, so a copy that starts late
77 /// or never costs time, not results: whatever the copies leave, the
78 /// caller computes after `broadcast` returns. A copy does not unwind
79 /// into the pool: a panic in a stage is caught in the copy and resumed
80 /// on the caller once `broadcast` returns.
81 fn broadcast(&self, copies: usize, job: &(dyn Fn() + Sync));
82}
83
84static POOL: OnceLock<&'static dyn Pool> = OnceLock::new();
85
86/// Run parallel stages on `pool` from now on. The first call wins; later
87/// calls change nothing.
88///
89/// With a pool set, [`threads`] honours [`set_threads`] and `OGEOM_THREADS`
90/// on every target, and a stage takes at most [`Pool::workers`] workers.
91pub fn set_pool(pool: &'static dyn Pool) {
92 let _first_wins = POOL.set(pool);
93}
94
95/// The thread count parallel stages will use.
96///
97/// The count given to [`set_threads`] when there is one; otherwise
98/// `OGEOM_THREADS` from the environment when it parses as a positive
99/// count; otherwise the machine's available parallelism, or the lent
100/// [`Pool`]'s worker count on a target that cannot start a thread. The
101/// environment is read once, on the first call that needs it. One on a
102/// target that cannot start a thread and has no pool.
103#[must_use]
104pub fn threads() -> usize {
105 let pool = POOL.get();
106 if !CAN_SPAWN && pool.is_none() {
107 return 1;
108 }
109 let configured = THREADS.load(Ordering::Relaxed);
110 if configured != 0 {
111 return configured;
112 }
113 static ENVIRONMENT: OnceLock<Option<usize>> = OnceLock::new();
114 let environment = *ENVIRONMENT
115 .get_or_init(|| from_environment(std::env::var("OGEOM_THREADS").ok().as_deref()));
116 if let Some(count) = environment {
117 return count;
118 }
119 match pool {
120 Some(pool) if !CAN_SPAWN => pool.workers().max(1),
121 _ => {
122 // Asked once: the query reads the scheduler's affinity and
123 // quota, and every parallel stage asks.
124 static MACHINE: OnceLock<usize> = OnceLock::new();
125 *MACHINE.get_or_init(|| {
126 std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
127 })
128 }
129 }
130}
131
132/// The thread count an `OGEOM_THREADS` value asks for: a positive integer,
133/// surrounding blanks allowed. Anything else asks for nothing.
134fn from_environment(value: Option<&str>) -> Option<usize> {
135 value?.trim().parse::<usize>().ok().filter(|&n| n > 0)
136}
137
138std::thread_local! {
139 /// Set on a worker for the life of its stage: a parallel stage inside
140 /// another (a boolean's face pairs each classifying points in parallel)
141 /// runs on the worker it lands on, rather than starting a machine's
142 /// worth of workers per outer item.
143 static INSIDE: core::cell::Cell<bool> = const { core::cell::Cell::new(false) };
144}
145
146/// Set the process-wide thread count for parallel stages. `0` restores the
147/// machine default. The answer never depends on this. Only the wall clock
148/// does.
149pub fn set_threads(count: usize) {
150 THREADS.store(count, Ordering::Relaxed);
151}
152
153/// The workers `std::thread::scope` starts where no [`Pool`] is lent.
154struct Scoped;
155
156impl Pool for Scoped {
157 fn workers(&self) -> usize {
158 usize::MAX
159 }
160
161 fn broadcast(&self, copies: usize, job: &(dyn Fn() + Sync)) {
162 std::thread::scope(|scope| {
163 for _ in 0..copies {
164 scope.spawn(job);
165 }
166 });
167 }
168}
169
170/// Map `f` over `items` on up to [`threads`] workers, returning results in
171/// item order. `f` receives the item index and the item.
172///
173/// The workers are scoped threads, or the lent [`Pool`]'s when one is set,
174/// capped at its [`Pool::workers`]. A call made from a worker of another
175/// stage runs on that worker.
176///
177/// Determinism holds by construction: items are computed independently and
178/// results placed by index, so the output is identical at any thread count,
179/// pool or not. The caller's progress watch is re-installed in every
180/// worker; `f` may checkpoint through it. A panic in `f` resumes on the
181/// caller.
182pub fn map_ordered<T, R>(items: &[T], f: impl Fn(usize, &T) -> R + Sync) -> Vec<R>
183where
184 T: Sync,
185 R: Send,
186{
187 let lent = POOL.get().copied();
188 let pool: &dyn Pool = lent.unwrap_or(&Scoped);
189 let workers = threads().min(pool.workers()).clamp(1, items.len().max(1));
190 if (!CAN_SPAWN && lent.is_none())
191 || workers <= 1
192 || items.len() <= 1
193 || INSIDE.with(core::cell::Cell::get)
194 {
195 return items.iter().enumerate().map(|(i, t)| f(i, t)).collect();
196 }
197
198 let snapshot = progress::snapshot();
199 // Work is *taken*, not dealt: expensive items cluster (one spline-heavy
200 // face's edges sit adjacent in a reader's job list), and a worker dealt
201 // that region as a contiguous chunk finishes last while the rest idle.
202 // Each worker pulls the next undone index instead, so the wall clock
203 // tracks the total work rather than the heaviest deal. The answer cannot
204 // tell the difference: every index is computed by the same call exactly
205 // once, and the merge reassembles by index, so the output is the item
206 // order however the indices were claimed.
207 let next = AtomicUsize::new(0);
208 let take = || {
209 let mut mine = Vec::new();
210 loop {
211 let i = next.fetch_add(1, Ordering::Relaxed);
212 let Some(item) = items.get(i) else { break };
213 mine.push((i, f(i, item)));
214 }
215 mine
216 };
217 let parts: Mutex<Vec<Vec<(usize, R)>>> = Mutex::new(Vec::with_capacity(workers));
218 let panicked: Mutex<Option<Box<dyn Any + Send>>> = Mutex::new(None);
219 let job = || {
220 let outer = INSIDE.with(|inside| inside.replace(true));
221 let run = std::panic::catch_unwind(AssertUnwindSafe(|| {
222 progress::with_snapshot(snapshot.as_ref(), take)
223 }));
224 INSIDE.with(|inside| inside.set(outer));
225 match run {
226 Ok(part) => parts
227 .lock()
228 .unwrap_or_else(PoisonError::into_inner)
229 .push(part),
230 Err(panic) => {
231 // The other copies stop at their next claim.
232 next.store(items.len(), Ordering::Relaxed);
233 panicked
234 .lock()
235 .unwrap_or_else(PoisonError::into_inner)
236 .get_or_insert(panic);
237 }
238 }
239 };
240 pool.broadcast(workers, &job);
241 if let Some(panic) = panicked
242 .into_inner()
243 .unwrap_or_else(PoisonError::into_inner)
244 {
245 std::panic::resume_unwind(panic);
246 }
247 let mut indexed: Vec<(usize, R)> = parts
248 .into_inner()
249 .unwrap_or_else(PoisonError::into_inner)
250 .into_iter()
251 .flatten()
252 .collect();
253 // Items no copy claimed (a pool that ran fewer copies than asked) are
254 // taken here, under the caller's own watch.
255 indexed.extend(take());
256 assert_eq!(
257 indexed.len(),
258 items.len(),
259 "a pool returned from broadcast while a copy was still running"
260 );
261 indexed.sort_unstable_by_key(|(i, _)| *i);
262 indexed.into_iter().map(|(_, r)| r).collect()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn order_is_item_order_at_any_thread_count() {
271 let items: Vec<usize> = (0..137).collect();
272 let serial: Vec<usize> = items.iter().map(|x| x * 3).collect();
273 for count in [1, 2, 7] {
274 set_threads(count);
275 let parallel = map_ordered(&items, |i, x| {
276 assert_eq!(i, *x);
277 x * 3
278 });
279 assert_eq!(parallel, serial);
280 }
281 set_threads(0);
282 }
283
284 #[test]
285 #[should_panic(expected = "item 50")]
286 fn a_panic_on_a_worker_resumes_on_the_caller() {
287 let items: Vec<usize> = (0..137).collect();
288 let _ = map_ordered(&items, |i, _| {
289 assert!(i != 50, "item 50");
290 i
291 });
292 }
293
294 #[test]
295 fn the_environment_count_must_be_a_positive_integer() {
296 assert_eq!(from_environment(Some(" 3 ")), Some(3));
297 for refused in [
298 None,
299 Some(""),
300 Some("0"),
301 Some("-2"),
302 Some("four"),
303 Some("2.5"),
304 ] {
305 assert_eq!(from_environment(refused), None, "{refused:?}");
306 }
307 }
308
309 #[test]
310 fn cancellation_reaches_the_workers() {
311 let watch = progress::Watch::new();
312 watch.canceller().cancel();
313 set_threads(4);
314 let items: Vec<usize> = (0..64).collect();
315 let outcomes = progress::watched(&watch, || {
316 map_ordered(&items, |_, _| progress::checkpoint())
317 });
318 set_threads(0);
319 assert!(outcomes.iter().all(std::result::Result::is_err));
320 }
321}