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.
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
plottersfeature 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.
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.
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.
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 = 1vagy2ajá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.
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:
- 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. - 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. - 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.
- 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. - 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.