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

joytrian
发布于 2023-5-5 11:16
浏览
0收藏

上一篇​openGauss数据库源码解析系列文章——执行器解析(一)​介绍了“ 执行器整体架构及代码概览”、“执行流程”及“执行算子”的相关内容,本篇将介绍“表达式计算”及“编译执行”的精彩内容。

四、表达式计算

表达式计算对应的代码源文件是“execQual.cpp”,openGauss处理SQL语句中的函数调用、计算式和条件表达式时需要用到表达式计算。

表达式的表示方式和查询计划树的计划节点类似,通过生成表达式计划来对每个表达式节点进行计算。表达式继承层次中的公共根类为Expr节点,其他表达式节点都继承Expr节点。表达式状态的公共根类为ExprState,记录了表达式的类型以及实现该表达式节点的函数指针。表达式内存上下文类为ExprContext,ExprContext充当了计划树节点中Estate的角色,表达式计算过程中的参数以及表达式所使用的内存上下文都会存放到此结构中。

表达式计算对应的主要结构体代码如下:

typedef struct Expr {
    NodeTag type;              /*表达式节点类型*/
} Expr;
struct ExprState {
    NodeTag type;
    Expr* expr;                 /*关联的表达式节点*/
    ExprStateEvalFunc evalfunc;   /*表达式运算的函数指针*/
    VectorExprFun vecExprFun;
    exprFakeCodeGenSig exprCodeGen; /*运行LLVM汇编函数的指针*/
    ScalarVector tmpVector;
    Oid resultType;
};

表达式计算的过程分为3个部分:初始化、执行和清理。初始化的过程使用统一接口ExecInitExpr,根据表达式的类型选择不同的处理方式,生成表达式节点树。执行过程使用统一接口宏ExecEvalExpr,执行过程类似于计划节点的递归方式。

4.1 初始化阶段

ExecInitExpr函数的作用是在执行的初始化阶段,准备要执行的表达式树。根据传入的表达式node tree,来创建并返回ExprState tree。在真正的执行阶段会根据ExprState tree中记录的处理函数,递归地执行每个节点。ExecInitExpr函数的核心代码如下:

if (node == NULL) {  /* 判断输入是否为空 */
  gstrace_exit(GS_TRC_ID_ExecInitExpr);
  return NULL;}
switch (nodeTag(node)) {  /* 根据节点类型初始化节点内容 */
        case T_Var:
        case T_Const:
case T_Param:
        ……
        case T_CaseTestExpr:
        case T_Aggref:
        ……
case T_CurrentOfExpr:
case T_TargetEntry: 
case T_List:
        case T_Rownum:
default:…… }
return state;   /* 返回表达式节点树 */

ExecInitExpr函数主要执行流程如下。

(1) 判断输入的node节点是否为空,若为空,则直接返回NULL,表示没有表达式限制。

(2) 根据输入的node节点的类型初始化变量evalfunc即node节点对应的执行函数,若节点存在参数或者表达式,则递归调用ExecInitExpr函数,最后生成ExprState tree。

(3) 返回ExprState tree,在执行表达式的时候会根据ExprState tree来递归执行。

ExecInitExpr函数流程如图12所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

4.2 执行阶段

执行阶段主要是根据宏定义ExecEvalExpr递归调用执行函数。在计算时的核心函数包括ExecMakeFunctionResult和ExecMakeFunctionResultNoSets,通过这两个函数计算出表达式的结果并返回。其他的表达式计算函数还包括ExecEvalFunc、ExecEvalOper、ExecEvalScalarVar、ExecEvalConst、ExecQual、ExecProject等,这些函数分别对应不同的表达式的类型或者参数类型,通过不同的逻辑来处理获取的计算结果。

执行过程就是上层函数调用下层函数。首先下层函数根据参数类型获取相应的数据,然后上层函数通过处理数据得到最后的结果,最后根据表达式逻辑返回结果。

通过一个简单的SQL语句介绍一下表达式计算的函数调用过程,每种SQL语句的执行流程不完全一致,此示例仅供参考。例句:“SELECT * FROM s WHERE s.a<3 or s.b<3;”。具体流程如下。

