use std::fmt;
use serde::{Deserialize, Serialize};
pub const DEFAULT_MARGIN_BYTES: u64 = 1 << 30;
pub const DEFAULT_ESTIMATE_BYTES: u64 = 1 << 30;
pub const LOCAL_PEAKS_KEPT: usize = 20;
const RECENT_PEAKS: usize = 3;
const GIB: f64 = (1u64 << 30) as f64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EstimateSource {
Coordinator,
Local,
Default,
}
impl EstimateSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Coordinator => "coordinator",
Self::Local => "local",
Self::Default => "default",
}
}
}
impl fmt::Display for EstimateSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Estimate {
pub bytes: u64,
pub source: EstimateSource,
}
impl Estimate {
#[must_use]
pub fn choose(coordinator: Option<u64>, local: Option<u64>) -> Self {
match (coordinator, local) {
(Some(bytes), _) => Self {
bytes,
source: EstimateSource::Coordinator,
},
(None, Some(bytes)) => Self {
bytes,
source: EstimateSource::Local,
},
(None, None) => Self {
bytes: DEFAULT_ESTIMATE_BYTES,
source: EstimateSource::Default,
},
}
}
}
#[must_use]
pub fn local_estimate(peaks: &[u64]) -> Option<u64> {
let p90 = percentile(peaks, 90)?;
peaks
.iter()
.rev()
.take(RECENT_PEAKS)
.copied()
.chain([p90])
.max()
}
#[must_use]
pub fn percentile<T: Copy + Ord>(values: &[T], p: u32) -> Option<T> {
if values.is_empty() {
return None;
}
let mut sorted = values.to_vec();
sorted.sort_unstable();
let rank = (sorted.len() * p.min(100) as usize).div_ceil(100).max(1);
sorted.get(rank - 1).copied()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Running {
pub estimate: u64,
pub current_rss: Option<u64>,
}
#[must_use]
pub fn reserved(running: &[Running]) -> u64 {
running.iter().fold(0u64, |total, attempt| {
total.saturating_add(
attempt
.estimate
.saturating_sub(attempt.current_rss.unwrap_or(0)),
)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryFigures {
pub free: u64,
pub reserved: u64,
pub estimate: Estimate,
pub margin: u64,
}
impl MemoryFigures {
#[must_use]
pub const fn needed(&self) -> u64 {
self.estimate.bytes.saturating_add(self.margin)
}
#[must_use]
pub const fn available(&self) -> u64 {
self.free.saturating_sub(self.reserved)
}
}
impl fmt::Display for MemoryFigures {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"needs {} ({} {} estimate + {} margin), {} free less {} reserved",
gib(self.needed()),
gib(self.estimate.bytes),
self.estimate.source,
gib(self.margin),
gib(self.free),
gib(self.reserved),
)
}
}
#[must_use]
pub fn gib(bytes: u64) -> String {
#[allow(
clippy::cast_precision_loss,
reason = "display only: a tenth of a GiB is the precision shown"
)]
let value = bytes as f64 / GIB;
format!("{value:.1} GiB")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryVerdict {
Unchecked,
Admit(MemoryFigures),
AdmitAlone(MemoryFigures),
Defer(MemoryFigures),
}
impl MemoryVerdict {
#[must_use]
pub const fn admits(&self) -> bool {
!matches!(self, Self::Defer(_))
}
#[must_use]
pub const fn figures(&self) -> Option<&MemoryFigures> {
match self {
Self::Unchecked => None,
Self::Admit(figures) | Self::AdmitAlone(figures) | Self::Defer(figures) => {
Some(figures)
}
}
}
}
#[must_use]
pub fn admit(
free: Option<u64>,
running: &[Running],
estimate: Estimate,
margin: u64,
) -> MemoryVerdict {
let Some(free) = free else {
return MemoryVerdict::Unchecked;
};
let figures = MemoryFigures {
free,
reserved: reserved(running),
estimate,
margin,
};
if figures.available() >= figures.needed() {
MemoryVerdict::Admit(figures)
} else if running.is_empty() {
MemoryVerdict::AdmitAlone(figures)
} else {
MemoryVerdict::Defer(figures)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemorySettings {
pub enabled: bool,
pub margin_bytes: u64,
}
impl Default for MemorySettings {
fn default() -> Self {
Self {
enabled: true,
margin_bytes: DEFAULT_MARGIN_BYTES,
}
}
}
pub fn parse_size(text: &str) -> Result<u64, String> {
let text = text.trim();
let invalid = || format!("`{text}` is not a size; write it as `1GiB`, `512MiB` or `2G`");
let split = text
.find(|ch: char| !(ch.is_ascii_digit() || ch == '.'))
.unwrap_or(text.len());
let (number, unit) = text.split_at(split);
if number.is_empty() {
return Err(invalid());
}
let unit = unit.trim().to_ascii_lowercase();
if unit.is_empty() && number.trim_start_matches(['0', '.']).is_empty() {
return Ok(0);
}
let shift = match unit.as_str() {
"" => {
return Err(format!(
"`{text}` needs a unit: write `{text}GiB` or `{text}MiB`"
));
}
"b" => 0,
"k" | "kb" | "kib" => 10,
"m" | "mb" | "mib" => 20,
"g" | "gb" | "gib" => 30,
"t" | "tb" | "tib" => 40,
_ => return Err(invalid()),
};
let value: f64 = number.parse().map_err(|_| invalid())?;
#[allow(clippy::cast_precision_loss, reason = "a size typed by an operator")]
let bytes = value * (1u64 << shift) as f64;
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "range-checked just above; a fraction of a byte is dropped"
)]
if bytes.is_finite() && bytes >= 0.0 && bytes < u64::MAX as f64 {
Ok(bytes as u64)
} else {
Err(format!("`{text}` is too large"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1 << 30;
fn estimate(bytes: u64) -> Estimate {
Estimate {
bytes,
source: EstimateSource::Local,
}
}
fn running(estimate: u64, current: Option<u64>) -> Running {
Running {
estimate,
current_rss: current,
}
}
#[test]
fn a_start_that_fits_is_admitted_and_one_that_does_not_is_deferred() {
let unreal = 11_800 << 20;
let app = 5_900 << 20;
let on_host = [running(unreal, Some(GIB))];
let first_app = admit(Some(20 * GIB), &on_host, estimate(app), GIB);
assert!(
matches!(first_app, MemoryVerdict::Admit(_)),
"{first_app:?}"
);
let on_host = [running(unreal, Some(GIB)), running(app, None)];
let second_app = admit(Some(19 * GIB), &on_host, estimate(app), GIB);
let MemoryVerdict::Defer(figures) = second_app else {
panic!("a second App build must wait: {second_app:?}");
};
assert_eq!(figures.reserved, (unreal - GIB) + app);
assert_eq!(figures.needed(), app + GIB);
assert!(figures.available() < figures.needed());
}
#[test]
fn usage_already_visible_is_not_reserved_twice() {
assert_eq!(reserved(&[running(4 * GIB, Some(4 * GIB))]), 0);
assert_eq!(reserved(&[running(4 * GIB, Some(6 * GIB))]), 0);
assert_eq!(reserved(&[running(4 * GIB, Some(GIB))]), 3 * GIB);
assert_eq!(reserved(&[running(4 * GIB, None)]), 4 * GIB);
}
#[test]
fn nothing_running_is_always_admitted_however_big_the_job() {
let verdict = admit(Some(8 * GIB), &[], estimate(30 * GIB), GIB);
assert!(
matches!(verdict, MemoryVerdict::AdmitAlone(_)),
"{verdict:?}"
);
assert!(verdict.admits());
let verdict = admit(
Some(8 * GIB),
&[running(GIB, Some(GIB))],
estimate(30 * GIB),
GIB,
);
assert!(matches!(verdict, MemoryVerdict::Defer(_)), "{verdict:?}");
}
#[test]
fn unreadable_free_memory_decides_nothing() {
let verdict = admit(None, &[running(GIB, None)], estimate(30 * GIB), GIB);
assert_eq!(verdict, MemoryVerdict::Unchecked);
assert!(verdict.admits());
}
#[test]
fn the_margin_is_kept_free_on_top_of_the_estimate() {
let on_host = [running(GIB, Some(GIB))];
assert!(admit(Some(3 * GIB), &on_host, estimate(2 * GIB), GIB).admits());
assert!(!admit(Some(3 * GIB - 1), &on_host, estimate(2 * GIB), GIB).admits());
}
#[test]
fn the_estimate_is_the_coordinators_then_the_hosts_then_the_default() {
assert_eq!(
Estimate::choose(Some(5), Some(7)),
Estimate {
bytes: 5,
source: EstimateSource::Coordinator
}
);
assert_eq!(
Estimate::choose(None, Some(7)).source,
EstimateSource::Local
);
assert_eq!(
Estimate::choose(None, None),
Estimate {
bytes: DEFAULT_ESTIMATE_BYTES,
source: EstimateSource::Default
}
);
}
#[test]
fn the_local_estimate_is_the_p90_or_a_heavier_recent_peak() {
assert_eq!(local_estimate(&[]), None);
let mut peaks: Vec<u64> = (1..=20).collect();
assert_eq!(
local_estimate(&peaks),
Some(20),
"the newest is the heaviest"
);
peaks.reverse();
assert_eq!(local_estimate(&peaks), Some(18), "p90 of 1..=20");
peaks.push(100);
assert_eq!(local_estimate(&peaks), Some(100), "a job that turned heavy");
}
#[test]
fn a_simulated_macm6_never_starts_more_than_its_memory_holds() {
const TOTAL: u64 = 24 * GIB;
const BASELINE: u64 = 2 * GIB; let unreal = 11_800u64 << 20;
let app = 5_900u64 << 20;
let queue = [(unreal, 10u64), (app, 6), (app, 6), (app, 6), (app, 6)];
let simulate = |admission: bool| -> (u64, u64) {
let mut running: Vec<(u64, u64, u64)> = Vec::new();
let mut waiting: Vec<(u64, u64)> = queue.to_vec();
let mut worst = 0u64;
let mut tick = 0u64;
while !(waiting.is_empty() && running.is_empty()) {
let mut still = Vec::new();
for (estimate, len) in waiting.drain(..) {
let current = |&(peak, ran, len): &(u64, u64, u64)| peak * ran.min(len) / len;
let in_use: u64 = running.iter().map(current).sum();
let fits = admit(
Some(TOTAL - BASELINE - in_use),
&running
.iter()
.map(|job| Running {
estimate: job.0,
current_rss: Some(current(job)),
})
.collect::<Vec<_>>(),
Estimate::choose(Some(estimate), None),
GIB,
)
.admits();
if !admission || fits {
running.push((estimate, 0, len));
} else {
still.push((estimate, len));
}
}
waiting = still;
for job in &mut running {
job.1 += 1;
}
let in_use: u64 = running
.iter()
.map(|&(peak, ran, len)| peak * ran.min(len) / len)
.sum();
worst = worst.max(BASELINE + in_use);
running.retain(|&(_, ran, len)| ran < len);
tick += 1;
}
(worst, tick)
};
let (crowded, fast) = simulate(false);
let (admitted, slower) = simulate(true);
assert!(
crowded > TOTAL,
"by capacity alone: {} on 24 GiB",
gib(crowded)
);
assert!(admitted <= TOTAL, "with admission: {}", gib(admitted));
assert_eq!((gib(crowded).as_str(), fast), ("32.0 GiB", 10));
assert_eq!((gib(admitted).as_str(), slower), ("17.4 GiB", 16));
}
#[test]
fn sizes_parse_in_binary_units() {
assert_eq!(parse_size("1GiB"), Ok(GIB));
assert_eq!(parse_size("1G"), Ok(GIB));
assert_eq!(parse_size("1.5gb"), Ok(GIB + GIB / 2));
assert_eq!(parse_size("512MiB"), Ok(512 << 20));
assert_eq!(parse_size("1 GiB"), Ok(GIB));
assert_eq!(parse_size(".5G"), Ok(GIB / 2));
assert_eq!(parse_size("2048B"), Ok(2048));
assert_eq!(parse_size(" 0 "), Ok(0));
assert!(parse_size("2").is_err(), "a bare number needs a unit");
assert!(parse_size("2048").is_err());
assert!(parse_size("lots").is_err());
assert!(parse_size("1 parsec").is_err());
assert!(parse_size("GiB").is_err());
assert!(parse_size("99999999999TiB").is_err());
}
#[test]
fn figures_read_in_gib() {
let figures = MemoryFigures {
free: 19 * GIB,
reserved: 16 * GIB,
estimate: estimate(6 * GIB),
margin: GIB,
};
assert_eq!(
figures.to_string(),
"needs 7.0 GiB (6.0 GiB local estimate + 1.0 GiB margin), 19.0 GiB free less 16.0 GiB reserved"
);
}
}