1use 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
18const RAISE_RUNS: u64 = 3;
21
22const AT_CAP: u64 = 95;
24
25const FAR_UNDER: u64 = 50;
27
28#[derive(Debug, Clone, Copy)]
30pub struct Context<'run> {
31 pub memory_max: Option<u64>,
33 pub cpus: Option<u64>,
35 pub window: Window,
37 pub last: &'run BTreeMap<String, u64>,
39 pub running: &'run BTreeMap<String, u32>,
41}
42
43pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151enum Lowering {
152 Allowed,
153 Held,
154}
155
156struct Budget {
158 caps: u64,
160 cpus: u64,
162 slots: BTreeMap<String, u32>,
164}
165
166impl Budget {
167 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 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
200struct Fresh {
203 start: u64,
205 jobs: u64,
206 wait: u64,
208 busy: u32,
209}
210
211impl Fresh {
212 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 fn wants_slots(&self) -> bool {
242 self.jobs >= RAISE_RUNS && (self.wait >= WAITED || self.busy >= BUSY)
243 }
244
245 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
262fn 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
290fn 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
298fn 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
308fn 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
337fn 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 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
376fn 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#[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 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
471fn 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
495struct Cpus {
497 to: u32,
498 reason: String,
499}
500
501#[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 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}