五、编译执行
为了提高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接口汇总
接口名称 |
接口类型 |
职责描述 |
initialize |
API |
分配Codegen使用内存使用环境 |
InitializeLLVM |
API |
初始化LLVM运行环境 |
parseIRFile |
API |
解析IR文件 |
cleanupLlvm |
API |
停止LLVM调用线程 |
createNewModule |
API |
创建一个新的LLVM模板 |
compileCurrentModule |
API |
编译当前指定LLVM模块中的函数 |
compileModule |
API |
编译模板并依据相关选项对模板中未用的IR函数进行优化 |
releaseResource |
API |
释放LLVM模块占用的系统资源 |
FinalizeFunction |
API |
确定最后的IR函数是否可用 |
getType |
API |
从openGauss的类型转换到LLVM内部对应的类型 |
verifyFunction |
API |
检查输入的LLVM IR函数的有效性 |
getPtrType |
API |
从openGauss的类型转换到LLVM内部对应该类型的指针类型 |
castPtrToLlvmPtr |
API |
将openGauss的指针转换为LLVM的指针 |
getIntConstant |
API |
将openGauss对应类型的常数转换为LLVM对应类型的常数 |
generatePrototype |
API |
创建要加入当前LLVM模块的函数原型 |
replaceCallSites |
API |
替换LLVM当前模块的函数 |
optimizeModule |
API |
优化LLVM当前模块中的函数 |
addFunctionToMCJit |
API |
外部函数调用接口 |
canInitCodegenInvironment |
API |
判断当前可否初始化CodeGen环境 |
canInitThreadCodeGen |
API |
判断当前可否初始化CodeGen线程 |
CodeGenReleaseResource |
API |
删除当前模板和LLVM执行引擎 |
CodeGenProcessInitialize |
API |
初始化LLVM服务进程 |
CodeGenThreadInitilize |
API |
初始化LLVM服务线程 |
CodeGenThreadRuntimeSetup |
API |
初始化LLVM服务对象 |
CodeGenThreadRuntimeCodeGenerate |
API |
编译当前LLVM模板中的IR函数 |
CodeGenThreadTearDown |
API |
释放LLVM模块占用的系统资源接口 |
CodeGenThreadObjectReady |
API |
判断当前LLVM服务对象是否有效 |
CodeGenThreadReset |
API |
清空当前内存中的机器码 |
CodeGenPassThreshold |
API |
根据返回行数判断是否需要CodeGen |
GsCodeGen提供LLVM环境处理函数和module函数,以及处理IR的函数。另一方面,为了处理算子函数功能,将每个算子涉及的各个操作符封装在ForeigenScanCodeGen类中,接口定义在“codegen/foreignscancodegen.h”中,各个接口功能如表5-2所示:
表5-2 ForeigenScanCodeGen接口汇总
接口名称 |
接口类型 |
职责描述 |
ScanCodeGen |
API |
生成外表扫描谓词表达式运算对应的IR函数 |
IsJittableExpr |
API |
谓词中的表达式是否支持LLVM化 |
buildConstValue |
API |
获取谓词表达式中的常量 |
目前针对不同的表达式,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接口汇总
接口名称 |
接口类型 |
职责描述 |
ExprJittable |
API |
判断单个表达式是否支持LLVM化 |
QualJittable |
API |
判断整个qual条件是否支持LLVM化 |
QualCodeGen |
API |
ExecVecQual的LLVM化,生成的“machine code”用于替换实际执行时的ExecVecQual |
ExprCodeGen |
API |
ExecInitExpr的LLVM化,目前只支持部分功能和函数的LLVM化 |
OpCodeGen |
API |
操作符表达式(算术表达式,比较表达式等)的LLVM化,目前支持的数据类型包括int、float、numeric、text和bpchar等类型 |
ScalarArrayCodeGen |
API |
ExecEvalScalarArrayOp的LLVM化处理,支持的类型包括text、varchar、bpchar、int和float类型 |
CaseCodeGen |
API |
ExecEvalVecCase的LLVM化处理,其中“case when”中的选项类型包括int类型和text、bpchar类型,对于复杂表达式的暂只支持substr |
VarCodeGen |
API |
ExecEvalVecVar的LLVM化处理 |
EvalConstCodeGen |
API |
ExecEvalConst的LLVM化处理 |
举例来说,以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接口汇总
接口名称 |
接口类型 |
职责描述 |
GetAlignedScale |
API |
计算当前表达式scale |
AggRefJittable |
API |
判断表达式是否支持LLVM化 |
AggRefFastJittable |
API |
判断当前表达式是否能用快速CodeGen |
AgghashingJittable |
API |
判断Agg节点是否能LLVM化 |
HashAggCodeGen |
API |
HashAgg节点构建IR函数的主函数 |
SonicHashAggCodeGen |
API |
Sonic hashagg节点构建IR函数的主函数 |
HashBatchCodeGen |
API |
为“hashBatch”函数生成LLVM函数指针 |
MatchOneKeyCodeGen |
API |
为“match_key”函数生成LLVM函数指针 |
BatchAggJittable |
API |
判断当前batch aggregation节点是否支持LLVM化 |
BatchAggregationCodeGen |
API |
为BatchAggregation节点生成LLVM函数指针 |
SonicBatchAggregationCodeGen |
API |
为SonicBatchAggregation节点生成LLVM函数指针 |
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接口汇总
接口名称 |
接口类型 |
职责描述 |
GetSimpHashCondExpr |
API |
返回var表达式 |
JittableHashJoin |
API |
判断当前hash join节点是否支持LLVM化 |
JittableHashJoin_buildandprobe |
API |
判断buildHashTable/probeHashTable是否可以LLVM化 |
JittableHashJoin_bloomfilter |
API |
判断bloom filter(布隆过滤器)函数是否能LLVM化 |
HashJoinCodeGen |
API |
hash join节点构建IR函数的主函数 |
HashJoinCodeGen_fastpath |
API |
hash join节点生成快速IR函数 |
KeyMatchCodeGen |
API |
keyMatch函数生成LLVM函数 |
HashJoinCodeGen_buildHashTable |
API |
为buildHashTable函数生成LLVM函数 |
HashJoinCodeGen_buildHashTable_NeedCopy |
API |
分区表中buildHashTable函数生成LLVM函数 |
HashJoinCodeGen_probeHashTable |
API |
probeHashTable生成LLVM函数 |