Skip to content

Commit bbd113a

Browse files
authored
Merge pull request #1390 from godot-rust/qol/bench-setup
`#[bench(manual)]` for more control, including setup
2 parents 732b1ae + 105c862 commit bbd113a

File tree

6 files changed

+114
-48
lines changed

6 files changed

+114
-48
lines changed

godot-macros/src/bench.rs

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,47 +19,79 @@ pub fn attribute_bench(input_decl: venial::Item) -> ParseResult<TokenStream> {
1919
_ => return bail!(&input_decl, "#[bench] can only be applied to functions"),
2020
};
2121

22-
// Note: allow attributes for things like #[rustfmt] or #[clippy]
23-
if func.generic_params.is_some() || !func.params.is_empty() || func.where_clause.is_some() {
22+
// Disallow generics, but allow attributes for things like #[rustfmt] or #[clippy].
23+
if func.generic_params.is_some() || func.where_clause.is_some() {
2424
return bad_signature(&func);
2525
}
2626

27-
// Ignore -> (), as no one does that by accident.
28-
// We need `ret` to make sure the type is correct and to avoid unused imports (by IDEs).
29-
let Some(ret) = func.return_ty else {
27+
let mut attr = KvParser::parse_required(&func.attributes, "bench", &func.name)?;
28+
let manual = attr.handle_alone("manual")?;
29+
let repetitions = attr.handle_usize("repeat")?;
30+
attr.finish()?;
31+
32+
// Validate attribute combinations.
33+
if manual && repetitions.is_some() {
3034
return bail!(
3135
func,
32-
"#[bench] function must return a value from its computation, to prevent optimizing the operation away"
36+
"#[bench(manual)] cannot be combined with `repeat` -- pass repetitions to bench_measure() instead"
3337
);
34-
};
38+
}
3539

36-
let mut attr = KvParser::parse_required(&func.attributes, "bench", &func.name)?;
37-
let repetitions = attr.handle_usize("repeat")?.unwrap_or(DEFAULT_REPETITIONS);
38-
attr.finish()?;
40+
let repetitions = repetitions.unwrap_or(DEFAULT_REPETITIONS);
41+
42+
// Validate parameter count.
43+
if !func.params.is_empty() {
44+
return bad_signature(&func);
45+
}
3946

4047
let bench_name = &func.name;
4148
let bench_name_str = func.name.to_string();
4249

4350
let body = &func.body;
4451

4552
// Filter out #[bench] itself, but preserve other attributes like #[allow], #[expect], etc.
46-
let other_attributes = retain_attributes_except(&func.attributes, "bench");
53+
let other_attributes: Vec<_> = retain_attributes_except(&func.attributes, "bench").collect();
4754

48-
Ok(quote! {
49-
#(#other_attributes)*
50-
pub fn #bench_name() {
51-
for _ in 0..#repetitions {
52-
let __ret: #ret = #body;
53-
crate::common::bench_used(__ret);
55+
let generated_fn = if manual {
56+
// Manual mode: user calls bench_measure() directly.
57+
let ret = func.return_ty;
58+
quote! {
59+
#(#other_attributes)*
60+
// Don't return crate::framework::BenchResult here, to keep user imports working naturally.
61+
pub fn #bench_name() -> #ret {
62+
#body
63+
}
64+
}
65+
} else {
66+
// Automatic mode: framework controls timing.
67+
// Ignore `-> ()`, as no one does that by accident.
68+
// We need `ret` to make sure the type is correct and to avoid unused imports (by IDEs).
69+
let Some(ret) = func.return_ty else {
70+
return bail!(
71+
func,
72+
"#[bench] function must return a value from its computation, to prevent optimizing the operation away"
73+
);
74+
};
75+
76+
quote! {
77+
#(#other_attributes)*
78+
pub fn #bench_name() -> crate::framework::BenchResult {
79+
crate::framework::bench_measure(#repetitions, || {
80+
let __ret: #ret = #body;
81+
__ret // passed onto bench_used() by caller.
82+
})
5483
}
5584
}
85+
};
86+
87+
Ok(quote! {
88+
#generated_fn
5689

5790
::godot::sys::plugin_add!(crate::framework::__GODOT_BENCH; crate::framework::RustBenchmark {
5891
name: #bench_name_str,
5992
file: std::file!(),
6093
line: std::line!(),
6194
function: #bench_name,
62-
repetitions: #repetitions,
6395
});
6496
})
6597
}
@@ -68,7 +100,12 @@ fn bad_signature(func: &venial::Function) -> Result<TokenStream, venial::Error>
68100
bail!(
69101
func,
70102
"#[bench] function must have one of these signatures:\
71-
\n fn {f}() {{ ... }}",
103+
\n\
104+
\n(1) #[bench]\
105+
\n fn {f}() -> T {{ ... }}\
106+
\n\
107+
\n(2) #[bench(manual)]\
108+
\n fn {f}() -> BenchResult {{ ... /* call to bench_measure() */ }}",
72109
f = func.name,
73110
)
74111
}

