Skip to main content

diskann_benchmark_runner/
benchmark.rs

1/*
2 * Copyright (c) Microsoft Corporation.
3 * Licensed under the MIT license.
4 */
5
6use serde::{Deserialize, Serialize};
7
8use crate::{Checkpoint, Input, Output};
9
10///////////////
11// Benchmark //
12///////////////
13
14/// A registered benchmark.
15///
16/// Benchmarks consist of an [`Input`] and a corresponding serialized `Output`. Inputs will
17/// first be validated with the benchmark using [`try_match`](Self::try_match). Only
18/// successful matches will be passed to [`run`](Self::run).
19pub trait Benchmark: 'static {
20    /// The [`Input`] type this benchmark matches against.
21    type Input: Input + 'static;
22
23    /// The concrete type of the results generated by this benchmark.
24    type Output: Serialize;
25
26    /// Return whether or not this benchmark is compatible with `input`.
27    ///
28    /// Use [`MatchContext::success`] to create an initial [`Score`], then progressively
29    /// refine it with [`Score::penalize`] and [`Score::fail`].
30    ///
31    /// Among successful matches, the benchmark with the lowest score wins. Ties are broken
32    /// by an unspecified procedure.
33    ///
34    /// When no successful match exists, failure scores rank the "nearest misses".
35    /// Implementations should use [`Score::fail`] with descriptive reasons to aid
36    /// diagnostics.
37    fn try_match(&self, input: &Self::Input, context: &MatchContext) -> Score;
38
39    /// Return descriptive information about the benchmark.
40    fn description(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result;
41
42    /// Run the benchmark with `input`.
43    ///
44    /// All prints should be directed to `output`. The `checkpoint` is provided so
45    /// long-running benchmarks can periodically save output to prevent data loss due to
46    /// an early error.
47    ///
48    /// Implementors may assume that [`Self::try_match`] returned a successful [`Score`]
49    /// for `input`.
50    fn run(
51        &self,
52        input: &Self::Input,
53        checkpoint: Checkpoint<'_>,
54        output: &mut dyn Output,
55    ) -> anyhow::Result<Self::Output>;
56}
57
58/// A refinement of [`Benchmark`], that supports before/after comparison of generated results.
59///
60/// Benchmarks are associated with a "tolerance" input, which may contain runtime values
61/// controlling the amount of slack a benchmark is allowed to have between runs before failing.
62///
63/// The semantics of pass or failure are left solely to the discretion of the [`Regression`]
64/// implementation.
65///
66/// See: [`register_regression`](crate::Registry::register_regression).
67pub trait Regression: Benchmark<Output: for<'a> Deserialize<'a>> {
68    /// The tolerance [`Input`] associated with this regression check.
69    type Tolerances: Input + 'static;
70
71    /// The report summary used to describe a successful regression check.
72    type Pass: Serialize + std::fmt::Display + 'static;
73
74    /// The report summary used to describe an unsuccessful regression check.
75    type Fail: Serialize + std::fmt::Display + 'static;
76
77    /// Run any regression checks necessary for two benchmark runs `before` and `after`.
78    /// Argument `tolerances` contain any tuned runtime tolerances to use when determining
79    /// whether or not a regression is detected.
80    ///
81    /// The `input` is the raw input that would have been provided to [`Benchmark::run`]
82    /// when generating the `before` and `after` outputs.
83    ///
84    /// Implementations of `check` should not attempt to print to `stdout` or any other
85    /// stream. Instead, all diagnostics should be encoded in the returned [`PassFail`] type
86    /// for reporting upstream.
87    fn check(
88        &self,
89        tolerances: &Self::Tolerances,
90        input: &Self::Input,
91        before: &Self::Output,
92        after: &Self::Output,
93    ) -> anyhow::Result<PassFail<Self::Pass, Self::Fail>>;
94}
95
96/// Describe whether or not a [`Regression`] passed or failed.
97#[derive(Debug, Clone, Copy)]
98pub enum PassFail<P, F> {
99    Pass(P),
100    Fail(F),
101}
102
103//////////////
104// Matching //
105//////////////
106
107/// Context for [`Benchmark::try_match`].
108///
109/// This is used to create matching [`Score`]s via [`Self::success`] and [`Self::fail`].
110/// Users can test their [`Benchmark::try_match`] implementations using [`Self::test`].
111///
112/// Internally, the [`MatchContext`] decides whether or not failure reasons are evaluated,
113/// eliding formatting in situations where the results will not be used.
114#[derive(Debug)]
115pub struct MatchContext {
116    record_failure_reasons: bool,
117}
118
119impl MatchContext {
120    /// Create a new [`Score`] configured for "success" with the given `score`.
121    ///
122    /// Lower scores indicate better successful matches.
123    pub fn success(&self, score: u32) -> Score {
124        Score {
125            inner: ScoreInner::Success(SuccessScore(score)),
126            context: self.hidden_clone(),
127        }
128    }
129
130    /// Create a new [`Score`] configured for "failure" with the given `score`.
131    ///
132    /// Lower scores indicate better "near misses".
133    pub fn fail(&self, score: u32, reason: &dyn std::fmt::Display) -> Score {
134        let mut s = self.success(u32::MAX);
135        s.fail(score, reason);
136        s
137    }
138
139    /// Test a [`Benchmark::try_match`] implementation, returning a [`TestScore`] with
140    /// full failure reasons (if applicable).
141    pub fn test<T>(benchmark: &T, input: &T::Input) -> TestScore
142    where
143        T: Benchmark,
144    {
145        benchmark
146            .try_match(input, &Self::with_reasons())
147            .into_test()
148    }
149
150    fn hidden_clone(&self) -> Self {
151        Self {
152            record_failure_reasons: self.record_failure_reasons,
153        }
154    }
155
156    /// Create a new [`MatchContext`] that does not evaluate failure reasons.
157    pub(crate) fn new() -> Self {
158        Self {
159            record_failure_reasons: false,
160        }
161    }
162
163    /// Create a new [`MatchContext`] that evaluates failure reasons.
164    pub(crate) fn with_reasons() -> Self {
165        Self {
166            record_failure_reasons: true,
167        }
168    }
169}
170
171/// The result of [`MatchContext::test`], providing low-level access to the final match
172/// scoring and failure reasons.
173#[derive(Debug)]
174pub enum TestScore {
175    Success(u32),
176    Failure {
177        score: u32,
178        reasons: Option<Vec<String>>,
179    },
180}
181
182/// A score for [`Benchmark::try_match`].
183///
184/// A [`Score`] is in one of two states: success or failure. Lower scores indicate better
185/// matches in both states. In the failure case, scores rank "near misses" for diagnostics.
186///
187/// - [`Score::penalize`] increases the score of a successful match.
188/// - [`Score::fail`] transitions to (or worsens) the failure state.
189///
190/// The current state can be queried with [`Score::is_success`].
191#[derive(Debug)]
192pub struct Score {
193    inner: ScoreInner,
194    context: MatchContext,
195}
196
197impl Score {
198    /// If the score is in the "success" state, penalize it by `by`.
199    ///
200    /// Has no effect if the score is already in the "failure" state.
201    pub fn penalize(&mut self, by: u32) {
202        match self.inner {
203            ScoreInner::Success(ref mut v) => v.0 = v.0.saturating_add(by),
204            ScoreInner::Failure(..) => {}
205        }
206    }
207
208    /// Transition the score to the "failure" state with penalty `by` and a `reason`.
209    ///
210    /// If the score is already in the "failure" state, `by` is added to the existing
211    /// failure score and `reason` is appended to the list of failure reasons.
212    pub fn fail(&mut self, by: u32, reason: &dyn std::fmt::Display) {
213        match &mut self.inner {
214            ScoreInner::Success(_) => {
215                self.inner = ScoreInner::Failure(
216                    FailureScore(by),
217                    self.context
218                        .record_failure_reasons
219                        .then(|| vec![reason.to_string()]),
220                );
221            }
222            ScoreInner::Failure(score, reasons) => {
223                score.0 = (score.0).saturating_add(by);
224                if self.context.record_failure_reasons {
225                    reasons
226                        .get_or_insert_with(|| Vec::with_capacity(1))
227                        .push(reason.to_string())
228                }
229            }
230        }
231    }
232
233    /// Return `true` if `self` is in the "success" state. Returning `false` implies the
234    /// "failure" state.
235    #[must_use = "this function has no side-effects"]
236    pub fn is_success(&self) -> bool {
237        matches!(self.inner, ScoreInner::Success(_))
238    }
239
240    fn into_test(self) -> TestScore {
241        match self.inner {
242            ScoreInner::Success(score) => TestScore::Success(score.0),
243            ScoreInner::Failure(score, reasons) => TestScore::Failure {
244                score: score.0,
245                reasons,
246            },
247        }
248    }
249
250    pub(crate) fn match_score(&self) -> Option<SuccessScore> {
251        match self.inner {
252            ScoreInner::Success(score) => Some(score),
253            ScoreInner::Failure(..) => None,
254        }
255    }
256
257    pub(crate) fn as_raw(&self) -> RawScore {
258        match self.inner {
259            ScoreInner::Success(s) => RawScore::Success(s),
260            ScoreInner::Failure(s, _) => RawScore::Failure(s),
261        }
262    }
263
264    pub(crate) fn reason(&self) -> Reason<'_> {
265        match &self.inner {
266            ScoreInner::Success(_) => Reason::none(),
267            ScoreInner::Failure(_, reasons) => Reason::new(reasons.as_deref()),
268        }
269    }
270}
271
272#[derive(Debug)]
273enum ScoreInner {
274    Success(SuccessScore),
275    Failure(FailureScore, Option<Vec<String>>),
276}
277
278pub(crate) struct Reason<'a>(Option<&'a [String]>);
279
280impl<'a> Reason<'a> {
281    fn new(reasons: Option<&'a [String]>) -> Self {
282        Self(reasons)
283    }
284
285    fn none() -> Self {
286        Self(None)
287    }
288}
289
290impl std::fmt::Display for Reason<'_> {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self.0 {
293            None => f.write_str("<missing>"),
294            Some(reasons) => {
295                let mut first = true;
296                for reason in reasons.iter() {
297                    if !first {
298                        writeln!(f)?;
299                    }
300                    write!(f, "- {}", reason)?;
301                    first = false;
302                }
303                Ok(())
304            }
305        }
306    }
307}
308
309#[derive(Debug, Clone, Copy, PartialEq)]
310pub(crate) enum RawScore {
311    Success(SuccessScore),
312    Failure(FailureScore),
313}
314
315impl RawScore {
316    #[cfg(test)]
317    fn success(score: u32) -> Self {
318        Self::Success(SuccessScore(score))
319    }
320
321    #[cfg(test)]
322    fn failure(score: u32) -> Self {
323        Self::Failure(FailureScore(score))
324    }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
328pub(crate) struct SuccessScore(pub(crate) u32);
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
331pub(crate) struct FailureScore(pub(crate) u32);
332
333//////////////
334// Internal //
335//////////////
336
337pub(crate) mod internal {
338    use super::*;
339
340    use crate::input::internal::Any;
341
342    use anyhow::Context;
343    use thiserror::Error;
344
345    /// Object-safe trait for type-erased benchmarks stored in the registry.
346    pub(crate) trait Benchmark {
347        fn try_match(&self, input: &Any, context: &MatchContext) -> Score;
348
349        fn description(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result;
350
351        fn run(
352            &self,
353            input: &Any,
354            checkpoint: Checkpoint<'_>,
355            output: &mut dyn Output,
356        ) -> anyhow::Result<serde_json::Value>;
357
358        /// If supported, return an object capable of running regression checks on this benchmark.
359        fn as_regression(&self) -> Option<&dyn Regression>;
360
361        fn as_string(&self) -> String {
362            Description(self).to_string()
363        }
364    }
365
366    struct Description<'a, T: ?Sized>(&'a T);
367
368    impl<T> std::fmt::Display for Description<'_, T>
369    where
370        T: Benchmark + ?Sized,
371    {
372        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373            self.0.description(f)
374        }
375    }
376
377    pub(crate) struct Checked {
378        pub(crate) json: serde_json::Value,
379        pub(crate) display: Box<dyn std::fmt::Display>,
380    }
381
382    impl Checked {
383        /// Serialize `value` to `serde_json::Value` and box it for future display.
384        fn new<T>(value: T) -> Result<Self, serde_json::Error>
385        where
386            T: Serialize + std::fmt::Display + 'static,
387        {
388            Ok(Self {
389                json: serde_json::to_value(&value)?,
390                display: Box::new(value),
391            })
392        }
393    }
394
395    pub(crate) type CheckedPassFail = PassFail<Checked, Checked>;
396
397    pub(crate) trait Regression {
398        fn tolerance(&self) -> &dyn crate::input::internal::DynInput;
399        fn input_tag(&self) -> &'static str;
400        fn check(
401            &self,
402            tolerances: &Any,
403            input: &Any,
404            before: &serde_json::Value,
405            after: &serde_json::Value,
406        ) -> anyhow::Result<CheckedPassFail>;
407    }
408
409    impl std::fmt::Debug for dyn Regression + '_ {
410        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411            f.debug_struct("dyn Regression")
412                .field("tolerance", &self.tolerance().tag())
413                .field("input_tag", &self.input_tag())
414                .finish()
415        }
416    }
417
418    pub(crate) trait AsRegression<T> {
419        fn as_regression(benchmark: &T) -> Option<&dyn Regression>;
420    }
421
422    #[derive(Debug, Clone, Copy)]
423    pub(crate) struct NoRegression;
424
425    impl<T> AsRegression<T> for NoRegression {
426        fn as_regression(_benchmark: &T) -> Option<&dyn Regression> {
427            None
428        }
429    }
430
431    #[derive(Debug, Clone, Copy)]
432    pub(crate) struct WithRegression;
433
434    impl<T> AsRegression<T> for WithRegression
435    where
436        T: super::Regression,
437    {
438        fn as_regression(benchmark: &T) -> Option<&dyn Regression> {
439            Some(benchmark)
440        }
441    }
442
443    impl<T> Regression for T
444    where
445        T: super::Regression,
446    {
447        fn tolerance(&self) -> &dyn crate::input::internal::DynInput {
448            &crate::input::internal::Wrapper::<T::Tolerances>::INSTANCE
449        }
450
451        fn input_tag(&self) -> &'static str {
452            T::Input::tag()
453        }
454
455        fn check(
456            &self,
457            tolerance: &Any,
458            input: &Any,
459            before: &serde_json::Value,
460            after: &serde_json::Value,
461        ) -> anyhow::Result<CheckedPassFail> {
462            let tolerance = tolerance
463                .downcast_ref::<T::Tolerances>()
464                .ok_or_else(|| BadDownCast::new(T::Tolerances::tag(), tolerance.tag()))
465                .context("failed to obtain tolerance")?;
466
467            let input = input
468                .downcast_ref::<T::Input>()
469                .ok_or_else(|| BadDownCast::new(T::Input::tag(), input.tag()))
470                .context("failed to obtain input")?;
471
472            let before = T::Output::deserialize(before)
473                .map_err(|err| DeserializationError::new(Kind::Before, err))?;
474
475            let after = T::Output::deserialize(after)
476                .map_err(|err| DeserializationError::new(Kind::After, err))?;
477
478            let passfail = match self.check(tolerance, input, &before, &after)? {
479                PassFail::Pass(pass) => PassFail::Pass(Checked::new(pass)?),
480                PassFail::Fail(fail) => PassFail::Fail(Checked::new(fail)?),
481            };
482
483            Ok(passfail)
484        }
485    }
486
487    #[derive(Debug, Clone, Copy)]
488    pub(crate) struct Wrapper<T, R = NoRegression> {
489        benchmark: T,
490        _regression: R,
491    }
492
493    impl<T, R> Wrapper<T, R> {
494        pub(crate) const fn new(benchmark: T, regression: R) -> Self {
495            Self {
496                benchmark,
497                _regression: regression,
498            }
499        }
500    }
501
502    const MATCH_FAIL: u32 = 10_000;
503
504    impl<T, R> Benchmark for Wrapper<T, R>
505    where
506        T: super::Benchmark,
507        R: AsRegression<T>,
508    {
509        fn try_match(&self, input: &Any, context: &MatchContext) -> Score {
510            if let Some(cast) = input.downcast_ref::<T::Input>() {
511                self.benchmark.try_match(cast, context)
512            } else {
513                struct TagMismatch<T>(&'static str, std::marker::PhantomData<T>);
514
515                impl<T> std::fmt::Display for TagMismatch<T>
516                where
517                    T: super::Benchmark,
518                {
519                    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520                        write!(
521                            f,
522                            "expected tag \"{}\" - instead got \"{}\"",
523                            T::Input::tag(),
524                            self.0,
525                        )
526                    }
527                }
528
529                context.fail(
530                    MATCH_FAIL,
531                    &TagMismatch::<T>(input.tag(), std::marker::PhantomData),
532                )
533            }
534        }
535
536        fn description(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
537            writeln!(f, "tag \"{}\"", <T::Input as Input>::tag())?;
538            self.benchmark.description(f)
539        }
540
541        fn run(
542            &self,
543            input: &Any,
544            checkpoint: Checkpoint<'_>,
545            output: &mut dyn Output,
546        ) -> anyhow::Result<serde_json::Value> {
547            match input.downcast_ref::<T::Input>() {
548                Some(input) => {
549                    let result = self.benchmark.run(input, checkpoint, output)?;
550                    Ok(serde_json::to_value(result)?)
551                }
552                None => Err(BadDownCast::new(T::Input::tag(), input.tag()).into()),
553            }
554        }
555
556        // Extensions
557        fn as_regression(&self) -> Option<&dyn Regression> {
558            R::as_regression(&self.benchmark)
559        }
560    }
561
562    //--------//
563    // Errors //
564    //--------//
565
566    #[derive(Debug, Clone, Copy, Error)]
567    #[error(
568        "INTERNAL ERROR: bad downcast - expected \"{}\" but got \"{}\"",
569        self.expected,
570        self.got
571    )]
572    struct BadDownCast {
573        expected: &'static str,
574        got: &'static str,
575    }
576
577    impl BadDownCast {
578        fn new(expected: &'static str, got: &'static str) -> Self {
579            Self { expected, got }
580        }
581    }
582
583    #[derive(Debug, Error)]
584    #[error(
585        "the \"{}\" results do not match the output schema expected by this benchmark",
586        self.kind
587    )]
588    struct DeserializationError {
589        kind: Kind,
590        source: serde_json::Error,
591    }
592
593    impl DeserializationError {
594        fn new(kind: Kind, source: serde_json::Error) -> Self {
595            Self { kind, source }
596        }
597    }
598
599    #[derive(Debug, Clone, Copy)]
600    enum Kind {
601        Before,
602        After,
603    }
604
605    impl std::fmt::Display for Kind {
606        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
607            let as_str = match self {
608                Self::Before => "before",
609                Self::After => "after",
610            };
611
612            write!(f, "{}", as_str)
613        }
614    }
615}
616
617///////////
618// Tests //
619///////////
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_score_no_reasons() {
627        let context = MatchContext::new();
628        let mut score = context.success(0);
629        assert!(score.is_success());
630        assert_eq!(score.as_raw(), RawScore::success(0));
631
632        score.penalize(10);
633        assert!(score.is_success());
634        assert_eq!(score.as_raw(), RawScore::success(10));
635
636        score.penalize(10);
637        assert!(score.is_success());
638        assert_eq!(score.as_raw(), RawScore::success(20));
639
640        // Ensure that we saturate properly.
641        score.penalize(u32::MAX);
642        assert!(score.is_success());
643        assert_eq!(score.as_raw(), RawScore::success(u32::MAX));
644
645        // Switch to failure
646        score.fail(5, &"some reason that is not evaluated");
647        assert!(!score.is_success());
648        assert_eq!(score.as_raw(), RawScore::failure(5));
649
650        score.fail(10, &"another reason that is not evaluated");
651        assert!(!score.is_success());
652        assert_eq!(score.as_raw(), RawScore::failure(15));
653
654        // Calling `penalize` should have no effect.
655        score.penalize(5);
656        assert!(!score.is_success());
657        assert_eq!(score.as_raw(), RawScore::failure(15));
658
659        // Since we aren't recording reasons - nothing should be returned.
660        assert_eq!(score.reason().to_string(), "<missing>");
661    }
662
663    #[test]
664    fn test_score_with_reasons() {
665        let context = MatchContext::with_reasons();
666        let mut score = context.success(0);
667        assert!(score.is_success());
668        assert_eq!(score.as_raw(), RawScore::success(0));
669
670        score.penalize(10);
671        assert!(score.is_success());
672        assert_eq!(score.as_raw(), RawScore::success(10));
673
674        score.penalize(10);
675        assert!(score.is_success());
676        assert_eq!(score.as_raw(), RawScore::success(20));
677        assert_eq!(score.reason().to_string(), "<missing>");
678
679        // Ensure that we saturate properly.
680        score.penalize(u32::MAX);
681        assert!(score.is_success());
682        assert_eq!(score.as_raw(), RawScore::success(u32::MAX));
683
684        // Switch to failure
685        score.fail(5, &"some reason that is evaluated");
686        assert!(!score.is_success());
687        assert_eq!(score.as_raw(), RawScore::failure(5));
688
689        score.fail(10, &"another reason that is evaluated");
690        assert!(!score.is_success());
691        assert_eq!(score.as_raw(), RawScore::failure(15));
692
693        // Calling `penalize` should have no effect.
694        score.penalize(5);
695        assert!(!score.is_success());
696        assert_eq!(score.as_raw(), RawScore::failure(15));
697
698        // Reasons are recorded, so both failure reasons should be returned.
699        let expected = "- some reason that is evaluated\n\
700                        - another reason that is evaluated";
701        assert_eq!(score.reason().to_string(), expected);
702    }
703}