A Rust gyors nyelv, de ez nem jelenti azt, hogy a kódod automatikusan gyors is. A leggyakoribb hiba, amit látok: valaki "megérzés alapján" optimalizál, átír egy függvényt, majd csodálkozik, hogy semmit nem javult a válaszidő. A megoldás a profilozás: megmérjük, hol tölti az idejét a program, és csak azután nyúlunk a kódhoz.

Linuxon a perf és a belőle generált flamegraph a legjobb barátod ehhez. Nézzük végig a teljes folyamatot, a telepítéstől egy valódi axum endpoint meggyógyításáig.

A perf telepítése és alapvető használata

A perf a Linux kernel saját profilozó eszköze, ami a kernel perf_events alrendszerét használja mintavételezésre. Debian/Ubuntu alapú rendszeren:

sudo apt install linux-tools-common linux-tools-generic linux-tools-$(uname -r)

Fedora/RHEL alapon:

sudo dnf install perf
Megjegyzés

Konténerben vagy felhős VM-ben futtatva gyakran hiányoznak a jogosultságok a hardveres teljesítményszámlálókhoz. Ha perf record Permission denied hibát dob, próbáld meg ideiglenesen lecsökkenteni a perf_event_paranoid értéket: sudo sysctl kernel.perf_event_paranoid=1.

A legegyszerűbb használat egy futó folyamat mintavételezése:

sudo perf record -F 99 -g -p <PID> -- sleep 30
sudo perf report

A -F 99 a mintavételi frekvenciát adja meg (99 Hz, hogy elkerüljük az egyéb periodikus rendszerfolyamatokkal való ütközést), a -g pedig a call graph rögzítését kéri. A perf report szöveges nézetben megmutatja, melyik függvény hányszor szerepelt a mintákban – de ez egy hosszú, statikus lista, amit nehéz áttekinteni. Itt jön képbe a flamegraph.

Debug szimbólumok bekapcsolása release build-hez

A perf csak akkor tud érthető függvényneveket mutatni, ha a bináris tartalmaz debug szimbólumokat. Alapértelmezés szerint a cargo build --release ezeket kihagyja, mert a Cargo.toml release profilja debug = false-t és strip = true-hoz hasonló viselkedést feltételez. Ezt felül kell írnunk:

[profile.release]
debug = true
Tipp

Ha csak a profilozáshoz szeretnél egy külön build variánst, anélkül hogy a normál release build méretét növelnéd, hozz létre egy külön profilt:

[profile.profiling]
inherits = "release"
debug = true
strip = false

Ezt így buildeled: cargo build --profile profiling.

A debug = true nem lassítja le a futó kódot – az optimalizációs szint (opt-level) változatlan marad, csak a DWARF debug információ kerül bele a binárisba. Ez viszont jelentősen megnöveli a fájl méretét, éles disztribúciónál érdemes strip-pelni utólag, de profilozás közben pont ez az információ kell nekünk.

Flamegraph generálása cargo-flamegraph segítségével

A cargo-flamegraph crate egyetlen paranccsal összefogja a perf record futtatását, a stack-ek összeszedését és az SVG generálást.

cargo install flamegraph

Ezután egy bináris célra egyszerűen:

cargo flamegraph --bin my-server

Ha a szerver folyamatosan fut, és külső terhelést akarsz rá küldeni (pl. wrk vagy hey segítségével), célszerű a --pid-es módot használni:

sudo cargo flamegraph --pid $(pgrep my-server)
Figyelem

A cargo flamegraph alapból sudo-t igényel, mert a perf record a kernel perf_events alrendszerét éri el. Ha ez zavaró CI-ban, fontold meg a perf_event_paranoid=1 beállítást a build agentben, de production rendszeren légy óvatos ezzel.

A parancs futása után egy flamegraph.svg fájlt kapsz, amit bármelyik böngészőben megnyithatsz. Az SVG interaktív: rákattintva egy dobozra, felnagyíthatod azt az al-fát.

A flamegraph olvasása: forró útvonalak azonosítása

A flamegraph elsőre ijesztő lehet, de a logikája egyszerű:

  • Az X tengely a mintavételezett idő arányát mutatja – nem időrendi sorrendet! A dobozok szélessége azzal arányos, hányszor jelent meg az adott függvény a mintákban.
  • Az Y tengely a call stack mélységét mutatja. Alul vannak a belépési pontok (pl. main, thread indítás), felfelé haladva egyre mélyebb hívások következnek.
  • A színek általában csak vizuális elkülönítést szolgálnak (véletlenszerű árnyalatok), nincs önmagukban szemantikus jelentésük, hacsak nem differenciált flamegraph-ot generálsz.

Amit keresel: széles, lapos platókat a grafikon tetején. Egy széles doboz a csúcson azt jelenti, hogy a program sok időt tölt pontosan abban a függvényben (nem a gyerekeiben) – ez egy erős jelzés, hogy ott van a bottleneck.

Jó tudni

