1use std::fmt;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, Once, OnceLock};
6
7pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 100_000_000;
14
15pub const MEMORY_BUDGET_ENV: &str = "MOLFRAME_MEMORY_BUDGET";
17
18pub const MAX_AUTOMATIC_BUDGET_BYTES: usize = 512 << 30;
20
21const UNKNOWN_PLATFORM_BYTES: usize = 1 << 30;
23
24pub const MEMORY_BUDGET_REMEDY: &str = "raise the memory budget: Python \
26ExecutionContext(memory_budget=...), Rust ExecutionContext::builder().memory_budget(MemoryBudget::new(..)?), \
27CLI --memory-budget, or environment variable MOLFRAME_MEMORY_BUDGET";
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub struct MemoryBudget {
32 bytes: usize,
33}
34
35impl MemoryBudget {
36 pub const fn new(bytes: usize) -> Result<Self, MemoryBudgetError> {
47 if bytes == 0 {
48 return Err(MemoryBudgetError::Zero);
49 }
50 Ok(Self { bytes })
51 }
52
53 #[must_use]
55 pub const fn bytes(self) -> usize {
56 self.bytes
57 }
58
59 #[must_use]
67 pub fn auto() -> Self {
68 static DETECTED: OnceLock<MemoryBudget> = OnceLock::new();
69 if let Ok(text) = std::env::var(MEMORY_BUDGET_ENV) {
70 match Self::parse(&text) {
71 Ok(budget) => return budget,
72 Err(_) => warn_invalid_env(&text),
73 }
74 }
75 *DETECTED.get_or_init(|| match detect_physical_bytes() {
76 Some(bytes) => Self::for_physical_memory(bytes),
77 None => Self {
78 bytes: UNKNOWN_PLATFORM_BYTES,
79 },
80 })
81 }
82
83 #[must_use]
85 pub fn for_physical_memory(physical_bytes: usize) -> Self {
86 let share = physical_bytes / 5 * 2;
87 Self {
88 bytes: share.clamp(DEFAULT_MEMORY_BUDGET_BYTES, MAX_AUTOMATIC_BUDGET_BYTES),
89 }
90 }
91
92 pub fn parse(text: &str) -> Result<Self, MemoryBudgetError> {
102 let trimmed = text.trim();
103 let split = match trimmed.find(|c: char| !(c.is_ascii_digit() || c == '.')) {
104 Some(split) => split,
105 None => trimmed.len(),
106 };
107 let (number, unit) = trimmed.split_at(split);
108 let multiplier: f64 = match unit.trim().to_ascii_lowercase().as_str() {
109 "" | "b" => 1.0,
110 "k" | "kib" => 1024.0,
111 "m" | "mib" => 1024.0 * 1024.0,
112 "g" | "gib" => 1024.0 * 1024.0 * 1024.0,
113 "t" | "tib" => 1024.0_f64.powi(4),
114 "kb" => 1e3,
115 "mb" => 1e6,
116 "gb" => 1e9,
117 "tb" => 1e12,
118 _ => return Err(MemoryBudgetError::Unparsable),
119 };
120 let value: f64 = number.parse().map_err(|_| MemoryBudgetError::Unparsable)?;
121 let bytes = value * multiplier;
122 let ceiling = crate::numeric::usize_to_f64(usize::MAX);
123 if !bytes.is_finite() || bytes >= ceiling {
124 return Err(MemoryBudgetError::Unparsable);
125 }
126 Self::new(crate::numeric::f64_to_usize(bytes))
128 }
129}
130
131impl Default for MemoryBudget {
132 fn default() -> Self {
134 Self::auto()
135 }
136}
137
138fn warn_invalid_env(text: &str) {
139 static WARNED: Once = Once::new();
140 WARNED.call_once(|| {
141 eprintln!(
142 "molframe: ignoring {MEMORY_BUDGET_ENV}={text:?}: expected a positive size such as 4GiB or 512MB"
143 );
144 });
145}
146
147fn detect_physical_bytes() -> Option<usize> {
149 let limit = cgroup_limit();
150 let physical = if cfg!(target_os = "linux") {
151 std::fs::read_to_string("/proc/meminfo")
152 .ok()
153 .and_then(|text| {
154 meminfo_kib(&text, "MemAvailable:").or_else(|| meminfo_kib(&text, "MemTotal:"))
155 })
156 .map(|kib| kib.saturating_mul(1024))
157 } else if cfg!(target_os = "macos") {
158 std::process::Command::new("sysctl")
159 .args(["-n", "hw.memsize"])
160 .output()
161 .ok()
162 .and_then(|out| String::from_utf8(out.stdout).ok())
163 .and_then(|text| text.trim().parse::<usize>().ok())
164 } else {
165 None
166 };
167 match (physical, limit) {
168 (Some(a), Some(b)) => Some(a.min(b)),
169 (a, b) => a.or(b),
170 }
171}
172
173fn meminfo_kib(text: &str, key: &str) -> Option<usize> {
174 text.lines()
175 .find_map(|line| line.strip_prefix(key))
176 .and_then(|rest| rest.split_whitespace().next())
177 .and_then(|value| value.parse().ok())
178}
179
180fn cgroup_limit() -> Option<usize> {
182 [
183 "/sys/fs/cgroup/memory.max",
184 "/sys/fs/cgroup/memory/memory.limit_in_bytes",
185 ]
186 .iter()
187 .find_map(|path| std::fs::read_to_string(path).ok())
188 .and_then(|text| text.trim().parse::<u64>().ok())
189 .filter(|limit| *limit < (1_u64 << 60))
192 .and_then(|limit| usize::try_from(limit).ok())
193}
194
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
197pub enum MemoryBudgetError {
198 Zero,
200 Unparsable,
202 Exhausted {
204 requested: usize,
206 available: usize,
208 },
209}
210
211impl fmt::Display for MemoryBudgetError {
212 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213 match self {
214 Self::Zero => formatter.write_str("execution memory budget must be non-zero"),
215 Self::Unparsable => {
216 formatter.write_str("memory budget must be a positive size such as 4GiB or 512MB")
217 }
218 Self::Exhausted {
219 requested,
220 available,
221 } => write!(
222 formatter,
223 "execution requested {requested} bytes with {available} bytes available; \
224 {MEMORY_BUDGET_REMEDY}"
225 ),
226 }
227 }
228}
229
230impl std::error::Error for MemoryBudgetError {}
231
232#[derive(Debug)]
233pub(crate) struct MemoryAccount {
234 budget: MemoryBudget,
235 reserved: AtomicUsize,
236 peak_reserved: AtomicUsize,
237 live_batches: AtomicUsize,
238 peak_live_batches: AtomicUsize,
239}
240
241impl MemoryAccount {
242 pub(crate) fn new(budget: MemoryBudget) -> Self {
243 Self {
244 budget,
245 reserved: AtomicUsize::new(0),
246 peak_reserved: AtomicUsize::new(0),
247 live_batches: AtomicUsize::new(0),
248 peak_live_batches: AtomicUsize::new(0),
249 }
250 }
251
252 pub(crate) const fn budget(&self) -> MemoryBudget {
253 self.budget
254 }
255
256 pub(crate) fn reserve(
257 account: &Arc<Self>,
258 bytes: usize,
259 ) -> Result<MemoryReservation, MemoryBudgetError> {
260 let mut current = account.reserved.load(Ordering::Acquire);
261 loop {
262 let Some(next) = current.checked_add(bytes) else {
263 return Err(MemoryBudgetError::Exhausted {
264 requested: bytes,
265 available: account.budget.bytes().saturating_sub(current),
266 });
267 };
268 if next > account.budget.bytes() {
269 return Err(MemoryBudgetError::Exhausted {
270 requested: bytes,
271 available: account.budget.bytes().saturating_sub(current),
272 });
273 }
274 match account.reserved.compare_exchange_weak(
275 current,
276 next,
277 Ordering::AcqRel,
278 Ordering::Acquire,
279 ) {
280 Ok(_) => {
281 account.peak_reserved.fetch_max(next, Ordering::AcqRel);
282 return Ok(MemoryReservation {
283 account: Arc::clone(account),
284 bytes,
285 });
286 }
287 Err(observed) => current = observed,
288 }
289 }
290 }
291
292 pub(crate) fn reserved_bytes(&self) -> usize {
293 self.reserved.load(Ordering::Acquire)
294 }
295
296 pub(crate) fn peak_reserved_bytes(&self) -> usize {
297 self.peak_reserved.load(Ordering::Acquire)
298 }
299
300 pub(crate) fn live_batches(&self) -> usize {
301 self.live_batches.load(Ordering::Acquire)
302 }
303
304 pub(crate) fn peak_live_batches(&self) -> usize {
305 self.peak_live_batches.load(Ordering::Acquire)
306 }
307
308 fn begin_batch(account: &Arc<Self>) -> BatchGuard {
309 let live = account.live_batches.fetch_add(1, Ordering::AcqRel) + 1;
310 account.peak_live_batches.fetch_max(live, Ordering::AcqRel);
311 BatchGuard {
312 account: Arc::clone(account),
313 }
314 }
315}
316
317#[derive(Debug)]
322pub struct MemoryReservation {
323 account: Arc<MemoryAccount>,
324 bytes: usize,
325}
326
327impl MemoryReservation {
328 #[must_use]
330 pub fn belongs_to(&self, context: &super::ExecutionContext) -> bool {
331 Arc::ptr_eq(&self.account, &context.memory)
332 }
333
334 #[must_use]
336 pub const fn bytes(&self) -> usize {
337 self.bytes
338 }
339
340 pub fn shrink_to(&mut self, bytes: usize) {
342 if bytes >= self.bytes {
343 return;
344 }
345 let released = self.bytes - bytes;
346 self.bytes = bytes;
347 self.account.reserved.fetch_sub(released, Ordering::AcqRel);
348 }
349
350 pub fn try_grow(&mut self, additional: usize) -> Result<(), MemoryBudgetError> {
357 if additional == 0 {
358 return Ok(());
359 }
360 let mut extra = MemoryAccount::reserve(&self.account, additional)?;
361 let Some(bytes) = self.bytes.checked_add(additional) else {
362 return Err(MemoryBudgetError::Exhausted {
363 requested: additional,
364 available: 0,
365 });
366 };
367 self.bytes = bytes;
368 extra.bytes = 0;
369 Ok(())
370 }
371
372 pub(crate) fn begin_batch(&self) -> BatchGuard {
373 MemoryAccount::begin_batch(&self.account)
374 }
375}
376
377impl Drop for MemoryReservation {
378 fn drop(&mut self) {
379 self.account
380 .reserved
381 .fetch_sub(self.bytes, Ordering::AcqRel);
382 }
383}
384
385#[derive(Debug)]
387pub(crate) struct BatchGuard {
388 account: Arc<MemoryAccount>,
389}
390
391impl Drop for BatchGuard {
392 fn drop(&mut self) {
393 self.account.live_batches.fetch_sub(1, Ordering::AcqRel);
394 }
395}
396
397#[cfg(test)]
398#[path = "memory_tests.rs"]
399mod tests;
400
401crate::diagnostic_from!(
402 MemoryBudgetError,
403 |_error| crate::Code::E7001,
404 |diagnostic, error| match error {
405 MemoryBudgetError::Zero => diagnostic.with_context("budget", "zero"),
406 MemoryBudgetError::Unparsable => diagnostic.with_context("budget", "unparsable"),
407 MemoryBudgetError::Exhausted {
408 requested,
409 available,
410 } => diagnostic
411 .with_context("requested_bytes", requested.to_string())
412 .with_context("available_bytes", available.to_string()),
413 }
414);