perf: Greatly expand profiler (#38584)

Expands on #38543 (notably allows setting importance categories and
weights on tests, and a lot of internal refactoring) because I couldn't
help myself. Also allows exporting runs to json and comparing across them. See code for docs.

Release Notes:

- N/A
This commit is contained in:
Nia
2025-09-21 13:54:59 +02:00
committed by GitHub
parent 839c216620
commit 11041ef3b0
9 changed files with 919 additions and 168 deletions
+1
View File
@@ -16,6 +16,7 @@ doctest = false
[dependencies]
quote.workspace = true
syn.workspace = true
perf.workspace = true
workspace-hack.workspace = true
[features]
+138 -88
View File
@@ -1,6 +1,7 @@
#![cfg_attr(not(target_os = "windows"), allow(unused))]
#![allow(clippy::test_attr_in_doctest)]
use perf::*;
use proc_macro::TokenStream;
use quote::{ToTokens, quote};
use syn::{ItemFn, LitStr, parse_macro_input, parse_quote};
@@ -90,68 +91,81 @@ pub fn line_endings(input: TokenStream) -> TokenStream {
}
/// Inner data for the perf macro.
#[derive(Default)]
struct PerfArgs {
/// How many times to loop a test before rerunning the test binary.
/// If left empty, the test harness will auto-determine this value.
/// How many times to loop a test before rerunning the test binary. If left
/// empty, the test harness will auto-determine this value.
iterations: Option<syn::Expr>,
/// How much this test's results should be weighed when comparing across runs.
/// If unspecified, defaults to `WEIGHT_DEFAULT` (50).
weight: Option<syn::Expr>,
/// How relevant a benchmark is to overall performance. See docs on the enum
/// for details. If unspecified, `Average` is selected.
importance: Importance,
}
impl syn::parse::Parse for PerfArgs {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
if input.is_empty() {
return Ok(PerfArgs { iterations: None });
#[warn(clippy::all, clippy::pedantic)]
impl PerfArgs {
/// Parses attribute arguments into a `PerfArgs`.
fn parse_into(&mut self, meta: syn::meta::ParseNestedMeta) -> syn::Result<()> {
if meta.path.is_ident("iterations") {
self.iterations = Some(meta.value()?.parse()?);
} else if meta.path.is_ident("weight") {
self.weight = Some(meta.value()?.parse()?);
} else if meta.path.is_ident("critical") {
self.importance = Importance::Critical;
} else if meta.path.is_ident("important") {
self.importance = Importance::Important;
} else if meta.path.is_ident("average") {
// This shouldn't be specified manually, but oh well.
self.importance = Importance::Average;
} else if meta.path.is_ident("iffy") {
self.importance = Importance::Iffy;
} else if meta.path.is_ident("fluff") {
self.importance = Importance::Fluff;
} else {
return Err(syn::Error::new_spanned(meta.path, "unexpected identifier"));
}
let mut iterations = None;
// In principle we only have one possible argument, but leave this as
// a loop in case we expand this in the future.
for meta in
syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated(input)?
{
match &meta {
syn::Meta::NameValue(meta_name_value) => {
if meta_name_value.path.is_ident("iterations") {
iterations = Some(meta_name_value.value.clone());
} else {
return Err(syn::Error::new_spanned(
&meta_name_value.path,
"unexpected argument, expected 'iterations'",
));
}
}
_ => {
return Err(syn::Error::new_spanned(
meta,
"expected name-value argument like 'iterations = 1'",
));
}
}
}
Ok(PerfArgs { iterations })
Ok(())
}
}
/// Marks a test as perf-sensitive, to be triaged when checking the performance
/// of a build. This also automatically applies `#[test]`.
///
/// By default, the number of iterations when profiling this test is auto-determined.
/// If this needs to be overwritten, pass the desired iteration count to the macro
/// as a parameter (`#[perf(iterations = n)]`). Note that the actual profiler may still
/// run the test an arbitrary number times; this flag just sets the number of executions
/// before the process is restarted and global state is reset.
///
/// # Usage notes
/// This should probably not be applied to tests that do any significant fs IO, as
/// locks on files may not be released in time when repeating a test many times. This
/// might lead to spurious failures.
/// # Usage
/// Applying this attribute to a test marks it as average importance by default.
/// There are 4 levels of importance (`Critical`, `Important`, `Average`, `Fluff`);
/// see the documentation on `Importance` for details. Add the importance as a
/// parameter to override the default (e.g. `#[perf(important)]`).
///
/// Each test also has a weight factor. This is irrelevant on its own, but is considered
/// when comparing results across different runs. By default, this is set to 50;
/// pass `weight = n` as a parameter to override this. Note that this value is only
/// relevant within its importance category.
///
/// By default, the number of iterations when profiling this test is auto-determined.
/// If this needs to be overwritten, pass the desired iteration count as a parameter
/// (`#[perf(iterations = n)]`). Note that the actual profiler may still run the test
/// an arbitrary number times; this flag just sets the number of executions before the
/// process is restarted and global state is reset.
///
/// This attribute should probably not be applied to tests that do any significant
/// disk IO, as locks on files may not be released in time when repeating a test many
/// times. This might lead to spurious failures.
///
/// # Examples
/// ```rust
/// use util_macros::perf;
///
/// #[perf]
/// fn expensive_computation_test() {
/// fn generic_test() {
/// // Test goes here.
/// }
///
/// #[perf(fluff, weight = 30)]
/// fn cold_path_test() {
/// // Test goes here.
/// }
/// ```
@@ -161,72 +175,108 @@ impl syn::parse::Parse for PerfArgs {
/// ```rust,ignore
/// use util_macros::perf;
///
/// #[perf(iterations = 1)]
/// #[perf(iterations = 1, critical)]
/// #[gpui::test]
/// fn oneshot_test(_cx: &mut gpui::TestAppContext) {
/// // Test goes here.
/// }
/// ```
#[proc_macro_attribute]
#[warn(clippy::all, clippy::pedantic)]
pub fn perf(our_attr: TokenStream, input: TokenStream) -> TokenStream {
// If any of the below constants are changed, make sure to also update the perf
// profiler to match!
/// The suffix on tests marked with `#[perf]`.
const SUF_NORMAL: &str = "__ZED_PERF";
/// The suffix on tests marked with `#[perf(iterations = n)]`.
const SUF_FIXED: &str = "__ZED_PERF_FIXEDITER";
/// The env var in which we pass the iteration count to our tests.
const ITER_ENV_VAR: &str = "ZED_PERF_ITER";
let iter_count = parse_macro_input!(our_attr as PerfArgs).iterations;
let mut args = PerfArgs::default();
let parser = syn::meta::parser(|meta| PerfArgs::parse_into(&mut args, meta));
parse_macro_input!(our_attr with parser);
let ItemFn {
mut attrs,
attrs: mut attrs_main,
vis,
mut sig,
sig: mut sig_main,
block,
} = parse_macro_input!(input as ItemFn);
attrs.push(parse_quote!(#[test]));
attrs.push(parse_quote!(#[allow(non_snake_case)]));
attrs_main.push(parse_quote!(#[test]));
attrs_main.push(parse_quote!(#[allow(non_snake_case)]));
let fns = if cfg!(perf_enabled) {
#[allow(clippy::wildcard_imports, reason = "We control the other side")]
use consts::*;
let block: Box<syn::Block> = if cfg!(perf_enabled) {
// Make the ident obvious when calling, for the test parser.
let mut new_ident = sig.ident.to_string();
if iter_count.is_some() {
new_ident.push_str(SUF_FIXED);
} else {
new_ident.push_str(SUF_NORMAL);
}
// Also set up values for the second metadata-returning "test".
let mut new_ident_main = sig_main.ident.to_string();
let mut new_ident_meta = new_ident_main.clone();
new_ident_main.push_str(SUF_NORMAL);
new_ident_meta.push_str(SUF_MDATA);
let new_ident = syn::Ident::new(&new_ident, sig.ident.span());
sig.ident = new_ident;
// If we have a preset iteration count, just use that.
if let Some(iter_count) = iter_count {
parse_quote!({
for _ in 0..#iter_count {
#block
}
})
} else {
// Otherwise, the perf harness will pass us the value in an env var.
let new_ident_main = syn::Ident::new(&new_ident_main, sig_main.ident.span());
sig_main.ident = new_ident_main;
// We don't want any nonsense if the original test had a weird signature.
let new_ident_meta = syn::Ident::new(&new_ident_meta, sig_main.ident.span());
let sig_meta = parse_quote!(fn #new_ident_meta());
let attrs_meta = parse_quote!(#[test] #[allow(non_snake_case)]);
// Make the test loop as the harness instructs it to.
let block_main = {
// The perf harness will pass us the value in an env var. Even if we
// have a preset value, just do this to keep the code paths unified.
parse_quote!({
let iter_count = std::env::var(#ITER_ENV_VAR).unwrap().parse::<usize>().unwrap();
for _ in 0..iter_count {
#block
}
})
}
};
let importance = format!("{}", args.importance);
let block_meta = {
// This function's job is to just print some relevant info to stdout,
// based on the params this attr is passed. It's not an actual test.
// Since we use a custom attr set on our metadata fn, it shouldn't
// cause problems with xfail tests.
let q_iter = if let Some(iter) = args.iterations {
quote! {
println!("{} {} {}", #MDATA_LINE_PREF, #ITER_COUNT_LINE_NAME, #iter);
}
} else {
quote! {}
};
let weight = args
.weight
.unwrap_or_else(|| parse_quote! { #WEIGHT_DEFAULT });
parse_quote!({
#q_iter
println!("{} {} {}", #MDATA_LINE_PREF, #WEIGHT_LINE_NAME, #weight);
println!("{} {} {}", #MDATA_LINE_PREF, #IMPORTANCE_LINE_NAME, #importance);
println!("{} {} {}", #MDATA_LINE_PREF, #VERSION_LINE_NAME, #MDATA_VER);
})
};
vec![
// The real test.
ItemFn {
attrs: attrs_main,
vis: vis.clone(),
sig: sig_main,
block: block_main,
},
// The fake test.
ItemFn {
attrs: attrs_meta,
vis,
sig: sig_meta,
block: block_meta,
},
]
} else {
block
vec![ItemFn {
attrs: attrs_main,
vis,
sig: sig_main,
block,
}]
};
ItemFn {
attrs,
vis,
sig,
block,
}
.into_token_stream()
.into()
fns.into_iter()
.flat_map(|f| TokenStream::from(f.into_token_stream()))
.collect()
}