Loading

MonetDB/X100:超流水线查询引擎

原文:MonetDB/X100: Hyper-Pipelining Query Execution
这篇论文于2005年发表,当时超标量、高度流水线化、支持分支预测等功能的CPU慢慢成为主流,而数据库领域的通用开发技术导致编译器无法生成能够高度利用这些功能的代码,导致IPC过低。这篇论文主要探讨和解决这个问题。

CPU工作原理

现代CPU通常配备了很多巧妙的优化,下面介绍的是常见的一些优化技术

流水线化执行

逻辑上,CPU给上层的接口就是一个确定性的状态机,它执行一个指令,转换状态,执行下一个指令。

但是,一条CPU指令实际上可能存在多个阶段,比如:取指令、译码、访问内存、运算、回写内存等,实际上它们是由不同的电路部分完成的。

流水线化的基本思路就是:在一个指令完成阶段A进入下一个阶段B时,便可以让下一个指令进入阶段A,以让这些独立的电路部分一直保持忙碌

当然,若指令2依赖指令1,则指令2的某些阶段可能必须要在指令1完成某些阶段后才能进行。

分支预测

想象:

if (a) {
    t_instruction_1;
    t_instruction_2;
    ...
} else {
    f_instruction_1;
    f_instruction_2;
    ...
}

分支场景会极大程度阻塞流水线,因为只有当条件a确定了,才知道下一条指令是什么,是t_instruction_1、还是f_instruction_1?也就是说必须当条件a已经有了实际的计算结果才能进行下一条指令的取指阶段,否则流水线中的前面部分都只能空置。

为了解决这一问题,CPU会做出假设,它在知道a的结果之前,先假设a的结果,然后直接进行对应分支的取指阶段,保证流水线满。

若分支预测失败,则CPU需要取消错误的指令执行所带来的影响,并重新选择另一条分支执行。

猜对了,充分压榨了硬件资源、猜错了,会比非流水线的情况更慢。

基于上面这些讨论,我们知道了CPU喜欢稳定的分支,即连续多次执行结果不会变的分支

下面的伪代码是一个例子,加不加array.sort()这一行成了决定性能的关键,如果不加,数据分布随机,分支结果也是随机的,CPU无法有效预测分支结果。

array.sort() # 重要的一行

for i in array:
    if i >= 128:
        do_something();

对于数据库系统来说,最常见的分支处理就是顺序扫描中的filter操作,这种场景几乎无法正确预测。而且数据库系统支持大量的数据类型,在执行任何操作前,必须检查值类型,这通常使用巨大的switch语句完成,其对CPU不友好。

超标量(super-scalar)

所谓超标量就是CPU在一个核心上有多条流水线,只要指令之间无关,CPU便可以同时把它们放到推入多个流水线上,进一步增加效率。

占满CPU吞吐量的核心

假设:

  1. CPU A:有6个流水线,指令被拆分为7个阶段,为了达到理论最大性能,在任何时候至少有\(6\times 7=42\)个无关指令
  2. CPU B:有3个流水线,有31个执行阶段,为了达到理论最大性能,在任何时候至少有\(31 \times 3 = 93\)个独立指令

编译器优化

识别程序中哪些操作是独立的,并给出最好的执行安排,这是编译器的工作,而非程序员的。下面用循环流水线化优化来举例

假设有这样的执行序列:

for i in array:
    F(i)
    G(i)

对数组中的每个元素,执行一系列有依赖的操作,编译器可以安全的做这样的转换:

F(0) G(0) F(1) G(1) F(2) G(2)
->
F(0) F(1) F(2) G(0) G(1) G(2)

如果F的流水线延迟为2个周期,如果不执行这样的重排,G就必须等待这个延迟后才能执行,因为它们是依赖的。而将G排到两个周期后,便可以让它直接拿到结果,同时,让后续的F利用这段时间执行。

缓存

虽然程序访问内存在50ns内可以完成,但转换成一个3.6GHzCPU的等待周期,就变成了惊人的180个。

CPU的片上缓存命中率对性能极为重要。现代DBMS的设计应该使用缓存敏感的数据结构,如按列存储等(经常一起访问的数据放在一起)。

SIMD

即单指令,多数据。

现代CPU通常提供了一系列SIMD指令,它们可以利用某些专用硬件部分对一批数据进行一次性运算,常用于数组计算。

假设对这个数组进行计算:

[1, 2, 3, 4, 5, 6, 7, 8]

用传统的SISD(单指令单数据),对于数组的每个元素都要使用一条指令来计算。SIMD允许对数组中的多个元素进行硬件级别的一次性计算(如前4个,通常看CPU提供多少bit),加快计算速度,减少指令数量。

列式存储往往能很好的应用SIMD。

微基准:TPC-H查询1