(1) 根据表达式“s.a<3 or s.b<3”确认第一步调用ExecQual函数。

(2) 由于本次表达式是or语句,所以需要将表达式传入到ExecEvalOr函数计算,在ExecEvalOr函数中采用for循环依次对子表达式“s.a<3”和“s.b<3”计算,将子表达式传入到下一层函数中。

(3) ExecEvalOper函数根据子表达式的返回值是否为set集来调用下一层函数,计算子表达式的结果。

(4) ExecMakeFunctionResultNoSets函数中获取子表达式中的参数的值,“s.a”和“3”分别通过ExecEvalScalarVar函数和ExecEvalConst函数来获取,获取到参数之后计算表达式结果,若s.a<3本次计算返回true,否则返回false,并依次向上层返回结果。

函数调用流程图如图13所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

执行阶段所有函数都共享此调用约定,相关代码如下:

输入:
expression:需要计算的表达式状态树。
econtext:评估上下文信息。
输出:
return value:Datum类型的返回值。
*isNull:如果结果为NULL,则设置为TRUE(实际返回值无意义);如果结果非空,则设置为FALSE。
*isDone:设置为set-result状态的指标。

只能接受单例(非集合)结果的调用方应该传递isDone为NULL,如果表达式计算得到集合结果(set-result),则返回错误将通过ereport报告。如果调用者传递的isDone指针不为空,需要将*isDone设置为以下3种状态之一:

(1) ExprSingleResult 单例结果(非集合)。

(2) ExprMultipleResult 返回值是集合的一个元素。

(3) ExprEndResult 集合中没有其他元素。

当返回ExprMultipleResult时,调用者应该重复调用并执行ExecEvalExpr函数,直到返回ExprEndResult。

表4-1中列举代码“execQual.cpp”文件中的部分主要函数,下面将依次详细介绍每个函数的功能、核心代码和执行流程。

表4-1 表达式计算的主要函数


主要函数

说明

ExecMakeFunctionResultNoSets

表达式计算(非集合)

ExecMakeFunctionResult

表达式计算(集合)

ExecEvalFunc/ExecEvalOper

调用表达式计算函数

ExecQual

检查条件表达式

ExecEvalOr

处理or表达式

ExecTargetList

计算targetlist中的所有表达式

ExecProject

计算投影信息

ExecEvalParamExec

获取Exec类型参数

ExecEvalParamExtern

获取Extern类型参数


ExecMakeFunctionResult函数和ExecMakeFunctionResultNoS函数是表达式计算的核心函数,主要作用是通过获取表达式的参数来计算出表达式结果。ExecMakeFunctionResultNoSets函数是ExecMakeFunctionResult函数的简化版,只能处理返回值是非集合情况。ExecMakeFunctionResult函数核心代码如下:

