Amikor egy Rust program lassabb, mint szeretnénk, ösztönösen elkezdünk találgatni: „biztos a klónozás lassít”, „valószínűleg az allokáció a bűnös”. A probléma az, hogy a megérzéseink gyakran félrevezetnek – a modern CPU-k és a Rust fordító optimalizálásai olyan meglepő módon viselkednek, hogy csak mérés alapján érdemes dönteni. Itt jön képbe a profiling.
A profiling szerepe a teljesítményoptimalizálásban
A profiling lényege, hogy adatot gyűjtünk arról, a program futása során mennyi időt tölt az egyes függvényekben, hívási láncokban. Ez alapján tudjuk megmondani, hol van érdemes optimalizálni – és hol nem érdemes időt ölni, mert az adott kódrész a futásidő elenyésző részét teszi ki.
Ökölszabály: ne optimalizálj addig, amíg nem mérted meg, hol a valódi hotspot. A Rust fordító (LLVM-en keresztül) sokszor jobb döntéseket hoz, mint gondolnánk, és a „nyilvánvaló” lassú kód a valóságban néhány százaléknyi futásidőt sem tesz ki.
Linuxon a perf az ipari sztenderd eszköz a natív bináris profilozására, mivel a CPU teljesítménymérő számlálóit (performance counters) és a mintavételezéses (sampling) profilozást is támogatja rendkívül alacsony overhead mellett. A perf kimenete önmagában nehezen olvasható, de ha flamegraph-ot generálunk belőle, egy pillantással látjuk, melyik függvényhívási lánc emészti fel a CPU-időt.
perf telepítése és alapvető használata Linuxon
A legtöbb disztribúción a perf a linux-tools csomag része. Debian/Ubuntu alapon:
sudo apt install linux-tools-common linux-tools-generic linux-tools-$(uname -r)
Arch alapon egyszerűbb a helyzet:
sudo pacman -S perf
Telepítés után ellenőrizzük, hogy működik-e:
perf --version
Ha jogosultsági hibát kapunk (Permission denied a perf_event_paranoid miatt), ideiglenesen engedhetjük a mintavételezést:
sudo sysctl kernel.perf_event_paranoid=1
Ez a beállítás rendszerszinten csökkenti a biztonsági korlátozásokat a performance countereken. Fejlesztői gépen elfogadható, production szerveren gondold át, mennyire szükséges tartósan bekapcsolva hagyni.
Egy futó binárist így profilozhatunk közvetlenül:
perf record --call-graph dwarf -- ./target/release/my_program
perf report
A perf report egy interaktív terminálos nézetet ad, ahol lehet böngészni a hívási fát, de ez nagy programoknál gyorsan áttekinthetetlenné válik – itt jön jól a flamegraph.
A cargo-flamegraph crate beállítása és futtatása
A cargo-flamegraph egy Cargo-subcommand, amely automatizálja a perf record futtatását, majd a Brendan Gregg-féle FlameGraph szkriptekkel SVG-t generál a mért adatokból. Telepítése egyszerű:
cargo install flamegraph
A telepítés után a projekt gyökerében egyszerűen ennyi a parancs:
cargo flamegraph --bin my_program
Ez automatikusan release módban buildel, futtatja a perf record-ot, majd egy flamegraph.svg fájlt hoz létre a projekt gyökerében, amit böngészőben megnyitva interaktívan bejárható. A vízszintes tengely a mintavételezett idő arányát mutatja, a függőleges pedig a hívási mélységet – minél szélesebb egy „láng”, annál több CPU-időt evett meg az adott függvény (a rekurzív hívásokkal együtt).
macOS-en és Windowsön is működik a cargo-flamegraph, de más aláfolyó eszközt használ (dtrace, illetve ETW), Linuxon viszont a perf a natív és legmegbízhatóbb backend.
Ha csak egy tesztet vagy benchmarkot akarunk profilozni, azt is megtehetjük:
cargo flamegraph --test my_test -- --exact test_name
Debug szimbólumok engedélyezése release build-ben
Alapértelmezetten a cargo build --release kikapcsolja a debug szimbólumokat, mivel azok növelik a bináris méretét és nem szükségesek a futáshoz. Ám profilozáshoz elengedhetetlenek – nélkülük a flamegraph csak memóriacímeket fog mutatni, függvényneveket nem.
A megoldás a Cargo.toml-ban:
[profile.release]
debug = true
Ha nem akarjuk állandóan bekapcsolva tartani (mert lassabb linkelést és nagyobb binárist eredményez), létrehozhatunk egy dedikált profilt is:
[profile.profiling]
inherits = "release"
debug = true
Ezt így hívjuk:
cargo flamegraph --profile profiling --bin my_program
A debug = true nélkül a perf sem tudja feloldani a szimbólumneveket megfelelően, és a flamegraph tele lesz [unknown] feliratú blokkokkal. Ez az egyik leggyakoribb hiba, amit kezdők elkövetnek profilozás közben.
Egy valós hotspot megtalálása és kiküszöbölése lépésről lépésre
Nézzünk egy konkrét, kicsit didaktikus, de valósághű esetet. Van egy programunk, amely nagy mennyiségű szöveges sort dolgoz fel, és minden sorból kivon egy azonosítót, amit egy Vec<String>-be gyűjt:
fn extract_ids(lines: &[String]) -> Vec<String> {
let mut ids = Vec::new();
for line in lines {
let id = line.split(',').next().unwrap_or("");
ids.push(id.to_string());
}
ids
}
Ez a kód elsőre teljesen ártalmatlannak tűnik. Ha lefuttatjuk a flamegraphot néhány millió soros bemeneten, viszont azt látjuk, hogy a legszélesebb láng nem is az extract_ids maga, hanem a Vec::push belsejében a reallokáció (RawVec::grow_amortized). A Vec::new() ugyanis kapacitás nélkül indul, és minden növekedésnél újra kell allokálni és másolni a memóriát.
A javítás egyszerű: ha tudjuk (vagy meg tudjuk becsülni) előre a végleges méretet, foglaljunk helyet előre:
fn extract_ids(lines: &[String]) -> Vec<String> {
let mut ids = Vec::with_capacity(lines.len());
for line in lines {
let id = line.split(',').next().unwrap_or("");
ids.push(id.to_string());
}
ids
}
Ez a flamegraph.svg újbóli generálása után látványosan lecsökkenti a grow_amortized blokk szélességét. De van egy második, kevésbé nyilvánvaló hotspot is: az id.to_string() minden sorban egy új heap-allokációt csinál egy rövid azonosítóhoz. Ha az azonosítók tényleg csak referenciák a bemeneti sorokra, és a lines élettartama elég hosszú, érdemes String helyett &str-t tárolni, kikerülve az allokációt teljesen:
fn extract_ids<'a>(lines: &'a [String]) -> Vec<&'a str> {
let mut ids = Vec::with_capacity(lines.len());
for line in lines {
let id = line.split(',').next().unwrap_or("");
ids.push(id);
}
ids
}
Ez a lifetime-alapú megoldás jól illusztrálja, hogy a Rust ownership rendszere nem csak a memóriabiztonságot szolgálja – jól kihasználva komoly teljesítménytöbbletet is hozhat, mert elkerülhetjük a felesleges klónozásokat és allokációkat.
Egy ilyen módosítás után újra futtatjuk a cargo flamegraph-ot, és összehasonlítjuk az eredményt az előzővel. A gyakorlatban ez a ciklus – mérés, hipotézis, módosítás, újramérés – az, amit minden komolyabb teljesítményoptimalizálásnál végig kell csinálni.
Ha több flamegraphot akarsz összehasonlítani, nevezd el őket beszédesen (before.svg, after.svg), és nyisd meg egyszerre két böngészőfülön – sokkal könnyebb így vizuálisan érzékelni a különbséget, mint a nyers perf report szövegéből olvasni ki.
Fontos megjegyezni, hogy a perf mintavételezéses eszköz: statisztikai jellegű adatokat gyűjt, tehát rövid futású programoknál vagy nagyon ritkán hívott függvényeknél a minta nem lesz reprezentatív. Ilyen esetekben érdemes szintetikus terhelést generálni (pl. a bemenetet sokszorozva ismételve futtatni), hogy elég hosszú ideig fusson a program a megbízható mintavételezéshez.
Összefoglalás
A perf és a cargo-flamegraph páros a Rust ökoszisztéma egyik legpraktikusabb teljesítménydiagnosztikai kombinációja Linuxon. A recept egyszerű: engedélyezd a debug szimbólumokat a release profilban, futtasd le a cargo flamegraph-ot, majd nézd meg, melyik láng a legszélesebb. Ne feledd, hogy a megérzés helyett mindig a mérés vezessen – a Rust fordító gyakran meglep bennünket azzal, hogy mi számít valójában lassúnak. A Vec::with_capacity és a felesleges allokációk elkerülése csak két apró példa arra, milyen sokat javíthatunk egyetlen flamegraph elemzés alapján, komolyabb architekturális átalakítás nélkül.