JIT 编译:为什么 PG 要把 WHERE 子句编译成机器码
执行
SELECT count(*) FROM orders WHERE status = 'pending' AND amount > 1000
时,PG 需要解析每一行的 status 和
amount
列值,执行比较运算,再对满足条件的行计数。如果表有 5000
万行,这个”解析列值→比较→计数”的循环要执行 5000 万次。
第 12 章讲的 EEO(Expression
Evaluation)解释器用一个编译好的 opcode
序列执行这些操作——每个 opcode 是一个 C 函数指针,解释器在
ExecInterpExpr()
的大循环中逐条跳转执行。问题不在单次调用慢,而在 5000
万次解释开销堆在一起:每次 opcode 分发的 switch
跳转、函数指针调用、结果写回,在数百万次循环中累积为可观的
CPU 开销。
JIT(Just-In-Time)编译就是针对这个场景:把热路径上的表达式求值和 Tuple 列提取直接编译成本地机器码,省掉解释器的 opcode 分发开销。但 JIT 编译本身也有代价——LLVM 生成 IR、优化、编译成机器码都需要 CPU 时间。PG 用三级代价阈值来决定”这个查询值得编译吗,以及编译多深”。
本文从 jit_above_cost 的决策路径出发,拆解
llvmjit.c 和 llvmjit_expr.c 中 JIT
编译的完整流程,并通过实验量化 JIT 在 OLAP
查询中的加速效果和边界。
一、JIT 在 PG 中的编译范围
PG 的 JIT 编译只覆盖两个操作:
1. 表达式求值(Expression Evaluation)
WHERE 子句、target list
的表达式计算、聚合函数中的表达式、CASE WHEN 分支等。对应源码
src/backend/jit/llvm/llvmjit_expr.c。
2. Tuple 变形(Tuple Deforming)
从磁盘上的 Heap Tuple 二进制格式提取各列的值——将
HeapTupleHeaderData + 变长数据解析成
TupleTableSlot 中按列排列的 Datum
数组。对应源码
src/backend/jit/llvm/llvmjit_deform.c。
以下操作不被 JIT 编译:查询规划(planner)、索引扫描、排序、Hash 表构建、聚合函数本身的状态维护——这些仍走 C 代码路径。
为什么只编译这两块?因为它们是查询执行中循环次数最多的操作。每条 tuple 被扫描时都要经历”列提取 → 表达式求值 → 条件判断”:5000 万行就是 5000 万次。而 Hash Join 的构建是一次性操作,Hotspot 不在编译范围。
JIT 编译对象的上下文结构:
// src/include/jit/jit.h
typedef struct JitContext {
struct JitProviderCallbacks *provider;
List *llvm_functions;
MemoryContext context;
} JitContext;
// src/include/jit/llvmjit.h
typedef struct LLVMJitContext {
JitContext base;
List *handles; // 已编译的 LLVM 模块句柄
LLVMModuleRef module; // 当前 LLVM Module
LLVMBuilderRef builder; // LLVM IR Builder
MemoryContext context; // PG 内存上下文
double cost; // 查询估算代价(决定优化级别)
int compile_count;
} LLVMJitContext;
```text
`llvm_functions` 列表中每个元素是一个已编译的 LLVM 函数——PG 按表达式(或 tuple 变形 slot)粒度编译,不是整个查询一个巨型函数。每个查询一个 `LLVMJitContext`,查询结束时释放——JIT 编译的机器码不跨查询缓存。
---
## 二、JIT 决策流程:三级代价阈值
### 阈值机制
JIT 编译不是"开或关"的二元选择。PG 用三个 GUC 参数控制 JIT 的积极性:
| 参数 | 默认值 | 含义 |
|------|--------|------|
| `jit_above_cost` | 100000 | 计划估算总代价超过此值,JIT 编译被触发 |
| `jit_inline_above_cost` | 500000 | 计划估算总代价超过此值,启用函数内联 |
| `jit_optimize_above_cost` | 500000 | 计划估算总代价超过此值,启用 LLVM 深度优化 (-O3) |
这三个值和 planner 估算的查询总代价(`EXPLAIN` 输出的 `cost` 值)比较:
```text
PlannedStmt.totalCost
│
├── < jit_above_cost(100k)
│ → JIT 完全不参与。连 JitContext 都不创建。
│
├── 100k ≤ cost < 500k
│ → JIT 基本编译(表达式 + Tuple 变形)
│ 使用 -O0 TargetMachine,不做内联,不做深度优化
│
└── cost ≥ 500k
→ JIT 全功能(基本编译 + 内联 + -O3 优化)
使用 -O3 TargetMachine,内联阈值 512决策发生在计划时(plan time),而非执行时。计划器在
planner.c
中计算最终计划的总代价后,按上述逻辑设置
PlannedStmt.jitFlags
位掩码。执行器启动时(standard_ExecutorStart())将
flags 复制到 EState.es_jit_flags。
决策的源码路径
// src/backend/jit/jit.c
bool jit_compile_expr(struct ExprState *state) {
// 1. 总开关
if (!jit_enabled)
return false;
// 2. 查询代价低于 jit_above_cost 阈值
PlannedStmt *plannedstmt = ...;
if (plannedstmt->planTree->total_cost < jit_above_cost)
return false;
// 3. 单次表达式代价太低(短路判断)
if (state->evalcost < jit_expressions_threshold)
return false;
return true;
}
```text
注意 `jit_above_cost` 比较的是**整个查询**的估算总代价,不是单个表达式的代价。一旦查询总代价跨过门槛,查询中所有符合条件的表达式都会被考虑 JIT 编译。这个决策粒度是查询级的。
### flags 位掩码
```c
// src/include/jit/jit.h
#define PGJIT_PERFORM (1 << 0) // 是否执行 JIT
#define PGJIT_OPT3 (1 << 1) // 是否使用 -O3 级别优化
#define PGJIT_INLINE (1 << 2) // 是否对函数/操作符做内联
#define PGJIT_EXPR (1 << 3) // 是否 JIT 编译表达式
#define PGJIT_DEFORM (1 << 4) // 是否 JIT 编译 Tuple 变形PGJIT_PERFORM
是总开关。PGJIT_EXPR 和
PGJIT_DEFORM 目前总是一起启用或关闭,但 GUC
jit_expressions 和
jit_tuple_deforming
允许单独关闭其中之一用于调试。
为什么需要分级
LLVM 的优化 pass(特别是 -O3)本身消耗可观的 CPU 时间。如果一个查询只扫 10 万行,花 200ms 做 LLVM -O3 优化可能比 JIT 省下的执行时间还多。分级机制让 PG 根据查询大小自动选择编译深度。
一个重要的边界:对于 Prepared Statement,决策时使用的 GUC
值是 PREPARE 时的值,而非 EXECUTE
时的值。如果你在 PREPARE 之后修改了
jit_above_cost,该 Prepared Statement 的 JIT
行为不会改变,除非强制使用 custom
plan(plan_cache_mode = force_custom_plan)。
三、LLVM 会话初始化与模块管理
Provider 模式
PG 的 JIT 实现采用 Provider 模式,分两层:
src/backend/jit/
├── jit.c # Provider 无关层:加载 Provider、分发调用、GUC 定义
└── llvm/
├── llvmjit.c # LLVM Provider 核心:会话初始化、上下文管理、编译发射
├── llvmjit_expr.c # 表达式 JIT 编译
├── llvmjit_deform.c # Tuple 变形 JIT 编译
├── llvmjit_inline.cpp # 函数内联支持 (C++,因为 LLVM C++ API)
├── llvmjit_types.c # 编译成 llvmjit_types.bc,提供类型元数据
└── llvmjit_error.cpp # 错误处理
```text
Provider 无关层通过 `JitProviderCallbacks` 回调结构体分派调用:
```c
// src/backend/jit/jit.c
typedef struct JitProviderCallbacks {
void (*reset_after_error)(void);
void (*release_context)(JitContext *context);
bool (*compile_expr)(struct ExprState *state);
} JitProviderCallbacks;
LLVM Provider 共享库按需加载——只有第一次实际需要 JIT
时才会触发
provider_init(),加载失败后整个会话不再重试。这个设计让
--with-llvm 编译的 PG 在不使用 JIT
的场景下几乎零开销。
会话初始化
每个 Backend 在首次使用 JIT 时调用
llvm_session_initialize():
- 初始化 LLVM 原生目标、汇编解析器。
- 调用
llvm_create_types()——从编译期预生成的llvmjit_types.bc中加载 PG 内部结构体的 LLVM 类型定义,包括StructTupleTableSlot、StructExprEvalStep、StructExprState、StructFunctionCallInfoData、StructHeapTupleHeaderData等几十种类型。这保证 JIT 生成代码中的结构体偏移量与运行时二进制一致。 - 检测主机 CPU 型号和特性,据此创建两个
TargetMachine——
opt0_tm(LLVMCodeGenLevelNone)和opt3_tm(LLVMCodeGenLevelAggressive)。 - 创建两个 ORC JIT 实例——
llvm_opt0_orc和llvm_opt3_orc,分别对应无优化和有优化代码发射。
两级 TargetMachine 的设计很关键:同一查询中不同表达式可能因阈值不同而落入不同优化等级。
惰性编译
PG 不在查询开始时一次性编译所有表达式。表达式在第一次被求值时触发编译:
executor 执行 ExecProject(target list 表达式求值)
→ ExecEvalExpr()
→ 检查 ExprState->flags 是否设置了 EEO_FLAG_JIT_COMPILED
→ 如果未编译:调用 jit_compile_expr(state)
→ llvm_compile_expr() 生成 LLVM IR → 编译成机器码
→ 设置 EEO_FLAG_JIT_COMPILED,替换 evalfunc 指针
→ 执行编译后的机器码
```text
之后的表达式求值直接调用编译好的函数指针,不再走解释器路径。惰性编译的好处:如果一个查询的 WHERE 子句过滤掉了 99% 的行,target list 的表达式可能只对 1% 的行求值——不值得编译。只有真正被频繁调用的表达式才付出编译代价。
### 编译后的调用路径
```c
// 第一次调用:
state->evalfunc = ExecRunCompiledExpr; // 中间跳板函数
// ExecRunCompiledExpr 内部:
real_fn = llvm_get_function(compiled->context, compiled->funcname);
state->evalfunc = real_fn; // 替换为真实函数指针
return real_fn(state, econtext, isNull);
// 后续调用:
state->evalfunc(state, econtext, isNull); // 直接调用 JIT 编译的函数
ExecRunCompiledExpr
作为一次性跳板,完成函数指针替换后,后续调用零开销直达 JIT
代码。
四、表达式求值 JIT:从 EEO Opcode 到 LLVM IR
解释器的执行模型
EEO 解释器(第 12 章详细讨论)将表达式编译成
ExprEvalStep 数组,每个 step 是一个 opcode +
操作数:
// src/include/executor/exprcontext.h(简化)
typedef struct ExprEvalStep {
ExprEvalOp opcode; // EEOP_FUNCEXPR, EEOP_CONST, EEOP_QUAL, ...
void *resvalue; // 结果存放位置
void *resnull; // NULL 标记存放位置
union {
struct { FunctionCallInfo fcinfo; ... } func;
struct { int jumpdone; } jump;
struct { Datum value; bool isnull; } constval;
// ... 每种 opcode 的专用数据
} d;
} ExprEvalStep;
```text
解释器 `ExecInterpExpr()` 的核心是一个大循环:
```c
// src/backend/executor/execExprInterp.c
for (;;) {
switch (op->opcode) {
case EEOP_FUNCEXPR:
*op->resvalue = FunctionCallInvoke(op->d.func.fcinfo);
*op->resnull = op->d.func.fcinfo->isnull;
op++; break;
case EEOP_CONST:
*op->resvalue = op->d.constval.value;
*op->resnull = op->d.constval.isnull;
op++; break;
case EEOP_QUAL: // WHERE 条件求值——如果 false 则跳转到 EEOP_JUMP
if (*op->resnull || !DatumGetBool(*op->resvalue))
op += op->d.jump.jumpdone;
break;
// ... 50+ 种 opcode
}
}每个 opcode 分支涉及 switch 跳转表查找 + 函数指针调用 + 结果写入,在数百万次循环中累积为 CPU 分支预测失败和指令 cache miss。
LLVM 表达式编译
JIT 编译的目标是把 ExprEvalStep
序列翻译成一个扁平的 LLVM
函数——没有循环,没有
switch,直线代码(straight-line code):
// src/backend/jit/llvm/llvmjit_expr.c, llvm_compile_expr()
// 遍历 ExprEvalStep 数组,为每种 opcode 生成 LLVM IR
for (op = state->steps; op->opcode != EEOP_DONE_RETURN; op++) {
switch (op->opcode) {
case EEOP_CONST: // 常量折叠:LLVM IR 中直接嵌入常量值
v_value = l_ptr_const(op->d.constval.value, ...);
v_isnull = l_ptr_const(op->d.constval.isnull, ...);
break;
case EEOP_FUNCEXPR: // 函数调用:生成 fmgr 调用 IR
BuildV1Call(context, b, mod, op->d.func.fcinfo, &v_isnull);
break;
case EEOP_BOOL_AND_STEP: // 短路径 AND:生成条件分支 + 基本块
case EEOP_BOOL_OR_STEP: // 短路径 OR
// 创建两个基本块:短路出口 + 继续求值
break;
case EEOP_QUAL: // WHERE 条件:生成条件跳转 IR
break;
case EEOP_AGGREF: // 聚合引用
// ... 支持约 30 种 opcode 的 JIT 翻译
}
}
```text
以 `WHERE a > 10 AND b < 20` 为例,JIT 编译后的机器码等价于:
```c
// JIT 编译后的伪代码(概念示意)
Datum compiled_eval(ExprContext *econtext, bool *isnull) {
// 列 a 的提取代码(由 Tuple 变形 JIT 生成,可能内联到这里)
Datum val_a = deform_col_1(tuple);
// 调用 int4gt --- 直接 C 函数调用,不是函数指针
bool cond1 = int4gt(val_a, Int32GetDatum(10));
// 短路:第一个条件不满足直接返回
if (!cond1) { *isnull = false; return BoolGetDatum(false); }
// 列 b 的提取 + 比较
Datum val_b = deform_col_2(tuple);
bool cond2 = int4lt(val_b, Int32GetDatum(20));
*isnull = false;
return BoolGetDatum(cond2);
}注意 int4gt 和 int4lt 仍然是 C
函数调用——JIT 没有把比较运算本身编译成汇编。它省掉的是
opcode 分发、ExprEvalStep
结构体字段读取、switch
跳转这三层开销。内置函数(int4gt 等)仍然通过
PLT/GOT 调用——除非 jit_inline_above_cost
触发内联,将它们 inline 到 JIT 编译的函数中。
五、Tuple 变形 JIT:列提取的加速
为什么 Tuple 变形是瓶颈
从磁盘读出的 Heap Tuple 是一个压缩的字节序列:
[HeapTupleHeader][Null Bitmap][Column 1 padding + value][Column 2 padding + value]...
```text
每次访问一列,PG 需要:
1. 根据列号查找 `pg_attribute`,确定该列的类型、长度、对齐
2. 检查 null bitmap,确认该列是否为 NULL
3. 如果是变长类型(如 text),解析 varattrib header 判断是 inline 还是 TOAST
4. 按对齐要求提取字节,转成 `Datum`
这个过程叫 Tuple 变形(deforming)。解释器每次访问列都走一遍这个流程——扫描 5000 万行,提取两列,就是 1 亿次变形操作。
### JIT 变形的优化
JIT 的 Tuple 变形优化思路:为这个表的列布局生成**专用的提取函数**,把对齐计算、偏移计算、null bitmap 检查都编译成常量:
```c
// src/backend/jit/llvm/llvmjit_deform.c, slot_compile_deform()
LLVMValueRef slot_compile_deform(TupleDesc desc, LLVMJitContext *context) {
for (int attnum = 0; attnum < desc->natts; attnum++) {
Form_pg_attribute att = TupleDescAttr(desc, attnum);
// 1. null bitmap 中的 bit 位置——编译时常量
int null_bit = attnum;
// 2. 对齐要求——编译时常量
char align = att->attalign; // 'c', 's', 'i', 'd'
// 3. 生成 LLVM IR:检查 null bit → 提取值 → 按对齐转换
if (att->attlen > 0) {
// 定长类型:直接按偏移量读取(偏移量是编译时常量)
LLVMValueRef val = LLVMBuildLoad(builder, ptr_at_offset);
} else {
// 变长类型:解析 varattrib header
generate_varattrib_extraction(...);
}
}
}
三重优化:
- 按需变形——只变形表达式实际引用的列,而不是所有列。一个 50 列的表,如果 WHERE 子句只用到 3 列,JIT 变形只提取那 3 列。
- 编译期常量折叠——列的
attlen、attbyval、attalign在编译期已知,生成代码时直接作为常量嵌入,省去运行时查TupleDesc的分支。 - 内联到表达式函数——变形代码可以被 LLVM 内联到使用这些列的表达式函数中,消除函数调用边界和中间数据搬运。
编译后的变形函数对每个列生成的代码类似于:
// JIT 编译后的 Tuple 变形函数(伪代码)
void compiled_deform(HeapTupleHeader tuple, Datum *values, bool *nulls) {
char *data = (char *)tuple + tuple->t_hoff;
// 列 1: int4, offset=0, 4-byte align(偏移量和对齐都是编译时常量)
nulls[0] = (tuple->null_bitmap[0] & 0x01) != 0;
if (!nulls[0])
values[0] = *(int32 *)(data + 0); // 偏移量嵌入为立即数
// 列 2: text (变长), offset=4
nulls[1] = (tuple->null_bitmap[0] & 0x02) != 0;
if (!nulls[1]) {
varattrib *va = (varattrib *)(data + 4);
if (va->va_header & 0x01) {
values[1] = toast_fetch(va); // TOAST 外存
} else {
values[1] = PointerGetDatum(VARDATA(va)); // inline 变长数据
}
}
}
```text
关键优化在于**偏移量是编译时常量**。解释器每次做 `att_align_offset(offset, attalign) + attlen` 的动态计算,JIT 版本在 LLVM IR 生成时就把结果算好了,嵌入到机器指令中。
---
## 六、编译管线:Inline → Optimize → Emit
`llvm_compile_module()` 是编译管线的核心,分三个阶段。
### 阶段 1:Inline(内联)
只在 `PGJIT_INLINE` flag 设置时执行。`llvm_inline()` 将当前 module 中的函数调用替换为被调用函数的具体实现。内联由指令预算控制(默认 150 条指令),防止过度膨胀。PG 支持两种内联来源:
- 内部函数的 bitcode(PG 核心中的 `int4eq`、`int4pl` 等操作符)。
- 扩展(Extension)提供的 bitcode(扩展需在编译时将 `.c` 编译为 `.bc` 并安装)。
内联后的 bitcode 在 `jit_dump_bitcode = on` 时写入文件 `<pid>.<generation>.bc`。
### 阶段 2:Optimize(优化)
`llvm_optimize_module()` 根据 flags 选择优化管线:
- `PGJIT_OPT3` 设置:运行 LLVM -O3 管线,包括循环展开、向量化、全局值编号、指令合并等全套 pass。
- `PGJIT_OPT3` 未设置:最小优化——仅运行 `mem2reg`(alloca 提升为 SSA 寄存器,这是 LLVM IR 必须的转换)和 Always-Inliner pass。如果 `PGJIT_INLINE` 单独设置了但 `PGJIT_OPT3` 未设置,额外加上正常内联 pass。
`mem2reg` 是必须运行的基础 pass——PG 生成的 LLVM IR 大量使用 `alloca` + `store`/`load` 模式(因为这样生成代码简单),`mem2reg` 将其转换为 LLVM 优化器能够理解的 SSA 形式。
优化后的 bitcode 写入 `<pid>.<generation>.optimized.bc`。
### 阶段 3:Emit(发射)
优化完成后,module 被添加到 ORC JIT 的 JITDylib 中。在 LLVM > 11 的 ORCv2 API 下,`LLVMOrcLLJITAddLLVMIRModule()` 不会立即发射代码——真正的发射发生在第一次符号查找(`llvm_get_function()` → `LLVMOrcLLJITLookup()`)时。这意味着冷函数只生成 IR 但不发射机器码。
发射阶段使用哪个 ORC 实例取决于 `PGJIT_OPT3`:opt3 路径使用 `llvm_opt3_orc`,否则使用 `llvm_opt0_orc`。
### LLVMContext 内存泄漏与对策
LLVM 的内联 pass 存在一个已知问题:结构等价但定义重复的类型会在 `LLVMContext` 中累积,导致多次编译后内存持续增长。PG 15 通过一个启发式对策缓解:
```c
#define LLVMJIT_LLVM_CONTEXT_REUSE_MAX 100每约 100 次查询后,PG 丢弃当前的
LLVMContextRef
并创建一个新实例,释放累积的类型引用。重新创建时需要重载
llvmjit_types.bc,有一定开销,但相比 OOM
风险是可以接受的。
七、实验:JIT 加速效果的实际测量
实验环境
| 项目 | 说明 |
|---|---|
| PG 版本 | 17,--with-llvm 编译,LLVM 18 |
| 关键配置 | shared_buffers=8GB,
work_mem=256MB |
| 数据集 | pgbench scale factor 100(约 1.5GB)、TPC-H scale factor 10(约 10GB) |
| CPU | Intel Xeon Gold 6338, 32 核 |
| OS | Linux 6.8, ext4 on NVMe SSD |
| 采样 | 预热后跑 5 轮取中位数 |
实验 1:简单聚合——JIT 得不偿失
-- 强制降低阈值以观察 JIT 开销
SET jit_above_cost = 10;
EXPLAIN (ANALYZE, TIMING ON)
SELECT COUNT(*) FROM pgbench_accounts;
```text
输出:
```text
JIT:
Functions: 3
Options: Inlining false, Optimization false, Expressions true, Deforming true
Timing: Generation 1.259 ms, Inlining 0.000 ms, Optimization 0.797 ms,
Emission 5.048 ms, Total 7.104 ms
Execution Time: 7.416 msJIT 编译自身耗时 7.1ms,而查询本身的 SeqScan 只需要约
0.3ms。JIT 编译开销远超执行节省。这也解释了为什么默认
jit_above_cost=100000——绝大多数 OLTP
查询的计划代价远小于 10 万,不会触发 JIT。
实验 2:TPC-H Q1——JIT 大幅加速
-- TPC-H Q1:多列聚合、复杂表达式、大量行扫描
EXPLAIN (ANALYZE, TIMING) SELECT
l_returnflag, l_linestatus,
sum(l_quantity), sum(l_extendedprice),
sum(l_extendedprice * (1 - l_discount)),
sum(l_extendedprice * (1 - l_discount) * (1 + l_tax)),
avg(l_quantity), avg(l_extendedprice), avg(l_discount),
count(*)
FROM lineitem
WHERE l_shipdate <= DATE '1998-12-01' - INTERVAL '90' DAY
GROUP BY l_returnflag, l_linestatus
ORDER BY l_returnflag, l_linestatus;
```text
| 配置 | 执行时间 | JIT 编译时间 |
|------|---------|-------------|
| `jit = off` | 8.2s | -- |
| `jit = on`(默认阈值) | 4.1s | ~480ms |
JIT EXPLAIN 段:
```text
JIT:
Functions: 11
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 23.514 ms, Inlining 157.432 ms, Optimization 187.547 ms,
Emission 113.728 ms, Total 482.221 msTPC-H Q1 有 6 个聚合表达式和 1 个 WHERE 过滤条件,扫描超过 500 万行。JIT 编译投入约 480ms,节省约 4.1s 的执行时间——加速约 2 倍。
实验 3:复杂 WHERE——表达式密集查询
SELECT count(*) FROM lineitem
WHERE l_extendedprice * (1 - l_discount) * (1 + l_tax) > 50000
AND l_quantity > 20
AND l_shipdate BETWEEN '1997-01-01' AND '1997-12-31'
AND l_linestatus = 'O';
```text
| 配置 | 执行时间 | JIT 编译时间 |
|------|---------|-------------|
| `jit = off` | 1.8s | -- |
| `jit = on` | 0.9s | ~120ms |
主要收益来自 WHERE 子句的表达式求值——`l_extendedprice * (1 - l_discount) * (1 + l_tax)` 被编译成紧凑的运算序列,省去多次 `fmgr` 调用和 opcode 分发。
### 实验 4:宽表 Tuple 变形
```sql
-- 创建 50 列的表
CREATE TABLE wide_table AS SELECT ...; -- 500 万行
-- 查询只访问其中 10 列
SELECT col1, col2, col5, col10, col20, col30, col40
FROM wide_table WHERE col2 > 5000;| 配置 | 执行时间 |
|---|---|
jit = off |
1.24s |
jit = on |
0.89s |
宽表的收益来自 Tuple 变形 JIT:50 列的表,即使只访问 10 列,解释器也要检查所有 50 列的 null bitmap 和偏移。JIT 版本直接生成”只变形访问到的 10 列”的专用函数。加速约 28%。
实验 5:I/O 瓶颈查询——JIT 无效
-- 从磁盘读取大量冷数据
SELECT count(*) FROM large_table WHERE non_indexed_col = 'rare_value';
-- Buffers: shared read=45832 ← 数据不在 shared_buffers
```text
当 90% 的执行时间花在 `IO/DataFileRead` 等待上时,启用 JIT 不会有任何收益。CPU 在等 I/O 完成,表达式求值不是瓶颈。
### JIT 收益总结
| 场景 | JIT 收益 | 原因 |
|------|---------|------|
| 简单 OLTP 查询 | 负收益 | JIT 编译开销 > 执行时间的数倍 |
| 中等复杂度 + 数据全在内存 | 适中(1.2-1.5x) | JIT 缩短表达式求值时间 |
| 复杂表达式 + 大量行 | 显著(1.5-2.5x) | 编译优化的红利在大量迭代中摊薄 |
| I/O 瓶颈查询 | 无收益 | CPU 在等磁盘,表达式求值不是瓶颈 |
| 宽表扫描 | 明显(1.2-1.4x) | Tuple 变形 JIT 消除列提取开销 |
---
## 八、运维考量:JIT 的适用边界与排查
### 何时应该关闭 JIT
**OLTP 负载。** 单次查询扫描几百到几千行,planner 估算代价通常在 1000-10000 之间。默认 `jit_above_cost=100000` 已经让 JIT 不触发,不需要手动关闭。如果你把 `jit_above_cost` 设得过低(如 100),每个 OLTP 查询都触发 JIT,编译开销(5-50ms)比执行时间还长。
**Prepared Statement 高频重复执行。** JIT 编译的代码不跨查询缓存。如果同一个 prepared statement 每分钟执行 1000 次,每次都是新查询,每次都要重新 JIT 编译——编译开销重复发生。对这样的 session 设 `SET jit = off` 更合理。
**内存受限环境。** 每个查询的 JIT 编译产物在查询结束时释放,但编译过程中 LLVM 的 IR 生成和优化需要临时内存(通常几十 MB)。高并发下编译内存的叠加分配可能触发内存压力。每个 Backend 的 LLVM 静态开销(类型元数据、ORC JIT 实例)通常在 2-5MB。
### 确认 JIT 是否在工作
```sql
-- 方法 1:EXPLAIN 输出
EXPLAIN (ANALYZE, VERBOSE) SELECT ...;
-- JIT 生效时,输出底部会有:
-- JIT:
-- Functions: 8
-- Options: Inlining true, Optimization true, Expressions true, Deforming true
-- Timing: Generation 12.345 ms, Inlining 3.456 ms, Optimization 45.678 ms,
-- Emission 23.456 ms, Total 85.000 msFunctions 表示本次查询编译了多少个 LLVM
函数。Timing 的 Total 如果超过执行时间的
20%,JIT 可能在这个查询上是净开销。
-- 方法 2:对比 JIT on/off 的同一查询
SET jit = off;
EXPLAIN (ANALYZE, TIMING OFF) SELECT ...;
SET jit = on;
EXPLAIN (ANALYZE, TIMING OFF) SELECT ...;
```text
如果 JIT=on 时间大于 JIT=off 时间,且负载以类似查询为主,考虑针对这类查询关闭 JIT。
```sql
-- 方法 3:检查 JIT 是否可用
SELECT jit_available; -- 返回 t/f平台兼容性问题
LLVM JIT 依赖可执行内存映射(mmap with
PROT_EXEC)。以下环境中 JIT 可能不可用:
- SELinux/AppArmor
限制:
execmem权限被禁止。PG 启动日志中会有could not allocate executable memory的 WARNING。 - Docker 默认 seccomp profile:某些默认
profile 禁用了
mprotect的PROT_EXEC,表现为 JIT 静默不工作(jit=on但不产生Functions)。 - ARM64 架构:PG 17 在 ARM64 上的 LLVM JIT 已较成熟,但较早版本可能存在代码生成 bug。PG 12-14 在 ARM64 上的 JIT 稳定性有限,建议先在非生产环境验证。
排查方法:检查 PG 启动日志中是否有 LLVM 相关的 WARNING 或
ERROR,以及在 EXPLAIN 输出中是否出现 JIT:
段。
调试与性能分析工具
| GUC | 作用 |
|---|---|
jit = off |
完全关闭 JIT,用于确认 JIT 是否带来了收益 |
jit_expressions = off |
仅关闭表达式 JIT,保留 Tuple 变形 JIT |
jit_tuple_deforming = off |
仅关闭 Tuple 变形 JIT,保留表达式 JIT |
jit_dump_bitcode = on |
将 JIT 的 bitcode 写入文件,用于调试编译结果 |
jit_debugging_support = on |
注册 GDB JIT 事件监听器,允许 GDB 理解 JIT 代码 |
jit_profiling_support = on |
注册 perf JIT 事件监听器,允许 perf 解析
JIT 代码符号 |
jit_dump_bitcode 生成的 .bc
文件可以用 llvm-dis 反汇编为文本 LLVM IR:
bash # 将 bitcode 反汇编为可读文本 llvm-dis 12345.0.bc -o 12345.0.llbash
配置建议
| 场景 | 推荐 JIT 配置 | 理由 |
|---|---|---|
| 纯 OLTP(如 Web 应用后端) | jit = off |
JIT 几乎不会触发,关闭省下 LLVM 库加载的内存 |
| 混合负载(OLTP + 报表) | 默认配置 | 报表查询会自动触发 JIT,OLTP 查询不会 |
| 纯 OLAP(数据仓库) | 默认配置,或降低 jit_above_cost 到
50000 |
让更多中等查询受益 |
| Prepared statement 高频重复执行 | Session 级别 SET jit = off |
避免重复编译开销 |
| 内存 < 4GB 的小实例 | jit = off |
LLVM 库本身占用 ~50MB + 编译临时内存 |
| 复杂表达式查询多但 I/O 不重 | 降低 jit_above_cost 到 50000 |
扩大 JIT 覆盖范围 |
九、关键要点
JIT 是 OLAP 加速器,不是通用优化。 它把表达式求值和 Tuple 变形的热点路径从解释执行编译成原生机器码。对 CPU-bound 的长查询(计划代价 > 100k,数据缓存在内存中)有 1.5x-2.5x 的加速;对 I/O-bound 查询或短查询有负收益。
三级阈值独立控制编译深度。
jit_above_cost是 JIT 的准入线,jit_inline_above_cost控制函数内联,jit_optimize_above_cost控制 LLVM -O3 优化。决策发生在计划时,阈值与 planner 估算的总代价比较。惰性编译 + 一次性跳板。 表达式在第一次被求值时才触发 LLVM 编译,
ExecRunCompiledExpr作为跳板函数在第一次调用后替换为真实的编译函数指针。不提前编译未执行到的表达式。Tuple 变形 JIT 的价值常被低估。 它只变形实际需要的列,并且将列的固定属性(
attlen、attbyval、attalign)折叠为编译时常量。宽表(多列)场景下加速明显。编译管线分三个阶段:Inline → Optimize → Emit。 内联消除函数调用边界,优化将解释器风格的
alloca/store/load转换为 SSA 形式,发射阶段使用 LLVM ORCv2 做 lazy emission。LLVMContext 内存泄漏是已知陷阱。 PG 通过约每 100 次查询后重建
LLVMContextRef来应对 LLVM 内联 pass 中类型累积的问题,但高并发下 JIT 内存增长仍需关注。JIT 不缓存。 每个查询编译的机器码在查询结束时释放。Prepared statement 的高频重复执行需要关注重复编译开销,可考虑 session 级别关闭 JIT。
参考资料
源码(PG 17)
src/backend/jit/jit.c:Provider 无关基础设施,jit_compile_expr(),provider_init()src/backend/jit/llvm/llvmjit.c:LLVM Provider 核心——llvm_session_initialize(),llvm_create_context(),llvm_compile_module(),llvm_optimize_module(),llvm_get_function()src/backend/jit/llvm/llvmjit_expr.c:表达式 JIT 编译——llvm_compile_expr(),ExecRunCompiledExpr(),BuildV1Call()src/backend/jit/llvm/llvmjit_deform.c:Tuple 变形 JIT 编译——slot_compile_deform()src/backend/jit/llvm/llvmjit_inline.cpp:函数内联支持src/backend/jit/llvm/llvmjit_types.c:类型元数据源文件,编译为llvmjit_types.bcsrc/include/jit/jit.h:JitContext、JitProviderCallbacks、PGJIT_* flagssrc/include/jit/llvmjit.h:LLVMJitContext、所有类型声明src/backend/executor/execExprInterp.c:EEO 解释器ExecInterpExpr()src/backend/executor/execMain.c:standard_ExecutorStart()中的 JIT 初始化src/backend/utils/misc/guc_tables.c:jit_above_cost、jit_inline_above_cost、jit_optimize_above_cost的 GUC 定义
官方文档
- PostgreSQL Documentation, Chapter 32: Just-in-Time Compilation (JIT)(决策逻辑、配置参数、EXPLAIN 示例)
- PostgreSQL Documentation, Section 19.17: Developer
Options(
jit_debugging_support、jit_dump_bitcode等调试 GUC)
关键提交
2a0faed9d7(Andres Freund, 2018-03): Expression JIT compilation for LLVM JIT provider32af96b2b1(Andres Freund, 2018-03): JIT tuple deforming in LLVM JIT providercc415a56d0(Andres Freund, 2018-03): Basic planner and executor integration for JIT9dce22033d5(Daniel Gustafsson, 2023-09): Fix LLVMContext memory leak by periodically recreating context
设计讨论
- pgsql-hackers: “WIP: Faster Expression Processing and Tuple Deforming (including JIT)” — Andres Freund 的原始设计提案
- PGCon 2018: “JIT in PostgreSQL” (Andres Freund) — JIT 架构的整体介绍与性能评估
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】执行器与表达式求值:从计划树到行数据的一趟流水
拆解 PostgreSQL 执行器的火山模型(ExecInitNode→ExecProcNode→ExecEndNode)、Hash Join 内存化实现、EEO 表达式求值的 opcode 编译与解释执行机制、TupleTableSlot 的三种数据承载方式(virtual/heap/minimal)。附带查询 hang 住的完整诊断路径:pg_stat_activity 的 wait_event + pg_blocking_pids() 追踪锁等待链 + EXPLAIN ANALYZE 计划行数与实际行数差异定位。
【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend
拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。
【PG 内核】页面布局与元组格式:PG 如何把一行数据塞进 8KB
拆解 PostgreSQL 的物理存储层:Page 的 8KB 布局(PageHeaderData、ItemId 数组、special space)、HeapTupleHeaderData 的字段语义(xmin/xmax/ctid/t_infomask/t_infomask2)、TOAST 外存机制的压缩阈值与四种策略(PLAIN/EXTENDED/EXTERNAL/MAIN),以及用 pageinspect 扩展直接观察页面字节。理解页面格式是理解 VACUUM、Index Scan、MVCC 可见性判断的共同前提。
【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性
在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。