pub mod host;
pub mod recent;
use std::collections::{BTreeMap, BTreeSet};
use crate::api::Spread;
use crate::api::{Basis, JobPeak};
use crate::config::{self, Config};
use crate::sizing::{GRANULE, History, Ran, spread};
use recent::{Ending, RECENT_RUNS, Recent};
pub const UNPREDICTABLE_FOR: u64 = 7 * 24 * 3600 * 1000;
pub const OWN_RUNS: u64 = 2;
pub const HIGH_WATER: u64 = 90;
pub const CPU_GRANULE: u64 = 100;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Key {
pub repository: String,
pub workflow: Option<String>,
pub name: String,
}
impl Key {
pub fn of(run: &Ran) -> Key {
Key {
repository: run.repository.clone(),
workflow: run.workflow.clone(),
name: run.name.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Copies {
Alone,
Several,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Prediction {
pub memory: u64,
pub basis: Basis,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CpuPrediction {
pub cpu: u64,
pub basis: Basis,
}
#[derive(Debug, Clone, Default)]
struct Averages {
sorted: Vec<u64>,
succeeded: u64,
}
impl Averages {
fn add(&mut self, average: u64, succeeded: bool) {
let at = self.sorted.partition_point(|known| *known <= average);
self.sorted.insert(at, average);
self.succeeded += u64::from(succeeded);
}
fn runs(&self) -> u64 {
self.sorted.len() as u64
}
fn p95(&self) -> u64 {
spread(self.sorted.clone()).p95
}
fn max(&self) -> u64 {
self.sorted.last().copied().unwrap_or(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct InProfile {
key: Key,
profile: String,
}
impl InProfile {
fn new(key: &Key, profile: &str) -> InProfile {
InProfile {
key: key.clone(),
profile: profile.to_owned(),
}
}
}
#[derive(Debug, Clone)]
pub struct Predictor {
margin: u32,
min_jobs: u32,
recent: Recent,
cpu_jobs: BTreeMap<InProfile, Averages>,
cpu_profiles: BTreeMap<String, Averages>,
}
impl Predictor {
pub fn new(rules: &config::Sizing) -> Predictor {
Predictor {
margin: rules.margin,
min_jobs: rules.min_jobs,
recent: Recent::new(RECENT_RUNS.max(u64::from(rules.min_jobs))),
cpu_jobs: BTreeMap::new(),
cpu_profiles: BTreeMap::new(),
}
}
pub fn read(
rules: &config::Sizing,
history: &History,
cap_of: impl Fn(&str) -> Option<u64>,
) -> Predictor {
let mut predictor = Predictor::new(rules);
for (run, ending) in recent::rows(history) {
let cap = run
.memory_cap
.or_else(|| cap_of(&run.profile))
.unwrap_or(u64::MAX);
let at = recent::finished(run);
match ending {
Ending::Finished => predictor.learn(run, &run.profile, cap, at),
Ending::Cancelled => predictor.mark(run, &run.profile, cap, at),
}
}
predictor
}
pub fn learn(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
self.recent.read(run, profile, Ending::Finished, cap, at);
self.learn_cpu(run, profile);
}
pub fn learn_cpu(&mut self, run: &Ran, profile: &str) {
let Some(average) = run.cpu_average else {
return;
};
self.cpu_profiles
.entry(profile.to_owned())
.or_default()
.add(average, run.succeeded);
self.cpu_jobs
.entry(InProfile::new(&Key::of(run), profile))
.or_default()
.add(average, run.succeeded);
}
pub fn predict_cpu(&self, key: &Key, profile: &str, cap: u64) -> CpuPrediction {
let job = self.cpu_jobs.get(&InProfile::new(key, profile));
let min_jobs = u64::from(self.min_jobs);
let peers = self
.cpu_profiles
.get(profile)
.filter(|peers| peers.runs() >= min_jobs);
let (cpu, basis) = match (job.filter(|job| job.runs() > 0), peers) {
(Some(job), _) if job.runs() >= min_jobs => (
job.p95(),
Basis::Job {
runs: job.runs(),
peak: JobPeak::P95,
},
),
(Some(job), Some(_)) if job.succeeded >= OWN_RUNS => (
job.max(),
Basis::Job {
runs: job.runs(),
peak: JobPeak::Largest,
},
),
(Some(job), Some(peers)) => (
job.max().max(peers.p95()),
Basis::Profile { runs: peers.runs() },
),
(None, Some(peers)) => (peers.max(), Basis::ProfileMax { runs: peers.runs() }),
_ => (cap, Basis::Cap),
};
CpuPrediction {
cpu: cpu
.div_ceil(CPU_GRANULE)
.saturating_mul(CPU_GRANULE)
.clamp(CPU_GRANULE, cap.max(CPU_GRANULE)),
basis,
}
}
pub fn mark(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
self.recent.read(run, profile, Ending::Cancelled, cap, at);
}
pub fn predict(
&self,
key: &Key,
profile: &str,
cap: u64,
now: u64,
copies: Copies,
) -> Prediction {
let job = self.recent.job(key, profile);
if let Some(hold) = job.and_then(|job| job.held(now, Some(UNPREDICTABLE_FOR))) {
return Prediction {
memory: cap,
basis: Basis::Unpredictable {
until: hold.at.saturating_add(UNPREDICTABLE_FOR) / 1000,
},
};
}
let min_jobs = u64::from(self.min_jobs);
let peers = self.recent.profile(profile);
let peers_runs = peers.len() as u64;
let overran = job.and_then(|job| {
job.overruns(now, UNPREDICTABLE_FOR)
.fold(None, |most: Option<(u64, u64)>, overrun| {
let until = overrun.at.saturating_add(UNPREDICTABLE_FOR);
Some(most.map_or((overrun.peak, until), |(peak, last)| {
(peak.max(overrun.peak), last.max(until))
}))
})
});
let (memory, basis) = match (job, peers_runs >= min_jobs) {
(Some(job), _) if job.runs() == 0 => (cap, Basis::Cap),
(Some(job), _) if let Some((peak, until)) = overran => (
self.with_margin(job.max().max(peak)),
Basis::Overran {
until: until / 1000,
},
),
(Some(job), _) if job.runs() >= min_jobs => (
self.with_margin(spread(job.peaks().to_vec()).p95),
Basis::Job {
runs: job.runs(),
peak: JobPeak::P95,
},
),
(Some(job), true) if job.succeeded() >= OWN_RUNS && copies == Copies::Alone => (
self.with_margin(job.max()),
Basis::Job {
runs: job.runs(),
peak: JobPeak::Largest,
},
),
(Some(job), true) => (
self.with_margin(job.max().max(spread(peers.to_vec()).p95)),
Basis::Profile { runs: peers_runs },
),
(None, true) => (
self.with_margin(peers.last().copied().unwrap_or(0)),
Basis::ProfileMax { runs: peers_runs },
),
_ => (cap, Basis::Cap),
};
Prediction {
memory: memory.min(cap),
basis,
}
}
fn with_margin(&self, peak: u64) -> u64 {
let memory = peak.saturating_add(peak.saturating_mul(u64::from(self.margin)) / 100);
memory
.div_ceil(GRANULE)
.saturating_mul(GRANULE)
.max(GRANULE)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Policy {
Caps,
Measured,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Replay {
pub jobs: u64,
pub never_started: u64,
pub wait: Spread,
pub mean_wait: u64,
pub most_running: u64,
pub worst_overlap: u64,
pub makespan: u64,
pub under_predicted: u64,
pub held_back: u64,
pub cpu_held: u64,
pub worst_cpu: u64,
}
struct Running<'run> {
run: &'run Ran,
profile: &'run str,
ends: u64,
reserved: u64,
peak: u64,
cpu: u64,
}
pub fn simulate(
config: &Config,
memory_max: Option<u64>,
cpu_budget: Option<u64>,
jobs: &[Ran],
policy: Policy,
slots: &BTreeMap<String, u32>,
) -> Replay {
let mirror = config.mirror_memory();
let budget = memory_max.map(|max| max.saturating_sub(mirror));
let mut queue: Vec<&Ran> = jobs
.iter()
.filter(|run| config.profiles.contains_key(&run.profile))
.filter(|run| matches!((run.started_at, run.finished_at), (Some(start), Some(end)) if end >= start))
.collect();
queue.sort_by_key(|run| run.queued_at);
let mut replayer = Replayer {
config,
budget,
cpu_budget,
policy,
slots,
predictor: Predictor::new(&config.sizing),
running: Vec::new(),
waits: Vec::new(),
most_running: 0,
worst_overlap: 0,
last_end: 0,
under_predicted: 0,
held_back: 0,
cpu_held: 0,
worst_cpu: 0,
};
let mut waiting: Vec<&Ran> = Vec::new();
let mut next = 0;
loop {
let arrival = queue.get(next).map(|run| run.queued_at);
let end = replayer.running.iter().map(|running| running.ends).min();
let Some(now) = [arrival, end].into_iter().flatten().min() else {
break;
};
replayer.finish(now);
replayer.rereserve(now);
while let Some(run) = queue.get(next).filter(|run| run.queued_at <= now) {
waiting.push(run);
next += 1;
}
let mut index = 0;
while let Some(run) = waiting.get(index).copied() {
if replayer.admit(run, &waiting, now) {
waiting.remove(index);
} else {
index += 1;
}
}
replayer.most_running = replayer.most_running.max(replayer.running.len() as u64);
let peaks: u64 = replayer.running.iter().map(|running| running.peak).sum();
replayer.worst_overlap = replayer.worst_overlap.max(peaks + mirror);
let cpu: u64 = replayer
.running
.iter()
.map(|running| running.run.cpu_average.unwrap_or(0))
.sum();
replayer.worst_cpu = replayer.worst_cpu.max(cpu);
}
let waits = replayer.waits;
let first = queue.first().map_or(0, |run| run.queued_at);
Replay {
jobs: queue.len() as u64,
never_started: waiting.len() as u64,
mean_wait: waits.iter().sum::<u64>() / (waits.len() as u64).max(1),
wait: spread(waits),
most_running: replayer.most_running,
worst_overlap: replayer.worst_overlap,
makespan: replayer.last_end.saturating_sub(first) / 1000,
under_predicted: replayer.under_predicted,
held_back: replayer.held_back,
cpu_held: replayer.cpu_held,
worst_cpu: replayer.worst_cpu,
}
}
struct Replayer<'run> {
config: &'run Config,
budget: Option<u64>,
cpu_budget: Option<u64>,
policy: Policy,
slots: &'run BTreeMap<String, u32>,
predictor: Predictor,
running: Vec<Running<'run>>,
waits: Vec<u64>,
most_running: u64,
worst_overlap: u64,
last_end: u64,
under_predicted: u64,
held_back: u64,
cpu_held: u64,
worst_cpu: u64,
}
impl<'run> Replayer<'run> {
fn finish(&mut self, now: u64) {
let (ended, running): (Vec<_>, Vec<_>) = std::mem::take(&mut self.running)
.into_iter()
.partition(|running| running.ends <= now);
self.running = running;
let mut ended = ended;
ended.sort_by_key(|ended| ended.ends);
for ended in ended {
let cap = ended
.run
.memory_cap
.unwrap_or(self.config.profiles[ended.profile].memory.0);
self.under_predicted += u64::from(ended.peak > ended.reserved);
let reserved = (self.policy == Policy::Measured).then_some(ended.reserved);
let run = Ran {
reserved,
..ended.run.clone()
};
self.predictor.learn(&run, ended.profile, cap, ended.ends);
self.last_end = self.last_end.max(ended.ends);
}
}
fn rereserve(&mut self, now: u64) {
if self.policy != Policy::Measured {
return;
}
let cpus: Vec<u64> = self
.running
.iter()
.map(|running| self.cpu(&Key::of(running.run), running.profile))
.collect();
let mut seen = BTreeSet::new();
for (running, cpu) in self.running.iter_mut().zip(cpus) {
let key = Key::of(running.run);
let copies = if seen.insert(key.clone()) {
Copies::Alone
} else {
Copies::Several
};
let limits = &self.config.profiles[running.profile];
running.reserved = self
.predictor
.predict(&key, running.profile, limits.memory.0, now, copies)
.memory;
running.cpu = cpu;
}
}
fn cpu(&self, key: &Key, profile: &str) -> u64 {
let cap = u64::from(self.config.profiles[profile].cpus).saturating_mul(1000);
let cpu = match self.policy {
Policy::Caps => cap,
Policy::Measured => self.predictor.predict_cpu(key, profile, cap).cpu,
};
self.cpu_budget.map_or(cpu, |budget| cpu.min(budget))
}
fn taken(&self) -> u64 {
self.running
.iter()
.map(|running| match self.policy {
Policy::Caps => running.reserved,
Policy::Measured => running.reserved.max(running.peak),
})
.sum()
}
fn admit(&mut self, run: &'run Ran, waiting: &[&Ran], now: u64) -> bool {
let Some((profile, limits)) = self.config.profiles.get_key_value(&run.profile) else {
return false;
};
let slots = self.slots.get(profile).copied().unwrap_or(limits.slots);
let in_profile = self
.running
.iter()
.filter(|running| running.profile == profile.as_str())
.count();
if in_profile >= slots as usize {
return false;
}
let cap = limits.memory.0;
let running = |key: &Key| {
self.running
.iter()
.any(|running| Key::of(running.run) == *key)
};
let queued = |job: &Ran| {
let key = Key::of(job);
let waiting = waiting.iter().filter(|other| Key::of(other) == key).count();
let copies = if waiting > 1 || running(&key) {
Copies::Several
} else {
Copies::Alone
};
self.predictor
.predict(&key, profile, cap, now, copies)
.memory
};
let started = || {
let key = Key::of(run);
let copies = if running(&key) {
Copies::Several
} else {
Copies::Alone
};
self.predictor
.predict(&key, profile, cap, now, copies)
.memory
};
let (starting, reserved) = match self.policy {
Policy::Caps => (cap, cap),
Policy::Measured => (
waiting
.iter()
.filter(|job| job.repository == run.repository && job.profile == run.profile)
.map(|job| queued(job))
.max()
.unwrap_or(0)
.max(started()),
started(),
),
};
if let Some(budget) = self.budget {
if self.taken() + starting > budget {
return false;
}
let peaks: u64 = self.running.iter().map(|running| running.peak).sum();
if self.policy == Policy::Measured
&& peaks.saturating_mul(100) >= budget.saturating_mul(HIGH_WATER)
{
self.held_back += 1;
return false;
}
}
let cpu = self.cpu(&Key::of(run), profile);
if let Some(cpu_budget) = self.cpu_budget {
let starting = waiting
.iter()
.filter(|job| job.repository == run.repository && job.profile == run.profile)
.map(|job| self.cpu(&Key::of(job), profile))
.max()
.unwrap_or(0)
.max(cpu);
let taken: u64 = self.running.iter().map(|running| running.cpu).sum();
if taken + starting > cpu_budget {
self.cpu_held += 1;
return false;
}
}
let peak = run.memory_peak.min(cap);
let ran_for = run
.finished_at
.unwrap_or_default()
.saturating_sub(run.started_at.unwrap_or_default());
self.waits.push(now.saturating_sub(run.queued_at) / 1000);
self.running.push(Running {
run,
profile,
ends: now + ran_for,
reserved,
peak,
cpu,
});
true
}
}
#[cfg(test)]
mod tests;