1.当代CPU特性
向量化执行和编译执行是目前主流的两种数据库执行引擎优化手段。
了解CPU特性可以让我们真正理解各种数据库执行引擎优化技术的动机。
影响数据库执行引擎执行效率的CPU特性主要有以下几点:
- 超标量流水线与乱序执行、
- 分支预测、
- 多级存储与数据预取、
- SIMD
超标量流水线与乱序执行
- CPU指令执行可以分为多个阶段(如取址、译码、取数、运算等);
(1)流水线的意思是指
一套控制单元可以同时执行多个指令,只是每个指令处在不同的阶段,例如上一条指令处理到了取数阶段,下一条指令处理到了译码阶段。
(2)超标量的意思是指
一个CPU核同时有多套控制单元,因此可以同时有多个流水线并发执行。
CPU维护了一个乱序执行的指令窗口,窗口中的无数据依赖的指令就会被取来并发执行。 - 程序做好以下两个方面,可以提高超标量流水线的吞吐(IPC,每时钟周期执行指令数)。
(1)流水线不要断,不需要等到上一条指令执行完,就可以开始执行下一条指令。这意味着程序分支越少越好(知道下一条指令在哪)。
(2)并发指令越多越好。指令之间没有依赖,意味着更流畅的流水线执行,以及在多个流水线并发执行。
分支预测
- 程序分支越少,流水线效率越高。
但是程序分支是不可避免的。程序分支可以分为两种,条件跳转和无条件跳转。
(1)条件跳转来自if或switch之类的语句。
(2)无条件跳转又可根据指令的操作数分为跳转地址还是跳转地址指针,分为直接跳转和间接跳转。
直接跳转一般为静态绑定的函数调用,间接跳转来自函数返回或虚函数之类动态绑定的函数调用。 - 当执行一个跳转指令时,在得到跳转的目的地址之前,不知道该从哪取下一条指令,流水线就只能空缺等待。
为了提高这种情况下的流水线效率,CPU引入了一组寄存器,用来专门记录最近几次某个地址的跳转指令的目的地址。
这样,当再一次执行到这个跳转指令时,就直接从上次保存的目的地址出取指令,放入流水线。等到真正获取到目的地址的时候,再看如果取错了,则推翻当前流水线中的指令,取真正的指令执行。
多级存储与数据预取
- 多级存储就不用解释了,当数据在寄存器、cache或内存中,CPU取数速度不在一个数量级。
尤其cache和内存访问,相差两个数量级。CPU在内存取数的时候会首先从cache中查找数据是否存在。若不存在,则访问内存,在访问内存的同时将访问的数据所在的一个内存块一起载入cache。 - 如果程序访问数据存在线性访问的模式,CPU会主动将后续的内存块预先载入cache,这就是CPU的数据预取。
有时候程序访问数据并不是线性的,例如Hash表查找等。CPU也提供了数据预取指令,程序可以事先主动将会用到的数据载入cache,这就是Software Prefetch。 - 如何利用好寄存器和cache是数据库查询执行非常重要的优化方向。
SIMD
-
单指令多数据流,对于计算密集型程序来说,可能经常会需要对大量不同的数据进行同样的运算。SIMD引入之前,执行流程为同样的指令重复执行,每次取一条数据进行运算。
例如有8个32位整形数据都需要进行移位运行,则由一条对32位整形数据进行移位的指令重复执行8次完成。
SIMD引入了一组大容量的寄存器,一个寄存器包含832位,可以将这8个数据按次序同时放到一个寄存器。同时,CPU新增了处理这种832位寄存器的指令,可以在一个指令周期内完成8个数据的位移运算。 -
如何利用好SIMD也是不少数据库的优化方向,尤其是向量化执行的策略下。
2.查询执行模型
火山模型
-
数据库查询执行最著名的是火山模型,也是在各种数据库系统中应用最广泛的模型。
-
SQL查询在数据库中经过解析,会生成一棵查询树,查询数的每个节点为代数运算符(Operator)。
火山模型把Operator看成迭代器,每个迭代器都会提供一个next() 接口。 -
一般Operator的next() 接口实现分为三步
(1)调用子节点Operator的next() 接口获取一行数据(tuple)
(2)对tuple进行Operator特定的处理(如filter 或project 等)
(3)返回处理后的tuple。 -
因此,查询执行时会由查询树自顶向下的调用next() 接口,数据则自底向上的被拉取处理。
-
火山模型的这种处理方式也称为拉取执行模型(Pull Based)。
-
火山模型的优点是,处理逻辑清晰,每个Operator 只要关心自己的处理逻辑即可,耦合性低。
-
火山模型的缺点:
数据以行为单位进行处理,不利于CPU cache 发挥作用。且每处理一行需要调用多次next() 函数,而next()为虚函数,开销大。
每次都是计算一个 tuple(Tuple-at-a-time),这样会造成多次调用 next ,也就是造成大量的虚函数调用,这样会造成 CPU 的利用率不高。
(C++ 中用 virtual 标记的函数,而在 Java 中没有 final 修饰的普通方法(没有标记为 static、native)都是虚函数。) -
为了更好地理解一个 Operator 中发生了什么,下面通过伪代码来理解 Select Operator:
Tuple Select::next() {
while (true) {
Tuple candidate = child->next(); // 从子节点中获取 next tuple
if (candidate == EndOfStream) // 是否得到结束标记
return EndOfStream;
if (condition->check(candidate)) // 是否满足过滤条件
return candidate; // 返回 tuple
}
}
- eg:
SELECT Id, Name, Age, (Age - 30) * 50 AS Bonus
FROM People
WHERE Age > 30
对应火山模型如下:
- User:客户端;
- Project:垂直分割(投影),选择字段;
- Select(或 Filter):水平分割(选择),用于过滤行,也称为谓词;
- Scan:扫描数据。
- 过程:
这里包含了 3 个 Operator:
首先 User 调用最上方的 Operator(Project)希望得到 next tuple;
Project 调用子节点(Select);
而 Select 又调用子节点(Scan);
Scan 获得表中的 tuple 返回给 Select,Select 会检查是否满足过滤条件,如果满足则返回给 Project,如果不满足则请求 Scan 获取 next tuple。
Project 会对每一个 tuple 选择需要的字段或者计算新字段并返回新的 tuple 给 User。当 Scan 发现没有数据可以获取时,则返回一个结束标记告诉上游已结束。 - tuple含义:
元组(tuple)是关系数据库中的基本概念,关系是一张表,表中的每行(即数据库中的每条记录)就是一个元组,每列就是一个属性。 在二维表里,元组也称为行
编译执行
-
考虑到火山模型大量虚函数调用导致的性能损失,推送执行模型(Push Based)很好的解决了这个问题。与拉取模型相反,推送模型自低向上的执行,执行逻辑由底层Operator开始,其处理完一个tuple之后,将tuple传给上层Operator处理
-
前面CPU的多级存储介绍提到,数据访问速度最快的是寄存器。
所以在执行查询树时最理想的情况就是数据一直留在寄存器中(假设寄存器的容量足以放下一个tuple),每个Operator直接处理寄存器中的数据。
Operator之间从拉取模型的虚函数调用,变成了以数据为中心(data-centric)的顺序执行。 -
当然,并不是所有的Operator的运算逻辑都可以处理完寄存器中的tuple之后,把tuple留在寄存器中,由下一个Operator 接着处理。
例如Join的时候,需要构建hash表,tuple就必须写入内存了(整个hash表当然不可能放到寄存器)。 -
[3]论文的思想:
每个Operator会根据规则拆分为两个代码块,一块对应Produce() ,一块对应consume()。代码生成的时候就可以根据这个规则生成代码。
Produce()函数负责产生结果tuple;
Consume()函数负责具体的tuple处理逻辑;
好处:编译执行以数据为中心,消灭了火山模型中的大量虚函数调用开销。甚至使大部分指令执行,可以直接从寄存器取数,极大的提高了执行效率。
向量化执行
-
向量化执行依然采用类似火山模型的拉取式模型,唯一的区别是其Operator的next()函数每次返回的是一批数据(如1000行)
-
一般向量化特指列式存储系统中,按列聚合的一组数据;
在行式系统中称为RowSet迭代,本文不严格区分这两种情况 -
eg:下图是一个JoinOperator的编译生成的伪代码和向量化执行的伪代码的对比。
-
图中(a)部分为编译执行模型生成的伪代码
JoinOperator 的执行逻辑为,以左表的数据构建Hash表,然后以右表中的每行记录,分别去Hash表查找。
这里的Hash表的冲突处理采用的是链地址法,伪代码中最后一个循环就是遍历链表,找到真正的匹配项。 -
图中(b)部分向量化执行的伪代码
与火山模型不同的,它一次处理一组数据。
因此,可以看到这里面的变量都是Vector。
由于变量为Vector,就需要事先定义一些专门处理Vector的元语(Primitives):例如为Vector中的每一个元素计算Hash值的proheHash_,以及图中的compareKeys_、buildGather_。
向量化执行模型有一下几个好处:
- 1.大大减少火山模型中的虚函数调用数量;
- 2.以块为处理单位数据,提供了cache命中率;
- 3.多行并发处理,契合了CPU乱序执行与并发执行的特性。
- 4.同时处理多行数据,使SIMD有了用武之地(虽然目前SIMD对大多数数据库查询起到的作用比较有限[1],本质上数据库查询都属于数据访问密集型应用,而不是SIMD最擅长的计算密集型应用)。
3.向量化VS编译执行
首先这两个模型是不相容的,二者只能取其一。
-
因为编译执行强调的是以数据为中心,在一个Pipeline内是不会有Materialization的;
但是向量化执行是拉取模型,每次经过next()调用,Vector的传递必须Materialize -
[1]的团队在同一个系统(Peloton)里实现了这两种模型,以进行各方面对比。
[1]选取了TPC-H中非常有代表性的5个SQL查询,Q1和Q18主要运算为定点数运算(Fixed-point arithmetic)和Aggregation,Q6主要运算为Filter,Q3和Q9主要运算为Join;
从图中可以看出向量化执行对于Q3和Q9查询更高效,而编译执行对于Q1和Q18语句更高效。
-
原因以及分析:
-
图中的Typer是编译执行引擎,Tectorwise是向量化执行引擎。Memory Stall指的是CPU执行指令时,内存取数的等待时间。
(1)可以看出Q3和Q9,Typer主要就是慢在了Memory Stall。因为Hash表数据分布比较随机,Hash查找时cache命中率不高,经常需要访问内存。
编译执行循环内部会包含多个Operator的运算,这些有依赖关系的指令占据了大部分的乱序执行窗口,并发度低,总的等待取数时间就长了
向量化执行模型的循环较短,并发度高,可以同时有更多的指令等待取数,总的等待时间就短了。
更复杂的循环也导致分支预测失败的代价更高。
(2)对于Q1和Q18,由于运算过程中cache命中率比较高,没有Memory Stall的拖累,编译执行模型Pipeline执行无Materialization的优势就体现出来了
4.编译执行融合向量化
[2]设计了一个原型系统 Relaxed Operator Fusion (ROF)来实现编译执行融合向量化
-
把查询树分解一下,部分用向量化方式,部分用编译执行方式即可
-
思想:
编译执行的主要目标是减少Materialization,ROF则是在编译执行的基础上,主动在其中的Pipeline中插入Materialization,将Pipeline分割为Stage,在Stage内依然是tuple-at-a-time data-centric的推送模型,保留了编译执行数据停留在寄存器中的优点。
而跨Stage或Pipeline时,则以Block(一组tuple)为单位传递数据,这个时候就可以利用上SIMD。
另外,ROF还使用了Software Prefetch来优化编译执行当Hash表查找时cache命中率低Memory Stall过多的问题。 -
eg:下面的SQL是TPC-H Q19的简化版本。
其中CLAUSE1~3分别是LineItem和Part两个表上的查询条件。
SELECT SUM(...) AS revenue
FROM LineItem JOIN Part ON l_partkey = p_partkey
WHERE (CLAUSE1) OR (CLAUSE2) OR (CLAUSE3)
这段SQL对应的编译执行的查询数和ROF的查询树如下图。
-
可以看到ROF 相比原来的查询树,在P2这个Pipeline的第一个Filter后面插入了一个Operator(图中标红)。
这个Operator表示Vector Output,把P2分成了两个Stage(我们称这种分割Stage的Operator为Stage boundary),在Stage内部为tuple-at-a-time,跨Stage则以Vector为单位传递数据。 -
P2对应ROF的伪代码如下图。
Stage1进行TableScan和Filter,将VECTOR_SIZE数量的Tuple插入Vector。
Stage2对Vector中的数据进行HashProbe、Filter以及Aggregate。
-
上面的例子展示了ROF是怎么把Pipeline分割成Stage的,但是到目前为止,我们并没有看到这么做有什么好处。
-
这里最关键的问题是,应该在Pipeline的哪个位置插入Stage boundary,才能达到最优的效果。
ROF按照两个规则分割Stage:
R1. 可以使用SIMD的Operator的输入和输出点;
R2. 需要对无规律地址数据(且数据量大于Cache)进行访问的Operator的输入点。
R1是为了利用SIMD进行并发计算,R2是为了使用Software prefetch提高cache命中率。
这是基本策略,在实现时还有一些技术点需要考虑。为了快速获取SIMD寄存器中Filter过后的数据,ROF利用一个Mask索引,将SIMD寄存器中的有效数据Shuffle到一起。
当数据量不大时,数据预取反而会带来额外开销。ROF在编译时会生成两套执行路径,在运行时根据数据量决定是否需要预取。
5.优化方向
-
其次,火山模型中一次只取一条数据,如果每次取多条数据呢?
(1)可以将每次 next 带来的 CPU 开销被一组数据给分摊。这样当 CPU 访问元组中的某个列时会将该元组加载到 CPU Cache(如果该元组大小小于 CPU Cache 缓存行的大小), 访问后继的列将直接从 CPU Cache 中获取,从而具有较高的 CPU Cache 命中率
(2)当然是同一列的时候,所以针对的是列存的场景,因为输入是同列的一组数据,面对的是相同的操作,这正是向量寄存器干的事情,这是 CPU 层面计算性能的优化,因此称为向量化。
并且如果每次只取一列的部分数据,返回一个可以放到 CPU Cache 的向量,那么又可以利用到 CPU Cache。 -
向量化
向量化计算就是将一个循环处理一个数组的时候每次处理 1 个数据共处理 N 次,转化为向量化——每次同时处理 8 个数据共处理 N/8 次,其中依赖的技术就是 SIMD(Single Instruction Multiple Data,单指令流多数据流),SIMD 可以在一条 CPU 指令上处理 2、4、8 或者更多份的数据。
CPU 层面的优化方案的结论
-
相比火山模型,编译执行和向量化都使数据库查询执行性能得到了极大的提升,这两者之间相比又如何呢。
首先这两个模型是不相容的,二者只能取其一。
因为编译执行强调的是以数据为中心,在一个 CPU Pipeline 内是不会有物化的;
向量化执行是拉模型,每次经过 next() 调用,向量的传递必须要进行物化。 -
相关论文
[1] Kersten, Timo, et al. “Everything you always wanted to know about compiled and vectorized queries but were afraid to ask.” Proceedings of the VLDB Endowment 11.13 (2018): 2209-2222.
[2] Menon, Prashanth, Todd C. Mowry, and Andrew Pavlo. “Relaxed operator fusion for in-memory databases: Making compilation, vectorization, and prefetching work together at last.” Proceedings of the VLDB Endowment 11.1 (2017): 1-13.
[3] Neumann, Thomas. “Efficiently compiling efficient query plans for modern hardware.” Proceedings of the VLDB Endowment4.9 (2011): 539-550.
[4] Boncz, Peter A., Marcin Zukowski, and Niels Nes. “MonetDB/X100: Hyper-Pipelining Query Execution.” Cidr. Vol. 5. 2005.