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