Postgres herbouwen voor 300x snellere analytics: batching, operator fusion en SIMD

De query-engine is een van de grootste wijzigingen die we hebben doorgevoerd om deze prestaties te behalen. De engine alleen al was verantwoordelijk voor ongeveer 10x van die 300x versnelling. We beginnen met een miniatuurversie van de Postgres query-engine en voegen vervolgens stap voor stap dezelfde optimalisaties toe die we hebben gebruikt om de pgrust query-engine zo snel te maken.

Waarom is er zoveel ruimte voor verbetering ten opzichte van Postgres?

Postgres is gebouwd in een ander tijdperk; het oorspronkelijke project dateert uit de jaren '80. In die tijd was disk I/O de belangrijkste flessenhals voor databaseprestaties. Drie trends hebben dit veranderd:

  • Veel datasets passen tegenwoordig in het RAM-geheugen, waardoor het meeste disk I/O wordt geëlimineerd.
  • Voor datasets die niet in het RAM passen, zijn de workloads veranderd. Data-analytics scant gegevens in bulk. De bottleneck is vaak niet langer de doorvoersnelheid van de schijf, maar de doorvoersnelheid van de CPU of het geheugen.
  • Schijven zijn de afgelopen jaren veel sneller geworden. NVMe is honderden keren sneller dan een traditionele harde schijf.

Al deze trends hebben ervoor gezorgd dat CPU- en geheugensnelheden belangrijker zijn geworden dan voorheen. Veel van onze optimalisaties richten zich hierop. De query-engine is de belangrijkste gebruiker van de CPU in een database. We hebben de pgrust query-engine geoptimaliseerd om minder CPU en minder geheugenbandbreedte te gebruiken dan Postgres bij het verwerken van dezelfde queries.

Om een idee te geven van hoe traag de Postgres query-engine is, nemen we een eenvoudige query die de eerste 500 miljoen getallen optelt:

CREATE TABLE my_table AS select col::float8 from generate_series(1.0, 500000000.0) g(col);
SELECT SUM(col) FROM my_table;

Wanneer ik dit in Postgres uitvoer, duurt het ongeveer 20 seconden (getest op een c8g.4xl met parallelle queries uitgeschakeld). Ter vergelijking: wanneer ik hetzelfde in Rust meet:

let table: Vec<f64> = (1..=500_000_000usize).map(|i| i as f64).collect();
let mut sum = 0.0;
for &value in &table {
    sum += value;
}

Deze operatie duurt 358 ms. Dat is ongeveer 55x sneller. Hoewel dit geen directe appel-met-appel vergelijking is — er gebeurt veel meer onder de motorkap bij Postgres — draait het optimaliseren van een database juist om het zoveel mogelijk verwijderen van die overhead. (De twee grootste oorzaken van overhead in Postgres zijn: 1. locking en 2. het parsen van het Postgres-opslagformaat en het extraheren van de relevante tuples voor de query).

De Postgres Query Engine en het Volcano-model

Om ons te concentreren op de impact van de query-engine, bouwen we een miniatuurversie ervan. Wanneer Postgres een SQL-query verwerkt, wordt deze eerst omgezet in een interne representatie genaamd een "Query Plan", dat beschrijft hoe Postgres de query zal uitvoeren. Voor het bovenstaande voorbeeld produceert Postgres een plan dat effectief zegt: "haal rijen uit my_table en tel de waarden in die rijen op".

Postgres heeft in totaal meer dan 40 verschillende soorten plan-nodes. Na het genereren van het plan wordt dit doorgegeven aan de query-engine, die gebruikmaakt van een executor-stijl die bekend staat als het "Volcano-model". Hier is een vereenvoudigde implementatie van deze engine:

use std::hint::black_box;

trait Node {
    fn next(&mut self) -> Option<f64>;
}

struct SeqScan<'a> {
    table: &'a [f64],
    pos: usize,
}

impl Node for SeqScan<'_> {
    fn next(&mut self) -> Option<f64> {
        if self.pos >= self.table.len() {
            return None; // einde tabel
        }
        let value = self.table[self.pos];
        self.pos += 1;
        Some(value)
    }
}

struct SumAggregate<'a> {
    child: Box<dyn Node + 'a>,
    total: f64,
    done: bool,
}

impl Node for SumAggregate<'_> {
    fn next(&mut self) -> Option<f64> {
        if self.done {
            return None;
        }
        while let Some(value) = self.child.next() {
            self.total += value;
        }
        self.done = true;
        Some(self.total)
    }
}

let table: Vec<f64> = (1..=500_000_000usize).map(|i| i as f64).collect();
let mut plan = SumAggregate {
    child: black_box(Box::new(SeqScan { table: &table, pos: 0 })),
    total: 0.0,
    done: false,
};
let sum = plan.next().unwrap();

De kern van het Volcano-model is de next() methode. Het doel van next() is om één enkele rij terug te geven. Een sequentiële scan geeft de volgende rij; een aggregatie berekent de volledige aggregatie en geeft vervolgens het resultaat terug. De executeur roept simpelweg next() aan op de root-node tot er geen rijen meer zijn.

Hoewel dit model simpel is, veroorzaakt het veel overhead. In dit voorbeeld duurt de uitvoering 1,3 seconden. Dit is sneller dan de volledige Postgres-versie omdat we andere componenten hebben verwijderd, maar het is nog steeds langzamer dan een raw for-loop vanwege de overhead van het Volcano-model.

Optimalisatie 1: Batching

