Skip to main content

grit_lib/hash/
parallel.rs

1//! Parallel batch hashing — thin wrappers over [`crate::parallel`].
2
3use std::convert::Infallible;
4use std::num::NonZeroUsize;
5
6use crate::objects::{HashAlgo, ObjectId, ObjectKind};
7use crate::parallel::{
8    parallel_worthwhile as parallel_worthwhile_opts, try_par_map, try_par_map_force,
9    ParallelOptions,
10};
11
12pub use crate::parallel::index_parallelism_from_config;
13pub use crate::parallel::{ParallelHashError, Parallelism};
14
15use super::hash_object;
16use crate::error::Error;
17
18/// Minimum item count before parallel helpers use more than one thread.
19pub const PAR_HASH_MIN_ITEMS: usize = 32;
20/// Minimum total payload bytes (summed over items) for parallel hashing.
21pub const PAR_HASH_MIN_TOTAL_BYTES: usize = 256 * 1024;
22
23/// Returns true when parallel hashing is expected to beat a serial loop.
24#[must_use]
25pub fn parallel_hash_worthwhile(item_count: usize, total_bytes: usize, threads: usize) -> bool {
26    if threads <= 1 {
27        return false;
28    }
29    parallel_worthwhile_opts(
30        item_count,
31        total_bytes,
32        NonZeroUsize::new(threads).unwrap_or(NonZeroUsize::MIN),
33        PAR_HASH_MIN_ITEMS,
34        PAR_HASH_MIN_TOTAL_BYTES,
35    )
36}
37
38fn hash_parallel_options(threads: NonZeroUsize) -> ParallelOptions {
39    ParallelOptions {
40        threads,
41        min_items: PAR_HASH_MIN_ITEMS,
42        min_total_bytes: Some(PAR_HASH_MIN_TOTAL_BYTES),
43        signed_entry_threshold: None,
44    }
45}
46
47/// Hash Git objects in parallel, preserving input order.
48///
49/// # Errors
50///
51/// Returns [`ParallelHashError::Task`] with [`Error::HashCollision`] when sha1dc
52/// detects a chosen-prefix collision in any item.
53pub fn hash_objects_parallel(
54    algo: HashAlgo,
55    items: &[(ObjectKind, &[u8])],
56    threads: NonZeroUsize,
57) -> Result<Vec<ObjectId>, ParallelHashError<Error>> {
58    let total_bytes: usize = items.iter().map(|(_, data)| data.len()).sum();
59    try_par_hash_with(items, threads, total_bytes, |(kind, data)| {
60        hash_object(algo, *kind, data)
61    })
62}
63
64/// Run `f` over `items` with up to `threads` workers, preserving order.
65#[must_use]
66pub fn par_hash_with<T, R, F>(
67    items: &[T],
68    threads: NonZeroUsize,
69    total_bytes: usize,
70    f: F,
71) -> Vec<R>
72where
73    T: Sync,
74    R: Send,
75    F: Fn(&T) -> R + Sync,
76{
77    try_par_hash_with::<T, R, Infallible, _>(items, threads, total_bytes, |item| Ok(f(item)))
78        .unwrap_or_else(|e| match e {
79            ParallelHashError::Task(infallible) => match infallible {},
80        })
81}
82
83/// Fallible variant of [`par_hash_with`].
84pub fn try_par_hash_with<T, R, E, F>(
85    items: &[T],
86    threads: NonZeroUsize,
87    total_bytes: usize,
88    f: F,
89) -> Result<Vec<R>, ParallelHashError<E>>
90where
91    T: Sync,
92    R: Send,
93    E: Send + 'static,
94    F: Fn(&T) -> Result<R, E> + Sync,
95{
96    try_par_map(items, hash_parallel_options(threads), total_bytes, f)
97}
98
99/// Like [`try_par_hash_with`], but ignores batch thresholds.
100pub fn try_par_hash_with_force<T, R, E, F>(
101    items: &[T],
102    threads: NonZeroUsize,
103    f: F,
104) -> Result<Vec<R>, ParallelHashError<E>>
105where
106    T: Sync,
107    R: Send,
108    E: Send + 'static,
109    F: Fn(&T) -> Result<R, E> + Sync,
110{
111    try_par_map_force(items, hash_parallel_options(threads), f)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::objects::{HashAlgo, ObjectKind};
118    use std::panic::{catch_unwind, AssertUnwindSafe};
119    use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
120    use std::sync::Arc;
121
122    fn threads(n: usize) -> NonZeroUsize {
123        NonZeroUsize::new(n).expect("non-zero threads")
124    }
125
126    fn serial_ids(algo: HashAlgo, items: &[(ObjectKind, &[u8])]) -> Vec<ObjectId> {
127        items
128            .iter()
129            .map(|(kind, data)| hash_object(algo, *kind, data).expect("serial hash"))
130            .collect()
131    }
132
133    struct DropCounter {
134        dropped: Arc<AtomicUsize>,
135    }
136
137    impl Drop for DropCounter {
138        fn drop(&mut self) {
139            self.dropped.fetch_add(1, AtomicOrdering::SeqCst);
140        }
141    }
142
143    #[test]
144    fn parallel_matches_serial_counts_and_threads() {
145        // Miri interprets every SHA-1 round; keep its batch small enough to finish.
146        let large = if cfg!(miri) { 64 } else { 10_000 };
147        let sizes = [0_usize, 1, 7, large];
148        let thread_counts = [1_usize, 2, 8];
149        let blob = vec![0xAB_u8; 512];
150        for &n in &sizes {
151            let items: Vec<(ObjectKind, &[u8])> = (0..n)
152                .map(|_| (ObjectKind::Blob, blob.as_slice()))
153                .collect();
154            let expect = serial_ids(HashAlgo::Sha1, &items);
155            for &t in &thread_counts {
156                let got =
157                    hash_objects_parallel(HashAlgo::Sha1, &items, threads(t)).expect("parallel");
158                assert_eq!(got, expect, "n={n} threads={t}");
159            }
160        }
161    }
162
163    #[test]
164    fn parallel_sha256_matches_serial() {
165        let blobs: Vec<[u8; 64]> = (0..500).map(|i| [i as u8; 64]).collect();
166        let items: Vec<(ObjectKind, &[u8])> = blobs
167            .iter()
168            .map(|b| (ObjectKind::Blob, b.as_slice()))
169            .collect();
170        let expect = serial_ids(HashAlgo::Sha256, &items);
171        let got = hash_objects_parallel(HashAlgo::Sha256, &items, threads(4)).expect("parallel");
172        assert_eq!(got, expect);
173    }
174
175    #[test]
176    fn par_hash_with_preserves_order() {
177        let items: Vec<usize> = (0..256).collect();
178        let got = par_hash_with(&items, threads(4), items.len(), |&x| x * 2);
179        let expect: Vec<_> = items.iter().map(|x| x * 2).collect();
180        assert_eq!(got, expect);
181    }
182
183    #[test]
184    fn try_par_hash_surfaces_task_error_without_deadlock() {
185        let items: Vec<i32> = (0..128).collect();
186        let err = try_par_hash_with(&items, threads(4), items.len(), |&x| {
187            if x == 64 {
188                Err("boom")
189            } else {
190                Ok(x)
191            }
192        })
193        .unwrap_err();
194        assert_eq!(err, ParallelHashError::Task("boom"));
195    }
196
197    #[test]
198    fn try_par_hash_drops_partial_results_on_task_error() {
199        let items: Vec<i32> = (0..128).collect();
200        let made = Arc::new(AtomicUsize::new(0));
201        let dropped = Arc::new(AtomicUsize::new(0));
202        let err = try_par_hash_with::<_, _, (), _>(&items, threads(8), items.len(), |&x| {
203            if x == 127 {
204                while made.load(AtomicOrdering::Acquire) < 127 {
205                    std::hint::spin_loop();
206                }
207                return Err(());
208            }
209            made.fetch_add(1, AtomicOrdering::SeqCst);
210            Ok(DropCounter {
211                dropped: Arc::clone(&dropped),
212            })
213        });
214        assert!(matches!(err, Err(ParallelHashError::Task(()))));
215        assert_eq!(made.load(AtomicOrdering::SeqCst), 127);
216        assert_eq!(dropped.load(AtomicOrdering::SeqCst), 127);
217    }
218
219    #[test]
220    fn try_par_hash_concurrent_errors_are_raced_without_ub() {
221        let items: Vec<i32> = (0..512).collect();
222        let err = try_par_hash_with(&items, threads(8), items.len(), |&x| {
223            if x % 7 == 0 {
224                Err(x)
225            } else {
226                Ok(x)
227            }
228        })
229        .unwrap_err();
230        match err {
231            ParallelHashError::Task(code) => assert_eq!(code % 7, 0),
232        }
233    }
234
235    #[test]
236    fn par_hash_panic_propagates_and_drops_partials() {
237        let items: Vec<i32> = (0..128).collect();
238        let made = Arc::new(AtomicUsize::new(0));
239        let dropped = Arc::new(AtomicUsize::new(0));
240        let result = catch_unwind(AssertUnwindSafe(|| {
241            let _ = try_par_hash_with::<_, _, std::convert::Infallible, _>(
242                &items,
243                threads(8),
244                items.len(),
245                |&x| {
246                    if x == 127 {
247                        while made.load(AtomicOrdering::Acquire) < 127 {
248                            std::hint::spin_loop();
249                        }
250                        panic!("fail");
251                    }
252                    made.fetch_add(1, AtomicOrdering::SeqCst);
253                    Ok(DropCounter {
254                        dropped: Arc::clone(&dropped),
255                    })
256                },
257            );
258        }));
259        assert!(result.is_err());
260        assert_eq!(made.load(AtomicOrdering::SeqCst), 127);
261        assert_eq!(dropped.load(AtomicOrdering::SeqCst), 127);
262    }
263
264    #[test]
265    fn parallelism_resolve_clamps_to_available() {
266        let avail = std::thread::available_parallelism()
267            .map(NonZeroUsize::get)
268            .unwrap_or(1);
269        let p = Parallelism::resolve(Some(usize::MAX));
270        assert_eq!(p.threads().get(), avail);
271        let auto = Parallelism::resolve(None);
272        assert_eq!(auto.threads().get(), avail);
273        let zero = Parallelism::resolve(Some(0));
274        assert_eq!(zero.threads().get(), avail);
275    }
276
277    #[test]
278    fn serial_fallback_below_threshold() {
279        let items: Vec<i32> = (0..4).collect();
280        let counter = Arc::new(AtomicUsize::new(0));
281        let c = Arc::clone(&counter);
282        let _ = par_hash_with(&items, threads(8), 0, move |&x| {
283            c.fetch_add(1, AtomicOrdering::Relaxed);
284            x
285        });
286        assert_eq!(counter.load(AtomicOrdering::Relaxed), 4);
287    }
288}