Miért csal a std::time::Instant?

Sokan így kezdik a teljesítménymérést: kiteszünk egy Instant::now()-t a kód elé és mögé, majd kivonjuk egymásból. Ez a módszer első ránézésre működik, de mikrobenchmark szinten szinte mindig félrevezet.

A probléma nem a std::time API-jával van, hanem azzal, ahogy egyetlen mérést kezelünk mérvadóként. A modern CPU-k frekvenciaskálázása, a cache állapota, az OS ütemezője, sőt a háttérben futó egyéb folyamatok is zajt visznek a mérésbe. Egyetlen futtatás alapján levont következtetés statisztikailag értéktelen.

Figyelem

Ha valaha azt látod, hogy egy println!-lel kombinált egyszeri időmérés alapján "bebizonyítottad", hogy A gyorsabb mint B, nagyon valószínű, hogy a mérési zaj nagyobb, mint a tényleges különbség.

Ezért használunk mikrobenchmark keretrendszert. A Rust ökoszisztémában erre a criterion crate a de facto standard. Ez a könyvtár többek között:

  • automatikus warm-up-ot végez, hogy a JIT-mentes, de mégis cache-hideg állapotból induló méréseket kiszűrje,
  • statisztikai módszerekkel (medián, konfidencia-intervallum) dolgozza fel az eredményeket,
  • kimutatja, ha egy változtatás statisztikailag szignifikánsan gyorsított vagy lassított,
  • HTML riportot generál grafikonokkal, ha a plotters feature engedélyezve van.

Az első Criterion benchmark

Kezdjük egy egyszerű példával. Adjuk hozzá a Cargo.toml-hoz a criterion-t dev-dependency-ként:

[dev-dependencies]
criterion = "0.4"

[[bench]]
name = "fib_benchmark"
harness = false

A harness = false azért kell, mert a Criterion saját mérési logikát futtat, nem a beépített #[bench] attribútumot (ami egyébként is csak nightly-n érhető el).

Hozzunk létre egy benches/fib_benchmark.rs fájlt:

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn bench_fibonacci(c: &mut Criterion) {
    c.bench_function("fib 20", |b| {
        b.iter(|| fibonacci(black_box(20)))
    });
}

criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);

A black_box kulcsfontosságú: ez akadályozza meg, hogy a fordító kiszámolja előre a konstans bemenetre adott eredményt, és teljesen kioptimalizálja a hívást. Enélkül a benchmarkod könnyen mérhet "semmit" – a fordító ugyanis rájön, hogy fibonacci(20) mindig ugyanaz, és konstansként helyettesíti be.

Futtatás:

cargo bench

A kimenetben olyasmit fogsz látni, mint:

fib 20                  time:   [22.145 µs 22.310 µs 22.489 µs]

A három szám az alsó konfidenciahatár, a becsült érték és a felső konfidenciahatár. Ha egy módosítás után az új intervallum nem fedi az előzőt, az statisztikailag releváns változás – nem csak zaj.

Tipp

Ha cargo bench közben a target/criterion mappában HTML riportot szeretnél, telepítsd a gnuplot-ot a rendszeredre, vagy hagyatkozz a beépített plotters backendre, ami alapértelmezett a Criterion újabb verzióiban.

Flamegraph generálása perf-fel Linuxon

A mikrobenchmark megmondja, hogy mennyi idő telik el, de nem mondja meg, hol megy el az idő a függvényen belül. Erre való a profiling, és Linuxon a legkényelmesebb belépési pont a cargo-flamegraph crate, ami a perf-et hívja a háttérben.

Telepítés:

cargo install flamegraph

A perf-nek natívan telepítve kell lennie (Debian/Ubuntu-n a linux-tools-common és linux-tools-generic csomagokból), illetve engedélyezni kell a perf_event_paranoid értékét:

sudo sh -c 'echo 1 > /proc/sys/kernel/perf_event_paranoid'

Mielőtt flamegraph-ot készítenénk, engedélyezzük a debug szimbólumokat release módban is, különben a stack trace-ek olvashatatlanok lesznek:

[profile.release]
debug = true

Ezután futtatható a profilozás:

cargo flamegraph --bin my_app

Ez generál egy flamegraph.svg-t, amit böngészőben megnyitva interaktívan lehet zoomolni. A szélesebb sávok jelentik a több CPU-időt elfoglaló függvényeket – ezek a "forró" kódutak. A cél, hogy megtaláld azokat a széles, sokszor ismétlődő blokkokat, amik nem indokoltan foglalják a végrehajtási időt.

Megjegyzés

A flamegraph vízszintes tengelye nem időbeli sorrendet jelöl, hanem az adott hívási minta relatív gyakoriságát a mintavételezés során. A magasság a hívási lánc mélységét mutatja.

Esettanulmány: allokáció-intenzív függvény optimalizálása

Nézzünk egy tipikus esetet: egy függvényt, ami szöveget dolgoz fel és minden iterációban új String-et allokál.

fn normalize_words(input: &str) -> Vec<String> {
    let mut result = Vec::new();
    for word in input.split_whitespace() {
        let mut normalized = String::new();
        for ch in word.chars() {
            normalized.push(ch.to_ascii_lowercase());
        }
        result.push(normalized);
    }
    result
}

