土法炼钢兴趣小组的算法知识备份

【PG 内核】JIT 编译:为什么 PG 要把 WHERE 子句编译成机器码

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#jit#llvm#expression-evaluation#tuple-deforming#olap#query-execution#jit-compilation

目录

JIT 编译:为什么 PG 要把 WHERE 子句编译成机器码

执行 SELECT count(*) FROM orders WHERE status = 'pending' AND amount > 1000 时,PG 需要解析每一行的 statusamount 列值,执行比较运算,再对满足条件的行计数。如果表有 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.cllvmjit_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_EXPRPGJIT_DEFORM 目前总是一起启用或关闭,但 GUC jit_expressionsjit_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()

  1. 初始化 LLVM 原生目标、汇编解析器。
  2. 调用 llvm_create_types()——从编译期预生成的 llvmjit_types.bc 中加载 PG 内部结构体的 LLVM 类型定义,包括 StructTupleTableSlotStructExprEvalStepStructExprStateStructFunctionCallInfoDataStructHeapTupleHeaderData 等几十种类型。这保证 JIT 生成代码中的结构体偏移量与运行时二进制一致。
  3. 检测主机 CPU 型号和特性,据此创建两个 TargetMachine——opt0_tmLLVMCodeGenLevelNone)和 opt3_tmLLVMCodeGenLevelAggressive)。
  4. 创建两个 ORC JIT 实例——llvm_opt0_orcllvm_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);
}

注意 int4gtint4lt 仍然是 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(...);
        }
    }
}

三重优化:

  1. 按需变形——只变形表达式实际引用的列,而不是所有列。一个 50 列的表,如果 WHERE 子句只用到 3 列,JIT 变形只提取那 3 列。
  2. 编译期常量折叠——列的 attlenattbyvalattalign 在编译期已知,生成代码时直接作为常量嵌入,省去运行时查 TupleDesc 的分支。
  3. 内联到表达式函数——变形代码可以被 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 ms

JIT 编译自身耗时 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 ms

TPC-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 ms

Functions 表示本次查询编译了多少个 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 可能不可用:

排查方法:检查 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 覆盖范围

九、关键要点

  1. JIT 是 OLAP 加速器,不是通用优化。 它把表达式求值和 Tuple 变形的热点路径从解释执行编译成原生机器码。对 CPU-bound 的长查询(计划代价 > 100k,数据缓存在内存中)有 1.5x-2.5x 的加速;对 I/O-bound 查询或短查询有负收益。

  2. 三级阈值独立控制编译深度。 jit_above_cost 是 JIT 的准入线,jit_inline_above_cost 控制函数内联,jit_optimize_above_cost 控制 LLVM -O3 优化。决策发生在计划时,阈值与 planner 估算的总代价比较。

  3. 惰性编译 + 一次性跳板。 表达式在第一次被求值时才触发 LLVM 编译,ExecRunCompiledExpr 作为跳板函数在第一次调用后替换为真实的编译函数指针。不提前编译未执行到的表达式。

  4. Tuple 变形 JIT 的价值常被低估。 它只变形实际需要的列,并且将列的固定属性(attlenattbyvalattalign)折叠为编译时常量。宽表(多列)场景下加速明显。

  5. 编译管线分三个阶段:Inline → Optimize → Emit。 内联消除函数调用边界,优化将解释器风格的 alloca/store/load 转换为 SSA 形式,发射阶段使用 LLVM ORCv2 做 lazy emission。

  6. LLVMContext 内存泄漏是已知陷阱。 PG 通过约每 100 次查询后重建 LLVMContextRef 来应对 LLVM 内联 pass 中类型累积的问题,但高并发下 JIT 内存增长仍需关注。

  7. JIT 不缓存。 每个查询编译的机器码在查询结束时释放。Prepared statement 的高频重复执行需要关注重复编译开销,可考虑 session 级别关闭 JIT。

上一章:执行器与表达式求值 下一章:B-Tree 索引


参考资料

源码(PG 17)

官方文档

关键提交

设计讨论

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】执行器与表达式求值:从计划树到行数据的一趟流水

拆解 PostgreSQL 执行器的火山模型(ExecInitNode→ExecProcNode→ExecEndNode)、Hash Join 内存化实现、EEO 表达式求值的 opcode 编译与解释执行机制、TupleTableSlot 的三种数据承载方式(virtual/heap/minimal)。附带查询 hang 住的完整诊断路径:pg_stat_activity 的 wait_event + pg_blocking_pids() 追踪锁等待链 + EXPLAIN ANALYZE 计划行数与实际行数差异定位。

2026-06-16 · database / kernel

【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend

拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。

2026-06-16 · database / kernel

【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 可见性判断的共同前提。

2026-06-16 · database / kernel

【PG 内核】MVCC 实现:CLOG、hint bit 与快照可扩展性

在已有 MVCC 文章基础上深入 PG 并发控制的三个基础设施:CLOG 的 SLRU 结构(事务状态位、页面格式、SLRU 淘汰)、hint bit 的写入时机和竞争问题(何时写、谁写、写坏了怎么办)、PG 14 snapshot scalability 优化的具体机制(ProcArrayLock 为什么是瓶颈、xid/xmin 的原子更新如何减少持锁路径),以及事务 ID 回卷(wraparound)的威胁模型。最后与 InnoDB undo log 方案做系统性对比。


By .