火山模型和优化(向量化执行、编译执行)

CPU的处理特性

想要知道如何提升cpu的使用性能,就该知道现代cpu的一些特性。当前CPU主要具有以下几个特征:流水线、乱序执行、分支预测、分层存储、SIMD。

超标量流水线与乱序执行

该特性可以允许不具有依赖关系的指令并发执行,避免因为等待某个指令而阻塞运行。

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

比较常见的例子比如go语言,优化后会把没有关联的语句并发执行。go有个标语是:不要用共享内存来通信,要用通信来共享内存。因为用共享内存通信很有可能就会因为cpu的流水线并发执行给弄出奇奇怪怪的结果。

程序做好以下两个方面,可以提高超标量流水线的吞吐(IPC,每时钟周期执行指令数)。

流水线不要断,不需要等到上一条指令执行完,就可以开始执行下一条指令。这意味着程序分支越少越好(知道下一条指令在哪)。
并发指令越多越好。指令之间没有依赖,意味着更流畅的流水线执行,以及在多个流水线并发执行。

分支预测

CPU会对分支进行预测并根据预测选取下一步执行的路径,提前加载指令和数据,但如果分支预测失败,也会导致流水线中断。程序分支越少,流水线效率越高。

现在假设你是处理器,当看到上述分支时,当你并不能决定该如何往下走,该如何做?只能暂停运行,等待之前的指令运行结束。然后才能继续沿着正确地路径往下走。

要知道,现代编译器是非常复杂的,运行时有着非常长的pipelines, 减速和热启动将耗费巨量的时间。

那么,有没有好的办法可以节省这些状态切换的时间呢?你可以猜测分支的下一步走向!

  • 如果猜错了,处理器要flush掉pipelines, 回滚到之前的分支,然后重新热启动,选择另一条路径。
  • 如果猜对了,处理器不需要暂停,继续往下执行。

来使用下面代码尝试一下减少分支预测错误获得的性能提升。

package main
 
import (
	"fmt"
	"math/rand"
	"sort"
	"time"
)
 
func test1(data []int) {
	tibe := time.Now()
	s := 0
	for _, i := range data {
		if i >= 128 {
			s += i
		}
	}
	fmt.Println(time.Since(tibe))
}
 
func test2(data []int) {
	tibe := time.Now()
	s := 0
	for _, i := range data {
		s += ^((i - 128) >> 31) & i
	}
	fmt.Println(time.Since(tibe))
}
 
func main() {
	n := 10000000
	rand.Seed(time.Now().UnixNano())
	data := make([]int, n)
	for i := 0; i < n; i++ {
		data[i] = rand.Intn(256)
	}
 
    fmt.Print("未排序,分支预测多:")
	test1(data)
	fmt.Print("未排序,去掉分支预测:")
	test2(data)
 
	sort.Ints(data)
 
    fmt.Print("排序,分支预测少:")
	test1(data)
    fmt.Print("排序,去掉分支预测:")
	test2(data)
 
}

来看看结果:

很惊讶对不对,排完序减少分支预测错误能提高3倍的性能,去掉分支预测更是能提高4倍的性能。

(当然要跟环境挂钩,如果环境cpu不支持分支预测,是看不到这个提升的)

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

像火山模型中,表达式计算和节点之间传数据都存在着大量的分支预测。数据一多,肯定分支预测错误就会增多。

多级存储与数据预取

CPU周围设置了寄存器、L1/L2/L3缓存、内存和磁盘等多级存储,数据越靠近CPU,计算速度越快,反之,如果频繁地从内存或者磁盘读取数据,会导致CPU把较多的时间浪费到IO上,计算效率减低。

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

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

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

SIMD

SIMD即单指令多数据流,一次操作完成多组操作数的计算,可以进一步提高计算效率。像SIMD等新硬件提供了更强的执行能力。

单指令多数据流,对于计算密集型程序来说,可能经常会需要对大量不同的数据进行同样的运算。

SIMD引入之前,执行流程为同样的指令重复执行,每次取一条数据进行运算。

例如有8个32位整形数据都需要进行移位运行,则由一条对32位整形数据进行移位的指令重复执行8次完成。SIMD引入了一组大容量的寄存器,一个寄存器包含8*32位,可以将这8个数据按次序同时放到一个寄存器。同时,CPU新增了处理这种8*32位寄存器的指令,可以在一个指令周期内完成8个数据的位移运算。

