1use std::collections::BTreeMap;
6
7use crate::api::Basis;
8use crate::api::Spread;
9use crate::config::{self, Config};
10use crate::sizing::{GRANULE, Ran, spread};
11
12const AT_CAP: u64 = 95;
14
15pub const UNPREDICTABLE_FOR: u64 = 7 * 24 * 3600 * 1000;
18
19pub const HIGH_WATER: u64 = 90;
21
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
24pub struct Key {
25 pub repository: String,
27 pub workflow: Option<String>,
29 pub name: String,
31}
32
33impl Key {
34 pub fn of(run: &Ran) -> Key {
36 Key {
37 repository: run.repository.clone(),
38 workflow: run.workflow.clone(),
39 name: run.name.clone(),
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct Prediction {
47 pub memory: u64,
49 pub basis: Basis,
51}
52
53#[derive(Debug, Clone, Default)]
56struct Peaks {
57 sorted: Vec<u64>,
58 unpredictable_until: u64,
59 overran_until: u64,
60 overran_peak: u64,
62}
63
64impl Peaks {
65 fn add(&mut self, peak: u64) {
66 let at = self.sorted.partition_point(|known| *known <= peak);
67 self.sorted.insert(at, peak);
68 }
69
70 fn runs(&self) -> u64 {
71 self.sorted.len() as u64
72 }
73
74 fn p95(&self) -> u64 {
75 spread(self.sorted.clone()).p95
76 }
77
78 fn max(&self) -> u64 {
79 self.sorted.last().copied().unwrap_or(0)
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct Predictor {
86 margin: u32,
87 min_jobs: u32,
88 jobs: BTreeMap<Key, Peaks>,
89 profiles: BTreeMap<String, Peaks>,
90}
91
92impl Predictor {
93 pub fn new(rules: &config::Sizing) -> Predictor {
95 Predictor {
96 margin: rules.margin,
97 min_jobs: rules.min_jobs,
98 jobs: BTreeMap::new(),
99 profiles: BTreeMap::new(),
100 }
101 }
102
103 pub fn learn(&mut self, run: &Ran, profile: &str, cap: u64, at: u64) {
105 self.mark(run, cap, at);
106 let peak = run.memory_peak;
107 if let Some(job) = self.jobs.get_mut(&Key::of(run)) {
108 job.add(peak);
109 }
110 self.profiles
111 .entry(profile.to_owned())
112 .or_default()
113 .add(peak);
114 }
115
116 pub fn mark(&mut self, run: &Ran, cap: u64, at: u64) {
120 let peak = run.memory_peak;
121 let job = self.jobs.entry(Key::of(run)).or_default();
122 if run.oom_killed || peak.saturating_mul(100) >= cap.saturating_mul(AT_CAP) {
123 job.unpredictable_until = job.unpredictable_until.max(at + UNPREDICTABLE_FOR);
124 }
125 if run.reserved.is_some_and(|reserved| peak > reserved) {
126 job.overran_until = job.overran_until.max(at + UNPREDICTABLE_FOR);
127 job.overran_peak = job.overran_peak.max(peak);
128 }
129 }
130
131 pub fn predict(&self, key: &Key, profile: &str, cap: u64, now: u64) -> Prediction {
133 let job = self.jobs.get(key);
134 if let Some(until) = job
135 .map(|job| job.unpredictable_until)
136 .filter(|until| *until > now)
137 {
138 return Prediction {
139 memory: cap,
140 basis: Basis::Unpredictable {
141 until: until / 1000,
142 },
143 };
144 }
145 let min_jobs = u64::from(self.min_jobs);
146 let (memory, basis) = match (job, self.profiles.get(profile)) {
147 (Some(job), _) if job.runs() == 0 => (cap, Basis::Cap),
149 (Some(job), _) if job.overran_until > now => (
150 self.with_margin(job.max().max(job.overran_peak)),
151 Basis::Overran {
152 until: job.overran_until / 1000,
153 },
154 ),
155 (Some(job), _) if job.runs() >= min_jobs => {
156 (self.with_margin(job.p95()), Basis::Job { runs: job.runs() })
157 }
158 (Some(job), Some(peers)) if peers.runs() >= min_jobs => (
159 self.with_margin(job.max().max(peers.p95())),
160 Basis::Profile { runs: peers.runs() },
161 ),
162 (None, Some(peers)) if peers.runs() >= min_jobs => (
164 self.with_margin(peers.max()),
165 Basis::ProfileMax { runs: peers.runs() },
166 ),
167 _ => (cap, Basis::Cap),
168 };
169 Prediction {
170 memory: memory.min(cap),
171 basis,
172 }
173 }
174
175 fn with_margin(&self, peak: u64) -> u64 {
177 let memory = peak.saturating_add(peak.saturating_mul(u64::from(self.margin)) / 100);
178 memory
179 .div_ceil(GRANULE)
180 .saturating_mul(GRANULE)
181 .max(GRANULE)
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum Policy {
188 Caps,
190 Measured,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct Replay {
199 pub jobs: u64,
201 pub never_started: u64,
203 pub wait: Spread,
205 pub mean_wait: u64,
207 pub most_running: u64,
209 pub worst_overlap: u64,
214 pub makespan: u64,
216 pub under_predicted: u64,
218 pub held_back: u64,
220}
221
222struct Running<'run> {
224 run: &'run Ran,
225 profile: &'run str,
226 ends: u64,
227 reserved: u64,
228 peak: u64,
229}
230
231pub fn simulate(
237 config: &Config,
238 memory_max: Option<u64>,
239 jobs: &[Ran],
240 policy: Policy,
241 slots: &BTreeMap<String, u32>,
242) -> Replay {
243 let mirror = if config.caches.mirror.enabled {
244 config.caches.mirror.memory.0
245 } else {
246 0
247 };
248 let budget = memory_max.map(|max| max.saturating_sub(mirror));
249 let mut queue: Vec<&Ran> = jobs
250 .iter()
251 .filter(|run| config.profiles.contains_key(&run.profile))
252 .filter(|run| matches!((run.started_at, run.finished_at), (Some(start), Some(end)) if end >= start))
253 .collect();
254 queue.sort_by_key(|run| run.queued_at);
255 let mut replayer = Replayer {
256 config,
257 budget,
258 policy,
259 slots,
260 predictor: Predictor::new(&config.sizing),
261 running: Vec::new(),
262 waits: Vec::new(),
263 most_running: 0,
264 worst_overlap: 0,
265 last_end: 0,
266 under_predicted: 0,
267 held_back: 0,
268 };
269 let mut waiting: Vec<&Ran> = Vec::new();
270 let mut next = 0;
271 loop {
272 let arrival = queue.get(next).map(|run| run.queued_at);
273 let end = replayer.running.iter().map(|running| running.ends).min();
274 let Some(now) = [arrival, end].into_iter().flatten().min() else {
275 break;
276 };
277 replayer.finish(now);
278 while let Some(run) = queue.get(next).filter(|run| run.queued_at <= now) {
279 waiting.push(run);
280 next += 1;
281 }
282 let mut index = 0;
283 while let Some(run) = waiting.get(index).copied() {
284 if replayer.admit(run, &waiting, now) {
285 waiting.remove(index);
286 } else {
287 index += 1;
288 }
289 }
290 replayer.most_running = replayer.most_running.max(replayer.running.len() as u64);
291 let peaks: u64 = replayer.running.iter().map(|running| running.peak).sum();
292 replayer.worst_overlap = replayer.worst_overlap.max(peaks + mirror);
293 }
294 let waits = replayer.waits;
295 let first = queue.first().map_or(0, |run| run.queued_at);
296 Replay {
297 jobs: queue.len() as u64,
298 never_started: waiting.len() as u64,
299 mean_wait: waits.iter().sum::<u64>() / (waits.len() as u64).max(1),
300 wait: spread(waits),
301 most_running: replayer.most_running,
302 worst_overlap: replayer.worst_overlap,
303 makespan: replayer.last_end.saturating_sub(first) / 1000,
304 under_predicted: replayer.under_predicted,
305 held_back: replayer.held_back,
306 }
307}
308
309struct Replayer<'run> {
311 config: &'run Config,
312 budget: Option<u64>,
313 policy: Policy,
314 slots: &'run BTreeMap<String, u32>,
315 predictor: Predictor,
316 running: Vec<Running<'run>>,
317 waits: Vec<u64>,
318 most_running: u64,
319 worst_overlap: u64,
320 last_end: u64,
321 under_predicted: u64,
322 held_back: u64,
323}
324
325impl<'run> Replayer<'run> {
326 fn finish(&mut self, now: u64) {
328 let (ended, running): (Vec<_>, Vec<_>) = std::mem::take(&mut self.running)
329 .into_iter()
330 .partition(|running| running.ends <= now);
331 self.running = running;
332 for ended in ended {
333 let cap = self.config.profiles[ended.profile].memory.0;
334 let reserved = (self.policy == Policy::Measured).then_some(ended.reserved);
335 let run = Ran {
336 reserved,
337 ..ended.run.clone()
338 };
339 self.predictor.learn(&run, ended.profile, cap, ended.ends);
340 self.last_end = self.last_end.max(ended.ends);
341 }
342 }
343
344 fn taken(&self) -> u64 {
347 self.running
348 .iter()
349 .map(|running| match self.policy {
350 Policy::Caps => running.reserved,
351 Policy::Measured => running.reserved.max(running.peak),
352 })
353 .sum()
354 }
355
356 fn admit(&mut self, run: &'run Ran, waiting: &[&Ran], now: u64) -> bool {
360 let Some((profile, limits)) = self.config.profiles.get_key_value(&run.profile) else {
361 return false;
362 };
363 let slots = self.slots.get(profile).copied().unwrap_or(limits.slots);
364 let in_profile = self
365 .running
366 .iter()
367 .filter(|running| running.profile == profile.as_str())
368 .count();
369 if in_profile >= slots as usize {
370 return false;
371 }
372 let cap = limits.memory.0;
373 let predict = |job: &Ran| {
374 self.predictor
375 .predict(&Key::of(job), profile, cap, now)
376 .memory
377 };
378 let (starting, reserved) = match self.policy {
379 Policy::Caps => (cap, cap),
380 Policy::Measured => (
381 waiting
382 .iter()
383 .filter(|job| job.repository == run.repository)
384 .map(|job| predict(job))
385 .max()
386 .unwrap_or(0)
387 .max(predict(run)),
388 predict(run),
389 ),
390 };
391 if let Some(budget) = self.budget {
392 if self.taken() + starting > budget {
393 return false;
394 }
395 let peaks: u64 = self.running.iter().map(|running| running.peak).sum();
396 if self.policy == Policy::Measured
397 && peaks.saturating_mul(100) >= budget.saturating_mul(HIGH_WATER)
398 {
399 self.held_back += 1;
400 return false;
401 }
402 }
403 let peak = run.memory_peak.min(cap);
405 self.under_predicted += u64::from(peak > reserved);
406 let ran_for = run
407 .finished_at
408 .unwrap_or_default()
409 .saturating_sub(run.started_at.unwrap_or_default());
410 self.waits.push(now.saturating_sub(run.queued_at) / 1000);
411 self.running.push(Running {
412 run,
413 profile,
414 ends: now + ran_for,
415 reserved,
416 peak,
417 });
418 true
419 }
420}
421
422#[cfg(test)]
423mod tests;