目录
相关概念
SIMD
(Single Instruction Multiple Data)单指令多数据:一条指令处理多个数据。cpu架构支持的指令集。
如何使用SIMD?
(1)编译器自动向量化:一些比较简单的场景,编译器可以自动将目标代码向量化(auto vectorization)使用 GCC 可以通过 -S 参数,输出中间汇编文件,以检查是否自动将代码进行向量化
(2)编译器扩展的向量支持:编译:g++ -o built-vec -mavx512f built-vec.cc
CodeGen
代码生成 ---- 软件开发人员用来生成源代码的工具:
(1)从元数据源(通常是存储库结构)读取信息:数据结构以及这些数据结构的属性,各种数据结构之间的关系
(2)从模板读取规则
(3)将规则与元数据合并以创建输出文件
LLVM
是构架编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile- time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。
sql执行计划中常见的算子:
- indexscan 索引扫描
- tablescan 顺序表扫描
- project 投影:从表中根据查询字段选择相关的列
- filter过滤:filter 会根据 where 条件中的筛选条件,筛选出符合的记录。其中过滤条件也叫谓词逻辑(谓词下推: join操作是做笛卡尔积,谓词逻辑在join操作前完成)
- exchange:在分布式数据库中,tablescan等操作是分布式进行的,各个分支结点将结果汇总的过程即exchange
-
LocalExchange 即本地数据结果汇总,没有网络IO RemoteExchange是数据从不同的数据结点汇总到某一结点,需要网络传输。
- join 连接:本质是两个表做笛卡尔积操作
- aggregation 聚合 :对数据做分组聚合,统计分析: eg: select avg(ctr_total_return) from customer_total_return_test where ctr_store_sk = 10;这个查询中只有一张表,由于是分布式执行,表虽然只扫描一次,但是会在多个数据结点进行扫描,所以avg函数会在每个结点先执行一次,exchange 汇总完后,会再进行一次avg操作。
- values:有时SQL中数据不是从表中查询出来的,而是给定的一个数字、字符或数组,这时 values操作会将这些标识符转化成具体的数值。
- scalar 标量:根据策略,给定一个结果值
- markDistinct:distinct操作时,对数据的类别进行标识
- window窗口:窗口函数是应用于窗口和分区的函数,可分为三类:排名函数,分析函数和聚合函数:计算时,会根据 partition by后的字段进行分区,然后进行统计分析或排名
- sort 排序
- topn:即 limit 操作,获取限定的记录条数
- output 向客户端输出结果。
数据库计算引擎的优化技术:向量化执行与代码生成
select c1 + 100, c2 from t where c1 < 100 and c3 = 10
该 SQL 包含了 3 个 operator:tablescan,selection 和 projection,而每个 operator 内部又包含了各自的 expression,例如 selection 内部的 expression 为c1 < 100 and c3 = 10,projection 内部的 expression 则为c1 + 100和c2。
经典的 SQL 计算引擎在 expression 层面一般采用 expression tree 的模型来解释执行,而在 operator 层面则大多采用火山模型。
在火山模型中,operator 也被组织为 operator tree 的形式,operator 之间则通过迭代器来串联。Operator 一般有如下定义:
class Operator {
Row next();
void open();
void close();
Operator children[];
}
火山模型优点:
- 最大好处是实现简单,每个 operator 都只需要完成其自身特定的功能,operator 之间是完全解耦合的,SQL complier 只需要根据 SQL 的逻辑构造对应的 operator 然后将 operator 串联起来即可
火山模型缺点:
- 火山模型诞生于上世纪九十年代,IO 速度是瓶颈,随着近年来数据库存储组件越来越快,计算速度也逐渐成为了数据库的一个瓶颈,从 expression 和 operator 两个层面来看,经典的计算模型都显得有点笨重:
1 Expression层面:
基于 expression tree 的解释执行往往使得一些看上去很简单的表达式执行起来很复杂,以上述 SQL 的 filter 条件为例:c1 < 100 and c3 = 10 这个过滤条件在数据库中会被转换为包含 7 个节点的 expression tree,对于表中的每行数据,这 7 个节点的 eval 函数都会被触发一次。而实际上,这个 expression tree 的效果与下面这段手写代码是等价的:
(input.getInt(0) < 100 && (input.getInt(2) == 10),
相比之下 expression tree 的解释执行就显得笨重很多。而且由于虚函数调用,即使是最简单的求值函数也基本无法被内联。
2 Operator 层面
面临的问题与 Expression 类似,火山模型虽然带来了实现简单、干净的好处,但是每次计算一行结果都会有一个很长的 next 虚函数调用链(而且 operator next 函数中一般还会有一个 expression eval 的虚函数调用链)。虽然虚函数调用本身开销并不算特别大,但是仍需要花费一定的时间,而虚函数内部的操作可能就是一个简单的轻量级计算,而且每一行数据都需要若干次的虚函数调用,当数据量非常大的时候,这个开销就会变得十分可观。
(3)为了适应复杂的表达式结构,计算一条表达式往往需要引入大量的指令;对于行式执行来说,处理单条数据需要算子树重新进行指令解释(instruction interpretation),从而带来了大量的指令解释开销。 此外,在最初的Volcano结构设计中,算子内部逻辑并没有避免分支预测(branch prediction)。错误的分支预测需要CPU终止当前的流水线,将ELSE语句中的指令重新载入,我们将这一过程称为pipeline flush或pipeline break。频繁的分支预测错误会严重影响数据库的执行性能。
总结:大量的指令解释开销、大量虚函数调用链、错误的分支预测(pipeline flush/break)
SQL 计算引擎的优化:均摊开销 消除开销
一 向量化执行:均摊开销
向量化执行的思想就是均摊开销:假设每次通过 operator tree 生成一行结果的开销是 C 的话,经典模型的计算框架总开销就是 C * N,其中 N 为参与计算的总行数,如果把计算引擎每次生成一行数据的模型改为每次生成一批数据的话,因为每次调用的开销是相对恒定的,所以计算框架的总开销就可以减小到C * N / M,其中 M 是每批数据的行数,这样每一行的开销就减小为原来的 1 / M,当 M 比较大时,计算框架的开销就不会成为系统瓶颈了。
除此之外,向量化执行还能给 compiler 带来更多的优化空间,因为引入向量化之后,实际上是将原来数据库运行时的一个大 for 循环拆成了两层 for 循环,内层的 for 循环通常会比较简单,对编译器来说也存在更大的优化可能性。
二 代码生成:消除开销
代码生成的思想是消除开销。代码生成可以在两个层面上进行,一个是 expression 层面,一个是 operator 层面,当然也可以同时在 expression 与 operator 层面进行代码生成。
经典 SQL 计算引擎的一大缺点就是各种虚函数调用不但会带来很多额外的开销,而且还挤压了 compiler 的优化空间。
代码生成可以直观的理解为在 SQL plan build 好之后,将 plan 中的代码进行一次逻辑上的内联。如果实现的好,代码生成能够将上述所说的火山模型代码转换为类似于手动优化的代码,显然和向量化执行一样,代码生成后的新代码也给编译器带来了更多的优化机会。
与向量化执行相比,代码生成之后数据库运行时仍然是一个 for 循环,只不过这个循环内部的代码从简单的一个虚函数调用plan.next()展开成了一系列具体的运算逻辑,这样数据就不用再各个 operator 之间进行传递,而且有些数据还可以直接被存放在寄存器中,进一步提升系统性能。当然为了获取这些好处代码生成也付出了一定的代价,代码生成需要在 SQL 编译器编译获得 plan 之后进行额外的 code gen + jit ,对应到具体的工程实现也比向量化执行的难度要高一些。