gwseq_io/parallel.rs
1//! Thread pools.
2//!
3//! One [`Executor`] over a `rayon::ThreadPool`.
4//!
5//! rayon's scoped parallelism covers what this library needs: a `par_iter`
6//! over batches writes into indexed slots, which *is* submission order. There
7//! is no completion-ordered result stream and no handle-checkout semaphore,
8//! because nothing here wants either.
9//!
10//! One pool per reader, owned for its lifetime, so that a request reading a
11//! few blocks does not pay to start and join threads. `close()` drops the
12//! executor and that is what gives the threads back — but dropping a rayon pool
13//! only *signals* its workers, so [`Executor`] keeps every `JoinHandle` and
14//! joins them itself. See its `Drop`.
15
16use std::sync::Arc;
17
18use parking_lot::Mutex;
19
20use crate::error::{Error, Result};
21
22/// One thread per core, capped here.
23pub const RECOMMENDED_MAX_THREADS: usize = 12;
24
25/// Turn the `parallel` argument of the public API — where zero or less spells
26/// "you decide" — into a thread count.
27pub fn resolve_parallel(parallel: i64) -> usize {
28 if parallel > 0 {
29 return parallel as usize;
30 }
31 std::thread::available_parallelism()
32 .map(|n| n.get())
33 .unwrap_or(1)
34 .min(RECOMMENDED_MAX_THREADS)
35}
36
37pub struct Executor {
38 /// `None` only during [`Drop`], which takes it to trigger the shutdown.
39 pool: Option<rayon::ThreadPool>,
40 /// The worker threads, so [`Drop`] can join them.
41 ///
42 /// Dropping a `rayon::ThreadPool` *signals* its workers to stop and returns
43 /// without waiting for them, so a caller counting process threads
44 /// immediately after `close()` still sees them. The pool is therefore
45 /// built with a `spawn_handler` that keeps every `JoinHandle`, and
46 /// dropping the pool is followed by joining them here.
47 handles: Arc<Mutex<Vec<std::thread::JoinHandle<()>>>>,
48 parallel: usize,
49}
50
51impl Drop for Executor {
52 fn drop(&mut self) {
53 // Order matters: dropping the pool is what tells the workers to stop,
54 // and joining them is what makes `close()` mean they are gone. Joining
55 // first would wait forever.
56 drop(self.pool.take());
57 for handle in self.handles.lock().drain(..) {
58 let _ = handle.join();
59 }
60 // Nothing after the joins. On Darwin `pthread_join` returns before the
61 // exiting thread has finished `__bsdthread_terminate`, so the process's
62 // own thread count can briefly still include a worker this has already
63 // joined — but the join is what "the threads are gone" means, and a
64 // sleep in a close path to make an observer of `proc_pidinfo` see the
65 // number it expects is measuring the OS, not this library.
66 }
67}
68
69/// Hand-written: `rayon::ThreadPool` is not `Debug`, and a reader that holds an
70/// executor still has to be printable.
71impl std::fmt::Debug for Executor {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("Executor")
74 .field("parallel", &self.parallel)
75 .finish()
76 }
77}
78
79impl Executor {
80 pub fn new(parallel: i64) -> Result<Self> {
81 let parallel = resolve_parallel(parallel);
82 let handles: Arc<Mutex<Vec<std::thread::JoinHandle<()>>>> =
83 Arc::new(Mutex::new(Vec::with_capacity(parallel)));
84 let sink = handles.clone();
85 let pool = rayon::ThreadPoolBuilder::new()
86 .num_threads(parallel)
87 .spawn_handler(move |thread| {
88 let name = thread
89 .name()
90 .map(str::to_string)
91 .unwrap_or_else(|| format!("gwseq-io-{}", thread.index()));
92 let handle = std::thread::Builder::new()
93 .name(name)
94 .spawn(move || thread.run())?;
95 sink.lock().push(handle);
96 Ok(())
97 })
98 .thread_name(|i| format!("gwseq-io-{i}"))
99 // rayon's default is to abort the process when a `spawn` job
100 // panics, which would make the recovery below it unreachable: the
101 // writer's `Promise::wait` returns `None` for a worker that dropped
102 // its half without filling it, and turns that into "a block failed
103 // to compress" on the call that is running. Swallowed here because
104 // rayon has already printed the panic; what this restores is the
105 // caller's chance to see an error instead of a signal.
106 .panic_handler(|_| {})
107 .build()
108 // `Io`, not `InvalidArgument`: a thread that will not spawn is the
109 // operating system refusing, not the caller asking for something
110 // impossible — `parallel` was clamped to something sane long before
111 // here. Python surfaces this as `SourceError`/`OSError`, which is
112 // what it is.
113 .map_err(|e| {
114 Error::io(
115 format!("could not start {parallel} threads"),
116 std::io::Error::other(e.to_string()),
117 )
118 })?;
119 Ok(Self {
120 pool: Some(pool),
121 handles,
122 parallel,
123 })
124 }
125
126 /// Worker handles still held, i.e. threads this executor will join when it
127 /// is dropped. Test-only.
128 #[cfg(test)]
129 fn handle_count(&self) -> usize {
130 self.handles.lock().len()
131 }
132
133 fn pool(&self) -> &rayon::ThreadPool {
134 self.pool
135 .as_ref()
136 .expect("the pool is only taken while the executor is being dropped")
137 }
138
139 /// How many workers, which is also how many batches the readers split a
140 /// request into.
141 pub fn parallel(&self) -> usize {
142 self.parallel
143 }
144
145 /// Run `f` on the pool. Rayon calls made inside it use these threads rather
146 /// than the global pool, which is what keeps one reader's `parallel` a
147 /// promise about this reader.
148 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
149 self.pool().install(f)
150 }
151
152 /// Hand one job to the pool and return without waiting.
153 ///
154 /// What the writer's deflate pipeline is built on: a block is submitted,
155 /// the caller goes on filling the next one, and the result is collected in
156 /// submission order later. Everything else here is fork-join, which does
157 /// not fit a producer that has to keep producing.
158 pub fn spawn(&self, f: impl FnOnce() + Send + 'static) {
159 self.pool().spawn(f);
160 }
161
162 /// Run one job per batch, collecting the first error.
163 ///
164 /// Batching is the caller's: `IndexedLocs::batches` splits loci into
165 /// coverage-balanced groups, and that split decides which blocks each
166 /// worker touches and so how much of the cache is shared. Letting rayon
167 /// work-steal its own split would change both, and with them the order
168 /// `f32` accumulators are summed in — which is visible in the last bits of
169 /// every mean and standard deviation.
170 pub fn for_each_batch<T: Send + Sync>(
171 &self,
172 batches: &[T],
173 f: impl Fn(usize, &T) -> Result<()> + Send + Sync,
174 ) -> Result<()> {
175 use rayon::prelude::*;
176 // `par_iter` over the batches lets rayon choose which thread runs which
177 // batch, and nothing more: `f` is called once per batch, so the batch
178 // boundaries — and with them the order values are accumulated inside
179 // one — stay exactly where the caller put them.
180 self.install(|| {
181 batches
182 .par_iter()
183 .enumerate()
184 .try_for_each(|(index, batch)| f(index, batch))
185 })
186 }
187
188 /// The same, keeping each batch's result in submission order.
189 ///
190 /// What the extraction kernels use: a batch's loci own scattered slices of
191 /// the output, not a contiguous one — the loci are sorted into file order
192 /// while their output slices follow the request's order — so each worker
193 /// fills a compact buffer of its own and the caller scatters afterwards.
194 /// That keeps every bin accumulated by exactly one worker in one order,
195 /// which is what makes the result bit-reproducible.
196 pub fn map_batches<T: Send, B: Send + Sync>(
197 &self,
198 batches: &[B],
199 f: impl Fn(usize, &B) -> Result<T> + Send + Sync,
200 ) -> Result<Vec<T>> {
201 use rayon::prelude::*;
202 self.install(|| {
203 batches
204 .par_iter()
205 .enumerate()
206 .map(|(index, batch)| f(index, batch))
207 .collect::<Result<Vec<T>>>()
208 })
209 }
210}
211
212/// A one-shot slot a worker fills and its submitter waits on.
213///
214/// What the writer's deflate pipeline hands out per block. `mpsc::Receiver` is
215/// the obvious thing and is `Send` but not `Sync`, which a `#[pyclass]` holding
216/// the writer needs; and a channel of one is more machinery than a slot and a
217/// condvar anyway.
218#[derive(Debug)]
219pub struct Promise<T> {
220 inner: Arc<(Mutex<Option<T>>, parking_lot::Condvar)>,
221}
222
223impl<T> Clone for Promise<T> {
224 fn clone(&self) -> Self {
225 Self {
226 inner: self.inner.clone(),
227 }
228 }
229}
230
231impl<T> Default for Promise<T> {
232 fn default() -> Self {
233 Self::new()
234 }
235}
236
237impl<T> Promise<T> {
238 pub fn new() -> Self {
239 Self {
240 inner: Arc::new((Mutex::new(None), parking_lot::Condvar::new())),
241 }
242 }
243
244 /// Fill it, waking whoever is waiting. A second `set` is ignored.
245 pub fn set(&self, value: T) {
246 let mut slot = self.inner.0.lock();
247 if slot.is_none() {
248 *slot = Some(value);
249 self.inner.1.notify_all();
250 }
251 }
252
253 /// Wait for it, or return `None` if every other holder was dropped without
254 /// filling it — which is what a panicked worker leaves behind.
255 pub fn wait(self) -> Option<T> {
256 let mut slot = self.inner.0.lock();
257 loop {
258 if let Some(value) = slot.take() {
259 return Some(value);
260 }
261 // One other holder is the worker; none means it is gone.
262 if Arc::strong_count(&self.inner) <= 1 {
263 return None;
264 }
265 self.inner
266 .1
267 .wait_for(&mut slot, std::time::Duration::from_millis(50));
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use std::sync::atomic::{AtomicUsize, Ordering};
276
277 #[test]
278 fn resolve_parallel_takes_a_positive_count_as_given() {
279 assert_eq!(resolve_parallel(1), 1);
280 assert_eq!(resolve_parallel(7), 7);
281 assert_eq!(resolve_parallel(100), 100);
282 }
283
284 #[test]
285 fn zero_or_less_means_one_per_core_capped() {
286 for asked in [0, -1, -12] {
287 let n = resolve_parallel(asked);
288 assert!(
289 (1..=RECOMMENDED_MAX_THREADS).contains(&n),
290 "{asked} gave {n}"
291 );
292 }
293 assert_eq!(resolve_parallel(0), resolve_parallel(-1));
294 }
295
296 #[test]
297 fn every_batch_runs_exactly_once() {
298 let executor = Executor::new(4).unwrap();
299 assert_eq!(executor.parallel(), 4);
300 let batches: Vec<usize> = (0..64).collect();
301 let seen: Vec<AtomicUsize> = (0..64).map(|_| AtomicUsize::new(0)).collect();
302 executor
303 .for_each_batch(&batches, |index, batch| {
304 assert_eq!(index, *batch);
305 seen[index].fetch_add(1, Ordering::SeqCst);
306 Ok(())
307 })
308 .unwrap();
309 assert!(seen.iter().all(|c| c.load(Ordering::SeqCst) == 1));
310 }
311
312 #[test]
313 fn a_failing_batch_surfaces_as_the_result() {
314 let executor = Executor::new(4).unwrap();
315 let batches: Vec<usize> = (0..32).collect();
316 let err = executor
317 .for_each_batch(&batches, |_, batch| {
318 if *batch == 17 {
319 Err(Error::invalid("batch 17"))
320 } else {
321 Ok(())
322 }
323 })
324 .unwrap_err();
325 assert!(err.to_string().contains("batch 17"));
326 }
327
328 #[test]
329 fn every_worker_is_held_for_joining_and_the_join_happens_on_drop() {
330 // Counting the process's threads after a `close()` would be the
331 // end-to-end version, but that count is noise here: cargo runs the
332 // other tests in this file at the same time. What is deterministic is
333 // that a handle is kept per worker and `Drop` joins every one of them
334 // before returning.
335 let executor = Executor::new(4).unwrap();
336 let batches: Vec<usize> = (0..64).collect();
337 executor.for_each_batch(&batches, |_, _| Ok(())).unwrap();
338 assert_eq!(
339 executor.handle_count(),
340 4,
341 "rayon spawned workers this executor did not keep a handle for"
342 );
343 // `drop` returns only once every `JoinHandle::join` has, which is the
344 // guarantee: rayon's own `Drop` merely signals.
345 drop(executor);
346 }
347
348 #[test]
349 fn map_batches_keeps_submission_order() {
350 let executor = Executor::new(4).unwrap();
351 let batches: Vec<usize> = (0..50).collect();
352 let out = executor
353 .map_batches(&batches, |index, batch| Ok(index * 10 + batch))
354 .unwrap();
355 assert_eq!(out, (0..50).map(|i| i * 11).collect::<Vec<_>>());
356 }
357
358 #[test]
359 fn map_batches_surfaces_a_failure() {
360 let executor = Executor::new(4).unwrap();
361 let batches: Vec<usize> = (0..32).collect();
362 let err = executor
363 .map_batches(&batches, |_, batch| {
364 if *batch == 5 {
365 Err(Error::invalid("batch 5"))
366 } else {
367 Ok(*batch)
368 }
369 })
370 .unwrap_err();
371 assert!(err.to_string().contains("batch 5"));
372 }
373
374 #[test]
375 fn an_empty_request_is_not_an_error() {
376 let executor = Executor::new(2).unwrap();
377 let batches: Vec<usize> = Vec::new();
378 executor.for_each_batch(&batches, |_, _| Ok(())).unwrap();
379 }
380}