concinnity_engine/app/budget.rs
1// src/app/budget.rs
2//
3// Process-level resource budgets computed once at App start from the host
4// machine and the world's `AppConfig` overrides, then published as world
5// resources so systems (and the debug server) can read them. Two budgets:
6//
7// ThreadBudget how many worker threads the shared job pool runs.
8// MemoryBudget a soft ceiling on host memory the runtime aims to stay under.
9//
10// The budgets are advisory today: they are computed, logged, and reported.
11// Cooperative enforcement (streaming byte budgets, back-off near the ceiling)
12// is a separate follow-up; nothing here aborts or caps an allocation.
13
14// Absolute default cap on the memory budget regardless of how much RAM the
15// machine has, so a workstation with hundreds of GiB does not implicitly invite
16// the runtime to grow without bound. An `AppConfig` override or a smaller
17// machine lowers it; nothing but an override raises it.
18const HARD_CEILING_BYTES: u64 = 16 * 1024 * 1024 * 1024;
19// Default budget as a percentage of total RAM (whichever is smaller than the
20// hard ceiling wins).
21const DEFAULT_FRACTION_PCT: u64 = 70;
22// An `AppConfig` override may not exceed this percentage of total RAM: a game
23// cannot ask for more memory than the machine can safely give.
24const MAX_FRACTION_PCT: u64 = 85;
25
26/// How many threads the runtime plans to run, computed from the machine's core
27/// count and the optional `AppConfig` override. Advisory: it sizes the shared
28/// job pool (`jobs::configure`) and is reported, but does not cap the streaming
29/// workers or the audio thread.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct ThreadBudget {
32 /// Logical cores the machine reports.
33 pub total_cores: usize,
34 /// Worker threads for the shared rayon job pool.
35 pub job_threads: usize,
36}
37
38impl ThreadBudget {
39 // `job_threads_override` of 0 means "auto": one worker per core, less one
40 // for the main thread (the historical `available_parallelism() - 1`). A
41 // non-zero override is honored but never exceeds the core count.
42 pub(crate) fn compute(job_threads_override: u32) -> Self {
43 let total_cores = std::thread::available_parallelism()
44 .map(|n| n.get())
45 .unwrap_or(1);
46 let job_threads = if job_threads_override > 0 {
47 (job_threads_override as usize).min(total_cores)
48 } else {
49 total_cores.saturating_sub(1).max(1)
50 };
51 Self {
52 total_cores,
53 job_threads,
54 }
55 }
56}
57
58/// A soft ceiling on host memory the runtime aims to stay under, computed from
59/// total RAM and the optional `AppConfig` override.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct MemoryBudget {
62 /// Total physical RAM, or `None` when the platform query failed (the budget
63 /// then falls back to the hard ceiling, or a bare override).
64 pub total_ram_bytes: Option<u64>,
65 /// The effective budget in bytes.
66 pub budget_bytes: u64,
67 /// Whether an `AppConfig` override set the budget (vs. the computed default).
68 pub overridden: bool,
69}
70
71impl MemoryBudget {
72 // `max_memory_mb_override` of 0 means "auto": `min(hard ceiling, 70% of
73 // RAM)`. A non-zero override is honored but clamped to 85% of RAM so a game
74 // cannot budget past what the machine can safely give. When total RAM is
75 // unknown, the default is the hard ceiling and an override passes through.
76 pub(crate) fn compute(total_ram_bytes: Option<u64>, max_memory_mb_override: u32) -> Self {
77 let override_bytes =
78 (max_memory_mb_override > 0).then(|| (max_memory_mb_override as u64) * 1024 * 1024);
79 let budget_bytes = match (total_ram_bytes, override_bytes) {
80 (Some(ram), Some(want)) => want.min(ram * MAX_FRACTION_PCT / 100),
81 (Some(ram), None) => HARD_CEILING_BYTES.min(ram * DEFAULT_FRACTION_PCT / 100),
82 (None, Some(want)) => want,
83 (None, None) => HARD_CEILING_BYTES,
84 };
85 Self {
86 total_ram_bytes,
87 budget_bytes,
88 overridden: override_bytes.is_some(),
89 }
90 }
91
92 /// The budget in whole mebibytes, for logging and reporting.
93 pub fn budget_mib(&self) -> u64 {
94 self.budget_bytes / (1024 * 1024)
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 const GIB: u64 = 1024 * 1024 * 1024;
103
104 #[test]
105 fn auto_thread_budget_leaves_a_core_for_the_main_thread() {
106 let tb = ThreadBudget::compute(0);
107 assert_eq!(tb.job_threads, tb.total_cores.saturating_sub(1).max(1));
108 assert!(tb.job_threads >= 1);
109 }
110
111 #[test]
112 fn thread_override_is_honored_but_capped_at_core_count() {
113 let tb = ThreadBudget::compute(2);
114 assert_eq!(tb.job_threads, 2.min(tb.total_cores));
115 // An absurd override never exceeds the machine's cores.
116 let huge = ThreadBudget::compute(9999);
117 assert_eq!(huge.job_threads, huge.total_cores);
118 }
119
120 #[test]
121 fn default_memory_budget_is_the_smaller_of_ceiling_and_fraction() {
122 // A small machine: 70% of RAM is under the ceiling, so the fraction wins.
123 let small = MemoryBudget::compute(Some(8 * GIB), 0);
124 assert_eq!(small.budget_bytes, 8 * GIB * 70 / 100);
125 assert!(!small.overridden);
126
127 // A large machine: 70% of RAM exceeds the ceiling, so the ceiling caps it.
128 let large = MemoryBudget::compute(Some(256 * GIB), 0);
129 assert_eq!(large.budget_bytes, HARD_CEILING_BYTES);
130 }
131
132 #[test]
133 fn memory_override_is_honored_but_clamped_to_a_safe_fraction() {
134 // A reasonable override under 85% of RAM passes through.
135 let ok = MemoryBudget::compute(Some(32 * GIB), 4096);
136 assert_eq!(ok.budget_bytes, 4096 * 1024 * 1024);
137 assert!(ok.overridden);
138
139 // An override past 85% of RAM is clamped to that safety fraction.
140 let greedy = MemoryBudget::compute(Some(8 * GIB), 16384);
141 assert_eq!(greedy.budget_bytes, 8 * GIB * 85 / 100);
142 assert!(greedy.overridden);
143 }
144
145 #[test]
146 fn unknown_ram_falls_back_to_ceiling_or_bare_override() {
147 let fallback = MemoryBudget::compute(None, 0);
148 assert_eq!(fallback.budget_bytes, HARD_CEILING_BYTES);
149 assert!(!fallback.overridden);
150
151 let bare_override = MemoryBudget::compute(None, 2048);
152 assert_eq!(bare_override.budget_bytes, 2048 * 1024 * 1024);
153 assert!(bare_override.overridden);
154 }
155}