写点什么

openGauss 数据库源码解析系列文章——执行器解析(2.2)

作者:daydayup
  • 2023-07-24
    北京
  • 本文字数:4902 字

    阅读完需:约 16 分钟

五、编译执行


为了提高 SQL 的执行速度,解决传统数据处理引擎条件逻辑冗余的问题,openGauss 为执行表达式引入了 CodeGen 技术,其核心思想是为具体的查询生成定制化的机器码代替通用的函数实现,并尽可能地将数据存储在 CPU 寄存器中。openGauss 通过 LLVM 编译框架来实现 CodeGen,LLVM 是“Low Level Virtual Machine”的缩写,开发之初是想作为一个底层虚拟机,但随着开发,以及功能的逐渐完善,慢慢变成一个模块化的编译系统,并能支持多种语言。LLVM 的系统架构如图 23 所示。



图 23 LLVM 系统架构


LLVM 大体上可以分成 3 个部分。


(1) 支持多种语言的前端。


(2) 优化器。


(3) 支持多种 CPU 架构的后端(X86、Aarch64)。


LLVM 与 GCC 一样,都是常用的编译系统,但是 LLVM 更加模块化,从而可以免去每使用一套语言换一套优化器的工作,开发者只要设计相应的前端,并针对各个目标平台做后端优化。