但是simd使用要求苛刻,不光和cpu环境挂钩,还和语言类型挂钩。c++支持simd的语法,有自己的库,但像go的编译器,就不支持编译出simd语法。当然go可以通过调用c来获得simd,但那样又会引发线程调度等别的问题。

优化方向

针对CPU的这些特征,人们提出了几个数据库查询性能的优化方向:

  • 首先,可以通过向量化批量计算提高CPU流水线和乱序执行的执行效率;
  • 其次,编写CPU计算友好的程序,比如通过减少上下依赖、减少分支、预取数据到缓存等方式,让CPU集中于计算任务;
  • 最后,还可以通过SIMD来对计算密集型的简单程序进行改造,加速计算效率。

数据库执行器的发展

火山模型

介绍

早期数据库受限于硬件水平,IO、内存和CPU资源都非常昂贵,所以大多数数据库的执行器都采用的是传统的火山模型(经典的Volcano 模型)。

火山模型又称 Volcano Model 或者 Pipeline Model。

该计算模型将关系代数中每一种操作抽象为一个 Operator,将整个 SQL 构建成一个 Operator 树,从根节点到叶子结点自上而下地递归调用 next() 函数。

一般Operator的next() 接口实现分为三步:

  • 调用子节点Operator的next() 接口获取一行数据(tuple);
  • 对tuple进行Operator特定的处理(如filter 或project 等);
  • 返回处理后的tuple。

因此,查询执行时会由查询树自顶向下的调用next() 接口,数据则自底向上的被拉取处理。火山模型的这种处理方式也称为拉取执行模型(Pull Based)。

大多数关系型数据库都是使用迭代模型的,如 SQLite、MongoDB、Impala、DB2、SQLServer、Greenplum、PostgreSQL、Oracle、MySQL 等。

火山模型的优点是,处理逻辑清晰,简单,每个Operator 只要关心自己的处理逻辑即可,耦合性低。但是缺点也非常明显:数据以行为单位进行处理,不利于CPU cache 发挥作用。且每处理一行需要调用多次next() 函数,而next()为虚函数,开销大。

例如 SQL:

SELECT Id, Name, Age, (Age - 30) * 50 AS Bonus
FROM People
WHERE Age > 30

对应火山模型如下:

实非常好理解。

  • User:客户端。负责获取用户的sql,也负责发送给客户端sql的执行结果。
  • Project:垂直分割(投影),选择字段。对应于sql为:“SELECT Id, Name, Age, (Age - 30) * 50 AS Bonus”,接收子节点数据后,通过处理,得到需要返回给上层的结果值。
  • Select(或 Filter):水平分割(选择),用于过滤行,也称为谓词。对应于sql为:“WHERE Age > 30”,接收子节点数据后,过滤掉不符合条件的数据。
  • Scan:扫描数据。将数据从存储层拉到计算层。比如将People的表数据从磁盘拉到内存。对应sql为:“FROM People”

优劣

早期数据库受限于硬件水平,IO、内存和CPU资源都非常昂贵,比如计算层的数据一多,内存容易爆掉,所以火山模型采用每次只计算一行数据的方式,极大缩减了内存使用量。

前面也说了,火山模型的优点是,处理逻辑清晰,每个Operator 只要关心自己的处理逻辑即可,耦合性低。

Volcano模型简单灵活,火山模型将更多的内存资源用于IO的缓存设计而没有优化CPU的执行效率,为什么之前的数据库设计者没有去优化这方面呢?

当时的 IO 速度是远远小于 CPU 的计算速度的,那么 SQL 查询引擎的优化则会被 IO 开销所遮蔽(毕竟花费很多精力只带来 1% 场景下的速度提升意义并不大)。

这在当时的硬件基础上是很自然的权衡。

但现在今时不同往日,硬件性能大力发展,在大数据等现代环境场景下,火山模型的弊端逐渐显露。性能表现差强人意。当需要处理的数据量增大时,具有显著的缺陷。

火山模型的缺点:查询树调用 next() 接口次数太多,并且一次只取一条数据,CPU 执行效率低;而 Joins, Subqueries, Order By 等操作经常会阻塞。

执行器为了适应复杂的表达式结构,计算一条表达式往往需要引入大量的指令;对于行式执行来说,处理单条数据需要算子树重新进行指令解释(instruction interpretation),从而带来了大量的指令解释开销。

