main.rs

  1//! Perf profiler for Zed tests. Outputs timings of tests marked with the `#[perf]`
  2//! attribute to stdout in Markdown. See the documentation of `util_macros::perf`
  3//! for usage details on the actual attribute.
  4//!
  5//! # Setup
  6//! Make sure `hyperfine` is installed and in the shell path.
  7//!
  8//! # Usage
  9//! Calling this tool rebuilds the targeted crate(s) with some cfg flags set for the
 10//! perf proc macro *and* enables optimisations (`release-fast` profile), so expect
 11//! it to take a little while.
 12//!
 13//! To test an individual crate, run:
 14//! ```sh
 15//! cargo perf-test -p $CRATE
 16//! ```
 17//!
 18//! To test everything (which will be **VERY SLOW**), run:
 19//! ```sh
 20//! cargo perf-test --workspace
 21//! ```
 22//!
 23//! Some command-line parameters are also recognised by this profiler. To filter
 24//! out all tests below a certain importance (e.g. `important`), run:
 25//! ```sh
 26//! cargo perf-test $WHATEVER -- --important
 27//! ```
 28//!
 29//! Similarly, to skip outputting progress to the command line, pass `-- --quiet`.
 30//! These flags can be combined.
 31//!
 32//! ## Comparing runs
 33//! Passing `--json=ident` will save per-crate run files in `.perf-runs`, e.g.
 34//! `cargo perf-test -p gpui -- --json=blah` will result in `.perf-runs/blah.gpui.json`
 35//! being created (unless no tests were run). These results can be automatically
 36//! compared. To do so, run `cargo perf-compare new-ident old-ident`.
 37//!
 38//! NB: All files matching `.perf-runs/ident.*.json` will be considered when
 39//! doing this comparison, so ensure there aren't leftover files in your `.perf-runs`
 40//! directory that might match that!
 41//!
 42//! # Notes
 43//! This should probably not be called manually unless you're working on the profiler
 44//! itself; use the `cargo perf-test` alias (after building this crate) instead.
 45
 46use perf::{FailKind, Importance, Output, TestMdata, Timings, consts};
 47
 48use std::{
 49    fs::OpenOptions,
 50    io::Write,
 51    num::NonZero,
 52    path::{Path, PathBuf},
 53    process::{Command, Stdio},
 54    sync::atomic::{AtomicBool, Ordering},
 55    time::{Duration, Instant},
 56};
 57
 58/// How many iterations to attempt the first time a test is run.
 59const DEFAULT_ITER_COUNT: NonZero<usize> = NonZero::new(3).unwrap();
 60/// Multiplier for the iteration count when a test doesn't pass the noise cutoff.
 61const ITER_COUNT_MUL: NonZero<usize> = NonZero::new(4).unwrap();
 62
 63/// Do we keep stderr empty while running the tests?
 64static QUIET: AtomicBool = AtomicBool::new(false);
 65
 66/// Report a failure into the output and skip an iteration.
 67macro_rules! fail {
 68    ($output:ident, $name:expr, $kind:expr) => {{
 69        $output.failure($name, None, None, $kind);
 70        continue;
 71    }};
 72    ($output:ident, $name:expr, $mdata:expr, $kind:expr) => {{
 73        $output.failure($name, Some($mdata), None, $kind);
 74        continue;
 75    }};
 76    ($output:ident, $name:expr, $mdata:expr, $count:expr, $kind:expr) => {{
 77        $output.failure($name, Some($mdata), Some($count), $kind);
 78        continue;
 79    }};
 80}
 81
 82/// How does this perf run return its output?
 83enum OutputKind<'a> {
 84    /// Print markdown to the terminal.
 85    Markdown,
 86    /// Save JSON to a file.
 87    Json(&'a Path),
 88}
 89
 90impl OutputKind<'_> {
 91    /// Logs the output of a run as per the `OutputKind`.
 92    fn log(&self, output: &Output, t_bin: &str) {
 93        match self {
 94            OutputKind::Markdown => print!("{output}"),
 95            OutputKind::Json(ident) => {
 96                let wspace_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
 97                let runs_dir = PathBuf::from(&wspace_dir).join(consts::RUNS_DIR);
 98                std::fs::create_dir_all(&runs_dir).unwrap();
 99                assert!(
100                    !ident.to_string_lossy().is_empty(),
101                    "FATAL: Empty filename specified!"
102                );
103                // Get the test binary's crate's name; a path like
104                // target/release-fast/deps/gpui-061ff76c9b7af5d7
105                // would be reduced to just "gpui".
106                let test_bin_stripped = Path::new(t_bin)
107                    .file_name()
108                    .unwrap()
109                    .to_str()
110                    .unwrap()
111                    .rsplit_once('-')
112                    .unwrap()
113                    .0;
114                let mut file_path = runs_dir.join(ident);
115                file_path
116                    .as_mut_os_string()
117                    .push(format!(".{test_bin_stripped}.json"));
118                let mut out_file = OpenOptions::new()
119                    .write(true)
120                    .create(true)
121                    .truncate(true)
122                    .open(&file_path)
123                    .unwrap();
124                out_file
125                    .write_all(&serde_json::to_vec(&output).unwrap())
126                    .unwrap();
127                if !QUIET.load(Ordering::Relaxed) {
128                    eprintln!("JSON output written to {}", file_path.display());
129                }
130            }
131        }
132    }
133}
134
135/// Runs a given metadata-returning function from a test handler, parsing its
136/// output into a `TestMdata`.
137fn parse_mdata(t_bin: &str, mdata_fn: &str) -> Result<TestMdata, FailKind> {
138    let mut cmd = Command::new(t_bin);
139    cmd.args([mdata_fn, "--exact", "--nocapture"]);
140    let out = cmd
141        .output()
142        .expect("FATAL: Could not run test binary {t_bin}");
143    assert!(out.status.success());
144    let stdout = String::from_utf8_lossy(&out.stdout);
145    let mut version = None;
146    let mut iterations = None;
147    let mut importance = Importance::default();
148    let mut weight = consts::WEIGHT_DEFAULT;
149    for line in stdout
150        .lines()
151        .filter_map(|l| l.strip_prefix(consts::MDATA_LINE_PREF))
152    {
153        let mut items = line.split_whitespace();
154        // For v0, we know the ident always comes first, then one field.
155        match items.next().ok_or(FailKind::BadMetadata)? {
156            consts::VERSION_LINE_NAME => {
157                let v = items
158                    .next()
159                    .ok_or(FailKind::BadMetadata)?
160                    .parse::<u32>()
161                    .map_err(|_| FailKind::BadMetadata)?;
162                if v > consts::MDATA_VER {
163                    return Err(FailKind::VersionMismatch);
164                }
165                version = Some(v);
166            }
167            consts::ITER_COUNT_LINE_NAME => {
168                // This should never be zero!
169                iterations = Some(
170                    items
171                        .next()
172                        .ok_or(FailKind::BadMetadata)?
173                        .parse::<usize>()
174                        .map_err(|_| FailKind::BadMetadata)?
175                        .try_into()
176                        .map_err(|_| FailKind::BadMetadata)?,
177                );
178            }
179            consts::IMPORTANCE_LINE_NAME => {
180                importance = match items.next().ok_or(FailKind::BadMetadata)? {
181                    "critical" => Importance::Critical,
182                    "important" => Importance::Important,
183                    "average" => Importance::Average,
184                    "iffy" => Importance::Iffy,
185                    "fluff" => Importance::Fluff,
186                    _ => return Err(FailKind::BadMetadata),
187                };
188            }
189            consts::WEIGHT_LINE_NAME => {
190                weight = items
191                    .next()
192                    .ok_or(FailKind::BadMetadata)?
193                    .parse::<u8>()
194                    .map_err(|_| FailKind::BadMetadata)?;
195            }
196            _ => unreachable!(),
197        }
198    }
199
200    Ok(TestMdata {
201        version: version.ok_or(FailKind::BadMetadata)?,
202        // Iterations may be determined by us and thus left unspecified.
203        iterations,
204        // In principle this should always be set, but just for the sake of
205        // stability allow the potentially-breaking change of not reporting the
206        // importance without erroring. Maybe we want to change this.
207        importance,
208        // Same with weight.
209        weight,
210    })
211}
212
213/// Compares the perf results of two profiles as per the arguments passed in.
214fn compare_profiles(args: &[String]) {
215    let ident_new = args.first().expect("FATAL: missing identifier for new run");
216    let ident_old = args.get(1).expect("FATAL: missing identifier for old run");
217    let wspace_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
218    let runs_dir = PathBuf::from(&wspace_dir).join(consts::RUNS_DIR);
219
220    // Use the blank outputs initially, so we can merge into these with prefixes.
221    let mut outputs_new = Output::blank();
222    let mut outputs_old = Output::blank();
223
224    for e in runs_dir.read_dir().unwrap() {
225        let Ok(entry) = e else {
226            continue;
227        };
228        let Ok(metadata) = entry.metadata() else {
229            continue;
230        };
231        if metadata.is_file() {
232            let Ok(name) = entry.file_name().into_string() else {
233                continue;
234            };
235
236            // A little helper to avoid code duplication. Reads the `output` from
237            // a json file, then merges it into what we have so far.
238            let read_into = |output: &mut Output| {
239                let mut elems = name.split('.').skip(1);
240                let prefix = elems.next().unwrap();
241                assert_eq!("json", elems.next().unwrap());
242                assert!(elems.next().is_none());
243                let handle = OpenOptions::new().read(true).open(entry.path()).unwrap();
244                let o_other: Output = serde_json::from_reader(handle).unwrap();
245                output.merge(o_other, prefix);
246            };
247
248            if name.starts_with(ident_old) {
249                read_into(&mut outputs_old);
250            } else if name.starts_with(ident_new) {
251                read_into(&mut outputs_new);
252            }
253        }
254    }
255
256    let res = outputs_new.compare_perf(outputs_old);
257    println!("{res}");
258}
259
260/// Runs a test binary, filtering out tests which aren't marked for perf triage
261/// and giving back the list of tests we care about.
262///
263/// The output of this is an iterator over `test_fn_name, test_mdata_name`.
264fn get_tests(t_bin: &str) -> impl ExactSizeIterator<Item = (String, String)> {
265    let mut cmd = Command::new(t_bin);
266    // --format=json is nightly-only :(
267    cmd.args(["--list", "--format=terse"]);
268    let out = cmd
269        .output()
270        .expect("FATAL: Could not run test binary {t_bin}");
271    assert!(
272        out.status.success(),
273        "FATAL: Cannot do perf check - test binary {t_bin} returned an error"
274    );
275    if !QUIET.load(Ordering::Relaxed) {
276        eprintln!("Test binary ran successfully; starting profile...");
277    }
278    // Parse the test harness output to look for tests we care about.
279    let stdout = String::from_utf8_lossy(&out.stdout);
280    let mut test_list: Vec<_> = stdout
281        .lines()
282        .filter_map(|line| {
283            // This should split only in two; e.g.,
284            // "app::test::test_arena: test" => "app::test::test_arena:", "test"
285            let line: Vec<_> = line.split_whitespace().collect();
286            match line[..] {
287                // Final byte of t_name is ":", which we need to ignore.
288                [t_name, kind] => (kind == "test").then(|| &t_name[..t_name.len() - 1]),
289                _ => None,
290            }
291        })
292        // Exclude tests that aren't marked for perf triage based on suffix.
293        .filter(|t_name| {
294            t_name.ends_with(consts::SUF_NORMAL) || t_name.ends_with(consts::SUF_MDATA)
295        })
296        .collect();
297
298    // Pulling itertools just for .dedup() would be quite a big dependency that's
299    // not used elsewhere, so do this on a vec instead.
300    test_list.sort_unstable();
301    test_list.dedup();
302
303    // Tests should come in pairs with their mdata fn!
304    assert!(
305        test_list.len().is_multiple_of(2),
306        "Malformed tests in test binary {t_bin}"
307    );
308
309    let out = test_list
310        .chunks_exact_mut(2)
311        .map(|pair| {
312            // Be resilient against changes to these constants.
313            if consts::SUF_NORMAL < consts::SUF_MDATA {
314                (pair[0].to_owned(), pair[1].to_owned())
315            } else {
316                (pair[1].to_owned(), pair[0].to_owned())
317            }
318        })
319        .collect::<Vec<_>>();
320    out.into_iter()
321}
322
323/// Runs the specified test `count` times, returning the time taken if the test
324/// succeeded.
325#[inline]
326fn spawn_and_iterate(t_bin: &str, t_name: &str, count: NonZero<usize>) -> Option<Duration> {
327    let mut cmd = Command::new(t_bin);
328    cmd.args([t_name, "--exact"]);
329    cmd.env(consts::ITER_ENV_VAR, format!("{count}"));
330    // Don't let the child muck up our stdin/out/err.
331    cmd.stdin(Stdio::null());
332    cmd.stdout(Stdio::null());
333    cmd.stderr(Stdio::null());
334    let pre = Instant::now();
335    // Discard the output beyond ensuring success.
336    let out = cmd.spawn().unwrap().wait();
337    let post = Instant::now();
338    out.iter().find_map(|s| s.success().then_some(post - pre))
339}
340
341/// Triage a test to determine the correct number of iterations that it should run.
342/// Specifically, repeatedly runs the given test until its execution time exceeds
343/// `thresh`, calling `step(iterations)` after every failed run to determine the new
344/// iteration count. Returns `None` if the test errored or `step` returned `None`,
345/// else `Some(iterations)`.
346///
347/// # Panics
348/// This will panic if `step(usize)` is not monotonically increasing, or if the test
349/// binary is invalid.
350fn triage_test(
351    t_bin: &str,
352    t_name: &str,
353    thresh: Duration,
354    mut step: impl FnMut(NonZero<usize>) -> Option<NonZero<usize>>,
355) -> Option<NonZero<usize>> {
356    let mut iter_count = DEFAULT_ITER_COUNT;
357    // It's possible that the first loop of a test might be an outlier (e.g. it's
358    // doing some caching), in which case we want to skip it.
359    let duration_once = spawn_and_iterate(t_bin, t_name, NonZero::new(1).unwrap())?;
360    loop {
361        let duration = spawn_and_iterate(t_bin, t_name, iter_count)?;
362        if duration.saturating_sub(duration_once) > thresh {
363            break Some(iter_count);
364        }
365        let new = step(iter_count)?;
366        assert!(
367            new > iter_count,
368            "FATAL: step must be monotonically increasing"
369        );
370        iter_count = new;
371    }
372}
373
374/// Profiles a given test with hyperfine, returning the mean and standard deviation
375/// for its runtime. If the test errors, returns `None` instead.
376fn hyp_profile(t_bin: &str, t_name: &str, iterations: NonZero<usize>) -> Option<Timings> {
377    let mut perf_cmd = Command::new("hyperfine");
378    // Warm up the cache and print markdown output to stdout, which we parse.
379    perf_cmd.args([
380        "--style",
381        "none",
382        "--warmup",
383        "1",
384        "--export-markdown",
385        "-",
386        // Parse json instead...
387        "--time-unit",
388        "millisecond",
389        &format!("{t_bin} --exact {t_name}"),
390    ]);
391    perf_cmd.env(consts::ITER_ENV_VAR, format!("{iterations}"));
392    let p_out = perf_cmd.output().unwrap();
393    if !p_out.status.success() {
394        return None;
395    }
396
397    let cmd_output = String::from_utf8_lossy(&p_out.stdout);
398    // Can't use .last() since we have a trailing newline. Sigh.
399    let results_line = cmd_output.lines().nth(3).unwrap();
400    // Grab the values out of the pretty-print.
401    // TODO: Parse json instead.
402    let mut res_iter = results_line.split_whitespace();
403    // Durations are given in milliseconds, so account for that.
404    let mean = Duration::from_secs_f64(res_iter.nth(5).unwrap().parse::<f64>().unwrap() / 1000.);
405    let stddev = Duration::from_secs_f64(res_iter.nth(1).unwrap().parse::<f64>().unwrap() / 1000.);
406
407    Some(Timings { mean, stddev })
408}
409
410fn main() {
411    let args = std::env::args().collect::<Vec<_>>();
412    // We get passed the test we need to run as the 1st argument after our own name.
413    let t_bin = args
414        .get(1)
415        .expect("FATAL: No test binary or command; this shouldn't be manually invoked!");
416
417    // We're being asked to compare two results, not run the profiler.
418    if t_bin == "compare" {
419        compare_profiles(&args[2..]);
420        return;
421    }
422
423    // Minimum test importance we care about this run.
424    let mut thresh = Importance::Iffy;
425    // Where to print the output of this run.
426    let mut out_kind = OutputKind::Markdown;
427
428    for arg in args.iter().skip(2) {
429        match arg.as_str() {
430            "--critical" => thresh = Importance::Critical,
431            "--important" => thresh = Importance::Important,
432            "--average" => thresh = Importance::Average,
433            "--iffy" => thresh = Importance::Iffy,
434            "--fluff" => thresh = Importance::Fluff,
435            "--quiet" => QUIET.store(true, Ordering::Relaxed),
436            s if s.starts_with("--json") => {
437                out_kind = OutputKind::Json(Path::new(
438                    s.strip_prefix("--json=")
439                        .expect("FATAL: Invalid json parameter; pass --json=ident"),
440                ));
441            }
442            _ => (),
443        }
444    }
445    if !QUIET.load(Ordering::Relaxed) {
446        eprintln!("Starting perf check");
447    }
448
449    let mut output = Output::default();
450
451    // Spawn and profile an instance of each perf-sensitive test, via hyperfine.
452    // Each test is a pair of (test, metadata-returning-fn), so grab both. We also
453    // know the list is sorted.
454    let i = get_tests(t_bin);
455    let len = i.len();
456    for (idx, (ref t_name, ref t_mdata)) in i.enumerate() {
457        if !QUIET.load(Ordering::Relaxed) {
458            eprint!("\rProfiling test {}/{}", idx + 1, len);
459        }
460        // Pretty-printable stripped name for the test.
461        let t_name_pretty = t_name.replace(consts::SUF_NORMAL, "");
462
463        // Get the metadata this test reports for us.
464        let t_mdata = match parse_mdata(t_bin, t_mdata) {
465            Ok(mdata) => mdata,
466            Err(err) => fail!(output, t_name_pretty, err),
467        };
468
469        if t_mdata.importance < thresh {
470            fail!(output, t_name_pretty, t_mdata, FailKind::Skipped);
471        }
472
473        // Time test execution to see how many iterations we need to do in order
474        // to account for random noise. This is skipped for tests with fixed
475        // iteration counts.
476        let final_iter_count = t_mdata.iterations.or_else(|| {
477            triage_test(t_bin, t_name, consts::NOISE_CUTOFF, |c| {
478                if let Some(c) = c.checked_mul(ITER_COUNT_MUL) {
479                    Some(c)
480                } else {
481                    // This should almost never happen, but maybe..?
482                    eprintln!(
483                        "WARNING: Ran nearly usize::MAX iterations of test {t_name_pretty}; skipping"
484                    );
485                    None
486                }
487            })
488        });
489
490        // Don't profile failing tests.
491        let Some(final_iter_count) = final_iter_count else {
492            fail!(output, t_name_pretty, t_mdata, FailKind::Triage);
493        };
494
495        // Now profile!
496        if let Some(timings) = hyp_profile(t_bin, t_name, final_iter_count) {
497            output.success(t_name_pretty, t_mdata, final_iter_count, timings);
498        } else {
499            fail!(
500                output,
501                t_name_pretty,
502                t_mdata,
503                final_iter_count,
504                FailKind::Profile
505            );
506        }
507    }
508    if !QUIET.load(Ordering::Relaxed) {
509        if output.is_empty() {
510            eprintln!("Nothing to do.");
511        } else {
512            // If stdout and stderr are on the same terminal, move us after the
513            // output from above.
514            eprintln!();
515        }
516    }
517
518    // No need making an empty json file on every empty test bin.
519    if output.is_empty() {
520        return;
521    }
522
523    out_kind.log(&output, t_bin);
524}