
深度干货|PolarDB分布式版基于向量化SIMD指令的探索
4. 表达式计算
Long数组相加
Vector API的最大优势就是加速计算,因此接下来我们会探索其可能能够带来性能提升的场景。首先我们对前文中给出的Long数组相加的场景进行了Benchmark,可以看到在数组相加场景下标量执行和SIMD执行相差不大,通过对汇编指令的追踪,我们发现不论是SIMD执行还是标量执行最终都会生成vpaddq这条指令:
1. SIMD执行
2. 标量执行
vpaddq:AVX512指令集。将(%r11 + %rbx * 8)开始的16个字节的数据和ymm3寄存器中的数据相加,写到ymm3寄存器中。
自动向量化(auto-vectorization)
我们发现,哪怕没有显示的使用Vector API,向量化加法的代码也会被进行向量化,这源于Java的自动向量化(auto-vectorization)机制。Java自动向量化的实现与Vector API类似,其会在JIT编译的时候检查代码能否使用SIMD指令进行运算,如果可以即替换为SIMD实现。但是自动向量化仅能处理比较简单的计算,对于复杂计算仍然需要手动SIMD(使用Vector API)。例如自动向量化已知的限制有:
1. 只支持自增的for循环
2. 只支持Int/Long类型(Short/Byte/Char 通过int间接支持)
3. 循环的上限必须是常量
通过对一些成熟OLAP系统(例如ClickHouse)的调研,以及我们线上实际场景的探索,我们使用Vector API重新实现了一批算子,这里我们将会展示4种比较有代表性的场景。
5. 大小写转化
使用SIMD实现大小写转化的思路比较简单,我们只需要调用compare方法进行比较,使用lanewise方法进行异或即可。这里引入了VectorMask,可以理解为一个boolean类型的寄存器,里面含有n个0/1。
Benchmark
测试环境:随机生成1000和10w个byte类型的字母来进行Benchmark
测试结果:在count=10w的场景下快了50x,原因在于ByteSpecies的长度为32,同时SIMD的执行方式没有分支预测失败flush流水线的开销。
6. SIMD Filter
使用SIMD指令实现Filter可以使用Gather运算读取数组中的元素,并使用compare方法进行比较,最后采用位运算的方式记录下满足条件的下标。
Benchmark
测试环境:随机生成1000和10w个int类型的整数来进行Benchmark
结果分析:在count=1000时SIMD的性能反而下降,这是因为此时函数并没有JIT,而是采用了JDK层面的标量执行。在count=10w时方法已经被JIT,因此性能会有25%的提升。
7. Local Exchange
什么是Local Exchange算子
Exchange算子是PolarDB分布式版MPP执行模式中进行数据shuffle的重要组件,关于其背景可以参考文章《PolarDB-X并行计算框架》[3],这里不再赘述。在这里,我们主要来优化Local Exchange算子中的LocalPartitionExchanger执行模式。本次实践中,我们通过向量化算子 + SIMD Scatter指令的方式实现了35%的性能提升。
LocalPartitionExchanger算子可以简单理解为: 对于某个下游pipline中的driver,其会对Chunk中的每行数据计算对应的partition分区,并将相同partition分区的数据重新组装为一个新的Chunk喂给上游driver。
对于这一段表述不理解的话也没关系,本文的重点在于介绍使用SIMD指令优化代码逻辑的方式,因此可以放心继续阅读下文。
非向量化版本(PolarDB分布式版现有版本)
PolarDB分布式版现有版本沿用了行存执行模型下的Local Exchange算子,其基本思想是row by row的逐行枚举、逐行计算partition并写入对应的Chunk,这一执行模式的缺点在于其既不能有效的利用列式内存布局,同时appendTo操作会带来大量的时间开销。
旧版本的详细执行流程为:
1. 计算position(行号)对应的partition:使用了n个链表来保存每个partition对应的position list
a. (partitionGenerator.getPartition可以简单的理解为对position对应的数据进行hash运算得到目标partition的位置)
2. buildChunk:生成对应partition的Chunk
a. 先枚举partition
i. 枚举partition对应的position(position表示行)
1. 枚举该行对应的列block
a. 调用builder的appendTo来为ChunkBuilder添加元素
b. appendTo有一次虚函数调用,性能开销极大!
Local Exchange SIMD优化
思路:
1. 对appendTo的优化: appendTo操作开销极大,尝试用更高效的方法拷贝数据, 例如system.arrayCopy
2. 对行式枚举模式的优化:逐行枚举的方式受限,因为每一列的数据在不同的Block内,不可能攒批copy。同时逐行枚举的方式对访存不友好。那么考虑先枚举列。
● 问题: 每一个partition对应的行并不连续;
● 解决方案:如下图所示,我们希望求出positionMapping数组使得相同partition的行能够被迁移到连续的行中,这样以来就可以使用Scatter运算进行加速。
3. 问题:如何知道positionMapping?
● 其实很简单,线性扫描一遍,记录下每个partition的size, 记为partitoinSize[]
● 知道了size,就可以求出每个partition在最终数组中的偏移量paritionOffset[]
● 已知offset就可以边遍历边求出positionMapping:只需要记录出该行对应的partition的offset,然后让offset自增即可。这一过程看代码会更直观。
具体步骤
1. 计算positionMapping[]
● 计算每个位置对应的partition以及每个partition的size
● 计算每个partition对应的offset
● 计算positionMapping
2. 使用scatter运算
● vectorizedSIMDScatterAppendTo中会判断该Block是否支持SIMD执行,并调用Block的copyPositions_scatter_simd方法,这里以IntBlock为例:
3. 使用writeBatchInts来实现内存拷贝
addElements的底层使用了System.arraycopy命令来批量的进行内存复制 System.arraycopy是JVM的内置函数,其实现效率远远快于调用append接口来逐个添加数据。
Benchmark
测试环境:我们设置parition的数量为3,并向Local Exchange算子输入100个大小为1024,包含4列int的Chunk来进行Benchmark
结果分析:
我们发现单纯的向量化算法并不会有性能提升,原因在于Local Exchange算子的瓶颈在于appendTo操作。而SIMD Scatter + System.arrayCopy的方式实现了35%的性能提升。
8. SIMD Hash Join
SIMD思路
在CMU 15-721中提到了SIMD Hash Probe的实现,我们将用Vector API来复现这一过程。
首先来回顾开放地址法的SIMD Hash Probe过程,其基本思路是一次性对4个位置的元素进行probe。
1. 计算出4个位置的hash值
2. 通过Gather运算读取到hash表中对应位置的元素
3. 通过SIMD的compare运算比较有无hash冲突
4. 前3步都比较好理解,接下来我们需要让有hash冲突的元素寻址到下一个位置继续解hash冲突,这一步可以由SIMD加法来实现。重点在于如何让没有冲突的元素移走,把数组中后面的元素放进来继续匹配呢?(例如我们希望把下图中的k1, k4分别替换为k5, k6)
解决方案:
a. 使用expand运算来进行selective load运算
b. expand运算可以将probeMask当中为1的n个位置元素替换为hashPosition数组从probeOffset位置开始的n个元素。
1. 例如在上面的例子中数据的变化为
2. hashPosition = [h1, h2, h3, h4, h5, h6, h7, h8.....]
3. probeOffset = 4
4. probeMask = [1, 0, 0, 1]
5. 入参: [h1, h2, h3, h4]
6. 出参: [h5, h2, h3, h6]
小插曲: 如何实现更强大的expand
在上述代码中,我们的expand运算传入了3个参数,但是openJDK官方的expand函数最多有一个入参(openJDK的expand用法不能传入数组,只能在Vector之间做expand)
这得益于阿里云强大的自研能力,我们与阿里云JVM团队进行了积极的沟通,JVM团队的同学帮助我们实现了更丰富的expand的运算。
验证
PolarDB分布式版的Hash方式并不是开放寻址法,而是布谷鸟Hash,但其SIMD的原理类似,这里给出对布谷鸟Hash的SIMD Hash Probe改造过程。
Benchmark
测试环境:在Build端我们向Hash表中插入了100w个大小在0-1000范围的元素来模拟Hash冲突,Probe时计算探测100w个元素所需要的时间。
测试结果:虽然我们实现了SIMD Hash Probe,但由于现有的Vector API对类型的支持并不充分(代码中含有大量的类型转化),因此实测结果并不优秀,甚至有2倍多的性能下降。
但抛开Java Vector API本身带来的性能下降,使用SIMD指令来优化Hash Probe也许并不是明智之举,这是因为Hash Join的瓶颈不在于计算,而在于访存。《Improving hash join performance through prefetching》写到数据库的Hash Join算法有73%的开销在CPU cache miss上,这也解释了SIMD指令没有优化的原因。
在PolarDB分布式版内部对TPC-H Q9的测试中发现浪费在cache miss上的CPU Cycle达到了计算的10倍之多,因此我们将对Hash Join的优化转移到了cache miss。PolarDB-X已经尝试使用prefetch指令预取(由阿里云JVM团队提供对Java的增强),向量化Hash Probe等方式优化cache miss,相关的优化成果会在后续的文章中展示。
9. 总结
本篇文章我们首先介绍了Vector API的用法与实现原理,并着重探索了其在数据库场景下的应用,在以计算为瓶颈的大小写转化中实现了50倍的性能提升,在Filter算子中实现了25%的性能提升,在Local Exchange算子中实现了35%的性能提升。同时我们也讨论了Vector API在Hash Probe这种以cache miss为瓶颈的算子中的局限性。
云原生数据库PolarDB分布式版作为一款分布式HTAP数据库,AP引擎的性能优化一直是我们的重点工作内容。我们不仅仅着眼于业内的常见优化,对于行列混存架构、向量化SIMD指令等无人涉及的“深水区”也在积极的探索当中。
敬请期待后续PolarDB分布式版列存引擎在公有云和开源的正式发布。
参考文章:
[1] PolarDB-X 向量化执行引擎:https://zhuanlan.zhihu.com/p/337574939
[2] PolarDB-X 向量化引擎:https://zhuanlan.zhihu.com/p/339514444
[3] PolarDB-X 并行计算框架:https://zhuanlan.zhihu.com/p/346320114
文章转载自公众号:阿里云瑶池数据库