Ha egy memcpy, malloc vagy allokátor-függvény széles a grafikon tetején, az gyakran túlzott klónozásra vagy felesleges allokációkra utal – ezekre a Rust kódban jellemzően .clone(), String::from(), vagy Vec::push reallokáció formájában bukkansz rá.

Ha a stack sok kis, egyforma szélességű dobozból áll a mélyben, az gyakran azt jelenti, hogy a mintavételezés túl ritka volt, vagy a munka egyenletesen szét van osztva – ilyenkor nincs egyetlen konkrét hotspot, hanem algoritmikus szintű optimalizálásra van szükség.

Esettanulmány: egy lassú axum endpoint optimalizálása

Nézzünk egy konkrét, valós hibát. Az alábbi axum handler egy felhasználói lista végpontot szolgál ki, ami minden kérésnél újra összeépíti egy nagy JSON választ egy megosztott állapotból:

use axum::{extract::State, response::Json, routing::get, Router};
use std::sync::{Arc, Mutex};
use serde::Serialize;

#[derive(Clone, Serialize)]
struct User {
    id: u64,
    name: String,
    email: String,
}

struct AppState {
    users: Mutex<Vec<User>>,
}

async fn list_users(State(state): State<Arc<AppState>>) -> Json<Vec<User>> {
    // Minden kérésnél teljes klón készül a Mutex alól,
    // hogy a lock-ot ne kelljen az await pontig tartani.
    let users = state.users.lock().unwrap().clone();
    Json(users)
}

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        users: Mutex::new(Vec::new()),
    });

    let app = Router::new()
        .route("/users", get(list_users))
        .with_state(state);

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Ezt terhelés alatt profilozva (10 ezer felhasználóval a listában) a flamegraph tetején egy széles doboz jelent meg Vec::clone és String::clone néven – pontosan ott, ahol vártuk. A .clone() minden kérésnél 10 ezer User struktúrát másolt, mindegyikben két String allokációval.

A megoldás: ne klónozzuk a teljes állapotot minden kéréshez, hanem tartsuk egy már szerializált, cache-elt JSON választ, amit csak akkor frissítünk, ha a lista tényleg változik.

use axum::{extract::State, response::Json, routing::get, Router};
use std::sync::{Arc, RwLock};
use serde::Serialize;
use serde_json::Value;

#[derive(Clone, Serialize)]
struct User {
    id: u64,
    name: String,
    email: String,
}

struct AppState {
    users: RwLock<Vec<User>>,
    // Előre kiszámított JSON válasz, csak íráskor frissül.
    cached_response: RwLock<Arc<Value>>,
}

async fn list_users(State(state): State<Arc<AppState>>) -> Json<Arc<Value>> {
    let cached = state.cached_response.read().unwrap().clone();
    Json(cached)
}

fn rebuild_cache(state: &AppState) {
    let users = state.users.read().unwrap();
    let Ok(json) = serde_json::to_value(&*users) else {
        return;
    };
    *state.cached_response.write().unwrap() = Arc::new(json);
}

A let-else szintaxist itt arra használjuk, hogy korán kilépjünk, ha a szerializáció valamiért meghiúsulna, ahelyett hogy egy egész match-et írnánk. Ez a minta stabil és jól olvasható.

A Mutex-et RwLock-ra váltottuk, mert a lista olvasása sokkal gyakoribb, mint az írása – így a párhuzamos olvasók nem blokkolják egymást. A drága klónozás pedig egyetlen Arc<Value> klónozására csökkent, ami csak egy referenciaszámláló növelése, nem adatmásolás.

A második futtatásnál a flamegraph teljesen más képet mutatott: a serde_json szerializációs blokk eltűnt a hot path-ból (mert csak íráskor fut), a list_users doboz szélessége a korábbi 60%-ról 4%-ra csökkent a teljes minta arányában. A válaszidő p99 értéke 40 ms-ról 1,2 ms-ra esett – ezt már nem kellett tippelni, a flamegraph két futtatás közötti összehasonlítása egyértelműen igazolta.

Tipp

Ha rendszeresen csinálsz ilyen összehasonlításokat, érdemes megnézni a cargo flamegraph --output before.svg és --output after.svg kombinációt, majd a két SVG-t egymás mellett böngészőben megnyitni – sokkal gyorsabban meglátod a különbséget, mint két külön perf report szöveges kimenetet bogarászva.

Összefoglalás

A perf és a flamegraph nem varázslat, csak vizualizáció – de pontosan ez a lényegük: a call stack minták halmazát egy pillantással feldolgozható képpé alakítják. A recept egyszerű: kapcsold be a debug szimbólumokat a release build-hez, futtasd a cargo flamegraph-ot valós terhelés alatt, keresd a széles platókat a grafikon tetején, és csak azután nyúlj a kódhoz. Az esettanulmányban látott minta – felesleges klónozás megosztott állapoton – az egyik leggyakoribb bottleneck, amit Rust webszerverekben találni fogsz, és a flamegraph pontosan megmutatja, mikor éppen ebbe futottál bele.