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