考虑如下 SQL 语句。



    SELECT * FROM dataTable WHRER (x + 2) * 3 > 4;
    复制代码


    正常的递归流程如图 24 所示。



    图 24 一般的表达式执行流程


    此类表达式的执行代码是一套通用的函数实现,每次递归都有很多冗余判断,需要依赖上一步的输出作为当前的输入,实现如下代码逻辑:


    void MaterializeTuple(char * tuple) {for (int I = 0; i < num_slots_; i++) {    char* slot = tuple + offsets_[i];    switch(types_[i]) {        case BOOLEAN:*slot = ParseBoolean();break;case INT:*slot = ParseInt();Break;case FLOAT: …      case STRING: …… …}}}
    复制代码


    通过 CodeGen 可以为表达式构造定制化的实现,如下代码所示:


    void MaterializeTuple(char * tuple) {*(tuple + 0) = ParseInt();*(tuple + 4) = ParseBoolean();*(tuple + 5) = ParseInt();}
    复制代码


    通过减少冗余的判断分支,极大减少了 SQL 执行时间,同时也减少大量虚函数的调用。为了实现基于 LLVM 的 CodeGen,并方便接口调用,openGauss 定义了一个 GsGodeGen 类,GodeGen 所有接口都在这个类中实现,主要的成员变量包括:


    llvm::Module* m_currentModule;    /* 当前query使用的module */bool m_optimizations_enabled;      /* modules是否能优化 */bool m_llvmIRLoaded;              /* IR文件是否已经载入 */bool m_isCorrupt;                  /* 当前query的module是否可用 */bool m_initialized;                 /* GsCodeGen 对象是否完成初始化 */llvm::LLVMContext* m_llvmContext;   /* llvm上下文 */List* m_machineCodeJitCompiled;   /* 保存所有机器码JIT编译完成的函数 */llvm::ExecutionEngine* m_currentEngine;  /* 当前query的llvm执行引擎 */bool m_moduleCompiled;              /* module是否编译完成 */MemoryContext m_codeGenContext;  /* CodeGen内存上下文 */List* m_cfunction_calls;             /* 记录表达式中调用IR的c函数 */
    复制代码


    这里涉及一些 LLVM 的概念。Module 是 LLVM 的一个重要类,可以把 Module 看作一个容器,每个 Moudle 以下的元素构成:函数、全局变量、符号表入口、以及 LLVM linker(联系 Moudles 之间其他模块的全局变量,函数的前向声明,以及外部符号表入口);LLVMContext 这是一个在线程上下文中使用 LLVM 的类。它拥有和管理 LLVM 核心基础设施的核心“全局”数据,包括类型和常量唯一表。IR 文件是 LLVM 的中间文件,前端将用户代码(C/C++、python 等)转换成 IR 文件,优化器对 IR 文件进行优化。openGauss 的 GodeGen 代码功能之一就是将函数转换成 IR 格式的文件。通常在代码中将源代码转换成 IR 的方式有多种,openGauss 生成 IR 是使用“llvm::IRBuilder<>”函数,在后面会详细介绍。如果查询计划树的算子支持 CodeGen,那么针对该函数生成“Intermediate Representation”函数(IR 函数)。这个 IR 函数是查询级别的,即每一个查询对应的 IR 函数是不同的。同时对应每一个查询有多个 IR 函数,这是因为可以只做局部替换,即只动态生成查询计划树中某个算子或某部分操作函数的 IR 函数,如只实现投影功能的 IR 函数。


    openGauss GodeGen 的整体编译流程如图 25 所示。



    图 25 openGauss CodeGen 编译执行流程


    数据库启动后,首先对 LLVM 初始化,其中 CodeGenProcessInitialize 函数对 LLVM 的环境进行初始化,包括通过 isCPUFeatureSupportCodegen 函数和 canInitCodegenInvironment 函数检查 CPU 是否支持 CodeGen、是否能够进行环境初始化。然后通过“GsCodeGen::InitializeLlvm”函数对本地环境检查,检查环境是否为 Aarch64 或 x86 架构,并返回全局变量 gscodegen_initialized。


    CodeGenThreadInitialize 函数在本线程中创建一个新的 GsCodeGen 对象,并创建内存。如果创建失败,要返回原来的内存上下文给系统,当前线程中 codegen 的部分保存在 knl_t_codegen_context 中,具体结构代码为:


    typedef struct knl_t_codegen_context {    void* thr_codegen_obj;    bool g_runningInFmgr;    long codegen_IRload_thr_count;} knl_t_codegen_context;
    复制代码


    其中 thr_codegen_obj 字段保存代码中 LLVM 对象,在初始化和调用时通常转换成 GsCodeGen 类,GsCodeGen 保存了 LLVM 全部封装好的 LLVM 函数、内存和成员变量等。g_runningInFmgr 字段表示函数是否运行在 function manager 中。codegen_IRload_thr_count 字段是 IR 载入计数。


    当所有的 LLVM 执行环境设置完成后,执行器初始化阶段可根据解析器和优化器提供的查询计划去检查当前的计划是否可以进行 LLVM 代码生成优化。以 gsql 客户端为例,整个运行过程内嵌在执行引擎运行过程内,函数的调用从函数 exec_simple_plan 函数为入口,LLVM 运行的 3 个阶段分别对应 executor 的 3 个阶段:ExecutorStart、ExecutorRun 以及 ExecutorEnd(从其他客户端输入的查询,最终也会走到 ExecutorStart、ExecutorRun 以及 ExecutorEnd 阶段)。


    (1) ExecutorStart 阶段:为运行准备阶段,初始化查询级别的 GsCodeGen 类对象,并在 InitPlan 阶段按照优化器产生的执行计划遍历其中各个算子节点初始化函数,生成 IR 函数。


    (2) ExecutorRun 阶段:为运行阶段,若已成功生成 LLVM IR 函数,则对该 IR 函数进行编译,生成可执行的机器码,并在具体的算子运行阶段用机器码替换到原本的执行函数入口。


    (3) ExecutorEnd 阶段:为运行完清理环境阶段,在 ExecutorEnd 函数中将第一阶段生成的 LLVMCodeGen 对象及其相关资源进行释放。


    GsCodeGen 的接口定义在文件“codegen/gscodegen.h”中,GsCodeGen 中接口说明如表 31 所示。


    表 5-1GsCodeGen 接口汇总



    GsCodeGen 提供 LLVM 环境处理函数和 module 函数,以及处理 IR 的函数。另一方面,为了处理算子函数功能,将每个算子涉及的各个操作符封装在 ForeigenScanCodeGen 类中,接口定义在“codegen/foreignscancodegen.h”中,各个接口功能如表 5-2 所示:


    表 5-2 ForeigenScanCodeGen 接口汇总



    目前针对不同的表达式,openGauss 实现了 4 个类:


    (1) VecExprCodeGen 类主要用于处理查询语句中表达式计算的 LLVM 动态编译优化。目前主要处理的是过滤条件语法中的表达式,即在 ExecVecQual 函数中处理的表达式计算。


    (2) VecHashAggCodeGen 类用于对节点 hashagg 运算的 LLVM 动态编译优化。


    (3) VecHashJoinCodeGen 类用于对节点 hash join 运算的 LLVM 动态编译优化。


    (4) VecSortCodeGen 类用于对节点 sort 运算的 LLVM


    5.1 VecExprCode 类


    VecExprCodeGen 类用于支持 openGauss 设计框架中向量化表达式的动态编译优化,即生成各类向量化表达式计算的 IR 函数。VecExprCodeGen 类主要针对存在 qual 的查询场景,即表达式在 WHERE 语法中的查询场景,VecExprCodeGen 接口定义在“codegen/vecexprcodegen.h”文件中,VecExprCode 类支持的语句场景为:



      SELECT targetlist expr FROM table WHERE filter expr…;
      复制代码


      其中,对 filter expr 进行 LLVM 化处理。


      列存储执行引擎每次处理的为一个 VectorBatch。在执行过程中,由于采用迭代计算模型,对于每一个 qual,会遍历整个 qual 表达式,然后根据遍历得到的信息去读取 VectorBatch 中的列向量 ScalarVector,这样就会导致需要不停地去替换当前存放在内存或寄存器中的数据。为了更好地减少数据读取,让数据在计算过程中更久地存放在寄存器中,将 ExecVecQual 与对 VectorBatch 进行结合处理:只有当前的数据处理完所有的 vecqual 时再更新寄存器中的数据,即原本的执行流程。相关代码如下:


      foreach(cell, qual){DealVecQual(batch->m_arr[var->attno-1]);}替换为for(i = 0; i < batch->m_rows; i++){foreach(cell, qual){DealVecQual(batch->m_arr[var->attno-1]->m_vals[i]);}}
      复制代码


      DealVecQual 代表的就是对当前的数据参数进行 qual 条件处理。可以看到现有的处理方式实际上已经退化为行存储的形式,即每次只处理 batch 中的一行数据信息,但是该数据信息会一直存放在寄存器中,直至所有的 qual 条件处理完成。表 7-33 列出了 VecExprCodeGen 的所有接口。


      表 5-3 VecExprCodeGen 接口汇总



      举例来说,以 ExecCStoreScan 函数中处理 qual 表达式来说明,以本次查询所生成的查询计划树为输入,编译得到机器码。因此实现调用需要做到如下两点。


      (1) 结合所实现的函数接口,依据当前查询计划树,生成对应的 IR 函数。


      如提供了 ExecVecQual 的 LLVM 化接口,则通过遍历每一个 qual 并判断是否支持 LLVM 化来判断当前的 ps.qual 是否可生成 IR 函数。如果判断可生成,则借助 IR builder API 生成对应于当前 quallist 的 IR 函数 .


      代码段显示了 ExecInitCStoreScan 函数中对于 ps.qual 部分的处理。如果存在 LLVM 环境,则优先去生成 ps.qual 的 IR 函数。在 QualCodeGen 函数中的 QualJittable 用于判断当前 ps.qual 是否可 LLVM 化。


      (2) 将原本的执行函数入口替换成预编译好的可执行机器码。


      当步骤(1)已经生成 IR 函数后,则根据如图 25 中所示那样会进行编译(compile IR Function)。那么在实际执行过滤的时候就会进行替换。


      复杂的运算都是通过循环结构和条件判断结构实现的。在 LLVM 中,循环结构和条件判断结构都是基于“IR Builder”类中的 BasicBlock 结构来实现的,因为循环结构和条件判断的执行都可以理解为当满足某个条件后去执行循环结构内部或对应条件分支内部的内容。事实上,“Basic Block”也是整个代码中的控制流。一个简单的条件判断调用代码为:


      其中 cond 为条件判断结果值。如果为 true,就进入 true-block 分支,如果为 false,就进入 false-block 分支。“builder.SetInsertPoint(entry)”表示进入对应的 entry-block 分支。在这样的基本设计思想下,如下一个简单的 for 循环结构:


      其中 builder.CreateBr 函数表示无条件进入对应的 block,实际上是一个控制流。CreateRet(b)表示当前函数结束后返回相应的值。


      上述的 IR 函数经过编译后就可以直接在执行阶段被调用。从而提升执行效率。而后续 OLAP-LLVM 层的代码设计都基于上述的基本数据结构,数据类型和 BasicBlock 控制流结构。


      因此后续单个 LLVM 函数的具体的设计和实现都将依赖于本节所介绍的基本框架。


      5.2 VecHashAggCodeGen 类


      对于 hash 聚合来说,数据库会根据“GROUP BY”字段后面的值算出哈希值,并根据前面使用的聚合函数在内存中维护对应的列表。VecHashAggCodeGen 类的接口实现在“codegen/vechashaggcodegen.h”文件中,接口的说明如表 5-4 所示。


      表 5-4 VecHashAggCodeGen 接口汇总



      openGauss 内核在处理 Agg 节点时,首先在 ExecInitVecAggregation 函数中判断是否进行 CodeGen,如果行数大于 codegen_cost_threshold 参数那么可以进行 CodeGen。


      如果输出行数小于 codegen_cost_threshold,那么 codegen 的成本要大于执行优化的成本。如果节点是 sonic 类型,执行 SonicHashAggCodeGen 函数;一般的 HashAgg 节点执行 HashAggCodeGen 函数。SonicHashAggCodeGen 函数和 HashAggCodeGen 函数的执行流程如图 26 所示。



      图 26 HashAgg 节点 CodeGen 流程


      HashAggCodeGen 函数是 HashAgg 节点 LLVM 化的主入口。openGauss 在结构体 VecAggState 中定义哈希策略的 Agg 节点。openGauss 针对 LLVM 化 Agg 节点增加了 5 个参数用来保存 codegen 后的函数指针:jitted_hashing、jitted_sglhashing、jitted_batchagg、jitted_sonicbatchagg 以及 jitted_SortAggMatchKey。而且 openGauss 在 addFunctionToMCJit 函数中用生成的 IR 函数与节点对应的函数指针构造一个链表。


      5.3 VecHashJoinCodeGen 类


      VecHashAggCodeGen 类的定义在“codegen/vechashjoincodegen.h”文件中,接口说明如表 5-5 所示。


      表 5-5 VecHashAggCodeGen 接口汇总



      用户头像

      daydayup

      关注

      还未添加个人签名 2023-07-18 加入

      还未添加个人简介

      评论

      发布
      暂无评论
      openGauss数据库源码解析系列文章——执行器解析(2.2)_opengauss_daydayup_InfoQ写作社区