【数据库内核】物理计算引擎Push模型之编译执行

目录

概述

自顶向下(Top-to-Bottom)- Pull模型

自底向上(Bottom-to-Top) - Push模型

CPU特征

超标量流水线与乱序执行

分支预测

多级存储与数据预取

SIMD

物理计算引擎的技术演进之路

推送模型与拉取模型的区别

编译执行

Hyper数据库编译执行的方式

结论

参考资料


概述

前文中讲了数据库计算引擎关于火山模型的设计方式和火山模型的痛点问题。火山模型是基于拉取模型的思想(Pull Model)设计的。本文讲述另一种关于数据库计算引擎推送模型(Push Model)的设计方案。

那么讲之前先了解下数据库计算引擎,执行计划的两种方式:

 

自顶向下(Top-to-Bottom)- Pull模型

这种处理方式的典型代表是火山模型。火山模型是数据库成熟的 SQL 语句解释执行方案,该模型是数据库执行器中非常流行的“设计模式”。开始从父节点向子节点拉数据,每一行数据都要经过所有算子的计算。但是这种设计模式对于Cpu Cache是不友好的。

 

自底向上(Bottom-to-Top) - Push模型

除了上述火山模型,还有一种反向的调用模型也十分流行,那就是 Push 模型。Push 模型简单来说就是由调度器先分析执行树,然后从树的叶子节点开始执行,在执行之后,有子节点通知父节点执行操作。

Push 模型的执行看起来更为直观,但是由于控制流是反转的,一般实现起来会比较繁琐。相比起来火山模型更易于操作,比如在终止查询的时候,火山模型只要停止从根节点继续迭代即可。但是 Push 模型相对单纯的火山模型也有很多优点:由于子算子产生的结果会直接 Push 给父算子进行操作,Push 模型的 Context switch 相对较少,对 CPU Cache 的友好性也更强。

下面我们了解一下关于CPU的一些特性,方便后面理解关于拉取模型和推送模型架构的优势在哪里,劣势又在哪里?

 

CPU特征

了解CPU特性可以让我们真正理解各种数据库执行引擎优化技术的动机。影响数据库执行引擎执行效率的CPU特性主要有以下几点:超标量流水线与乱序执行、分支预测、多级存储与数据预取、SIMD。

 

超标量流水线与乱序执行

CPU指令执行可以分为多个阶段(如取址、译码、取数、运算等),流水线的意思是指一套控制单元可以同时执行多个指令,只是每个指令处在不同的阶段,例如上一条指令处理到了取数阶段,下一条指令处理到了译码阶段。超标量的意思是指一个CPU核同时有多套控制单元,因此可以同时有多个流水线并发执行。CPU维护了一个乱序执行的指令窗口,窗口中的无数据依赖的指令就会被取来并发执行。

程序做好以下两个方面,可以提高超标量流水线的吞吐(IPC,每时钟周期执行指令数)。一,流水线不要断,不需要等到上一条指令执行完,就可以开始执行下一条指令。这意味着程序分支越少越好(知道下一条指令在哪)。二,并发指令越多越好。指令之间没有依赖,意味着更流畅的流水线执行,以及在多个流水线并发执行。

 

分支预测

程序分支越少,流水线效率越高。但是程序分支是不可避免的。程序分支可以分为两种,条件跳转和无条件跳转。条件跳转来自if或switch之类的语句。无条件跳转又可根据指令的操作数为跳转地址还是跳转地址指针,分为直接跳转和间接跳转。直接跳转一般为静态绑定的函数调用,间接跳转来自函数返回或虚函数之类动态绑定的函数调用。

当执行一个跳转指令时,在得到跳转的目的地址之前,不知道该从哪取下一条指令,流水线就只能空缺等待。为了提高这种情况下的流水线效率,CPU引入了一组寄存器,用来专门记录最近几次某个地址的跳转指令的目的地址。这样,当再一次执行到这个跳转指令时,就直接从上次保存的目的地址上取指令,放入流水线。等到真正获取到目的地址的时候,再看如果取错了,则推翻当前流水线中的指令,取真正的指令执行。

 

多级存储与数据预取

多级存储就不用解释了,当数据在寄存器、cache或内存中,CPU取数速度不在一个数量级。尤其cache和内存访问,相差两个数量级。CPU在内存取数的时候会首先从cache中查找数据是否存在。若不存在,则访问内存,在访问内存的同时将访问的数据所在的一个内存块一起载入cache。

如果程序访问数据存在线性访问的模式,CPU会主动将后续的内存块预先载入cache,这就是CPU的数据预取。有时候程序访问数据并不是线性的,例如Hash表查找等。CPU也提供了数据预取指令,程序可以事先主动将会用到的数据载入cache,这就是Software Prefetch。

如何利用好寄存器和cache是数据库查询执行非常重要的优化方向。

 

SIMD

