DeepSeek总结的pgrust以光速编程:构建 Gravity 查询引擎

以光速编程:构建 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 上发生的事情的文章。

相关推荐
重生之我在番茄自学网安拯救世界1 小时前
AI开发者日报 第2期 | Anthropic零 Rust 团队靠Claude重写核心 API,CPU 降 40–50%
人工智能·网络安全·大模型·ai编程·claude
zwc32104320551 小时前
Day 8:Canny边缘检测
人工智能·计算机视觉
kaixin_啊啊1 小时前
【零基础学AI】第 21 章——神经网络怎样工作
人工智能·深度学习·神经网络
郝学胜-神的一滴1 小时前
游戏设计:原型与开发 19:U3D工位布置与性格说明书
人工智能·算法·unity·unreal engine·游戏策划·游戏设计
木木_Q4057733491 小时前
Oracle 增量同步配置指南:只搬变了的数据,不重复也不漏数
数据库·oracle·数据同步·数据库同步·增量同步
Data-Miner1 小时前
AI和Excel怎么结合才能真提效
人工智能·excel
七夜zippoe1 小时前
Spring AI 函数调用:让 Java 方法变成 Agent 的工具(实战全流程)
java·人工智能·spring·ai·agent·函数调用
wflynn1 小时前
DeepSeek 开源 Super-agent 架构:AI 主动发现 Bug 并提交 PR
人工智能·ai
升鲜宝供应链及收银系统源代码服务1 小时前
Java Spring Boot 商业版 升鲜宝供应链管理系统--商品模块表与字段分析(商品管理页面业务逻辑与数据表关系分析)
数据库·生鲜配送系统排名·生鲜配送系统架构设计·b2b订货商城解决方案·生鲜配送系统源代码出售·生鲜配送系统设计源代码