究其原因。主要有如下几点:

  • 每次 next 都是一次虚函数调用过程是被动拉数据,编译器无法对虚函数进行inline优化,同时也带来分支预测的开销,且很容易预测失败,导致CPU流水线执行混乱。
  • Volcano Style的代码对数据的局部性并不友好,往往造成cache miss。我们知道CPU cache是存储着连续数据空间,每次可以对连续数据进行集中处理,将受益最大。而Volcano模型每次调用只处理一行。

物化模型(Materialization Model)

物化模型的处理方式是:每个 operator 一次处理所有的输入,处理完之后将所有结果一次性输出。物化模型更适合 OLTP 负载,这些查询每次只访问小规模的数据,只需要少量的函数调用。

火山模型每次处理一行数据,物化模型每次处理全部的数据,虽然确实减少了大量函数调用开销,但是不可避免的会引起其他问题:

  • 一个是需要存储全部数据到内存中,很容易引起oom;
  • 另外一个是执行书的节点会强制转变成串行执行,多核下无法充分利用cpu。

向量化/批处理模型(Vectorized / Batch Model)

批处理模型是火山模型和物化模型的折衷。

鉴于火山模型每次处理一行一行数据,而next调用代价又比较高。物化模型又过于极端,有oom风险,所以批量处理模型在业界被提出。

向量化模型 和 火山模型 类似,每个 operator 需要实现一个 next() 函数,但是每次调用 next() 函数会返回一批的元组(tuples),而不是一个元组,所以向量化模型也可称为批处理模型。

在算子间传递数据不再是一条一条记录,而是一批数据,算子每次执行的时候都会在内部攒一批数据,数据大小尽可能和CPU cache对齐,不仅大大提高了cache命中率,而且有效了减少了函数调用次数。

Presto、snowflake、SQLServer、Amazon Redshift、Doris等数据库支持这种处理模式。

Spark 2.x 的 SQL 引擎开始也支持向量化执行模型。

push模型 / pull模型

考虑火山模型的每个节点。

一般来说,每个处理节点都有两个通道,一个入口,负责接收子节点的数据;一个出口,负责输出给上层节点处理后的值。

那么每量个处理节点(父子节点),都可以看做是一个生产者消费者模型。

对于消费者而言,有两种方式获取信息:

  • 推模型push:由消息中间件主动将消息推送给消费者;可以尽可能快地将消息发送给消费者,但是若消费者的处理消息的能力较弱(一条消息长时间处理),中间件会不断地向消费者push消息,消费者的缓冲区可能会溢出;
  • 拉模型pull:由消费者主动向消息中间件拉取消息;会增加消息的延迟,即消息到达消费者的时间变长。

push模型比pull模型复杂,但cpu利用率要高于pull模型。由于子算子产生的结果会直接 Push 给父算子进行操作,Push 模型的 Context switch 相对较少,对 CPU Cache 的友好性也更强。

但是使用push模型会不可避免的产生其他问题。如果使用pull模型,那么使用一个线程就可以完成整个sql的执行流程;但是换成push模型,每个节点都会自发运行往父节点推数据,那么一个sql就需要使用多个线程来完成,cpu的利用率肯定是上去了,但是如果存在高并发场景,并发执行sql量很多,那么线程数也会暴增。

所以使用需要考量适度性。

向量化执行

介绍

为了更好地符合现在cpu的特性,提高cpu的使用效率。人们提出了向量化引擎。

数据库向量化执行系统最早由论文 MonetDB/X100: Hyper-Pipelining Query Execution 提出,它有以下几个要点:

  • 采用vector-at-a-time的执行模式,即以向量(vector)为数据组织单位。可以理解为使用批量化模型。
  • 使用向量化原语(vectorization primitives)来作为向量化算子的基本单位,从而构建整个向量化执行系统。原语中避免产生分支预测。

