1pub mod host;
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::api::Spread;
11use crate::api::{Basis, JobPeak};
12use crate::config::{self, Config};
13use crate::sizing::{GRANULE, Ran, spread};
14
15pub const UNPREDICTABLE_FOR: u64 = 7 * 24 * 3600 * 1000;
18
19pub const OWN_RUNS: u64 = 2;
22
23pub const HIGH_WATER: u64 = 90;
25
26pub const CPU_GRANULE: u64 = 100;
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
31pub struct Key {
32 pub repository: String,
34 pub workflow: Option<String>,
36 pub name: String,
38}
39
40impl Key {
41 pub fn of(run: &Ran) -> Key {
43 Key {
44 repository: run.repository.clone(),
45 workflow: run.workflow.clone(),
46 name: run.name.clone(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Copies {
55 Alone,
57 Several,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Prediction {
64 pub memory: u64,
66 pub basis: Basis,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct CpuPrediction {
73 pub cpu: u64,
75 pub basis: Basis,
77}
78
79#[derive(Debug, Clone, Default)]
81struct Averages {
82 sorted: Vec<u64>,
83 succeeded: u64,
84}
85
86impl Averages {
87 fn add(&mut self, average: u64, succeeded: bool) {
88 let at = self.sorted.partition_point(|known| *known <= average);
89 self.sorted.insert(at, average);
90 self.succeeded += u64::from(succeeded);
91 }
92
93 fn runs(&self) -> u64 {
94 self.sorted.len() as u64
95 }
96
97 fn p95(&self) -> u64 {
98 spread(self.sorted.clone()).p95
99 }
100
101 fn max(&self) -> u64 {
102 self.sorted.last().copied().unwrap_or(0)
103 }
104}
105
106#[derive(Debug, Clone, Default)]
109struct Peaks {
110 sorted: Vec<u64>,
111 succeeded: u64,
113 unpredictable_until: u64,
114 overran_until: u64,
115 overran_peak: u64,
117}
118
119impl Peaks {
120 fn add(&mut self, peak: u64) {
121 let at = self.sorted.partition_point(|known| *known <= peak);
122 self.sorted.insert(at, peak);
123 }
124
125 fn runs(&self) -> u64 {
126 self.sorted.len() as u64
127 }
128
129 fn p95(&self) -> u64 {
130 spread(self.sorted.clone()).p95
131 }
132
133 fn max(&self) -> u64 {
134 self.sorted.last().copied().unwrap_or(0)
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
140struct InProfile {
141 key: Key,
142 profile: String,
143}
144
145impl InProfile {
146 fn new(key: &Key, profile: &str) -> InProfile {
147 InProfile {
148 key: key.clone(),
149 profile: profile.to_owned(),
150 }
151 }
152}
153
154#[derive(Debug, Clone)]
156pub struct Predictor {
157 margin: u32,
158 min_jobs: u32,
159 jobs: BTreeMap<InProfile, Peaks>,
162 profiles: BTreeMap<String, Peaks>,
163 cpu_jobs: BTreeMap<InProfile, Averages>,
166 cpu_profiles: BTreeMap<String, Averages>,
167}
168
169impl Predictor {
170 pub fn new(rules: &config::Sizing) -> Predictor {
172 Predictor {
173 margin: rules.margin,
174 min_jobs: rules.min_jobs,
175 jobs: BTreeMap::new(),
176 profiles: BTreeMap::new(),
177 cpu_jobs: BTreeMap::new(),
178 cpu_profiles: BTreeMap::new(),
179 }
180 }
181
182 pub fn learn(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
184 self.mark(run, profile, cap, at);
185 let peak = run.memory_peak;
186 if let Some(job) = self.jobs.get_mut(&InProfile::new(&Key::of(run), profile)) {
187 job.add(peak);
188 job.succeeded += u64::from(run.succeeded);
189 }
190 self.profiles
191 .entry(profile.to_owned())
192 .or_default()
193 .add(peak);
194 self.learn_cpu(run, profile);
195 }
196
197 pub fn learn_cpu(&mut self, run: &Ran, profile: &str) {
200 let Some(average) = run.cpu_average else {
201 return;
202 };
203 self.cpu_profiles
204 .entry(profile.to_owned())
205 .or_default()
206 .add(average, run.succeeded);
207 self.cpu_jobs
208 .entry(InProfile::new(&Key::of(run), profile))
209 .or_default()
210 .add(average, run.succeeded);
211 }
212
213 pub fn predict_cpu(&self, key: &Key, profile: &str, cap: u64) -> CpuPrediction {
217 let job = self.cpu_jobs.get(&InProfile::new(key, profile));
218 let min_jobs = u64::from(self.min_jobs);
219 let peers = self
220 .cpu_profiles
221 .get(profile)
222 .filter(|peers| peers.runs() >= min_jobs);
223 let (cpu, basis) = match (job.filter(|job| job.runs() > 0), peers) {
224 (Some(job), _) if job.runs() >= min_jobs => (
225 job.p95(),
226 Basis::Job {
227 runs: job.runs(),
228 peak: JobPeak::P95,
229 },
230 ),
231 (Some(job), Some(_)) if job.succeeded >= OWN_RUNS => (
232 job.max(),
233 Basis::Job {
234 runs: job.runs(),
235 peak: JobPeak::Largest,
236 },
237 ),
238 (Some(job), Some(peers)) => (
239 job.max().max(peers.p95()),
240 Basis::Profile { runs: peers.runs() },
241 ),
242 (None, Some(peers)) => (peers.max(), Basis::ProfileMax { runs: peers.runs() }),
243 _ => (cap, Basis::Cap),
244 };
245 CpuPrediction {
246 cpu: cpu
247 .div_ceil(CPU_GRANULE)
248 .saturating_mul(CPU_GRANULE)
249 .clamp(CPU_GRANULE, cap.max(CPU_GRANULE)),
250 basis,
251 }
252 }
253
254 pub fn mark(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
258 let peak = run.memory_peak;
259 let job = self
260 .jobs
261 .entry(InProfile::new(&Key::of(run), profile))
262 .or_default();
263 if run.met_cap(cap) {
264 job.unpredictable_until = job.unpredictable_until.max(at + UNPREDICTABLE_FOR);
265 }
266 if run.reserved.is_some_and(|reserved| peak > reserved) {
267 job.overran_until = job.overran_until.max(at + UNPREDICTABLE_FOR);
268 job.overran_peak = job.overran_peak.max(peak);
269 }
270 }
271
272 pub fn predict(
275 &self,
276 key: &Key,
277 profile: &str,
278 cap: u64,
279 now: u64,
280 copies: Copies,
281 ) -> Prediction {
282 let job = self.jobs.get(&InProfile::new(key, profile));
283 if let Some(until) = job
284 .map(|job| job.unpredictable_until)
285 .filter(|until| *until > now)
286 {
287 return Prediction {
288 memory: cap,
289 basis: Basis::Unpredictable {
290 until: until / 1000,
291 },
292 };
293 }
294 let min_jobs = u64::from(self.min_jobs);
295 let (memory, basis) = match (job, self.profiles.get(profile)) {
296 (Some(job), _) if job.runs() == 0 => (cap, Basis::Cap),
298 (Some(job), _) if job.overran_until > now => (
299 self.with_margin(job.max().max(job.overran_peak)),
300 Basis::Overran {
301 until: job.overran_until / 1000,
302 },
303 ),
304 (Some(job), _) if job.runs() >= min_jobs => (
305 self.with_margin(job.p95()),
306 Basis::Job {
307 runs: job.runs(),
308 peak: JobPeak::P95,
309 },
310 ),
311 (Some(job), Some(peers))
312 if peers.runs() >= min_jobs
313 && job.succeeded >= OWN_RUNS
314 && copies == Copies::Alone =>
315 {
316 (
317 self.with_margin(job.max()),
318 Basis::Job {
319 runs: job.runs(),
320 peak: JobPeak::Largest,
321 },
322 )
323 }
324 (Some(job), Some(peers)) if peers.runs() >= min_jobs => (
325 self.with_margin(job.max().max(peers.p95())),
326 Basis::Profile { runs: peers.runs() },
327 ),
328 (None, Some(peers)) if peers.runs() >= min_jobs => (
330 self.with_margin(peers.max()),
331 Basis::ProfileMax { runs: peers.runs() },
332 ),
333 _ => (cap, Basis::Cap),
334 };
335 Prediction {
336 memory: memory.min(cap),
337 basis,
338 }
339 }
340
341 fn with_margin(&self, peak: u64) -> u64 {
343 let memory = peak.saturating_add(peak.saturating_mul(u64::from(self.margin)) / 100);
344 memory
345 .div_ceil(GRANULE)
346 .saturating_mul(GRANULE)
347 .max(GRANULE)
348 }
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum Policy {
354 Caps,
356 Measured,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct Replay {
365 pub jobs: u64,
367 pub never_started: u64,
369 pub wait: Spread,
371 pub mean_wait: u64,
373 pub most_running: u64,
375 pub worst_overlap: u64,
380 pub makespan: u64,
382 pub under_predicted: u64,
384 pub held_back: u64,
386 pub cpu_held: u64,
388 pub worst_cpu: u64,
390}
391
392struct Running<'run> {
394 run: &'run Ran,
395 profile: &'run str,
396 ends: u64,
397 reserved: u64,
398 peak: u64,
399 cpu: u64,
401}
402
403pub fn simulate(
418 config: &Config,
419 memory_max: Option<u64>,
420 cpu_budget: Option<u64>,
421 jobs: &[Ran],
422 policy: Policy,
423 slots: &BTreeMap<String, u32>,
424) -> Replay {
425 let mirror = config.mirror_memory();
426 let budget = memory_max.map(|max| max.saturating_sub(mirror));
427 let mut queue: Vec<&Ran> = jobs
428 .iter()
429 .filter(|run| config.profiles.contains_key(&run.profile))
430 .filter(|run| matches!((run.started_at, run.finished_at), (Some(start), Some(end)) if end >= start))
431 .collect();
432 queue.sort_by_key(|run| run.queued_at);
433 let mut replayer = Replayer {
434 config,
435 budget,
436 cpu_budget,
437 policy,
438 slots,
439 predictor: Predictor::new(&config.sizing),
440 running: Vec::new(),
441 waits: Vec::new(),
442 most_running: 0,
443 worst_overlap: 0,
444 last_end: 0,
445 under_predicted: 0,
446 held_back: 0,
447 cpu_held: 0,
448 worst_cpu: 0,
449 };
450 let mut waiting: Vec<&Ran> = Vec::new();
451 let mut next = 0;
452 loop {
453 let arrival = queue.get(next).map(|run| run.queued_at);
454 let end = replayer.running.iter().map(|running| running.ends).min();
455 let Some(now) = [arrival, end].into_iter().flatten().min() else {
456 break;
457 };
458 replayer.finish(now);
459 replayer.rereserve(now);
460 while let Some(run) = queue.get(next).filter(|run| run.queued_at <= now) {
461 waiting.push(run);
462 next += 1;
463 }
464 let mut index = 0;
465 while let Some(run) = waiting.get(index).copied() {
466 if replayer.admit(run, &waiting, now) {
467 waiting.remove(index);
468 } else {
469 index += 1;
470 }
471 }
472 replayer.most_running = replayer.most_running.max(replayer.running.len() as u64);
473 let peaks: u64 = replayer.running.iter().map(|running| running.peak).sum();
474 replayer.worst_overlap = replayer.worst_overlap.max(peaks + mirror);
475 let cpu: u64 = replayer
476 .running
477 .iter()
478 .map(|running| running.run.cpu_average.unwrap_or(0))
479 .sum();
480 replayer.worst_cpu = replayer.worst_cpu.max(cpu);
481 }
482 let waits = replayer.waits;
483 let first = queue.first().map_or(0, |run| run.queued_at);
484 Replay {
485 jobs: queue.len() as u64,
486 never_started: waiting.len() as u64,
487 mean_wait: waits.iter().sum::<u64>() / (waits.len() as u64).max(1),
488 wait: spread(waits),
489 most_running: replayer.most_running,
490 worst_overlap: replayer.worst_overlap,
491 makespan: replayer.last_end.saturating_sub(first) / 1000,
492 under_predicted: replayer.under_predicted,
493 held_back: replayer.held_back,
494 cpu_held: replayer.cpu_held,
495 worst_cpu: replayer.worst_cpu,
496 }
497}
498
499struct Replayer<'run> {
501 config: &'run Config,
502 budget: Option<u64>,
503 cpu_budget: Option<u64>,
504 policy: Policy,
505 slots: &'run BTreeMap<String, u32>,
506 predictor: Predictor,
507 running: Vec<Running<'run>>,
508 waits: Vec<u64>,
509 most_running: u64,
510 worst_overlap: u64,
511 last_end: u64,
512 under_predicted: u64,
513 held_back: u64,
514 cpu_held: u64,
515 worst_cpu: u64,
516}
517
518impl<'run> Replayer<'run> {
519 fn finish(&mut self, now: u64) {
521 let (ended, running): (Vec<_>, Vec<_>) = std::mem::take(&mut self.running)
522 .into_iter()
523 .partition(|running| running.ends <= now);
524 self.running = running;
525 for ended in ended {
526 let cap = self.config.profiles[ended.profile].memory.0;
527 self.under_predicted += u64::from(ended.peak > ended.reserved);
528 let reserved = (self.policy == Policy::Measured).then_some(ended.reserved);
529 let run = Ran {
530 reserved,
531 ..ended.run.clone()
532 };
533 self.predictor.learn(&run, ended.profile, cap, ended.ends);
534 self.last_end = self.last_end.max(ended.ends);
535 }
536 }
537
538 fn rereserve(&mut self, now: u64) {
542 if self.policy != Policy::Measured {
543 return;
544 }
545 let cpus: Vec<u64> = self
546 .running
547 .iter()
548 .map(|running| self.cpu(&Key::of(running.run), running.profile))
549 .collect();
550 let mut seen = BTreeSet::new();
551 for (running, cpu) in self.running.iter_mut().zip(cpus) {
552 let key = Key::of(running.run);
553 let copies = if seen.insert(key.clone()) {
554 Copies::Alone
555 } else {
556 Copies::Several
557 };
558 let limits = &self.config.profiles[running.profile];
559 running.reserved = self
560 .predictor
561 .predict(&key, running.profile, limits.memory.0, now, copies)
562 .memory;
563 running.cpu = cpu;
564 }
565 }
566
567 fn cpu(&self, key: &Key, profile: &str) -> u64 {
570 let cap = u64::from(self.config.profiles[profile].cpus).saturating_mul(1000);
571 let cpu = match self.policy {
572 Policy::Caps => cap,
573 Policy::Measured => self.predictor.predict_cpu(key, profile, cap).cpu,
574 };
575 self.cpu_budget.map_or(cpu, |budget| cpu.min(budget))
576 }
577
578 fn taken(&self) -> u64 {
581 self.running
582 .iter()
583 .map(|running| match self.policy {
584 Policy::Caps => running.reserved,
585 Policy::Measured => running.reserved.max(running.peak),
586 })
587 .sum()
588 }
589
590 fn admit(&mut self, run: &'run Ran, waiting: &[&Ran], now: u64) -> bool {
594 let Some((profile, limits)) = self.config.profiles.get_key_value(&run.profile) else {
595 return false;
596 };
597 let slots = self.slots.get(profile).copied().unwrap_or(limits.slots);
598 let in_profile = self
599 .running
600 .iter()
601 .filter(|running| running.profile == profile.as_str())
602 .count();
603 if in_profile >= slots as usize {
604 return false;
605 }
606 let cap = limits.memory.0;
607 let running = |key: &Key| {
608 self.running
609 .iter()
610 .any(|running| Key::of(running.run) == *key)
611 };
612 let queued = |job: &Ran| {
614 let key = Key::of(job);
615 let waiting = waiting.iter().filter(|other| Key::of(other) == key).count();
616 let copies = if waiting > 1 || running(&key) {
617 Copies::Several
618 } else {
619 Copies::Alone
620 };
621 self.predictor
622 .predict(&key, profile, cap, now, copies)
623 .memory
624 };
625 let started = || {
627 let key = Key::of(run);
628 let copies = if running(&key) {
629 Copies::Several
630 } else {
631 Copies::Alone
632 };
633 self.predictor
634 .predict(&key, profile, cap, now, copies)
635 .memory
636 };
637 let (starting, reserved) = match self.policy {
638 Policy::Caps => (cap, cap),
639 Policy::Measured => (
640 waiting
641 .iter()
642 .filter(|job| job.repository == run.repository && job.profile == run.profile)
643 .map(|job| queued(job))
644 .max()
645 .unwrap_or(0)
646 .max(started()),
647 started(),
648 ),
649 };
650 if let Some(budget) = self.budget {
651 if self.taken() + starting > budget {
652 return false;
653 }
654 let peaks: u64 = self.running.iter().map(|running| running.peak).sum();
655 if self.policy == Policy::Measured
656 && peaks.saturating_mul(100) >= budget.saturating_mul(HIGH_WATER)
657 {
658 self.held_back += 1;
659 return false;
660 }
661 }
662 let cpu = self.cpu(&Key::of(run), profile);
663 if let Some(cpu_budget) = self.cpu_budget {
664 let starting = waiting
666 .iter()
667 .filter(|job| job.repository == run.repository && job.profile == run.profile)
668 .map(|job| self.cpu(&Key::of(job), profile))
669 .max()
670 .unwrap_or(0)
671 .max(cpu);
672 let taken: u64 = self.running.iter().map(|running| running.cpu).sum();
673 if taken + starting > cpu_budget {
674 self.cpu_held += 1;
675 return false;
676 }
677 }
678 let peak = run.memory_peak.min(cap);
680 let ran_for = run
681 .finished_at
682 .unwrap_or_default()
683 .saturating_sub(run.started_at.unwrap_or_default());
684 self.waits.push(now.saturating_sub(run.queued_at) / 1000);
685 self.running.push(Running {
686 run,
687 profile,
688 ends: now + ran_for,
689 reserved,
690 peak,
691 cpu,
692 });
693 true
694 }
695}
696
697#[cfg(test)]
698mod tests;