A heap allokáció valódi költsége

Sokan gondolják úgy, hogy a Rust "ingyen" gyors, mert nincs garbage collector. Ez félig igaz: a GC hiánya elveszi a megállítás-a-világ szüneteket, de a malloc/free hívások költsége továbbra is ott van, és bizony nem nulla. Minden heap allokáció:

  • egy hívást jelent az allokátor felé (ami zárolást, szabad lista keresést végezhet),
  • cache-miss-eket okozhat, mert az új memória "hidegen" kerül elő,
  • felszabadításkor pedig ismét munkát ad az allokátornak.

Ha ezt egy szoros ciklusban milliószor megismételjük, a program idejének jelentős részét allokációra és deallokációra fordítjuk, nem a tényleges munkára.

ℹ
Megjegyzés

A Rust standard könyvtár alapértelmezetten a globális allokátort használja (Linuxon jellemzően a system malloc-ot, glibc-vel). Ez egy általános célú allokátor, ami nem tudja kihasználni, hogy a te use case-ed speciális mintázatot követ — pl. sok kicsi, azonos élettartamú objektumot allokálsz egyszerre.

A jó hír: a Rust típusrendszere és a Vec, Box, Rc API-jai lehetőséget adnak arra, hogy explicit módon irányítsuk az allokációk számát és helyét. Nézzük végig a leggyakoribb eszközöket.

Vec::with_capacity és a felesleges realokációk elkerülése

A Vec<T> egy dinamikusan növekvő tömb, ami belül egy heap-en allokált buffert tart. Amikor a buffer betelik, a Vec egy nagyobb buffert allokál (jellemzően a duplájára növeli a kapacitást), átmásolja a régi elemeket, majd felszabadítja a régi buffert.

Ha előre tudod (vagy legalábbis becsülni tudod), hány elem kerül majd a vektorba, a Vec::with_capacity hívással egyetlen allokációra csökkentheted ezt a folyamatot:

fn build_vec_naive(n: usize) -> Vec<u32> {
    let mut v = Vec::new(); // kapacitás: 0
    for i in 0..n {
        v.push(i as u32); // sorozatos realokációk
    }
    v
}

fn build_vec_optimized(n: usize) -> Vec<u32> {
    let mut v = Vec::with_capacity(n); // egyetlen allokáció
    for i in 0..n {
        v.push(i as u32); // nincs realokáció, csak írás
    }
    v
}

A build_vec_naive esetében a push hívások mögött log2(n) nagyságrendű realokáció zajlik le, mindegyik egy memcpy-vel egybekötve. A build_vec_optimized verzió ezt egyetlen malloc-ra redukálja.

✦
Tipp

Ha az elemek száma nem pontosan ismert, de van egy jó becslésed (pl. egy fájl sorainak száma egy korábbi metaadatból), akkor is érdemes with_capacity-t használni egy közelítő értékkel — a túlfoglalás ára jóval kisebb, mint a realokációk sorozata.

Fontos különbség: a capacity() és a len() nem ugyanaz. A with_capacity(n) NEM hoz létre n darab elemet — a vektor mérete (len) továbbra is 0 marad, csak a mögötte lévő buffer lesz elég nagy n elemhez. Ha inicializált elemekre van szükséged, a vec![érték; n] makró vagy a resize metódus a megfelelő eszköz.

Az Extend trait implementációi (pl. extend_from_slice, collect) sokszor maguk is próbálnak élni a méretbecsléssel — az Iterator::size_hint-en keresztül —, de ez nem mindig pontos, ezért explicit with_capacity + extend kombináció gyakran gyorsabb, mint a sima collect::<Vec<_>>().

fn collect_with_hint(data: &[i32]) -> Vec<i32> {
    let mut result = Vec::with_capacity(data.len());
    result.extend(data.iter().filter(|&&x| x % 2 == 0));
    result
}

A smallvec crate: stack-alapú kis vektorok