单指令多数据流,对于计算密集型程序来说,可能经常会需要对大量不同的数据进行同样的运算。SIMD引入之前,执行流程为同样的指令重复执行,每次取一条数据进行运算。例如有8个32位整形数据都需要进行移位运行,则由一条对32位整形数据进行移位的指令重复执行8次完成。SIMD引入了一组大容量的寄存器,一个寄存器包含8*32位,可以将这8个数据按次序同时放到一个寄存器。同时,CPU新增了处理这种8*32位寄存器的指令,可以在一个指令周期内完成8个数据的位移运算。

如何利用好SIMD也是不少数据库的优化方向,尤其是向量化执行的策略下。

为了方便理解,举个例子如下:

在做一些聚合的时候,有这样一个函数,我要去做一个求和。正常人写程序,他就是一个 for 循环,做累加。但是在一个数据库里面,如果有一百亿条数据做聚合,每一次执行这条操作的时候,CPU 的这个指令是一次一次的执行,数据量特别大或者扫描的行数特别多的时候,就会很明显的感受到这个差别。

 

现代的 CPU 会支持一些批量的指令,比如像 _mm_add_epi32,可以一次通过一个 32 位字长度对齐的命令,批量的操作 4 个累加。看上去只是省了几个 CPU 的指令,但如果是在一个大数据量的情况下,基本上能得到 4 倍速度的提升。

 

物理计算引擎的技术演进之路

物理计算引擎的技术迭代如下图所示:

 

前文说到第一代火山模型的设计是基于单行计算的设计方式,上层算子通过虚函数调用下次算子的方式实现,可以使算子之间的逻辑解耦。使用火山模型可以使整个架构的设计和实现趋向于简单。但是这种设计是对于CPU Cache非常不友好的。每一行数据都要通过虚函数的调用来计算结果,虚函数的间接调用本身会导致分支预测。据统计,间接分支预测的失败率在25%左右。一旦分支预测失败,将会导致流水线被冲刷,进而需要重新取指令、译码,对性能造成严重的影响。如下图所示:

第二代火山模型的设计是批量计算的设计方式,因为第一代火山模型的痛点,单行计算导致频繁的虚函数调用开销,导致程序的性能低下。那么可不可以批量计算的方式,来降低虚函数的调用次数,从而简单虚函数的调用开销,从而提升程序的性能。

火山模型通过虚函数来实现的,如果数据量很大,那么虚函数的调用开销和对CPU Cache的不友好型成为了它的痛点,那么业界通过批处理计算的方式,原先火山模型传递的数据是一行数据,那么我们可以一次传递多行数据同时计算,所以技术演进出批处理之火山模型。如下图所示:

所谓成也风云,败也风云。一次性需要处理批量的数据。如果我们要处理的数据特别大,假设某个表有1亿条数据,Scan需要把所有数据先读取到内存中,再传输给上层节点,可能在这个过程中,就已经OOM(out of memory)了。

考虑到火山模型这些痛点问题,推送执行模型(Push Based)应运而生很好的解决了这个问题。与拉取模型相反,推送模型自低向上的执行,执行逻辑由底层算子开始,其处理完一行数据之后,将一行数据传给上层算子处理。

推送模型最早在一些流媒体计算中被使用,随着大数据时代的来临,在一些基于内存设计的OLAP数据库也被大量使用起来,例如HyPer、LegoBase等。

 

推送模型与拉取模型的区别

推送模型与拉取模型的两种不同的控制流和数据流关系图如下所示:

根据上图可知,拉取模型的设计更加符合查询执行的直观印象,上层算子按需要向下层算子获取数据并执行。其实本质上就是一层层的虚函数调用。

推送模型与拉取模型刚好相反,通过将上层的计算下推到数据产生的操作符中,由数据的最终生产者驱动上层运算符对数据进行消费。

为了方便大家理解,我们使用伪代码的方式来描述上述的SQL例子中关于拉取模型与推送模型的代码样子如下图所示:

SELECT SUM(T.A) FROM TAB T WHERE T.B < 10

相比于拉取模型,推送模型改变了数据迭代过程中的嵌套调用关系,大大简化了查询过程中的指令跳转流程,从而使得推送模型有更好的代码局部性,优化了CPU执行效率。但是推送模型实现也更为复杂。

 

编译执行

拉取模型和推送模型影响的是执行流程的代码布局,但只要是解释执行,就无法避免运算符之间的虚函数调用。随着计算机硬件的发展,内存变得越来越大,这意味着越来越多的数据可以直接cache在内存中,而访问磁盘的频率被大幅度的降低,这个时候“IO墙”的效应被削弱,而由于解释执行无法感知CPU寄存器,高频的内存访问,使CPU和内存之间形成了“内存墙”效应,为了解决这个问题,越来越多的内存数据库开始使用编译执行来优化自己的查询效率,例如HyPer、MemSQL、Hekaton、Impala、Spark Tungsten等。

 

