runnerd_core/sizing/
auto.rs1use std::collections::BTreeMap;
8
9use crate::api::{Advice, ProfileSizing, Resize, SizingChange};
10use crate::config::{Admission, Config, Profile};
11
12use super::{GRANULE, History, Window, advise};
13
14#[derive(Debug, Clone, Copy)]
16pub struct Context<'run> {
17 pub memory_max: Option<u64>,
19 pub window: Window,
21 pub last: &'run BTreeMap<String, u64>,
23 pub running: &'run BTreeMap<String, u32>,
25}
26
27pub fn changes(config: &Config, history: &History, context: Context<'_>) -> Vec<SizingChange> {
35 let rules = &config.sizing;
36 if !rules.auto {
37 return Vec::new();
38 }
39 let window = context.window;
40 let sizing = advise(config, context.memory_max, history, window);
41 let now = window.now / 1000;
42 let interval = rules.interval.as_secs();
43 let mut budget = Budget {
44 caps: config.memory_caps().map_or(u64::MAX, |caps| caps.0),
45 slots: sizing
46 .profiles
47 .iter()
48 .map(|advised| (advised.name.clone(), advised.slots))
49 .collect(),
50 };
51 let mut changes = Vec::new();
52 for advised in &sizing.profiles {
53 let Some(profile) = config.profiles.get(&advised.name) else {
54 continue;
55 };
56 if context
57 .last
58 .get(&advised.name)
59 .is_some_and(|at| now < at.saturating_add(interval))
60 {
61 continue;
62 }
63 let from = Resize {
64 memory: profile.memory.0,
65 slots: profile.slots,
66 };
67 let mut reasons = Vec::new();
68 let memory = memory(
69 config,
70 profile,
71 advised.advice,
72 met_cap(history, &advised.name),
73 );
74 if memory != from.memory {
75 reasons.push(sizing.memory_line(advised));
76 }
77 let running = context.running.get(&advised.name).copied().unwrap_or(0);
78 let slots = match config.host.admission {
79 Admission::Caps => caps_slots(profile, advised, memory, &context, &budget, running),
80 Admission::Measured => {
81 let slots = measured_slots(config, &sizing.profiles, advised, &context, &budget);
82 if slots > from.slots {
83 reasons.push(format!(
84 "jobs waited {}s for a slot (p95) and slots were busy {}%; {slots} slots at their jobs' \
85 p95 peaks fit the slice beside the other profiles'",
86 advised.wait.p95, advised.busy
87 ));
88 }
89 slots
90 }
91 };
92 let to = Resize { memory, slots };
93 if to == from || slots == 0 || !within(profile, from, to) {
94 continue;
95 }
96 budget.caps = budget
97 .caps
98 .saturating_sub(from.memory.saturating_mul(u64::from(from.slots)))
99 .saturating_add(to.memory.saturating_mul(u64::from(to.slots)));
100 budget.slots.insert(advised.name.clone(), to.slots);
101 changes.push(SizingChange {
102 at: now,
103 profile: advised.name.clone(),
104 from,
105 to,
106 reason: reasons.join("; "),
107 uncertain: false,
108 });
109 }
110 changes
111}
112
113struct Budget {
115 caps: u64,
117 slots: BTreeMap<String, u32>,
119}
120
121fn within(profile: &Profile, from: Resize, to: Resize) -> bool {
124 profile.min_memory.is_none_or(|min| to.memory >= min.0)
125 && profile.max_memory.is_none_or(|max| to.memory <= max.0)
126 && (to.slots <= from.slots || profile.max_slots.is_some_and(|max| to.slots <= max))
127}
128
129fn met_cap(history: &History, profile: &str) -> bool {
131 history
132 .jobs
133 .iter()
134 .chain(&history.cancelled)
135 .filter(|job| job.profile == profile)
136 .any(|job| job.oom_killed || job.memory_cap.is_some_and(|cap| job.met_cap(cap)))
137}
138
139fn memory(config: &Config, profile: &Profile, advice: Advice, met_cap: bool) -> u64 {
142 let current = profile.memory.0;
143 let step = current.saturating_mul(u64::from(config.sizing.max_step)) / 100;
144 match advice {
145 Advice::Lower { memory, .. } if !met_cap => {
146 let floor = current
147 .saturating_sub(step)
148 .div_ceil(GRANULE)
149 .saturating_mul(GRANULE);
150 let bound = profile.min_memory.map_or(0, |min| min.0);
151 memory.max(floor).max(bound).min(current)
152 }
153 Advice::Raise { memory, .. } => {
154 let ceiling = current.saturating_add(step) / GRANULE * GRANULE;
155 let bound = profile.max_memory.map_or(u64::MAX, |max| max.0);
156 memory.min(ceiling).min(bound).max(current)
157 }
158 _ => current,
159 }
160}
161
162fn caps_slots(
166 profile: &Profile,
167 advised: &ProfileSizing,
168 memory: u64,
169 context: &Context<'_>,
170 budget: &Budget,
171 running: u32,
172) -> u32 {
173 let current = profile.slots;
174 let fit = |room: u64| u32::try_from(room / memory.max(1)).unwrap_or(u32::MAX);
175 if memory < profile.memory.0 && advised.wants_slots {
176 let share = profile.memory.0.saturating_mul(u64::from(current));
177 return fit(share)
178 .min(profile.max_slots.unwrap_or(current))
179 .max(current);
180 }
181 if memory > profile.memory.0 {
182 let Some(memory_max) = context.memory_max else {
183 return current;
184 };
185 let share = profile.memory.0.saturating_mul(u64::from(current));
186 let room = memory_max.saturating_sub(budget.caps.saturating_sub(share));
187 let slots = fit(room).min(current);
189 return if slots < running { 0 } else { slots };
190 }
191 current
192}
193
194fn measured_slots(
198 config: &Config,
199 profiles: &[ProfileSizing],
200 advised: &ProfileSizing,
201 context: &Context<'_>,
202 budget: &Budget,
203) -> u32 {
204 let current = advised.slots;
205 let wants = advised.wants_slots && !matches!(advised.advice, Advice::TooFew { .. });
206 let Some(max_slots) = config
207 .profiles
208 .get(&advised.name)
209 .and_then(|profile| profile.max_slots)
210 .filter(|max| wants && *max > current)
211 else {
212 return current;
213 };
214 let Some(memory_max) = context.memory_max else {
215 return (current + 1).min(max_slots);
216 };
217 let peaks: u64 = profiles
218 .iter()
219 .map(|other| {
220 let peak = if matches!(other.advice, Advice::TooFew { .. }) {
221 other.memory
222 } else {
223 other.peaks.memory.p95
224 };
225 let slots = if other.name == advised.name {
226 current + 1
227 } else {
228 budget
229 .slots
230 .get(&other.name)
231 .copied()
232 .unwrap_or(other.slots)
233 };
234 peak.saturating_mul(u64::from(slots))
235 })
236 .fold(config.mirror_memory(), u64::saturating_add);
237 if peaks <= memory_max {
238 current + 1
239 } else {
240 current
241 }
242}