Разработчики pgrust — движка для Postgres на Rust — выпустили версию 0.2, полностью посвящённую производительности. Новая версия работает в 10 раз быстрее предыдущей. На OLTP-нагрузках pgrust опережает Postgres на 30%, а на ClickBench — бенчмарке ClickHouse для аналитических баз данных — pgrust обгоняет Postgres в 300 раз, оставляя позади даже сам ClickHouse.

Один из главных факторов роста производительности — переписанный движок запросов: на его долю приходится примерно 10-кратное ускорение из общих 300. Ниже разбирается миниатюрная версия движка запросов Postgres, к которой по очереди добавляются те же оптимизации, что применялись в pgrust.

Прежде чем переходить к деталям, стоит объяснить, почему у Postgres так много пространства для улучшений. Проект появился в 80-х годах, в эпоху, когда главным узким местом производительности баз данных был дисковый ввод-вывод. С тех пор ситуация изменилась по трём причинам:

  1. Многие датасеты теперь целиком помещаются в оперативную память, что устраняет большую часть дискового I/O
  2. Для датасетов, которые всё же не влезают в RAM, характер нагрузки изменился: аналитика сканирует данные массово, и узким местом всё чаще становится не пропускная способность диска, а производительность CPU или памяти
  3. Диски за последние годы стали значительно быстрее — NVMe работает в сотни раз быстрее обычного жёсткого диска

Все три тенденции сделали скорость CPU и памяти важнее, чем раньше. Большая часть оптимизаций в pgrust направлена именно на это. Движок запросов — главный потребитель CPU в базе данных, и его удалось переписать так, чтобы он тратил меньше процессорного времени и полосы пропускания памяти на обработку тех же самых запросов, что и Postgres.

Чтобы показать, насколько медленным может быть движок запросов Postgres, возьмём простой запрос — сумму первых 500 миллионов чисел:

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

При запуске в Postgres этот запрос выполняется около 20 секунд (тест проводился на c8g.4xl с отключёнными параллельными запросами).

Для сравнения — эквивалентный код на Rust:

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;
}

Этот запрос выполняется за 358 мс — примерно в 55 раз быстрее. И, как оказалось, это далеко не предел: можно добиться результата ещё быстрее. Конечно, сравнение не совсем честное — внутри Postgres происходит гораздо больше работы. Но именно оптимизация базы данных и заключается в устранении максимума этих накладных расходов (для справки: два крупнейших источника overhead в Postgres — это блокировки и разбор внутреннего формата хранения с извлечением нужных для запроса кортежей).

Чтобы сфокусироваться исключительно на влиянии движка запросов, соберём его миниатюрную версию. Сначала — короткое объяснение, что такое движок запросов вообще. Обрабатывая SQL-запрос, Postgres сначала преобразует его во внутреннее представление — «план запроса», описывающий, как именно запрос будет выполнен. Для примера выше план запроса может выглядеть примерно так:

По сути, план говорит: «взять строки из my_table и просуммировать значения в них». Для такого простого запроса план получается элементарным, но при работе с join'ами, сортировками, подзапросами и прочим он становится намного сложнее. Всего в Postgres более 40 разных типов узлов плана.

После построения плана Postgres передаёт его движку запросов — той части системы, которая непосредственно извлекает строки и выполняет агрегацию. Postgres использует так называемую модель Volcano. Чтобы понять принцип её работы, вот миниатюрная реализация движка запросов Postgres:

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; // end of table
        }
        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();

(black_box нужен, чтобы компилятор не оптимизировал бенчмарк, устраняя реальную работу)

Ключевая особенность модели Volcano — метод next(), который поддерживается всеми узлами плана запроса. Его задача — вернуть одну строку. У узла последовательного сканирования next() возвращает следующую строку таблицы. У узла агрегации next() вычисляет всю агрегацию целиком и возвращает единственный результат. Выполнение плана запроса сводится к вызову next() у корневого узла до тех пор, пока он не перестанет возвращать строки. Преимущество модели Volcano — простота: достаточно реализовать по одному методу для каждого типа узла плана. Приведённый код упрощён, но очень близок к тому, что происходит внутри Postgres на самом деле.

