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.

Megjegyzés

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.

Tipp

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.

Jó tudni

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.

Figyelem

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.