Skip to content

Commit 2dbfd6c

Browse files
committed
Benchmark runner + #[bench] infrastructure
1 parent 50f401d commit 2dbfd6c

File tree

12 files changed

+433
-21
lines changed

12 files changed

+433
-21
lines changed

godot-macros/src/bench.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
*/
6+
7+
use proc_macro2::TokenStream;
8+
use quote::quote;
9+
use venial::{Declaration, Error, Function};
10+
11+
use crate::util::{bail, KvParser};
12+
use crate::ParseResult;
13+
14+
const DEFAULT_REPETITIONS: usize = 100;
15+
16+
pub fn attribute_bench(input_decl: Declaration) -> ParseResult<TokenStream> {
17+
let func = match input_decl {
18+
Declaration::Function(f) => f,
19+
_ => return bail!(&input_decl, "#[bench] can only be applied to functions"),
20+
};
21+
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() {
24+
return bad_signature(&func);
25+
}
26+
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 {
30+
return bail!(
31+
func,
32+
"#[bench] function must return a value from its computation, to prevent optimizing the operation away"
33+
);
34+
};
35+
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()?;
39+
40+
let bench_name = &func.name;
41+
let bench_name_str = func.name.to_string();
42+
43+
let body = &func.body;
44+
45+
Ok(quote! {
46+
pub fn #bench_name() {
47+
for _ in 0..#repetitions {
48+
let __ret: #ret = #body;
49+
crate::common::bench_used(__ret);
50+
}
51+
}
52+
53+
::godot::sys::plugin_add!(__GODOT_BENCH in crate::framework; crate::framework::RustBenchmark {
54+
name: #bench_name_str,
55+
file: std::file!(),
56+
line: std::line!(),
57+
function: #bench_name,
58+
repetitions: #repetitions,
59+
});
60+
})
61+
}
62+
63+
fn bad_signature(func: &Function) -> Result<TokenStream, Error> {
64+
bail!(
65+
func,
66+
"#[bench] function must have one of these signatures:\
67+
\n fn {f}() {{ ... }}",
68+
f = func.name,
69+
)
70+
}

godot-macros/src/lib.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@
44
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
55
*/
66

7+
mod bench;
78
mod class;
89
mod derive;
910
mod gdextension;
1011
mod itest;
1112
mod util;
1213

13-
use crate::util::ident;
1414
use proc_macro::TokenStream;
1515
use proc_macro2::TokenStream as TokenStream2;
1616
use quote::quote;
1717
use venial::Declaration;
1818

19+
use crate::util::ident;
20+
1921
// Below intra-doc link to the trait only works as HTML, not as symbol link.
2022
/// Derive macro for [the `GodotClass` trait](../obj/trait.GodotClass.html) on structs.
2123
///
@@ -528,6 +530,14 @@ pub fn itest(meta: TokenStream, input: TokenStream) -> TokenStream {
528530
translate_meta("itest", meta, input, itest::attribute_itest)
529531
}
530532

533+
/// Similar to `#[test]`, but runs an benchmark with Godot.
534+
///
535+
/// Calls the `fn` many times and gathers statistics from its execution time.
536+
#[proc_macro_attribute]
537+
pub fn bench(meta: TokenStream, input: TokenStream) -> TokenStream {
538+
translate_meta("bench", meta, input, bench::attribute_bench)
539+
}
540+
531541
/// Proc-macro attribute to be used in combination with the [`ExtensionLibrary`] trait.
532542
///
533543
/// [`ExtensionLibrary`]: trait.ExtensionLibrary.html

godot-macros/src/util/kv_parser.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,36 @@ impl KvParser {
125125
}
126126
}
127127

