实验 09:代码生成(Code Generation)

> 把查询计划从"一条元组一条元组地解释执行",改成"针对这条查询生成一段专用源码、编译成字节码再跑",看看能快多少。

一、实验目标

完成本实验后,你应该能够:

1. 说出 Volcano / 迭代器模型(iterator model)的接口约定 `open() / next() / close()`,并区分 **pull(拉)** 与 **push(推)** 两种驱动方向。
2. 指出解释执行的四类开销来源:函数调用、属性查找、通用分派、谓词求值。
3. 读懂 `compile(emit)` 的代码生成套路:把父算子的代码片段当作参数一层层往下传,最后拼出一段嵌套循环。
4. 用 Python 的 `compile()` / `exec()` 把生成的代码编译成字节码并执行。
5. 测量三种执行范式的耗时(解释执行、解释执行 + 通用分派、编译执行),算出加速比。
6. 写出差异测试(differential testing):验证解释执行与编译执行的结果完全一致。
7. 说明什么时候不该用编译执行(编译开销 > 执行收益)。

二、预备知识

2.1 元组与算子

一条元组(tuple)在本实验中是一个字典:

```python
{"title": "DB101", "author": "Alice", "price": 8.5}
```

一个算子(operator)是对元组流做一次变换的东西。本实验用到四类:

| 算子 | 作用 | 输入 | 输出 |
| --- | --- | --- | --- |
| `Scan` | 从存储文件读元组 | — | 元组流 |
| `Filter` | 按谓词筛掉元组 | 元组流 | 元组流 |
| `SHJ` | 简单哈希连接(simple hash join) | 两个元组流 | 元组流 |
| `Count` / `Print` | 汇(sink),消费元组不往上推 | 元组流 | 一个数字 / 屏幕输出 |

2.2 Volcano / 迭代器模型

Volcano 模型(也称迭代器模型)出自 Graefe, *Volcano, An Extensible and Parallel Query Evaluation System*, IEEE TKDE 6(1), 1994, pp.120–135。每个算子实现三个方法:

```
open() 准备好内部状态(打开文件、建哈希表、复位计数器)
next() 返回下一条元组(pull 模型)/ 处理一条推上来的元组(push 模型)
close() 释放资源(关文件、清哈希表)
```

**pull 模型**(经典 Volcano):父算子主动向下"拉"。

```
Print ──next()──► SHJ ──next()──► Filter ──next()──► Scan
◄────tuple───── ◄────tuple──── ◄─────tuple─────
```

**push 模型**:叶子算子主动向上"推",本实验的教学 DBMS 用这种。

```
Scan ──tuple──► Filter ──tuple──► SHJ ──tuple──► Print
```

push 模型下,"next()" 的语义变成"处理一条刚被推上来的元组",本实验代码里叫 `interpret_next(tup)`。

push 模型的一个直接好处:算子之间不需要保存"我拉到哪了"的状态,数据一直待在寄存器/缓存里往上走,这也是它更容易被编译的原因。

2.3 解释执行的开销在哪

解释执行时,查询计划是一棵**对象树**,执行就是在这棵树上反复做方法调用。以 `price < 10` 这条谓词为例,处理一条元组要付出:

| 开销来源 | 具体动作 | 代价 |
| --- | --- | --- |
| 函数调用 | `Scan.interpret_next` → `Filter.interpret_next` → `SHJ.interpret_next` → `Count.interpret_next` | 每元组 4 次 Python 函数调用 |
| 属性查找 | `self.parent`、`self.pred`、`self.ht` | 字典/属性查找 |
| 通用分派 | 算子不知道父算子是谁,只能走 `self.parent.interpret_next(tup)` | 一次动态绑定 |
| 谓词求值 | `eval("price < 10", {}, tup)` | 每次都要重新编译表达式、建名字空间 |

在真实的 C/C++ 数据库里,前三项同样存在(虚函数调用、指针追逐)。第四项在教学实现里尤其夸张:`eval()` 每次都要解析字符串。

2.4 编译执行(以数据为中心的代码生成)

编译执行的思路是:既然查询计划在**执行前**就完全确定了,那就别在运行时反复分派,直接生成一段"只服务于这一条查询"的代码。

```
输入:查询计划(对象树)
输出:一段源码,形如

import pickle
num_tuples = 0
ht = dict()
with open('persons.pkl', 'rb') as f:
for tup in pickle.load(f):
ht.setdefault(tup['name'], list()).append(tup)
with open('books.pkl', 'rb') as f:
for tup in pickle.load(f):
if tup['price'] < 10:
for tup2 in ht.get(tup['author'], list()):
tup = tup | tup2
num_tuples += 1
```

对照解释执行,编译后的代码里:

• 没有算子对象,没有方法调用 —— 生成的是嵌套的 `for` 循环;

• 谓词被**内联**进循环体,写成 `if tup['price'] < 10:` 这样一个普通的比较;

• 元组始终在局部变量 `tup` 里,不需要跨函数传递。

这种风格叫**以数据为中心**(data-centric)的代码生成,也叫"pipeline fission / 算子融合"。出自 Neumann, *Efficiently Compiling Efficient Query Plans for Modern Hardware*, VLDB 2011(用 LLVM 编译,集成于 HyPer)。

2.5 另一种流派:向量化

向量化(vectorization)出自 Boncz et al., MonetDB/X100, CIDR 2005。它不改执行模型,而是把"一次处理一条元组"改成"一次处理一批(例如 1024 条)",算子内部用紧凑数组和循环处理。

两种流派的对比(Kersten et al., PVLDB 2018):

| 维度 | 向量化 | 以数据为中心的编译 |
| --- | --- | --- |
| 隐藏 cache miss 延迟 | 更擅长(批内并行度天然存在) | 较弱 |
| CPU 指令数 | 较多(要物化批次、管理批边界) | 更少 |
| 适合的负载 | 数据量大、cache 装不下 | cache-resident(数据能装进缓存) |
| 实现复杂度 | 较低,改算子内部即可 | 较高,要写代码生成器 |

一句话记忆:**向量化擅长"等内存",编译擅长"少干活"**。

2.6 什么时候不该编译

编译不是免费的:要生成代码、要调用编译器、生成的代码要被加载,这些都要时间。PostgreSQL 的 JIT 就设了阈值(官方文档给出的默认值):

