SQL优化之火山模型、向量化、编译执行

本文深入探讨了CPU特性如何影响数据库查询执行效率,包括超标量流水线、乱序执行、分支预测、多级存储与数据预取以及SIMD技术。介绍了火山模型、编译执行和向量化执行的查询执行模型,分析了它们的优缺点,并提出编译执行融合向量化的新思路。此外,文章还讨论了优化数据库查询的方向,如减少虚函数调用、提高cache命中率和利用SIMD进行并行计算。
摘要由CSDN通过智能技术生成

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

  • Materialization Model,物化

  • [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.

  • 参考:SQL 优化之火山模型向量化与编译执行浅析

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

喜欢打篮球的普通人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值