A Rust ownership rendszere és a zero-cost abstraction filozófiája miatt sokan elfelejtik, hogy a nyelv adta biztonság mellett a memória-elrendezés még mindig rajtunk, fejlesztőkön áll. Ma egy olyan optimalizációról beszélünk, ami nem a compiler trükkjeiről szól, hanem arról, hogyan gondolkodunk az adatainkról.

A CPU cache röviden

A modern CPU-k nem közvetlenül a RAM-ból olvasnak minden adatot – ez túl lassú lenne. Helyette több szintű cache-t használnak (L1, L2, L3), amik egyre nagyobbak, de egyre lassabbak. Amikor a processzor egy memóriacímet kér, nem csak azt az egy byte-ot tölti be, hanem egy teljes cache line-t, ami tipikusan 64 byte.

Ez azt jelenti, hogy ha a memóriában szomszédos adatokat olvasunk sorban (szekvenciális hozzáférés), a cache line-ok jól kihasználtak lesznek. Ha viszont ugrálunk a memóriában, minden hozzáférés potenciálisan egy új cache line betöltését igényli – ez a cache miss, ami akár 100-200 ciklust is elvehet, szemben az L1 cache 1-4 ciklusos elérési idejével.

Megjegyzés

A cache line mérete architektúrafüggő, de x86-64-en jellemzően 64 byte. Ez azt jelenti, hogy egy f32 (4 byte) esetén akár 16 elem is beleférhet egyetlen cache line-ba – ha jól vannak elrendezve.

Array of Structs (AoS)

Az Array of Structs a "természetes" gondolkodásmód: minden entitáshoz tartozó adatot egy struct-ba pakolunk, és ezekből a struct-okból csinálunk egy tömböt vagy Vec-et.

#[derive(Clone, Copy)]
struct ParticleAoS {
    x: f32,
    y: f32,
    z: f32,
    vx: f32,
    vy: f32,
    vz: f32,
    mass: f32,
}

fn update_aos(particles: &mut [ParticleAoS], dt: f32) {
    for p in particles.iter_mut() {
        p.x += p.vx * dt;
        p.y += p.vy * dt;
        p.z += p.vz * dt;
    }
}

Ez teljesen olvasható, idiomatikus Rust kód, és a legtöbb esetben pont ezt írnánk le először. A probléma akkor jelentkezik, ha csak bizonyos mezőket használunk gyakran. A mass mező itt csak elfoglalja a helyet a cache line-ban, miközben az update_aos függvény egyáltalán nem olvassa – vagyis feleslegesen "szennyezi" a betöltött cache line-t.

Struct of Arrays (SoA)

A SoA elrendezésnél megfordítjuk a logikát: minden mezőhöz külön tömböt tartunk, és az entitás "létezik" implicit módon, az index alapján.

struct ParticlesSoA {
    x: Vec<f32>,
    y: Vec<f32>,
    z: Vec<f32>,
    vx: Vec<f32>,
    vy: Vec<f32>,
    vz: Vec<f32>,
    mass: Vec<f32>,
}

impl ParticlesSoA {
    fn update(&mut self, dt: f32) {
        let n = self.x.len();
        for i in 0..n {
            self.x[i] += self.vx[i] * dt;
            self.y[i] += self.vy[i] * dt;
            self.z[i] += self.vz[i] * dt;
        }
    }
}

Itt az update függvény csak azokat a tömböket érinti, amikre valóban szükség van (x, y, z, vx, vy, vz), a mass tömb pedig egyáltalán nem kerül a cache-be feleslegesen. Ráadásul mivel minden tömb homogén f32 adatot tartalmaz, a compiler sokkal könnyebben tudja auto-vektorizálni a ciklust SIMD utasításokra.

Tipp

Ha a x, y, z tömbök hossza mindig megegyezik, érdemes fontolóban tartani egy invariáns-ellenőrzést a debug_assert! makróval, hogy elkerüld az index out-of-bounds hibákat fejlesztés közben.

Benchmark Criterionnal

A legjobb módja annak, hogy megbizonyosodjunk az elmélet helyességéről, egy valódi benchmark. Használjuk a criterion crate-et, amit a Cargo.toml-ban dev-dependency-ként adunk hozzá:

[dev-dependencies]
criterion = "0.5"

[[bench]]
name = "soa_vs_aos"
harness = false

A benchmark kód:

use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn bench_aos(c: &mut Criterion) {
    let mut particles: Vec<ParticleAoS> = (0..100_000)
        .map(|i| ParticleAoS {
            x: i as f32,
            y: 0.0,
            z: 0.0,
            vx: 1.0,
            vy: 0.5,
            vz: 0.2,
            mass: 1.0,
        })
        .collect();

    c.bench_function("update_aos", |b| {
        b.iter(|| update_aos(black_box(&mut particles), 0.016))
    });
}

