Skip to main content

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}