Miért nem elég a std::time mérés mikrobenchmarkokhoz
Ha valaha írtál már ilyet:
use std::time::Instant;
fn main() {
let start = Instant::now();
let result = fibonacci(30);
let elapsed = start.elapsed();
println!("Eredmény: {result}, idő: {elapsed:?}");
}
fn fibonacci(n: u64) -> u64 {
if n < 2 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}
...akkor tudod, hogy ez az eredmény minden futásnál más. CPU frequency scaling, cache állapot, más folyamatok a gépen, JIT-mentes de mégis változó optimalizáció — mind belezavar a mérésbe. Egy mikrobenchmark esetében, ahol nanoszekundumos vagy mikroszekundumos különbségekről beszélünk, ez a zaj könnyen nagyobb, mint a mérni kívánt jelenség maga.
Egy másik gyakori hiba: a compiler egyszerűen kioptimalizálja a kódot, amit mérni akarsz, mert látja, hogy az eredményt sosem használod fel. A std::time-os megoldás semmilyen védelmet nem ad ez ellen.
A std::time::Instant önmagában semmilyen hibás API — csak nem statisztikai eszköz. Egyszeri időmérésre, logolásra tökéletes, de teljesítmény-összehasonlításra nem alkalmas.
Ezért született meg a criterion crate, amely statisztikailag megalapozott, ismételt mintavételezésen alapuló benchmarkolást biztosít, és HTML riportot is generál a mérésekről.
A criterion crate telepítése és alapbeállítása
A criterion nem a szokásos #[test] mechanizmust használja, hanem egy külön benchmark harness-t. Ehhez létre kell hoznod egy benches mappát, és a Cargo.toml-ban ki kell kapcsolnod a beépített harness-t az adott bench célra.
[package]
name = "criterion-demo"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "my_benchmark"
harness = false
A harness = false sor lényeges: ez mondja meg a Cargo-nak, hogy ne a saját tesztfuttatóját használja, hanem hagyja, hogy a criterion vezérelje a futást (ő maga tartalmaz egy main függvényt a criterion_main! makró révén).
Hozz létre egy benches/my_benchmark.rs fájlt — ez lesz a benchmark belépési pontja.
A criterion futtatásához nem az cargo test, hanem a cargo bench parancsot használjuk. Ez fontos, mert a cargo test alapból nem futtatja a benchmarkokat.
Egyszerű benchmark írása black_box használatával
A legfontosabb fogalom, amit meg kell értened: a black_box. Ez egy olyan függvény (vagy makró), amely elrejti a compiler elől, hogy mi történik az értékkel — ezzel megakadályozza, hogy az optimalizáló kioptimalizálja a mérni kívánt számítást, vagy előre kiszámolja fordítási időben.
Rust 1.66 óta a standard könyvtár is tartalmaz egy std::hint::black_box függvényt, amit érdemes preferálni a criterion saját, régebbi implementációja helyett, mert erősebb garanciákat ad az optimalizáló ellen.
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn fibonacci(n: u64) -> u64 {
if n < 2 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}
fn bench_fibonacci(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| {
b.iter(|| fibonacci(black_box(20)))
});
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
Itt két helyen is fontos a black_box: a bemeneti értéket (20) is becsomagoljuk, hogy a compiler ne tudja fordítási időben konstansként kiértékelni a teljes függvényt, és így ne essen ki a mérés a futásból.
A bench_function első paramétere a benchmark neve — ezt fogod visszalátni a riportban és a cargo bench kimenetében is. A b.iter(...) closure-t a criterion sokszor lefuttatja, méri az időt, és statisztikai elemzést végez az eredményeken.
Ha elfelejted a black_box-ot használni, előfordulhat, hogy a lefordított kód gyakorlatilag semmit nem csinál — a compiler kiszámolja előre az eredményt, és a benchmark 0 nanoszekundum körüli, hihetetlenül gyors futást fog mutatni. Ez klasszikus jele annak, hogy valami nem stimmel a méréseddel.
Statisztikai eredmények értelmezése és a zajszűrés
Amikor lefuttatod a cargo bench parancsot, a criterion nem egyszer futtatja le a kódot, hanem több száz vagy több ezer mintát vesz, és ezekből statisztikai becslést készít. A tipikus kimenet valami ilyesmi:
fibonacci 20 time: [1.2453 µs 1.2501 µs 1.2558 µs]
change: [-2.1034% -0.8721% +0.3452%] (p = 0.24 > 0.05)
No change in performance detected.
Az első sor egy konfidencia-intervallumot ad: a középső érték a legjobb becslés, a szélső két érték pedig a 95%-os konfidencia-intervallum alsó és felső határa. Minél szűkebb ez a sáv, annál stabilabb a mérés.
A change sor akkor jelenik meg, ha van korábbi mérési adat (a criterion elmenti a target/criterion mappába az előző futások eredményeit). Itt láthatod, hogy a jelenlegi kód gyorsabb, lassabb, vagy statisztikailag nem különbözik a korábbi verziótól. A p érték (p-value) azt jelzi, hogy mekkora eséllyel véletlen zaj a mért különbség — ha p > 0.05, a criterion óvatosan azt mondja: "nincs kimutatható változás".
Ne hagyd figyelmen kívül a p értéket! Sokan csak a középső időértéket néznék, és abból vonnak le messzemenő következtetéseket, miközben a különbség statisztikailag simán lehet zaj.
Ha a mérés túl zajos (túl széles a konfidencia-intervallum), próbáld:
- Bezárni más, CPU-t terhelő programokat a mérés alatt.
- Kikapcsolni a CPU frequency scaling-et, ha lehet (Linuxon pl.
cpupower frequency-set --governor performance). - Növelni a minták számát a
Criterion::default().sample_size(200)beállítással.
Két implementáció összehasonlítása grafikonon
A criterion egyik legjobb funkciója, hogy több benchmarkot egy csoportba tehetsz, és automatikusan generál összehasonlító grafikonokat (SVG/HTML formában, ha a plotters feature engedélyezve van, ami alapból bekapcsolt).
Nézzünk egy tipikus esetet: két Fibonacci implementáció, az egyik rekurzív, a másik iteratív.
use criterion::{criterion_group, criterion_main, Criterion, BenchmarkId};
use std::hint::black_box;
fn fib_recursive(n: u64) -> u64 {
if n < 2 { n } else { fib_recursive(n - 1) + fib_recursive(n - 2) }
}
fn fib_iterative(n: u64) -> u64 {
let (mut a, mut b) = (0u64, 1u64);
for _ in 0..n {
let tmp = a + b;
a = b;
b = tmp;
}
a
}
fn bench_compare(c: &mut Criterion) {
let mut group = c.benchmark_group("fibonacci");
for n in [10, 20, 25].iter() {
group.bench_with_input(BenchmarkId::new("recursive", n), n, |b, n| {
b.iter(|| fib_recursive(black_box(*n)))
});
group.bench_with_input(BenchmarkId::new("iterative", n), n, |b, n| {
b.iter(|| fib_iterative(black_box(*n)))
});
}
group.finish();
}
criterion_group!(benches, bench_compare);
criterion_main!(benches);
A benchmark_group és a BenchmarkId kombinációja lehetővé teszi, hogy paraméterezett benchmarkokat futtass, és a criterion egy közös grafikonon jelenítse meg mindkét implementáció görbéjét a bemenet méretének függvényében. A cargo bench futás után a target/criterion/fibonacci/report/index.html fájlt megnyitva egy interaktív HTML riportot kapsz, ahol jól látszik, hogy a rekurzív verzió exponenciálisan romlik, míg az iteratív lineárisan skálázódik.
Ha csak egy adott bemenetre akarsz szűrni futás közben, a cargo bench -- fibonacci/recursive/20 szintaxissal célzottan futtathatod le a benchmark egy részét — hasznos, ha egy nagy benchmark szuite-ból csak egy résszel dolgozol.
Gyakori hibák elkerülése benchmark írásakor
A criterion használata közben pár tipikus csapdába könnyű beleesni:
-
A setup kódot a mérésbe csúsztatod. Ha a benchmark closure-jében nem csak a mérni kívánt logikát futtatod, hanem például egy vektort is minden iterációban újra allokálsz, a mérés torzul. Használd a
b.iter_batchedvagyb.iter_with_setupvariánsokat, ha a setup kódot ki akarod venni a mérésből. -
Elfelejtett black_box. Már említettük, de nem lehet elégszer hangsúlyozni: bemenet és kimenet is legyen
black_box-olva, különben a compiler „csal”. -
Túl kevés minta vagy túl rövid mérési idő. Ha a mérni kívánt függvény nagyon gyors (néhány nanoszekundum), a criterion alapértelmezett beállításai (kb. 100 minta, 3-5 másodperc mérési idő csoportonként) simán elegendőek lehetnek, de érdemes explicit módon beállítani a
Criterion::default().measurement_time(Duration::from_secs(10))értéket, ha stabilabb eredményt akarsz. -
Mérés éles környezet helyett fejlesztői laptopon, háttérfolyamatokkal. A zajos mérési környezet a legnagyobb ellenség. Ha lehetőséged van, futtasd a benchmarkokat dedikált, minimális háttérterheléssel rendelkező gépen.
-
A
releaseprofil elhagyása. Acargo benchalapból release módban fordít, de ha egyedi profilbeállításokat használsz (opt-level,lto), ellenőrizd, hogy azok a[profile.bench]szekcióban is megfelelően vannak beállítva aCargo.toml-ban.
[profile.bench]
opt-level = 3
lto = true
Ne hasonlíts össze debug build-del mért eredményt release build-es benchmarkkal — ez alapvető, mégis gyakran elkövetett hiba, különösen, ha a benchmark eredményeit kézzel, cargo run-nal mért adatokkal veted össze.
Összefoglalás
A criterion crate nem csak egy „szebb stopper” — statisztikai alapokon nyugvó eszköz, amely megmutatja, mikor bízhatsz meg egy teljesítménybeli különbségben, és mikor csak a mérési zajt látod. A black_box helyes használata, a benchmark_group-okkal végzett összehasonlítás, és a generált HTML riportok grafikonjai együtt olyan eszköztárat adnak, amivel megalapozott döntéseket hozhatsz a kódod optimalizálásáról. Ha eddig csak Instant::now()-val méregettél, most van itt az idő, hogy áttérj egy komolyabb, statisztikailag megbízható megoldásra.