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 const THREAD_COST: usize = 500;
171 const MAX_PARALLEL: usize = 20;
172
173 if !core_preload_index(config) || blob_count < PARALLEL_STAT_MIN_ENTRIES {
174 return 1;
175 }
176
177 let mut threads = blob_count / THREAD_COST;
178 if threads < 2 {
179 threads = 2;
180 }
181 threads = threads.min(MAX_PARALLEL);
182
183 let index_cap = resolve_index_threads(config, self.threads_override).get();
184 threads = threads.min(index_cap);
185
186 if let Some(requested) = stat_parallel_threads {
187 if requested <= 1 {
188 return 1;
189 }
190 threads = threads.min(requested);
191 }
192
193 threads.max(1)
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct ParallelOptions {
200 pub threads: NonZeroUsize,
202 pub min_items: usize,
204 pub min_total_bytes: Option<usize>,
206 pub signed_entry_threshold: Option<i64>,
208}
209
210impl ParallelOptions {
211 #[must_use]
213 pub fn parallelism(self) -> Parallelism {
214 Parallelism::new(self.threads)
215 }
216}
217
218#[must_use]
219fn core_preload_index(config: &ConfigSet) -> bool {
220 config
221 .get_bool("core.preloadindex")
222 .or_else(|| config.get_bool("core.preloadIndex"))
223 .and_then(|r| r.ok())
224 .unwrap_or(true)
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229enum IndexThreadsSetting {
230 UnsetAuto,
232 Auto,
233 Serial,
234 Count(usize),
235}
236
237#[must_use]
238fn index_threads_setting(config: &ConfigSet) -> IndexThreadsSetting {
239 if let Some(Ok(n)) = config.get_i64("index.threads") {
240 return match n {
241 0 => IndexThreadsSetting::Auto,
242 1 => IndexThreadsSetting::Serial,
243 n if n > 1 => IndexThreadsSetting::Count(usize::try_from(n).unwrap_or(1)),
244 _ => IndexThreadsSetting::Serial,
245 };
246 }
247 if config.get("index.threads").is_some_and(|v| v.trim() == "0") {
248 return IndexThreadsSetting::Auto;
249 }
250 if let Some(result) = config.get_bool("index.threads") {
251 return match result {
252 Ok(true) => IndexThreadsSetting::Auto,
253 Ok(false) => IndexThreadsSetting::Serial,
254 Err(_) => IndexThreadsSetting::UnsetAuto,
255 };
256 }
257 IndexThreadsSetting::UnsetAuto
258}
259
260#[must_use]
261fn resolve_index_threads(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
262 if let Some(n) = override_threads {
263 return Parallelism::resolve(Some(n)).threads();
264 }
265 match index_threads_setting(config) {
266 IndexThreadsSetting::UnsetAuto | IndexThreadsSetting::Auto => {
267 Parallelism::resolve(None).threads()
268 }
269 IndexThreadsSetting::Serial => NonZeroUsize::MIN,
270 IndexThreadsSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
271 }
272}
273
274fn parse_pack_threads(config: &ConfigSet) -> Result<Option<usize>, ConfigError> {
276 const KEY: &str = "pack.threads";
277 let Some(raw) = config.get(KEY) else {
278 return Ok(None);
279 };
280 let trimmed = raw.trim();
281 match config.get_i64(KEY) {
282 Some(Ok(n)) if n < 0 => Err(ConfigError::Other(format!(
283 "invalid number of threads specified ({n})"
284 ))),
285 Some(Ok(0)) => Ok(None),
286 Some(Ok(n)) => Ok(Some(usize::try_from(n).unwrap_or(1))),
287 Some(Err(_)) | None => Err(ConfigError::BadNumericValue {
288 key: KEY.into(),
289 value: trimmed.into(),
290 reason: BadNumericSource::InvalidUnit,
291 }),
292 }
293}
294
295fn resolve_pack_threads(
296 config: &ConfigSet,
297 override_threads: Option<usize>,
298) -> Result<NonZeroUsize, ConfigError> {
299 if let Some(n) = override_threads {
300 return Ok(Parallelism::resolve(Some(n)).threads());
301 }
302 let requested = parse_pack_threads(config)?;
303 Ok(Parallelism::resolve(requested).threads())
304}
305
306enum CheckoutWorkersSetting {
308 Unset,
310 Auto,
312 Count(usize),
314}
315
316#[must_use]
317fn checkout_workers_setting(config: &ConfigSet) -> CheckoutWorkersSetting {
318 match config.get_i64("checkout.workers") {
319 None | Some(Err(_)) => CheckoutWorkersSetting::Unset,
320 Some(Ok(n)) if n < 1 => CheckoutWorkersSetting::Auto,
321 Some(Ok(n)) => CheckoutWorkersSetting::Count(n as usize),
322 }
323}
324
325#[must_use]
326fn resolve_checkout_workers(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
327 let setting = checkout_workers_setting(config);
328
329 if let Some(n) = override_threads {
330 #[cfg(windows)]
331 if matches!(setting, CheckoutWorkersSetting::Unset) {
332 let _ = n;
335 return NonZeroUsize::MIN;
336 }
337 return Parallelism::resolve(Some(n)).threads();
338 }
339
340 match setting {
341 CheckoutWorkersSetting::Unset => {
342 #[cfg(windows)]
343 {
344 return NonZeroUsize::MIN;
345 }
346 #[cfg(not(windows))]
347 {
348 Parallelism::resolve(None).threads()
349 }
350 }
351 CheckoutWorkersSetting::Auto => Parallelism::resolve(None).threads(),
352 CheckoutWorkersSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
353 }
354}
355
356#[must_use]
357fn checkout_parallelism_threshold(config: &ConfigSet) -> i64 {
358 match config.get_i64("checkout.thresholdForParallelism") {
359 None => 100,
360 Some(Ok(n)) => n,
361 Some(Err(_)) => 100,
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::config::{ConfigScope, ConfigSet};
369 use crate::environment::Environment;
370
371 fn config_from(text: &str) -> ConfigSet {
372 use crate::config::ConfigFile;
373 use std::path::Path;
374 let file = ConfigFile::parse(Path::new("test"), text, ConfigScope::Local).expect("parse");
375 let mut set = ConfigSet::new();
376 set.merge(&file);
377 set
378 }
379
380 #[test]
381 fn index_threads_bool_and_int_semantics() {
382 let true_auto = config_from("[index]\n\tthreads = true\n");
383 assert_eq!(index_threads_setting(&true_auto), IndexThreadsSetting::Auto);
384 let false_serial = config_from("[index]\n\tthreads = false\n");
385 assert_eq!(
386 index_threads_setting(&false_serial),
387 IndexThreadsSetting::Serial
388 );
389 let zero = config_from("[index]\n\tthreads = 0\n");
390 assert_eq!(index_threads_setting(&zero), IndexThreadsSetting::Auto);
391 let one = config_from("[index]\n\tthreads = 1\n");
392 assert_eq!(index_threads_setting(&one), IndexThreadsSetting::Serial);
393 let four = config_from("[index]\n\tthreads = 4\n");
394 assert_eq!(index_threads_setting(&four), IndexThreadsSetting::Count(4));
395 let neg = config_from("[index]\n\tthreads = -3\n");
396 assert_eq!(index_threads_setting(&neg), IndexThreadsSetting::Serial);
397 }
398
399 #[test]
400 fn index_threads_negative_is_serial_for_refresh() {
401 let cfg = config_from("[index]\n\tthreads = -3\n");
402 let opts = ThreadPolicy::default().resolve(ParallelOp::IndexRefresh, &cfg);
403 assert_eq!(opts.threads, NonZeroUsize::MIN);
404 }
405
406 #[test]
407 fn pack_threads_negative_is_config_error() {
408 let cfg = config_from("[pack]\n\tthreads = -3\n");
409 let err = ThreadPolicy::default()
410 .try_resolve(ParallelOp::IndexPack, &cfg)
411 .expect_err("negative pack.threads");
412 assert_eq!(
413 err,
414 ConfigError::Other("invalid number of threads specified (-3)".into())
415 );
416 }
417
418 #[test]
419 fn pack_threads_bool_is_bad_numeric() {
420 for (raw, label) in [("true", "true"), ("false", "false")] {
421 let cfg = config_from(&format!("[pack]\n\tthreads = {raw}\n"));
422 let err = ThreadPolicy::default()
423 .try_resolve(ParallelOp::IndexPack, &cfg)
424 .expect_err(&format!("pack.threads = {label}"));
425 assert!(
426 matches!(
427 &err,
428 ConfigError::BadNumericValue {
429 key,
430 value,
431 reason
432 } if key == "pack.threads" && value == label && *reason == BadNumericSource::InvalidUnit
433 ),
434 "unexpected error for {label}: {err:?}"
435 );
436 }
437 }
438
439 #[test]
440 fn preload_index_disables_index_refresh_workers() {
441 let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 8\n");
442 let policy = ThreadPolicy::default();
443 let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
444 assert_eq!(opts.threads, NonZeroUsize::MIN);
445 }
446
447 #[test]
448 fn thread_override_enables_index_refresh_when_preload_off() {
449 let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 2\n");
450 let policy = ThreadPolicy::with_threads_override(Some(6));
451 let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
452 assert_eq!(
453 opts.threads.get(),
454 6.min(
455 std::thread::available_parallelism()
456 .map(|n| n.get())
457 .unwrap_or(1)
458 )
459 );
460 }
461
462 #[test]
463 fn thread_override_beats_config() {
464 let cfg = config_from("[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 min_items_override_for_tests() {
479 let cfg = ConfigSet::new();
480 let policy = ThreadPolicy::with_min_items_override(Some(0));
481 let opts = policy.resolve(ParallelOp::IndexPack, &cfg);
482 assert_eq!(opts.min_items, 0);
483 }
484
485 #[test]
486 fn pack_threads_zero_is_auto() {
487 let cfg = config_from("[pack]\n\tthreads = 0\n");
488 let opts = ThreadPolicy::default().resolve(ParallelOp::IndexPack, &cfg);
489 assert!(opts.threads.get() > 1 || std::thread::available_parallelism().is_ok());
490 }
491
492 #[test]
493 #[cfg(not(windows))]
494 fn checkout_workers_unset_defaults_to_auto() {
495 let cfg = ConfigSet::new();
496 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
497 assert_eq!(
498 opts.threads.get(),
499 std::thread::available_parallelism()
500 .map(NonZeroUsize::get)
501 .unwrap_or(1)
502 );
503 assert_eq!(opts.signed_entry_threshold, Some(100));
504 }
505
506 #[test]
507 #[cfg(windows)]
508 fn checkout_workers_unset_defaults_to_serial() {
509 let cfg = ConfigSet::new();
510 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
511 assert_eq!(opts.threads, NonZeroUsize::MIN);
512 assert_eq!(opts.signed_entry_threshold, Some(100));
513 }
514
515 #[test]
516 #[cfg(windows)]
517 fn checkout_global_threads_override_ignored_without_checkout_workers() {
518 let cfg = ConfigSet::new();
519 let policy = ThreadPolicy::with_threads_override(Some(8));
520 let opts = policy.resolve(ParallelOp::Checkout, &cfg);
521 assert_eq!(opts.threads, NonZeroUsize::MIN);
522 }
523
524 #[test]
525 #[cfg(windows)]
526 fn checkout_explicit_auto_enables_parallel_workers() {
527 let cfg = config_from("[checkout]\n\tworkers = 0\n");
528 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
529 assert_eq!(
530 opts.threads.get(),
531 std::thread::available_parallelism()
532 .map(NonZeroUsize::get)
533 .unwrap_or(1)
534 );
535 }
536
537 #[test]
538 #[cfg(windows)]
539 fn checkout_workers_two_respects_cpu_cap_on_windows() {
540 let cfg = config_from("[checkout]\n\tworkers = 2\n");
541 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
542 assert_eq!(
543 opts.threads.get(),
544 2.min(
545 std::thread::available_parallelism()
546 .map(|n| n.get())
547 .unwrap_or(1)
548 )
549 );
550 }
551
552 #[test]
553 #[cfg(windows)]
554 fn checkout_thread_override_beats_workers_config_on_windows() {
555 let cfg = config_from("[checkout]\n\tworkers = 2\n");
556 let policy = ThreadPolicy::with_threads_override(Some(4));
557 let opts = policy.resolve(ParallelOp::Checkout, &cfg);
558 assert_eq!(
559 opts.threads.get(),
560 4.min(
561 std::thread::available_parallelism()
562 .map(|n| n.get())
563 .unwrap_or(1)
564 )
565 );
566 }
567
568 #[test]
569 fn checkout_workers_zero_is_auto() {
570 let cfg = config_from("[checkout]\n\tworkers = 0\n");
571 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
572 assert_eq!(
573 opts.threads.get(),
574 std::thread::available_parallelism()
575 .map(|n| n.get())
576 .unwrap_or(1)
577 );
578 }
579
580 #[test]
581 fn checkout_threshold_negative_is_preserved() {
582 let cfg = config_from("[checkout]\n\tthresholdForParallelism = -5\n");
583 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
584 assert_eq!(opts.signed_entry_threshold, Some(-5));
585 }
586
587 #[test]
588 fn checkout_threshold_negative_keeps_small_batches_serial() {
589 use crate::parallel::map::pool_worthwhile;
590 let cfg =
591 config_from("[checkout]\n\tthresholdForParallelism = -5\n[checkout]\n\tworkers = 4\n");
592 let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
593 assert!(!pool_worthwhile(0, 0, opts));
594 assert_eq!(pool_worthwhile(100, 0, opts), opts.threads.get() > 1);
597 }
598
599 #[test]
600 fn single_item_never_uses_the_pool() {
601 use crate::parallel::map::pool_worthwhile;
602 let opts = ParallelOptions {
603 threads: NonZeroUsize::new(8).unwrap_or(NonZeroUsize::MIN),
604 min_items: 0,
605 min_total_bytes: None,
606 signed_entry_threshold: None,
607 };
608 assert!(!pool_worthwhile(1, usize::MAX, opts));
609 assert!(pool_worthwhile(2, 0, opts));
610 }
611
612 #[test]
613 fn checkout_thread_override_beats_workers_config() {
614 let cfg = config_from("[checkout]\n\tworkers = 2\n");
615 let policy = ThreadPolicy::with_threads_override(Some(6));
616 let opts = policy.resolve(ParallelOp::Checkout, &cfg);
617 assert_eq!(
618 opts.threads.get(),
619 6.min(
620 std::thread::available_parallelism()
621 .map(|n| n.get())
622 .unwrap_or(1)
623 )
624 );
625 }
626
627 #[test]
628 fn stat_preload_respects_preload_math_and_override() {
629 let cfg = ConfigSet::new();
630 let policy = ThreadPolicy::default();
631 let index_cap = resolve_index_threads(&cfg, None).get();
632 assert_eq!(
633 policy.index_stat_preload_threads(&cfg, 5000, None),
634 10_usize.min(20).min(index_cap)
635 );
636 let policy = ThreadPolicy::with_threads_override(Some(8));
637 let override_cap = resolve_index_threads(&cfg, Some(8)).get();
638 assert_eq!(
639 policy.index_stat_preload_threads(&cfg, 5000, None),
640 10_usize.min(20).min(override_cap)
641 );
642 assert_eq!(
643 policy.index_stat_preload_threads(&cfg, 5000, Some(4)),
644 4_usize.min(override_cap)
645 );
646 }
647
648 #[test]
649 #[cfg_attr(miri, ignore = "creates a temp dir; Miri isolation blocks mkdir")]
650 fn pack_index_parallelism_for_git_dir_matches_config() {
651 let dir = tempfile::tempdir().expect("tempdir");
652 std::fs::write(dir.path().join("config"), "[pack]\n\tthreads = 3\n").expect("write config");
653 let env = Environment::empty();
654 let par = ConfigSet::pack_index_parallelism_for_git_dir(&env, Some(dir.path()));
655 assert_eq!(par.threads().get(), 3);
656 }
657}