128+
pub fn handle_usize(&mut self, key: &str) -> ParseResult<Option<usize>> {
129+
let Some(expr) = self.handle_expr(key)? else {
130+
return Ok(None);
131+
};
132+
133+
let mut tokens = expr.into_iter();
134+
let Some(TokenTree::Literal(lit)) = tokens.next() else {
135+
return bail!(
136+
key,
137+
"missing value for '{key}' (must be unsigned integer literal)"
138+
);
139+
};
140+
141+
if let Some(surplus) = tokens.next() {
142+
return bail!(
143+
key,
144+
"value for '{key}' must be unsigned integer literal; found extra {surplus:?}"
145+
);
146+
}
147+
148+
let Ok(int) = lit.to_string().parse() else {
149+
return bail!(
150+
key,
151+
"value for '{key}' must be unsigned integer literal; found {lit:?}"
152+
);
153+
};
154+
155+
Ok(Some(int))
156+
}
157+
128158
/// Handles a key that must be provided and must have an identifier as the value.
129159
pub fn handle_ident_required(&mut self, key: &str) -> ParseResult<Ident> {
130160
self.handle_ident(key)?

godot-macros/src/util/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ pub fn bail_fn<R, T>(msg: impl AsRef<str>, tokens: T) -> ParseResult<R>
3636
where
3737
T: Spanned,
3838
{
39+
// TODO: using T: Spanned often only highlights the first tokens of the symbol, e.g. #[attr] in a function.
40+
// Could use function.name; possibly our own trait to get a more meaningful span... or change upstream in venial.
41+
3942
Err(error_fn(msg, tokens))
4043
}
4144

godot/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ pub mod bind {
175175
/// Testing facilities (unstable).
176176
#[doc(hidden)]
177177
pub mod test {
178-
pub use godot_macros::itest;
178+
pub use godot_macros::{bench, itest};
179179
}
180180

181181
#[doc(hidden)]

itest/godot/TestRunner.gd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ func _ready():
6161
filters
6262
)
6363

64+
if success:
65+
rust_runner.run_all_benchmarks(self)
66+
6467
var exit_code: int = 0 if success else 1
6568
get_tree().quit(exit_code)
6669

itest/rust/src/benchmarks/mod.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
*/
6+
7+
// File can be split once this grows.
8+
9+
use std::hint::black_box;
10+
11+
use godot::bind::GodotClass;
12+
use godot::builtin::inner::InnerRect2i;
13+
use godot::builtin::{GodotString, Rect2i, StringName, Vector2i};
14+
use godot::engine::{Node3D, Os, RefCounted};
15+
use godot::obj::{Gd, InstanceId};
16+
17+
use crate::framework::bench;
18+
19+
#[bench]
20+
fn builtin_string_ctor() -> GodotString {
21+
GodotString::from("some test string")
22+
}
23+
24+
#[bench]
25+
fn builtin_stringname_ctor() -> StringName {
26+
StringName::from("some test string")
27+
}
28+
29+
#[bench]
30+
fn builtin_rust_call() -> bool {
31+
let point = black_box(Vector2i::new(50, 60));
32+
33+
let rect = Rect2i::from_components(0, 0, 100, 100);
34+
35+
rect.contains_point(point)
36+
}
37+
38+
#[bench]
39+
fn builtin_ffi_call() -> bool {
40+
let point = black_box(Vector2i::new(50, 60));
41+
42+
let rect = Rect2i::from_components(0, 0, 100, 100);
43+
let rect = InnerRect2i::from_outer(&rect);
44+
45+
rect.has_point(point)
46+
}
47+
48+
#[bench(repeat = 25)]
49+
fn class_node_life() -> InstanceId {
50+
let node = Node3D::new_alloc();
51+
let instance_id = node.instance_id();
52+
53+
node.free();
54+
instance_id // No longer valid, but enough for compiler to assume it's used.
55+
}
56+
57+
#[bench(repeat = 25)]
58+
fn class_refcounted_life() -> Gd<RefCounted> {
59+
RefCounted::new()
60+
}
61+
62+
#[bench(repeat = 25)]
63+
fn class_user_refc_life() -> Gd<MyBenchType> {
64+
Gd::<MyBenchType>::new_default()
65+
}
66+
67+
#[bench]
68+
fn class_singleton_access() -> Gd<Os> {
69+
Os::singleton()
70+
}
71+
72+
#[bench]
73+
fn utilities_allocate_rid() -> i64 {
74+
godot::engine::utilities::rid_allocate_id()
75+
}
76+
77+
#[bench]
78+
fn utilities_rust_call() -> f64 {
79+
let base = black_box(5.678);
80+
let exponent = black_box(3.456);
81+
82+
f64::powf(base, exponent)
83+
}
84+
85+
#[bench]
86+
fn utilities_ffi_call() -> f64 {
87+
let base = black_box(5.678);
88+
let exponent = black_box(3.456);
89+
90+
godot::engine::utilities::pow(base, exponent)
91+
}
92+
93+
// ----------------------------------------------------------------------------------------------------------------------------------------------
94+
// Helpers for benchmarks above
95+
96+
#[derive(GodotClass)]
97+
#[class(init)]
98+
struct MyBenchType {}

itest/rust/src/common.rs

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

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

itest/rust/src/framework/bencher.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
5+
*/
6+
7+
// This is a very minimalistic measurement tool for micro-benchmarks. Its goal is to provide coarse overview of performance improvements
8+
// or regressions, NOT a statistically rigorous analysis. We simply measure wall time (not CPU time) and don't consider specifics of
9+
// the hardware or architecture. There are more sophisticated benchmarking tools, but at the moment there is no need for them:
10+
// - https://github.com/bheisler/criterion.rs
11+
// - https://github.com/Canop/glassbench
12+
// - https://github.com/sharkdp/hyperfine
13+
14+
// We currently avoid mean or max, as we're not that interested in outliers (e.g. CPU spike).
15+
// This may of course obscure bad performance in only small number of cases, but that's something we take into account.
16+
// 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.
17+
// See also https://easyperf.net/blog/2019/12/30/Comparing-performance-measurements#average-median-minimum.
18+
19+
use std::time::{Duration, Instant};
20+
21+
const WARMUP_RUNS: usize = 200;
22+
const TEST_RUNS: usize = 501; // uneven, so median need not be interpolated.
23+
const METRIC_COUNT: usize = 2;
24+
25+
pub struct BenchResult {
26+
pub stats: [Duration; METRIC_COUNT],
27+
}
28+
29+
pub fn metrics() -> [&'static str; METRIC_COUNT] {
30+
["min", "median"]
31+
}
32+
33+
pub fn run_benchmark(code: fn(), inner_repetitions: usize) -> BenchResult {
34+
for _ in 0..WARMUP_RUNS {
35+
code();
36+
}
37+
38+
let mut times = Vec::with_capacity(TEST_RUNS);
39+
for _ in 0..TEST_RUNS {
40+
let start = Instant::now();
41+
code();
42+
let duration = start.elapsed();
43+
44+
times.push(duration / inner_repetitions as u32);
45+
}
46+
times.sort();
47+
48+
calculate_stats(times)
49+
}
50+
51+
fn calculate_stats(times: Vec<Duration>) -> BenchResult {
52+
// See top of file for rationale.
53+
54+
/*let mean = {
55+
let total = times.iter().sum::<Duration>();
56+
total / TEST_RUNS as u32
57+
};
58+
let std_dev = {
59+
let mut variance = 0;
60+
for time in times.iter() {
61+
let diff = time.as_nanos() as i128 - mean.as_nanos() as i128;
62+
variance += (diff * diff) as u128;
63+
}
64+
Duration::from_nanos((variance as f64 / TEST_RUNS as f64).sqrt() as u64)
65+
};
66+
let max = times[TEST_RUNS - 1];
67+
let percentile05 = times[(TEST_RUNS as f64 * 0.05) as usize];
68+
*/
69+
70+
// Interpolating percentiles is not that important.
71+
let min = times[0];
72+
let median = times[TEST_RUNS / 2];
73+
74+
BenchResult {
75+
stats: [min, median],
76+
}
77+
}

0 commit comments

Comments
 (0)