Skip to main content

runnerd_core/sizing/
auto.rs

1//! Auto sizing: which profiles' `memory`, `slots` and `cpus` the daemon changes now, from the advice, the runs since
2//! each profile's last change and the limits `[sizing]` and each profile set. Pure: the daemon reads the history and
3//! when each profile last changed, writes each change through the config check as `PUT /v1/config` does, and records
4//! it. Never lowers a profile a run met the cap of, never changes `memory` from fewer than `[sizing] min_jobs` jobs,
5//! never lowers within `[sizing] interval` of a profile's last change or raises within `raise_interval`, never
6//! leaves a profile outside its bounds, and sizes each profile against the budget the changes before it in the same
7//! look leave.
8
9use std::collections::BTreeMap;
10
11use crate::api::{Advice, ProfileSizing, Resize, Sizing, SizingChange, cores};
12use crate::config::{Admission, ByteSize, Config, Profile, cpu_budget};
13
14use super::{
15    BUSY, GRANULE, History, Ran, WAITED, Window, advise, busy, first_runner, spread, waits,
16};
17
18/// The fewest runs a raise of `slots` or `cpus` reads: the jobs queued since the profile's last change, or its runs
19/// at its current `cpus`. A wrong raise costs a wait or a slower run, not a kill, so it does not wait for `min_jobs`.
20const RAISE_RUNS: u64 = 3;
21
22/// Runs whose CPU peak came within this share of their cap, in percent, at p95, were held back by it.
23const AT_CAP: u64 = 95;
24
25/// `cpus` are lowered once the largest CPU peak plus the margin is at most this share of them, in percent.
26const FAR_UNDER: u64 = 50;
27
28/// What auto sizing reads besides the config and the history.
29#[derive(Debug, Clone, Copy)]
30pub struct Context<'run> {
31    /// The slice's `MemoryMax`; `None` when it is unlimited.
32    pub memory_max: Option<u64>,
33    /// The slice's CPUs, in millicores; `None` when they could not be read, and then `cpus` are never raised.
34    pub cpus: Option<u64>,
35    /// The history's window; `now` is when the changes are made.
36    pub window: Window,
37    /// When auto sizing last changed each profile, in seconds, by name, however long ago.
38    pub last: &'run BTreeMap<String, u64>,
39    /// How many runners each profile runs now, by name.
40    pub running: &'run BTreeMap<String, u32>,
41}
42
43/// The changes to make now, in order, one per profile at most. Empty unless `[sizing] auto` is on.
44///
45/// A change that lowers `memory` or `cpus` waits `[sizing] interval` after the profile's last change; one that only
46/// raises waits `raise_interval`. Each moves `memory` toward the advice, and `cpus` toward what its runs reach, by at
47/// most `[sizing] max_step` percent, `memory` within the profile's `min_memory` and `max_memory`, `cpus` within the
48/// slice's CPUs. A profile whose jobs waited for a slot, or whose slots were busy, since its last change gets one
49/// more slot when every profile's slots still fit `MemoryMax`: at their caps under `caps` admission, at their jobs'
50/// p95 peaks under `measured`; at most `max_slots` when set, and never under what runs. Under `caps` a lowered
51/// `memory` adds the slots the advice gives. Each change is sized as if the ones before it were written: they take
52/// budget the later ones cannot.
53pub fn changes(config: &Config, history: &History, context: Context<'_>) -> Vec<SizingChange> {
54    let rules = &config.sizing;
55    if !rules.auto {
56        return Vec::new();
57    }
58    let window = context.window;
59    let sizing = advise(config, context.memory_max, history, window);
60    let now = window.now / 1000;
61    let mut budget = Budget::new(config, &sizing.profiles);
62    let mut changes = Vec::new();
63    for advised in &sizing.profiles {
64        let Some(profile) = config.profiles.get(&advised.name) else {
65            continue;
66        };
67        let last = context.last.get(&advised.name).copied();
68        let since = last.map_or(u64::MAX, |at| now.saturating_sub(at));
69        if since < rules.raise_after().as_secs() {
70            continue;
71        }
72        let lowering = if since >= rules.interval.as_secs() {
73            Lowering::Allowed
74        } else {
75            Lowering::Held
76        };
77        let fresh = Fresh::read(history, advised, last, window);
78        let mut reasons = Vec::new();
79        let memory = memory(
80            config,
81            profile,
82            advised.advice,
83            met_cap(history, &advised.name),
84            lowering,
85        );
86        let running = context.running.get(&advised.name).copied().unwrap_or(0);
87        let slots = match config.host.admission {
88            Admission::Caps => {
89                caps_slots(profile, advised, &fresh, memory, &context, &budget, running)
90            }
91            Admission::Measured => {
92                measured_slots(config, &sizing.profiles, advised, &fresh, &context, &budget)
93            }
94        };
95        let (slots, cpus) = cpus_and_slots(
96            config,
97            profile,
98            advised,
99            history,
100            fresh.start,
101            &context,
102            &budget,
103            slots,
104            lowering,
105        );
106        if memory != profile.memory.0 {
107            reasons.push(memory_reason(
108                &sizing,
109                advised,
110                memory,
111                slots,
112                config.sizing.max_step,
113            ));
114        }
115        // Under caps a lowered memory's slots are its line's.
116        let advised_slots = config.host.admission == Admission::Caps && memory < profile.memory.0;
117        if slots > profile.slots && !advised_slots {
118            reasons.push(fresh.waited(last.is_some()));
119        }
120        if let Some(cpus) = &cpus {
121            reasons.push(cpus.reason.clone());
122        }
123        let from = Resize {
124            memory: profile.memory.0,
125            slots: profile.slots,
126            cpus: cpus.as_ref().map(|_| profile.cpus),
127        };
128        let to = Resize {
129            memory,
130            slots,
131            cpus: cpus.map(|cpus| cpus.to),
132        };
133        if to == from || slots == 0 || !within(profile, from, to) {
134            continue;
135        }
136        budget.take(&advised.name, profile, from, to);
137        changes.push(SizingChange {
138            at: now,
139            profile: advised.name.clone(),
140            from,
141            to,
142            reason: reasons.join("; "),
143            uncertain: false,
144        });
145    }
146    changes
147}
148
149/// Whether a change may lower `memory` or `cpus`: only `interval` after the profile's last change.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum Lowering {
152    Allowed,
153    Held,
154}
155
156/// The budget as the changes picked so far leave it.
157struct Budget {
158    /// `slots × memory` over all profiles, with the mirror.
159    caps: u64,
160    /// `slots × cpus` over all profiles, in millicores.
161    cpus: u64,
162    /// Each profile's slots.
163    slots: BTreeMap<String, u32>,
164}
165
166impl Budget {
167    /// Takes what a change of `profile` from `from` to `to` adds, and gives back what it frees.
168    fn take(&mut self, name: &str, profile: &Profile, from: Resize, to: Resize) {
169        self.caps = self
170            .caps
171            .saturating_sub(from.memory.saturating_mul(u64::from(from.slots)))
172            .saturating_add(to.memory.saturating_mul(u64::from(to.slots)));
173        let millicores = |resize: Resize| {
174            u64::from(resize.cpus.unwrap_or(profile.cpus)) * 1000 * u64::from(resize.slots)
175        };
176        self.cpus = self
177            .cpus
178            .saturating_sub(millicores(from))
179            .saturating_add(millicores(to));
180        self.slots.insert(name.to_owned(), to.slots);
181    }
182
183    /// The budget the config takes as it is.
184    fn new(config: &Config, profiles: &[ProfileSizing]) -> Budget {
185        Budget {
186            caps: config.memory_caps().map_or(u64::MAX, |caps| caps.0),
187            cpus: config
188                .profiles
189                .values()
190                .map(|profile| u64::from(profile.cpus) * 1000 * u64::from(profile.slots))
191                .sum(),
192            slots: profiles
193                .iter()
194                .map(|advised| (advised.name.clone(), advised.slots))
195                .collect(),
196        }
197    }
198}
199
200/// What the jobs queued since a profile's last change say about its slots: a raise reads only them, so one slot
201/// that fixed a month's waits is not followed by another every `raise_interval`.
202struct Fresh {
203    /// When the evidence starts, in milliseconds: the last change, or the window's start.
204    start: u64,
205    jobs: u64,
206    /// Seconds from queued to started, p95.
207    wait: u64,
208    busy: u32,
209}
210
211impl Fresh {
212    /// The profile's jobs queued since `last` (seconds), or in the whole window without a last change.
213    fn read(
214        history: &History,
215        advised: &ProfileSizing,
216        last: Option<u64>,
217        window: Window,
218    ) -> Fresh {
219        let start = last.map_or(window.since, |at| at.saturating_mul(1000).max(window.since));
220        let jobs: Vec<_> = history
221            .jobs
222            .iter()
223            .filter(|job| job.profile == advised.name && job.queued_at >= start)
224            .collect();
225        Fresh {
226            start,
227            jobs: jobs.len() as u64,
228            wait: waits(&jobs).p95,
229            busy: busy(
230                history,
231                &advised.name,
232                advised.slots,
233                first_runner(history, start),
234                window.now,
235            ),
236        }
237    }
238
239    /// Whether another slot would help: enough jobs, which waited a minute at p95 or kept the slots busy half the
240    /// time.
241    fn wants_slots(&self) -> bool {
242        self.jobs >= RAISE_RUNS && (self.wait >= WAITED || self.busy >= BUSY)
243    }
244
245    /// Why a slot is added.
246    fn waited(&self, changed: bool) -> String {
247        format!(
248            "{} jobs {}waited {}s for a slot (p95) and slots were busy {}%; one more fits the slice beside the \
249             other profiles'",
250            self.jobs,
251            if changed {
252                "since its last change "
253            } else {
254                ""
255            },
256            self.wait,
257            self.busy
258        )
259    }
260}
261
262/// Why `memory` changes, as the advice line says it of the `memory` and `slots` this change writes, not the
263/// advice's own: a step of `max_step` stops short of the advice's memory, and the slots that fit follow the step.
264/// A step short of the advice says what it goes toward.
265fn memory_reason(
266    sizing: &Sizing,
267    advised: &ProfileSizing,
268    memory: u64,
269    slots: u32,
270    max_step: u32,
271) -> String {
272    let made = ProfileSizing {
273        advice: match advised.advice {
274            Advice::Lower { .. } => Advice::Lower { memory, slots },
275            Advice::Raise { .. } => Advice::Raise { memory, slots },
276            other => other,
277        },
278        ..advised.clone()
279    };
280    let line = sizing.memory_line(&made);
281    match advised.advice.change() {
282        Some(advice) if advice.memory != memory => format!(
283            "{line}; a step toward {}, at most {max_step}% a change",
284            ByteSize(advice.memory)
285        ),
286        _ => line,
287    }
288}
289
290/// Whether `to` keeps the profile within its bounds: `memory` within `min_memory` and `max_memory`, and `slots` at
291/// most `max_slots` when they grow.
292fn within(profile: &Profile, from: Resize, to: Resize) -> bool {
293    profile.min_memory.is_none_or(|min| to.memory >= min.0)
294        && profile.max_memory.is_none_or(|max| to.memory <= max.0)
295        && (to.slots <= from.slots || profile.max_slots.is_none_or(|max| to.slots <= max))
296}
297
298/// Whether a run of the profile in the history, cancelled ones too, was killed for memory or met its cap.
299fn met_cap(history: &History, profile: &str) -> bool {
300    history
301        .jobs
302        .iter()
303        .chain(&history.cancelled)
304        .filter(|job| job.profile == profile)
305        .any(|job| job.oom_killed || job.memory_cap.is_some_and(|cap| job.met_cap(cap)))
306}
307
308/// The profile's next `memory`: the advice's, at most `max_step` percent away and within its bounds, in steps of
309/// `GRANULE` toward the advice. A lowering waits while a run met the cap, and while `lowering` holds it.
310fn memory(
311    config: &Config,
312    profile: &Profile,
313    advice: Advice,
314    met_cap: bool,
315    lowering: Lowering,
316) -> u64 {
317    let current = profile.memory.0;
318    let step = current.saturating_mul(u64::from(config.sizing.max_step)) / 100;
319    match advice {
320        Advice::Lower { memory, .. } if !met_cap && lowering == Lowering::Allowed => {
321            let floor = current
322                .saturating_sub(step)
323                .div_ceil(GRANULE)
324                .saturating_mul(GRANULE);
325            let bound = profile.min_memory.map_or(0, |min| min.0);
326            memory.max(floor).max(bound).min(current)
327        }
328        Advice::Raise { memory, .. } => {
329            let ceiling = current.saturating_add(step) / GRANULE * GRANULE;
330            let bound = profile.max_memory.map_or(u64::MAX, |max| max.0);
331            memory.min(ceiling).min(bound).max(current)
332        }
333        _ => current,
334    }
335}
336
337/// Under `caps`: as many slots as the advice gives for the new memory. A lowering whose jobs waited adds as many as
338/// the memory the slots take now holds, up to `max_slots`; a raise keeps as many as the slice holds beside the other
339/// profiles, and 0, no change, while more than that run. With the memory kept, one more slot when its jobs waited
340/// since the last change and its cap fits `MemoryMax` beside every other cap, up to `max_slots`. Otherwise the same.
341fn caps_slots(
342    profile: &Profile,
343    advised: &ProfileSizing,
344    fresh: &Fresh,
345    memory: u64,
346    context: &Context<'_>,
347    budget: &Budget,
348    running: u32,
349) -> u32 {
350    let current = profile.slots;
351    let max_slots = profile.max_slots.unwrap_or(u32::MAX);
352    let fit = |room: u64| u32::try_from(room / memory.max(1)).unwrap_or(u32::MAX);
353    if memory < profile.memory.0 && advised.wants_slots {
354        let share = profile.memory.0.saturating_mul(u64::from(current));
355        return fit(share).min(max_slots).max(current);
356    }
357    let Some(memory_max) = context.memory_max else {
358        return current;
359    };
360    if memory > profile.memory.0 {
361        let share = profile.memory.0.saturating_mul(u64::from(current));
362        let room = memory_max.saturating_sub(budget.caps.saturating_sub(share));
363        // Fewer slots than run would leave the running ones over the budget: wait for some to finish.
364        let slots = fit(room).min(current);
365        return if slots < running { 0 } else { slots };
366    }
367    if fresh.wants_slots()
368        && current < max_slots
369        && budget.caps.saturating_add(memory) <= memory_max
370    {
371        return current + 1;
372    }
373    current
374}
375
376/// Under `measured`: one more slot when its jobs waited since the last change and every profile's slots, this one's
377/// with the new one, fit the slice beside the mirror at their jobs' p95 peaks (at its cap, a profile with fewer than
378/// `min_jobs` jobs), up to `max_slots`, with the slots the changes before it gave. Without `max_slots` or a
379/// `MemoryMax`, nothing bounds slots, so none is added. Otherwise the same.
380fn measured_slots(
381    config: &Config,
382    profiles: &[ProfileSizing],
383    advised: &ProfileSizing,
384    fresh: &Fresh,
385    context: &Context<'_>,
386    budget: &Budget,
387) -> u32 {
388    let current = advised.slots;
389    let max_slots = config
390        .profiles
391        .get(&advised.name)
392        .and_then(|profile| profile.max_slots);
393    if !fresh.wants_slots() || max_slots.is_some_and(|max| max <= current) {
394        return current;
395    }
396    let Some(memory_max) = context.memory_max else {
397        return if max_slots.is_some() {
398            current + 1
399        } else {
400            current
401        };
402    };
403    let peaks: u64 = profiles
404        .iter()
405        .map(|other| {
406            let peak = if matches!(other.advice, Advice::TooFew { .. }) {
407                other.memory
408            } else {
409                other.peaks.memory.p95
410            };
411            let slots = if other.name == advised.name {
412                current + 1
413            } else {
414                budget
415                    .slots
416                    .get(&other.name)
417                    .copied()
418                    .unwrap_or(other.slots)
419            };
420            peak.saturating_mul(u64::from(slots))
421        })
422        .fold(config.mirror_memory(), u64::saturating_add);
423    if peaks <= memory_max {
424        current + 1
425    } else {
426        current
427    }
428}
429
430/// The profile's next `cpus` (`None` to keep them) and `slots`, from the `slots` memory and waits chose: under `caps`
431/// with a CPU budget, slots past what the budget holds at the new `cpus` are dropped, and the `cpus` sized again at
432/// the slots that stay, so an extra slot the budget refuses does not also refuse a CPU raise that fits without it.
433#[expect(
434    clippy::too_many_arguments,
435    reason = "each is one thing a CPU change reads; a struct for them would be read here only"
436)]
437fn cpus_and_slots(
438    config: &Config,
439    profile: &Profile,
440    advised: &ProfileSizing,
441    history: &History,
442    since: u64,
443    context: &Context<'_>,
444    budget: &Budget,
445    slots: u32,
446    lowering: Lowering,
447) -> (u32, Option<Cpus>) {
448    let size = |slots: u32| {
449        cpus(
450            config, profile, advised, history, since, context, budget, slots, lowering,
451        )
452    };
453    // Slots the CPU budget cannot hold would wait for CPU anyway, and their waits would ask for more.
454    let fit = |slots: u32, cpus: &Option<Cpus>| {
455        let cpus = cpus.as_ref().map_or(profile.cpus, |cpus| cpus.to);
456        if slots > profile.slots {
457            cpu_slots(config, profile, context, budget, cpus)
458                .map_or(slots, |fit| slots.min(fit.max(profile.slots)))
459        } else {
460            slots
461        }
462    };
463    let mut cpus = size(slots);
464    let fitted = fit(slots, &cpus);
465    if fitted < slots {
466        cpus = size(fitted);
467    }
468    (fit(fitted, &cpus), cpus)
469}
470
471/// Under `caps` admission with a CPU budget, the most slots of the profile at `cpus` each that fit the budget beside
472/// every other profile's `slots × cpus`, as the changes before it left them; none more than it has when the slice's
473/// CPUs are unknown. `None` when no CPU budget bounds slots.
474fn cpu_slots(
475    config: &Config,
476    profile: &Profile,
477    context: &Context<'_>,
478    budget: &Budget,
479    cpus: u32,
480) -> Option<u32> {
481    let (Admission::Caps, Some(overcommit)) = (config.host.admission, config.host.cpu_overcommit)
482    else {
483        return None;
484    };
485    let Some(slice) = context.cpus else {
486        return Some(profile.slots);
487    };
488    let others = budget
489        .cpus
490        .saturating_sub(u64::from(profile.cpus) * 1000 * u64::from(profile.slots));
491    let room = cpu_budget(slice, overcommit).saturating_sub(others);
492    Some(u32::try_from(room / (u64::from(cpus.max(1)) * 1000)).unwrap_or(u32::MAX))
493}
494
495/// A new `cpus` and why.
496struct Cpus {
497    to: u32,
498    reason: String,
499}
500
501/// The profile's next `cpus`, from the CPU peaks of its runs whose container had them; `None` to keep them.
502///
503/// With `RAISE_RUNS` such runs queued since `since` (ms), the last change, peaking within `AT_CAP` percent of the cap at p95, `max_step` percent more, at least
504/// 1, up to the slice's CPUs; under `caps` admission with a CPU budget, only as many as every profile's `slots ×
505/// cpus` fit in it, at the `slots` this change gives. With `min_jobs` runs whose largest peak plus the margin is at
506/// most `FAR_UNDER` percent of the cap, down to that, rounded up, by at most `max_step` percent, while `lowering`
507/// allows it.
508#[expect(
509    clippy::too_many_arguments,
510    reason = "each is one thing a CPU change reads; a struct for them would be read here only"
511)]
512fn cpus(
513    config: &Config,
514    profile: &Profile,
515    advised: &ProfileSizing,
516    history: &History,
517    since: u64,
518    context: &Context<'_>,
519    budget: &Budget,
520    slots: u32,
521    lowering: Lowering,
522) -> Option<Cpus> {
523    let current = profile.cpus;
524    let cap = u64::from(current) * 1000;
525    let at_cap: Vec<&Ran> = history
526        .jobs
527        .iter()
528        .filter(|job| job.profile == advised.name && job.cpu_cap == Some(cap))
529        .collect();
530    let step = (current.saturating_mul(config.sizing.max_step) / 100).max(1);
531    // A raise reads only runs since the last change: runs at a cap the profile had before, and went back to by a
532    // lowering or an undo, would raise it again with nothing new.
533    let recent: Vec<u64> = at_cap
534        .iter()
535        .filter(|job| job.queued_at >= since)
536        .map(|job| job.cpu_peak)
537        .collect();
538    let (runs, p95) = (recent.len() as u64, spread(recent).p95);
539    if runs >= RAISE_RUNS && p95.saturating_mul(100) >= cap.saturating_mul(AT_CAP) {
540        let slice = context.cpus?;
541        let mut to = current
542            .saturating_add(step)
543            .min(u32::try_from(slice / 1000).unwrap_or(u32::MAX));
544        if let (Admission::Caps, Some(overcommit)) =
545            (config.host.admission, config.host.cpu_overcommit)
546        {
547            let others = budget
548                .cpus
549                .saturating_sub(cap.saturating_mul(u64::from(profile.slots)));
550            let room = cpu_budget(slice, overcommit).saturating_sub(others);
551            let fit = room / 1000 / u64::from(slots.max(1));
552            to = to.min(u32::try_from(fit).unwrap_or(u32::MAX));
553        }
554        return (to > current).then(|| Cpus {
555            to,
556            reason: format!(
557                "{runs} runs at its {current} CPUs peaked at {} CPUs (p95), at the cap",
558                cores(p95)
559            ),
560        });
561    }
562    let runs = at_cap.len() as u64;
563    if lowering == Lowering::Held || runs < u64::from(config.sizing.min_jobs) {
564        return None;
565    }
566    let largest = at_cap.iter().map(|job| job.cpu_peak).max().unwrap_or(0);
567    let need =
568        largest.saturating_add(largest.saturating_mul(u64::from(config.sizing.margin)) / 100);
569    if need.saturating_mul(100) > cap.saturating_mul(FAR_UNDER) {
570        return None;
571    }
572    let to = u32::try_from(need.div_ceil(1000))
573        .unwrap_or(u32::MAX)
574        .max(current.saturating_sub(step))
575        .max(1);
576    (to < current).then(|| Cpus {
577        to,
578        reason: format!(
579            "{runs} runs at its {current} CPUs peaked at {} CPUs at most; + {}% is {}",
580            cores(largest),
581            config.sizing.margin,
582            cores(need)
583        ),
584    })
585}