fcinfo = &fcache->fcinfo_data;                           /* 声明fcinfo */
InitFunctionCallInfoArgs(*fcinfo, list_length(fcache->args), 1); /*初始化fcinfo */ 
econtext->is_cursor = false;
    foreach (arg, fcache->args) {                          /* 遍历获取参数值 */
        ExprState* argstate = (ExprState*)lfirst(arg);
        fcinfo->argTypes[i] = argstate->resultType;
        fcinfo->arg[i] = ExecEvalExpr(argstate, econtext, &fcinfo->argnull[i], NULL);
if (fcache->func.fn_strict)                   /* 判断参数是否存在空值 */
…… 
result = FunctionCallInvoke(fcinfo);           /* 计算表达式结果 */
return result;

ExecMakeFunctionResultNoSets函数的执行流程如下。

(1) 声明fcinfo来存储表达式需要的参数信息,通过InitFunctionCallInfoArgs函数初始化fcinfo中的字段。

(2) 遍历表达式中的参数args,通过ExecEvalExpr宏调用接口获取每一个参数的值,存储到“fcinfo->arg[i]”中。

(3) 根据func.fn_strict函数来判断是否需要检查参数空值情况。如果不需要检查,则通过“FunctionCalllv-oke”宏将参数传入表达式并计算出表达式的结果。否则进行判空处理,若存在空值则直接返回空,若不存在空值则通过FunctionCalllvoke宏计算表达式结果。

(4) 返回计算结果。

流程如图14所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecMakeFunctionResult函数的执行流程如图7-15所示。

(1) 判断funcResultStore是否存在,如果存在则从中获取结果返回(注:如果下文(3)中的模式是SFRM_Materialize,则会直接跳到此处)。

(2) 计算出参数值存入到fcinfo中。

(3) 把参数传入到表达式函数中计算表达式,首先判断参数args是否存在空,然后判断返回集合的函数的返回模式,SFRM_ValuePerCall模式是每次调用返回一个值,SFRM_Materialize模式是在Tuplestore中实例化的结果集。

(4) 根据不同的模式进行计算并返回结果。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecEvalFunc和ExecEvalOper这两个函数的功能类似。通过调用结果处理函数来获取结果。如果函数本身或者它的任何输入参数都可以返回一个集合,那么就会调ExecMakeFunctionResult函数来计算结果,否则调用ExecMakeFunctionResultNoSets函数来计算结果。核心代码如下:

init_fcache<false>(func->funcid,func->inputcollid,fcache, econtext->ecxt_per_query_memory, true);                 /* 初始化fcache */
if (fcache->func.fn_retset) {                           /* 判断返回结果类型 */
    ……
return ExecMakeFunctionResult<true, true, true>(fcache, econtext, isNull, isDone);
} else if (expression_returns_set((Node*)func->args)) {
……
return ExecMakeFunctionResult<true, true, false>(fcache, econtext, isNull, isDone);
} else {
……
return ExecMakeFunctionResultNoSets<true, true>(fcache, econtext, isNull, isDone);
}

ExecEvalFunc函数的执行流程如下。

(1) 是通过init_fcache函数初始化FuncExprState节点,包括初始化参数、内存管理等等。

(2) 根据FuncExprState函数中的数据判断返回结果是否为set类型,并调用相应的函数计算结果。

ExecEvalFunc函数执行流程如图16所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecQual函数的作用是检查slot结果是否满足表达式中的子表达式,如果子表达式为false,则返回false否则返回true,表示该结果符合预期,需要输出。核心代码如下:

foreach (l, qual) {        /* 遍历qual中的子表达式并计算 */
expr_value = ExecEvalExpr(clause, econtext, &isNull, NULL);
if (isNull) {  /* 判断计算结果 */
if (resultForNull == false) {
result = false; 
break;
}
        } else {
            if (!DatumGetBool(expr_value)) {
                  result = false;
 ……
 return result;   /* 返回结果是否满足表达式 */

ExecQual函数的主要执行流程如下。

(1) 遍历qual中的子表达式,根据ExecEvalExpr函数计算结果是否满足该子表达式,若满足则expr_value为1,否则为0。

(2) 判断结果是否为空,若为空,则根据resultForNull参数得到返回值信息。若不为空,则根据expr_value判断返回true或者false。

(3) 返回result。

ExecQual函数的执行流程如图17所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecEvalOr函数的作用是计算通过or连接的bool表达式(布尔表达式,最终只有true(真)和false(假)两个取值),检查slot结果是否满足表达式中的or表达式。如果结果符合or表达式中的任何一个子表达式,则直接返回true,否则返回false。如果获取的结果为null,则记录isNull为true。核心代码如下:

foreach (clause, clauses) {              /* 遍历子表达式 */
        ExprState* clausestate = (ExprState*)lfirst(clause);
        Datum clause_value;
        clause_value = ExecEvalExpr(clausestate, econtext, isNull, NULL);  /* 执行表达式 */
        /* 如果得到不空且ture的结果,直接返回结果 */
if (*isNull) 
/* 记录存在空值 */
            AnyNull = true; 
        else if (DatumGetBool(clause_value))
/* 一次结果为true就返回 */
            return clause_value;  /* 返回执行结果 */
    }
*isNull = AnyNull;
return BoolGetDatum(false);

ExecEvalOr函数主要执行流程如下。

(1) 遍历子表达式clauses。

(2) 通过ExecEvalExpr函数来调用clause中的表达式计算函数,计算出结果。

(3) 对结果进行判断,or表达式中若有一个结果满足条件,就会跳出循环直接返回。

ExecEvalOr函数的执行流程如图18所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecTargetList函数的作用是根据给定的表达式上下文计算targetlist中的所有表达式,将计算结果存储到元组中。主要结构体代码如下:

typedef struct GenericExprState {
    ExprState xprstate;
    ExprState* arg; /*子节点的状态*/
} GenericExprState;
typedef struct TargetEntry {
    Expr xpr;
    Expr* expr;            /*要计算的表达式*/
    AttrNumber resno;      /*属性号*/
    char* resname;         /*列的名称*/
    Index ressortgroupref;    /*如果被sort/group子句引用,则为非零*/
    Oid resorigtbl;           /*列的源表的OID */
    AttrNumber resorigcol;    /*源表中的列号*/
    bool resjunk;            /*设置为true可从最终目标列表中删除该属性*/
} TargetEntry;

ExecTargetList函数主要执行流程如下。

(1) 遍历targetlist中的表达式。

(2) 计算表达式结果。

(3) 判断结果中itemIsDone[resind]参数并生成最后的元组。

ExecTargetList函数的执行流程如图19所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecProject函数的作用是进行投影操作,投影操作是一种属性过滤过程,该操作将对元组的属性进行精简,把在上层计划节点中不需要用的属性从元组中去掉,从而构造一个精简版的元组。投影操作中被保留下来的那些属性被称为投影属性。主要结构体代码如下:

typedef struct ProjectionInfo {
    NodeTag type;
    List* pi_targetlist;            /*目标列表*/
    ExprContext* pi_exprContext;  /*内存上下文*/
    TupleTableSlot* pi_slot;       /*投影结果*/
    ExprDoneCond* pi_itemIsDone; /*ExecProject的工作区数组*/
    bool pi_directMap;
    int pi_numSimpleVars;    /*在原始tlist(查询目标列表)中找到的简单变量数*/
    int* pi_varSlotOffsets;    /*指示变量来自哪个slot(槽位)的数组*/
    int* pi_varNumbers;     /*包含变量的输入属性数的数组*/
    int* pi_varOutputCols;   /*包含变量的输出属性数的数组*/
    int pi_lastInnerVar;      /*内部参数*/
    int pi_lastOuterVar;     /*外部参数*/
    int pi_lastScanVar;      /*扫描参数*/
    List* pi_acessedVarNumbers;
    List* pi_sysAttrList;
    List* pi_lateAceessVarNumbers;
    List* pi_maxOrmin;    /*列表优化,指示获取此列的最大值还是最小值*/
    List* pi_PackTCopyVars;            /*记录需要移动的列*/
    List* pi_PackLateAccessVarNumbers;  /*记录cstore(列存储)扫描中移动的内容的列*/
    bool pi_const;
    VectorBatch* pi_batch;
    vectarget_func jitted_vectarget;      /* LLVM函数指针*/
    VectorBatch* pi_setFuncBatch;
} ProjectionInfo;

ExecProject函数的主要执行流程如下。

(1) 取ProjectionInfo需要投影的信息。按照执行的偏移获取原属性所在的元组,通过偏移量获取该属性,并通过目标属性的序号找到对应的新元组属性位置进行赋值。

(2) 对pi_targetlist进行运算,将结果赋值给对应元组中的属性。

(3)产生一个行记录结果,对slot做标记处理,slot包含一个有效的虚拟元组。

ExecProject函数的执行流程如图20所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecEvalParamExec函数的作用是获取并返回PARAM_EXEC类型的参数。PARAM_EXEC参数是指内部执行器参数,是需要执行子计划来获取的结果,最后需要将结果返回到上层计划中。核心代码如下:

prm = &(econtext->ecxt_param_exec_vals[thisParamId]); /* 获取econtext中参数 */
if (prm->execPlan != NULL) {                    /* 判断是否需要生成参数 */
  /* 参数还未计算执行此函数*/
  ExecSetParamPlan((SubPlanState*)prm->execPlan, econtext);
  /*参数计算完计划重置为空*/
  Assert(prm->execPlan == NULL);
  prm->isConst = true;
  prm->valueType = expression->paramtype;
}
*isNull = prm->isnull;
prm->isChanged = true;
return prm->value;                               /* 返回生成的参数 */

ExecEvalParamExec函数的主要执行流程如下。

(1) 获取econtext中的ecxt_param_exec_vals参数。

(2) 判断子计划是否为空,若不为空则调用ExecSetParamPlan函数执行子计划获取结果,并把计划置为空,当再次执行此函数时,不需要重新执行计划,直接返回已经获取过结果。

(3) 将结果prm->value返回。

ExecEvalParamExec函数的执行流程如图21所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

ExecEvalParamExtern函数的作用是获取并返回PARAM_EXTERN类型的参数。该参数是指外部传入参数,例如在PBE执行时,PREPARE的语句中的参数,在需要execute语句执行时传入。核心代码如下:

if (paramInfo && thisParamId > 0 && thisParamId <= paramInfo->numParams) {/* 判断参数 */
ParamExternData* prm = ¶mInfo->params[thisParamId - 1];
  if (!OidIsValid(prm->ptype) && paramInfo->paramFetch != NULL)   /* 获取动态参数 */ 
    (*paramInfo->paramFetch)(paramInfo, thisParamId);
    if (OidIsValid(prm->ptype)) {                               /*检查参数并返回 */ 
if (prm->ptype != expression->paramtype)
ereport(……);
       *isNull = prm->isnull;
       if (econtext->is_cursor && prm->ptype == REFCURSOROID) {
         CopyCursorInfoData(&econtext->cursor_data, &prm->cursor_data);
         econtext->dno = thisParamId - 1;
       }
       return prm->value;
   }
}
  ereport(ERROR, (errcode(ERRCODE_UNDEFINED_OBJECT), errmsg("no value found for parameter %d", thisParamId)));
  return (Datum)0;

ExecEvalParamExtern函数主要执行流程如下。

(1) 判断PARAM_EXTERN类型的参数否存在,若存在则从ecxt_param_list_info中获取该参数,否则直接报错。

(2) 判断参数是否是动态的,若是动态的则再次获取参数。

(3) 判断参数类型是否符合要求,若符合要求直接返回该参数。

ExecEvalParamExtern函数的执行流程如图22所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

五、编译执行

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

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

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

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

(2) 优化器。

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

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

考虑如下SQL语句。

SELECT * FROM dataTable WHRER (x + 2) * 3 > 4;

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

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

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

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所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

数据库启动后,首先对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函数,相关代码如下:

if (!codegen_in_up_level) {
consider_codegen = CodeGenThreadObjectReady() &&
CodeGenPassThreshold(((Plan*)node)->plan_rows,
estate->es_plannedstmt->num_nodes, ((Plan*)node)->dop);
    if (consider_codegen) {
        jitted_vecqual = dorado::VecExprCodeGen::QualCodeGen(scan_stat->ps.qual, (PlanState*)scan_stat);
        if (jitted_vecqual != NULL)
            llvm_code_gen->addFunctionToMCJit(jitted_vecqual, reinterpret_cast<void**>(&(scan_stat->jitted_vecqual)));
    }
}

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

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

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

if (node->jitted_vecqual)
p_vector = node->jitted_vecqual(econtext);
else
p_vector = ExecVecQual(qual, econtext, false);

代码段显示了如果生成了用于处理CStoreScan函数中plan.qual的机器码,则直接去调用生成的jitted_vecqual函数。如果没有,则按照原有的执行逻辑去处理。


表5-3中提到OpCodegen是操作符的LLVM化,其支持的数据结构包括了布尔型、浮点型、整型和字符型等,源代码在“gausskernel/runtime/codegen/codegenutil”目录中,以boolcodegen.cpp、datecocegen.cpp格式命名。


LLVM提供了很多针对于数据的基本操作,包括基本算术运算和比较运算。由于LLVM最高可支持(223-1)位的整数类型,且数据类型可以进行二进制转换(延展,扩充都可以),因此LLVM只需要提供整型数据比较和浮点型数据比较即可。一个典型的比较运算符接口代码如下(以’=’为例):

llvm:Value *CreateICmpEQ(Value *LHS, Value *RHS, const Twine &Name = "")

其中LHS和RHS为参与运算的输入参数,而Name表示在运算时候的变量名。类似地,LLVM也提供了众多的基本运算,如两个整型数据相加的接口代码为:

llvm:Value *CreateAdd(Value *LHS, Value *RHS, const Twine &Name = "")

通过LLVM提供的这些基本接口就完成一些常用的运算操作。


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

builder.CreateCondBr(Value *cond, BasicBlock *true, BasicBlock *false);

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

int i = 0;
int b = 0;
for( i = 0; i < 100; i++)
{
    b = b + 1;
}

就需要通过如下的LLVM builder伪代码实现:

Builder.SetInsertPoint(for_begin);
cond=builder.CreateICmpLT(i,100);
builder.CreateCondBr(cond, for_body, for_end);
builder.SetInsertPoint(for_body);
b = builder.CreateAdd(b,1);
buider.CreateBr(for_check);
builder.SetInsertPoint(for_check);
i=builder.CreateAdd(i,1);
builder.CreateBr(for_begin);
builder.SetInsertPoint(for_end);
builder.CreateAlignLoad(b);
builder.CreateRet(b);

其中builder.CreateBr函数表示无条件进入对应的block,实际上是一个控制流。CreateRet(b)表示当前函数结束后返回相应的值。通过编写类似的程序就可以生成如下执行所需的IR函数:

define i32 @main() #0 {
  %1 = alloca i32, align 4
  %i = alloca i32, align 4
  %b = alloca i32, align 4
  store i32 0, i32* %1
  store i32 0, i32* %i, align 4
  store i32 0, i32* %b, align 4
  store i32 0, i32* %i, align 4
  br label %2
; <label>:2                                       ; preds = %8, %0
  %3 = load i32* %i, align 4
  %4 = icmp slt i32 %3, 100 
  br i1 %4, label %5, label %11 
; <label>:5                                       ; preds = %2
  %6 = load i32* %b, align 4
  %7 = add nsw i32 %6, 1
  store i32 %7, i32* %b, align 4
  br label %8
; <label>:8                                       ; preds = %5
  %9 = load i32* %i, align 4
  %10 = add nsw i32 %9, 1
  store i32 %10, i32* %i, align 4
  br label %2
; <label>:11                                      ; preds = %2
  %12 = load i32* %b, align 4
  ret i32 %12 
}

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

llvm::Function *func(InputArg[计划树信息])
{
定义数据类型,变量值;
申明动态参数[带有实际数据的参数];
控制流主体;
返回结果值。
}

因此后续单个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。

bool consider_codegen =
   CodeGenThreadObjectReady() &&CodeGenPassThreshold(((Plan*)outer_plan)->plan_rows, 
estate->es_plannedstmt->num_nodes, ((Plan*)outer_plan)->dop);
   if (consider_codegen) {
        if (node->aggstrategy == AGG_HASHED && node->is_sonichash) {
            dorado::VecHashAggCodeGen::SonicHashAggCodeGen(aggstate);
        } else if (node->aggstrategy == AGG_HASHED) {
            dorado::VecHashAggCodeGen::HashAggCodeGen(aggstate);
}
}

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

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

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函数


在函数ExecInitVecHashJoin中,为hash join节点进行CodeGen的代码为:

if (consider_codegen && !node->isSonicHash) {
dorado::VecHashJoinCodeGen::HashJoinCodeGen(hash_state);
}

其中consider_codegen是根据行数判断是否进行CodeGen。HashJoinCodeGen是hash join节点LLVM化的主入口,与其他可LLVM化的节点一样,生成IR函数后,将IR函数与节点结构体中对应变量绑定,如图27所示。

openGauss数据库源码解析系列文章——执行器解析(二)-鸿蒙开发者社区

图中所有CodeGen函数返回都是“LLVM::Function”类型的IR函数指针,其中值得注意的是当enable_fast_keyMatch的值等于0时,是正常的“key match”;等于2时,所有key值类型都是int4或者int8并且不为NULL,这时候可使用更少的内存和更少的分支,所以叫作“fast path”。

5.4 VecSortCodeGen类

VecSortCodeGen是为sort节点LLVM化定义的一个类,类中的接口声明在“codegen/vecsortcodegen.h”文件中,接口描述如表5-6所示。

表5-6 VecSortCodeGen接口汇总


接口名称

接口类型

职责描述

JittableCompareMultiColumn

API

判断sort node节点是否支持LLVM

CompareMultiColumnCodeGen

API

CompareMultiColumn函数生成LLVM函数

CompareMultiColumnCodeGen_TOPN

API

在Top N sort场景下为CompareMultiColumn函数生成LLVM函数

bpcharcmpCodeGen_long(short)

API

为bpcharcmp函数生成LLVM函数

LLVMIRmemcmp_CMC_CodeGen

API

memcmp函数生成LLVM函数

textcmpCodeGen

API

text_cmp函数生成LLVM函数

numericcmpCodeGen

API

numeric_cmp函数生成LLVM函数

JittableSortAggMatchKey

API

判断sort aggregationmatch_key函数是否支持LLVM

SortAggMatchKeyCodeGen

API

sort aggregationmatch_key函数生成LLVM函数

SortAggBpchareqCodeGen

API

Bpchareq函数生成LLVM函数

SortAggMemcmpCodeGen_long(short)

API

match_key中为memcmp函数生成LLVM函数


如果cosider_codegen为Ture,那么CompareMultiColumnCodeGen对sort节点进行LLVM化。此外如果父节点是Limit节点,那么还要继续通过CompareMultiColumnCodeGen_TOPN函数对sort节点进一步LLVM化。

if (consider_codegen) {
/* 根据行数判断是否使用codegen,如果使用则开始codegen */
jitted_comparecol = dorado::VecSortCodeGen::CompareMultiColumnCodeGen(sort_stat, use_prefetch); /* 为sort操作进行codegen */
if (jitted_comparecol != NULL) {
/* 如果生成了llvm函数则加到MCJIT LIST中 */
        llvm_codegen->addFunctionToMCJit(jitted_comparecol, reinterpret_cast<void**>(&(sort_stat->jitted_CompareMultiColumn)));
    }
Plan* plan_tree = estate->es_plannedstmt->planTree;
/* 如果sort节点包含“limit”父节点则继续调用相应codegen函数 */
    bool has_topn = MatchLimitNode(node, plan_tree);
    if (has_topn && (jitted_comparecol != NULL)) {
        jitted_comparecol_topn= dorado::VecSortCodeGen::CompareMultiColumnCodeGen_TOPN(sort_stat, 
use_prefetch);
        if (jitted_comparecol_topn != NULL) {
           llvm_codegen->addFunctionToMCJit(jitted_comparecol_topn, reinterpret_cast<void**>(&(sort_stat->jitted_CompareMultiColumn_TOPN)));
        }
    }
}

在调用时,与其他类一样,首先判断节点是否LLVM化,没有LLVM化则进行非codegen的处理。

if (jitted_CompareMultiColumn) /* 如果有codegen则使用jit */
compareMultiColumn = ((LLVM_CMC_func)(jitted_CompareMultiColumn));
else
compareMultiColumn = CompareMultiColumn<false>;

感谢大家学习本篇执行器解析中“表达式计算”及“编译执行”的精彩内容,下一篇我们开启“向量化引擎”、“执行器解析篇小结”的相关内容的介绍。

敬请期待。


文章转载自公众号:openGauss

分类
标签
已于2023-5-5 11:16:21修改
收藏
回复
举报
回复
    相关推荐