Skip to main content

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}