1pub mod host;
7pub mod recent;
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use crate::api::Spread;
12use crate::api::{Basis, JobPeak};
13use crate::config::{self, Config};
14use crate::sizing::{GRANULE, History, Ran, spread};
15use recent::{Ending, RECENT_RUNS, Recent};
16
17pub const UNPREDICTABLE_FOR: u64 = 7 * 24 * 3600 * 1000;
21
22pub const OWN_RUNS: u64 = 2;
25
26pub const HIGH_WATER: u64 = 90;
28
29pub const CPU_GRANULE: u64 = 100;
31
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34pub struct Key {
35 pub repository: String,
37 pub workflow: Option<String>,
39 pub name: String,
41}
42
43impl Key {
44 pub fn of(run: &Ran) -> Key {
46 Key {
47 repository: run.repository.clone(),
48 workflow: run.workflow.clone(),
49 name: run.name.clone(),
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Copies {
58 Alone,
60 Several,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Prediction {
67 pub memory: u64,
69 pub basis: Basis,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct CpuPrediction {
76 pub cpu: u64,
78 pub basis: Basis,
80}
81
82#[derive(Debug, Clone, Default)]
84struct Averages {
85 sorted: Vec<u64>,
86 succeeded: u64,
87}
88
89impl Averages {
90 fn add(&mut self, average: u64, succeeded: bool) {
91 let at = self.sorted.partition_point(|known| *known <= average);
92 self.sorted.insert(at, average);
93 self.succeeded += u64::from(succeeded);
94 }
95
96 fn runs(&self) -> u64 {
97 self.sorted.len() as u64
98 }
99
100 fn p95(&self) -> u64 {
101 spread(self.sorted.clone()).p95
102 }
103
104 fn max(&self) -> u64 {
105 self.sorted.last().copied().unwrap_or(0)
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
111struct InProfile {
112 key: Key,
113 profile: String,
114}
115
116impl InProfile {
117 fn new(key: &Key, profile: &str) -> InProfile {
118 InProfile {
119 key: key.clone(),
120 profile: profile.to_owned(),
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
127pub struct Predictor {
128 margin: u32,
129 min_jobs: u32,
130 recent: Recent,
132 cpu_jobs: BTreeMap<InProfile, Averages>,
135 cpu_profiles: BTreeMap<String, Averages>,
136}
137
138impl Predictor {
139 pub fn new(rules: &config::Sizing) -> Predictor {
141 Predictor {
142 margin: rules.margin,
143 min_jobs: rules.min_jobs,
144 recent: Recent::new(RECENT_RUNS.max(u64::from(rules.min_jobs))),
145 cpu_jobs: BTreeMap::new(),
146 cpu_profiles: BTreeMap::new(),
147 }
148 }
149
150 pub fn read(
153 rules: &config::Sizing,
154 history: &History,
155 cap_of: impl Fn(&str) -> Option<u64>,
156 ) -> Predictor {
157 let mut predictor = Predictor::new(rules);
158 for (run, ending) in recent::rows(history) {
159 let cap = run
160 .memory_cap
161 .or_else(|| cap_of(&run.profile))
162 .unwrap_or(u64::MAX);
163 let at = recent::finished(run);
164 match ending {
165 Ending::Finished => predictor.learn(run, &run.profile, cap, at),
166 Ending::Cancelled => predictor.mark(run, &run.profile, cap, at),
167 }
168 }
169 predictor
170 }
171
172 pub fn learn(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
175 self.recent.read(run, profile, Ending::Finished, cap, at);
176 self.learn_cpu(run, profile);
177 }
178
179 pub fn learn_cpu(&mut self, run: &Ran, profile: &str) {
181 let Some(average) = run.cpu_average else {
182 return;
183 };
184 self.cpu_profiles
185 .entry(profile.to_owned())
186 .or_default()
187 .add(average, run.succeeded);
188 self.cpu_jobs
189 .entry(InProfile::new(&Key::of(run), profile))
190 .or_default()
191 .add(average, run.succeeded);
192 }
193
194 pub fn predict_cpu(&self, key: &Key, profile: &str, cap: u64) -> CpuPrediction {
198 let job = self.cpu_jobs.get(&InProfile::new(key, profile));
199 let min_jobs = u64::from(self.min_jobs);
200 let peers = self
201 .cpu_profiles
202 .get(profile)
203 .filter(|peers| peers.runs() >= min_jobs);
204 let (cpu, basis) = match (job.filter(|job| job.runs() > 0), peers) {
205 (Some(job), _) if job.runs() >= min_jobs => (
206 job.p95(),
207 Basis::Job {
208 runs: job.runs(),
209 peak: JobPeak::P95,
210 },
211 ),
212 (Some(job), Some(_)) if job.succeeded >= OWN_RUNS => (
213 job.max(),
214 Basis::Job {
215 runs: job.runs(),
216 peak: JobPeak::Largest,
217 },
218 ),
219 (Some(job), Some(peers)) => (
220 job.max().max(peers.p95()),
221 Basis::Profile { runs: peers.runs() },
222 ),
223 (None, Some(peers)) => (peers.max(), Basis::ProfileMax { runs: peers.runs() }),
224 _ => (cap, Basis::Cap),
225 };
226 CpuPrediction {
227 cpu: cpu
228 .div_ceil(CPU_GRANULE)
229 .saturating_mul(CPU_GRANULE)
230 .clamp(CPU_GRANULE, cap.max(CPU_GRANULE)),
231 basis,
232 }
233 }
234
235 pub fn mark(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
239 self.recent.read(run, profile, Ending::Cancelled, cap, at);
240 }
241
242 pub fn predict(
245 &self,
246 key: &Key,
247 profile: &str,
248 cap: u64,
249 now: u64,
250 copies: Copies,
251 ) -> Prediction {
252 let job = self.recent.job(key, profile);
253 if let Some(hold) = job.and_then(|job| job.held(now, Some(UNPREDICTABLE_FOR))) {
254 return Prediction {
255 memory: cap,
256 basis: Basis::Unpredictable {
257 until: hold.at.saturating_add(UNPREDICTABLE_FOR) / 1000,
258 },
259 };
260 }
261 let min_jobs = u64::from(self.min_jobs);
262 let peers = self.recent.profile(profile);
263 let peers_runs = peers.len() as u64;
264 let overran = job.and_then(|job| {
265 job.overruns(now, UNPREDICTABLE_FOR)
266 .fold(None, |most: Option<(u64, u64)>, overrun| {
267 let until = overrun.at.saturating_add(UNPREDICTABLE_FOR);
268 Some(most.map_or((overrun.peak, until), |(peak, last)| {
269 (peak.max(overrun.peak), last.max(until))
270 }))
271 })
272 });
273 let (memory, basis) = match (job, peers_runs >= min_jobs) {
274 (Some(job), _) if job.runs() == 0 => (cap, Basis::Cap),
276 (Some(job), _) if let Some((peak, until)) = overran => (
277 self.with_margin(job.max().max(peak)),
278 Basis::Overran {
279 until: until / 1000,
280 },
281 ),
282 (Some(job), _) if job.runs() >= min_jobs => (
283 self.with_margin(spread(job.peaks().to_vec()).p95),
284 Basis::Job {
285 runs: job.runs(),
286 peak: JobPeak::P95,
287 },
288 ),
289 (Some(job), true) if job.succeeded() >= OWN_RUNS && copies == Copies::Alone => (
290 self.with_margin(job.max()),
291 Basis::Job {
292 runs: job.runs(),
293 peak: JobPeak::Largest,
294 },
295 ),
296 (Some(job), true) => (
297 self.with_margin(job.max().max(spread(peers.to_vec()).p95)),
298 Basis::Profile { runs: peers_runs },
299 ),
300 (None, true) => (
302 self.with_margin(peers.last().copied().unwrap_or(0)),
303 Basis::ProfileMax { runs: peers_runs },
304 ),
305 _ => (cap, Basis::Cap),
306 };
307 Prediction {
308 memory: memory.min(cap),
309 basis,
310 }
311 }
312 fn with_margin(&self, peak: u64) -> u64 {
314 let memory = peak.saturating_add(peak.saturating_mul(u64::from(self.margin)) / 100);
315 memory
316 .div_ceil(GRANULE)
317 .saturating_mul(GRANULE)
318 .max(GRANULE)
319 }
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum Policy {
325 Caps,
327 Measured,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct Replay {
336 pub jobs: u64,
338 pub never_started: u64,
340 pub wait: Spread,
342 pub mean_wait: u64,
344 pub most_running: u64,
346 pub worst_overlap: u64,
351 pub makespan: u64,
353 pub under_predicted: u64,
355 pub held_back: u64,
357 pub cpu_held: u64,
359 pub worst_cpu: u64,
361}
362
363struct Running<'run> {
365 run: &'run Ran,
366 profile: &'run str,
367 ends: u64,
368 reserved: u64,
369 peak: u64,
370 cpu: u64,
372}
373
374pub fn simulate(
389 config: &Config,
390 memory_max: Option<u64>,
391 cpu_budget: Option<u64>,
392 jobs: &[Ran],
393 policy: Policy,
394 slots: &BTreeMap<String, u32>,
395) -> Replay {
396 let mirror = config.mirror_memory();
397 let budget = memory_max.map(|max| max.saturating_sub(mirror));
398 let mut queue: Vec<&Ran> = jobs
399 .iter()
400 .filter(|run| config.profiles.contains_key(&run.profile))
401 .filter(|run| matches!((run.started_at, run.finished_at), (Some(start), Some(end)) if end >= start))
402 .collect();
403 queue.sort_by_key(|run| run.queued_at);
404 let mut replayer = Replayer {
405 config,
406 budget,
407 cpu_budget,
408 policy,
409 slots,
410 predictor: Predictor::new(&config.sizing),
411 running: Vec::new(),
412 waits: Vec::new(),
413 most_running: 0,
414 worst_overlap: 0,
415 last_end: 0,
416 under_predicted: 0,
417 held_back: 0,
418 cpu_held: 0,
419 worst_cpu: 0,
420 };
421 let mut waiting: Vec<&Ran> = Vec::new();
422 let mut next = 0;
423 loop {
424 let arrival = queue.get(next).map(|run| run.queued_at);
425 let end = replayer.running.iter().map(|running| running.ends).min();
426 let Some(now) = [arrival, end].into_iter().flatten().min() else {
427 break;
428 };
429 replayer.finish(now);
430 replayer.rereserve(now);
431 while let Some(run) = queue.get(next).filter(|run| run.queued_at <= now) {
432 waiting.push(run);
433 next += 1;
434 }
435 let mut index = 0;
436 while let Some(run) = waiting.get(index).copied() {
437 if replayer.admit(run, &waiting, now) {
438 waiting.remove(index);
439 } else {
440 index += 1;
441 }
442 }
443 replayer.most_running = replayer.most_running.max(replayer.running.len() as u64);
444 let peaks: u64 = replayer.running.iter().map(|running| running.peak).sum();
445 replayer.worst_overlap = replayer.worst_overlap.max(peaks + mirror);
446 let cpu: u64 = replayer
447 .running
448 .iter()
449 .map(|running| running.run.cpu_average.unwrap_or(0))
450 .sum();
451 replayer.worst_cpu = replayer.worst_cpu.max(cpu);
452 }
453 let waits = replayer.waits;
454 let first = queue.first().map_or(0, |run| run.queued_at);
455 Replay {
456 jobs: queue.len() as u64,
457 never_started: waiting.len() as u64,
458 mean_wait: waits.iter().sum::<u64>() / (waits.len() as u64).max(1),
459 wait: spread(waits),
460 most_running: replayer.most_running,
461 worst_overlap: replayer.worst_overlap,
462 makespan: replayer.last_end.saturating_sub(first) / 1000,
463 under_predicted: replayer.under_predicted,
464 held_back: replayer.held_back,
465 cpu_held: replayer.cpu_held,
466 worst_cpu: replayer.worst_cpu,
467 }
468}
469
470struct Replayer<'run> {
472 config: &'run Config,
473 budget: Option<u64>,
474 cpu_budget: Option<u64>,
475 policy: Policy,
476 slots: &'run BTreeMap<String, u32>,
477 predictor: Predictor,
478 running: Vec<Running<'run>>,
479 waits: Vec<u64>,
480 most_running: u64,
481 worst_overlap: u64,
482 last_end: u64,
483 under_predicted: u64,
484 held_back: u64,
485 cpu_held: u64,
486 worst_cpu: u64,
487}
488
489impl<'run> Replayer<'run> {
490 fn finish(&mut self, now: u64) {
493 let (ended, running): (Vec<_>, Vec<_>) = std::mem::take(&mut self.running)
494 .into_iter()
495 .partition(|running| running.ends <= now);
496 self.running = running;
497 let mut ended = ended;
498 ended.sort_by_key(|ended| ended.ends);
499 for ended in ended {
500 let cap = ended
502 .run
503 .memory_cap
504 .unwrap_or(self.config.profiles[ended.profile].memory.0);
505 self.under_predicted += u64::from(ended.peak > ended.reserved);
506 let reserved = (self.policy == Policy::Measured).then_some(ended.reserved);
507 let run = Ran {
508 reserved,
509 ..ended.run.clone()
510 };
511 self.predictor.learn(&run, ended.profile, cap, ended.ends);
512 self.last_end = self.last_end.max(ended.ends);
513 }
514 }
515
516 fn rereserve(&mut self, now: u64) {
520 if self.policy != Policy::Measured {
521 return;
522 }
523 let cpus: Vec<u64> = self
524 .running
525 .iter()
526 .map(|running| self.cpu(&Key::of(running.run), running.profile))
527 .collect();
528 let mut seen = BTreeSet::new();
529 for (running, cpu) in self.running.iter_mut().zip(cpus) {
530 let key = Key::of(running.run);
531 let copies = if seen.insert(key.clone()) {
532 Copies::Alone
533 } else {
534 Copies::Several
535 };
536 let limits = &self.config.profiles[running.profile];
537 running.reserved = self
538 .predictor
539 .predict(&key, running.profile, limits.memory.0, now, copies)
540 .memory;
541 running.cpu = cpu;
542 }
543 }
544
545 fn cpu(&self, key: &Key, profile: &str) -> u64 {
548 let cap = u64::from(self.config.profiles[profile].cpus).saturating_mul(1000);
549 let cpu = match self.policy {
550 Policy::Caps => cap,
551 Policy::Measured => self.predictor.predict_cpu(key, profile, cap).cpu,
552 };
553 self.cpu_budget.map_or(cpu, |budget| cpu.min(budget))
554 }
555
556 fn taken(&self) -> u64 {
559 self.running
560 .iter()
561 .map(|running| match self.policy {
562 Policy::Caps => running.reserved,
563 Policy::Measured => running.reserved.max(running.peak),
564 })
565 .sum()
566 }
567
568 fn admit(&mut self, run: &'run Ran, waiting: &[&Ran], now: u64) -> bool {
572 let Some((profile, limits)) = self.config.profiles.get_key_value(&run.profile) else {
573 return false;
574 };
575 let slots = self.slots.get(profile).copied().unwrap_or(limits.slots);
576 let in_profile = self
577 .running
578 .iter()
579 .filter(|running| running.profile == profile.as_str())
580 .count();
581 if in_profile >= slots as usize {
582 return false;
583 }
584 let cap = limits.memory.0;
585 let running = |key: &Key| {
586 self.running
587 .iter()
588 .any(|running| Key::of(running.run) == *key)
589 };
590 let queued = |job: &Ran| {
592 let key = Key::of(job);
593 let waiting = waiting.iter().filter(|other| Key::of(other) == key).count();
594 let copies = if waiting > 1 || running(&key) {
595 Copies::Several
596 } else {
597 Copies::Alone
598 };
599 self.predictor
600 .predict(&key, profile, cap, now, copies)
601 .memory
602 };
603 let started = || {
605 let key = Key::of(run);
606 let copies = if running(&key) {
607 Copies::Several
608 } else {
609 Copies::Alone
610 };
611 self.predictor
612 .predict(&key, profile, cap, now, copies)
613 .memory
614 };
615 let (starting, reserved) = match self.policy {
616 Policy::Caps => (cap, cap),
617 Policy::Measured => (
618 waiting
619 .iter()
620 .filter(|job| job.repository == run.repository && job.profile == run.profile)
621 .map(|job| queued(job))
622 .max()
623 .unwrap_or(0)
624 .max(started()),
625 started(),
626 ),
627 };
628 if let Some(budget) = self.budget {
629 if self.taken() + starting > budget {
630 return false;
631 }
632 let peaks: u64 = self.running.iter().map(|running| running.peak).sum();
633 if self.policy == Policy::Measured
634 && peaks.saturating_mul(100) >= budget.saturating_mul(HIGH_WATER)
635 {
636 self.held_back += 1;
637 return false;
638 }
639 }
640 let cpu = self.cpu(&Key::of(run), profile);
641 if let Some(cpu_budget) = self.cpu_budget {
642 let starting = waiting
644 .iter()
645 .filter(|job| job.repository == run.repository && job.profile == run.profile)
646 .map(|job| self.cpu(&Key::of(job), profile))
647 .max()
648 .unwrap_or(0)
649 .max(cpu);
650 let taken: u64 = self.running.iter().map(|running| running.cpu).sum();
651 if taken + starting > cpu_budget {
652 self.cpu_held += 1;
653 return false;
654 }
655 }
656 let peak = run.memory_peak.min(cap);
658 let ran_for = run
659 .finished_at
660 .unwrap_or_default()
661 .saturating_sub(run.started_at.unwrap_or_default());
662 self.waits.push(now.saturating_sub(run.queued_at) / 1000);
663 self.running.push(Running {
664 run,
665 profile,
666 ends: now + ran_for,
667 reserved,
668 peak,
669 cpu,
670 });
671 true
672 }
673}
674
675#[cfg(test)]
676mod tests;