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.

Megjegyzés

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
Tipp

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.

Jó tudni

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.

Figyelem

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.

Tipp

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.