itest/rust/src/benchmarks/mod.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use godot::obj::{Gd, InstanceId, NewAlloc, NewGd, Singleton};
1616
use godot::prelude::{varray, Callable, RustCallable, Variant};
1717
use godot::register::GodotClass;
1818

19-
use crate::framework::bench;
19+
use crate::framework::{bench, bench_measure, BenchResult};
2020

2121
mod color;
2222

@@ -114,16 +114,18 @@ fn packed_array_from_iter_unknown_size() -> PackedInt32Array {
114114
}))
115115
}
116116

117-
#[bench(repeat = 25)]
118-
fn call_callv_rust_fn() -> Variant {
117+
#[bench(manual)]
118+
fn call_callv_rust_fn() -> BenchResult {
119119
let callable = Callable::from_fn("RustFunction", |_| ());
120-
callable.callv(&varray![])
120+
121+
bench_measure(25, || callable.callv(&varray![]))
121122
}
122123

123-
#[bench(repeat = 25)]
124-
fn call_callv_custom() -> Variant {
124+
#[bench(manual)]
125+
fn call_callv_custom() -> BenchResult {
125126
let callable = Callable::from_custom(MyRustCallable {});
126-
callable.callv(&varray![])
127+
128+
bench_measure(25, || callable.callv(&varray![]))
127129
}
128130

129131
// ----------------------------------------------------------------------------------------------------------------------------------------------

itest/rust/src/common.rs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,3 @@ where
2121

2222
assert_eq!(value, back);
2323
}
24-
25-
/// Signal to the compiler that a value is used (to avoid optimization).
26-
pub fn bench_used<T: Sized>(value: T) {
27-
// The following check would be used to prevent `()` arguments, ensuring that a value from the bench is actually going into the blackbox.
28-
// However, we run into this issue, despite no array being used: https://github.com/rust-lang/rust/issues/43408.
29-
// error[E0401]: can't use generic parameters from outer function
30-
// sys::static_assert!(std::mem::size_of::<T>() != 0, "returned unit value in benchmark; make sure to use a real value");
31-
32-
std::hint::black_box(value);
33-
}

itest/rust/src/framework/bencher.rs

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,39 +17,59 @@
1717
// Instead, we focus on min (fastest run) and median -- even median may vary quite a bit between runs; but it gives an idea of the distribution.
1818
// See also https://easyperf.net/blog/2019/12/30/Comparing-performance-measurements#average-median-minimum.
1919

20+
use std::any::TypeId;
2021
use std::time::{Duration, Instant};
2122

2223
const WARMUP_RUNS: usize = 200;
2324
const TEST_RUNS: usize = 501; // uneven, so median need not be interpolated.
2425
const METRIC_COUNT: usize = 2;
2526

26-
pub struct BenchResult {
27+
pub type BenchResult = Result<BenchMeasurement, String>;
28+
29+
pub struct BenchMeasurement {
2730
pub stats: [Duration; METRIC_COUNT],
2831
}
2932

3033
pub fn metrics() -> [&'static str; METRIC_COUNT] {
3134
["min", "median"]
3235
}
3336

34-
pub fn run_benchmark(code: fn(), inner_repetitions: usize) -> BenchResult {
37+
/// Measures the timing of the passed closure (repeated `inner_repetitions` times).
38+
///
39+
/// Used by both `#[bench]` automatic mode (generated by macro) and `#[bench(manual)]` (called explicitly).
40+
///
41+
/// Returns `Err(String)` if there is something wrong with the benchmark setup.
42+
pub fn bench_measure<R: 'static>(inner_repetitions: usize, work: impl Fn() -> R) -> BenchResult {
43+
// Runtime check: ensure the closure doesn't return ().
44+
if TypeId::of::<R>() == TypeId::of::<()>() {
45+
return Err("bench_measure(): closure must return non-unit type to prevent the computation from being optimized away".to_string());
46+
}
47+
48+
// Warmup phase.
3549
for _ in 0..WARMUP_RUNS {
36-
code();
50+
for _ in 0..inner_repetitions {
51+
let _ = work();
52+
}
3753
}
3854

55+
// Measurement phase.
3956
let mut times = Vec::with_capacity(TEST_RUNS);
4057
for _ in 0..TEST_RUNS {
4158
let start = Instant::now();
42-
code();
59+
for _ in 0..inner_repetitions {
60+
let fruits_of_labor = work();
61+
bench_used(fruits_of_labor);
62+
}
4363
let duration = start.elapsed();
4464

4565
times.push(duration / inner_repetitions as u32);
4666
}
4767
times.sort();
4868

49-
calculate_stats(times)
69+
Ok(calculate_stats(times))
5070
}
5171

52-
fn calculate_stats(times: Vec<Duration>) -> BenchResult {
72+
fn calculate_stats(times: Vec<Duration>) -> BenchMeasurement {
5373
// See top of file for rationale.
5474

5575
/*let mean = {
@@ -72,7 +92,17 @@ fn calculate_stats(times: Vec<Duration>) -> BenchResult {
7292
let min = times[0];
7393
let median = times[TEST_RUNS / 2];
7494

75-
BenchResult {
95+
BenchMeasurement {
7696
stats: [min, median],
7797
}
7898
}
99+
100+
/// Signal to the compiler that a value is used (to avoid optimization).
101+
fn bench_used<T: Sized>(value: T) {
102+
// The following check would be used to prevent `()` arguments, ensuring that a value from the bench is actually going into the blackbox.
103+
// However, we run into this issue, despite no array being used: https://github.com/rust-lang/rust/issues/43408.
104+
// error[E0401]: can't use generic parameters from outer function
105+
// sys::static_assert!(std::mem::size_of::<T>() != 0, "returned unit value in benchmark; make sure to use a real value");
106+
107+
std::hint::black_box(value);
108+
}

itest/rust/src/framework/mod.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,7 @@ pub struct RustBenchmark {
158158
pub file: &'static str,
159159
#[allow(dead_code)]
160160
pub line: u32,
161-
pub function: fn(),
162-
pub repetitions: usize,
161+
pub function: fn() -> BenchResult,
163162
}
164163

165164
pub fn passes_filter(filters: &[String], test_name: &str) -> bool {

itest/rust/src/framework/runner.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ impl IntegrationTests {
376376
print_bench_pre(bench.name, bench.file, last_file.as_deref());
377377
last_file = Some(bench.file.to_string());
378378

379-
let result = bencher::run_benchmark(bench.function, bench.repetitions);
379+
let result = (bench.function)();
380380
print_bench_post(result);
381381
}
382382
}
@@ -546,9 +546,17 @@ fn print_bench_pre(benchmark: &str, bench_file: &str, last_file: Option<&str>) {
546546
}
547547

548548
fn print_bench_post(result: BenchResult) {
549-
for stat in result.stats.iter() {
550-
print!(" {:>10.3}μs", stat.as_nanos() as f64 / 1000.0);
549+
match result {
550+
Ok(measured) => {
551+
for stat in measured.stats.iter() {
552+
print!(" {:>10.3}μs", stat.as_nanos() as f64 / 1000.0);
553+
}
554+
}
555+
Err(msg) => {
556+
print!(" {FMT_RED}ERROR: {msg}{FMT_END}");
557+
}
551558
}
559+
552560
println!();
553561
}
554562

0 commit comments

Comments
 (0)