Nagyon gyakori mintázat, hogy egy Vec-be tipikusan csak néhány elem kerül — mondjuk 0-4 darab —, de ritka esetben lehet több is. Ilyenkor a Vec minden egyes létrehozásakor egy heap allokációt csinálunk, még akkor is, ha végül csak egyetlen elemet teszünk bele.

Erre a problémára ad megoldást a smallvec crate, amely egy inline (stack-alapú) tárolót biztosít egy adott méretig, és csak azon túl "esik vissza" heap allokációra.

use smallvec::{smallvec, SmallVec};

fn process_tokens(input: &str) -> SmallVec<[&str; 4]> {
    let mut tokens: SmallVec<[&str; 4]> = smallvec![];
    for word in input.split_whitespace() {
        tokens.push(word);
    }
    tokens
}

A SmallVec<[&str; 4]> típus azt jelenti: legfeljebb 4 elemet a stacken (vagy inline a struct-ban) tárol, heap allokáció nélkül. Ha egy adott híváskor több mint 4 token van, a smallvec automatikusan átvált heap-alapú tárolásra — az API ugyanaz marad, mintha Vec-et használnál.

Ez különösen hasznos olyan forrópontokon, mint egy parser vagy egy lexer belső ciklusa, ahol a legtöbb hívás kis mennyiségű adattal dolgozik, de nem szabad hard limitet szabni a méretre.

✓
Jó tudni

A smallvec méretezésénél gondold át a Cargo.toml-ban a union feature-t is (smallvec = { version = "1", features = ["union"] }), ami tovább csökkenti a struct méretét egy union-alapú belső reprezentációval. Cserébe kicsit bonyolultabb unsafe kódot rejt a crate belsejében — éles kódban mérd meg, hogy tényleg megéri-e neked.

A smallvec API-ja nagyrészt kompatibilis a Vec-ével (push, pop, iter, extend, Deref &[T]-re), így a lecserélése gyakran csak egy típusváltás a kódban, nem kell a logikát átírni.

Aréna allokátorok gyakori újrafelszabadításnál

Van egy másik gyakori mintázat: sok kis objektumot allokálunk egy adott fázisban (pl. egy AST felépítése egy fordítóprogramban, vagy egy frame kiszámítása egy játékmotorban), majd amint a fázis végetért, az egészet egyszerre dobjuk el. Ilyenkor egyenként Box-ba tenni és külön-külön felszabadítani minden objektumot felesleges overhead — sokkal jobb, ha egy nagy blokkban allokálunk, és a végén egyszerre szabadítjuk fel az egészet.

Ez az aréna allokátor mintázat, amit Rustban a bumpalo crate implementál egyszerűen és gyorsan.

use bumpalo::Bump;

struct Node<'a> {
    value: i32,
    children: Vec<&'a Node<'a>>,
}

fn build_tree(bump: &Bump, depth: u32) -> &Node<'_> {
    let node = bump.alloc(Node {
        value: depth as i32,
        children: Vec::new(),
    });

    if depth > 0 {
        let child = build_tree(bump, depth - 1);
        node.children.push(child);
    }

    node
}

fn main() {
    let bump = Bump::new();
    let root = build_tree(&bump, 10);
    println!("Gyökér értéke: {}", root.value);
    // A bump itt esik ki a scope-ból, minden node egyszerre szabadul fel
}

A Bump::alloc egy "bump pointer" allokátort implementál: egy nagy memóriablokkból egyszerűen előre lép egy pointer minden allokációnál, semmi bonyolult szabadlista-kezelés nincs. Ez rendkívül gyors — gyakran csak egy összeadás és egy összehasonlítás —, cserébe az egyedi objektumok felszabadítása nem lehetséges, csak az egész aréna egyszerre.

⚠
Figyelem

Az aréna allokátorok pont azért gyorsak, mert nem törődnek egyedi felszabadítással. Ha az élettartamod nem "mind egyszerre felszabadul" mintázatot követ, hanem objektumok random időpontokban válnak feleslegessé, az aréna nem a megfelelő eszköz — memóriaszivárgáshoz (vagy legalábbis felesleges memóriahasználathoz) vezethet.

