- ClickHouse 源码解析: 综述
- ClickHouse 源码解析: MergeTree Write-Path
- ClickHouse 源码解析: MergeTree Read-Path
- ClickHouse 源码解析: 查询引擎经典理论 (就是这篇)
- ClickHouse 源码解析: 查询引擎实现概述 (待更)
- ClickHouse 源码解析: 查询引擎源码解析 (待更)
- ClickHouse 源码解析: MergeTree Merge Algorithm (待更)
- ClickHouse 源码解析: ReplicatedMergeTree (待更)
- ClickHouse 源码解析: Vector Engine (向量化引擎) (待更)
- ClickHouse 源码解析: IColumn & Data Type (待更)
- ClickHouse 源码解析: Block & Block Streams (待更)
- ClickHouse 源码解析: Replication (副本) (待更)
- ClickHouse 源码解析: Parsers (语法解析) (待更)
- ClickHouse 源码解析: Functions & Aggregate Functions (函数) (待更)
- ClickHouse 源码解析: I/O Formats (待更)
- ClickHouse 源码解析: Server (待更)
- ClickHouse 源码解析: Group By (待更)
- ClickHouse 源码解析: Join (待更)
- ClickHouse 源码解析: Quey Optimization (待更)
- ClickHouse 源码解析: Materialized View (待更)
- ClickHouse 源码解析: Live View (待更)
- ClickHouse 源码解析: Window Functions (待更)
- ClickHouse 源码解析: User Define Function (UDF) (待更)
- ClickHouse 源码解析: Gorilla Codec (待更)
- ClickHouse 源码解析: Arrow Support (待更)
- ClickHouse 源码解析: Dictionay (待更)
由于查询引擎在数据库处于核心地位,我将查询引擎部分分为三个部分:
- 查询引擎经典理论
- 查询引擎实现概述
- 查询引擎源码解析
分别从理论、实现、源码解析三个维度去描述,这里仅介绍 "查询引擎经典理论" 这一部分,剩下两部分将会额外分成两篇内容。
1. 查询引擎架构
《Architecture of a Database System》 给出了普适意义上的 RDBMS 架构:
查询引擎使用存储引擎提供的数据,进行对应的计算,最终响应客户端的查询请求并输出结果。
执行一条SQL查询一般包括三个步骤
- SQL解析, 将 SQL 语句解析为数据库内部 AST
- 查询优化, 选择合适(性能、成本、时延稳定性等考虑因素)的查询执行方案
- 执行查询, 按照优化后的查询计划执行查询
如下图所示:
那为什么要分成这三个阶段呢 ?以我个人观点:
- SQL 是一种 DSL 语言,要去执行势必要先进行语义解析,而这部分是可复用的,因此SQL解析独立出来作为一个模块
- SQL 既然是一种 DSL 语言,要执行就需要有执行环境。就像 Java 语言有 Java 虚拟机,Python 有 Python 解释器,SQL 也需要有 SQL 执行环境,在一些SQL数据库实现中,SQL解析和执行分属于不同进程,因此执行也会独立出来作为一个模块
- 因为 SQL 语句有多种写法可以表达相同的语义,同一个查询有多种语义相同的执行方式(例如使用不同的索引,不同的JOIN顺序),具体选择哪个执行方式效率更高呢?这些优化的方式有可复用的部分,并且业界发现这一部分的实现复杂度极高,因此这一部分独立开来作为一个模块而非合并在查询执行中
在这里,我们聚焦于 “执行查询(Query Excution)” 这一方面,因为在 ClickHouse 的实现中:
- SQL 解析是完全按照 ClickHouse 的 SQL 语法手工打造的,这部分并非 ClickHouse 的核心
- 查询优化在ClickHouse的地位,相比于传统关系型数据库明显较低,因为 ClickHouse 更聚焦于简单的聚合查询,而不是复杂的多表 JOIN 查君。
- 执行引擎则是向量化执行引擎,辅以简单的 JIT 运行时代码生成,是 ClickHouse 之所以性能出众,比较核心的部分
2. 迭代模型 (Iterator Model)
说起数据库查询执行模型,就绕不开迭代模型。
迭代模型又称为火山模型(Volcano),或者说 Pipeline 模型,几乎应用于所有90年代之后的现代数据库实现中。
迭代模型的特点是:
- 每个 SQL 算子(如 SeqScan / Hash Join / Group by),都实现了一个 Next() 方法,用来返回一个 Tuple,或者是 null (当没有新的数据时)。而将这些 SQL 算子组合起来
- 每个算子可以有 N 个子算子提供 input
- 通过实现 Loop 不断重复调用 Next() 及调用子节点的 Next(),便可以不断驱动查询的一步步执行,直到最终查询完成
看起来,就像是这样:
class iterator {
iterator &inputs[];
void init();
tuple next();
void close();
}
迭代模型有一些非常好的优点:
- 简单,容易实现,每个 SQL 算子都只需实现 Next() 接口及初始化 (Init) 和销毁收尾的代码 (Close)
- 灵活程度高,只需要简单组合这些算子即可完成各种复杂的查询
- I/O 友好,读到数据后,马上就能参与后续的计算和输出
以下面的 SQL 查询为例:
SELECT R.id, S.cdate
FROM R JOIN S
ON R.id = S.id
WHERE S.value > 100
从上图中,我们可以得到以下认知:
- 将SQL算子按照树形结构,组织为无环组合,进行查询
- 每个算子都会实现一个 Next() 算子
- Next() 算子每次仅返回一个 Tuple (可以近似理解为一行数据)
- 对于 Hash Join 来说,情况复杂一些,需要先对左侧的算子重复调用 Next() 构建 HashTable 后,才能每次调用右侧算子 Next() 可以输出一个 Tuple
- 对于类似 Hash Join / Group By 这种,不能进去一个 Tuple 出来一个 Tuple 的算子,称之为 Pipeline-Breaker,而在 Spark 中,Pipeline-Breaker 算子通常会造成需要拆分出新的 Job Stage
而迭代模型的执行过程,可以简要示例如下:
从上图中,我们可以得到以下认知:
- 迭代模型从最上层的算子调用 Next() 开始驱动
- 从上到下的 Next() 调用,可以理解为查询的控制流
- 从下到上的 Next() Tuple 传递,可以理解为查询的数据流
- 从观感上看,感觉像是顶层算子,不断的 Pull 子节点的算子,这种自上而下的迭代模型执行方式又称为 Pull-based query engine。
再举一个更真实一点的例子,Postgresql 的迭代模型实现。Postgresql 的源码中,有一段对于其迭代模型实现方式的一段非常简洁清晰的描述, Postgresql: src/backend/executor/execProcnode.c
首先我们可以看到 execProcnode.c 文件头的注释完美表达了迭代模型的基础概念。
* execProcnode.c
* contains dispatch functions which call the appropriate "initialize",
* "get a tuple", and "cleanup" routines for the given node type.
* If the node has children, then it will presumably call ExecInitNode,
* ExecProcNode, or ExecEndNode on its subnodes and do the appropriate
* processing.
- ExecInitNode, 算子初始化
- ExecProcNode, 返回一个 Tuple
- ExecEndNode, 算子生命周期结束时的收尾处理
- 如果有子节点,将会根据算子逻辑需要去调用子算子的 ExecInitNode/ExecProcNode/ExecEndNode
接着,execProcnode.c注释中,有一个举例 (也可跳过,后面会简要解释一下)
* EXAMPLE
* Suppose we want the age of the manager of the shoe department and
* the number of employees in that department. So we have the query:
*
* select DEPT.no_emps, EMP.age
* from DEPT, EMP
* where EMP.name = DEPT.mgr and
* DEPT.name = "shoe"
*
* Suppose the planner gives us the following plan:
*
* Nest Loop (DEPT.mgr = EMP.name)
* / \
* / \
* Seq Scan Seq Scan
* DEPT EMP
* (name = "shoe")
*
* ExecutorStart() is called first.
* It calls InitPlan() which calls ExecInitNode() on
* the root of the plan -- the nest loop node.
*
* * ExecInitNode() notices that it is looking at a nest loop and
* as the code below demonstrates, it calls ExecInitNestLoop().
* Eventually this calls ExecInitNode() on the right and left subplans
* and so forth until the entire plan is initialized. The result
* of ExecInitNode() is a plan state tree built with the same structure
* as the underlying plan tree.
*
* * Then when ExecutorRun() is called, it calls ExecutePlan() which calls
* ExecProcNode() repeatedly on the top node of the plan state tree.
* Each time this happens, ExecProcNode() will end up calling
* ExecNestLoop(), which calls ExecProcNode() on its subplans.
* Each of these subplans is a sequential scan so ExecSeqScan() is
* called. The slots returned by ExecSeqScan() may contain
* tuples which contain the attributes ExecNestLoop() uses to
* form the tuples it returns.
*
* * Eventually ExecSeqScan() stops returning tuples and the nest
* loop join ends. Lastly, ExecutorEnd() calls ExecEndNode() which
* calls ExecEndNestLoop() which in turn calls ExecEndNode() on
* its subplans which result in ExecEndSeqScan().
*
* This should show how the executor works by having
* ExecInitNode(), ExecProcNode() and ExecEndNode() dispatch
* their work to the appropriate node support routines which may
* in turn call these routines themselves on their subplans.
从这段注释,我们可以知道 Postgresql 是按照下述方式执行查询:
- Executor 根据 SQL 语句构建出树形的 Query Plan,来执行查询
- ExecutorStart() 函数用于进行 Executor 的初始化
- ExecutorStart() 会调用顶层(root)算子的 ExecInitNode() 来执行算子的初始化,而顶层算子同时也会级连调用所有子算子的 ExecInitNode(),以确保整个查询的所有算子都处于可工作状态
- ExecutorRun() 于 ExecutorStart() 之后调用,用于开始执行查询及输出结果
- ExecutorRun() 重复调用顶层(root)算子的 ExecProcNode() 来返回查询结果,而顶层算子为了完成工作,也会调用所有子算子的 ExecProcNode() 作为输入
- 而类似于 SeqScan 这种算子,可直接从磁盘/缓存页中获取数据,不依赖其他算子
- 而当 SeqScan 没有数据可以返回时,其将返回 null tuple 到父算子,但请注意,这里不会直接调用 ExecEndNode() 来做资源释放
- 当一个算子所有的子算子的 ExecProcNode() 都返回 Null 时,这个算子本身也将返回 ExecProcNode()
- 当顶层(root)算子 ExecProcNode() 返回 Null 时,就意味着查询即将结束了,所有数据都已经输出
- 此时将会由顶层算子调用 ExecEndNode() 来做资源释放,同 ExecInitNode() 时一样,对所有子算子也会级连调用 ExecEndNode() 来让所有算子的资源得到释放
- 查询结束
总结一下,通过上述多种举例,我们简单了解了迭代模型。
- Tuple-at-a-time 的数据处理风格
- Pull-based, 各个 SQL 算子主动去拉自己需要的数据,而不是把数据 Push 给各个算子
而下面我们将探讨迭代模型在 OLAP 查询中所面临的性能困境。
3. 迭代模型在 OLAP 查询中的性能困境
在早期的 OLTP 负载为主的数据库应用背景下,迭代模型工作良好。但随着 CPU 架构的发展以及2000年左右互联网大爆炸所带来的数据量级激增,迭代模型在处理大体量查询时的性能瓶颈越来越引起人们注意。
2005 年 CIDR 会议上,MonetDB/X100 团队发表了数据库领域一篇重要论文 MonetDB/X100: Hyper-Pipelining Query Execution,文中详细分析了迭代模型为什么不能充分利用硬件资源。
这一小节,以 MonetDB/X100 论文为主线,结合 CPU 架构的发展,我会简要分析 迭代模型在 OLAP 查询中的性能困境。
要理解迭代模型的局限性,就必须理解现代 CPU 架构及其对性能的影响。以下会有不小的篇幅介绍 CPU 架构及其对性能的影响。
3.1 CPU Pipeline
在 1980 年代,处理器设计中开始引入 Pipeline,并且后来的几乎所有现代微处理器都采用了流水线架构,因为其对性能有非常大的提升。
首先我们看看经典的 MIPS CPU 微架构设计。
MIPS 是一种 RISC 精简指令集,指令的执行分为以下五个 Stage:
- IF: 加载指令
- ID: 对指令解码并且填充寄存器
- EX: ALU 数值/逻辑/地址 计算
- MEM: 内存访问 (LOAD, STORE 类需要访问的内存的指令)
- WB: 将结果写回寄存器 (结果来自 ALU 或者内存加载)
而这 5 个 Stage 并非在一个指令周期完成,而是分 4~5 个时钟周期来执行,而在CPU时钟之间,寄存器就是唯一能保存数据的地方,因此各个阶段之间还有寄存器用于保存临时数据。
对于 x86 架构,因为其指令更为复杂,需要 CPU 前端将复杂指令拆解为微指令 (uOps),Intel 设计的 x86 架构 CPU 通常会需要 15 ~ 20 个时钟周期完成一条指令。
图片来自于 Youtube: Architecture All Access: Modern CPU Architecture Part 2 – Microarchitecture Deep Dive
而衡量CPU一个基础指令周期需要多少时钟周期完成的指标,就称为流水线深度(Pipeline Depth).
为何我们倾向于越来越深的流水线呢 ?类比于工业生产中的流水线,每个环节只需要负责很小一部分,工人只专心自己的一小部分事情,效率很高。对于 CPU 同样是如此,将 CPU 的工作拆解为多个 Stage,这样每个 Stage 的实现复杂度会降低,则每个 Stage 会更快完成。
那可否无限增加 Stage 来提升性能呢 ?也不能。因为 Stage 之间需要有寄存器连接来存储中间结果,寄存器的存储有最小时延限制,当 Stage 多到时钟周期内所有时间都需要用来进行中间寄存器的临时结果存储,那就理论上不能再增加新的 Stage 了。
在流水线的描述中,我们缺了重要一环: 流水线的每个 Stage 是并行执行的。之前我们只是说了分成 5 个 Stage,但是如果每个时刻都只有一个 Stage 在运行,显然这不是我们希望的。
对于 MIPS 架构,每个指令分成约 5 个时钟周期完成,但是并非 CPU 每 5 个周期才能完成一条指令,实际上 CPU 每个时钟周期都可以完成一条指令。这有点像是时延和吞吐量之间的关系,需要考虑到并发在里面。
实际上,对于 MIPS 架构,在同一时间,有 5 条指令在同时执行(对于目前主流的x86架构,这个值会更大),指令和时序的对应关系如下:
图中主要表达了以下核心观点:
- 每个时钟周期,都会开始执行一条新的指令
- 在第五个时钟周期,CPU 流水线将处于“满载”状态,IF/ID/EX/MEM/WB 五个CPU模块都在工作
- 但是分属于 5 个指令,看起来这些指令就像是在并行执行
上面是流水线比较理想的情况,但也有一些意外情况要处理 (就如工厂流水线可能有员工临时请假,那整条流水线的产能都会受影响),常见的意外情况包括以下三种。
(1) CPU Pipeline: Structural hazards
Structural hazards : 流水线的并发指令有资源访问的冲突,如果内存一个时钟周期仅允许一次写入,但流水线有两个并发写指令。但是,这种情形和处理器设计细节相关性较大,从软件层面不好规避,这里暂不讨论
(2) CPU Pipeline: Data Hazards
Data Hazards: 大部分场景又可称为 Data dependency,即指令的操作数来自于之前的指令计算结果。如果过早加载进 Pipeline,会造成读取到旧的值。举例如下:
DADD R1, R2, R3
DSUB R4, R1, R5
AND R6, R1 ,R7
OR R8, R1, R9
XOR R10, R1, R11
DSUB 指令中用到的操作数 R1 来自于 DADD 指令的计算结果。如果按照正常流水线流程计算 DSUB,那么将会产生如下数据冲突:
而要规避此冲突,最简单的方式是将 DSUB 指令的执行(EX)阶段延后执行,直到前一条指令 DADD 的写回(WB)阶段完成后,再开始执行,而如此的话,自然会造成流水线效率下降,有部分阶段会短暂处于空转状态,如下:
降低这种数据依赖问题,有一个非常重要的编译器层面的优化方法: Loop pipelining
。考虑以下数据库常见场景:
SELECT 2 * value1 + 200
FROM t1
可以理解为伪代码:
for (i = 0; i < t1.length(); i ++) {
a = t1[i].value1
a = a * 2
a = a + 200
}
可以明显看到在执行时会有数据依赖,在进行 "+ 200" 之前,需要先将 value1 的值乘以 2。 编译器自动进行的 Loop pipelining 优化,则是将上述代码优化成:
for (i = 0; i < t1.length(); i += 4) { // 这里未考虑数组长度对齐的问题
a1 = t1[i].value1
a2 = t1[i + 1].value1
a3 = t1[i + 2].value1
a4 = t1[i + 3].value1
a1 = a1 * 2
a2 = a2 * 2
a3 = a3 * 2
a4 = a4 * 2
a1 = a1 + 200
a2 = a2 + 200
a3 = a3 + 200
a4 = a4 + 200
}
如果流水线的数据依赖延迟为 3 个 Cycles 的话,在计算 a1 = a1 + 200 时,a1 = a1 * 2 已经提前进入流水线 4 个 Cycles 了,此时数据依赖已经解除。
一切都很美好,但是不适用于迭代模型。回想起来,迭代模型为 Tuple-at-a-time,实际上,上面伪码更为真实的样子是:
while (record = get_next()) {
a = record.value1
a = a * 2
a = a + 200
}
这看起来,并不是在处理一个数组,所以编译器也不能自动进行 Loop pipelining 优化。因此数据依赖造成的流水线性能下降,对于迭代模型的影响更为严峻。
(3) CPU Pipeline: Branch Hazards
Branch Hazards: 指指令流发生变更的情况,常见场景为分支跳转指令和JMP跳转指令。如下为 MIPS 微架构分支跳转指令示意图
分支对于流水线有极其严重的性能影响,尤其是对于流水线层级较深的微架构实现,如 x86。 一旦分支执行错误,在流水线中已经执行的指令就要全部作废(甚至需要恢复原样),这将是巨大的性能浪费,对于 x86 架构,这可能意味着 10 个左右的 cycle。
对于函数调用对应的 JMP 指令,虽说相比于分支跳转对 Pipeline 的性能影响小一些,但还是会因为计算地址和执行跳转而阻塞流水线。
同样,回想迭代模型,每条数据都会产生一个或多个函数调用,这些函数调用在处理器层面实际表现就是 LOAD + JMP 指令。而每个这样的调用组合都会让短时间流水线工作不畅,而过于频繁的函数调用则会让处理器效率极速下降。
在 C/C++ 语言层面,函数调用还有额外的入栈/出栈/保存寄存器等上下文切换开销,同样是造成函数调用性能差的重要因素
以下为 MonetDB/X100 提到的 MySQL 4.1 (论文写作年代较早) 性能数据。
可以看到为了计算 SQL 语句的加法,MySQL 调用函数并且最终用了 49 个 Cycles,而硬件实际上只是需要 3 个 Cycles,其余的四十多个 Cycles 来自于迭代模型(树形的表达式求值方式)引起的性能开销。
在一些数据库的实现中,情况可能会更糟,我们以 Postgresql 的加法计算为例:
int
PGTYPESnumeric_add(numeric *var1, numeric *var2, numeric *result)
{
/*
* Decide on the signs of the two variables what to do
*/
if (var1->sign == NUMERIC_POS)
{
if (var2->sign == NUMERIC_POS)
{
/*
* Both are positive result = +(ABS(var1) + ABS(var2))
*/
if (add_abs(var1, var2, result) != 0)
return -1;
result->sign = NUMERIC_POS;
}
else
{
/*
* var1 is positive, var2 is negative Must compare absolute values
*/
switch (cmp_abs(var1, var2))
{
case 0:
/* ----------
* ABS(var1) == ABS(var2)
* result = ZERO
* ----------
*/
zero_var(result);
result->rscale = Max(var1->rscale, var2->rscale);
result->dscale = Max(var1->dscale, var2->dscale);
break;
...
你可以看到,这并非仅是简单意义上的加法,为了保持在不同架构上的兼容性,这里的实现包含了多个条件分支,这会让性能更加雪上加霜。
从 CPU Pipeline 实现机制,我们可以看到迭代模型面临的性能困境。 除了 Pipeline,还有 CPU Cache 机制对迭代模型的性能同样影响巨大。
3.2 CPU Cache Misses
Memory Hierarchy 是一项对程序员友好的发明,面向程序员提供了近似无限的存储。通过利用数据在时间和空间上的本地性,CPU 在硬件层面提供 Cache 来尝试屏蔽 CPU 和 Memory 之间的巨大时延差异。但在数据库 OLAP 场景,CPU Cache 并不是能被忽略的细节。
回忆一下,我们首先需要理解内存相比于 CPU 是非常、非常慢的,通常内存访问的时延在100个时钟周期左右。
或许下图会更直观。
关于 Cache Misses 的成本,更详细的分析见 Computer Architecture - A Quantitative Approach, Figure2.21
既然 Cache Misses 成本这么高,我们自然就需要尽力避免 Cache Misses。对数据库领域,频繁的 Cache Misses 通常发生于下面场景:
- 随机内存访问,例如两个大表JOIN场景,如果是简单版本的 Hash Join 实现,则在 Build 阶段会生成一个无法装进 Cache 的巨型 Map,在 Probe 阶段,面对随机的 Key Lookup,将会发生大量的 Cache Misses,这也是为何数据库大表 JOIN 性能如此差的原因之一
- 顺序内存访问,但是 CPU 处理的太快了,内存带宽跟不上
我们着重思考一下第二种情况。设计 CPU 的都是聪明人,肯定会给和 CPU 处理能力配套的的内存带宽,内存带宽为何会不够用呢 ?这种情况的发生,也要从迭代模型的缺陷来解释。
首先,我们要理解,内存数据是按 Block(若干字节的固定长度) 被加载到 Cache 中的,考虑到数据具有空间上的本地性,很可能很快就要用到下一个地址的数据。
迭代模型一般每次返回一个 Tuple,这个 Tuple 本身在内存一般是连续存储的,这本身没有什么问题。问题在于 Tuple 中,不是每个字段都是当前的SQL算子会用到的(因为数据库通常将磁盘页整个加载到内存,这里的 Tuple 通常也是来自数据存储页的引用或者拷贝)。这就造成,加载到 Cache 中的数据,并非都需要参与计算,这造成了内存带宽的浪费。当这种内存浪费达到一定比例,最终会造成内存带宽和 CPU 处理速度的失调,最终引起频繁 Cache Misses。
关于 Cache Misses 的进一步介绍可参考 DBMSs On A Modern Processor: Where Does Time Go?
3.3 SIMD 优化
现代CPU通常有指令级的数据并行设计,一种常见的设计方式就称为 SIMD (Single instruction stream, multiple data streams),即一条指令同时操作 N 条数据 (“同时”是一种近似的说法,具体实现可能有适度妥协)。
SIMD 指令是通过冗余的 ALU 运算单元,并且配合 CPU Pipeline 机制来实现的。
更现实一些的例子如上,为 Intel Cascade Lake 架构处理器架构图。
如上,从 Intel 于 1996 年发布 MMX 开始,SIMD 在微处理器领域一直有长足发展。x86 架构先后推出了 MMX/SSE/SSE2/AVX/AVX2/AVX-512 等数代 SIMD 拓展指令集。
SIMD 技术潜在可以提升十余倍性能(如上图)。但需要理性认识到,这建立在对应用场景对硬件有充分利用的情况下。在实际应用中,只有部分高性能计算,尤其是科学数值计算可从中获益较多。
不管怎么样,这是一项压榨处理器,使其达到极限处理机能的重要手段,在数据库领域中,OLAP 是数据密集型计算,当然也可以从 SIMD 指令获益,目前主要应用为 Expression 表达式求值、加速 Where 子句、字符串处理 等场景。
但是,基于迭代模型的查询引擎很难从 SIMD 中获益。
- 因为数据被 next() 函数隔开,编译器的自动 SIMD 编译优化目前还只能识别到比较简单的 Loop 场景
- 每次只能拿到一个数据,算子就算 cache 住几条数据一起做 SIMD 算,性能损失还是很大
因此,迭代模型并不能充分享受不到 SIMD 的红利。
整体回顾
我们花了很大篇幅,陈述了一下会影响迭代模型性能的各种因素,比较琐碎,只是想从一个更底层的视角,去陈述是如何问题发生,以更好的解决。 还有一些问题,没有展开陈述,例如 Cache 是按照 Block 加载的,为什么是 Block,Block 多大合适呢?这又要从更底层的硬件来分析,感兴趣的读者可以进一步深挖。
总结一下,本节表达的核心观点:
- 因为处理器的 Pipeline 设计,Pipeline 流程被打断时,就会有性能的下降,Pipeline 深度越深,性能下降越明显,x86 处理器的 Pipeline 深度在 15 ~ 20 左右
- 迭代模型因为 next() 函数频繁调用造成 Pipeline 经常遇到 LOAD + JMP 指令从而影响流水线效率 (函数调用本身的开销同样影响较大)
- 迭代模型因为 next() 隔断让编译器难以进行针对现代CPU架构的优化,例如 Loop pipelining
和 SIMD - 迭代模型一次返回一个 Tuple,这种 Row-based 的处理方法,会让与计算无关的字段加载到 Cache 中,浪费了内存带宽,最终造成 Cache Misses 概率增大
为了克服迭代模型在 OLAP 查询中遇到的性能困境,后面将分别介绍 Vectorization 和 Compilation 两种针对迭代模型性能问题的解法。
说些题外话,处理器的技术一直在发展,同时不断克服自身的缺点。拿我自己的体验讲,条件分支对 Pipeline 的性能影响已经比较低,数据依赖问题同样如此。软件和硬件就是这样交替发展,每个时代都会有新的挑战。
4. 向量化执行模型 (Vectorization)
4.1 介绍 Vectorization
向量化执行,在业界比较早的落地来自于 MonetDB/X100: Hyper-Pipelining Query Execution。
向量化模型的思路非常简单,如果 Next() 函数只返回一个 Tuple() 的 Overhead 过大,那就每次多返回一些数据,多么简单朴素的 Batch 思想,当然实现复杂度会比原始的迭代模型高一些。
示例如下:
可以重新对照上文提到的迭代模型看一下差别:
可以看到核心区别就是:
- 不再每次仅返回一个 Tuple,而是返回一组 Tuple (Tuple Batch)
- 因为每次需要返回一组,因此算子内部要维护一个 Buffer,缓存数据后在一并输出
4.2 Vector size
看到这里,你可能会有一个疑问,这个 Buffer 多大合适呢 ?
我觉得这里有一个评判的依据: 一个 Tuple Batch 是否可以完全放在 CPU Cache ?如果不能完整放倒 CPU Cache,那么 Buffer 中的数据在提交时,最早写入的已经 Cache Misses 了,这显然不是我们希望看到结果。
根据 MonetDB/X100 的论文数据,这个值在 512 ~ 8K 比较合适。
但这个值显然与时代平均硬件水平相关,MonetDB/X100 给的数据年代过于久远 (6MB Cache),而当前的主流处理器 L3 Cache 大约为 20 MB ~ 60MB。
ClickHouse 对标的配置 index_granularity 默认值为 8192 (8K).
4.3 DSM Storage
通过前面提到的 Next() 批量提交 Tuple 的方式,可以解决下面的问题:
- 迭代模型因为 next() 函数频繁调用造成 Pipeline 经常遇到 LOAD + JMP 指令从而影响流水线效率 (函数调用本身的开销同样影响较大)
- 迭代模型因为 next() 隔断让编译器难以进行针对现代CPU架构的优化,例如 Loop pipelining
和 SIMD
但应注意解决不了下面的问题:
- 迭代模型一次返回一个 Tuple,这种 Row-based 的处理方法,会让与计算无关的字段加载到 Cache 中,浪费了内存带宽,最终造成 Cache Misses 概率增大
为了解决返回所有字段组成的 Tuple 会浪费内存带宽(同样也有磁盘I/O带宽)的问题,可以使用列式存储(DSM, Decomposition Storage Model)解决问题。
列式存储方式的优点在于,磁盘数据加载到内存时,每列数据都会在物理位置上临接在一起,在计算时,这些用到的列在加载到处理器 Cache 时,不会夹带过多用不到的字段数据,这就提升了内存带宽的使用效率,进而降低了可能的 Cache Misses。
出了上面所说的优点,列式存储对于 OLAP 场景还有一些其他优势:
- 提升数据压缩率,因为相同的列数据放在一起,单位大小的原始数据,总熵会更低
- 每个 Loop 仅处理一列数据,更加简单,增加了潜在的编译器自动优化机会
同样需要意识到,数据更新对于列式存储是明显的短板,很多选择了列式存储的数据库,实际上都放弃了 OLTP,例如 ClickHouse。
在存储是列式情况下,想要同时支持 OLTP 的话,就需要对数据更新有更为柔性的处理,可以参考的案例如 Kudu: Storage for Fast Analytics on Fast Data
5. 编译后执行 (Compilation)
编译执行的思想,在第一代关系型数据库 System R 中便已经得到实践。
5.1 System R 预编译执行
让时光回到 1974 年,IBM System R 开始进行原型构建。作为一个永远被记录到数据库发展历史上的的伟大项目,System R 有太多优秀的理论实践,而编译执行只是其中之一。
如下图,不同于现代的编译执行是在数据库内部,System R 在客户端应用编译时(注意不是运行时),做了预编译,用于提升执行速度。
而在真正执行的时候,是不涉及 SQL 解析 / 查询优化等环节的,如下:
预编译涉及以下环节:
- 解析 SQL
- 查询优化
- 生成可以在 System R 执行引擎执行的低层级代码
这样做的好处在于,直接省去了解析和优化环节,执行时间大大降低,节省的执行时间如下所示。
这种预编译加速执行的思想,已经内置到现代数据库内部,现代数据库通过缓存优化后的查询计划,来达到与 System R 类似的预编译目的。
现代的缓存实现相比于 System R 预编译效果更好,System R 的预编译实现被商业上证明缺少硬件上的兼容性。不过这种编译来加速 SQL 执行的思想,被广泛使用,只不过需要更好的工具绕开兼容性问题
5.2 Hyber 编译执行
让时光回到 2010 年,又一款对现代数据库设计产生较大影响的项目展露在学术界视野: HyPer - Hybrid OLTP&OLAP High Performance Database System.
从 HyPer 身上可以发现很多关键词: In-memory、Hybrid OLTP & OLAP、MVCC、Compilation,而我们今天主要说 Compilation 这一部分。
在论文 Efficiently Compiling Efficient Query Plans for Modern Hardware 中,HyPer 陈述了自身在编译执行上的实践方式。
5.2.1 Pipeline breaker
上文我们已经提到过 Pipeline breaker 的概念,类似于 GROUP BY / SORT 这种不能进去一条,出来一条的算子就称为 Pipeline breaker,从感觉上讲,这些 Pipeline breaker 算子就像是长江上的三峡大坝,数据(水流) 到那里需要驻留一段时间才能出去。
Pipeline breaker 使 Pipeline 发生中断且需要将数据进行 Materialization (优先到内存,过大到磁盘),因此 Pipeline breaker 是一种比较重的 SQL 算子。
而 Hyper 对 Pipeline breaker 的定义更为严格,只要算子在输出第一条数据前,曾经讲 Tuple 带离 CPU registers (即保存到 Memory),就认为这个算子是 Pipeline breaker。也就是说,在 Hyber 实现时,将内存定义为一种低速设备,希望尽可能让核心计算在 CPU 寄存器上完成。
来自 Hyper 论文的示例:
可以看到这里 JOIN 的实现为 Hash Join,以左侧表作为 Build 阶段的表。
一共 4 个连续处理的块,可以认为块内部的处理不会出 CPU registers,而 GROUP BY / JOIN 算子则是 Pipeline breaker 打断了数据处理流程,这里和经典的 Pipeline breaker 定义得到的划分其实大体上是一致的。
5.2.2 Data centric & Push-based Query Model
如果要对上面的查询计划进行编译,那么得到的骨架应该类似于:
相比于迭代模型,这里最大的区别就是 operator centric 变为了 data centric。不再有 operator 调用 next() 驱动计算,而是以数据为中心,一个 loop 尽可能完成所有可以对数据做的操作,如下:
我们看到两个 JOIN 和 一个 SCAN 算子是在一个 Loop 下完成的,这在迭代模型是需要三个 Loop 的。只要不出寄存器,不管多少个算子,都可以揉在一起跑。
这便是为何编译执行可以为何提升查询性能: 接近于手写代码,按照物理而非逻辑的角度去执行,降低不必要的数据搬运,高效的代码应该围绕数据来构建 (Hadoop: "握手,英雄所见略同 [狗头]")。
而免除了 Operator 驱动数据流向的职责后,Pull-based 的风格也就退化成了 Push-based 的风格,Push-based 风格并不需要什么额外的努力,现代计算机架构下,自然的数据处理方式就是这样的。
5.2.3 Produce/Consume 处理模型
TyPer 构建了一种类似于迭代模型的处理范式, 每个算子实现下面的函数:
- produce()
- consume(attributes, source)
对于 σ (Selection) 算子来说,其 produce() / consume() 如下:
需要注意的是,这些 produce/consume 函数并不真的在编译生成的代码中存在(可以理解为伪码),这些只是为了辅助 CodeGen 引擎生成代码,不然 consume() 调用过多同样会造成迭代模型 next() 调用过多相同的问题。
5.2.4 Code Generation 方案
直接对查询生成 C++ 代码并在运行时编译会有以下缺点:
- 慢,编译一个复杂查询可能要几秒
- 编译后代码作为 Shared library 加载后,不能完整的使用 C++ 的能力,这造成潜在的性能问题
最终,HyPer 选择了 LLVM 作为了执行底座。
选择 LLVM 原因:
- 编译快,几十 ms 左右,对于 OLAP 查询来说,是可以接受的
- 可以直接调用 C++ 实现的方法,对于一些难以用低层级 LLVM 代码实现的算子来说,这是一条捷径
LLVM 就像胶水,将 C++ 实现的复杂算子/LLVM实现的简单算子串联起来,形成数据的传送带,而 Scan 算子是发动机,驱动传送带的运转。形象示意如下:。
性能方面,测试数据如下:
这部分细节较多,感兴趣可以看原论文,下面以一个论文中的简单例子,来直观上感受下基于 LLVM 的 JIT 编译输出是什么样子的。
下面是一个简单的 JOIN 查询,我们后续来分析一下它的 LLVM Code Generation 来结束对编译执行的讨论。
select d_tax
from warehouse, district
where w_id=d_w_id and w_zip=’137411111’
通过上述代码,我们可以看到:
- 生成的代码主体是 LLVM 汇编代码
- 中间夹杂了 C++ 调用 (HashTable 相关)
- 很少的函数调用,少数的几处如: 在 Join Build 阶段调用了 storeInputTuple 函数将 w_id 字段插入到 Hash Table
Vectorization + Compilation
相比于 Vectorization 尝试降低迭代模型缺点,Compilation 则是几乎舍弃了迭代模型的思想,完全从硬件执行角度去构建执行逻辑(像手写代码一样),围绕数据为中心建立数据处理流程。
那么,究竟 Vectorization 和 Compilation 谁更适合 OLAP 呢 ?
Vectorwise 团队于 2011 年发布了一篇论文探讨两种技术的选型: Vectorization vs. Compilation in Query Execution
文中从实践角度,测试了多种场景下的实际表现:
- Scan-Project, 简单的 Scan 后计算表达式, 没有 GROUP BY / JOIN 等复杂算子
SELECT l_extprice*(1-l_discount)*(1+l_tax)
FROM lineitem
可以看到 SIMD 是左右胜局的关键,Compilation + SIMD 是最优方案,而 Compilation 的方案在 Vector size 大于 128 之后就没有性能提升了 (L1 Cache)。
HashJoin 的 Probe 阶段,在不同控制变量下的性能对比,可以看到 Partial Compilation 的执行方式表现最好。
Partial Compilation 是在 Vectorization 的基础上,对热点代码进行 Compilation 局部优化。
总结起来,Vectorwise 团队认为 Vectorization 和 Compilation 并不是非常冲突,在 Vectorization 基础上,针对核心热点进行 Compilation 优化,通常能够达到更好效果。
ClickHouse 的查询执行模型
ClickHouse 有两种执行模型实现: Pipeline 和 Processor,Processor 是 Pipeline 的下一代版本,越来越多的算子后续加入 Processor,Processor 模型实现。
对于 Pipeline 模型,SELECT 和 INSERT 分别用 PULL 和 PUSH 的执行方式。
而 Processor 看起来,更像是基于 Push 的模型(如下),这样可以可以更简单的进行查询内并发执行。
最终执行时,可以混合两种并行方式:
- 数据级并行,不同线程处理不同的数据
- 流水线式并行,复杂任务拆解为多个算子,流水线式并行执行
ClickHouse 对 Vectorization 和 Compilation 的优化(即两者都采用),散落在各个算子内部,对有需要进行优化的热点进行优化。
后面会有专门的一篇介绍 ClickHouse 的执行模型,这里简单概要介绍到这里,以说明事实上为了达到更好的实践效果,可以混杂各种优秀思想,没有一种技术可以长久保持竞争力。
总结
我们通过回顾数据库查询引擎发展历史,从迭代模型开始,介绍了迭代模型的基础概念以及在 Postgresql 的实现方式。
后续,由于时代和硬件架构的发展,迭代模型暴露出在 OLAP 查询中的性能问题,我们从硬件角度分析了为何迭代模型在性能方便表现不佳,比如 CPU Pipeline 和 Cache Misses。
之后,我们引申出 Vectorization 和 Compilation 两种提升性能的解法,而这两种方式并非水火不容,而是可以搭配达到更高的效果。
最后,我们简述了 ClickHouse 的执行模型,细节将会在后续的 《ClickHouse 源码解析-查询引擎实现概述》有详细的解析。
出于篇幅,其实还漏了一些重要内容没有展开,例如查询优化、如何构建查询内并行 ?在现代的 CPU 架构下,如何针对 CPU 架构做更好的查询优化 ?这些内容感兴趣的话,可以阅读文后的 [参考文献]
参考文献
- Architecture of a Database System
- Computer Architecture - A Quantitative Approach, Figure2.21
- DBMSs On A Modern Processor: Where Does Time Go?
- MonetDB/X100: Hyper-Pipelining Query Execution
- Kudu: Storage for Fast Analytics on Fast Data
- HyPer - Hybrid OLTP&OLAP High Performance Database System
- Efficiently Compiling Efficient Query Plans for Modern Hardware
- Vectorization vs. Compilation in Query Execution
- A History and Evaluation of System R
- Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask
- Morsel-Driven Parallelism - A NUMA-Aware Query Evaluation Framework for the Many-Core Age
- Access path selection in a relational database management system
- Youtube: Architecture All Access: Modern CPU Architecture Part 2 – Microarchitecture Deep Dive
- CMU 15-445/645 Database System
- CMU 15-721 Advanced Database Systems
- Yandex: Clickhouse query execution pipeline