malisper 写了篇《JIT Compiling Code in 5μs》,讲他在 pgrust(一个用 Rust 重写 PostgreSQL 的项目)里把 JIT 编译做到约 5 微秒。这个数字的意义不是"快",而是它跨过了一条能力边界:编译足够便宜之后,你可以给每一条 SQL 查询都做 JIT,而不是只挑最贵的那些。

对比一下就知道差距有多大。PostgreSQL 内置的 JIT 走 LLVM,为一条查询生成并优化机器码,开销在毫秒量级。毫秒对"一条要跑几百毫秒的分析查询"可以接受,对"一条要跑几十微秒的 OLTP 查询"就是纯亏。所以 Postgres 的 JIT 默认只对代价超阈值的查询启用——这条阈值存在的唯一原因,就是编译太贵。

5 微秒把这个前提掀了。

为什么传统 JIT 慢

作者开头那句判断很准:历史上写快 JIT 是门黑魔法,你得懂汇编。原因在于传统 JIT 的工作流程。

一个常规 JIT 要么自己写后端(把 IR 降成机器码,处理寄存器分配、指令选择、重定位),要么借用现成的编译器库(LLVM、Cranelift、QBE)。前者要投入大量工程,后者每次编译都要走一遍完整的优化流水线——IR 构造、pass 管理、指令选择、寄存器分配、代码发射。即使只是"加两个整数",LLVM 也要老老实实跑完整条链。

对短小的查询计划来说,这条链的成本远远超过被编译代码本身的执行收益。这就是为什么 JIT 在数据库里长期是个"可选加速器",而不是默认执行路径。

Copy-and-patch:把编译变成填空

pgrust 走的是 copy-and-patch 的路子。核心想法是:既然要生成的代码模式是有限的(加、乘、比较、跳转、函数调用……),那就提前把这些模式编译成机器码模板,也就是所谓的 stencil。

每个 stencil 是一段经过编译器预处理的机器码,里面留了若干个"洞"——需要填入立即数、寄存器编号、跳转地址的位置。这些洞的位置在构建期就被记录下来,跟着 stencil 一起存进表里。

运行时要做的事情变得极其简单:按查询计划选出一串 stencil,把每个 stencil 的洞填上当前需要的值,然后把填好的机器码块依次拷贝到一块可执行内存里,最后跳过去执行。

用作者的话说,就是"把若干个填好的 stencil 串起来,在运行时拼出一个程序,性能接近手写版本"。

5 微秒就是这么来的。没有 IR,没有优化 pass,没有寄存器分配——只有查表、填值、memcpy。一次 memcpy 一条 cache line 的量级,几微秒完全合理。

代价与前提

这套方法不是没有代价,要想清楚才敢用:

优化能力受限。stencil 是预编译的固定形态,你没法在运行时对它做跨块的优化——不能内联、不能重排指令、不能做全局的寄存器分配。它换来的速度,一部分是从"不做优化"里省出来的。所以它适合的是"代码模式简单、执行次数多"的场景,不适合"代码复杂、需要深度优化"的场景。

模板覆盖率决定上限。你能生成的代码,等于你能提供的 stencil 集合。查询计划里出现了没有对应模板的操作,就得回退到解释执行或者另想办法。这要求实现者把 SQL 执行涉及的操作穷举得足够全。

可执行内存的管理。拼出来的代码要放进可写可执行的内存页,涉及到 W^X 策略、缓存刷新的问题。在 x86 上相对宽松,在 ARM 上还要处理指令缓存与数据缓存的一致性。这部分不做好,性能和正确性都会出问题。

但换来的东西很实在:编译成本从"毫秒级、要挑查询"变成"微秒级、可以全量"。作者提到优化得当的话常有 2 到 5 倍的提速,某些场景更高。当编译免费的时候,JIT 就从"给大查询的奖励"变成"所有查询的默认路径",这是量变引发质变。

对做数据库/DSL 的人意味着什么

如果你在设计一个查询引擎或者任何需要"运行时生成代码"的系统,这件事的启示是:

先问编译成本能不能降两个数量级。很多时候限制 JIT 应用范围的不是"编译出来的代码好不好",而是"编译本身要多久"。把编译成本压到比解释执行一次的开销还低,整个设计空间就打开了。

别急着上通用编译器后端。LLVM 是万能工具,但它的每次调用都有固定成本。如果你的生成代码形态有限、且生成频率高,模板拼接往往比调用 LLVM 更划算。实测数字比"哪个技术更先进"重要。

用可测量的方式定义"够快"。作者给的是 5 微秒这个具体数,不是"很快"。有了这个数,才能判断"给每条查询都 JIT"是划算的——一条查询的平均执行时间如果是 50 微秒,5 微秒的编译开销占 10%,值得;如果是 10 微秒,就需要再想。

我的做法

做性能敏感系统时,我会先量出编译/构建环节的固定成本,再决定它的使用边界:

# 粗测一次编译开销,判断能不能放进热路径
perf stat -e task-clock ./bench_single_query --iterations 100000

如果单次编译在微秒量级,就考虑全量启用;在毫秒量级,就只能当优化开关,按代价阈值挑查询。pgrust 这篇的价值是把前一种情况变得可行了——它证明了"编译成本"不是物理常量,是可以被架构选择改变的量。