Amikor egy Rust program lassabb, mint vártad, a legrosszabb, amit tehetsz, hogy ösztösen elkezdesz optimalizálni "gyanús" helyeket. Ehelyett mérj. A Linux perf eszköze és a belőle generált flamegraph pontosan megmutatja, melyik függvényben tölti a programod a CPU-időt — nem becslés, hanem valódi mintavételezett adat.
A perf eszköz telepítése és alapvető használata Linux alatt
A perf a Linux kernel teljesítménymérő alrendszeréhez (perf_events) kapcsolódó parancssori eszköz. Debian/Ubuntu alatt így telepíthető:
sudo apt install linux-tools-common linux-tools-generic linux-tools-$(uname -r)
Arch alatt a perf a linux-tools csomagban van, Fedora alatt pedig sudo dnf install perf.
Ha konténerben vagy virtuális gépen dolgozol, ellenőrizd, hogy a perf_event_paranoid beállítás engedi-e a mintavételezést. Root nélkül gyakran ezt kell futtatni:
sudo sysctl kernel.perf_event_paranoid=1
A legegyszerűbb használat: futtatod a programot perf record alatt, majd megnézed az eredményt perf report-tal.
perf record -g -- ./target/release/sebes_program
perf report
A -g kapcsoló bekapcsolja a call graph (hívási lánc) rögzítését — ez nélkülözhetetlen ahhoz, hogy később flamegraphot tudjunk belőle csinálni. A perf report egy interaktív, szöveges nézetet ad, ahol látod, melyik szimbólum hány százalékban van jelen a mintákban. Ez már önmagában hasznos, de a flamegraph sokkal gyorsabban ad átfogó képet.
Flamegraph generálása cargo-flamegraph segítségével
A cargo-flamegraph crate pontosan ezt automatizálja: futtatja a programot perf alatt, összegyűjti a mintákat, és SVG-be renderel egy flamegraphot. Telepítés:
cargo install flamegraph
Ezután a projekt gyökerében:
cargo flamegraph --bin sebes_program
Ez release módban buildel (alapértelmezetten), lefuttatja a programot perf record alatt, és a végén egy flamegraph.svg fájlt hoz létre a projekt gyökerében. Ha a bináris parancssori argumentumokat vár, azokat a -- után add meg:
cargo flamegraph --bin sebes_program -- --input adatok.csv
Ha a programod nagyon rövid ideig fut (pl. néhány tized másodperc), a mintavételezés nem lesz elég sűrű ahhoz, hogy releváns adatot kapj. Ilyenkor célszerű a mérendő kódrészt egy ciklusba csomagolni, vagy a perf record -F (frekvencia) kapcsolóját megnövelni, például -F 999-re.
A flamegraph olvasása: mit jelentenek a szélesség és mélység
A flamegraph elsőre ijesztően néz ki, de a logikája egyszerű:
- Az X tengely (szélesség) nem időt jelent balról jobbra haladva, hanem a mintavett stackek arányát. Egy blokk szélessége azt mutatja, hogy a teljes mintavételezési idő hány százalékában volt aktív az adott függvény a call stacken. Minél szélesebb egy blokk, annál több CPU-időt eszik meg — ez az, amit keresel.
- Az Y tengely (mélység) a hívási láncot mutatja. Alul van a
main(vagy a szál belépési pontja), és felfelé haladva látod, hogy melyik függvény hívta melyiket. Egy magas, keskeny "tornyot" gyakran mély rekurzió vagy sok réteg wrapper hívás okoz. - A blokkok színe általában nem hordoz jelentést (véletlenszerű vagy hash alapú), csak a jobb megkülönböztethetőségért van.
Amit keresel: széles "fennsíkok" a diagram tetején. Ezek azok a levél-függvények (leaf function), amiket a profiler tényleg "munkában" talált — nem csak áthaladt rajtuk, hanem tényleg ott töltötte az időt a program.
Ha a flamegraph tetején rengeteg allokátor-hívást (malloc, __rust_alloc) vagy memcpy-t látsz, az gyakran nem az algoritmusod hibája, hanem felesleges klónozás vagy allokáció — ezekre külön érdemes rákeresni a kódban.
Debug szimbólumok beállítása release módban a pontos profilhoz
Alapértelmezetten a cargo build --release nem tartalmaz debug szimbólumokat, így a flamegraph tele lesz olvashatatlan, hash-elt szimbólumnevekkel, vagy a compiler agresszíven inline-olja a függvényeket, és nem tudod megkülönböztetni, melyik hívás melyik forráskód-sorhoz tartozik.
A megoldás: kapcsold be a debug információkat a release profilban, a Cargo.toml-ban:
[profile.release]
debug = true
Ez nem lassítja le a futó bináris teljesítményét (a debug info nem kerül be a futásidejű gépi kódba, csak külön szekcióba a bináris fájlban), csak megnöveli a fájl méretét. Cserébe a perf report és a flamegraph pontos függvényneveket és — megfelelő optimalizálási szint mellett — forráskód-sorokat is tud mutatni.
Az agresszív inline-olás (amit az opt-level = 3 hoz magával) miatt előfordulhat, hogy egy rövid, sokszor hívott függvény "beleolvad" a hívójába a flamegraphon. Ha egy konkrét apró függvényt akarsz külön izolálni a mérésben, ideiglenesen tehetsz rá #[inline(never)] attribútumot — mérés után persze vedd le.
#[inline(never)]
fn parse_sor(sor: &str) -> Option<(u32, f64)> {
let mut reszek = sor.split(',');
let id = reszek.next()?.trim().parse().ok()?;
let ertek = reszek.next()?.trim().parse().ok()?;
Some((id, ertek))
}
Esettanulmány: egy lassú függvény megtalálása és optimalizálása
Nézzünk egy konkrét, mesterségesen lassú programot. Adott egy CSV-fájl feldolgozása, ahol soronként összegyűjtünk azonosító–érték párokat egy HashMap<u32, Vec<f64>>-be:
use std::collections::HashMap;
fn feldolgoz(sorok: &[String]) -> HashMap<u32, Vec<f64>> {
let mut terkep: HashMap<u32, Vec<f64>> = HashMap::new();
for sor in sorok {
if let Some((id, ertek)) = parse_sor(sor) {
// Minden sornál lekérdezzük, majd újraírjuk a bejegyzést,
// ami felesleges klónozást okoz nagy Vec-ek esetén.
let mut lista = terkep.get(&id).cloned().unwrap_or_default();
lista.push(ertek);
terkep.insert(id, lista);
}
}
terkep
}
Ha erre lefuttatjuk a cargo flamegraph-ot néhány százezer soros bemenettel, a flamegraph tetején egy meglepően széles blokkot találunk: Vec::clone és a hozzá tartozó allokáció. Ez elárulja, hogy minden egyes soron O(n) klónozást csinálunk egy Vec<f64>-en, ahelyett hogy közvetlenül a helyben módosítanánk azt.
A javítás egyszerű: az entry API-t kell használni, ami elkerüli a felesleges olvasást és klónozást:
use std::collections::HashMap;
fn feldolgoz_gyors(sorok: &[String]) -> HashMap<u32, Vec<f64>> {
let mut terkep: HashMap<u32, Vec<f64>> = HashMap::with_capacity(sorok.len());
for sor in sorok {
if let Some((id, ertek)) = parse_sor(sor) {
terkep.entry(id).or_default().push(ertek);
}
}
terkep
}
Ennyi. Az entry(id).or_default() egyetlen hash-lookup után visszaad egy mutable referenciát a Vec-hez (vagy létrehoz egy üreset), amire közvetlenül push-olhatunk — nincs klónozás, nincs felesleges allokáció. A with_capacity hívás pedig elkerüli a HashMap ismételt átméretezését, ha van előzetes becslésünk a méretről.
Újra futtatva a cargo flamegraph-ot, a Vec::clone blokk teljesen eltűnik a diagramból, és a program a mi mesterséges tesztadatunkon körülbelül harmadára csökkent futásidőt mutatott. A lényeg: nem kellett tippelni — a flamegraph pontosan megmutatta, hova nézzünk.
Ha a let-else szintaxist szereted a hibaágak rövidítésére, a parse_sor hívásnál is használhatod hasonló mintát:
let Some((id, ertek)) = parse_sor(sor) else {
continue;
};
terkep.entry(id).or_default().push(ertek);
Ez ugyanazt csinálja, csak kevesebb behúzással.
Összefoglalás
A perf és a cargo-flamegraph páros a legjobb barátod, ha egy Rust program teljesítményével van gondod, és nem akarsz vaktalálgatásba fogni. A recept egyszerű: kapcsold be a debug szimbólumokat release módban (debug = true), generálj egy flamegraphot valós vagy realisztikus terheléssel, és keresd a legszélesebb blokkokat a diagram tetején — azok a valódi bűnösök. Sokszor, mint a fenti példában is, a megoldás nem egy bonyolult algoritmuscsere, hanem egy felesleges klónozás vagy allokáció kiiktatása. A mérés nélküli optimalizálás vakrepülés; a flamegraph adja meg a térképet.