| 参数 | 默认值 | 含义 |
| --- | --- | --- |
| `jit_above_cost` | 100000 | 估算代价超过这个值才启用 JIT |
| `jit_inline_above_cost` | 500000 | 超过这个值才做函数内联 |
| `jit_optimize_above_cost` | 500000 | 超过这个值才做优化编译 |

官方立场:JIT 主要有利于**长运行、CPU 密集的分析型查询**;短查询的编译开销常常大于收益,反而更慢。

这条规则在本实验里同样适用——步骤 7 会让你测出"编译本身花了多久",并算出需要跑多少遍才能回本。

三、实验原理

3.1 `compile(emit)` 的递归套路

代码生成的关键设计是参数 `emit`。

`emit` 是一段**字符串**,含义是"当一条元组到达我这里时,接下来要执行的代码"。生成过程从汇算子(sink)开始,往叶子方向递归:

```
Count.compile()
emit = "num_tuples += 1"
│
▼
SHJ.compile("num_tuples += 1")
生成: 建哈希表
左孩子.compile("ht.setdefault(tup['name'],...).append(tup)")
右孩子.compile("for tup2 in ht.get(tup['author'],...):\n tup = tup|tup2\n num_tuples += 1")
│
▼
Filter.compile("for tup2 in ...")
编译谓词: "price < 10" → "tup['price'] < 10"
返回: 孩子.compile("if tup['price'] < 10:\n " + indent(emit))
│
▼
Scan.compile("if tup['price'] < 10: ...")
返回: "with open('books.pkl','rb') as f:\n for tup in pickle.load(f):\n " + indent(emit, 2)
```

每一步都做同一件事:**把父算子给的 `emit` 包进自己这一层的控制流里,再传给子算子**。递归到叶子时,整棵计划就被"展开"成了一段嵌套代码。

这个套路叫 continuation passing(续延传递),是手写代码生成器最省事的写法。

3.2 缩进处理

拼接字符串时必须处理缩进,否则生成的 Python 代码语法错误。工具函数:

```python
@staticmethod
def indent_emit_(emit, n=1):
indent = " " * n
return f"\n{indent}".join(emit.split("\n"))
```

含义:把 `emit` 的每一行(除第一行)前面加上 `n` 层缩进,使其整体嵌进上一层的循环体/分支体。

3.3 谓词编译

`Filter` 持有字符串谓词 `"price < 10"`。生成代码时要把它改写成字典访问形式:

```python
import re
compiled_pred = re.sub("([a-zA-Z]+)", r"tup['\1']", "price < 10")

结果: "tup['price'] < 10"

```

正则 `([a-zA-Z]+)` 匹配连续的字母串(属性名),替换成 `tup['属性名']`。数字、运算符、空格不受影响。

**这个改写的代价是 O(谓词长度),而且只做一次。** 对比解释执行里每条元组都要跑一次 `eval()` —— 这是编译执行最大的一块收益。

3.4 哈希连接的编译形态

`SHJ` 有两个孩子:左孩子建哈希表(build),右孩子探测(probe)。编译结果:

```
ht = dict() ← 建表语句
<左孩子的循环,emit = 插入哈希表> ← build 阶段
<右孩子的循环,emit = 查表 + 合并 + 父 emit> ← probe 阶段
```

注意 probe 阶段有一句 `tup = tup | tup2`:把探测到的元组与哈希到的元组合并成一个新字典(`|` 是 Python 3.9+ 的字典合并运算符),然后**复用同一个变量名 `tup`**。这样上层的 `emit` 不需要改变量名,代码生成器好写。

3.5 参考图件

| 图件 | 内容 |
| --- | --- |
| `../figures/fig_codegen.png` | 解释执行与编译执行的耗时对比 |
| `../figures/fig_push_pipeline.png` | push 模型下算子的连接与元组流向 |

四、实验环境与准备

4.1 环境要求

| 项目 | 要求 |
| --- | --- |
| Python | 3.10 及以上(用到字典合并运算符 `|`,需 3.9+) |
| 必装库 | 无,只用标准库 `pickle` / `re` / `time` / `random` |
| 选装库 | `matplotlib`(步骤 8 画图) |
| 参考实现 | `notebooks/CodeGen.ipynb` |
| 参考模块 | `system/query_processing/operators.py` 的 `compile()` / `interpret_*()` |
| 参考测试 | `system/tests/test_code_gen.py`(差异测试) |

4.2 数据规模

本实验用 `books` 与 `persons` 各 **60,000** 条。第 6 节的实测数据在此规模下取得。

> 说明:`notebooks/CodeGen.ipynb` 里的 `number_of_tuples` 默认是 10,000(为了让 CI 跑得快)。本指导书把它调到 60,000 以对齐实测数据。若机器较慢,改回 10,000 也能跑通全部流程,但**数字会不同**,请填写你自己的实测值。

4.3 数据生成方式

notebook 用 `faker` 生成人名和书名。为了让本指导书的代码零外部依赖,改用纯随机数:

• `persons`:60,000 条,姓名从一个大小为 12,000 的姓名池中抽取(因此存在重名);

• `books`:60,000 条,`price` 在 `[0, 100)` 上均匀分布,`author` 从同一个姓名池中抽取。

因为人名有重复,`name = author` 这个连接条件可能一本书匹配到多个 person,所以连接结果的行数会明显多于"`price < 10` 的书的数量"。本机实测结果为 **29,891 行**。本实验的数据生成固定了随机种子(`random.seed(42)`),这份代码在任何 CPython 上都应逐位复现。

五、实验步骤

步骤 1:生成数据并写入"存储"

用 `pickle` 把元组列表写进文件,模拟数据库的存储层。

```python
import pickle, random

N = 60_000 # persons 与 books 各 60,000 条
NAME_POOL = 12_000 # 姓名池大小,故意小于 N 以产生重名

random.seed(42)

persons = [
{"name": f"Person{random.randrange(NAME_POOL)}",
"birthday": f"19{random.randrange(70, 100)}-01-01"}
for _ in range(N)
]

books = [
{"title": f"Book{i}",
"author": f"Person{random.randrange(NAME_POOL)}",
"price": random.random() * 100}
for i in range(N)
]

with open("persons.pkl", "wb") as f:
pickle.dump(persons, f)
with open("books.pkl", "wb") as f:
pickle.dump(books, f)

print("persons:", len(persons), " books:", len(books))
```