相比于解释执行,编译执行具有以下优点:

  1. 移除条件分支:因为已知运行时信息,所以可以优化if/switch语句。这是最简单有效的方式,因为最终机器码中的分支指令会阻止指令的管道化(instruction pipelining)和并行执行(instruction-level parallelism)。同时,通过展开for循环(因为我们已经知道循环次数)和解析数据类型,分支指令能被一起移除。
  2. 移除内存加载:从内存加载数据是开销很大而且阻止管道化的操作。如果每次加载的结果都一样的话,我们就可以使用代码生成来替代数据加载。
  3. 内联虚函数调用:虚函数对性能的影响很大,尤其是函数很小很简单,因为它无法内联化。因此当对象实例的类型在运行时可知时,我们可以使用代码生成来取代虚函数的调用,并做内联化。

编译执行从根本上讲,提高数据库性能的方法是减少 CPU 指令数量;有实验之处数据库处理事务过程中,正真有用的指令,即用于事务逻辑的指令不到5%。

 

Hyper数据库编译执行的方式

Hyper作为代表性的编译执行的数据库,采用了推送模型。我们直接来看Hyper论文中的例子,有如下SQL查询,

SELECT  * FROM  R1,  R3,  ( SELECT R2.Z, COUNT( * ) FROM R2 WHERE R2.Y = 3 GROUP BY R2.Z ) R2 WHERE  R1.X = 7   AND R1.A = R3.B   AND R2.Z = R3.C

 

对于上面的SQL查询,其对应的Operator查询树如下图左侧所示

 

前面CPU的多级存储介绍提到,数据访问速度最快的是寄存器。所以在执行查询树时最理想的情况就是数据一直留在寄存器中(假设寄存器的容量足以放下一行数据),每个算子直接处理寄存器中的数据。算子之间从拉取模型的虚函数调用,变成了以数据为中心(data-centric)的顺序执行。

当然,并不是所有的算子的运算逻辑都可以处理完寄存器中的一行数据之后,把一行数据留在寄存器中,由下一个算子 接着处理。例如Join的时候,需要构建hash表,一行数据就必须写入内存了(整个hash表当然不可能放到寄存器)。

Hyper把Join这种不得不把数据从寄存器取出来,然后物化到内存的算子称为Pipeline Breaker。然后以Pipeline Breaker为分割,将查询树划分为多个pipeline。在一个pipeline内,数据可以一直留在寄存器中。因此上图左侧的查询树,分割为Pipeline之后,对应的查询树就如图右侧所示。说到这里还是会觉得有点抽象,看一下上面的查询树对应的编译执行的伪代码,如下图。可见每个Pipeline对应一个For循环,一次循环处理一行数据,一行数据在一次循环内是不离开寄存器的。

 

编译执行的难点在于如何把查询树编译成这样的代码执行。不像拉取模型,一个next()调用把数据传递和数据处理逻辑分的明明白白。复杂的算子逻辑直接影响到编译执行的代码生成。Hyper观察算子处理数据的模式,从中抽象出了两种函数接口Produce() 和 Consume()。Produce()函数负责产生结果一行数据,然后通过调用下一个算子的Consume()函数,将一行数据向上传递。Consume()函数负责具体的一行数据处理逻辑。Produce() 和 Consume()只是为了代码生成引入的逻辑概念,实际上是每个算子会根据规则拆分为两个代码块,一块对应Produce() ,一块对应consume()。代码生成的时候就可以根据这个规则生成代码。从下图可以看出Join、Filter和Scan 算子与代码块的简单对应。当然,实际上会更加复杂。Hyper会利用LLVM直接生成其中间语言。

论文的放到下面的参考资料中,感兴趣的同学可以看下。《Efficiently Compiling Efficient Query Plans for Modern Hardware》

 

结论

本文延续上篇火山模型的物理计算引擎的拉取式架构,这次讲述了推送式架构的物理计算引擎的设计和Hyper数据库关于计算引擎设计的思想。Llvm 代码生成技术的产生让我们在提高物理计算引擎上扩展出了更多的可能。让计算性能得到非常好的飞跃。关于计算引擎有很多论文资料可以参考,也放到下面的参考资料中,想要扩展学习和加深学习的可以参考下。

 

参考资料

《Efficiently Compiling Efficient Query Plans for Modern Hardware》

  • https://www.vldb.org/pvldb/vol4/p539-neumann.pdf

《Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask》

  • http://www.vldb.org/pvldb/vol11/p2209-kersten.pdf

《MonetDB/X100: Hyper-Pipelining Query Execution》

  • https://w6113.github.io/files/papers/monetdb-cidr05.pdf
  • 【数据库】物理计算引擎Pull模型之火山模型

 

分享大数据行业的一些前沿技术和手撕一些开源库的源代码
微信公众号名称:技术茶馆
微信公众号ID    :    Night_ZW
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值