Skip to main content

git_policy_cli/foundation/
parallel_budget.rs

1//! What:
2//!  How many workers a set of independent items may use together: the CPUs the process is allowed minus a margin, the memory
3//!  that is free, an optional ceiling the shared storage was measured to need, and the person's own cap.
4//! Why:
5//!  Real jobs may use almost the whole host, so a bare constant is wrong in both directions: too low on a large host, too high
6//!  inside a container with a quota or on a host whose memory other sessions already hold. The budget is derived from the
7//!  facts of the moment, and the facts are a value so a control can state them.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const workers = workerBudget({ items: 48, perItemMemory: 256 * MiB, ceiling: undefined, cap: undefined, host: readHost() });
12//! ```
13
14use std::path::{Path, PathBuf};
15
16/// The smallest number of entries at which creating them on several threads beat creating them on one, for the three places that
17///  make files by cloning: 16 entries. Measured with the copy stage (`copy_manifest_entries`), the install (`install_snapshot`
18///  with a recorder that syncs every batch) and `clone_tree`, each forced to 1, 2, 4 and 8 workers on reflinked trees of this
19///  repository (2026-11-03, medians of five runs in alternating orders, other sessions running, a debug build): `clone_tree` took
20///  0.6 ms on one thread and 0.5 to 0.6 ms on 2, 4 and 8 for 10 entries, and 1.8 ms against 1.2 ms for 30; the copy stage 1.1 ms
21///  against 0.7 ms for 30, 3.6 ms against 1.9 ms for 90, 397.6 ms against 104.4 ms for 9,301; the install 1.6 ms against 1.3 ms for
22///  30, 14.8 ms against 8.9 ms for 296, 500.2 ms against 244.9 ms for 9,301. The parallel path was never slower from 16 entries up.
23///  The coordinator's pick under the owner's delegation, open to the owner's veto (`doc/planning/cli-git-shipped-limits-audit.md`,
24///  "Picked for these follow-ups", thresholds of the copy stage and the install, option A).
25pub const PARALLEL_BREAK_EVEN_ENTRIES: usize = 16;
26
27/// The share of the CPUs the process is allowed that stays free: one in this many, at least one when there are more than two.
28///  The owner's words are "make sure we're running everything that can be run in parallel in parallel" (handover
29///  `doc/handover/cli-git-rust-implementation.md`, lines 864 and 865), which leaves a margin to a person's other work. On this
30///  28-thread host the margin leaves 25 workers, and the measured ceilings are 8 for file clones and 16 for conversions, so the
31///  divisor does not bind here; it binds on hosts of 8 threads or fewer. A share cannot be measured as a quantity, so the value
32///  is the coordinator's pick under the owner's delegation, open to the owner's veto
33///  (`doc/planning/cli-git-shipped-limits-audit.md`, "Parallelism caps and memory estimates").
34const CPU_MARGIN_DIVISOR: usize = 8;
35
36/// The fraction of the memory that is free which the items may use together: one over this many. The rest stays for the
37///  operating system's caches and for the other sessions of the host. With 49 GB available on this host and the estimates below
38///  (128 MiB at most per item) it admits more than 180 items at once, so it binds only on hosts of a few gigabytes. A share
39///  cannot be measured as a quantity: the coordinator's pick under the owner's delegation, open to the owner's veto.
40const MEMORY_SHARE_DIVISOR: u64 = 2;
41
42/// Where the kernel lists the memory it can still give out.
43const MEMORY_INFORMATION: &str = "/proc/meminfo";
44
45/// Where the kernel names the control group of the process.
46const CONTROL_GROUP_NAME: &str = "/proc/self/cgroup";
47
48/// The root of the unified control-group hierarchy.
49const CONTROL_GROUP_ROOT: &str = "/sys/fs/cgroup";
50
51/// What:
52///  The facts a budget is derived from.
53/// Why:
54///   Reading the host is one place and the arithmetic another, so a control states a host of any size.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// type HostResources = { parallelism: number; memoryAvailable?: number };
59/// ```
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub struct HostResources {
62    /// The threads the process may run at once (a container's CPU quota included).
63    pub parallelism: usize,
64    /// The bytes that can still be given out without pushing others out, or nothing when the host does not say.
65    pub memory_available: Option<u64>,
66}
67
68/// What:
69///  What one budget asks for.
70/// Why:
71///   One value keeps the call sites short and names every input.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// type BudgetRequest = { items: number; perItemMemory: number; ceiling?: number; cap?: number; host: HostResources };
76/// ```
77#[derive(Clone, Copy, Debug)]
78pub struct BudgetRequest<'host> {
79    /// How many independent items there are; more workers than items would idle.
80    pub items: usize,
81    /// The bytes one item holds at its peak, a measured figure of the caller.
82    pub per_item_memory: u64,
83    /// The most workers the shared storage was measured to use usefully, or nothing when it was not measured to flatten.
84    pub ceiling: Option<usize>,
85    /// The most workers the person allows (`--jobs`), or nothing.
86    pub cap: Option<usize>,
87    /// The facts of this host now.
88    pub host: &'host HostResources,
89}
90
91impl HostResources {
92    /// What:
93    ///  The resources of this process now: the threads it may run, and the least of the system's available memory and the
94    ///  room left in its control group and the groups above it.
95    /// Why:
96    ///   The memory figure that matters is the one the kernel would enforce, which a quota can make smaller than the host's.
97    ///
98    /// In TS you'd write (pseudocode):
99    /// ```ts
100    /// const host = HostResources.read();
101    /// ```
102    pub fn read() -> HostResources {
103        let parallelism: usize = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
104        let system: Option<u64> = std::fs::read_to_string(MEMORY_INFORMATION).ok().and_then(|text: String| { return memory_available_from(text.as_str()); });
105        let group: Option<u64> = std::fs::read_to_string(CONTROL_GROUP_NAME)
106            .ok()
107            .and_then(|text: String| { return control_group_path(text.as_str()); })
108            .and_then(|path: String| { return group_headroom(Path::new(CONTROL_GROUP_ROOT), path.as_str()); });
109        let memory_available: Option<u64> = match (system, group) {
110            (Some(left), Some(right)) => Some(left.min(right)),
111            (left, right) => left.or(right),
112        };
113        return HostResources { parallelism, memory_available };
114    }
115}
116
117/// The `MemAvailable` figure of `/proc/meminfo` text, in bytes.
118pub fn memory_available_from(text: &str) -> Option<u64> {
119    let line: &str = text.lines().find(|candidate: &&str| { return candidate.starts_with("MemAvailable:"); })?;
120    let kibibytes: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
121    return kibibytes.checked_mul(1024);
122}
123
124/// The path of the unified (version 2) control group in `/proc/self/cgroup` text: the line that starts with `0::`.
125pub fn control_group_path(text: &str) -> Option<String> {
126    return text.lines().find_map(|line: &str| { return line.strip_prefix("0::").map(String::from); });
127}
128
129/// The bytes a control group can still take under its limit: `max` minus `current`, nothing when there is no numeric limit.
130pub fn headroom_from(max: &str, current: &str) -> Option<u64> {
131    let limit: u64 = max.trim().parse().ok()?;
132    let used: u64 = current.trim().parse().ok()?;
133    return Some(limit.saturating_sub(used));
134}
135
136/// The least headroom of the group at `path` below `root` and of every group above it, nothing when none has a limit.
137fn group_headroom(root: &Path, path: &str) -> Option<u64> {
138    let mut least: Option<u64> = None;
139    let mut directory: PathBuf = root.join(path.trim_start_matches('/'));
140    loop {
141        let limit: Option<String> = std::fs::read_to_string(directory.join("memory.max")).ok();
142        let current: Option<String> = std::fs::read_to_string(directory.join("memory.current")).ok();
143        if let (Some(max_text), Some(current_text)) = (limit, current)
144            && let Some(room) = headroom_from(max_text.as_str(), current_text.as_str())
145        {
146            least = Some(least.map_or(room, |known: u64| { return known.min(room); }));
147        }
148        if directory == root || !directory.pop() || !directory.starts_with(root) {
149            break;
150        }
151    }
152    return least;
153}
154
155/// The threads left free for everything else: an eighth of them, at least one, and none when the process may run only two.
156pub fn cpu_margin(parallelism: usize) -> usize {
157    if parallelism <= 2 {
158        return 0;
159    }
160    return (parallelism / CPU_MARGIN_DIVISOR).max(1);
161}
162
163/// What:
164///  The number of workers for independent items.
165/// Why:
166///   The least of the items, the CPUs minus the margin, the memory that is free over what one item holds, the ceiling the
167///  storage was measured to use, and the person's cap; never fewer than one, so a starved host still makes progress.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// const workers = Math.max(1, Math.min(items, cpus - margin, memoryShare / perItem, ceiling, cap));
172/// ```
173pub fn worker_budget(request: &BudgetRequest<'_>) -> usize {
174    let by_cpu: usize = request.host.parallelism.saturating_sub(cpu_margin(request.host.parallelism)).max(1);
175    let by_memory: usize = match request.host.memory_available {
176        Some(bytes) => usize::try_from((bytes / MEMORY_SHARE_DIVISOR) / request.per_item_memory.max(1)).unwrap_or(usize::MAX),
177        None => usize::MAX,
178    };
179    return request
180        .items
181        .min(by_cpu)
182        .min(by_memory)
183        .min(request.ceiling.unwrap_or(usize::MAX))
184        .min(request.cap.unwrap_or(usize::MAX))
185        .max(1);
186}
187
188/// The bytes one slice of a file-copy or file-removal job holds at its peak: a batch of entries and the handles it opens, estimated
189///  at sixteen mebibytes.
190///  The estimate was checked against `clone_tree`: its resident size grew by 1.9 MiB over a pre-clone level of 21.6 MiB (peak
191///  24,080 KiB) for 22,718 entries at 16 workers, so a slice holds far less than sixteen mebibytes. The estimate is kept as a
192///  conservative upper bound because the copy stage itself was not measured; the coordinator's pick under the owner's delegation, open to the owner's veto.
193pub const SLICE_MEMORY: u64 = 16 << 20;
194
195/// What:
196///  The workers for file-tree jobs that split their entries into slices: the host's budget up to a ceiling the filesystem was
197///  measured or chosen to use.
198/// Why:
199///   The copy, install and removal of a worktree's files share the filesystem's metadata locks, so a ceiling stays, but the CPUs
200///  left free for other sessions and the memory of the control group bound them too, which a bare `min(available, 8)` ignored.
201///
202/// In TS you'd write (pseudocode):
203/// ```ts
204/// const workers = slicedWorkers({ items, ceiling: 8, host });
205/// ```
206pub fn sliced_workers(items: usize, ceiling: usize, host: &HostResources) -> usize {
207    return worker_budget(&BudgetRequest { items, per_item_memory: SLICE_MEMORY, ceiling: Some(ceiling), cap: None, host });
208}
209
210/// Budget controls stay out of the release executable.
211#[cfg(test)]
212#[path = "parallel_budget_tests.rs"]
213mod tests;