Het grootste prestatieprobleem is dat next() slechts één rij per keer verwerkt. Er is geen batching. De functie SeqScan.next() wordt per rij aangeroepen, wat significante overhead oplevert omdat CPU-optimalisaties (zoals pipelining) minder goed werken bij functies die pas tijdens runtime worden bepaald.

Door batching te implementeren, kunnen we dit oplossen:

const BATCH: usize = 1024;

trait BatchNode {
    fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize;
}

struct BatchSeqScan<'a> {
    table: &'a [f64],
    pos: usize,
}

impl BatchNode for BatchSeqScan<'_> {
    fn next_batch(&mut self, out: &mut [f64; BATCH]) -> usize {
        let n = (self.table.len() - self.pos).min(BATCH);
        out[..n].copy_from_slice(&self.table[self.pos..self.pos + n]);
        self.pos += n;
        n
    }
}

struct BatchSumAggregate<'a> {
    child: Box<dyn BatchNode + 'a>,
    total: f64,
}

impl BatchSumAggregate<'_> {
    fn run(&mut self) -> f64 {
        let mut buf = [0.0f64; BATCH];
        loop {
            let n = self.child.next_batch(&mut buf);
            if n == 0 {
                break;
            }
            for &value in &buf[..n] {
                self.total += value;
            }
        }
        self.total
    }
}

let mut plan = BatchSumAggregate {
    child: black_box(Box::new(BatchSeqScan { table: &table, pos: 0 })),
    total: 0.0,
};
let sum = plan.run();

Batching elimineert het grootste deel van de overhead en brengt de tijd terug van 1,3 seconden naar ongeveer 480 ms. Een belangrijk detail is dat de batch-buffer op de stack wordt gealloceerd; hierdoor hoeft de aggregatie-node tijdens de uitvoering geen geheugen te alloceren, wat een relatief trage operatie is.

Optimalisatie 2: Operator Fusion

Wanneer we de batched versie profileren, blijkt copyfromslice de nieuwe hotspot te zijn. Zelfs met batching moeten we items nog steeds kopiëren naar de buffer. Dit kan worden opgelost met "operator fusion". Als we weten dat bepaalde operaties altijd samen worden uitgevoerd, kunnen we een enkele node maken die twee nodes vervangt. In dit geval creëren we een SumAggregateSequentialScan node:

struct SumAggregateSequentialScan<'a> {
    table: &'a [f64],
    done: bool,
}

impl Node for SumAggregateSequentialScan<'_> {
    fn next(&mut self) -> Option<f64> {
        if self.done {
            return None;
        }
        self.done = true;
        let mut total = 0.0;
        for &value in self.table {
            total += value;
        }
        Some(total)
    }
}

Dit levert dezelfde prestaties op als de straight for-loop, omdat het letterlijk dezelfde code is. Hoewel dit voor een specifieke query lijkt op "valsspelen", is het zinvol om de meest voorkomende gevallen hard te coderen. Voor complexere queries kan JIT-compilatie worden gebruikt om dynamisch de ideale code te genereren en zo altijd operator fusion toe te passen.

Optimalisatie 3: SIMD

De laatste optimalisatie is SIMD (Single Instruction, Multiple Data). Hiermee kan de CPU één operatie gelijktijdig op meerdere stukken data uitvoeren. In dit voorbeeld voor ARM64-architectuur ziet dat er zo uit:

#[cfg(target_arch = "aarch64")]
struct SumAggregateSequentialScanSimd<'a> {
    table: &'a [f64],
    done: bool,
}

#[cfg(target_arch = "aarch64")]
impl Node for SumAggregateSequentialScanSimd<'_> {
    fn next(&mut self) -> Option<f64> {
        if self.done {
            return None;
        }
        self.done = true;
        use std::arch::aarch64::*;
        let mut acc = unsafe { [vdupq_n_f64(0.0); 4] };
        let (chunks, rest) = self.table.as_chunks::<8>();
        for chunk in chunks {
            for lane in 0..4 {
                unsafe {
                    let v = vld1q_f64(chunk.as_ptr().add(2 * lane));
                    acc[lane] = vaddq_f64(acc[lane], v);
                }
            }
        }
        let mut tail = 0.0;
        for &value in rest {
            tail += value;
        }
        Some(unsafe {
            let s01 = vaddq_f64(acc[0], acc[1]);
            let s23 = vaddq_f64(acc[2], acc[3]);
            vaddvq_f64(vaddq_f64(s01, s23)) + tail
        })
    }
}

Met SIMD duurt de operatie slechts 135 ms. Dit is bijna 3x sneller dan de for-loop en 10x sneller dan onze oorspronkelijke Volcano-code. Compilers vermijden SIMD vaak bij floats omdat floating point rekenkunde niet associatief is; het veranderen van de volgorde van optellen kan namelijk een licht verschillend resultaat opleveren.

Resultaten overzicht

Door deze drie optimalisaties is de query 10x sneller geworden ten opzichte van het basismodel:

ImplementatieTijdVersnelling
Postgres~20 s
Volcano model1,3 s
+ batching480 ms2,7×
+ operator fusion358 ms3,6×
+ SIMD135 ms9,6×

Dit type optimalisaties stelt pgrust in staat om bij analytische queries honderden keren sneller te zijn dan Postgres.

Benchmark setup: AWS c8g.4xlarge (Graviton4, 16 vCPU), PostgreSQL 18.4 met maxparallelworkerspergather = 0, data warm in shared buffers, mediaan van 5 runs. Rust gebouwd met cargo build –release, 4 runs per implementatie, allen gemeten in één proces op één machine.