1use std::collections::BTreeMap;
17use std::path::PathBuf;
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Error, Result};
23use crate::metadata::yaml;
24use crate::repository::Repository;
25
26use super::results::{write_atomic, CacheMode, ResultDocument};
27
28pub const BASELINE_DIR: &str = "benchmarks/rust";
30
31pub const POLICY_FILE: &str = "policy.yaml";
33
34#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
36pub struct Threshold {
37 pub relative: f64,
39 #[serde(default)]
43 pub minimum_samples: usize,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
48pub struct Policy {
49 pub regression: BTreeMap<String, Threshold>,
51 pub minimum_absolute_us: f64,
53 #[serde(default)]
59 pub targets: BTreeMap<String, BTreeMap<String, Threshold>>,
60 #[serde(default)]
63 pub budgets: Vec<Budget>,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
84pub struct Budget {
85 pub prefix: String,
87 pub cache: CacheMode,
89 pub metric: String,
91 pub microseconds: f64,
93 #[serde(default)]
96 pub minimum_samples: usize,
97}
98
99impl Default for Policy {
100 fn default() -> Self {
101 Policy {
102 regression: [
103 (
104 "p50".to_string(),
105 Threshold {
106 relative: 0.25,
107 minimum_samples: 0,
108 },
109 ),
110 (
111 "p95".to_string(),
112 Threshold {
113 relative: 0.30,
114 minimum_samples: 50,
115 },
116 ),
117 (
118 "p99".to_string(),
119 Threshold {
120 relative: 0.50,
121 minimum_samples: 200,
122 },
123 ),
124 ]
125 .into_iter()
126 .collect(),
127 minimum_absolute_us: 1000.0,
128 targets: BTreeMap::new(),
129 budgets: Vec::new(),
130 }
131 }
132}
133
134impl Policy {
135 pub fn load(repo: &Repository) -> Result<Self> {
138 let path = policy_path(repo);
139 if !path.exists() {
140 return Ok(Policy::default());
141 }
142 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
143 let map = yaml::parse_mapping(&text).map_err(|reason| Error::InvalidManifest {
144 path: path.clone(),
145 reason,
146 })?;
147 let v = serde_json::Value::Object(map);
148 let mut policy = Policy::default();
149 if let Some(reg) = v.get("regression").and_then(|r| r.as_object()) {
150 policy.regression.clear();
151 for (metric, t) in reg {
152 let threshold = parse_threshold(t, &path, &format!("regression.{metric}"), 0)?;
153 policy.regression.insert(metric.clone(), threshold);
154 }
155 }
156 if let Some(targets) = v.get("targets").and_then(|r| r.as_array()) {
157 for (i, item) in targets.iter().enumerate() {
158 let Some(fields) = item.as_object() else {
159 return Err(Error::InvalidManifest {
160 path: path.clone(),
161 reason: format!("targets[{i}] is not a mapping"),
162 });
163 };
164 let Some(key) = fields.get("target").and_then(|k| k.as_str()) else {
165 return Err(Error::InvalidManifest {
166 path: path.clone(),
167 reason: format!("targets[{i}] has no `target` key"),
168 });
169 };
170 let mut per_metric = BTreeMap::new();
171 for (metric, t) in fields.iter().filter(|(k, _)| k.as_str() != "target") {
172 let inherited = policy
173 .regression
174 .get(metric)
175 .map(|b| b.minimum_samples)
176 .unwrap_or(0);
177 let threshold =
178 parse_threshold(t, &path, &format!("targets.{key}.{metric}"), inherited)?;
179 per_metric.insert(metric.clone(), threshold);
180 }
181 policy.targets.insert(key.to_string(), per_metric);
182 }
183 }
184 if let Some(budgets) = v.get("budgets").and_then(|r| r.as_array()) {
185 for (i, item) in budgets.iter().enumerate() {
186 policy.budgets.push(parse_budget(item, &path, i)?);
187 }
188 }
189 if let Some(m) = v.get("minimum_absolute_us") {
190 policy.minimum_absolute_us = m
191 .as_str()
192 .and_then(|s| s.parse::<f64>().ok())
193 .or_else(|| m.as_f64())
194 .ok_or_else(|| Error::InvalidManifest {
195 path: path.clone(),
196 reason: "minimum_absolute_us is not a number".into(),
197 })?;
198 }
199 Ok(policy)
200 }
201
202 pub fn threshold_for(&self, key: &str, metric: &str) -> Option<&Threshold> {
205 self.targets
206 .get(key)
207 .and_then(|m| m.get(metric))
208 .or_else(|| self.regression.get(metric))
209 }
210}
211
212fn parse_threshold(
214 t: &serde_json::Value,
215 path: &std::path::Path,
216 at: &str,
217 inherited: usize,
218) -> Result<Threshold> {
219 let relative = t
220 .get("relative")
221 .and_then(|x| {
222 x.as_str()
223 .and_then(|s| s.parse::<f64>().ok())
224 .or_else(|| x.as_f64())
225 })
226 .ok_or_else(|| Error::InvalidManifest {
227 path: path.to_path_buf(),
228 reason: format!("{at}.relative is not a number"),
229 })?;
230 let minimum_samples = match t.get("minimum_samples") {
231 None => inherited,
232 Some(x) => x
233 .as_str()
234 .and_then(|s| s.parse::<usize>().ok())
235 .or_else(|| x.as_u64().map(|n| n as usize))
236 .ok_or_else(|| Error::InvalidManifest {
237 path: path.to_path_buf(),
238 reason: format!("{at}.minimum_samples is not a whole number"),
239 })?,
240 };
241 Ok(Threshold {
242 relative,
243 minimum_samples,
244 })
245}
246
247fn parse_budget(item: &serde_json::Value, path: &std::path::Path, i: usize) -> Result<Budget> {
251 let invalid = |reason: String| Error::InvalidManifest {
252 path: path.to_path_buf(),
253 reason,
254 };
255 let text = |key: &str| {
256 item.get(key)
257 .and_then(|x| x.as_str())
258 .filter(|s| !s.is_empty())
259 .ok_or_else(|| invalid(format!("budgets[{i}].{key} is missing")))
260 };
261 let prefix = text("prefix")?.to_string();
262 let metric = text("metric")?.to_string();
263 let cache = match text("cache")? {
264 "warm" => CacheMode::Warm,
265 "cold" => CacheMode::Cold,
266 "uncached" => CacheMode::Uncached,
267 "not_applicable" => CacheMode::NotApplicable,
268 other => {
269 return Err(invalid(format!(
270 "budgets[{i}].cache is `{other}`, not one of warm, cold, uncached, not_applicable"
271 )))
272 }
273 };
274 let number = |key: &str| {
275 item.get(key).and_then(|x| {
276 x.as_str()
277 .and_then(|s| s.parse::<f64>().ok())
278 .or_else(|| x.as_f64())
279 })
280 };
281 let milliseconds = number("milliseconds")
282 .filter(|ms| *ms > 0.0)
283 .ok_or_else(|| {
284 invalid(format!(
285 "budgets[{i}].milliseconds is not a positive number"
286 ))
287 })?;
288 let minimum_samples = match item.get("minimum_samples") {
289 None => 0,
290 Some(_) => number("minimum_samples")
291 .filter(|n| *n >= 0.0 && n.fract() == 0.0)
292 .map(|n| n as usize)
293 .ok_or_else(|| {
294 invalid(format!(
295 "budgets[{i}].minimum_samples is not a whole number"
296 ))
297 })?,
298 };
299 Ok(Budget {
300 prefix,
301 cache,
302 metric,
303 microseconds: milliseconds * 1000.0,
304 minimum_samples,
305 })
306}
307
308pub fn policy_path(repo: &Repository) -> PathBuf {
310 repo.root()
311 .join(repo.repo_path())
312 .join(BASELINE_DIR)
313 .join(POLICY_FILE)
314}
315
316pub fn baseline_path(repo: &Repository, platform: &str) -> PathBuf {
318 policy_path(repo)
319 .parent()
320 .map(|d| d.join(format!("baseline.{platform}.json")))
321 .unwrap_or_default()
322}
323
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
326pub struct CheckLine {
327 pub key: String,
329 pub cache_mode: CacheMode,
331 pub metric: String,
333 pub baseline_us: f64,
335 pub current_us: f64,
337 pub delta: f64,
339 pub allowed: f64,
341 #[serde(default)]
343 pub samples: usize,
344 #[serde(default)]
347 pub agreeing: usize,
348 pub verdict: String,
353}
354
355pub(crate) const AGREEING_SAMPLES: usize = 2;
357
358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
377pub struct BudgetLine {
378 pub key: String,
380 pub cache_mode: CacheMode,
382 pub metric: String,
384 pub current_us: f64,
386 pub budget_us: f64,
388 #[serde(default)]
390 pub samples: usize,
391 pub verdict: String,
394}
395
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
398pub struct Check {
399 pub platform: String,
401 pub baseline_found: bool,
403 pub lines: Vec<CheckLine>,
405 pub new_targets: Vec<String>,
407 pub stale_baseline_targets: Vec<String>,
409 pub registry_changed: bool,
411 pub baseline_host: String,
413 pub current_host: String,
415 #[serde(default)]
417 pub current_load: String,
418 #[serde(default)]
420 pub baseline_load: String,
421 pub comparable: bool,
424 #[serde(default)]
427 pub budgets: Vec<BudgetLine>,
428}
429
430impl Check {
431 pub fn compare(
433 run: &ResultDocument,
434 baseline: Option<&ResultDocument>,
435 policy: &Policy,
436 ) -> Self {
437 let platform = run.provenance.platform();
438 let budgets = Self::budgets(run, policy);
439 let Some(base) = baseline else {
440 return Check {
441 platform,
442 baseline_found: false,
443 lines: Vec::new(),
444 new_targets: run.results.iter().map(|r| r.key.clone()).collect(),
445 stale_baseline_targets: Vec::new(),
446 registry_changed: false,
447 baseline_host: String::new(),
448 current_host: run.provenance.host.clone(),
449 current_load: run.provenance.load.clone(),
450 baseline_load: String::new(),
451 comparable: false,
452 budgets,
453 };
454 };
455 let mut lines = Vec::new();
456 let mut new_targets = Vec::new();
457 for r in &run.results {
458 let Some(b) = base.find(&r.key, r.cache_mode) else {
459 if !new_targets.contains(&r.key) {
460 new_targets.push(r.key.clone());
461 }
462 continue;
463 };
464 for metric in policy.regression.keys() {
465 let Some(threshold) = policy.threshold_for(&r.key, metric) else {
466 continue;
467 };
468 let (Some(cur), Some(bas)) = (r.stats.metric(metric), b.stats.metric(metric))
469 else {
470 continue;
471 };
472 let delta = if bas > 0.0 { cur / bas - 1.0 } else { 0.0 };
473 let agreeing = r.stats.samples_at_or_above(metric).unwrap_or(0);
474 let verdict = if r.stats.samples < threshold.minimum_samples {
475 "SHORT"
476 } else if cur - bas < policy.minimum_absolute_us {
477 "NOISE"
478 } else if delta > threshold.relative && agreeing < AGREEING_SAMPLES {
479 "WARN"
480 } else if delta > threshold.relative {
481 "FAIL"
482 } else {
483 "PASS"
484 };
485 lines.push(CheckLine {
486 key: r.key.clone(),
487 cache_mode: r.cache_mode,
488 metric: metric.clone(),
489 baseline_us: bas,
490 current_us: cur,
491 delta,
492 allowed: threshold.relative,
493 samples: r.stats.samples,
494 agreeing,
495 verdict: verdict.into(),
496 });
497 }
498 }
499 let measured: std::collections::BTreeSet<&str> =
500 run.results.iter().map(|r| r.key.as_str()).collect();
501 let mut stale: Vec<String> = base
502 .results
503 .iter()
504 .filter(|b| !measured.contains(b.key.as_str()))
505 .map(|b| b.key.clone())
506 .collect();
507 crate::order::canonical(&mut stale);
508 stale.dedup();
509 Check {
510 platform,
511 baseline_found: true,
512 lines,
513 new_targets,
514 stale_baseline_targets: stale,
515 registry_changed: base.provenance.registry_fingerprint
516 != run.provenance.registry_fingerprint,
517 baseline_host: base.provenance.host.clone(),
518 current_host: run.provenance.host.clone(),
519 current_load: run.provenance.load.clone(),
520 baseline_load: base.provenance.load.clone(),
521 comparable: run.provenance.same_host(&base.provenance),
522 budgets,
523 }
524 }
525
526 fn budgets(run: &ResultDocument, policy: &Policy) -> Vec<BudgetLine> {
528 let mut lines = Vec::new();
529 for r in &run.results {
530 for b in &policy.budgets {
531 if r.cache_mode != b.cache || !r.key.starts_with(&b.prefix) {
532 continue;
533 }
534 let Some(current) = r.stats.metric(&b.metric) else {
535 continue;
536 };
537 let verdict = if r.stats.samples < b.minimum_samples {
538 "SHORT"
539 } else if current > b.microseconds {
540 "OVER"
541 } else {
542 "PASS"
543 };
544 lines.push(BudgetLine {
545 key: r.key.clone(),
546 cache_mode: r.cache_mode,
547 metric: b.metric.clone(),
548 current_us: current,
549 budget_us: b.microseconds,
550 samples: r.stats.samples,
551 verdict: verdict.into(),
552 });
553 }
554 }
555 lines
556 }
557
558 pub fn over_budget(&self) -> bool {
588 self.budgets.iter().any(|l| l.verdict == "OVER")
589 }
590
591 fn render_budgets(&self) -> String {
594 let mut s = String::new();
595 if self.budgets.is_empty() {
596 return s;
597 }
598 s.push_str(&format!(
599 "{:<52} {:<6} {:>5} {:>12} {:>12} {:>5} budget\n",
600 "target", "cache", "metric", "current_us", "budget_us", "n"
601 ));
602 for l in &self.budgets {
603 s.push_str(&format!(
604 "{:<52} {:<6} {:>5} {:>12.1} {:>12.1} {:>5} {}\n",
605 l.key,
606 format!("{:?}", l.cache_mode).to_lowercase(),
607 l.metric,
608 l.current_us,
609 l.budget_us,
610 l.samples,
611 l.verdict
612 ));
613 }
614 s.push_str(&format!(
615 "bench --check: {} ({})\n",
616 if self.over_budget() {
617 "over budget"
618 } else {
619 "within budget"
620 },
621 self.measured_under()
622 ));
623 s
624 }
625
626 pub fn measured_under(&self) -> String {
655 let or = |v: &str, absent: &str| {
656 if v.is_empty() {
657 absent.to_string()
658 } else {
659 v.to_string()
660 }
661 };
662 let mut s = format!(
663 "runner {}, load {}",
664 or(&self.current_host, "unidentified"),
665 or(&self.current_load, "unknown")
666 );
667 if self.baseline_found {
668 s.push_str(&format!(
669 "; baseline recorded on {}, load {}",
670 or(&self.baseline_host, "an unidentified host"),
671 or(&self.baseline_load, "unknown")
672 ));
673 }
674 s
675 }
676
677 pub fn failed(&self) -> bool {
681 self.over_budget() || (self.comparable && self.lines.iter().any(|l| l.verdict == "FAIL"))
682 }
683
684 pub fn render(&self) -> String {
686 let mut s = self.render_budgets();
687 if !self.baseline_found {
688 s.push_str(&format!(
689 "no baseline for {}: nothing compared; `majordomus bench baseline update` records one ({})\n",
690 self.platform,
691 self.measured_under()
692 ));
693 return s;
694 }
695 s.push_str(&format!(
696 "{:<52} {:<6} {:>5} {:>12} {:>12} {:>8} {:>8} {:>9} verdict\n",
697 "target", "cache", "metric", "baseline_us", "current_us", "delta", "allowed", "agree/n"
698 ));
699 for l in &self.lines {
700 s.push_str(&format!(
701 "{:<52} {:<6} {:>5} {:>12.1} {:>12.1} {:>+7.0}% {:>+7.0}% {:>9} {}\n",
702 l.key,
703 format!("{:?}", l.cache_mode).to_lowercase(),
704 l.metric,
705 l.baseline_us,
706 l.current_us,
707 l.delta * 100.0,
708 l.allowed * 100.0,
709 format!("{}/{}", l.agreeing, l.samples),
710 l.verdict
711 ));
712 }
713 let warned = self.lines.iter().filter(|l| l.verdict == "WARN").count();
714 if warned > 0 {
715 s.push_str(&format!(
716 "WARN {warned} metric(s) over the threshold on fewer than {AGREEING_SAMPLES} agreeing samples: re-measure with more samples; not a regression until they agree\n"
717 ));
718 }
719 for k in &self.new_targets {
720 s.push_str(&format!("NEW {k} (not in the baseline)\n"));
721 }
722 for k in &self.stale_baseline_targets {
723 s.push_str(&format!(
724 "STALE {k} (in the baseline, not measured; renamed, removed or filtered)\n"
725 ));
726 }
727 if self.registry_changed {
728 s.push_str("NOTE the registry fingerprint differs from the baseline's: the repository or the descriptors changed\n");
729 }
730 if !self.comparable {
731 let recorded = if self.baseline_host.is_empty() {
732 "an unidentified host".to_string()
733 } else {
734 self.baseline_host.clone()
735 };
736 s.push_str(&format!(
737 "NOTE baseline recorded on {recorded}; this host is {}; reporting only, nothing fails\n",
738 self.current_host
739 ));
740 s.push_str(&format!(
741 "bench --check: reporting only (the baseline is from another host; {})\n",
742 self.measured_under()
743 ));
744 return s;
745 }
746 s.push_str(&format!(
747 "bench --check: {} ({})\n",
748 if self.failed() {
749 "regression(s) found"
750 } else {
751 "within policy"
752 },
753 self.measured_under()
754 ));
755 s
756 }
757}
758
759pub fn load_baseline(repo: &Repository, platform: &str) -> Result<Option<ResultDocument>> {
761 let path = baseline_path(repo, platform);
762 if !path.exists() {
763 return Ok(None);
764 }
765 let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
766 let doc: ResultDocument = serde_json::from_str(&text).map_err(|e| Error::InvalidManifest {
767 path,
768 reason: format!("not a benchmark result document: {e}"),
769 })?;
770 Ok(Some(doc))
771}
772
773pub fn write_baseline(repo: &Repository, run: &ResultDocument) -> Result<PathBuf> {
776 let path = baseline_path(repo, &run.provenance.platform());
777 if let Some(dir) = path.parent() {
778 std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
779 }
780 write_atomic(&path, &run.render())?;
781 Ok(path)
782}