向量化执行引擎是怎么玩的?

在比较前沿的数据库中,比如cilckhouse,polar-x,TDSQL,都提到了一个比较新的词汇,叫向量化执行引擎。

clickhouse
polarDB-X
tdsql-A

向量化执行引擎似乎已经成为了主流数据库的版本之子。

那么向量化执行引擎是什么东西,做了哪些优化,能有什么收益呢?我决定来分析一下。

传统数据库执行器

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

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

该计算模型将关系代数中每一种操作抽象为一个 Operator,将整个 SQL 构建成一个 Operator 树,从根节点到叶子结点自上而下地递归调用 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 可以单独抽象实现、不需要关心其他 Operator 的逻辑。

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

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

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

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

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

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

据论文 MonetDB/X100: Hyper-Pipelining Query Execution 统计,在MySQL执行TPC-H测试集的 Query1 时,指令解释就耗费了90%的执行时间。 

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

  • 每次 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等数据库支持这种处理模式。

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

push模型 / pull模型

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

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

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

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

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

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

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

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

CPU的处理特性

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

超标量流水线与乱序执行

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

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

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

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

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

分支预测

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来对计算密集型的简单程序进行改造,加速计算效率。

编译执行和向量化执行

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

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

  1. 采用vector-at-a-time的执行模式,即以向量(vector)为数据组织单位。可以理解为使用批量化模型。
  2. 使用向量化原语(vectorization primitives)来作为向量化算子的基本单位,从而构建整个向量化执行系统。原语中避免产生分支预测。
  3. 使用code generation(代码生成)技术来解决静态类型带来的code explosion(代码爆炸)问题。

向量数据结构

为了尽可能满足向量化执行的需求,节点之间传递数据一般使用的是列存的结构。

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

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

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

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

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

采用列存储的好处有很多,列存储还可以通过压缩算法带来更高的压缩比,也可以通过字典里列排序或者稀疏索引加速数据的查找效率。

另外,这种列式的存储组织形式还为上层计算的性能优化提供了很大的便利,特别是在向量化查询和延迟物化等方面。

延迟物化简单来说就是尽可能让赋值操作往后放,tidb中的chunk的sel数组就是干这个的。

向量化原语与向量化计算

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

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

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

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

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

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

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

  1. Type-Specific以及Tight-Loop的结构,大大减少了指令解释的开销;
  2. 避免分支预测失败和虚函数调用对CPU流水线的干扰,同时也能有利于 loop pipeline 优化
  3. 从向量中存取数据,有利于触发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里面,只是这样,就能去掉频繁的分支预测。

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

 

代码生成技术

除了向量化执行,另外一个优化的方法是编译执行,也叫代码生成技术。

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

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

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

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

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

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

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

编译执行具有以下优点:

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

在实际场景中我们要考虑得更多一些,比如在 SQL 很大时,是否会造成编译(生成代码)过慢呢?相比火山模型,编译执行和向量化都使数据库查询执行性能得到了极大的提升,这两者之间相比又如何呢?这些只能实际情况实际分析了。

这里只能说,使用编译执行的更多的是内存数据库,例如HyPer、MemSQL、Hekaton、Impala、Spark Tungsten等。

polar-X也说自己用了代码生成技术,但是他的代码生成技术和这个编译执行还不一样。polarX是在编译阶段,基于原语模板批量生成全类型组合的代码,然后和其他代码块一起进行编译打包。不是使用llVM,在执行阶段做编译生成代码。

参考

PolarDB-X 向量化执行引擎(1) - 知乎

PolarDB-X 向量化引擎(2) - 知乎

分支预测(Branch Prediction)问题与分析_我爱学习 学习使我快乐-CSDN博客_分支预测错误

干货帖 | TDSQL-A核心架构揭秘 - 云+社区 - 腾讯云

「分布式技术专题」三种常见的数据库查询引擎执行模型_mb601ce0d29b15f的技术博客_51CTO博客
PolarDB-X 面向 HTAP 的混合执行器 - 知乎

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Aiky哇

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

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

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

打赏作者

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

抵扣说明:

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

余额充值