Skip to main content

grit_lib/parallel/
mod.rs

1//! Ordered scoped-thread parallelism and Git config-driven thread policy.
2//!
3//! There is no global pool and no environment reads in this module. Embedders and
4//! the CLI pass an explicit [`ThreadPolicy`] (for example `--threads`) when opening
5//! a [`crate::repo::Repository`].
6//!
7//! Library guide: [parallelism](https://grit-scm.com/docs/library/parallelism)
8//!
9//! ```rust
10//! use grit_lib::parallel::ThreadPolicy;
11//! # fn main() {
12//! let _ = ThreadPolicy::default();
13//! # }
14//! ```
15
16mod map;
17mod policy;
18mod pool;
19
20pub(crate) use map::pool_worthwhile;
21pub(crate) use pool::{run_ordered_batches, run_ordered_pool};
22
23pub use map::{
24    par_map, parallel_worthwhile, try_par_map, try_par_map_force, try_par_map_with_local,
25};
26pub use policy::{ParallelOp, ParallelOptions, ThreadPolicy};
27
28use std::num::NonZeroUsize;
29
30/// Failure from fallible parallel helpers when a closure returns an error.
31///
32/// If a worker panics, partial results are dropped and the panic is resumed on the
33/// caller thread after workers join.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ParallelHashError<E> {
36    /// The first error returned by a worker (additional concurrent errors are discarded).
37    Task(E),
38}
39
40impl<E: std::fmt::Display> std::fmt::Display for ParallelHashError<E> {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Task(e) => write!(f, "{e}"),
44        }
45    }
46}
47
48impl<E: std::fmt::Debug + std::fmt::Display> std::error::Error for ParallelHashError<E> {}
49
50/// Resolved worker thread count for parallel helpers.
51///
52/// Config keys are interpreted by [`ThreadPolicy::resolve`]; this type only clamps
53/// an optional request to [`std::thread::available_parallelism`].
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct Parallelism {
56    threads: NonZeroUsize,
57}
58
59impl Parallelism {
60    /// Use exactly `threads` workers (must be at least 1).
61    #[must_use]
62    pub fn new(threads: NonZeroUsize) -> Self {
63        Self { threads }
64    }
65
66    /// Resolve a thread count from config (`None` or `Some(0)` → all logical CPUs).
67    #[must_use]
68    pub fn resolve(requested: Option<usize>) -> Self {
69        let avail = std::thread::available_parallelism()
70            .map(NonZeroUsize::get)
71            .unwrap_or(1);
72        let n = match requested {
73            None | Some(0) => avail,
74            Some(n) => n.min(avail),
75        };
76        Self {
77            threads: NonZeroUsize::new(n.max(1)).unwrap_or(NonZeroUsize::MIN),
78        }
79    }
80
81    /// Explicit worker count passed to parallel entry points.
82    #[must_use]
83    pub fn threads(self) -> NonZeroUsize {
84        self.threads
85    }
86}
87
88/// Worker count for index preload / parallel worktree hashing from config alone.
89#[must_use]
90pub fn index_parallelism_from_config(config: &crate::config::ConfigSet) -> Parallelism {
91    ThreadPolicy::default()
92        .resolve(ParallelOp::IndexRefresh, config)
93        .parallelism()
94}
95
96/// Resolve `IngestPackOptions::threads` from explicit fetch options, CLI policy, or config.
97///
98/// Returns `Some(n)` only when `explicit` or a [`ThreadPolicy::threads_override`] is set.
99/// Otherwise returns `None` so index-pack reads `pack.threads` from the repository config.
100pub fn index_pack_threads_for_ingest(
101    policy: ThreadPolicy,
102    config: &crate::config::ConfigSet,
103    explicit: Option<usize>,
104) -> Result<Option<usize>, crate::error::ConfigError> {
105    if let Some(n) = explicit {
106        return Ok(Some(n));
107    }
108    if policy.threads_override.is_some() {
109        return Ok(Some(
110            policy
111                .try_resolve(ParallelOp::IndexPack, config)?
112                .threads
113                .get(),
114        ));
115    }
116    Ok(None)
117}
118
119/// Lazy name-hash thread count from config and Git's `LAZY_THREAD_COST` thresholds.
120#[must_use]
121pub fn index_load_lazy_thread_count(
122    policy: &ThreadPolicy,
123    config: &crate::config::ConfigSet,
124    cache_nr: usize,
125    try_threaded: bool,
126) -> usize {
127    if !try_threaded {
128        return 0;
129    }
130    let opts = policy.resolve(ParallelOp::IndexLoad, config);
131    if opts.threads.get() <= 1 {
132        return 0;
133    }
134    if cache_nr < 2 * crate::index_name_hash_lazy::LAZY_THREAD_COST {
135        return 0;
136    }
137    let mut nr = opts.threads.get();
138    if cache_nr < nr * crate::index_name_hash_lazy::LAZY_THREAD_COST {
139        nr = cache_nr / crate::index_name_hash_lazy::LAZY_THREAD_COST;
140    }
141    nr
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::sync::atomic::{AtomicUsize, Ordering};
148    use std::sync::Arc;
149
150    fn threads(n: usize) -> NonZeroUsize {
151        NonZeroUsize::new(n).expect("non-zero threads")
152    }
153
154    #[test]
155    fn try_par_map_preserves_order() {
156        let items: Vec<usize> = (0..256).collect();
157        let opts = ParallelOptions {
158            threads: threads(4),
159            min_items: 0,
160            min_total_bytes: None,
161            signed_entry_threshold: None,
162        };
163        let got = par_map(&items, opts, items.len(), |&x| x * 2);
164        let expect: Vec<_> = items.iter().map(|x| x * 2).collect();
165        assert_eq!(got, expect);
166    }
167
168    #[test]
169    fn try_par_map_surfaces_task_error() {
170        let items: Vec<i32> = (0..128).collect();
171        let opts = ParallelOptions {
172            threads: threads(4),
173            min_items: 0,
174            min_total_bytes: None,
175            signed_entry_threshold: None,
176        };
177        let err = try_par_map(&items, opts, items.len(), |&x| {
178            if x == 64 {
179                Err("boom")
180            } else {
181                Ok(x)
182            }
183        })
184        .unwrap_err();
185        assert_eq!(err, ParallelHashError::Task("boom"));
186    }
187
188    #[test]
189    fn ordered_batches_consume_in_item_order_with_worker_state() {
190        let items: Vec<usize> = (0..1000).collect();
191        let inits = AtomicUsize::new(0);
192        let mut seen = Vec::new();
193        let mut batch_lens = Vec::new();
194        run_ordered_batches(
195            &items,
196            7,
197            4,
198            || {
199                inits.fetch_add(1, Ordering::Relaxed);
200                0usize
201            },
202            |mapped: &mut usize, &x| -> Result<usize, ()> {
203                *mapped += 1;
204                Ok(x * 3)
205            },
206            |batch, results| {
207                assert_eq!(batch.len(), results.len());
208                batch_lens.push(batch.len());
209                seen.extend(results);
210                Ok(())
211            },
212        )
213        .expect("batches");
214        assert_eq!(seen, items.iter().map(|x| x * 3).collect::<Vec<_>>());
215        assert_eq!(batch_lens.iter().sum::<usize>(), items.len());
216        assert!(batch_lens[..batch_lens.len() - 1].iter().all(|&n| n == 7));
217        assert_eq!(
218            inits.load(Ordering::Relaxed),
219            4,
220            "init runs once per worker"
221        );
222    }
223
224    #[test]
225    fn ordered_batches_surface_map_and_consume_errors() {
226        let items: Vec<i32> = (0..500).collect();
227        let err = run_ordered_batches(
228            &items,
229            10,
230            3,
231            || (),
232            |_, &x| if x == 250 { Err("map") } else { Ok(x) },
233            |_, _| Ok(()),
234        )
235        .unwrap_err();
236        assert_eq!(err, "map");
237
238        let mut consumed = 0;
239        let err = run_ordered_batches(
240            &items,
241            10,
242            3,
243            || (),
244            |_, &x| Ok::<_, &str>(x),
245            |_, _| {
246                consumed += 1;
247                if consumed == 2 {
248                    Err("consume")
249                } else {
250                    Ok(())
251                }
252            },
253        )
254        .unwrap_err();
255        assert_eq!(err, "consume");
256        assert_eq!(consumed, 2, "no batch is consumed after an error");
257
258        run_ordered_batches(
259            &[] as &[i32],
260            10,
261            3,
262            || (),
263            |_, &x| Ok::<_, ()>(x),
264            |_, _| panic!("nothing to consume"),
265        )
266        .expect("empty input");
267    }
268
269    #[test]
270    fn ordered_batches_resume_worker_panics() {
271        let items: Vec<i32> = (0..200).collect();
272        let outcome = std::panic::catch_unwind(|| {
273            run_ordered_batches(
274                &items,
275                5,
276                4,
277                || (),
278                |_, &x| -> Result<i32, ()> {
279                    assert!(x != 120, "worker boom");
280                    Ok(x)
281                },
282                |_, _| Ok(()),
283            )
284        });
285        assert!(outcome.is_err());
286    }
287
288    #[test]
289    fn index_pack_threads_for_ingest_honors_override_only() {
290        let cfg = config_from("[pack]\n\tthreads = 2\n");
291        assert_eq!(
292            index_pack_threads_for_ingest(ThreadPolicy::default(), &cfg, None).expect("ingest"),
293            None
294        );
295        assert_eq!(
296            index_pack_threads_for_ingest(ThreadPolicy::default(), &cfg, Some(3)).expect("ingest"),
297            Some(3)
298        );
299        let policy = ThreadPolicy::with_threads_override(Some(6));
300        assert_eq!(
301            index_pack_threads_for_ingest(policy, &cfg, None).expect("ingest"),
302            Some(policy.resolve(ParallelOp::IndexPack, &cfg).threads.get())
303        );
304    }
305
306    fn config_from(text: &str) -> crate::config::ConfigSet {
307        use crate::config::{ConfigFile, ConfigScope, ConfigSet};
308        use std::path::Path;
309        let file = ConfigFile::parse(Path::new("test"), text, ConfigScope::Local).expect("parse");
310        let mut set = ConfigSet::new();
311        set.merge(&file);
312        set
313    }
314
315    #[test]
316    fn try_par_map_with_local_reuses_per_worker_state() {
317        let items: Vec<i32> = (0..256).collect();
318        let opts = ParallelOptions {
319            threads: threads(4),
320            min_items: 0,
321            min_total_bytes: None,
322            signed_entry_threshold: None,
323        };
324        let init_count = Arc::new(AtomicUsize::new(0));
325        let init_count_for_init = Arc::clone(&init_count);
326        let got = try_par_map_with_local(
327            &items,
328            opts,
329            items.len(),
330            move || {
331                init_count_for_init.fetch_add(1, Ordering::SeqCst);
332                0_i32
333            },
334            |state, &x| -> Result<i32, ()> {
335                *state += 1;
336                Ok(x + *state)
337            },
338        )
339        .expect("map");
340        assert_eq!(got.len(), items.len());
341        let inits = init_count.load(Ordering::SeqCst);
342        assert!(
343            inits >= 2,
344            "expected multiple workers to init state, got {inits}"
345        );
346        assert!(
347            inits <= 4,
348            "init count should not exceed worker count, got {inits}"
349        );
350    }
351}