步骤 2:实现 push 模型的算子(解释执行)

```python
import math, re

class Operator:
"""所有算子的基类。"""

def __init__(self, parent, children):
self.parent = parent
self.children = children or []

def set_parent(self, parent):
self.parent = parent

@staticmethod
def indent_emit_(emit, n=1):
"""把 emit 的每一行(除首行)缩进 n 层。"""
indent = " " * n
return f"\n{indent}".join(emit.split("\n"))

class Scan(Operator):
"""从 pickle 文件读元组,往上推。"""

def __init__(self, file, num_tuples=math.inf):
super().__init__(None, [])
self.file = file
self.num_tuples = num_tuples

# ---- 解释执行 ----
def interpret_open(self):
with open(self.file, "rb") as f:
i = 0
for tup in pickle.load(f):
if i < self.num_tuples:
self.parent.interpret_next(tup)
i += 1
else:
break

# ---- 编译执行 ----
def compile(self, emit=None):
if self.num_tuples == math.inf:
return (f"with open('{self.file}', 'rb') as f:\n"
f" for tup in pickle.load(f):\n"
f" {self.indent_emit_(emit, 2)}")
return (f"with open('{self.file}', 'rb') as f:\n"
f" i = 0\n"
f" for tup in pickle.load(f):\n"
f" if i < {self.num_tuples}:\n"
f" {self.indent_emit_(emit, 3)}\n"
f" i += 1\n"
f" else:\n"
f" break")

class Filter(Operator):
"""按谓词筛元组。"""

def __init__(self, child, pred):
super().__init__(None, [child])
self.pred = pred
child.set_parent(self)

def interpret_open(self):
self.children[0].interpret_open()

def interpret_next(self, tup):
if eval(self.pred, {}, tup):
self.parent.interpret_next(tup)

def compile(self, emit=None):
compiled_pred = re.sub("([a-zA-Z]+)", r"tup['\1']", self.pred)
return self.children[0].compile(
f"if {compiled_pred}:\n {self.indent_emit_(emit)}"
)

class SHJ(Operator):
"""简单哈希连接:左孩子建表,右孩子探测。"""

def __init__(self, left_child, right_child, left_attr, right_attr):
super().__init__(None, [left_child, right_child])
self.left_attr = left_attr
self.right_attr = right_attr
left_child.set_parent(self)
right_child.set_parent(self)

def interpret_open(self):
self.ht = {}
self.is_build_phase = True
self.children[0].interpret_open()
self.is_build_phase = False
self.children[1].interpret_open()

def interpret_next(self, tup):
if self.is_build_phase:
self.ht.setdefault(tup[self.left_attr], []).append(tup)
else:
for tup2 in self.ht.get(tup[self.right_attr], []):
self.parent.interpret_next(tup | tup2)

def compile(self, emit=None):
return (
"ht = dict()\n"
+ self.children[0].compile(
f"ht.setdefault(tup['{self.left_attr}'], list()).append(tup)")
+ "\n"
+ self.children[1].compile(
f"for tup2 in ht.get(tup['{self.right_attr}'], list()):\n"
f" tup = tup | tup2\n"
f" {self.indent_emit_(emit)}")
)

class Count(Operator):
"""汇算子:统计元组条数,不往上推。"""

def __init__(self, child):
super().__init__(None, [child])
child.set_parent(self)

def interpret_open(self):
self.num_tuples = 0
self.children[0].interpret_open()

def interpret_next(self, tup):
self.num_tuples += 1

def compile(self):
return "import pickle\n\nnum_tuples = 0\n" + \
self.children[0].compile("num_tuples += 1")
```

步骤 3:搭建查询计划

对应 SQL:

```sql
SELECT *
FROM persons JOIN books
ON name = author
WHERE price < 10
```

```python
def create_qep(sf=1.0):
"""sf 是规模因子(scale factor),用来控制每个 Scan 读多少条元组。"""
n = int(N * sf)
scan_persons = Scan("persons.pkl", n)
scan_books = Scan("books.pkl", n)
filter_books = Filter(scan_books, "price < 10")
join = SHJ(scan_persons, filter_books, "name", "author")
return Count(join)

plan = create_qep()
plan.interpret_open()
print("解释执行结果:", plan.num_tuples)
```

`sf`(scale factor,规模因子)是无量纲的比例系数,取值 `0 < sf ≤ 1`,表示"每个 Scan 只读全表的 `sf` 比例"。步骤 7 会用它画出不同规模下的耗时曲线。

步骤 4:打印生成的源码

```python
code = plan.compile()
print(code)
```

预期输出(结构如下,具体缩进以实际打印为准):

```python
import pickle

num_tuples = 0
ht = dict()
with open('persons.pkl', 'rb') as f:
i = 0
for tup in pickle.load(f):
if i < 60000:
ht.setdefault(tup['name'], list()).append(tup)
i += 1
else:
break
with open('books.pkl', 'rb') as f:
i = 0
for tup in pickle.load(f):
if i < 60000:
if tup['price'] < 10:
for tup2 in ht.get(tup['author'], list()):
tup = tup | tup2
num_tuples += 1
i += 1
else:
break
```

对照 2.4 节:谓词 `price < 10` 被内联成了 `if tup['price'] < 10:`,看不到任何算子对象和方法调用。

步骤 5:编译成字节码并执行

```python
def run_compiled(plan):
"""生成代码 → 编译成字节码 → 执行,返回计数结果。"""
code = plan.compile()
ns = {}
exec(compile(code, "<generated>", "exec"), ns)
return ns["num_tuples"]

print("编译执行结果:", run_compiled(plan))
```

也可以按 notebook 的做法落盘、用 `py_compile` 生成 `.pyc`、再用子进程跑:

```python
from py_compile import compile as pyc_compile
import subprocess

code = plan.compile()
with open("codegen.py", "w", encoding="utf-8") as f:
f.write(code)
with open("codegen.pyc", "w", encoding="utf-8"):
pass
pyc_compile("codegen.py", "codegen.pyc", doraise=True)
subprocess.call(["python", "codegen.pyc"])
```