При всей простоте модель Volcano создаёт значительные накладные расходы. Приведённый пример выполняется за 1,3 секунды — это заметно быстрее, чем в Postgres, поскольку убраны все части, не связанные с движком запросов, но всё ещё медленнее, чем обычный цикл, из-за издержек самой модели.

Главная проблема кода выше — то, что next() обрабатывает лишь одну строку за раз, без батчинга. Функция SeqScan.next() вызывается на каждой отдельной строке, что создаёт значительные накладные расходы — особенно потому, что многие оптимизации CPU, такие как конвейеризация, плохо работают, когда вызывается функция, неизвестная до момента выполнения. Первая оптимизация, которую можно применить, — батчинг:

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();

Один только батчинг устраняет большую часть накладных расходов: время выполнения запроса падает с 1,3 секунды до примерно 480 мс. Это всё ещё медленнее обычного цикла, но заметно ближе к нему. Важная деталь: буфер батча размещается на стеке, то есть узел агрегации не выделяет память во время работы. Выделение памяти — одна из самых медленных операций, поэтому при написании максимально быстрого кода число таких выделений нужно минимизировать.

При профилировании батчевой версии главной "горячей точкой" оказывается copy_from_slice: несмотря на батчинг, элементы всё равно приходится копировать в буфер. Эти издержки можно устранить с помощью так называемого "слияния операторов" (operator fusion). Если известно, что определённые операции почти всегда выполняются вместе, их логику можно объединить в один узел вместо двух. В данном случае можно создать единый узел SumAggregateSequentialScan, объединяющий логику последовательного сканирования и суммирования:

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)
}
}

Это даёт ровно ту же производительность, что и обычный цикл, потому что по сути это тот же самый код. Может показаться, что так вроде "нечестно" — и это действительно так, поскольку оптимизация жёстко захардкожена для конкретного заранее известного запроса. Слияние операторов имеет смысл применять для нескольких самых частых случаев, но довольно быстро встретятся запросы, для которых заранее подготовленного варианта не окажется.

Эту проблему решает JIT-компиляция. С её помощью можно на лету генерировать идеальный код для любого запроса и применять "трюк" слияния операторов всегда, а не только для заранее известных случаев. Как именно pgrust использует JIT-компиляцию — тема для отдельного разговора, поскольку этот материал уже получился довольно объёмным.

Последняя оптимизация — SIMD. Это набор процессорных инструкций, позволяющих выполнять одну операцию сразу над несколькими элементами данных. Обработка нескольких строк одной SIMD-операцией обычно намного быстрее, чем поочерёдная обработка строк по одной. Вот как код выглядит с использованием SIMD:

#[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
        })
    }
}

Теперь код выполняется за 135 мс — почти в 3 раза быстрее обычного цикла и в 10 раз быстрее исходной реализации на модели Volcano. Хотя компиляторы часто сами заменяют циклы на SIMD-эквиваленты, этот пример специально подобран так, чтобы этого не произошло: компиляторы обычно избегают автоматического SIMD при работе с числами с плавающей точкой, поскольку это может дать немного другой результат — арифметика с плавающей точкой не ассоциативна, и изменение порядка суммирования способно немного изменить итоговое значение.

В общей сложности три простых оптимизации ускорили запрос в 10 раз. Итоговая картина:

Реализация Время Ускорение
Postgres ~20 с
Модель Volcano 1,3 с
+ батчинг 480 мс 2,7×
+ слияние операторов 358 мс 3,6×
+ SIMD 135 мс 9,6×

Эти и другие подобные оптимизации в совокупности позволяют pgrust выполнять аналитические запросы в сотни раз быстрее, чем Postgres.

Параметры бенчмарка: AWS c8g.4xlarge (Graviton4, 16 vCPU), PostgreSQL 18.4 с max_parallel_workers_per_gather = 0, данные "прогреты" в shared buffers, медиана из 5 запусков. Rust-код собран с cargo build --release, 4 запуска на каждую реализацию, все замеры сделаны в одном процессе на одной машине.