1use std::num::NonZeroUsize;
4
5use crate::config::ConfigSet;
6use crate::error::{BadNumericSource, ConfigError};
7use crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES;
8
9const PAR_HASH_MIN_ITEMS: usize = 32;
11const PAR_HASH_MIN_TOTAL_BYTES: usize = 256 * 1024;
13
14use super::Parallelism;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum ParallelOp {
19 IndexRefresh,
21 IndexLoad,
23 Checkout,
25 IndexPack,
27 PackWrite,
29 PackDeltaSearch,
31 DiffRename,
33 DiffContent,
35}
36
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct ThreadPolicy {
40 pub threads_override: Option<usize>,
42 pub min_items_override: Option<usize>,
44 pub min_total_bytes_override: Option<usize>,
46}
47
48impl ThreadPolicy {
49 #[must_use]
51 pub fn with_threads_override(threads: Option<usize>) -> Self {
52 Self {
53 threads_override: threads,
54 ..Self::default()
55 }
56 }
57
58 #[must_use]
60 pub fn with_min_items_override(min_items: Option<usize>) -> Self {
61 Self {
62 min_items_override: min_items,
63 ..Self::default()
64 }
65 }
66
67 #[must_use]
69 pub fn min_items_override(mut self, min_items: Option<usize>) -> Self {
70 self.min_items_override = min_items;
71 self
72 }
73
74 #[must_use]
76 pub fn min_total_bytes_override(mut self, min_bytes: Option<usize>) -> Self {
77 self.min_total_bytes_override = min_bytes;
78 self
79 }
80
81 #[must_use]
88 pub fn resolve(&self, op: ParallelOp, config: &ConfigSet) -> ParallelOptions {
89 self.try_resolve(op, config).unwrap_or_else(|e| match op {
90 ParallelOp::IndexPack | ParallelOp::PackWrite | ParallelOp::PackDeltaSearch => {
91 panic!("invalid pack.threads config: {e}")
92 }
93 _ => unreachable!("only pack operations return config errors"),
94 })
95 }
96
97 pub fn try_resolve(
104 &self,
105 op: ParallelOp,
106 config: &ConfigSet,
107 ) -> Result<ParallelOptions, ConfigError> {
108 let preload = core_preload_index(config);
109 let threads = match op {
110 ParallelOp::IndexRefresh | ParallelOp::IndexLoad => {
111 if self.threads_override.is_some() || preload {
112 resolve_index_threads(config, self.threads_override)
113 } else {
114 NonZeroUsize::MIN
115 }
116 }
117 ParallelOp::DiffRename | ParallelOp::DiffContent => {
118 if self.threads_override.is_some() || preload {
119 resolve_index_threads(config, self.threads_override)
120 } else {
121 NonZeroUsize::MIN
122 }
123 }
124 ParallelOp::IndexPack | ParallelOp::PackWrite | ParallelOp::PackDeltaSearch => {
125 resolve_pack_threads(config, self.threads_override)?
126 }
127 ParallelOp::Checkout => resolve_checkout_workers(config, self.threads_override),
128 };
129
130 let (default_min_items, default_min_bytes) = match op {
131 ParallelOp::IndexRefresh => (PAR_HASH_MIN_ITEMS, Some(PAR_HASH_MIN_TOTAL_BYTES)),
132 ParallelOp::IndexLoad => (crate::index_name_hash_lazy::LAZY_THREAD_COST * 2, None),
133 ParallelOp::Checkout => (0, None),
134 ParallelOp::IndexPack | ParallelOp::PackWrite | ParallelOp::PackDeltaSearch => {
135 (0, None)
136 }
137 ParallelOp::DiffRename | ParallelOp::DiffContent => {
138 (PAR_HASH_MIN_ITEMS, Some(PAR_HASH_MIN_TOTAL_BYTES))
139 }
140 };
141
142 let min_items = self.min_items_override.unwrap_or(default_min_items);
143 let signed_entry_threshold = match op {
144 ParallelOp::Checkout => Some(checkout_parallelism_threshold(config)),
145 _ => None,
146 };
147
148 let min_total_bytes = match self.min_total_bytes_override {
149 Some(0) => None,
150 Some(n) => Some(n),
151 None => default_min_bytes,
152 };
153
154 Ok(ParallelOptions {
155 threads,
156 min_items,
157 min_total_bytes,
158 signed_entry_threshold,
159 })
160 }
161
162 #[must_use]
164 pub fn index_stat_preload_threads(
165 &self,
166 config: &ConfigSet,
167 blob_count: usize,
168 stat_parallel_threads: Option<usize>,
169 ) -> usize {
170 if !core_preload_index(config) || blob_count < PARALLEL_STAT_MIN_ENTRIES {
171 return 1;
172 }
173 self.index_stat_scan_threads(config, blob_count, stat_parallel_threads)
174 }
175
176 #[must_use]
179 pub(crate) fn index_stat_scan_threads(
180 &self,
181 config: &ConfigSet,
182 blob_count: usize,
183 stat_parallel_threads: Option<usize>,
184 ) -> usize {
185 const THREAD_COST: usize = 500;
186 const MAX_PARALLEL: usize = 20;
187
188 if blob_count < PARALLEL_STAT_MIN_ENTRIES {
189 return 1;
190 }
191
192 let mut threads = blob_count / THREAD_COST;
193 if threads < 2 {
194 threads = 2;
195 }
196 threads = threads.min(MAX_PARALLEL);
197
198 let index_cap = resolve_index_threads(config, self.threads_override).get();
199 threads = threads.min(index_cap);
200
201 if let Some(requested) = stat_parallel_threads {
202 if requested <= 1 {
203 return 1;
204 }
205 threads = threads.min(requested);
206 }
207
208 threads.max(1)
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub struct ParallelOptions {
215 pub threads: NonZeroUsize,
217 pub min_items: usize,
219 pub min_total_bytes: Option<usize>,
221 pub signed_entry_threshold: Option<i64>,
223}
224
225impl ParallelOptions {
226 #[must_use]
228 pub fn parallelism(self) -> Parallelism {
229 Parallelism::new(self.threads)
230 }
231}
232
233#[must_use]
234fn core_preload_index(config: &ConfigSet) -> bool {
235 config
236 .get_bool("core.preloadindex")
237 .or_else(|| config.get_bool("core.preloadIndex"))
238 .and_then(|r| r.ok())
239 .unwrap_or(true)
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244enum IndexThreadsSetting {
245 UnsetAuto,
247 Auto,
248 Serial,
249 Count(usize),
250}
251
252#[must_use]
253fn index_threads_setting(config: &ConfigSet) -> IndexThreadsSetting {
254 if let Some(Ok(n)) = config.get_i64("index.threads") {
255 return match n {
256 0 => IndexThreadsSetting::Auto,
257 1 => IndexThreadsSetting::Serial,
258 n if n > 1 => IndexThreadsSetting::Count(usize::try_from(n).unwrap_or(1)),
259 _ => IndexThreadsSetting::Serial,
260 };
261 }
262 if config.get("index.threads").is_some_and(|v| v.trim() == "0") {
263 return IndexThreadsSetting::Auto;
264 }
265 if let Some(result) = config.get_bool("index.threads") {
266 return match result {
267 Ok(true) => IndexThreadsSetting::Auto,
268 Ok(false) => IndexThreadsSetting::Serial,
269 Err(_) => IndexThreadsSetting::UnsetAuto,
270 };
271 }
272 IndexThreadsSetting::UnsetAuto
273}
274
275#[must_use]
276fn resolve_index_threads(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
277 if let Some(n) = override_threads {
278 return Parallelism::resolve(Some(n)).threads();
279 }
280 match index_threads_setting(config) {
281 IndexThreadsSetting::UnsetAuto | IndexThreadsSetting::Auto => {
282 Parallelism::resolve(None).threads()
283 }
284 IndexThreadsSetting::Serial => NonZeroUsize::MIN,
285 IndexThreadsSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
286 }
287}
288
289fn parse_pack_threads(config: &ConfigSet) -> Result<Option<usize>, ConfigError> {
291 const KEY: &str = "pack.threads";
292 let Some(raw) = config.get(KEY) else {
293 return Ok(None);
294 };
295 let trimmed = raw.trim();
296 match config.get_i64(KEY) {
297 Some(Ok(n)) if n < 0 => Err(ConfigError::Other(format!(
298 "invalid number of threads specified ({n})"
299 ))),
300 Some(Ok(0)) => Ok(None),
301 Some(Ok(n)) => Ok(Some(usize::try_from(n).unwrap_or(1))),
302 Some(Err(_)) | None => Err(ConfigError::BadNumericValue {
303 key: KEY.into(),
304 value: trimmed.into(),
305 reason: BadNumericSource::InvalidUnit,
306 }),
307 }
308}
309
310fn resolve_pack_threads(
311 config: &ConfigSet,
312 override_threads: Option<usize>,
313) -> Result<NonZeroUsize, ConfigError> {
314 if let Some(n) = override_threads {
315 return Ok(Parallelism::resolve(Some(n)).threads());
316 }
317 let requested = parse_pack_threads(config)?;
318 Ok(Parallelism::resolve(requested).threads())
319}
320
321enum CheckoutWorkersSetting {
323 Unset,
325 Auto,
327 Count(usize),
329}
330
331#[must_use]
332fn checkout_workers_setting(config: &ConfigSet) -> CheckoutWorkersSetting {
333 match config.get_i64("checkout.workers") {
334 None | Some(Err(_)) => CheckoutWorkersSetting::Unset,
335 Some(Ok(n)) if n < 1 => CheckoutWorkersSetting::Auto,
336 Some(Ok(n)) => CheckoutWorkersSetting::Count(n as usize),
337 }
338}
339
340#[must_use]
341fn resolve_checkout_workers(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
342 let setting = checkout_workers_setting(config);
343
344 if let Some(n) = override_threads {
345 #[cfg(windows)]
346 if matches!(setting, CheckoutWorkersSetting::Unset) {
347 let _ = n;
350 return NonZeroUsize::MIN;
351 }
352 return Parallelism::resolve(Some(n)).threads();
353 }
354
355 match setting {
356 CheckoutWorkersSetting::Unset => {
357 #[cfg(windows)]
358 {
359 return NonZeroUsize::MIN;
360 }
361 #[cfg(not(windows))]
362 {
363 Parallelism::resolve(None).threads()
364 }
365 }
366 CheckoutWorkersSetting::Auto => Parallelism::resolve(None).threads(),
367 CheckoutWorkersSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
368 }
369}
370
371#[must_use]
372fn checkout_parallelism_threshold(config: &ConfigSet) -> i64 {
373 match config.get_i64("checkout.thresholdForParallelism") {
374 None => 100,
375 Some(Ok(n)) => n,
376 Some(Err(_)) => 100,
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use crate::config::{ConfigScope, ConfigSet};
384 use crate::environment::Environment;
385
386 fn config_from(text: &str) -> ConfigSet {
387 use crate::config::ConfigFile;
388 use std::path::Path;
389 let file = ConfigFile::parse(Path::new("test"), text, ConfigScope::Local).expect("parse");
390 let mut set = ConfigSet::new();
391 set.merge(&file);
392 set
393 }
394
395 #[test]
396 fn index_threads_bool_and_int_semantics() {
397 let true_auto = config_from("[index]\n\tthreads = true\n");
398 assert_eq!(index_threads_setting(&true_auto), IndexThreadsSetting::Auto);
399 let false_serial = config_from("[index]\n\tthreads = false\n");
400 assert_eq!(
401 index_threads_setting(&false_serial),
402 IndexThreadsSetting::Serial
403 );
404 let zero = config_from("[index]\n\tthreads = 0\n");
405 assert_eq!(index_threads_setting(&zero), IndexThreadsSetting::Auto);
406 let one = config_from("[index]\n\tthreads = 1\n");
407 assert_eq!(index_threads_setting(&one), IndexThreadsSetting::Serial);
408 let four = config_from("[index]\n\tthreads = 4\n");
409 assert_eq!(index_threads_setting(&four), IndexThreadsSetting::Count(4));
410 let neg = config_from("[index]\n\tthreads = -3\n");
411 assert_eq!(index_threads_setting(&neg), IndexThreadsSetting::Serial);
412 }
413
414 #[test]
415 fn index_threads_negative_is_serial_for_refresh() {
416 let cfg = config_from("[index]\n\tthreads = -3\n");
417 let opts = ThreadPolicy::default().resolve(ParallelOp::IndexRefresh, &cfg);
418 assert_eq!(opts.threads, NonZeroUsize::MIN);
419 }
420
421 #[test]
422 fn pack_threads_negative_is_config_error() {
423 let cfg = config_from("[pack]\n\tthreads = -3\n");
424 let err = ThreadPolicy::default()
425 .try_resolve(ParallelOp::IndexPack, &cfg)
426 .expect_err("negative pack.threads");
427 assert_eq!(
428 err,
429 ConfigError::Other("invalid number of threads specified (-3)".into())
430 );
431 }
432
433 #[test]
434 fn pack_threads_bool_is_bad_numeric() {
435 for (raw, label) in [("true", "true"), ("false", "false")] {
436 let cfg = config_from(&format!("[pack]\n\tthreads = {raw}\n"));
437 let err = ThreadPolicy::default()
438 .try_resolve(ParallelOp::IndexPack, &cfg)
439 .expect_err(&format!("pack.threads = {label}"));
440 assert!(
441 matches!(
442 &err,
443 ConfigError::BadNumericValue {
444 key,
445 value,
446 reason
447 } if key == "pack.threads" && value == label && *reason == BadNumericSource::InvalidUnit
448 ),
449 "unexpected error for {label}: {err:?}"
450 );
451 }
452 }
453
454 #[test]
455 fn preload_index_disables_index_refresh_workers() {
456 let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 8\n");
457 let policy = ThreadPolicy::default();
458 let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
459 assert_eq!(opts.threads, NonZeroUsize::MIN);
460 }
461
462 #[test]
463 fn thread_override_enables_index_refresh_when_preload_off() {
464 let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 2\n");
465 let policy = ThreadPolicy::with_threads_override(Some(6));
466 let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
467 assert_eq!(
468 opts.threads.get(),
469 6.min(
470 std::thread::available_parallelism()
471 .map(|n| n.get())
472 .unwrap_or(1)
473 )
474 );
475 }
476
477 #[test]
478 fn thread_override_beats_config() {
479 let cfg = config_from("[index]\n\tthreads = 2\n");
480 let policy = ThreadPolicy::with_threads_override(Some(6));
481 let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
482 assert_eq!(
483 opts.threads.get(),
484 6.min(
485 std::thread::available_parallelism()
486 .map(|n| n.get())
487 .unwrap_or(1)
488 )
489 );
490 }
491
492 #[test]
493 fn min_items_override_for_tests() {
494 let cfg = ConfigSet::new();
495 let policy = ThreadPolicy::with_min_items_override(Some(0));
496 let opts = policy.resolve(ParallelOp::IndexPack, &cfg);
497 assert_eq!(opts.min_items, 0);
498 }
499
500 #[test]
501 fn pack_threads_zero_is_auto() {
502 let cfg = config_from("[pack]\n\tthreads = 0\n");
503 let opts = ThreadPolicy::default().resolve(ParallelOp::IndexPack, &cfg);
504 assert!(opts.threads.get() > 1 || std::thread::available_parallelism().is_ok());
505 }
506
507 #[test]
508 #[cfg(not(windows))]
509 fn checkout_workers_unset_defaults_to_auto() {
510 let cfg = ConfigSet::new();
511 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
512 assert_eq!(
513 opts.threads.get(),
514 std::thread::available_parallelism()
515 .map(NonZeroUsize::get)
516 .unwrap_or(1)
517 );
518 assert_eq!(opts.signed_entry_threshold, Some(100));
519 }
520
521 #[test]
522 #[cfg(windows)]
523 fn checkout_workers_unset_defaults_to_serial() {
524 let cfg = ConfigSet::new();
525 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
526 assert_eq!(opts.threads, NonZeroUsize::MIN);
527 assert_eq!(opts.signed_entry_threshold, Some(100));
528 }
529
530 #[test]
531 #[cfg(windows)]
532 fn checkout_global_threads_override_ignored_without_checkout_workers() {
533 let cfg = ConfigSet::new();
534 let policy = ThreadPolicy::with_threads_override(Some(8));
535 let opts = policy.resolve(ParallelOp::Checkout, &cfg);
536 assert_eq!(opts.threads, NonZeroUsize::MIN);
537 }
538
539 #[test]
540 #[cfg(windows)]
541 fn checkout_explicit_auto_enables_parallel_workers() {
542 let cfg = config_from("[checkout]\n\tworkers = 0\n");
543 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
544 assert_eq!(
545 opts.threads.get(),
546 std::thread::available_parallelism()
547 .map(NonZeroUsize::get)
548 .unwrap_or(1)
549 );
550 }
551
552 #[test]
553 #[cfg(windows)]
554 fn checkout_workers_two_respects_cpu_cap_on_windows() {
555 let cfg = config_from("[checkout]\n\tworkers = 2\n");
556 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
557 assert_eq!(
558 opts.threads.get(),
559 2.min(
560 std::thread::available_parallelism()
561 .map(|n| n.get())
562 .unwrap_or(1)
563 )
564 );
565 }
566
567 #[test]
568 #[cfg(windows)]
569 fn checkout_thread_override_beats_workers_config_on_windows() {
570 let cfg = config_from("[checkout]\n\tworkers = 2\n");
571 let policy = ThreadPolicy::with_threads_override(Some(4));
572 let opts = policy.resolve(ParallelOp::Checkout, &cfg);
573 assert_eq!(
574 opts.threads.get(),
575 4.min(
576 std::thread::available_parallelism()
577 .map(|n| n.get())
578 .unwrap_or(1)
579 )
580 );
581 }
582
583 #[test]
584 fn checkout_workers_zero_is_auto() {
585 let cfg = config_from("[checkout]\n\tworkers = 0\n");
586 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
587 assert_eq!(
588 opts.threads.get(),
589 std::thread::available_parallelism()
590 .map(|n| n.get())
591 .unwrap_or(1)
592 );
593 }
594
595 #[test]
596 fn checkout_threshold_negative_is_preserved() {
597 let cfg = config_from("[checkout]\n\tthresholdForParallelism = -5\n");
598 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
599 assert_eq!(opts.signed_entry_threshold, Some(-5));
600 }
601
602 #[test]
603 fn checkout_threshold_negative_keeps_small_batches_serial() {
604 use crate::parallel::map::pool_worthwhile;
605 let cfg =
606 config_from("[checkout]\n\tthresholdForParallelism = -5\n[checkout]\n\tworkers = 4\n");
607 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
608 assert!(!pool_worthwhile(0, 0, opts));
609 assert_eq!(pool_worthwhile(100, 0, opts), opts.threads.get() > 1);
612 }
613
614 #[test]
615 fn single_item_never_uses_the_pool() {
616 use crate::parallel::map::pool_worthwhile;
617 let opts = ParallelOptions {
618 threads: NonZeroUsize::new(8).unwrap_or(NonZeroUsize::MIN),
619 min_items: 0,
620 min_total_bytes: None,
621 signed_entry_threshold: None,
622 };
623 assert!(!pool_worthwhile(1, usize::MAX, opts));
624 assert!(pool_worthwhile(2, 0, opts));
625 }
626
627 #[test]
628 fn checkout_thread_override_beats_workers_config() {
629 let cfg = config_from("[checkout]\n\tworkers = 2\n");
630 let policy = ThreadPolicy::with_threads_override(Some(6));
631 let opts = policy.resolve(ParallelOp::Checkout, &cfg);
632 assert_eq!(
633 opts.threads.get(),
634 6.min(
635 std::thread::available_parallelism()
636 .map(|n| n.get())
637 .unwrap_or(1)
638 )
639 );
640 }
641
642 #[test]
643 fn stat_preload_respects_preload_math_and_override() {
644 let cfg = ConfigSet::new();
645 let policy = ThreadPolicy::default();
646 let index_cap = resolve_index_threads(&cfg, None).get();
647 assert_eq!(
648 policy.index_stat_preload_threads(&cfg, 5000, None),
649 10_usize.min(20).min(index_cap)
650 );
651 let policy = ThreadPolicy::with_threads_override(Some(8));
652 let override_cap = resolve_index_threads(&cfg, Some(8)).get();
653 assert_eq!(
654 policy.index_stat_preload_threads(&cfg, 5000, None),
655 10_usize.min(20).min(override_cap)
656 );
657 assert_eq!(
658 policy.index_stat_preload_threads(&cfg, 5000, Some(4)),
659 4_usize.min(override_cap)
660 );
661 }
662
663 #[test]
664 #[cfg_attr(miri, ignore = "creates a temp dir; Miri isolation blocks mkdir")]
665 fn pack_index_parallelism_for_git_dir_matches_config() {
666 let dir = tempfile::tempdir().expect("tempdir");
667 std::fs::write(dir.path().join("config"), "[pack]\n\tthreads = 3\n").expect("write config");
668 let env = Environment::empty();
669 let par = ConfigSet::pack_index_parallelism_for_git_dir(&env, Some(dir.path()));
670 assert_eq!(par.threads().get(), 3);
671 }
672}