> 注意:走子进程的方式会额外付一次 Python 解释器启动开销(通常为几十毫秒量级),做性能对比时会掩盖代码生成本身的收益。步骤 7 的计时用进程内的 `exec` 版本。

步骤 6:差异测试——两种执行方式结果必须一致

```python
def normalized_interpret(plan):
plan.interpret_open()
return plan.num_tuples

for sf in (0.1, 0.25, 0.5, 0.75, 1.0):
p = create_qep(sf)
a = normalized_interpret(p)
b = run_compiled(p)
print(f"sf={sf:<5} 解释={a:<8} 编译={b:<8} 一致={a == b}")
assert a == b, "解释执行与编译执行结果不一致!"
```

预期:五个规模下全部 `一致=True`。

这一步就是**差异测试**(differential testing):同一条查询用两条完全不同的执行路径跑,结果必须逐字节相同。它是编译器类代码最重要的测试手段——仓库里的 `system/tests/test_code_gen.py` 用的就是这个方法。

步骤 7:几种执行范式的耗时对比

前面的 `Filter.interpret_next` 用的是 `eval(self.pred, {}, tup)`,每条元组都要重新解析一次表达式字符串,这是最慢的一种做法(notebook 里就是这么写的)。真实系统不会这样,它会把谓词**预编译**成一个可调用对象,然后每条元组调用一次。所以这里补两个变体,一共比较四种范式:

| 范式 | 谓词求值方式 |
| --- | --- |
| A 解释执行(`eval` 字符串) | 每条元组重新解析表达式字符串 |
| B 解释执行(预编译谓词函数) | 表达式编译一次,每条元组调用一次函数 |
| C 解释执行(多一层通用分派) | 在 B 之上再包一层 lambda |
| D 生成专用代码 | 谓词内联进循环,没有函数调用 |

```python
class PredFnFilter(Filter):
"""把谓词预编译成代码对象,每条元组调用一次。"""

def __init__(self, child, pred):
super().__init__(child, pred)
self.pred_code = compile(pred, "<pred>", "eval")

def interpret_next(self, tup):
if eval(self.pred_code, {}, tup):
self.parent.interpret_next(tup)

class DispatchFilter(Filter):
"""在谓词求值外面再包一层通用函数分派,模拟真实的通用谓词接口。"""

def __init__(self, child, pred):
super().__init__(child, pred)
self.pred_fn = lambda tup: eval(self.pred, {}, tup)

def interpret_next(self, tup):
if self.pred_fn(tup):
self.parent.interpret_next(tup)

def create_qep_with(filter_cls, sf=1.0):
n = int(N * sf)
scan_persons = Scan("persons.pkl", n)
scan_books = Scan("books.pkl", n)
filter_books = filter_cls(scan_books, "price < 10")
join = SHJ(scan_persons, filter_books, "name", "author")
return Count(join)

import time

def measure(fn, repeat=5):
"""跑 repeat 次,返回每次耗时(毫秒)的列表。"""
out = []
for _ in range(repeat):
t0 = time.perf_counter()
fn()
out.append((time.perf_counter() - t0) * 1000)
return out

def median(xs):
return sorted(xs)[len(xs) // 2]

rows = []
for sf in (0.1, 0.25, 0.5, 0.75, 1.0):
p_a = create_qep(sf) # A: eval 字符串
p_b = create_qep_with(PredFnFilter, sf) # B: 预编译谓词
p_c = create_qep_with(DispatchFilter, sf) # C: 多一层分派
p_d = create_qep(sf) # D: 编译执行

t_a = median(measure(lambda: p_a.interpret_open()))
t_b = median(measure(lambda: p_b.interpret_open()))
t_c = median(measure(lambda: p_c.interpret_open()))
t_d = median(measure(lambda: run_compiled(p_d)))

rows.append((sf, t_a, t_b, t_c, t_d))
print(f"sf={sf:<5} A={t_a:8.2f} B={t_b:8.2f} C={t_c:8.2f} D={t_d:8.2f} ms"
f" B/D={t_b / t_d:.2f}x")
```

预期趋势:**`D < B < A < C`**。理由:

• `B` 比 `A` 快:B 只执行已编译的代码对象,A 每条元组还要解析一次字符串。

• `C` 比 `A` 慢:C 在 A 之上多包了一层 Python 函数调用。

• `D` 最快:谓词内联,根本没有函数调用。

`B/D` 这一列就是"解释执行 vs 编译执行"的加速比——**A 与 C 的差距反映的是 `eval` 的字符串解析成本,不是执行模型的差异**,看加速比时只用 B/D。

> 关于小 `sf` 时的比值不稳定:`pickle.load(f)` 会把整个文件反序列化出来,这份开销**与 `sf` 无关**,是一个常数。当 `sf` 很小时它占了大头,四种范式的耗时被拉平,比值失去参考意义。要看清差距,用 `sf ≥ 0.75` 的数据(实测 sf = 0.5 时 B/D 仍会反转成 0.87×)。彻底去掉这个干扰的办法见 8.7 题(改用内存 `Relation` 算子)。

步骤 8:把编译开销单独拆出来

代码生成不是免费的,把三个阶段分开计时:

```python
p = create_qep()

code = p.compile() # 阶段 1:生成源码
t0 = time.perf_counter()
code = p.compile()
t_gen = (time.perf_counter() - t0) * 1000

t0 = time.perf_counter()
bytecode = compile(code, "<generated>", "exec") # 阶段 2:编译字节码
t_comp = (time.perf_counter() - t0) * 1000

t0 = time.perf_counter()
ns = {}
exec(bytecode, ns) # 阶段 3:执行
t_exec = (time.perf_counter() - t0) * 1000

print(f"生成源码 {t_gen:.3f} ms")
print(f"编译字节码 {t_comp:.3f} ms")
print(f"执行 {t_exec:.3f} ms")

t_int = median(measure(lambda: create_qep_with(PredFnFilter).interpret_open()))
overhead = t_gen + t_comp
saved = t_int - t_exec
print(f"解释执行(B) {t_int:.3f} ms,编译执行(D) {t_exec:.3f} ms")
print(f"每执行一次省下 {saved:.3f} ms,需要跑 {overhead / saved:.3f} 次才能回本")
```

