【连载】openGauss 执行器技术
(1)初始化阶段。在这个阶段执行器会完成一些初始化工作,通常的做法是遍历整个执行树,根据每个算子的不同特征进行初始化执行。比如 HashJoin 这个算子,在这个阶段会进行 Hash 表的初始化,主要是内存的分配。
(2)执行阶段。这个阶段是执行器最重要的部分。在这个阶段,执行器完成对于执行树的迭代(Pipeline)遍历,通过从磁盘读取数据,根据执行树的具体逻辑完成查询语义。
(3)清理阶段。因为执行器在初始化阶段向系统申请了资源,所以在这个阶段要完成对资源的清理。比如在 HashJoin 初始化时对 Hash 表内存申请的释放。
(1)扫描内表元组,根据连接键计算哈希值,并插入到哈希表中根据哈希值计算出来的槽位上。在这个步骤中,系统会反复读取内表元组直到把内表读取完,并将哈希表构建出来。
(2)扫描外表元组,根据连接键计算哈希值,直接查找哈希表进行连接操作,并将 结果输出,在这个步骤中,系统会反复读取外表直到外表读取完毕,这个时候连接的结 果也将全部输出。
上面提到,如果当前的内表元组无法全部放在内存里,会进行下盘(写入磁盘)操作,HashJoin对于下盘支持的设计思想非常精妙,采用了典型的分而治之的算法。
(3)根据内表和外表的键值的哈希值,对内表和外表进行分区,经过分区之后,内表和外表被划分成很多小的内、外表,这里的划分原则是以相同的哈希值分区之后数据要划分到相同下标的内、外表中,同时内表的数据要能够存放在内存里。
(4)取相同下标的内、外表,重复步骤(1)和(2)中的算法进行元组输出。
(5)重复步骤(4)的操作,直到处理完所有的经过分区后的内、外表。
(1)过滤:根据表达式的逻辑,过滤掉不符合规则的数据。
(2)投影:根据表达式的逻辑,对数据流进行表达式变换,产生新的数据。表达式计算的核心是对表达式树的遍历和计算,前面说到算子也是用树来表达执行计划。树这个基础的数据结构在执行器的流程中扮演了非常重要的角色。
SQL2:select w_id from warehouse where 2*w_tax + 0.9 > 1 and w_city != ‘Beijing’;
(1)根节点11 代表一个 AND 运算符,AND 逻辑是只要有一个子树的结果为false,则提前终止运算,否则进行下一个子树运算。下面有两个子表达式,先处理节点9,首先递归遍历到其子节点3。
(2)节点3代表了一个乘法,有两个子节点1、2,从节点1列中取得w_tax的值,从节点2中取得定值2,然后进行乘法运算,计算数据存储到节点3引擎的暂存空间中。
(3)节点5代表一个加法运算,有两个子节点3、4,因此从节点4上取定值0.9,表达式3的结果刚才在第(2)步中已经计算了,只需要读取出来,运算结果存储到节点5的暂存空间里。
(4)节点9代表一个比较运算,其有两个子节点5、6,因此将节点5存储的数据和节点6上的定值数据1进行大于比较,如果结果为false,则提前终止当前的表达式运算, 跳入下一行,重新从步骤(1)开始计算,如果为true,则进行下一个子表达式的计算。
(5)节点9已经处理完毕,接着处理节点10。
(6)节点10代表字符串不等于比较运算,有两个子节点7、8,从节点7中取得 w_city值,同时从节点8中取得定值字符串“Beijing”,然后进行不等于字符串比较运算,如果为true,输出元组(Tuple),否则重新从步骤(1)开始计算。
(1)函数调用:函数调用过程中需要维护参数和返回地址在栈帧的管理,处理完成之后还要返回到之前的栈帧,因此在用户的函数调用过程中,CPU 要消耗额外的指令进行函数调用上下文的维护。
(2)分支预测:指令在现代 CPU 中以流水线运行,当处理器遇到分支条件跳转指令时,通常不能确定执行哪个分支,因此处理器采用分支预测来预测每条跳转指令是否会执行。如果猜测准确,那么流水线中就会充满指令;如果对跳转猜测错误,那么就要求处理器丢掉它这个跳转指令后的所有已做的操作,然后再开始用从正确位置起始的指令去填充流水线。可以看到,这种预测错误会导致很严重的性能惩罚,即会导致20~40个时钟周期的浪费,从而导致 CPU 性能严重下降。提速方式有两种:一种是更准确的智能预测,但是无论多么准确,总会存在误判;另一种就是从根本上消除分支。
(3)CPU 存取数据:CPU 对于数据的存取存在鲜明的层次关系,CPU 在寄存器、CPU 高速缓存(CACHE)、内存中的存取速度依次越来越慢,所承载的容量却越来越大。同时,CPU 在访问数据的时候也会遵循从快到慢的原则,比如缓存中找不到的数据才会从内存中找,而这两者的访问速度差距在两个数量级。如果 CPU 的访问模式是线性的(比如访问数组),CPU 会主动将后续的内存地址预加载到缓存,这就是 CPU的数据预取。因此,如果程序能够充分利用这个特征,将大大提高程序的性能。
(4)SIMD(单指令多数据流):对于计算密集型程序来说,可能经常需要对大量不同的数据进行同样的运算。SIMD引入之前,执行流程为同样的指令重复执行,每次取一条数据进行运算。而SIMD可以一条指令执行多个位宽数据的计算。比如当前最新的体系结构已经支持512位宽的SIMD指令,那么对于16位整型的加法,可以并行执行32个整型对的加法。
(1)表达式计算框架的通用性决定了其执行模式要适配各种不同的运算符和数据类型,因此在运行时要根据表达式遍历的具体结果来确定执行的函数和类型,对这些类型的判断要引入非常多的分支判断。
(2)表达式计算在整体的执行过程中要进行多次的函数调用,其调用的深度取决于表达式树的深度,这也有着非常大的开销。
(1)openGauss内置的 LLVM 编译框架通过为每一个计算单元(表达式或者执行算子里面的热点函数)生成一段独特的执行代码,由于在编译的时候提前知道了表达式涉及的操作和数据类型,可将表达式生成的执行代码中所有的逻辑内联,完全去除函数调用。
比如对于上文提到的表达式计算过程,openGauss内置的 LLVM 编译为这个表达式生成了下面这样一段特殊代码,其中已经没有任何函数调用,所有的函数都已经被内联在一起,同时去掉了关于数据类型的分支判断。
Bool qual()
{
bool qual1res = 2 * w_tax + 0.9 > 1;
bool qual2res = w_city !=’Beijing’;
Return qual1res && qual2res;
}
(2)LLVM 编译框架利用编译技术最大限度地让生成的代码将中间结果的数据存储在 CPU 寄存器里,以加快数据读取的速度。
(1)一次一元组的函数模型在控制流的调动下,每次都需要进行函数调用,调用次数随着数据的增长而增长,而一次一批元组的模式则大大降低了执行节点的函数调用开销,如果设定一次一批元组的数量为1000,则函数调用相对于一次一元组能减少3个数量级。
(2)一次一批元组的模式在内部实现上是通过数组来表达的,CPU 对数组的存取非常友好,能够让数组在后续的数据处理过程中,大概率能够在缓存中被命中。比如下面这个简单计算两个整型数据加法的函数(其代码仅为了展示,不代表真实实现),展示了一次一元组和一次一批元组的两种编写代码方法。
int int4addint4(int4 a, int b)
{
Return a+b;
void int4addint4(int4 a[], int b[], int res[])
{ for(int i = 0; i < N; i++)
res[i] = a[i] + b[i];
}
(3) 一次一批元组的数据数组化的组织方式为利用SIMD特性带来了非常好的机会,使SIMD能够大大提升在元组上的计算性能。还是以上述整型数据加法的例子讲解,可以重写上述的函数如下。
void int4addint4SIMD(int4 a[], int b[], int res[])
{
for(int i = 0; i < N/SIMDLEN; i++)
res[i..i+SIMDLEN] = SIMDADD(a[i..i+SIMDLEN], b[i..i+ SIMDLEN];
}
全文完,希望可以帮到正在阅读的你,如果觉得此文对你有帮助,可以分享给你身边的朋友,同事,你关心谁就分享给谁,一起学习共同进步~~~
❤️ 欢迎关注我的公众号【JiekeXu DBA之路】,一起学习新知识!
————————————————————————————
公众号:JiekeXu DBA之路
CSDN :https://blog.csdn.net/JiekeXu
墨天轮:https://www.modb.pro/u/4347
腾讯云:https://cloud.tencent.com/developer/user/5645107
————————————————————————————