以光速编程:构建 Gravity 查询引擎
我们如何从硬件的速度极限出发反向工作,构建出 pgrust v0.4-beta 中的查询引擎 Gravity。
Michael Malis · 2026 年 10 月 29 日
随着 pgrust v0.4-beta 的发布,我们的新查询引擎 Gravity 在 ClickBench 上现在比 ClickHouse 快多达 3 倍,而 ClickBench 正是 ClickHouse 自己设计来展示 ClickHouse 有多快的基准测试。更好的是,Gravity 在实现这种速度的同时支持完整的 Postgres SQL 方言和类型系统。
在之前的几篇文章中,我写过我们如何使用批处理、算子融合和 SIMD 使 pgrust 在分析查询上比 Postgres 快数百倍,以及我们如何构建了一个在 5 微秒内编译桩代码的 copy-and-patch JIT 编译器。所有这些经过多个版本积累的工作,就是后来的 Gravity。
Copy-and-patch 是一个很好的起点,但将预编译模板粘合在一起只能让我们走到这一步。因为我们没有真正的中间表示或跨整个循环的寄存器分配,与 rustc 或 Clang 这样的优化编译器相比,我们最终在性能上留下了很多余地。对于 pgrust v0.4,我们想一路走到最后,构建一个能为任何 SQL 查询生成最优机器码的查询编译器。这就是我们构建 Gravity 的原因。
通常,当工程师试图让软件更快时,他们自下而上工作。他们运行基准测试,查看火焰图,找到热点,并弄清楚如何将代码加速 15% 或 20%。自下而上工作的问题是你不知道天花板在哪里。你可能距离硬件能运行的最快代码只有 20%,或者你可能差了 100 倍。
当我们开始构建 Gravity 时,我们把这个问题翻转了过来。我们问自己:数据库实际上能有多快?我们没有逐步加速现有的查询引擎,而是想从我们的硬件和文件格式的物理速度极限出发反向工作。我喜欢称之为"以光速编程"。
为了找出"光速"是什么样子,我们让 Claude 针对一组分析查询,直接针对我们的列式存储 crate 编写独立的、高度优化的 Rust 程序。这些程序绕过了整个 SQL 执行器,只是直接在我们的磁盘格式上运行手工调优的、多线程的带 SIMD 的 Rust 循环。当我们运行那些手写的 Rust 程序时,它们在所有查询上比 ClickHouse 等现有分析数据库快大约 4 倍。然后我们从那些手工调优的 Rust 循环中挖掘出可以纳入 Gravity 的优化。
我们对 Gravity 的目标变得很简单:对于任何 SQL 查询,在运行时 JIT 编译机器码,使其达到 Claude 直接针对我们的文件格式编写的手写 Rust 代码的 20% 以内。
Gravity 的总体架构
设计 Gravity 架构时,我们的主要目标是在生成的输出中给自己大量灵活性。我们知道,如果我们能轻松地改变 Gravity 以发出查询所需的任何低级优化,那么缩小差距并接近光速就会容易得多。
我们没有硬编码执行模式或将固定桩代码缝合在一起,而是将 Gravity 构建为一个多阶段编译器管道,逐步将高层 Postgres 计划降低为原始汇编:
SQL 查询
-> Postgres 计划
-> 管道图
-> Lisp IR
-> 汇编
为了看看查询在编译过程的每个阶段是什么样子,让我们拿以下查询为例:
sql
SELECT UserID, extract(minute FROM EventTime) AS m, MIN(URL), COUNT(*)
FROM hits
WHERE URL LIKE '%google%'
GROUP BY UserID, m
ORDER BY COUNT(*) DESC
LIMIT 10;
阶段 1:Postgres 计划
Gravity 不替换 Postgres 的解析器或查询计划器。当查询到达时,pgrust 计划器分析表统计信息并产生一个标准的查询计划。
Limit (cost=... rows=10)
-> Sort (Sort Key: count(*) DESC)
-> HashAggregate (Group Key: userid, extract(minute FROM eventtime))
-> Seq Scan on hits (Filter: (url ~~ '%google%'))
阶段 2:管道图
接下来,Gravity 取得查询计划并将其转换为一个"管道图"。这是一个中间表示,表达数据将在查询执行过程中如何流动。每个管道代表一行在查询中如何被转换和操作。
以下是我们查询的管道图草图:
graph pipelines=3 breakers=2 root=p2
p0 eligible end=breaker(slot=0 HashAgg)
source range rel=hits am=pgrc
lanes Attr(UserID):byval8 Attr(EventTime):byval8 Attr(URL):dict4
filter like(URL, param0)
project (key0=UserID, key1=extract_minute(EventTime), agg0=min(URL), agg1=count_star())
p1 serial end=breaker(slot=1 TopK)
source breaker slot=0
topk k=10 order=agg1:DESC
p2 serial end=sink
source breaker slot=1
project (UserID, m, min_url, count) -> wire
在这种情况下,查询被分成三个管道:
- p0 从 hits 表中取出行,过滤出 url 包含"google"的行,然后将行插入哈希表以按 user_id 和事件时间的分钟计算 COUNT(*) 和 MIN(url)
- p1 取得结果哈希表并将其放入大小为 10 的堆中,以计算 COUNT(*) 最高的 10 个组
- p2 取得结果堆,取出这些行并通过网络发送
阶段 3:Lisp IR
接下来,Gravity 将每个管道编译成我们基于 Lisp 的、类汇编中间表示中的单个函数。因为 IR 是通用的,我们可以将 p0 中的扫描、过滤、投影和聚合融合成我们想要的任何循环结构。以下是 p0 的代码示例:
lisp
;; ── 循环头:每行一次行程 ─────────────────────────────── (Seq Scan)
(block b210 ((v2574 i64) ...) ; v2574 = 行索引;循环状态作为块参数随行
(v2585 (icmp slt v2574 v103)) ; 本批次还有更多行吗?
(condbr v2585 (b211) (b35 ...))) ; 否 -> 获取下一批
(block b213 ((v2587 i64)) ; ── 读取行的列 ── (Seq Scan)
(v2591 (ptr_add v105 v2587 8 0)) ; &EventTime[row]
(v2592 (ptr_add v109 v2587 8 0)) ; &UserID[row]
(v2593 (ptr_add v113 v2587 4 0)) ; &URL[row] (字典代码)
(v2612 (loadz i32 v2593 0)) ; ── WHERE URL LIKE '%google%' ── (Filter)
(v2616 (load i64 (ptr_add v2605 (ushr v2612 6) 8 0)))
(v2621 (iand (ushr v2616 (iand v2612 63)) 1)) ; 每个不同 URL 一位
;; ... ; 行失败 -> 直接到下一行
)
(block b227 () ; ── extract(minute FROM EventTime) ── (Projection)
(v2695 (load i64 v2591 0))
(v2711 (iadd v2695 211813660800000000))
(v2715 (umulh (ushr v2711 10) 10746007285463371))
(v2720 (isub v2711 (imul (ushr v2715 11) 3600000000)))
(v2726 (ushr (imul (ushr v2720 8) 18764999) 42)) ; 分钟
(v2732 (load i64 v2592 0)) ; UserID
;; ...
)
(block b229 () ; ── GROUP BY UserID, m ── (HashAggregate)
(v2897 (icmp eq v2576 v2732)) ; 与寄存器中的组相同 UserID?
(v2898 (icmp eq v2577 v2739)) ; 相同分钟?
(v2899 (iand v2897 v2898))
(condbr v2901 (b249) (b250))) ; 否 -> 将其写入哈希表 (b250),开始新组
(block b249 () ; ── MIN(URL), COUNT(*) ── (HashAggregate)
(v2903 (loadz i32 v2593 0)) ; 此行的 URL 代码
(v2911 (icmp ule v2903 (iand v2581 4294967295)))
(v2912 (select v2911 v2908 v2581)) ; MIN:保留较小的代码
(v2921 (iadd v2584 1)) ; COUNT(*) += 1
(br b228 v2576 v2577 v2578 v2579 v12 v2912 v2913 v2915 v2921))
(block b228 (...) ; ── 下一行 ──
(br b214 ...)) ; -> b214:行索引 + 1 -> 回到 b210
阶段 4:汇编
最后,我们为每个目标都有将 IR 编译为汇编的后端:
asm
;; ── b210 循环头 ─────────────────────────────────────────── (Seq Scan)
2b3c cmp x10, x24 ; v2585 = row < rows_in_batch
2b40 b.lt 0x2b70 ; condbr v2585 (b211) (b35)
;; ── b213 读取行的列 ───────────────────────────────────── (Seq Scan)
; v2591/v2592/v2593 (ptr_add) 不发出指令:
; 它们折叠到下面的索引加载中 ([base, row, lsl #n])
2b8c ldr w6, [x27, x5, lsl #2] ; v2612 = URL[row] (loadz i32)
;; ── WHERE URL LIKE '%google%' ──────────────────────────────── (Filter)
2b90 lsr x7, x6, #6 ; code >> 6
2b94 ldr x8, [x0, x7, lsl #3] ; v2616 = words[code >> 6]
2b98 and x0, x6, #0x3f ; code & 63
2b9c lsr x7, x8, x0 ; word >> (code & 63)
2ba0 and x7, x7, #0x1 ; v2621 = matched bit
;; ── b227 extract(minute FROM EventTime) ───────────────────── (Projection)
2e04 ldr x1, [x25, x5, lsl #3] ; v2695 = EventTime[row]
2e38 mov x7, #0x2000 ; ┐
2e3c movk x7, #0xfa8f, lsl #16 ; │ 常量 211813660800000000
2e40 movk x7, #0x8366, lsl #32 ; │
2e44 movk x7, #0x2f0, lsl #48 ; ┘
2e48 add x1, x1, x7 ; v2711
2e4c lsr x7, x1, #10 ; v2711 >> 10
2e50 mov x8, #0xf94b ; ┐
2e54 movk x8, #0xcb00, lsl #16 ; │ 常量 10746007285463371
2e58 movk x8, #0x2d6f, lsl #32 ; │
2e5c movk x8, #0x26, lsl #48 ; ┘
2e60 umulh x7, x7, x8 ; v2715
2e64 lsr x7, x7, #11 ; v2715 >> 11 (小时)
2e68 mov x8, #0xa400 ; ┐ 常量 3600000000
2e6c movk x8, #0xd693, lsl #16 ; ┘
2e70 mul x7, x7, x8 ; hours * 3600000000
2e74 sub x1, x1, x7 ; v2720 = 小时内微秒数
2e78 lsr x1, x1, #8 ; v2720 >> 8
2e7c mov x7, #0x54c7 ; ┐ 常量 18764999
2e80 movk x7, #0x11e, lsl #16 ; ┘
2e84 mul x1, x1, x7
2e88 lsr x1, x1, #42 ; v2726 = 分钟
2e9c ldr x0, [x26, x5, lsl #3] ; v2732 = UserID[row]
;; ── b229 与寄存器中的行程是同一组吗?──────────────────────── (HashAggregate)
2eb0 cmp x12, x0 ; v2897 = run.UserID == UserID
2eb4 ccmp x13, x1, #0x0, eq ; v2898, v2899 = && run.minute == minute
2eb8 ccmp x14, x6, #0x0, eq ; v2901 = && run.flags == flags (第三个键比较,从 IR 示例中截去)
2ec8 b.ne 0x2efc ; condbr v2901 (b249) (b250)
;; ── b249 MIN(URL), COUNT(*) ───────────────────────────────── (HashAggregate)
2ecc ldr w7, [x27, x5, lsl #2] ; v2903 = URL[row] (再次加载)
2ed0 ldr w8, [x19, #264] ; ┐ v2908 = (part << 32) | code
2ed4 orr x0, x7, x8, lsl #32 ; ┘ (select 的操作数;其定义从 IR 示例中截去)
2ed8 and x8, x30, #0xffffffff ; v2581 & 0xFFFFFFFF (run.min 在 x30 中)
2edc cmp x7, x8 ; ┐ v2911 = code <= run.min's code
2ee0 cset x7, ls ; ┘
2ee4 cmp x7, #0x0 ; ┐ v2912 = select(v2911, v2908, v2581)
2ee8 csel x30, x0, x30, ne ; ┘
2ef0 add x4, x4, #0x1 ; v2921 = COUNT(*) + 1 (count 在 x4 中)
2ef8 b 0x337c ; br b228 ...
;; ── b228 → b214 下一行 ──────────────────────────────────────
337c mov x18, #0x1 ; 块参数 v12("一个行程已打开")
3380 add x10, x10, #0x1 ; row + 1
3384 b 0x2b3c ; 回到 b210
从底层开始:先设计和基准测试 Lisp IR
我们构建的 Gravity 的第一部分是后端。我们想在我们构建 SQL 编译器之前证明我们的 IR 和代码生成器实际上能够匹配 rustc -O3。一旦我们的 Lisp IR 和后端工作起来,我们通过让 Claude 编写优化 IR 程序来测试它们,这些程序镜像了之前手写的 Rust 代码。
对这些手写 IR 程序进行基准测试给我们带来了一个重大消息:我们的 Lisp IR 编译时间不到一毫秒,运行时间在 rustc 编译的手写 Rust 代码的 10% 到 20% 以内。更重要的是,在我们测试的几乎每个查询上,我们都不需要发明任何奇特的新 IR 指令。IR 的表达力足够强,以至于我们朴素的 SQL 引擎和"光速"之间的整个差距归结为将 SQL 查询编译到 IR。
教计划器新算法
当我们在一组查询语料库上将 Gravity 与 Claude 手写的 Rust 进行比较时,我们发现有些查询手写代码仍然快几个数量级,不是因为循环的编译方式,而是因为它在运行一种根本不同的算法。尽管我们保留了 Postgres 的计划器,但标准 Postgres 计划节点无法表达光速算法。我们必须确保我们的计划能够编码它们。
在我们优化的查询中,我们发现了需要添加到计划器的三种算法。这三种算法都是列式格式特有的,所以 Postgres 计划器无法支持它们是有道理的。请记住,对于列式表,你通常会指定一个排序键,所以数据通常以将相似值放在彼此附近的顺序排列。这三种算法:
- ZoneWalk - 对于像 ORDER BY ... LIMIT n 这样的查询,Postgres 会通过使用索引或扫描整个表来执行它。pgrcolumnar 启用了一种额外的执行查询的方式。pgrcolumnar 将表分成 8192 行的批次,称为 granules。每个 granule 都有跟踪该批次最小值和最大值的元数据。我们没有扫描整个表并排序以找到前 n 行,而是使用最小/最大边界按顺序读取 granules,一旦 n 行被确定就停止。
- ZoneTopK - 对于 GROUP BY ... LIMIT n 形状的查询,我们可以使用一种与 ZoneWalk 非常相似的算法。我们没有扫描整个表并对每一行进行哈希以仅返回 n 个组,而是可以使用元数据按 GROUP BY 键的顺序遍历 granules。一旦我们看到 n 个完整的组,其最大可能的键低于每个剩余批次的最小键,我们就可以立即停止扫描。ClickHouse 26.10 有一个类似的优化,enable_group_by_top_k_dynamic_filtering,它也需要完整的组。
- TwoPass - 对于 GROUP BY ... ORDER BY count(*) DESC LIMIT 10 形状的查询,如果 group by 中的一列具有高基数,那么分两遍执行查询可能更快:
- 通过按键计数行来计算每个组大小的上限:SELECT unique_key, COUNT(*) ... GROUP BY unique_key
- 然后回去只获取上限足够大、可能进入前 10 的组
这能工作是因为查询中最昂贵的部分是第一遍。通过第一遍只获取一列,然后在第二遍获取其余部分,你能够节省大量内存带宽。
所有这三个计划都作为成本化计划添加到 pgrust 计划器中。在计划时,计划器估计一次遍历在停止之前实际需要读取多少个 granules,并且只有在它比标准扫描更便宜时才选择重写。
现在我们的查询使用了与光速实现相同的算法,并且我们知道我们的 IR 足够快以编译我们的查询计划,最后一步就是将 SQL 编译到我们的 IR。
优化 SQL→IR 编译
对于 SQL→IR 编译,唯一重要的是我们能将 IR 优化到什么程度。因为我们使用与光速 Rust 程序相同的算法,我们可以将我们的代码与 Rust 代码进行差异比较,看看差异来自哪里。这带来了 100 多项优化。以下是其中三项,让你感受一下是什么让 Gravity 如此快。
1. 内联函数调用和聚合
在标准 Postgres 中,评估像 extract(minute FROM EventTime) 这样的 SQL 函数或更新像 AVG 或 MIN 这样的聚合,会在每一行上通过 Postgres 的函数管理器(fmgr)进行函数调用。每行一次函数调用(甚至每个小批次一次)完全破坏性能。
为了解决这个问题,我们将所有 Postgres 内置函数分类到不同的层级,并且对于 238 个核心函数和聚合,我们直接将它们内联到我们的 Lisp IR 中。一个带有 COUNT(*) 和 SUM 的查询将改为看起来像这样:
lisp
(block b32 ()
(v313 (load i64 v307 24)) ; 从聚合状态加载 count
(v314 (iadd v313 v194)) ; count + 1
((store v307 24 v314))
(v315 (load i64 v307 32)) ; 从聚合状态加载 sum
(v316 (iadd v315 v309)) ; sum + val
((store v307 32 v316))
(br b22 v197))
基准测试 AVG 的性能时,内联将循环从每行 392 条指令降到每行 120 条指令,并使查询快了 2.56 倍。
2. 延迟物化
在我们的列式格式中,像 URL 这样的文本列是字典编码的:表的每个部分存储一个唯一字符串的字典,而行本身存储指向字典的整数代码。
我们早期版本的 Gravity 仅根据 SQL 类型(TEXT、INT4、NUMERIC)来决定如何为表达式发出 IR。因为 url 的 SQL 类型是 TEXT,Gravity 将字典代码解码为字符串并将其复制到每一行。当我们查看 Claude 为这些查询手写的 Rust 时,它从未在行循环内接触字符串。它使用了延迟物化。它尽可能长时间地操作字典代码,只在最后才物化实际的字符串。
我们重新设计了 Gravity,使每个值尽可能晚地物化。当在每一行上计算像 url LIKE '%google%' 这样的过滤器时,编译器发出一个步骤,对字典中的每个唯一条目运行我们高度优化的子串匹配器,并创建一个位图,记录哪些字典代码匹配该过滤器。默认情况下,v0.4-beta 将该位图保存在服务器内存中,以过滤器及其常量和表部分为键,这样以后对未更改数据使用相同 LIKE 的查询会重用它,而不是再次运行匹配器。设置 pgrust.dict_filter_cache = off,或 pgrust.condition_cache 为 granule 或 off,会让每个查询构建自己的位图。
要计算像 MIN(URL) 这样的聚合,Gravity 不会逐行比较字符串。压缩字典是排序的,所以对于从同一字典编码的两个字符串,代码较小的字符串就是较小的字符串。因此,当计算用同一字典压缩的一系列行中的最小字符串时,它主要变成整数比较而不是字符串比较。
3. 寄存器中的行程折叠
即使我们的过滤器和表达式被内联,在数百万行上探测多键哈希表也是昂贵的。
当我们在 GROUP BY ... 查询上将 Gravity 的输出与 Claude 手写的 Rust 进行比较时,我们注意到一些有趣的事情。在现实世界的表中,像 UserID 或事件时间戳这样的列的值通常在磁盘上部分聚集,这意味着我们经常看到属于完全相同组的连续行行程。Claude 的 Rust 代码利用了这一点,将当前组的键和运行计数保存在局部变量中(rustc 将其保存在 CPU 寄存器中)。只要下一行与前一行具有相同的键,它就只是在寄存器中增加计数器,只在行程结束时才探测哈希表。在一个 1 亿行的表上,这将 1 亿次哈希表探测折叠到 2200 万次。
我们修改了 Gravity 以跟踪前一个组键,并且只在看到新组键时才探测哈希表。
确保它能泛化
在构建 Gravity 时,我们遇到的一个挑战是 Claude 倾向于将优化过拟合到我们的语料库查询。通常 Claude 只会为我们正在基准测试的那个确切查询编写优化。如果用户写 strpos(URL, 'google') > 0 而不是 URL LIKE '%google%',或者按包装在 Postgres DOMAIN 类型中的列分组,脆弱的模式匹配器会错过快速路径并静默地回退到慢速代码。
为了抵消这一点,我们制作了每个优化的目录。然后我们编写了一个 fuzzer,它取一个优化并在许多不同的查询形状上测试它。它会尝试各种类型(int2、int4、int8、numeric、text、domain)的 group by,以及更多,以确认优化在许多查询中生效,而不仅仅是一个。Dan Luu 写过 pgrust 投入了多少测试,这也发现了 Postgres 本身的真正 bug。
Gravity 今天的状态
先针对手写 Rust 设计我们的 Lisp IR,然后教 Gravity 发出内联的、延迟物化的、寄存器优化的 IR,让我们非常接近我们的"光速"目标。在广泛的分析查询中,Gravity 的运行时 JIT 在不到一毫秒内编译查询,并在 Claude 直接针对我们的文件格式编写的手写 Rust 代码的 10% 到 20% 以内执行。
当我们通过在完全相同的列式表上运行完全相同的 pgrust 二进制文件、打开 Gravity 与我们的 Volcano 行执行器相比来隔离查询引擎时,Gravity 冷运行快 102 倍,热运行快 499 倍,并且比 ClickHouse 快多达 3 倍,同时支持 Postgres 的完整 SQL 方言和类型系统。对于来自 Postgres 的任何人:在 ClickBench 的 c6a.4xlarge 机器上,pgrust v0.4-beta 的综合得分比 PostgreSQL 17 好 1,000 倍以上,冷运行好 1,365 倍。
如果你想深入研究 Lisp IR、TPDE 后端或 lowerer,你可以查看代码并在 github.com/malisper/pgrust 关注我们的定期提交。
关于我们早期 v0.4-preview ClickBench 条目的说明:我写了一篇关于 Hacker News 上发生的事情的文章。