这里用 B 作为解释执行的基线,因为它是"逐条调用谓词函数"的合理解释执行,与 D 可比。

你会看到一个反直觉的结果:**回本次数接近 0**。原因是 CPython 的 `compile()`(源码 → 字节码)非常便宜,通常不到 1 毫秒。

由此引出一个重要对比:

| 系统 | 编译什么 | 一次性开销 | 是否需要阈值 |
| --- | --- | --- | --- |
| 本实验(CPython) | Python 源码 → 字节码 | 亚毫秒级 | 几乎不需要 |
| HyPer / LLVM | C++ IR → 优化的机器码 | 毫秒到百毫秒级 | 需要 |
| PostgreSQL JIT | 表达式 → LLVM IR → 机器码 | 毫秒级 | 需要(`jit_above_cost`) |

优化编译器(LLVM)要做寄存器分配、指令选择、循环优化,代价远高于 Python 的字节码编译。这就是为什么真实数据库必须设阈值——**编译开销的量级决定了是否值得**。

步骤 9:画图(可选)

需要 `matplotlib`:

```python
import matplotlib.pyplot as plt

sf = [r[0] for r in rows]
fig, ax = plt.subplots()
ax.plot(sf, [r[1] for r in rows], "o-", label="A 解释执行(eval 字符串)")
ax.plot(sf, [r[2] for r in rows], "s-", label="B 解释执行(预编译谓词)")
ax.plot(sf, [r[3] for r in rows], "d-", label="C 解释执行(多一层分派)")
ax.plot(sf, [r[4] for r in rows], "^-", label="D 编译执行")
ax.set_xlabel("规模因子 sf")
ax.set_ylabel("耗时 (ms)")
ax.legend()
ax.grid(alpha=0.3)
plt.show()
```

六、观测记录表

6.1 数据规模

| 项目 | 值 |
| --- | --- |
| `persons` 条数 | 60,000 |
| `books` 条数 | 60,000 |
| 姓名池大小 | 12,000 |
| 连接结果行数(实测) | 29,891 |
| 你的连接结果行数 | |

6.2 执行范式耗时(本指导书代码口径,books = 60,000,命中 29,891,sf = 1.0,5 次中位数)

| 范式 | 执行方式 | 参考实测 | 你的实测值 | 相对 D 的倍数 |
| --- | --- | ---: | ---: | ---: |
| A | 解释执行(每条元组 `eval` 字符串) | 约 570–600 ms | | |
| B | 解释执行(逐条调用谓词函数) | 约 110–125 ms | | |
| C | 解释执行(多一层通用分派) | 约 610–690 ms | | |
| D | 生成专用代码(谓词内联进循环) | 约 100–116 ms | | 1.00 |

加速比参考值:**约 1.05–1.1×**(B/D,sf = 1.0)。

请自己算出你的加速比并填在下面:

```
我的加速比(B/D) = ______ / ______ = ______
```

**关于 A 这一行**:本指导书的 `Filter` 用的是 `eval(self.pred, {}, tup)`,每条元组都要重新解析表达式字符串,耗时会明显高于 B(本环境实测高出数倍)。这个差距反映的是 `eval` 的字符串解析成本,**不是执行模型的差异**,看加速比时不要用它。

**为什么只有约 1.1 倍,而图件里是 1.47 倍?** 本表是**本指导书代码的口径**:`Scan` 每次执行都要 `pickle.load` 把 60,000 本书的字典反序列化出来,这笔开销与执行范式无关,却是总耗时的大头(sf = 1.0 时约占九成)。四条耗时都被这个常数垫高,B/D 的差距被摊薄;去掉它(8.7 题的内存 `Relation` 方案)就回到图件口径——解释 5.18 ms / 通用分派 5.90 ms / 生成代码 3.52 ms(1.47×)。

**关于你的数字**:绝对值随机器变化,量级参考即可。重点核对**相对关系**(sf ≥ 0.75 时 `D < B < A < C`)与**趋势**(随 `sf` 增大 B/D 缓慢拉大)。sf 较小时四种范式被 `pickle.load` 常数拉平,sf = 0.5 时 B/D 甚至会反转(实测 0.87×),属正常现象。

6.3 编译开销拆分

| 阶段 | 你的实测值 |
| --- | --- |
| 生成源码 | |
| 编译字节码 | |
| 执行生成的字节码 | |
| 前两项合计(一次性开销) | |
| 每执行一次省下的时间 | |
| 回本所需的执行次数 | |

6.4 差异测试结果

| 规模因子 sf | 解释执行结果 | 编译执行结果 | 是否一致 |
| --- | ---: | ---: | --- |
| 0.1 | | | |
| 0.25 | | | |
| 0.5 | | | |
| 0.75 | | | |
| 1.0 | | | |

6.5 概念核对

| 说法 | 对 / 错 | 理由 |
| --- | --- | --- |
| 编译执行的加速主要来自"少读磁盘" | | |
| 编译执行生成的代码里没有算子对象 | | |
| 查询越短越适合编译执行 | | |
| 差异测试能发现"结果对但性能差"的问题 | | |

6.4 三套计时口径对照

同一份"代码生成"主题,三个入口会给出三套不同的数字——**先分清口径,再比大小**:

| 口径 | 入口 | 数据 | 数字 | 说明 |
| --- | --- | --- | --- | --- |
| A:图件/讲义 | `fig_codegen` 图与讲义 3.2 节 | 内存数据、无反序列化;books = 60,000,命中 29,895 | 解释 5.18 / 通用分派 5.90 / 生成 3.52 ms(1.47×) | 纯执行模型的差距 |
| B:笔记本计时单元 | `CodeGen.ipynb` 计时 cell(子进程) | books = 10,000,命中 1,198 | 解释 159.4 ms / "compiler" 约 1150–1460 ms | "compiler" 高出来的部分几乎全是 Python 解释器启动开销 |
| C:笔记本进程内 | 同一笔记本改为进程内执行 | books = 10,000 | sf = 1.0:159.4 / 20.0 ms(约 8×) | 去掉子进程后差距立刻显形 |
| 本表 | 本指导书步骤 1–7 | 含 `pickle.load` 常数;books = 60,000,命中 29,891 | 约 110–125 / 610–690 / 100–116 ms(B/D ≈ 1.05–1.1×) | 常数垫高全体,摊薄差距 |