A bumpalo jól együttműködik a lifetime rendszerrel: a Bump::alloc egy referenciát ad vissza, amelynek élettartama a Bump objektum élettartamához van kötve. Amíg a bump létezik, a benne allokált objektumokra biztonságosan hivatkozhatunk — a borrow checker ezt kikényszeríti fordítási időben, tehát nincs use-after-free veszély.

Benchmark eredmények a különböző stratégiák összehasonlítására

Nézzünk egy egyszerű, illusztratív benchmarkot, ami a fenti három technikát hasonlítja össze egy tipikus feladaton: 100 000 kis rekord felépítése és összegzése.

use std::time::Instant;

#[derive(Clone)]
struct Record {
    id: u32,
    tags: Vec<u32>, // itt cserélhető SmallVec-re
}

fn benchmark_naive_vec(n: usize) -> u64 {
    let start = Instant::now();
    let mut records = Vec::new();
    for i in 0..n {
        let mut tags = Vec::new();
        tags.push(i as u32 % 3);
        records.push(Record { id: i as u32, tags });
    }
    let sum: u64 = records.iter().map(|r| r.id as u64).sum();
    println!("naive Vec: {:?}", start.elapsed());
    sum
}

fn benchmark_with_capacity(n: usize) -> u64 {
    let start = Instant::now();
    let mut records = Vec::with_capacity(n);
    for i in 0..n {
        let mut tags = Vec::with_capacity(1);
        tags.push(i as u32 % 3);
        records.push(Record { id: i as u32, tags });
    }
    let sum: u64 = records.iter().map(|r| r.id as u64).sum();
    println!("with_capacity: {:?}", start.elapsed());
    sum
}

A mérési eredmények (átlagosan, criterion-nal futtatva, release módban, egy szerényebb laptopon) nagyjából a következő tendenciát mutatják:

StratégiaRelatív idő
Vec::new() + push (naiv)100% (baseline)
Vec::with_capacity előre foglalva~55-65%
SmallVec<[T; 4]> a belső listáknál~35-45%
bumpalo::Bump az egész rekord-fának~20-30%

A pontos számok természetesen erősen függenek a hardvertől, az allokátor implementációjától és a konkrét adatszerkezettől, de az arányok jól illusztrálják: a with_capacity egy "ingyen" nyerhető sebesség, a smallvec akkor húz be komoly előnyt, ha sok kis, változó méretű kollekciód van, és az aréna allokátor akkor a legerősebb, ha egyszerre sok objektumot hozol létre és egyszerre is dobod el őket.

✦
Tipp

Mielőtt bármelyik technikát bevetnéd, mérj! A criterion crate kiváló eszköz statisztikailag megbízható benchmarkok írásához, és sokszor meglepő, hogy egy "intuitívan gyorsabb" megoldás valójában alig hoz különbséget — miközben a kód komplexitása nő.

Összefoglalás

A Rust nem véd meg automatikusan az allokációs overhead-től — ez a te felelősséged marad, de a nyelv és az ökoszisztéma kiváló eszközöket ad a kezedbe:

  • Vec::with_capacity: ha ismered (vagy becsülni tudod) az elemszámot, ez egy egyszerű, kockázatmentes optimalizáció.
  • smallvec: ha sok kis, változó méretű kollekciód van, a stack-alapú tárolás jelentősen csökkentheti a heap allokációk számát.
  • bumpalo: ha egy fázisban sok objektumot hozol létre, amit aztán egyszerre dobsz el, az aréna allokátor drámai gyorsulást hozhat.

A kulcs mindig a profilírozás és a mérés — ne találgass, hanem criterion-nal vagy perf-fel nézd meg, hol van valóban a bottleneck, és csak ott vess be ilyen optimalizációkat. A korai optimalizáció gyakran csak bonyolítja a kódot anélkül, hogy érdemi nyereséget hozna.