use std::collections::BTreeMap;
use std::fmt::Write as _;
use crate::api::{
Advice, ProfileSizing, Resize, Sizing, SizingChange, SizingRefused, SizingRevert, cores,
job_title,
};
use crate::config::{Admission, ByteSize, Config, Profile, cpu_budget};
use super::{
BUSY, CpusSet, GRANULE, History, WAITED, Window, advise, busy, first_runner, spread, waits,
};
const RAISE_RUNS: u64 = 3;
const BOUND: u64 = 70;
const HEADROOM: u64 = 2;
#[derive(Debug, Clone, Copy)]
pub struct Context<'run> {
pub memory_max: Option<u64>,
pub cpus: Option<u64>,
pub window: Window,
pub last: &'run BTreeMap<String, u64>,
pub cpus_set: &'run [CpusSet],
pub running: &'run BTreeMap<String, u32>,
}
pub fn changes(config: &Config, history: &History, context: Context<'_>) -> Vec<SizingChange> {
next_look(config, history, context).changes
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Look {
pub changes: Vec<SizingChange>,
pub refused: Vec<SizingRefused>,
}
pub fn next_look(config: &Config, history: &History, context: Context<'_>) -> Look {
let rules = &config.sizing;
if !rules.auto {
return Look::default();
}
let window = context.window;
let sizing = advise(config, context.memory_max, history, window);
let now = window.now / 1000;
let mut budget = Budget::new(config, &sizing.profiles);
let mut changes = Vec::new();
let mut refused = Vec::new();
for advised in &sizing.profiles {
let Some(profile) = config.profiles.get(&advised.name) else {
continue;
};
let last = context.last.get(&advised.name).copied();
let since = last.map_or(u64::MAX, |at| now.saturating_sub(at));
if let Some(revert) = &advised.revert {
match reverted(config, profile, advised, revert, &sizing, &context, now) {
Revert::Write(change) => {
budget.take(&advised.name, profile, change.from, change.to);
changes.push(change);
continue;
}
Revert::Wait => continue,
Revert::Outside => {}
}
}
if since < rules.raise_after().as_secs() {
continue;
}
let lowering = if since >= rules.interval.as_secs() {
Lowering::Allowed
} else {
Lowering::Held
};
let fresh = Fresh::read(history, advised, last, window);
let mut reasons = Vec::new();
let memory = memory(config, profile, advised.advice, lowering);
let running = context.running.get(&advised.name).copied().unwrap_or(0);
let slots = match config.host.admission {
Admission::Caps => {
caps_slots(profile, advised, &fresh, memory, &context, &budget, running)
}
Admission::Measured => {
measured_slots(config, &sizing.profiles, advised, &fresh, &context, &budget)
}
};
let (slots, cpus) = cpus_and_slots(
config,
profile,
advised,
history,
fresh.start,
&context,
&budget,
slots,
lowering,
);
if memory != profile.memory.0 {
reasons.push(memory_reason(
&sizing,
advised,
memory,
slots,
config.sizing.max_step,
));
}
let advised_slots = config.host.admission == Admission::Caps && memory < profile.memory.0;
if slots > profile.slots && !advised_slots {
reasons.push(fresh.waited(last.is_some()));
}
let cpus = cpus.taken(&advised.name, &mut reasons, &mut refused);
let from = Resize {
memory: profile.memory.0,
slots: profile.slots,
cpus: cpus.map(|_| profile.cpus),
};
let to = Resize {
memory,
slots,
cpus,
};
if to == from || slots == 0 || !within(profile, from, to) {
continue;
}
budget.take(&advised.name, profile, from, to);
changes.push(SizingChange {
at: now,
profile: advised.name.clone(),
from,
to,
reason: reasons.join("; "),
uncertain: false,
});
}
Look { changes, refused }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Lowering {
Allowed,
Held,
}
struct Budget {
caps: u64,
cpus: u64,
slots: BTreeMap<String, u32>,
}
impl Budget {
fn take(&mut self, name: &str, profile: &Profile, from: Resize, to: Resize) {
self.caps = self
.caps
.saturating_sub(from.memory.saturating_mul(u64::from(from.slots)))
.saturating_add(to.memory.saturating_mul(u64::from(to.slots)));
let millicores = |resize: Resize| {
u64::from(resize.cpus.unwrap_or(profile.cpus)) * 1000 * u64::from(resize.slots)
};
self.cpus = self
.cpus
.saturating_sub(millicores(from))
.saturating_add(millicores(to));
self.slots.insert(name.to_owned(), to.slots);
}
fn new(config: &Config, profiles: &[ProfileSizing]) -> Budget {
Budget {
caps: config.memory_caps().map_or(u64::MAX, |caps| caps.0),
cpus: config
.profiles
.values()
.map(|profile| u64::from(profile.cpus) * 1000 * u64::from(profile.slots))
.sum(),
slots: profiles
.iter()
.map(|advised| (advised.name.clone(), advised.slots))
.collect(),
}
}
}
struct Fresh {
start: u64,
jobs: u64,
wait: u64,
busy: u32,
}
impl Fresh {
fn read(
history: &History,
advised: &ProfileSizing,
last: Option<u64>,
window: Window,
) -> Fresh {
let start = last.map_or(window.since, |at| at.saturating_mul(1000).max(window.since));
let jobs: Vec<_> = history
.jobs
.iter()
.filter(|job| job.profile == advised.name && job.queued_at >= start)
.collect();
Fresh {
start,
jobs: jobs.len() as u64,
wait: waits(&jobs).p95,
busy: busy(
history,
&advised.name,
advised.slots,
first_runner(history, start),
window.now,
),
}
}
fn wants_slots(&self) -> bool {
self.jobs >= RAISE_RUNS && (self.wait >= WAITED || self.busy >= BUSY)
}
fn waited(&self, changed: bool) -> String {
format!(
"{} jobs {}waited {}s for a slot (p95) and slots were busy {}%; one more fits the slice beside the \
other profiles'",
self.jobs,
if changed {
"since its last change "
} else {
""
},
self.wait,
self.busy
)
}
}
fn memory_reason(
sizing: &Sizing,
advised: &ProfileSizing,
memory: u64,
slots: u32,
max_step: u32,
) -> String {
let made = ProfileSizing {
advice: match advised.advice {
Advice::Lower { .. } => Advice::Lower { memory, slots },
Advice::Raise { .. } => Advice::Raise { memory, slots },
other => other,
},
revert: advised.revert.clone().filter(|revert| revert.to == memory),
..advised.clone()
};
let line = sizing.memory_line(&made);
match advised.advice.change() {
Some(advice) if advice.memory != memory && memory > advised.memory => format!(
"{line}; as far as its max_memory allows, short of {}",
ByteSize(advice.memory)
),
Some(advice) if advice.memory != memory => format!(
"{line}; a step toward {}, at most {max_step}% a change",
ByteSize(advice.memory)
),
_ => line,
}
}
fn within(profile: &Profile, from: Resize, to: Resize) -> bool {
profile.min_memory.is_none_or(|min| to.memory >= min.0)
&& profile.max_memory.is_none_or(|max| to.memory <= max.0)
&& (to.slots <= from.slots || profile.max_slots.is_none_or(|max| to.slots <= max))
}
enum Revert {
Write(SizingChange),
Wait,
Outside,
}
fn reverted(
config: &Config,
profile: &Profile,
advised: &ProfileSizing,
revert: &SizingRevert,
sizing: &Sizing,
context: &Context<'_>,
now: u64,
) -> Revert {
let Advice::Raise { memory, slots } = advised.advice else {
return Revert::Outside;
};
let running = context.running.get(&advised.name).copied().unwrap_or(0);
let from = Resize {
memory: profile.memory.0,
slots: profile.slots,
cpus: None,
};
let to = Resize {
memory,
slots,
cpus: None,
};
if memory != revert.to || !within(profile, from, to) {
return Revert::Outside;
}
if config.host.admission == Admission::Caps && running > slots {
return Revert::Wait;
}
Revert::Write(SizingChange {
at: now,
profile: advised.name.clone(),
from,
to,
reason: sizing.memory_line(advised),
uncertain: false,
})
}
fn memory(config: &Config, profile: &Profile, advice: Advice, lowering: Lowering) -> u64 {
let current = profile.memory.0;
let step = current.saturating_mul(u64::from(config.sizing.max_step)) / 100;
match advice {
Advice::Lower { memory, .. } if lowering == Lowering::Allowed => {
let floor = current
.saturating_sub(step)
.div_ceil(GRANULE)
.saturating_mul(GRANULE);
let bound = profile.min_memory.map_or(0, |min| min.0);
memory.max(floor).max(bound).min(current)
}
Advice::Raise { memory, .. } => {
let bound = profile.max_memory.map_or(u64::MAX, |max| max.0);
memory.min(bound).max(current)
}
_ => current,
}
}
fn caps_slots(
profile: &Profile,
advised: &ProfileSizing,
fresh: &Fresh,
memory: u64,
context: &Context<'_>,
budget: &Budget,
running: u32,
) -> u32 {
let current = profile.slots;
let max_slots = profile.max_slots.unwrap_or(u32::MAX);
let fit = |room: u64| u32::try_from(room / memory.max(1)).unwrap_or(u32::MAX);
if memory < profile.memory.0 && advised.wants_slots {
let share = profile.memory.0.saturating_mul(u64::from(current));
return fit(share).min(max_slots).max(current);
}
let Some(memory_max) = context.memory_max else {
return current;
};
if memory > profile.memory.0 {
let share = profile.memory.0.saturating_mul(u64::from(current));
let room = memory_max.saturating_sub(budget.caps.saturating_sub(share));
let slots = fit(room).min(current);
return if slots < running { 0 } else { slots };
}
if fresh.wants_slots()
&& current < max_slots
&& budget.caps.saturating_add(memory) <= memory_max
{
return current + 1;
}
current
}
fn measured_slots(
config: &Config,
profiles: &[ProfileSizing],
advised: &ProfileSizing,
fresh: &Fresh,
context: &Context<'_>,
budget: &Budget,
) -> u32 {
let current = advised.slots;
let max_slots = config
.profiles
.get(&advised.name)
.and_then(|profile| profile.max_slots);
if !fresh.wants_slots() || max_slots.is_some_and(|max| max <= current) {
return current;
}
let Some(memory_max) = context.memory_max else {
return if max_slots.is_some() {
current + 1
} else {
current
};
};
let peaks: u64 = profiles
.iter()
.map(|other| {
let peak = if matches!(other.advice, Advice::TooFew { .. }) {
other.memory
} else {
other.peaks.memory.p95
};
let slots = if other.name == advised.name {
current + 1
} else {
budget
.slots
.get(&other.name)
.copied()
.unwrap_or(other.slots)
};
peak.saturating_mul(u64::from(slots))
})
.fold(config.mirror_memory(), u64::saturating_add);
if peaks <= memory_max {
current + 1
} else {
current
}
}
#[expect(
clippy::too_many_arguments,
reason = "each is one thing a CPU change reads; a struct for them would be read here only"
)]
fn cpus_and_slots(
config: &Config,
profile: &Profile,
advised: &ProfileSizing,
history: &History,
since: u64,
context: &Context<'_>,
budget: &Budget,
slots: u32,
lowering: Lowering,
) -> (u32, CpuStep) {
let size = |slots: u32| {
cpus(
config, profile, advised, history, since, context, budget, slots, lowering,
)
};
let fit = |slots: u32, cpus: &CpuStep| {
let cpus = match cpus {
CpuStep::To(cpus) => cpus.to,
CpuStep::Keep | CpuStep::Refused(_) => profile.cpus,
};
if slots > profile.slots {
cpu_slots(config, profile, context, budget, cpus)
.map_or(slots, |fit| slots.min(fit.max(profile.slots)))
} else {
slots
}
};
let mut cpus = size(slots);
let fitted = fit(slots, &cpus);
if fitted < slots {
cpus = size(fitted);
}
(fit(fitted, &cpus), cpus)
}
fn cpu_slots(
config: &Config,
profile: &Profile,
context: &Context<'_>,
budget: &Budget,
cpus: u32,
) -> Option<u32> {
let (Admission::Caps, Some(overcommit)) = (config.host.admission, config.host.cpu_overcommit)
else {
return None;
};
let Some(slice) = context.cpus else {
return Some(profile.slots);
};
let others = budget
.cpus
.saturating_sub(u64::from(profile.cpus) * 1000 * u64::from(profile.slots));
let room = cpu_budget(slice, overcommit).saturating_sub(others);
Some(u32::try_from(room / (u64::from(cpus.max(1)) * 1000)).unwrap_or(u32::MAX))
}
enum CpuStep {
Keep,
To(Cpus),
Refused(String),
}
impl CpuStep {
fn taken(
self,
profile: &str,
reasons: &mut Vec<String>,
refused: &mut Vec<SizingRefused>,
) -> Option<u32> {
match self {
CpuStep::To(cpus) => {
reasons.push(cpus.reason);
Some(cpus.to)
}
CpuStep::Refused(reason) => {
refused.push(SizingRefused {
profile: profile.to_owned(),
reason,
});
None
}
CpuStep::Keep => None,
}
}
}
struct Cpus {
to: u32,
reason: String,
}
type JobKey<'run> = (&'run str, Option<&'run str>, &'run str);
fn cpus_since(set: &[CpusSet], profile: &str, cpus: u32, now: u64) -> u64 {
let mut since = None;
let mut seen = false;
for event in set.iter().filter(|event| event.profile == profile) {
seen = true;
if event.cpus != cpus {
since = None;
} else if since.is_none() {
since = Some(event.at);
}
}
since.unwrap_or(if seen { now } else { 0 })
}
fn averages<'run>(
history: &'run History,
profile: &str,
cap: u64,
since: u64,
) -> BTreeMap<JobKey<'run>, Vec<u64>> {
let mut jobs: BTreeMap<JobKey<'run>, Vec<u64>> = BTreeMap::new();
for job in &history.jobs {
if job.profile == profile
&& job.cpu_cap == Some(cap)
&& job.succeeded
&& job.queued_at >= since
{
let averages = jobs
.entry((&job.repository, job.workflow.as_deref(), &job.name))
.or_default();
averages.extend(job.cpu_average.filter(|average| *average > 0));
}
}
jobs
}
fn job_named(job: JobKey<'_>) -> String {
let (repository, workflow, name) = job;
format!("{repository} {}", job_title(workflow, name, None))
}
#[expect(
clippy::too_many_arguments,
reason = "each is one thing a CPU change reads; a struct for them would be read here only"
)]
fn cpus(
config: &Config,
profile: &Profile,
advised: &ProfileSizing,
history: &History,
since: u64,
context: &Context<'_>,
budget: &Budget,
slots: u32,
lowering: Lowering,
) -> CpuStep {
let current = profile.cpus;
let cap = u64::from(current) * 1000;
let since = since.max(cpus_since(
context.cpus_set,
&advised.name,
current,
context.window.now,
));
let jobs = averages(history, &advised.name, cap, since);
let step = (current.saturating_mul(config.sizing.max_step) / 100).max(1);
let bound = jobs
.iter()
.filter(|(_, averages)| averages.len() as u64 >= RAISE_RUNS)
.map(|(job, averages)| (*job, averages.len(), spread(averages.clone()).p50))
.filter(|(_, _, median)| median.saturating_mul(100) >= cap.saturating_mul(BOUND))
.max_by_key(|(_, _, median)| *median);
if let Some((job, runs, median)) = bound {
let held = format!(
"{} runs of {} averaged {} of its {current} CPUs (median): the cap holds them back",
runs,
job_named(job),
cores(median)
);
let Some(slice) = context.cpus else {
return CpuStep::Refused(format!("{held}; the slice's CPUs could not be read"));
};
let slice_cpus = u32::try_from(slice / 1000).unwrap_or(u32::MAX);
let mut to = current.saturating_add(step).min(slice_cpus);
let mut why = format!("it has all {slice_cpus} of the slice's CPUs");
if let (Admission::Caps, Some(overcommit)) =
(config.host.admission, config.host.cpu_overcommit)
{
let others = budget
.cpus
.saturating_sub(cap.saturating_mul(u64::from(profile.slots)));
let room = cpu_budget(slice, overcommit).saturating_sub(others);
let fit = room / 1000 / u64::from(slots.max(1));
let fit = u32::try_from(fit).unwrap_or(u32::MAX);
if fit < to {
to = fit;
why = format!(
"{} CPUs for each of its {slots} slots do not fit the CPU budget beside the other profiles'",
current.saturating_add(1)
);
}
}
return if to > current {
CpuStep::To(Cpus { to, reason: held })
} else {
CpuStep::Refused(format!("{held}; {why}"))
};
}
let runs: usize = jobs.values().map(Vec::len).sum();
if lowering == Lowering::Held
|| (runs as u64) < u64::from(config.sizing.min_jobs)
|| jobs.values().any(Vec::is_empty)
{
return CpuStep::Keep;
}
let Some((job, heaviest)) = jobs
.iter()
.map(|(job, averages)| (*job, spread(averages.clone()).p95))
.max_by_key(|(_, p95)| *p95)
else {
return CpuStep::Keep;
};
let need = heaviest.saturating_mul(HEADROOM);
let target = u32::try_from(need.div_ceil(1000))
.unwrap_or(u32::MAX)
.max(1);
if target >= current {
return CpuStep::Keep;
}
let to = target.max(current.saturating_sub(step)).max(1);
let mut reason = format!(
"{runs} runs at its {current} CPUs: the heaviest job, {}, averaged {} CPUs (p95); twice that is {target}",
job_named(job),
cores(heaviest)
);
if to != target {
let _ = write!(
reason,
"; a step toward {target}, at most {}% a change",
config.sizing.max_step
);
}
CpuStep::To(Cpus { to, reason })
}