A flamegraph ezen a függvényen valószínűleg egy nagy, széles blokkot mutatna a String::push és a mögötte lévő allokátor hívások körül, mert minden push esetén újra kell méretezni a belső buffert, ahogy a String növekszik.

Az első optimalizálási lépés: foglaljunk kapacitást előre, hiszen tudjuk a szó hosszát:

fn normalize_words(input: &str) -> Vec<String> {
    let mut result = Vec::with_capacity(input.len() / 4);
    for word in input.split_whitespace() {
        let mut normalized = String::with_capacity(word.len());
        for ch in word.chars() {
            normalized.push(ch.to_ascii_lowercase());
        }
        result.push(normalized);
    }
    result
}

Ez jelentősen csökkenti az újraallokációk számát, mert a String::with_capacity egyszer foglal elég helyet, nem pedig lépésenként duplázza a buffert.

A Criterion benchmarkkal mérve egy 10 000 szavas bemeneten a különbség jól látszódhat a konfidencia-intervallumok szétválásában – ez az objektív bizonyíték arra, hogy az optimalizálás tényleg segített, nem csak a megérzésünk mondja.

Jó tudni

Mindig mérj előtte és utána is ugyanazzal a benchmark szettel. Egy optimalizálás, amit "logikusnak" gondolsz, a valóságban simán lassíthat is – például ha a with_capacity becslésed rendszeresen túlfoglal, feleslegesen sok memóriát mozgatsz.

Cargo.toml trükkök: opt-level, lto, codegen-units

A mért eredmények nagyban függnek a fordítási beállításoktól. A cargo bench alapértelmezetten a bench profilt használja, ami a release profilból örököl, de érdemes explicit módon is beállítani a fontosabb kapcsolókat:

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
panic = "abort"
  • opt-level = 3: a legagresszívabb optimalizálást kéri az LLVM-től. Fejlesztés közben inkább opt-level = 1 vagy 2 ajánlott a gyorsabb fordítás miatt, de méréshez mindig a végleges release beállítást használd.
  • lto = "fat": link-time optimization, ami a fordítási egységek határain átívelő inline-olást és holt kód eltávolítást tesz lehetővé. Cserébe jelentősen megnöveli a fordítási időt.
  • codegen-units = 1: egyetlen kódgenerálási egységet kényszerít ki, ami jobb optimalizálási lehetőségeket ad az LLVM-nek, de szintén lassítja a buildet, mert elveszik a párhuzamos fordítás előnye.
Tipp

Ha csak a benchmarkhoz szeretnéd ezeket a beállításokat használni, hozz létre egy külön [profile.bench] szekciót, hogy a napi fejlesztői buildjeid ne lassuljanak le feleslegesen.

Érdemes minden egyes beállítást külön-külön mérni Criterion-nal, mert nem ritka, hogy egy adott munkaterhelésen az lto = "thin" majdnem ugyanolyan jó, mint a "fat", miközben a fordítási idő töredéke.

Gyakori hibák és hamis következtetések

Néhány csapda, amibe könnyű beleesni:

  1. Debug build mérése. Ha elfelejted a --release-t, a nem optimalizált kód akár 10-20x lassabb is lehet, és ez teljesen félrevezető képet ad.
  2. Dead code elimination. Ha a benchmark eredményét sehol nem használod fel (nincs black_box, és a visszatérési érték eldobódik), a fordító képes lehet az egész számítást kiiktatni.
  3. Zajos gépen mérni. Böngésző, IDE indexelés, háttérben futó build – mindegyik torzítja az eredményt. Zárj be mindent, amit lehet, mielőtt benchmarkolsz.
  4. Túl kevés iteráció. A Criterion alapból igyekszik elég mintát venni, de ha a függvényed nagyon lassú (pl. I/O-t végez), érdemes kézzel csökkenteni a sample_size-t, különben órákig futhat a mérés.
  5. Egyetlen bemenet alapján általánosítani. Egy string-feldolgozó függvény teljesen máshogy viselkedhet 10 karakteres és 10 megabájtos bemeneten. Használj BenchmarkGroup-ot több bemeneti mérettel.
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};

fn bench_normalize(c: &mut Criterion) {
    let mut group = c.benchmark_group("normalize_words");
    for size in [10, 100, 1000].iter() {
        let input = "hello ".repeat(*size);
        group.bench_with_input(BenchmarkId::from_parameter(size), &input, |b, input| {
            b.iter(|| normalize_words(input));
        });
    }
    group.finish();
}

fn normalize_words(input: &str) -> Vec<String> {
    input
        .split_whitespace()
        .map(|w| w.to_ascii_lowercase())
        .collect()
}

criterion_group!(benches, bench_normalize);
criterion_main!(benches);

Ez a minta megmutatja, hogyan skálázódik a függvényed a bemenet méretével – ha lineárisan nő az idő, az jó jel, ha hirtelen ugrik, valahol egy rejtett O(n²) viselkedés lapul.

Összefoglalás

A Rust gyors nyelv, de a "gyors" nem garancia – a tényleges teljesítményt csak méréssel lehet igazolni. A Criterion megadja a statisztikailag megbízható számokat, a flamegraph pedig megmutatja, hol érdemes optimalizálni. Ha ezt a két eszközt kombinálod a helyes Cargo profil beállításokkal, és tudatosan elkerülöd a klasszikus mérési hibákat, akkor nemcsak hiszed, hanem tudod is, hogy a kódod valóban gyorsabb lett.