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)]
140pub struct Predictor {
141 margin: u32,
142 min_jobs: u32,
143 jobs: BTreeMap<Key, Peaks>,
144 profiles: BTreeMap<String, Peaks>,
145 cpu_jobs: BTreeMap<Key, Averages>,
148 cpu_profiles: BTreeMap<String, Averages>,
149}
150
151impl Predictor {
152 pub fn new(rules: &config::Sizing) -> Predictor {
154 Predictor {
155 margin: rules.margin,
156 min_jobs: rules.min_jobs,
157 jobs: BTreeMap::new(),
158 profiles: BTreeMap::new(),
159 cpu_jobs: BTreeMap::new(),
160 cpu_profiles: BTreeMap::new(),
161 }
162 }
163
164 pub fn learn(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
166 self.mark(run, cap, at);
167 let peak = run.memory_peak;
168 if let Some(job) = self.jobs.get_mut(&Key::of(run)) {
169 job.add(peak);
170 job.succeeded += u64::from(run.succeeded);
171 }
172 self.profiles
173 .entry(profile.to_owned())
174 .or_default()
175 .add(peak);
176 self.learn_cpu(run, profile);
177 }
178
179 pub fn learn_cpu(&mut self, run: &Ran, profile: &str) {
182 let Some(average) = run.cpu_average else {
183 return;
184 };
185 self.cpu_profiles
186 .entry(profile.to_owned())
187 .or_default()
188 .add(average, run.succeeded);
189 self.cpu_jobs
190 .entry(Key::of(run))
191 .or_default()
192 .add(average, run.succeeded);
193 }
194
195 pub fn predict_cpu(&self, key: &Key, profile: &str, cap: u64) -> CpuPrediction {
199 let job = self.cpu_jobs.get(key);
200 let min_jobs = u64::from(self.min_jobs);
201 let peers = self
202 .cpu_profiles
203 .get(profile)
204 .filter(|peers| peers.runs() >= min_jobs);
205 let (cpu, basis) = match (job.filter(|job| job.runs() > 0), peers) {
206 (Some(job), _) if job.runs() >= min_jobs => (
207 job.p95(),
208 Basis::Job {
209 runs: job.runs(),
210 peak: JobPeak::P95,
211 },
212 ),
213 (Some(job), Some(_)) if job.succeeded >= OWN_RUNS => (
214 job.max(),
215 Basis::Job {
216 runs: job.runs(),
217 peak: JobPeak::Largest,
218 },
219 ),
220 (Some(job), Some(peers)) => (
221 job.max().max(peers.p95()),
222 Basis::Profile { runs: peers.runs() },
223 ),
224 (None, Some(peers)) => (peers.max(), Basis::ProfileMax { runs: peers.runs() }),
225 _ => (cap, Basis::Cap),
226 };
227 CpuPrediction {
228 cpu: cpu
229 .div_ceil(CPU_GRANULE)
230 .saturating_mul(CPU_GRANULE)
231 .clamp(CPU_GRANULE, cap.max(CPU_GRANULE)),
232 basis,
233 }
234 }
235
236 pub fn mark(&mut self, run: &Ran, cap: u64, at: u64) {
240 let peak = run.memory_peak;
241 let job = self.jobs.entry(Key::of(run)).or_default();
242 if run.met_cap(cap) {
243 job.unpredictable_until = job.unpredictable_until.max(at + UNPREDICTABLE_FOR);
244 }
245 if run.reserved.is_some_and(|reserved| peak > reserved) {
246 job.overran_until = job.overran_until.max(at + UNPREDICTABLE_FOR);
247 job.overran_peak = job.overran_peak.max(peak);
248 }
249 }
250
251 pub fn predict(
254 &self,
255 key: &Key,
256 profile: &str,
257 cap: u64,
258 now: u64,
259 copies: Copies,
260 ) -> Prediction {
261 let job = self.jobs.get(key);
262 if let Some(until) = job
263 .map(|job| job.unpredictable_until)
264 .filter(|until| *until > now)
265 {
266 return Prediction {
267 memory: cap,
268 basis: Basis::Unpredictable {
269 until: until / 1000,
270 },
271 };
272 }
273 let min_jobs = u64::from(self.min_jobs);
274 let (memory, basis) = match (job, self.profiles.get(profile)) {
275 (Some(job), _) if job.runs() == 0 => (cap, Basis::Cap),
277 (Some(job), _) if job.overran_until > now => (
278 self.with_margin(job.max().max(job.overran_peak)),
279 Basis::Overran {
280 until: job.overran_until / 1000,
281 },
282 ),
283 (Some(job), _) if job.runs() >= min_jobs => (
284 self.with_margin(job.p95()),
285 Basis::Job {
286 runs: job.runs(),
287 peak: JobPeak::P95,
288 },
289 ),
290 (Some(job), Some(peers))
291 if peers.runs() >= min_jobs
292 && job.succeeded >= OWN_RUNS
293 && copies == Copies::Alone =>
294 {
295 (
296 self.with_margin(job.max()),
297 Basis::Job {
298 runs: job.runs(),
299 peak: JobPeak::Largest,
300 },
301 )
302 }
303 (Some(job), Some(peers)) if peers.runs() >= min_jobs => (
304 self.with_margin(job.max().max(peers.p95())),
305 Basis::Profile { runs: peers.runs() },
306 ),
307 (None, Some(peers)) if peers.runs() >= min_jobs => (
309 self.with_margin(peers.max()),
310 Basis::ProfileMax { runs: peers.runs() },
311 ),
312 _ => (cap, Basis::Cap),
313 };
314 Prediction {
315 memory: memory.min(cap),
316 basis,
317 }
318 }
319
320 fn with_margin(&self, peak: u64) -> u64 {
322 let memory = peak.saturating_add(peak.saturating_mul(u64::from(self.margin)) / 100);
323 memory
324 .div_ceil(GRANULE)
325 .saturating_mul(GRANULE)
326 .max(GRANULE)
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub enum Policy {
333 Caps,
335 Measured,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct Replay {
344 pub jobs: u64,
346 pub never_started: u64,
348 pub wait: Spread,
350 pub mean_wait: u64,
352 pub most_running: u64,
354 pub worst_overlap: u64,
359 pub makespan: u64,
361 pub under_predicted: u64,
363 pub held_back: u64,
365 pub cpu_held: u64,
367 pub worst_cpu: u64,
369}
370
371struct Running<'run> {
373 run: &'run Ran,
374 profile: &'run str,
375 ends: u64,
376 reserved: u64,
377 peak: u64,
378 cpu: u64,
380}
381
382pub fn simulate(
397 config: &Config,
398 memory_max: Option<u64>,
399 cpu_budget: Option<u64>,
400 jobs: &[Ran],
401 policy: Policy,
402 slots: &BTreeMap<String, u32>,
403) -> Replay {
404 let mirror = config.mirror_memory();
405 let budget = memory_max.map(|max| max.saturating_sub(mirror));
406 let mut queue: Vec<&Ran> = jobs
407 .iter()
408 .filter(|run| config.profiles.contains_key(&run.profile))
409 .filter(|run| matches!((run.started_at, run.finished_at), (Some(start), Some(end)) if end >= start))
410 .collect();
411 queue.sort_by_key(|run| run.queued_at);
412 let mut replayer = Replayer {
413 config,
414 budget,
415 cpu_budget,
416 policy,
417 slots,
418 predictor: Predictor::new(&config.sizing),
419 running: Vec::new(),
420 waits: Vec::new(),
421 most_running: 0,
422 worst_overlap: 0,
423 last_end: 0,
424 under_predicted: 0,
425 held_back: 0,
426 cpu_held: 0,
427 worst_cpu: 0,
428 };
429 let mut waiting: Vec<&Ran> = Vec::new();
430 let mut next = 0;
431 loop {
432 let arrival = queue.get(next).map(|run| run.queued_at);
433 let end = replayer.running.iter().map(|running| running.ends).min();
434 let Some(now) = [arrival, end].into_iter().flatten().min() else {
435 break;
436 };
437 replayer.finish(now);
438 replayer.rereserve(now);
439 while let Some(run) = queue.get(next).filter(|run| run.queued_at <= now) {
440 waiting.push(run);
441 next += 1;
442 }
443 let mut index = 0;
444 while let Some(run) = waiting.get(index).copied() {
445 if replayer.admit(run, &waiting, now) {
446 waiting.remove(index);
447 } else {
448 index += 1;
449 }
450 }
451 replayer.most_running = replayer.most_running.max(replayer.running.len() as u64);
452 let peaks: u64 = replayer.running.iter().map(|running| running.peak).sum();
453 replayer.worst_overlap = replayer.worst_overlap.max(peaks + mirror);
454 let cpu: u64 = replayer
455 .running
456 .iter()
457 .map(|running| running.run.cpu_average.unwrap_or(0))
458 .sum();
459 replayer.worst_cpu = replayer.worst_cpu.max(cpu);
460 }
461 let waits = replayer.waits;
462 let first = queue.first().map_or(0, |run| run.queued_at);
463 Replay {
464 jobs: queue.len() as u64,
465 never_started: waiting.len() as u64,
466 mean_wait: waits.iter().sum::<u64>() / (waits.len() as u64).max(1),
467 wait: spread(waits),
468 most_running: replayer.most_running,
469 worst_overlap: replayer.worst_overlap,
470 makespan: replayer.last_end.saturating_sub(first) / 1000,
471 under_predicted: replayer.under_predicted,
472 held_back: replayer.held_back,
473 cpu_held: replayer.cpu_held,
474 worst_cpu: replayer.worst_cpu,
475 }
476}
477
478struct Replayer<'run> {
480 config: &'run Config,
481 budget: Option<u64>,
482 cpu_budget: Option<u64>,
483 policy: Policy,
484 slots: &'run BTreeMap<String, u32>,
485 predictor: Predictor,
486 running: Vec<Running<'run>>,
487 waits: Vec<u64>,
488 most_running: u64,
489 worst_overlap: u64,
490 last_end: u64,
491 under_predicted: u64,
492 held_back: u64,
493 cpu_held: u64,
494 worst_cpu: u64,
495}
496
497impl<'run> Replayer<'run> {
498 fn finish(&mut self, now: u64) {
500 let (ended, running): (Vec<_>, Vec<_>) = std::mem::take(&mut self.running)
501 .into_iter()
502 .partition(|running| running.ends <= now);
503 self.running = running;
504 for ended in ended {
505 let cap = self.config.profiles[ended.profile].memory.0;
506 self.under_predicted += u64::from(ended.peak > ended.reserved);
507 let reserved = (self.policy == Policy::Measured).then_some(ended.reserved);
508 let run = Ran {
509 reserved,
510 ..ended.run.clone()
511 };
512 self.predictor.learn(&run, ended.profile, cap, ended.ends);
513 self.last_end = self.last_end.max(ended.ends);
514 }
515 }
516
517 fn rereserve(&mut self, now: u64) {
521 if self.policy != Policy::Measured {
522 return;
523 }
524 let cpus: Vec<u64> = self
525 .running
526 .iter()
527 .map(|running| self.cpu(&Key::of(running.run), running.profile))
528 .collect();
529 let mut seen = BTreeSet::new();
530 for (running, cpu) in self.running.iter_mut().zip(cpus) {
531 let key = Key::of(running.run);
532 let copies = if seen.insert(key.clone()) {
533 Copies::Alone
534 } else {
535 Copies::Several
536 };
537 let limits = &self.config.profiles[running.profile];
538 running.reserved = self
539 .predictor
540 .predict(&key, running.profile, limits.memory.0, now, copies)
541 .memory;
542 running.cpu = cpu;
543 }
544 }
545
546 fn cpu(&self, key: &Key, profile: &str) -> u64 {
549 let cap = u64::from(self.config.profiles[profile].cpus).saturating_mul(1000);
550 let cpu = match self.policy {
551 Policy::Caps => cap,
552 Policy::Measured => self.predictor.predict_cpu(key, profile, cap).cpu,
553 };
554 self.cpu_budget.map_or(cpu, |budget| cpu.min(budget))
555 }
556
557 fn taken(&self) -> u64 {
560 self.running
561 .iter()
562 .map(|running| match self.policy {
563 Policy::Caps => running.reserved,
564 Policy::Measured => running.reserved.max(running.peak),
565 })
566 .sum()
567 }
568
569 fn admit(&mut self, run: &'run Ran, waiting: &[&Ran], now: u64) -> bool {
573 let Some((profile, limits)) = self.config.profiles.get_key_value(&run.profile) else {
574 return false;
575 };
576 let slots = self.slots.get(profile).copied().unwrap_or(limits.slots);
577 let in_profile = self
578 .running
579 .iter()
580 .filter(|running| running.profile == profile.as_str())
581 .count();
582 if in_profile >= slots as usize {
583 return false;
584 }
585 let cap = limits.memory.0;
586 let running = |key: &Key| {
587 self.running
588 .iter()
589 .any(|running| Key::of(running.run) == *key)
590 };
591 let queued = |job: &Ran| {
593 let key = Key::of(job);
594 let waiting = waiting.iter().filter(|other| Key::of(other) == key).count();
595 let copies = if waiting > 1 || running(&key) {
596 Copies::Several
597 } else {
598 Copies::Alone
599 };
600 self.predictor
601 .predict(&key, profile, cap, now, copies)
602 .memory
603 };
604 let started = || {
606 let key = Key::of(run);
607 let copies = if running(&key) {
608 Copies::Several
609 } else {
610 Copies::Alone
611 };
612 self.predictor
613 .predict(&key, profile, cap, now, copies)
614 .memory
615 };
616 let (starting, reserved) = match self.policy {
617 Policy::Caps => (cap, cap),
618 Policy::Measured => (
619 waiting
620 .iter()
621 .filter(|job| job.repository == run.repository)
622 .map(|job| queued(job))
623 .max()
624 .unwrap_or(0)
625 .max(started()),
626 started(),
627 ),
628 };
629 if let Some(budget) = self.budget {
630 if self.taken() + starting > budget {
631 return false;
632 }
633 let peaks: u64 = self.running.iter().map(|running| running.peak).sum();
634 if self.policy == Policy::Measured
635 && peaks.saturating_mul(100) >= budget.saturating_mul(HIGH_WATER)
636 {
637 self.held_back += 1;
638 return false;
639 }
640 }
641 let cpu = self.cpu(&Key::of(run), profile);
642 if let Some(cpu_budget) = self.cpu_budget {
643 let starting = waiting
645 .iter()
646 .filter(|job| job.repository == run.repository)
647 .map(|job| self.cpu(&Key::of(job), profile))
648 .max()
649 .unwrap_or(0)
650 .max(cpu);
651 let taken: u64 = self.running.iter().map(|running| running.cpu).sum();
652 if taken + starting > cpu_budget {
653 self.cpu_held += 1;
654 return false;
655 }
656 }
657 let peak = run.memory_peak.min(cap);
659 let ran_for = run
660 .finished_at
661 .unwrap_or_default()
662 .saturating_sub(run.started_at.unwrap_or_default());
663 self.waits.push(now.saturating_sub(run.queued_at) / 1000);
664 self.running.push(Running {
665 run,
666 profile,
667 ends: now + ran_for,
668 reserved,
669 peak,
670 cpu,
671 });
672 true
673 }
674}
675
676#[cfg(test)]
677mod tests;