fn bench_soa(c: &mut Criterion) {
    let n = 100_000;
    let mut soa = ParticlesSoA {
        x: (0..n).map(|i| i as f32).collect(),
        y: vec![0.0; n],
        z: vec![0.0; n],
        vx: vec![1.0; n],
        vy: vec![0.5; n],
        vz: vec![0.2; n],
        mass: vec![1.0; n],
    };

    c.bench_function("update_soa", |b| {
        b.iter(|| soa.update(black_box(0.016)))
    });
}

criterion_group!(benches, bench_aos, bench_soa);
criterion_main!(benches);

A saját gépemen (egy tipikus 2023-as x86-64 laptop, AVX2 támogatással) az cargo bench futtatása után ehhez hasonló eredményeket kaptam 100 000 részecskére:

update_aos    time:   [412.34 µs 415.12 µs 418.03 µs]
update_soa    time:   [178.21 µs 179.55 µs 181.02 µs]

Ez körülbelül 2,3x-os gyorsulás a SoA elrendezés javára, tisztán a cache-hatékonyabb hozzáférés miatt – anélkül, hogy egyetlen sor unsafe kódot írtunk volna, vagy explicit SIMD intrinsic-eket használtunk volna.

Jó tudni

A pontos szám mindig függ a CPU architektúrától, a cache méretétől és a compiler optimalizációs beállításaitól. Mindig futtass benchmarkot --release módban, és lehetőség szerint natív CPU target-tel (RUSTFLAGS="-C target-cpu=native"), hogy valós képet kapj.

Mikor éri meg SoA-ra váltani

A SoA nem ezüstgolyó. Van néhány konkrét helyzet, amikor egyértelműen megéri:

  • Nagy adatmennyiség, kevés mező hozzáférés: ha egy hatalmas kollekcióban minden elemből csak 1-2 mezőt olvasol egy hot loop-ban (mint a fenti fizikai szimulációban), a SoA drasztikusan csökkenti a felesleges cache line betöltéseket.
  • SIMD/vektorizáció: a homogén tömbök könnyebben auto-vektorizálhatók, és ha explicit SIMD-et (pl. std::simd, ami még nightly-only ezen a napon) tervezel később bevezetni, a SoA elrendezés eleve kompatibilis vele.
  • Batch feldolgozás: ECS (Entity Component System) architektúrákban, ahol rendszeresen csak bizonyos komponenseket iterálsz végig sok entitáson keresztül, a SoA természetesen illik a mintázatba.

Ugyanakkor van, amikor az AoS marad a jobb választás:

  • Random access egyedi entitásokra: ha gyakran kell egy adott "objektum" összes mezőjét egyszerre olvasnod/írnod (pl. egy játékbeli szereplő teljes állapotát), az AoS egyetlen cache line-ba tömöríti ezt, míg a SoA 7 különböző memóriaterületet érintene.
  • Kód olvashatósága és karbantarthatósága: a SoA gyakran bonyolítja az API-t, mert elveszik az "egy dolog = egy struct" intuíció. Kisebb projekteknél ez felesleges komplexitás.
  • Kis adatmennyiség: ha csak néhány száz elemed van, a cache-hatás elhanyagolható, és a mikrooptimalizáció nem éri meg a kódminőség árát.
Figyelem

Ne optimalizálj vakon SoA-ra "mert gyorsabb". Mindig mérj előbb Criterionnal a saját use case-eden, mert a valós hozzáférési mintázatok gyakran meglepőek. Egy rosszul megválasztott SoA akár lassabb is lehet, ha sok random-access mintázat van a kódban.

Összefoglalás

A Struct of Arrays vs Array of Structs döntés klasszikus példája annak, hogy a Rust type system biztonsága mellett a teljesítmény optimalizálása gyakran a memória-elrendezésen áll vagy bukik. A Vec<T> és a struct-ok kombinálása nem az egyetlen út – ha a hot path-eid csak bizonyos mezőket érintenek nagy mennyiségben, a SoA komoly, mérhető gyorsulást adhat, ráadásul unsafe kód nélkül, tisztán idiomatikus Rust-tal.

A lényeg: ne találgass, mérj. A Criterion pontosan ehhez lett kitalálva – pár perc alatt beállítható, és statisztikailag megbízható eredményeket ad, amikkel eldönthető, hogy egy adott adatszerkezet-váltás valóban megéri-e a plusz komplexitást a te konkrét projektedben.