Ha valaha megpróbáltad összehasonlítani két algoritmus sebességét úgy, hogy egy Instant::now() párost tettél a kód elé és mögé, majd egyszer lefuttattad, akkor tudod: a szám, amit kaptál, gyakran hazudik. A gépi zaj, a CPU frequency scaling, a cache állapota és a compiler optimalizációi mind torzítják az eredményt. Ma megnézzük, hogyan csináljuk ezt rendesen a criterion crate-tel.
Miért nem elég a time vagy a stopperóra
A time cargo run --release parancs kényelmes, de két nagy hibája van. Egyrészt egyetlen futást mér, ami statisztikailag semmit sem mond a szórásról. Egyrészt a folyamatindítás, a dinamikus linkelés és az OS scheduler zaja is belekeveredik a mérésbe. Másrészt gyakran elfelejtjük, hogy a --release mellett is lehet jelentős eltérés két futás között pusztán a gépi terhelés miatt.
A saját Instant-alapú mérés jobb, de tele van csapdával:
use std::time::Instant;
fn main() {
let start = Instant::now();
let result = fibonacci(30);
let elapsed = start.elapsed();
println!("{} eredmény, {:?} alatt", result, elapsed);
}
fn fibonacci(n: u64) -> u64 {
if n < 2 { n } else { fibonacci(n - 1) + fibonacci(n - 2) }
}
Ez a kód egyetlen mintavételt vesz. Nincs bemelegítés (warm-up), nincs statisztikai szórás, és semmi nem garantálja, hogy az optimalizáló compiler nem dobja el az egész számítást, ha a result-tal végül nem történik semmi. Ide kell a Criterion.
A Criterion egy Rust benchmarking library, ami statisztikailag megbízható méréseket készít, automatikus warm-up-pal, kiugró értékek szűrésével és szép HTML riportokkal.
A Criterion telepítése és az első benchmark
A Criterion nem a szokásos dependencies alá kerül, hanem dev-dependencies-be, mivel csak fejlesztés közben, a cargo bench futtatásakor van rá szükség:
[dev-dependencies]
criterion = "0.4"
[[bench]]
name = "fibonacci_bench"
harness = false
A harness = false sor kulcsfontosságú: ezzel mondjuk meg a Cargo-nak, hogy ne a beépített (jelenleg is nightly mögött instabil) benchmark harness-t használja, hanem a Criterion saját main függvényét futtassa.
Ezután hozzunk létre egy benches/fibonacci_bench.rs fájlt:
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);
A std::hint::black_box a Rust 1.66 óta a standard könyvtár része, és arra való, hogy megakadályozza a compiler-t abban, hogy a bemenetet konstansnak lássa és kiszámolja fordítási időben, vagy hogy a kimenetet, mivel nem használjuk fel sehol, egyszerűen eldobja.
Futtatás:
cargo bench
Ez első futáskor lefordítja a benchmarkot release módban (optimalizálva, de debug assertion nélkül), majd elkezd mérni.
A kimenet értelmezése: statisztika dióhéjban
A Criterion kimenete elsőre ijesztő lehet, de valójában logikus:
fibonacci 20 time: [4.1234 µs 4.1560 µs 4.1932 µs]
change: [-2.1453% -0.8821% +0.3012%] (p = 0.24 > 0.05)
No change in performance detected.
A három szám a time sorban a 95%-os konfidenciaintervallum alsó határa, a becsült medián, és a felső határ. Nem egyetlen futásról van szó, hanem több száz vagy több ezer mintáról, amiket a Criterion statisztikailag (bootstrap resampling-gel) dolgoz fel. Ha a három szám közel van egymáshoz, a mérés stabil; ha nagyon szét van csúszva, valami zajforrás (háttérfolyamat, thermal throttling, laptop akkumulátoros üzemmódja) valószínűleg zavarja a mérést.
A change sor csak akkor jelenik meg, ha van korábbi mérési eredmény (target/criterion mappában tárolva). A p érték (p-value) azt mutatja, mekkora az esélye annak, hogy a látott eltérés csak véletlen zaj. Ha p > 0.05, a Criterion nem tekinti szignifikánsnak a változást — ez pontosan a fenti példa esete.
A cargo bench futás után nyisd meg a target/criterion/report/index.html fájlt böngészőben — grafikonokkal, hisztogrammal és lineáris regressziós ábrával mutatja be az eredményt, ami sokkal informatívabb, mint a terminál kimenete.
Csoportosítás és paraméterezett benchmarkok
Ha több bemeneti méretre akarod ugyanazt a függvényt mérni — mondjuk a fibonacci-t n = 10, 20, 30 értékekre —, nem kell külön bench_function hívást írni mindegyikhez. Erre való a benchmark_group:
use criterion::{criterion_group, criterion_main, BenchmarkId, 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_group(c: &mut Criterion) {
let mut group = c.benchmark_group("fibonacci");
for n in [10u64, 20, 30].iter() {
group.bench_with_input(BenchmarkId::from_parameter(n), n, |b, &n| {
b.iter(|| fibonacci(black_box(n)))
});
}
group.finish();
}
criterion_group!(benches, bench_fibonacci_group);
criterion_main!(benches);
A BenchmarkId::from_parameter gondoskodik róla, hogy a riportban minden bemenet külön sorként, olvasható néven jelenjen meg (fibonacci/10, fibonacci/20, fibonacci/30). Nagy adathalmazokon dolgozó függvényeknél (pl. rendezés, hash-elés) ez a mintázat különösen hasznos, mert egyben megmutatja, hogyan skálázódik az algoritmus a bemenet méretével — ami gyakran fontosabb infó, mint egyetlen abszolút szám.
Ha a bemeneti méret jelentősen befolyásolja a mérés idejét (pl. n = 30-nál már másodperces nagyságrendbe kerülünk a naiv Fibonacci miatt), a Criterion alapértelmezett mintaszáma (100) és mérési ideje (5 másodperc) nem biztos, hogy elég. Ezt a group.sample_size(50) és group.measurement_time(Duration::from_secs(10)) hívásokkal lehet finomítani.
Regresszió-detektálás: eredmények összehasonlítása korábbi futásokkal
Az egyik legnagyobb erőssége a Criterionnek, hogy alapból megőrzi az előző futás eredményeit a target/criterion mappában, és minden új futtatáskor automatikusan összehasonlít velük. Ez azt jelenti, hogy ha optimalizáltál egy függvényt, vagy éppen véletlenül lassítottál rajta egy refaktorálással, a cargo bench kimenete azonnal jelzi:
fibonacci/30 time: [1.8321 ms 1.8455 ms 1.8601 ms]
change: [+12.453% +14.021% +15.602%] (p = 0.00 < 0.05)
Performance has regressed.
Ez különösen hasznos CI-ban is, bár érdemes tudni, hogy a megosztott CI runner-ek gyakran zajosak (más konténerek is futnak a gépen), így ott a threshold-ot lazábbra kell állítani, vagy a --save-baseline opcióval nevesített baseline-okat érdemes kezelni:
cargo bench -- --save-baseline main
# ...módosítás a kódban...
cargo bench -- --baseline main
Ez a workflow lehetővé teszi, hogy explicit módon nevesített referenciaponthoz hasonlíts, ne csak a legutóbbi futáshoz — ami akkor hasznos, ha egy feature branch-en dolgozol, és a main ághoz akarod mérni magad.
Gyakori hiba: amikor az optimalizáló kiiktatja a mérendő kódot
A leggyakoribb kezdő hiba, hogy a benchmark függvény visszatérési értékét senki nem használja fel, így az LLVM optimalizáló egyszerűen kidobja az egész számítást. Nézzük ezt a hibás verziót:
fn bench_bad(c: &mut Criterion) {
c.bench_function("sum naive", |b| {
b.iter(|| {
let v: Vec<u64> = (0..1000).collect();
let _sum: u64 = v.iter().sum();
// a _sum-mal semmi nem történik utána!
})
});
}
Elméletileg a _sum prefixű változónév csak a dead-code warning-ot néma le, de az optimalizáló látja, hogy az érték soha nem kerül felhasználásra egy megfigyelhető helyen (I/O, visszatérési érték, stb.), így akár a teljes ciklust is kihagyhatja. A megoldás a black_box mindkét oldalon: a bemenetnél és a kimenetnél is.
use std::hint::black_box;
fn bench_good(c: &mut Criterion) {
c.bench_function("sum naive", |b| {
b.iter(|| {
let v: Vec<u64> = (0..black_box(1000)).collect();
let sum: u64 = v.iter().sum();
black_box(sum)
})
});
}
A black_box a bemeneti oldalon megakadályozza, hogy a compiler konstansként kezelje és fordítási időben kiszámolja az eredményt, a kimeneti oldalon pedig jelzi, hogy az értéket "valahogy" felhasználjuk, így nem dobható el.
A black_box nem varázsszer — bizonyos esetekben, különösen kis, egyszerű függvényeknél, a compiler még mindig találhat kiskaput az optimalizálásra. Mindig érdemes összevetni a cargo asm vagy a cargo expand kimenetét, ha gyanús a mérési eredmény (pl. gyanúsan gyors egy triviálisnak tűnő művelethez képest).
Egy másik gyakori hiba a debug_assertions figyelmen kívül hagyása: a Criterion benchmarkok alapból release profilban futnak, de ha a Cargo.toml-ban [profile.bench] szekcióban explicit módon debug = true-t állítasz be (ami hasznos lehet profilozáshoz, pl. perf-fel), az extra bináris méretet és esetenként eltérő optimalizálási döntéseket eredményezhet, ami torzíthatja az összehasonlítást a [profile.release] beállításokhoz képest.
Összefoglalás
A Criterion nem luxus, hanem alapfelszerelés, ha komolyan gondolod a teljesítményoptimalizálást Rustban. A statisztikailag megalapozott mérés, a beépített regresszió-detektálás és a benchmark_group által kínált paraméterezett tesztelés mind olyan eszközök, amik nélkül könnyen áltatja magát az ember egy-egy println!-es stopperórás méréssel. Kezdd egyszerűen: telepítsd a crate-et, írj egy benchmarkot a legkritikusabb függvényedre, majd figyeld meg, hogyan reagál a Criterion, amikor tényleg optimalizálsz valamit — a change sor öröme garantált.