差异来源,按影响从大到小:① 是否每条元组 `eval` 字符串;② 是否走子进程;③ 是否含 `pickle.load` 反序列化常数;④ 数据规模与命中率。**不要**把 5.18 ms 说成"运行笔记本会看到的值",也**不要**把笔记本柱状图上的 1000+ ms 说成"编译执行的代价"。

七、思考题

1. 解释执行处理一条元组要调用几次 Python 函数?请把 `Scan → Filter → SHJ → Count` 这条链上的调用逐一列出来。
2. 生成的代码里有一句 `tup = tup | tup2`。为什么要**覆盖** `tup` 而不是用新变量名 `tup3`?如果改成新变量名会有什么问题?
3. 本实验测得加速比 1.47×。论文(Neumann, VLDB 2011)报告的加速比通常高得多。列出至少三个导致本实验加速比偏低的原因。
4. 谓词编译用了正则 `re.sub("([a-zA-Z]+)", r"tup['\1']", pred)`。如果谓词写成 `price < 10 and author != ''`,改写结果是什么?这个改写对哪些谓词会出错?
5. 步骤 8 算出"回本所需的执行次数"。请说明这个数字与 PostgreSQL 的 `jit_above_cost = 100000` 在思想上有什么联系。
6. 为什么向量化在 cache 装不下数据时比编译执行更有优势?反过来,为什么编译执行在 cache-resident 负载下更优?
7. 本实验的 `Scan` 在解释执行和编译执行里都用了 `pickle.load(f)` 一次性把整个文件读进内存。这会对结论产生什么影响?如何改进?
8. 差异测试能保证什么、不能保证什么?举例说明一个"通过了差异测试但仍然是错的"的实现。

八、思考题答案与解析

8.1 题 1

以 push 模型、从 `Scan(books)` 出发的一条元组为例(它通过了谓词并在哈希表里命中 1 条):

| 序号 | 调用 | 说明 |
| --- | --- | --- |
| 1 | `Scan.interpret_open` 的循环体 → `self.parent.interpret_next(tup)` | 叶子推动 |
| 2 | `Filter.interpret_next(tup)` | 谓词求值 |
| 3 | `Filter` → `self.parent.interpret_next(tup)` | 继续上推 |
| 4 | `SHJ.interpret_next(tup)` | 探测阶段,循环体里每条 `tup2` 一次 |
| 5 | `SHJ` → `self.parent.interpret_next(tup \| tup2)` | 合并后上推 |
| 6 | `Count.interpret_next(tup)` | 计数 |

`Scan(persons)` 那条链类似,但 `SHJ` 处于 build 阶段,不会调用父算子,所以少第 5、6 步。

计数要点:**每处理一条元组,链条上有 3~4 次 Python 方法调用**。一次 Python 方法调用的成本在几十到上百纳秒量级,而生成的代码里这些调用全部消失,只剩循环体内的字典访问。这是 1.47× 加速的主要来源。

用代码量一下纯函数调用的代价:

```python
class Sink:
def f(self, x):
return x + 1

s = Sink()
n = 2_000_000
t0 = time.perf_counter()
for i in range(n):
s.f(i)
print("方法调用:", (time.perf_counter() - t0) / n * 1e9, "ns/次")

t0 = time.perf_counter()
for i in range(n):
pass
print("空循环 :", (time.perf_counter() - t0) / n * 1e9, "ns/次")
```

两者相减即为单次方法调用的净开销(具体数字需自行实测确认)。

8.2 题 2

原因是**代码生成器写起来简单、生成的代码也简单**。

`emit` 是父算子交给子算子的一段代码,它里面的变量名必须和子算子放入作用域的变量名一致。`SHJ` 的探测阶段把"当前元组"放在变量 `tup` 里;上层的 `emit`(这里是 `num_tuples += 1`,不需要访问元组)以及更上层的 `Filter` 生成的 `if tup['price'] < 10:` 都假定元组变量叫 `tup`。

如果改成 `tup3 = tup | tup2`,那么后续所有代码里的 `tup` 都要改成 `tup3`,而 `emit` 是**字符串**,改名意味着要么做一次字符串替换(脆弱、易错),要么引入一层变量别名(多一次赋值开销)。

直接覆盖 `tup` 的代价是:合并前那个"探测到的元组"在循环体后续代码里访问不到了。对本实验的查询没有影响(后续只用到合并结果),但如果上层还要单独访问探测侧的列,就需要保留两个变量。

8.3 题 3

至少四点:

1. **实现语言不同。** 论文用 LLVM 把查询编译成优化的机器码,可以做寄存器分配、循环展开、常量传播、向量化指令选择。本实验编译成的是 **Python 字节码**,仍然由 CPython 解释器逐条执行,只是省掉了 Python 层的函数调用。收益的天花板低得多。
2. **编译开销没有摊薄。** 本实验每次都重新生成 + 重新编译(`compile()`),步骤 8 会测出这部分是毫秒级的一次性开销。论文里编译开销被长查询的执行时间摊薄了。
3. **瓶颈不同。** 论文面对的是内存内、CPU 密集的负载,指令数就是瓶颈。本实验里 `pickle.load(f)` 一次性反序列化整个文件,这部分开销巨大且与执行模型无关,把指令数的差异稀释掉了。
4. **查询太简单。** 本查询只有 1 个过滤 + 1 个连接。算子越多、流水线越长,解释执行的方法调用开销越大,编译执行的收益也越大。可以用更深的计划验证这一点(扩展任务 9.1)。

8.4 题 4

改写结果:

```python
import re
print(re.sub("([a-zA-Z]+)", r"tup['\1']", "price < 10 and author != ''"))
```

实际输出:

```
tup['price'] < 10 tup['and'] tup['author'] != ''
```

**这个结果就是错的**。关键字 `and` 也被当成了属性名,改写成 `tup['and']`,生成的代码要么抛 `KeyError`,要么引用到不存在的列。

这条正则只在**最简单的谓词**上正确,例如 `price < 10`(单个属性名 + 运算符 + 数字常量)。

会出错的谓词:

| 谓词 | 改写结果 | 问题 |
| --- | --- | --- |
| `price < 10.5` | `tup['price'] < 10.5` | 数字不匹配 `[a-zA-Z]+`,正常 |
| `price < 10 and author != ''` | `tup['price'] < 10 tup['and'] tup['author'] != ''` | 关键字 `and` 被当成属性名 |
| `title LIKE 'DB%'` | `tup['title'] LIKE tup['DB']%` | `LIKE` 不是 Python 运算符;字符串常量 `'DB'` 被当成属性名 |
| `author == 'Alice'` | `tup['author'] == tup['Alice']` | 字符串常量被当成属性名 |
| `abs(price) < 10` | `tup['abs'](tup['price']) < 10` | 函数名 `abs` 被改写 |
| `a_b > 1`(属性名含下划线) | `tup['a']_tup['b'] > 1` | 下划线不在 `[a-zA-Z]` 里,属性名被切开 |
| `a[0] > 1` | `tup['a'][0] > 1` | 碰巧可用,但语义不是我们想要的 |

根本问题:这个正则**不区分标识符的种类**(属性名、函数名、字符串常量、关键字),也不支持下标、下划线、点号。它只是教学用的最小实现,能跑通本实验的 `price < 10` 就够了——但你必须知道它的边界在哪里。

正确的做法不是改进正则,而是写一个真正的表达式解析器(或复用 `ast` 模块):

```python
import ast

class Rewriter(ast.NodeTransformer):
def visit_Name(self, node):
return ast.Subscript(
value=ast.Name(id="tup", ctx=ast.Load()),
slice=ast.Constant(value=node.id),
ctx=ast.Load(),
)

def compile_expr(pred):
tree = ast.parse(pred, mode="eval")
tree = Rewriter().visit(tree)
ast.fix_missing_locations(tree)
return ast.unparse(tree)

print(compile_expr("price < 10 and author != 'Alice'"))
```

`ast` 模块能正确区分 `Name`(标识符)、`Constant`(常量)、`Call`(函数调用),只在 `Name` 节点上做改写。

8.5 题 5

思想完全一致:**只有执行阶段的收益超过编译阶段的开销时,才启用编译**。

• PostgreSQL 用查询优化器估算出的**代价**(cost,一个无量纲的估算值)作为代理指标:估算代价 > 100000 才 JIT。代价与预计要处理的元组数、要调用的表达式次数正相关,因此它近似代表"执行阶段的规模"。

• 本实验步骤 8 直接算出 `回本次数 = (生成 + 编译) / (每次省下的时间)`,是同一个不等式解出来的显式形式:

```
一次性开销 < 执行次数 × 每次省下的时间
```

• 差别只在度量方式:PostgreSQL 用**事前估算**(不需要真的跑),本实验用**事后实测**(已经跑过了)。

推论:同一个查询如果会被反复执行(例如预编译的存储过程、参数化查询反复调用),编译开销可以被多次摊薄,此时阈值应该下调;反之,一次性即席查询应保守启用。

8.6 题 6

**cache 装不下(数据量大)时,执行时间的很大一部分是等内存。** 向量化一次处理一批(例如 1024 条)元组,批内的多次内存访问彼此独立,CPU 可以在等第一条的同时发出第二条、第三条的请求,延迟被重叠隐藏了(memory-level parallelism)。编译执行生成的代码是逐元组的循环,一次访问完成后才能发起下一次,遇到 cache miss 就真的停住等。所以在"等内存"占主导时,向量化更快。

**cache-resident(数据能装进缓存)时,内存访问几乎不耗时,瓶颈变成 CPU 指令数。** 此时:

• 向量化要付出额外的批管理开销:填充批次、传递批边界、在算子之间物化中间结果数组。这些是纯开销。

• 编译执行把整条流水线融合成一个循环,元组始终在寄存器里,中间结果从不物化。指令数明显更少。

结论出自 Kersten et al., PVLDB 2018 的实验对比:**向量化更擅长隐藏 cache miss 延迟;以数据为中心的编译所需 CPU 指令更少,更适合 cache-resident 负载**。

记忆方法:数据小 → 少干活(编译);数据大 → 会等待(向量化)。

8.7 题 7

`pickle.load(f)` 会把整个文件一次性反序列化成一个 Python 列表,**两种执行范式都要付这份代价,而且它在总时间里占大头**。

影响:

• 实测的加速比(1.47×)被**低估**了。因为分子分母里都含有一大块与执行模型无关的常数,比值被拉向 1。

• 换句话说,如果只看"元组处理"这一段,编译执行的相对优势会比 1.47× 更明显。

改进办法:

1. 把 `pickle.load` 挪到计时之外——预先把 `persons`、`books` 读成内存列表,`Scan` 改成 `Relation`(直接遍历内存列表)。这样测出来的就是纯粹的元组处理时间。
2. 用 `Relation` 算子替代 `Scan`:

```python
class Relation(Operator):
def __init__(self, name, data):
super().__init__(None, [])
self.name = name
self.data = data

def interpret_open(self):
for tup in self.data:
self.parent.interpret_next(tup)

def compile(self, emit=None):
return (f"for tup in DATA['{self.name}']:\n"
f" {self.indent_emit_(emit)}")
```

注意 `compile` 生成的代码引用了全局变量 `DATA`,执行时要在 `exec` 的名字空间里提供它:

```python
DATA = {"persons": persons, "books": books}
ns = {"DATA": DATA}
exec(compile(code, "<generated>", "exec"), ns)
```

用这个版本重跑步骤 7,对比加速比的变化(需自行实测确认)。

8.8 题 8

**能保证**:两条执行路径在给定输入上产生相同的输出。它能高效抓出"代码生成逻辑写错"这类 bug——例如缩进错误导致代码块位置不对、谓词编译错误、连接顺序写反、变量覆盖出错。这类 bug 单独看某一方的输出很难发现,一对比就暴露。

**不能保证**:

• **正确性之外的性质**。性能退化了(生成的代码仍然正确但很慢),差异测试不会报错。

• **未被测试覆盖的路径**。差异测试只对跑过的查询有效。下面就是一个"通过了差异测试但仍然错误"的例子:

