use std::fmt;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Once, OnceLock};
pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 100_000_000;
pub const MEMORY_BUDGET_ENV: &str = "MOLFRAME_MEMORY_BUDGET";
pub const MAX_AUTOMATIC_BUDGET_BYTES: usize = 512 << 30;
const UNKNOWN_PLATFORM_BYTES: usize = 1 << 30;
pub const MEMORY_BUDGET_REMEDY: &str = "raise the memory budget: Python \
ExecutionContext(memory_budget=...), Rust ExecutionContext::builder().memory_budget(MemoryBudget::new(..)?), \
CLI --memory-budget, or environment variable MOLFRAME_MEMORY_BUDGET";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MemoryBudget {
bytes: usize,
}
impl MemoryBudget {
pub const fn new(bytes: usize) -> Result<Self, MemoryBudgetError> {
if bytes == 0 {
return Err(MemoryBudgetError::Zero);
}
Ok(Self { bytes })
}
#[must_use]
pub const fn bytes(self) -> usize {
self.bytes
}
#[must_use]
pub fn auto() -> Self {
static DETECTED: OnceLock<MemoryBudget> = OnceLock::new();
if let Ok(text) = std::env::var(MEMORY_BUDGET_ENV) {
match Self::parse(&text) {
Ok(budget) => return budget,
Err(_) => warn_invalid_env(&text),
}
}
*DETECTED.get_or_init(|| match detect_physical_bytes() {
Some(bytes) => Self::for_physical_memory(bytes),
None => Self {
bytes: UNKNOWN_PLATFORM_BYTES,
},
})
}
#[must_use]
pub fn for_physical_memory(physical_bytes: usize) -> Self {
let share = physical_bytes / 5 * 2;
Self {
bytes: share.clamp(DEFAULT_MEMORY_BUDGET_BYTES, MAX_AUTOMATIC_BUDGET_BYTES),
}
}
pub fn parse(text: &str) -> Result<Self, MemoryBudgetError> {
let trimmed = text.trim();
let split = match trimmed.find(|c: char| !(c.is_ascii_digit() || c == '.')) {
Some(split) => split,
None => trimmed.len(),
};
let (number, unit) = trimmed.split_at(split);
let multiplier: f64 = match unit.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1.0,
"k" | "kib" => 1024.0,
"m" | "mib" => 1024.0 * 1024.0,
"g" | "gib" => 1024.0 * 1024.0 * 1024.0,
"t" | "tib" => 1024.0_f64.powi(4),
"kb" => 1e3,
"mb" => 1e6,
"gb" => 1e9,
"tb" => 1e12,
_ => return Err(MemoryBudgetError::Unparsable),
};
let value: f64 = number.parse().map_err(|_| MemoryBudgetError::Unparsable)?;
let bytes = value * multiplier;
let ceiling = crate::numeric::usize_to_f64(usize::MAX);
if !bytes.is_finite() || bytes >= ceiling {
return Err(MemoryBudgetError::Unparsable);
}
Self::new(crate::numeric::f64_to_usize(bytes))
}
}
impl Default for MemoryBudget {
fn default() -> Self {
Self::auto()
}
}
fn warn_invalid_env(text: &str) {
static WARNED: Once = Once::new();
WARNED.call_once(|| {
eprintln!(
"molframe: ignoring {MEMORY_BUDGET_ENV}={text:?}: expected a positive size such as 4GiB or 512MB"
);
});
}
fn detect_physical_bytes() -> Option<usize> {
let limit = cgroup_limit();
let physical = if cfg!(target_os = "linux") {
std::fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|text| {
meminfo_kib(&text, "MemAvailable:").or_else(|| meminfo_kib(&text, "MemTotal:"))
})
.map(|kib| kib.saturating_mul(1024))
} else if cfg!(target_os = "macos") {
std::process::Command::new("sysctl")
.args(["-n", "hw.memsize"])
.output()
.ok()
.and_then(|out| String::from_utf8(out.stdout).ok())
.and_then(|text| text.trim().parse::<usize>().ok())
} else {
None
};
match (physical, limit) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
}
}
fn meminfo_kib(text: &str, key: &str) -> Option<usize> {
text.lines()
.find_map(|line| line.strip_prefix(key))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|value| value.parse().ok())
}
fn cgroup_limit() -> Option<usize> {
[
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
]
.iter()
.find_map(|path| std::fs::read_to_string(path).ok())
.and_then(|text| text.trim().parse::<u64>().ok())
.filter(|limit| *limit < (1_u64 << 60))
.and_then(|limit| usize::try_from(limit).ok())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MemoryBudgetError {
Zero,
Unparsable,
Exhausted {
requested: usize,
available: usize,
},
}
impl fmt::Display for MemoryBudgetError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Zero => formatter.write_str("execution memory budget must be non-zero"),
Self::Unparsable => {
formatter.write_str("memory budget must be a positive size such as 4GiB or 512MB")
}
Self::Exhausted {
requested,
available,
} => write!(
formatter,
"execution requested {requested} bytes with {available} bytes available; \
{MEMORY_BUDGET_REMEDY}"
),
}
}
}
impl std::error::Error for MemoryBudgetError {}
#[derive(Debug)]
pub(crate) struct MemoryAccount {
budget: MemoryBudget,
reserved: AtomicUsize,
peak_reserved: AtomicUsize,
live_batches: AtomicUsize,
peak_live_batches: AtomicUsize,
}
impl MemoryAccount {
pub(crate) fn new(budget: MemoryBudget) -> Self {
Self {
budget,
reserved: AtomicUsize::new(0),
peak_reserved: AtomicUsize::new(0),
live_batches: AtomicUsize::new(0),
peak_live_batches: AtomicUsize::new(0),
}
}
pub(crate) const fn budget(&self) -> MemoryBudget {
self.budget
}
pub(crate) fn reserve(
account: &Arc<Self>,
bytes: usize,
) -> Result<MemoryReservation, MemoryBudgetError> {
let mut current = account.reserved.load(Ordering::Acquire);
loop {
let Some(next) = current.checked_add(bytes) else {
return Err(MemoryBudgetError::Exhausted {
requested: bytes,
available: account.budget.bytes().saturating_sub(current),
});
};
if next > account.budget.bytes() {
return Err(MemoryBudgetError::Exhausted {
requested: bytes,
available: account.budget.bytes().saturating_sub(current),
});
}
match account.reserved.compare_exchange_weak(
current,
next,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
account.peak_reserved.fetch_max(next, Ordering::AcqRel);
return Ok(MemoryReservation {
account: Arc::clone(account),
bytes,
});
}
Err(observed) => current = observed,
}
}
}
pub(crate) fn reserved_bytes(&self) -> usize {
self.reserved.load(Ordering::Acquire)
}
pub(crate) fn peak_reserved_bytes(&self) -> usize {
self.peak_reserved.load(Ordering::Acquire)
}
pub(crate) fn live_batches(&self) -> usize {
self.live_batches.load(Ordering::Acquire)
}
pub(crate) fn peak_live_batches(&self) -> usize {
self.peak_live_batches.load(Ordering::Acquire)
}
fn begin_batch(account: &Arc<Self>) -> BatchGuard {
let live = account.live_batches.fetch_add(1, Ordering::AcqRel) + 1;
account.peak_live_batches.fetch_max(live, Ordering::AcqRel);
BatchGuard {
account: Arc::clone(account),
}
}
}
#[derive(Debug)]
pub struct MemoryReservation {
account: Arc<MemoryAccount>,
bytes: usize,
}
impl MemoryReservation {
#[must_use]
pub fn belongs_to(&self, context: &super::ExecutionContext) -> bool {
Arc::ptr_eq(&self.account, &context.memory)
}
#[must_use]
pub const fn bytes(&self) -> usize {
self.bytes
}
pub fn shrink_to(&mut self, bytes: usize) {
if bytes >= self.bytes {
return;
}
let released = self.bytes - bytes;
self.bytes = bytes;
self.account.reserved.fetch_sub(released, Ordering::AcqRel);
}
pub fn try_grow(&mut self, additional: usize) -> Result<(), MemoryBudgetError> {
if additional == 0 {
return Ok(());
}
let mut extra = MemoryAccount::reserve(&self.account, additional)?;
let Some(bytes) = self.bytes.checked_add(additional) else {
return Err(MemoryBudgetError::Exhausted {
requested: additional,
available: 0,
});
};
self.bytes = bytes;
extra.bytes = 0;
Ok(())
}
pub(crate) fn begin_batch(&self) -> BatchGuard {
MemoryAccount::begin_batch(&self.account)
}
}
impl Drop for MemoryReservation {
fn drop(&mut self) {
self.account
.reserved
.fetch_sub(self.bytes, Ordering::AcqRel);
}
}
#[derive(Debug)]
pub(crate) struct BatchGuard {
account: Arc<MemoryAccount>,
}
impl Drop for BatchGuard {
fn drop(&mut self) {
self.account.live_batches.fetch_sub(1, Ordering::AcqRel);
}
}
#[cfg(test)]
#[path = "memory_tests.rs"]
mod tests;
crate::diagnostic_from!(
MemoryBudgetError,
|_error| crate::Code::E7001,
|diagnostic, error| match error {
MemoryBudgetError::Zero => diagnostic.with_context("budget", "zero"),
MemoryBudgetError::Unparsable => diagnostic.with_context("budget", "unparsable"),
MemoryBudgetError::Exhausted {
requested,
available,
} => diagnostic
.with_context("requested_bytes", requested.to_string())
.with_context("available_bytes", available.to_string()),
}
);