这里首先选用了一个仅针对表达式计算的benchmark来对各种RDBMS、手写代码以及MonetDB/X100进行性能测试。

TCP-H benchmark在1GB数据仓上操作,可以通过一个扩展因子SF来增加大小。

我们的查询1在lineitem表中的\(SF\times 6M\)个元组上进行扫描,选择几乎所有元组(\(SF\times 5.9M\)),然后计算一些定点decimal表达式:两个列-常量的减法,一个列-常量加法,三个列-列乘法,以及八个聚合函数(4个SUM()、三个AVG()以及一个COUNT()。聚合分组在两个单字符列上,只有4个唯一组合,以让它可以在一个小hash-table上完成,不需要额外I/O甚至不存在CPU缓存丢失(对于访问hash-table)。

img

在关系型数据库上的查询1

img

早期的RDBMS通常遵循Volcano模型(火山模型、管道模型...)实现一个物理的关系代数。其具在参数层面具有高度的自由度,举个例子,一个简单的ScanSelect(R, b, P)只有在查询时才能接收到全部的知识:输入关系的格式R(列数、类型、记录偏移);布尔选择表达式b;用于定义输出关系的投影表达式列表P(每一个都可能很复杂)。为了处理所有可能的RbP,DBMS实现者必须实现一个表达式解释器以处理任意复杂的表达式。

这种解释器的风险是,真正的工作(执行查询中的这些表达式)往往只占查询执行总消耗的微小部分。表2中展示了MySQL 4.1在SF=1的数据集上执行TPC-H查询1时的gprof跟踪。第二列是例程的总执行时间百分比;第一列是累计秒数;第三列是例程被调用了多少次;第四第五列展示了每一个调用中平均每周期指令执行数量,也就是达到的IPC。

img

第一个观察是执行全部工作的五个操作(标粗体的,用于实现聚合函数)仅仅只占用了10%的总执行时间,仔细检查发现,28%的执行时间被创建、查找聚合要用到的hash-table占据。剩下62%的执行时间分散在如rec_get_nth_field这样的函数上,它们会遍历MySQL的记录结构,并且将数据从中拿出或写入其中。如锁消耗(pthread_mutex_unlockmutex_test_and_set)或buffer page分配(buf_frame_align)等其它因素看起来在这个决策支持查询中微不足道。

第二个观察是与查询中的计算工作对应的Item操作的成本。举个例子,Item_func_plus::val在每一个加法操作中花费了38个指令。此次性能追踪是在一台装载了MIPS R12000 CPU^3的SGI机器上执行的,每个周期可以执行3个整数或浮点数指令以及一个load/store指令,一个简单的算术操作+(double src1, double src2): double在RISC指令中应该是这样的:

LOAD src1, reg1
LOAD src2, reg2
ADD reg1, reg2, reg3
STOR dst, reg3

这段代码中的限制因素是这三个load/store命令,因此,MIPS处理器需要在3个周期执行一个+(double, double),这和MySQL的花费有着严重的反差!#ins/Instruction-Per-Cycle=38/0.8=49。对于这种高花费的一个解释是循环流水线化的缺失。它们一次调用只处理一个加法操作,这个加法操作由4个相互依赖的命令组成,它们必须互相等待。如果MySQL利用数组,一次性处理多个加法操作,编译器就可以进行循环流水线化优化,以让流水线始终处于忙碌状态。如果平均指令延迟是5个周期,这消耗了大概20个周期,49个周期中余下的是跳进例程(function call)以及随之而来的push、pop栈操作。

MySQL的tuple-at-a-time的表达式执行策略的结果是双重的:

  • Item_func_plus::val只执行一个加法,阻止了编译器创建流水线化循环。一个操作的多条指令高度依赖,空流水线槽必须等待指令延迟。
  • 无法将例程调用的成本摊销,对于每一个操作都要消耗一遍,而且这个成本几乎将操作的成本double了

我们也在一个知名的商业RDBMS中测试了相同的查询,由于我们缺乏这个产品的源代码,所以无法生成一个gprof跟踪,然而,查询执行成本与MySQL非常相似。

MonetDB/MIL上的查询1

译者:MonetDB/MIL并非本篇论文要介绍的X100,MIL使用物化模型,虽然CPU效能高,但具有占用带宽高的问题。X100使用向量模型来解决这个问题。

我们团队开发的MonetDB系统使用了垂直碎片存储,以列方式存储表,每一个列都在一个二元关联表(Binary Association Table, BAT)中。BAT包含[oid, value]的组合,它是一个2列表,左列被称作head,右列被称作tail,MonetDB的代数查询语言是一个被称作MIL的列代数。

与关系代数不同,MIL代数没有任何程度的自由度,它的代数操作父具有一个固定数量的参数,并且这些参数有着固定的格式(所有都是2列表或常量)。被一个操作符计算的表达式时固定的,对于结果的形状也一样。举个例子,MIL的join(BAT[tl, te] A, BAT[te, tr] B) : BAT[tl, tr]是一个对于A上的tail列和B上的head列的等值连接,对于每一个匹配的元组组合,返回A的head和B的tail。如果想基于A的其它列(如head)来join,可以使用MIL的reverse(A)操作符,会返回A上的,列被交换过的视图:BAT[te, tl],这个reverse是一个零成本操作,仅仅是交换一些指针。在MIL中,复杂表达式必须使用多个语句执行,比如:extprice * (1 - tax)需变成tmp1 := [-](1, tax); tmp2 := [*](extprice, tmp1)[+][*]用于将一个函数映射到整个BAT上。MIL以列方式运行,始终消费一系列BAT的物化输入,并且输出一个物化BAT。

我们使用MonetDB/MIL SQL前端来将TPC-H的查询1转换成MIL并运行它。表3展示了所有20个MIL调用占用了大约99%的查询时间!

img

在查询1上,MonetDB/MIL明显快于同一台机器上的MySQL以及其它商业DBMS。而且,表3展示几乎所有的MIL操作都是内存受限而非CPU受限的。

列2和列4列出了每个MIL操作达到的带宽(MB/),算了BAT输入以及产生的输出。在SF=1时,MonetDB获取了500MB/s的带宽,达到该硬件的最大带宽了。当在SF=0.001时,完全运行在CPU缓存中,带宽达到了1.5GB/s之上。对于多重乘法(multiplexed multiplication)[*](),带宽仅为500MB/s,意味着每秒处理20M个元组(16字节输入,8字节输出),在我们的1533MHz CPU上每次乘法需要75个时钟周期,这甚至比MySQL更慢。

因此,MIL的column-at-a-time策略是一把双刃剑。优势是,MonetDB不会花费90%的查询执行时间在tuple-at-a-time的解释消耗上。由于执行表达式计算工作的多重操作运行在整个BAT上(布局信息在编译时就知道了的数组),编译器能够实施循环流水线优化,实现高CPU效率。

然而,我们也发现了完全物化的下列问题。首先,在大量元组上,包含复杂计算表达式的查询会为表达式中每一个函数物化整个结果列,通常,这些函数结果在查询结果中是不需要的,只是为了作为表达式中其它函数的输入。例如,如果聚合函数是最上层的运算符,最终结果的大小应该是很小的。在这种情况下,MIL物化了比严格需要的更多的数据,导致了它的高带宽消耗。

而且,查询1从6M的元组数据中选择98%的数据,并在剩下的5.9M的元组上执行聚合,MonetDB使用六个join()将相关结果列物化,这些join在Volcano-like的管道执行模型中不需要,它们可以在一趟中做查询、计算以及聚合,不需要物化任何数据。

译者:早期的RDBMS通常使用Volcano模型,其优势就是管道化执行,最顶层节点调用Next(),数据就从底层向上依次流通,但缺点是一次只处理一个元组。物化模型就是MIL使用的模型,其优势就是CPU效率高,但是需要物化大量结果占用带宽。后来另一种广泛被OLAP系统使用的模型应该是向量模型,也是X100使用的模型,其一次处理多个元组(通常是列式数据库中的列),CPU效率高,且无需物化。

虽然本篇论文聚焦在主存场景的CPU效率,但是我们也指出,MonetDB/MIL生成了人工的高带宽占用,使得它很难扩展到基于磁盘的系统。

X100: 向量化查询处理器

X100的目标:

  1. 以高CPU效率执行大量查询
  2. 可以扩展到数据挖掘、多媒体检索等应用领域,并在可扩展代码上保持同样高的效率
  3. 以最低层次存储结构(磁盘)的大小扩展

为了达到这些目标,X100必须在整个计算机体系中与瓶颈作斗争:

磁盘:X100的ColumnBM I/O子系统旨在实现高效的顺序数据访问。为了减少带宽消耗,它使用垂直碎片(vertically fragmented)数据布局,在某些情况下增项了轻量级数据压缩

RAM:与I/O类似,RAM访问是通过显式的内存到缓存、缓存到内存例程执行的(这包含平台相关优化,比如SSE预取以及数据移动汇编指令)。相同的垂直分区甚至磁盘数据压缩布局也被用在RAM中以节省空间和带宽

缓存:我们使用一个Volcano-like执行管道,基于一种向量化的处理模型。称作“向量”的小的(如1000个值)的缓存驻留数据项的垂直块,是X100执行原语的操作单元。CPU缓存是唯一的带宽无关的地方,因此,(解)压缩发生在RAM和缓存的边界上。X100查询处理操作符应具有缓存意识,能够将大型数据集高效的分到缓存块中,并在此执行随机数据访问

CPU:向量化基本元素向编译器暴露了当前元组的处理与上一个和下一个元组无关,向量基本元(vectorized primitives)对于投影(表达式计算)可以轻松做到这点,但是我们正在尝试对于其它查询处理操作符也达到相同的目标(如聚合)。这允许编译器生成高效的循环管道化代码。为了进一步增强CPU吞吐量(主要通过减少load/store的数量),X100包含用于将为整个表达式子树而非单一函数编译向量化基元的基础设施,当前它是静态的,但是他最终将变成一个被优化器管理的运行时活动。

译者:关键点,列式存储,结构缓存友好,操作符实现时考虑缓存效能,使用Volcano类似的向量化模型,达到数据批处理以及编译器可以应用的循环管道优化,貌似还有某种动态编译技术来进一步减少分支数?避免分支预测失误?

img

ColumnBM缓冲管理器仍在开发,且磁盘并非本文的主要关注对象,所以本文使用MonetDB的存储管理器,它将操作内存BATs。

查询语言

X100使用相当标准的关系代数作为查询语言,我们从MIL语言中舍弃了column-at-a-time所以关系操作符可以一次性处理多个列(的向量),允许使用一个表达式产生的向量作为另一个的输入,同时数据在CPU缓存中。

示例

下图是使用X100关系代数语法表示的TPC-H查询1的简化版本:

1. 扫描lineitem表
2. 根据shipdate过滤
3. 利用已有的俩列计算discountprice
4. 根据returnflag分组
5. 组内根据discountprice聚合计算



Aggr(
    Project(
        Select(
            Table(lineitem), < (shipdate, date('1998-09-03))
        ),
        [discountprice = *( -(flt('1.0), discount), extendedprice)]
    ),
    [returnflag],
    [sum_disc_pric = sum(discountprice)]
)

执行过程在向量粒度(如1000个值)上使用Volcano-like流水线,Scan操作符从Monet Bat中以vector-at-a-time方式检索数据,注意只有与查询相关的那些属性被真正扫描到了。

第二个步骤是Select操作符,创建一个选择向量(selection-vector),与我们的谓词匹配的元组将会被填充进去。然后Project操作符被执行来计算最终聚合所需的表达式。选择向量带入map-基元以只对相关元组执行计算,将结果写出到输出向量中与输入相同位置。需要将选择向量传递到最终的Aggr中,因此对于每一个元组,其在hash表中的位置应该被计算,然后,使用这个数据,聚合结果得到更新,分组属性值被保存,一旦底层操作符耗尽,无法产生更多向量,hash表的内容就会作为查询结果使用。

img

X100代数

下图列出了当前支持的X100代数操作符

img

在X100代数中,Table是一个物化关系,Dataflow只包含在流水线间流动的元组。

OrderTopNSelect返回一个与输入形状相同的Dataflow,其它操作符定义一个新形状的Dataflow。该代数的一些特殊性是——Project只用于表达式计算,它不消除重复,可以使用只有groupby列的Aggr来进行重复消除。

通过三个物理操作符来支持聚合:

  1. 直接聚合
  2. hash聚合
  3. 排序聚合

当全部分组成员将依次从到达Dataflow时,会选用后者。直接聚合可以被用于小型数据类型,它们的位表示被限定在一个已知的小域中,类似于在手写代码中解决聚合的方式。在所有其它情况下,Hash聚合被使用。

X100当前仅支持左深join。默认的物理实现是一个CartProd操作符,上面再加一个Select(比如嵌套循环join)。如果X100在join条件中检测到了外键条件,并且join-index可用,它便会利用Fetch1JoinFetchNJoin

向量化基元

使用面向列的向量布局的主要原因不是优化缓存中的内存布局(但无论如何X100支持在缓存数据上操作),而是向量化执行基元具有低自由度的优势。在垂直碎片的数据模型中,执行基元只了解它们当前正在操作的列,不需要知道全表的布局(如数据偏移)。当编译X100时,C编译器看到X100的向量基元操作符在受限(独立)的固定形状数组上操作,这允许它应用基金的循环流水线化优化,这是现代CPU性能的关键。作为示例,我们展示向量化浮点数相加(生成的)代码:

img

译者:sel可以不看,这是X100所有向量化基元都允许传入的一个参数,用于选择原始数组的位置。可以直接看!sel的分支。

X100包含数百个向量化基元,它们不是手写的,是从基元pattern生成的,addition的基元pattern是:
any::1 + (any::1 x, any::1 y) plus = x + y

数据存储

TPC-H实验

见原文

未完...

posted @ 2025-05-16 08:33  于花花  阅读(41)  评论(0)    收藏  举报