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.

Megjegyzés

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.

Tipp

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.

Figyelem

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".

Jó tudni

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.

Tipp

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:

  1. 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_batched vagy b.iter_with_setup variánsokat, ha a setup kódot ki akarod venni a mérésből.

  2. 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”.

  3. 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.

  4. 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.

  5. A release profil elhagyása. A cargo bench alapbó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 a Cargo.toml-ban.

[profile.bench]
opt-level = 3
lto = true
Figyelem

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.