DeepSeek总结的通过批处理、运算符融合和SIMD重建Postgres,实现300倍更快的分析查询
来源:https://malisper.me/how-we-made-postgres-hundreds-of-times-faster-the-query-engine/
通过批处理、运算符融合和SIMD重建Postgres,实现300倍更快的分析查询
上周我们发布了 pgrust 的 0.2 版本。这个版本主要关注性能。它比 pgrust 的上一版本快了 10 倍。在 OLTP 基准测试中,pgrust 比 Postgres 快 30%,而在 Clickbench(Clickhouse 的分析数据库基准测试)上,pgrust 比 Postgres 快 300 倍。它甚至领先于 Clickhouse!
查询引擎是我们为实现更好性能所做的最大改动之一。单是查询引擎本身就贡献了 300 倍加速中的约 10 倍。我们将从 Postgres 查询引擎的微型版本开始,然后逐一添加我们为使 pgrust 查询引擎如此快速而做的相同优化。
为了说明为什么相对于 Postgres 有这么大的改进空间,需要了解 Postgres 创建于一个不同的时代。最初的 Postgres 项目可以追溯到 80 年代。它诞生于磁盘 I/O 是数据库性能主要瓶颈的时代。三个趋势改变了这种情况:
- 许多数据集现在可以装入 RAM,消除了大部分磁盘 I/O。
- 对于无法装入 RAM 的数据集,工作负载也发生了变化。数据分析会批量扫描数据。瓶颈往往不再是磁盘吞吐量,而通常是 CPU 吞吐量或内存吞吐量。
- 近年来磁盘速度大幅提升。NVMe 比硬盘快数百倍。
这三个趋势都使得 CPU 和内存速度比历史上更加重要。我们的许多优化都针对这一点。查询引擎是数据库中 CPU 的主要使用者。我们优化了 pgrust 的查询引擎,使其在处理相同查询时,比 Postgres 使用更少的 CPU 和更少的内存带宽。
为了让你了解 Postgres 查询引擎有多慢,让我们看一个简单的查询,它计算前 5 亿个数字的总和:
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 倍,而且信不信由你,我们还能做到比 358 毫秒更快。当然,这个例子并非严格的苹果对苹果比较。Postgres 底层有更多复杂的东西在处理。同时,优化数据库的核心就是尽可能多地消除这些开销。(如果你好奇,Postgres 开销的两个最大来源是:1. 锁定;2. 解析 Postgres 存储格式并提取查询相关的元组。)
为了将我们的焦点缩小到仅查询引擎的影响,让我们构建一个 Postgres 查询引擎的微型版本。首先,简要解释什么是查询引擎。在处理你的 SQL 查询时,Postgres 首先将你的查询转换成一个称为“查询计划”的内部表示,它描述了 Postgres 将如何执行该查询。在上面的例子中,Postgres 将生成一个可能如下所示的查询计划:
这实际上是在说“从 my_table 获取行,并对这些行中的值求和”。鉴于查询的性质,这个查询计划相当简单,但当你开始处理连接/排序/子查询等操作时,它们会变得复杂得多。Postgres 总共有超过 40 种不同类型的计划节点。
生成查询计划后,Postgres 将其传递给查询引擎。Postgres 查询引擎是 Postgres 的一部分,它接收查询计划并实际检索行并执行聚合。Postgres 使用一种称为“火山模型”(Volcano model)的执行器风格。为了了解其工作原理,这里是 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; // 表结束
}
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 用于防止编译器优化干扰我们的基准测试)
火山模型的关键特性是 next() 方法,查询计划中的所有节点都支持它。next() 的工作是返回一行数据。顺序扫描中的 next() 返回顺序扫描的下一行。聚合上的 next() 将计算整个聚合,然后返回单行结果。执行查询计划只需在根计划节点上调用 next(),直到它不再返回任何行。火山模型的优点是非常简单。你只需为每个计划节点实现一个方法,仅此而已。虽然经过了简化,但上述代码与 Postgres 内部的实际操作非常接近。
虽然火山模型使事情变得简单,但它也增加了大量开销。当我运行这个例子时,耗时 1.3 秒。这比 Postgres 版本快得多,因为我们移除了许多非查询引擎的部分,但由于火山模型的开销,它仍然比原始 for 循环慢。
上述代码中最大的性能损失在于 next() 一次只处理一行。没有批处理。SeqScan.next() 函数每行被调用一次。这增加了显著的开销,特别是因为当你在运行时调用一个未知的函数时,许多 CPU 优化(如流水线化)无法很好地工作。我们可以实现的第一个优化是批处理(batching):
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 毫秒。仍然比 for 循环慢,但接近了很多。一个非常重要的细节是,批处理缓冲区是在栈上分配的。这意味着聚合节点在运行时不需要分配任何内存。内存分配通常是较慢的操作之一。因此,在编写超高速代码时,你会希望尽量减少内存分配次数。
现在,如果你对批处理版本进行性能分析,热点现在是 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)
}
}
这给我们带来了与直接 for 循环相同的性能,因为它实际上就是 for 循环的代码。这可能看起来像作弊,确实也是,因为我们为预先知道的特定查询硬编码了一个优化。通过运算符融合,硬编码少数几个最常见的情况是有意义的,但你仍然会很快遇到你没有事先准备好的情况。
这可以通过 JIT 编译来解决。使用 JIT 编译,你可以生成你希望拥有的理想代码,并在每个查询中“作弊”。JIT 编译允许你为你的查询生成完美的代码,无论查询是什么,并且总是执行运算符融合。不幸的是,这篇文章已经够长了,所以我将不得不另找时间讨论 pgrust 如何利用 JIT 编译。
最后,我们可以考虑 SIMD(单指令多数据流)。SIMD 指的是一组 CPU 操作,可以同时对多个数据执行一项操作。使用 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 毫秒,几乎比 for 循环快 3 倍,比我们最初的火山模型代码快 10 倍。虽然编译器通常会用 SIMD 等效代码替换 for 循环,但我选择这个例子正是为了不让这种情况发生。编译器通常会避免在对浮点数操作时引入 SIMD,因为它们会产生略有不同的结果。这是因为浮点运算不满足结合律,改变求和顺序可能会产生略有不同的结果。
—
总而言之,通过三个简单的优化,我们能够将查询速度提高 10 倍。以下是最终结果:
| 实现 | 时间 | 加速比 |
|---|---|---|
| Postgres | ~20 秒 | — |
| 火山模型 | 1.3 秒 | 1× |
| + 批处理 | 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,数据预热在共享缓冲区中,5 次运行的中位数。Rust 使用 cargo build --release 构建,每个实现运行 4 次,所有测量在同一台机器上的一个进程内完成。
感谢阅读,如果你想支持这个项目,支持 pgrust 的最好方式是在 GitHub 上给我们一个 star。如果你想持续关注:
- GitHub
- Discord
- 邮件列表 —— pgrust 每周更新,包括关于 JIT 编译的后续内容
- pgrust.com
分享:
X LinkedIn Facebook Email Print
发布于 2026 年 8 月 3 日,作者 malisper,分类 pgrust
[文件内容结束]
更多推荐

所有评论(0)