```python
class BadFilter(Filter):
def compile(self, emit=None):
# 只对形如 "attr < 常数" 的谓词做了改写,其他谓词原样输出
if "<" not in self.pred:
compiled = self.pred # 忘了改写成 tup['...'] 形式
else:
compiled = re.sub("([a-zA-Z]+)", r"tup['\1']", self.pred)
return self.children[0].compile(
f"if {compiled}:\n {self.indent_emit_(emit)}")
```

如果差异测试只跑了 `price < 10` 这一条谓词,这个实现会通过测试(它走了 `<` 分支,改写正确)。但一旦查询里出现 `price > 10` 或 `price == 10`,生成的代码就是 `if price > 10:`,执行时抛 `NameError` 或引用到错误的变量。

所以差异测试必须配合**谓词/查询模板的枚举**:至少覆盖 `<`、`>`、`==`、`!=`、`AND`、`OR`、字符串常量、函数调用这些组合。

九、扩展任务(选做)

9.1 更深的查询计划

把查询改成三表连接(`persons ⋈ books ⋈ orders`),或加上 `AND` 连接的复合谓词(`price < 10 and author != 'X'`),重跑步骤 7。预测加速比会变大还是变小?用实测验证。

9.2 实现 `Print` 与 `Collect` 汇算子

按 `Count` 的写法补上这两个:

```python
class Print(Operator):
def interpret_next(self, tup):
print(tuple(tup.values()))

def compile(self):
return "import pickle\n\n" + \
self.children[0].compile("print(tuple(tup.values()))")
```

`Collect` 把结果收进 `result = list()`。用它们验证生成的代码与解释执行的逐行输出一致。

9.3 把代码生成换成 `ast` 实现

用 8.4 题里的 `Rewriter` 替换正则改写,并让 `compile()` 直接返回 `ast` 节点树而不是字符串。比较两种生成器的代码量与可维护性。

9.4 加上聚合

实现一个 `GroupBy` 算子(按 `author` 分组计数)。它在解释执行里要维护一个字典,在编译执行里会生成什么样的一段代码?实现并测速。

9.5 读源码

读 `system/query_processing/operators.py` 里 `Scan` / `Filter` / `SHJ` / `SemiJ` / `Print` / `Collect` / `Count` 的 `compile()`,与你自己的实现逐行对照。再读 `system/tests/test_code_gen.py`,看差异测试是怎么组织的。

9.6 阅读

• Neumann, *Efficiently Compiling Efficient Query Plans for Modern Hardware*, VLDB 2011。

• Kersten et al., *Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask*, PVLDB 2018。

• Boncz et al., *MonetDB/X100: Hyper-Pipelining Query Execution*, CIDR 2005。

十、常见错误与排查

| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 生成的代码抛 `IndentationError` | 拼接时没调 `indent_emit_`,或缩进层数不对 | 检查每层 `compile()` 里的 `indent_emit_(emit, n)`,`n` 等于该层新增的嵌套层数 |
| 生成的代码抛 `NameError: name 'tup' is not defined` | `emit` 里的变量名与生成代码中放入作用域的变量名不一致 | 统一用 `tup`;`SHJ` 里用 `tup = tup \| tup2` 覆盖而不是新建变量 |
| 生成的代码抛 `NameError: name 'pickle' is not defined` | 汇算子的 `compile()` 忘了加 `import pickle` | 在 `Count` / `Print` / `Collect` 的 `compile()` 开头拼上 `"import pickle\n\n"` |
| `exec` 后取不到 `num_tuples` | 没给 `exec` 传名字空间字典 | `ns = {}; exec(bytecode, ns); print(ns["num_tuples"])` |
| 编译执行结果与解释执行不一致 | 缩进/变量覆盖/连接顺序三处之一写错 | 用步骤 4 打印源码人工核对;用 `sf=0.05` 的小规模先跑通 |
| `TypeError: unsupported operand type(s) for \|` | Python 版本低于 3.9 | 升级到 3.9+,或改成 `{**tup, **tup2}` |
| 谓词里的字符串常量被改写成 `tup['Alice']` | 正则改写不区分常量与属性名 | 改用 `ast` 模块做改写(见 8.4 题) |
| 编译执行比解释执行还慢 | 数据量太小,编译开销没被摊薄,或 `pickle.load` 占主导 | 增大 `N`;用步骤 8 拆开计时;改用 `Relation` 算子去掉反序列化开销 |
| 用子进程跑 `.pyc` 时耗时几十毫秒 | 子进程包含 Python 解释器启动开销 | 性能对比改用进程内 `exec`(步骤 5 的第一个版本) |
| `py_compile` 报 `doraise` 相关的语法错误 | 生成的代码本身有语法错误 | 先把 `print(code)` 的输出粘到 `.py` 文件里单独跑一遍 |
| 每次运行结果行数不同 | 数据生成用了随机且没固定种子 | 在生成数据前 `random.seed(42)`;两份数据用同一个种子 |
| `FileNotFoundError: persons.pkl` | 步骤 1 没跑,或工作目录不对 | 先跑步骤 1;确认 `.pkl` 生成在当前工作目录 |
| 计时波动大 | 后台进程干扰、只跑了 1 次 | 跑 5 次取中位数;关闭其他程序;用 `time.perf_counter()` |

附:本实验引用的文献与默认值

| 条目 | 出处 / 说明 |
| --- | --- |
| Volcano / 迭代器模型 | Graefe, *Volcano*, 1990;接口为 `open()` / `next()` / `close()`;push 模型由叶子驱动、元组向上推 |
| 以数据为中心的编译 | Neumann, *Efficiently Compiling Efficient Query Plans for Modern Hardware*, VLDB 2011(用 LLVM 编译,集成于 HyPer) |
| 向量化 | Boncz et al., MonetDB/X100, CIDR 2005 |
| 向量化 vs 编译 | Kersten et al., PVLDB 2018:向量化更擅长隐藏 cache miss 延迟;以数据为中心的编译所需 CPU 指令更少,更适合 cache-resident 负载 |
| PostgreSQL JIT 默认值 | `jit_above_cost` = 100000;`jit_inline_above_cost` = 500000;`jit_optimize_above_cost` = 500000 |
| PostgreSQL 官方立场 | JIT 主要有利于长运行、CPU 密集的分析型查询;短查询编译开销常大于收益 |