向量化执行依然采用类似火山模型的拉取式模型,唯一的区别是其Operator的next()函数每次返回的是一批数据(如1000行,并且为了尽可能满足向量化执行的需求,节点之间传递数据一般使用的是列存的结构。

向量化执行模型有一下几个好处:

  • 大大减少火山模型中的虚函数调用数量;
  • 以块为处理单位数据,提供了cache命中率;
  • 多行并发处理,契合了CPU乱序执行与并发执行的特性。
  • 同时处理多行数据,使SIMD有了用武之地(虽然目前SIMD对大多数数据库查询起到的作用比较有限,本质上数据库查询都属于数据访问密集型应用,而不是SIMD最擅长的计算密集型应用)。

向量数据结构

以tidb来举例,以下是tidb执行器中,节点之间传递数据的结构。

可以看出,和传统的火山模型的行结构不一样,向量化引擎中更常使用的是列存数据。

使用列存可以让内存结构更加紧凑,每个Column代表一个具体的列类型,在执行函数时使用这种数据结构,可以每次只映射一次数据类型,执行大量数据,可以看成是模拟cpu的simd。

使用列存后,row不再是Object数组,而是变成一个逻辑上的结构,一个Row,代表一个chunk中所有Column相同下标的值组成的数据。

列存储中,每一列是单独存储的,这样就可以只读取需要的列。

采用列存储的好处有很多:

  • 列存储还可以通过压缩算法带来更高的压缩比;
  • 也可以通过字典里列排序或者稀疏索引加速数据的查找效率;
  • 这种列式的存储组织形式还为上层计算的性能优化提供了很大的便利,特别是在向量化查询和延迟物化等方面。延迟物化简单来说就是尽可能让赋值操作往后放,tidb中的chunk的sel数组就是干这个的。

向量化原语与向量化计算

向量化原语是向量化执行系统中的执行单位,它最大程度限制了执行期间的自由度。

原语不用关注上下文信息,也不用在运行时进行类型解析和函数调用,只需要关注传入的向量即可。它是类型特定(Type-Specific)的,即一类原语只能处理特定类型

这就更好的适配了上面的Column数据类型。

在原始的火山模型中,使用的是行存,每行会有不同的数据类型,如果实现不好,可能行中的每种数据类型都会产生一次动态绑定。改为列存后,每一列的数据都是相同的数据类型,那么就不会存在这种问题。

众所周知,sql中存在大量的复杂表达式计算。利用原语来构建表达式,以向量作为运行时数据结构。这样,每类原语处理特定类型,每列数据类型特定,就能够对应起来,将列放到向量化原语中进行执行。

每种原语仅为特定类型进行服务,从而减少了指令总数使用向量化原语,在一个循环体内部,只需要进行取值和运算即可,没有任何的分支运算和函数调用。

向量化原语带来了以下优点:

  • Type-Specific以及Tight-Loop的结构,大大减少了指令解释的开销;
  • 避免分支预测失败和虚函数调用对CPU流水线的干扰,同时也能有利于 loop pipeline 优化
  • 从向量中存取数据,有利于触发cache prefetch,减少cache miss带来的开销。

举个简单的例子,将两个数组根据op类型进行操作:

data1 []int
data2 []int
ans []int
// + - * /
op char
 
非向量化:
 
for i:=range data1{
    switch op{
    case '+':
        ans[i]=data1[i]+data2[i]
    case '-':
        ans[i]=data1[i]-data2[i]
    case '*':
        ans[i]=data1[i]*data2[i]
    case '/':
        ans[i]=data1[i]/data2[i]
    }
}
 
向量化:
 
switch op{
case '+':
    for i:=range data1{
        ans[i]=data1[i]+data2[i]
    }
case '-':
    for i:=range data1{
        ans[i]=data1[i]-data2[i]
    }
case '*':
    for i:=range data1{
        ans[i]=data1[i]*data2[i]
    }
case '/':
    for i:=range data1{
        ans[i]=data1[i]/data2[i]
    }
}

别看只是将for循环放到了if里面,只是这样,就能去掉频繁的分支预测。

这只是简单的例子,在数据库的表达式计算中,数据类型不同,操作符不同,会有更加庞大复杂的计算。

例子

直接看一个例子,下图是一个JoinOperator的编译生成的伪代码和向量化执行的伪代码的对比。JoinOperator 的执行逻辑为,以左表的数据构建Hash表,然后以右表中的每行记录,分别去Hash表查找。这里的Hash表的冲突处理采用的是链地址法,伪代码中最后一个循环就是遍历链表,找到真正的匹配项。

图中(a)部分为编译执行模型生成的伪代码。看一下图中(b)部分向量化执行的伪代码。刚刚提到,向量化执行的模式为拉取模型,每个Operator实现一个next()接口。与火山模型不同的,它一次处理一组数据。因此,可以看到这里面的变量都是Vector。由于变量为Vector,就需要事先定义一些专门处理Vector的元语(Primitives)。例如为Vector中的每一个元素计算Hash值的proheHash_,以及图中的compareKeys_、buildGather_。了解了这个,上面的伪代码也就不难看懂了。

编译执行(代码生成)

介绍

编译执行从根本上讲,提高数据库性能的方法是减少 CPU 指令数量。

当我们提到编译执行的时候到底是在讲什么呢?通常我们是先编写代码,再编译,最后运行。

而对于这里提到的编译执行,更多的是运行时期的代码生成生成技术。

在执行过程中生成编译生成执行代码,避免过多的虚函数调用和解析执行,因为在执行之初我们是知道关系代数的schema信息。

在具备Schema信息的情况下,事先生成好的代码,可以有效减少很多执行分支预测开销。这里直接参考自Impala论文给出来的代码比对。

如上图的右边的代码非常紧凑,有效消除了字段个数,字段大小,字段类型,对于数据量特别多的处理场景,可以大大减少CPU开销,提高性能。

如何生成右边的代码?业界常用的代码生成框架有ASM/LLVM IR等。但是每个表达式和算子都需要单独编译,如何减少编译开销?在这个基础上发展出来了Pipeline Compilation技术,就是将多个operator融合在一起编译,减少开销;此外还有Operator Cache技术,将事后编译好的代码cache起来,类似的查询语句可以直接复用编译好的代码,进一步减少编译开销时间。

编译执行具有以下优点:

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

推送模型

考虑到火山模型大量虚函数调用导致的性能损失,推送执行模型(Push Based)很好的解决了这个问题。与拉取模型相反,推送模型自底向上的执行,它利用LLVM生成中间语言,执行逻辑由底层Operator开始,其处理完一个tuple之后,将tuple传给上层Operator处理。

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查询树如下图左侧所示。其中的符号对应着SQL应该也能看得出来,分别为Filter、Aggregation和Join。

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

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

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

编译执行的难点在于如何把查询树编译成这样的代码执行。不像拉取模型,一个next()调用把数据传递和数据处理逻辑分的明明白白。复杂的Operator逻辑直接影响到编译执行的代码生成。

Hyper观察Operator处理数据的模式,从中抽象出了两种函数接口Produce() 和 Consume()。Produce()函数负责产生结果tuple,然后通过调用下一个Operator的Consume()函数,将tuple向上传递。Consume()函数负责具体的tuple处理逻辑。Produce() 和 Consume()只是为了代码生成引入的逻辑概念,实际上是每个Operator会根据规则拆分为两个代码块,一块对应Produce() ,一块对应consume()。代码生成的时候就可以根据这个规则生成代码。从下图可以看出Join、Filter和Scan Operator与代码块的简单对应。

当然,实际上会更加复杂。Hyper会利用LLVM直接生成其中间语言。

编译执行以数据为中心,消灭了火山模型中的大量虚函数调用开销。甚至使大部分指令执行,可以直接从寄存器取数,极大的提高了执行效率。

向量化VS编译执行

相比火山模型,向量化与编译执行都使数据库查询执行性能得到了极大的提升,这两者之间相比又如何呢。首先这两个模型是不相容的,二者只能取其一。因为编译执行强调的是推送模型,以数据为中心,在一个Pipeline内是不会有Materialization的,但是向量化执行是拉取模型,每次经过next()调用,Vector的传递必须Materialize。

编译执行融合向量化

什么?融合?不是才说这两种执行模型不相容么?说简单也简单,把查询树分解一下,部分用向量化方式,部分用编译执行方式即可。基本思想是简单,可真的做起来,还是有不少问题需要解决的。

编译执行的主要目标是减少Materialization,ROF则是在编译执行的基础上,主动在其中的Pipeline中插入Materialization,将Pipeline分割为Stage,在Stage内依然是tuple-at-a-time data-centric的推送模型,保留了编译执行数据停留在寄存器中的优点而跨Stage或Pipeline时,则以Vector(一组tuple)为单位传递数据,这个时候就可以利用上SIMD。另外,ROF还使用了Software Prefetch来优化编译执行当Hash表查找时cache命中率低Memory Stall过多的问题。

我们可以通过下面这个例子了解ROF的主要思想。

SELECT SUM(...) AS revenue
FROM LineItem JOIN Part ON l_partkey = p_partkey
WHERE (CLAUSE1) OR (CLAUSE2) OR (CLAUSE3)

上面的SQL是TPC-H Q19的简化版本。其中CLAUSE1~3分别是LineItem和Part两个表上的查询条件。这段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在编译时会生成两套执行路径,在运行时根据数据量决定是否需要预取。

猜你喜欢

转载自blog.csdn.net/qq_44766883/article/details/131353665
今日推荐