450 lines · 17.5 KB · Rust
RawDownload
//! The implementation of the this crate is kept in a separate module//! so that it is easy to publish this crate as part of GPUI's dependencies use collections::HashMap;use serde::{Deserialize, Serialize};use std::{num::NonZero, time::Duration}; pub mod consts {    //! Preset identifiers and constants so that the profiler and proc macro agree    //! on their communication protocol.     /// The suffix on the actual test function.    pub const SUF_NORMAL: &str = "__ZED_PERF_FN";    /// The suffix on an extra function which prints metadata about a test to stdout.    pub const SUF_MDATA: &str = "__ZED_PERF_MDATA";    /// The env var in which we pass the iteration count to our tests.    pub const ITER_ENV_VAR: &str = "ZED_PERF_ITER";    /// The prefix printed on all benchmark test metadata lines, to distinguish it from    /// possible output by the test harness itself.    pub const MDATA_LINE_PREF: &str = "ZED_MDATA_";    /// The version number for the data returned from the test metadata function.    /// Increment on non-backwards-compatible changes.    pub const MDATA_VER: u32 = 0;    /// The default weight, if none is specified.    pub const WEIGHT_DEFAULT: u8 = 50;    /// How long a test must have run to be assumed to be reliable-ish.    pub const NOISE_CUTOFF: std::time::Duration = std::time::Duration::from_millis(250);     /// Identifier for the iteration count of a test metadata.    pub const ITER_COUNT_LINE_NAME: &str = "iter_count";    /// Identifier for the weight of a test metadata.    pub const WEIGHT_LINE_NAME: &str = "weight";    /// Identifier for importance in test metadata.    pub const IMPORTANCE_LINE_NAME: &str = "importance";    /// Identifier for the test metadata version.    pub const VERSION_LINE_NAME: &str = "version";     /// Where to save json run information.    pub const RUNS_DIR: &str = ".perf-runs";} /// How relevant a benchmark is.#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]pub enum Importance {    /// Regressions shouldn't be accepted without good reason.    Critical = 4,    /// Regressions should be paid extra attention.    Important = 3,    /// No extra attention should be paid to regressions, but they might still    /// be indicative of something happening.    #[default]    Average = 2,    /// Unclear if regressions are likely to be meaningful, but still worth keeping    /// an eye on. Lowest level that's checked by default by the profiler.    Iffy = 1,    /// Regressions are likely to be spurious or don't affect core functionality.    /// Only relevant if a lot of them happen, or as supplemental evidence for a    /// higher-importance benchmark regressing. Not checked by default.    Fluff = 0,} impl std::fmt::Display for Importance {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        match self {            Importance::Critical => f.write_str("critical"),            Importance::Important => f.write_str("important"),            Importance::Average => f.write_str("average"),            Importance::Iffy => f.write_str("iffy"),            Importance::Fluff => f.write_str("fluff"),        }    }} /// Why or when did this test fail?#[derive(Clone, Debug, Serialize, Deserialize)]pub enum FailKind {    /// Failed while triaging it to determine the iteration count.    Triage,    /// Failed while profiling it.    Profile,    /// Failed due to an incompatible version for the test.    VersionMismatch,    /// Could not parse metadata for a test.    BadMetadata,    /// Skipped due to filters applied on the perf run.    Skipped,} impl std::fmt::Display for FailKind {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        match self {            FailKind::Triage => f.write_str("errored in triage"),            FailKind::Profile => f.write_str("errored while profiling"),            FailKind::VersionMismatch => f.write_str("test version mismatch"),            FailKind::BadMetadata => f.write_str("bad test metadata"),            FailKind::Skipped => f.write_str("skipped"),        }    }} /// Information about a given perf test.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct TestMdata {    /// A version number for when the test was generated. If this is greater    /// than the version this test handler expects, one of the following will    /// happen in an unspecified manner:    /// - The test is skipped silently.    /// - The handler exits with an error message indicating the version mismatch    ///   or inability to parse the metadata.    ///    /// INVARIANT: If `version` <= `MDATA_VER`, this tool *must* be able to    /// correctly parse the output of this test.    pub version: u32,    /// How many iterations to pass this test if this is preset, or how many    /// iterations a test ended up running afterwards if determined at runtime.    pub iterations: Option<NonZero<usize>>,    /// The importance of this particular test. See the docs on `Importance` for    /// details.    pub importance: Importance,    /// The weight of this particular test within its importance category. Used    /// when comparing across runs.    pub weight: u8,} /// The actual timings of a test, as measured by Hyperfine.#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Timings {    /// Mean runtime for `self.iter_total` runs of this test.    pub mean: Duration,    /// Standard deviation for the above.    pub stddev: Duration,} impl Timings {    /// How many iterations does this test seem to do per second?    #[expect(        clippy::cast_precision_loss,        reason = "We only care about a couple sig figs anyways"    )]    #[must_use]    pub fn iters_per_sec(&self, total_iters: NonZero<usize>) -> f64 {        (1000. / self.mean.as_millis() as f64) * total_iters.get() as f64    }} /// Aggregate results, meant to be used for a given importance category. Each/// test name corresponds to its benchmark results, iteration count, and weight.type CategoryInfo = HashMap<String, (Timings, NonZero<usize>, u8)>; /// Aggregate output of all tests run by this handler.#[derive(Clone, Debug, Default, Serialize, Deserialize)]pub struct Output {    /// A list of test outputs. Format is `(test_name, mdata, timings)`.    /// The latter being `Ok(_)` indicates the test succeeded.    ///    /// INVARIANT: If the test succeeded, the second field is `Some(mdata)` and    /// `mdata.iterations` is `Some(_)`.    tests: Vec<(String, Option<TestMdata>, Result<Timings, FailKind>)>,} impl Output {    /// Instantiates an empty "output". Useful for merging.    #[must_use]    pub fn blank() -> Self {        Output { tests: Vec::new() }    }     /// Reports a success and adds it to this run's `Output`.    pub fn success(        &mut self,        name: impl AsRef<str>,        mut mdata: TestMdata,        iters: NonZero<usize>,        timings: Timings,    ) {        mdata.iterations = Some(iters);        self.tests            .push((name.as_ref().to_string(), Some(mdata), Ok(timings)));    }     /// Reports a failure and adds it to this run's `Output`. If this test was tried    /// with some number of iterations (i.e. this was not a version mismatch or skipped    /// test), it should be reported also.    ///    /// Using the `fail!()` macro is usually more convenient.    pub fn failure(        &mut self,        name: impl AsRef<str>,        mut mdata: Option<TestMdata>,        attempted_iters: Option<NonZero<usize>>,        kind: FailKind,    ) {        if let Some(ref mut mdata) = mdata {            mdata.iterations = attempted_iters;        }        self.tests            .push((name.as_ref().to_string(), mdata, Err(kind)));    }     /// True if no tests executed this run.    #[must_use]    pub fn is_empty(&self) -> bool {        self.tests.is_empty()    }     /// Sorts the runs in the output in the order that we want them printed.    pub fn sort(&mut self) {        self.tests.sort_unstable_by(|a, b| match (a, b) {            // Tests where we got no metadata go at the end.            ((_, Some(_), _), (_, None, _)) => std::cmp::Ordering::Greater,            ((_, None, _), (_, Some(_), _)) => std::cmp::Ordering::Less,            // Then sort by importance, then weight.            ((_, Some(a_mdata), _), (_, Some(b_mdata), _)) => {                let c = a_mdata.importance.cmp(&b_mdata.importance);                if matches!(c, std::cmp::Ordering::Equal) {                    a_mdata.weight.cmp(&b_mdata.weight)                } else {                    c                }            }            // Lastly by name.            ((a_name, ..), (b_name, ..)) => a_name.cmp(b_name),        });    }     /// Merges the output of two runs, appending a prefix to the results of the new run.    /// To be used in conjunction with `Output::blank()`, or else only some tests will have    /// a prefix set.    pub fn merge<'a>(&mut self, other: Self, pref_other: impl Into<Option<&'a str>>) {        let pref = if let Some(pref) = pref_other.into() {            "crates/".to_string() + pref + "::"        } else {            String::new()        };        self.tests = std::mem::take(&mut self.tests)            .into_iter()            .chain(                other                    .tests                    .into_iter()                    .map(|(name, md, tm)| (pref.clone() + &name, md, tm)),            )            .collect();    }     /// Evaluates the performance of `self` against `baseline`. The latter is taken    /// as the comparison point, i.e. a positive resulting `PerfReport` means that    /// `self` performed better.    ///    /// # Panics    /// `self` and `baseline` are assumed to have the iterations field on all    /// `TestMdata`s set to `Some(_)` if the `TestMdata` is present itself.    #[must_use]    pub fn compare_perf(self, baseline: Self) -> PerfReport {        let self_categories = self.collapse();        let mut other_categories = baseline.collapse();         let deltas = self_categories            .into_iter()            .filter_map(|(cat, self_data)| {                // Only compare categories where both           meow                // runs have data.                              /                let mut other_data = other_categories.remove(&cat)?;                let mut max = f64::MIN;                let mut min = f64::MAX;                 // Running totals for averaging out tests.                let mut r_total_numerator = 0.;                let mut r_total_denominator = 0;                // Yeah this is O(n^2), but realistically it'll hardly be a bottleneck.                for (name, (s_timings, s_iters, weight)) in self_data {                    // Only use the new weights if they conflict.                    let Some((o_timings, o_iters, _)) = other_data.remove(&name) else {                        continue;                    };                    let shift =                        (o_timings.iters_per_sec(o_iters) / s_timings.iters_per_sec(s_iters)) - 1.;                    if shift > max {                        max = shift;                    }                    if shift < min {                        min = shift;                    }                    r_total_numerator += shift * f64::from(weight);                    r_total_denominator += u32::from(weight);                }                // There were no runs here!                if r_total_denominator == 0 {                    None                } else {                    let mean = r_total_numerator / f64::from(r_total_denominator);                    // TODO: also aggregate standard deviation? That's harder to keep                    // meaningful, though, since we dk which tests are correlated.                    Some((cat, PerfDelta { max, mean, min }))                }            })            .collect();         PerfReport { deltas }    }     /// Collapses the `PerfReport` into a `HashMap` over `Importance`, with    /// each importance category having its tests contained.    fn collapse(self) -> HashMap<Importance, CategoryInfo> {        let mut categories = HashMap::<Importance, HashMap<String, _>>::default();        for entry in self.tests {            if let Some(mdata) = entry.1                && let Ok(timings) = entry.2            {                if let Some(handle) = categories.get_mut(&mdata.importance) {                    handle.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight));                } else {                    let mut new = HashMap::default();                    new.insert(entry.0, (timings, mdata.iterations.unwrap(), mdata.weight));                    categories.insert(mdata.importance, new);                }            }        }         categories    }} impl std::fmt::Display for Output {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        // Don't print the header for an empty run.        if self.tests.is_empty() {            return Ok(());        }         // We want to print important tests at the top, then alphabetical.        let mut sorted = self.clone();        sorted.sort();        // Markdown header for making a nice little table :>        writeln!(            f,            "| Command | Iter/sec | Mean [ms] | SD [ms] | Iterations | Importance (weight) |",        )?;        writeln!(f, "|:---|---:|---:|---:|---:|---:|")?;        for (name, metadata, timings) in &sorted.tests {            match metadata {                Some(metadata) => match timings {                    // Happy path.                    Ok(timings) => {                        // If the test succeeded, then metadata.iterations is Some(_).                        writeln!(                            f,                            "| {} | {:.2} | {} | {:.2} | {} | {} ({}) |",                            name,                            timings.iters_per_sec(metadata.iterations.unwrap()),                            {                                // Very small mean runtimes will give inaccurate                                // results. Should probably also penalise weight.                                let mean = timings.mean.as_secs_f64() * 1000.;                                if mean < consts::NOISE_CUTOFF.as_secs_f64() * 1000. / 8. {                                    format!("{mean:.2} (unreliable)")                                } else {                                    format!("{mean:.2}")                                }                            },                            timings.stddev.as_secs_f64() * 1000.,                            metadata.iterations.unwrap(),                            metadata.importance,                            metadata.weight,                        )?;                    }                    // We have (some) metadata, but the test errored.                    Err(err) => writeln!(                        f,                        "| ({}) {} | N/A | N/A | N/A | {} | {} ({}) |",                        err,                        name,                        metadata                            .iterations                            .map_or_else(|| "N/A".to_owned(), |i| format!("{i}")),                        metadata.importance,                        metadata.weight                    )?,                },                // No metadata, couldn't even parse the test output.                None => writeln!(                    f,                    "| ({}) {} | N/A | N/A | N/A | N/A | N/A |",                    timings.as_ref().unwrap_err(),                    name                )?,            }        }        Ok(())    }} /// The difference in performance between two runs within a given importance/// category.struct PerfDelta {    /// The biggest improvement / least bad regression.    max: f64,    /// The weighted average change in test times.    mean: f64,    /// The worst regression / smallest improvement.    min: f64,} /// Shim type for reporting all performance deltas across importance categories.pub struct PerfReport {    /// Inner (group, diff) pairing.    deltas: HashMap<Importance, PerfDelta>,} impl std::fmt::Display for PerfReport {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        if self.deltas.is_empty() {            return write!(f, "(no matching tests)");        }        let sorted = self.deltas.iter().collect::<Vec<_>>();        writeln!(f, "| Category | Max | Mean | Min |")?;        // We don't want to print too many newlines at the end, so handle newlines        // a little jankily like this.        write!(f, "|:---|---:|---:|---:|")?;        for (cat, delta) in sorted.into_iter().rev() {            const SIGN_POS: &str = "↑";            const SIGN_NEG: &str = "↓";            const SIGN_NEUTRAL_POS: &str = "±↑";            const SIGN_NEUTRAL_NEG: &str = "±↓";             let prettify = |time: f64| {                let sign = if time > 0.05 {                    SIGN_POS                } else if time > 0. {                    SIGN_NEUTRAL_POS                } else if time > -0.05 {                    SIGN_NEUTRAL_NEG                } else {                    SIGN_NEG                };                format!("{} {:.1}%", sign, time.abs() * 100.)            };             // Pretty-print these instead of just using the float display impl.            write!(                f,                "\n| {cat} | {} | {} | {} |",                prettify(delta.max),                prettify(delta.mean),                prettify(delta.min)            )?;        }        Ok(())    }}