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