文章目录
1.开源编译器框架LLVM
JIT(Just-in-time compilation)动态编译
- 通常,程序有两种运行方式:静态编译与动态直译。
- 静态编译的程序在执行前全部被翻译为机器码,而直译执行的则是一句一句边运行边翻译。
- JIT 则混合了这二者,一句一句编译源代码,但是会将翻译过的代码缓存起来以降低性能损耗。相对于静态编译代码,即时编译的代码可以处理延迟绑定并增强安全性。
传统数据库后端编码是一套通用的代码,实现通用的数据处理,eg:
- 一套代码实现不同数据类型(定长,变长)组合的表结构的读和写,而一个 SQL 往往只涉及几个有限的表。比如 select * from t 这个 SQL 只涉及一个表,这表的数据类型列的个数是固定的。
- 通用的 DB 执行器,可以兼容不同执行节点随意组合的执行方式,而一个SQL 往往是固定的几个执行节点的组合,比如 select count(*) from t 这个 SQL 就是一个全表扫描节点 Seqscan 和聚集节点 Group 的简单组合。
- 实现一个通用的 SQL 表达式计算框架,兼容所有表达式计算(各种数据类型 + - 计算 不同参数的函数调用)。
而单个 SQL 中表达式是固定的。比如 select fun(t.a,t.b) + t.c from t 这个 SQL 即是把表 t 中的3个列做简单的函数加合计算
可以看出,单个 SQL 中,很多之前的“变量”,已经成为“常量”,如果我们在知道用户的 SQL 的之后再 “coding”,会少很多条件判断,少很多无关代码,效率自然会高很多。
LLVM,一个自由软件项目,是一种编译器的基础建设,以 C++ 写成。它是为了任意一种编程语言写成的程序,利用虚拟技术,创造出编译时期,链接时期,运行时期以及“闲置时期”的最优化。
LLVM特性
- LLVM 也可以在编译时期、链接时期,甚至是运行时期产生可重新定位的代码(Relocatable Code)上面已经提到;
- LLVM 支持与语言无关的指令集架构及类型系统。
LLVM 可以提供完整编译器系统的中间层,从编译器获取中间表示(IR)代码并发出优化的 IR。
然后可以将这个新的 IR 转换并链接到目标平台的依赖于机器的汇编语言代码。
在我们的应用场景中,使用 LLVM API 会生成中间代码 IR,存放在内存或外部文件中。
在目标平台执行时,针对对应的平台再生成对应的机器码再执行。
这意味着我们在 IR 层编程,在不同的 CPU 上执行,会生当前硬件平台生成最优的机器码。即使是 intel 的 x86 平台,不同代的 CPU 优化程度也不同。例如 LLVM 会充分利用新 CPU 上的指令集,例如 SIMD。这一点在数据仓库做浮点数计算时会用到。 - LLVM 有一套完整的 API 可以用于编码,并生成 LLVM IR 中间代码。支持很多种编码语言,C/C++ 都覆盖到了。我们主要的工作都在这了。
- LLVM 的前端编译器 clang 兼容 gcc, 且性能相当。相关代码使用编译器 clang 编译,能和 gcc 编译的二进制相互链接。
目前比较流行的数据分析数据库中使用 JIT 技术的有
产品 | 简介 |
HyPer | 高性能内存数据库 |
Impala | Cloudera公司主导开发的新型查询系统 |
Postgres Professional | PostgreSQL 的一个发行版 |
gpdb | 基于 PostgreSQL 的 MPP 产品 |
2.LLVM基本概念
如下图所示LLVM编译器框架
-
Clang的发音是/ˈklæŋ/
-
可以将clang和lld都看做是LLVM的组成部分
-
LLVM由一些库和工具组成,正因为它的这种设计思想,使它可以很容易和IDE集成(因为IDE软件可以直接调用库来实现一些如静态检查这些功能),也很容易构建生成各种功能的工具(因为新的工具只需要调用需要的库就行)
-
最初时,LLVM的前端是GCC,我们也可以开发自己的前端,和LLVM后端配合起来,实现我们自定义的编程语言的编译器。
-
LLVM IR是LLVM的中间表示,这是LLVM中很重要的一个东西,链接
-
大多数的优化都依赖于LLVM IR展开,LLVM的一个设计思想是优化可以渗透在整个编译流程中各个阶段,比如编译时、链接时、运行时等
-
在LLVM中,IR有三种表示
(1)一种是可读的IR,类似于汇编代码,但其实它介于高等语言和汇编之间,这种表示就是给人看的,磁盘文件后缀为.ll;
(2)第二种是不可读的二进制IR,被称作位码(bitcode),磁盘文件后缀为.bc;
(3)第三种表示是一种内存格式,只保存在内存中,所以谈不上文件格式和文件后缀,这种格式是LLVM之所以编译快的一个原因,它不像gcc,每个阶段结束会生成一些中间过程文件,它编译的中间数据都是这第三种表示的IR。
三种格式是完全等价的,我们可以在Clang/LLVM工具的参数中指定生成这些文件(默认不生成,对于非编译器开发人员来说,也没必要生成),可以通过llvm-as和llvm-dis来在前两种文件之间做转换。 -
LLVM IR linker,IR的链接器
为了实现链接时优化,LLVM在前端(Clang)生成单个代码单元的IR后,将整个工程的IR都链接起来,同时做链接时优化。 -
LLVM backend就是LLVM真正的后端
LLVM核心,包括编译、汇编、链接这一套,最后生成汇编文件或者目标码。
这里的LLVM compiler和gcc中的compiler不一样,这里的LLVM compiler只是编译LLVM IR。
LLVM
- LLVM项目或基础架构:这是对整个LLVM编译器框架的程序,包括了前端、优化器、后端、汇编器、链接器,以及libc++、JIT等。上下文如:“LLVM项目由以下几个模块组成”。
- 基于LLVM开发的编译器:这是指一部分或全部基于LLVM项目开发的编译器软件,软件可能基于LLVM的前端或后端来实现。
- LLVM库:LLVM项目由库代码和一些工具组成,有时会指代LLVM库内容。(JIT是其中一个库)。
- LLVM核心:在IR和后端算法上的内容,就是LLVM核心,也就是通常Clang/LLVM中的LLVM。
- LLVM IR:有些时候也会指代其中间表示。上下文如:
Clang
-
通常我们在命令行上调用的clang工具,是Clang驱动程序,因为LLVM本质上只是一个编译器框架,所以需要一个驱动程序把整个编译器的功能串起来,clang能够监控整个编译器的流程,即能够调用到Clang和LLVM的各种库,最终实现编译的功能。
-
BTW,其实gcc也是驱动程序,由它将cc、as、ld等程序驱动起来。
-
如果由clang来监控运行,则整个IR的阶段,IR的表示形式都是内存形式,这样就不会在编译过程中产生中间文件,提高了编译的效率。
另一种方法是调用LLVM的工具,类似于gcc中的cc、as、ld一样,LLVM也有自己的工具,这样工具之间运行需要用户来控制输入输出,这时的IR表示形式就是硬盘格式,可以是LLVM汇编(.ll),也可以是位码(.bc)。
LLVM工具
- opt
这是一个在IR级别做程序优化的工具,输入和输出都是同一类型的LLVM IR,做优化不必要修改文件格式。 - llc
(1)这是微观意义上的LLVM编译器,不同于gcc的编译器,它的输入是LLVM IR,输出是汇编文件或者是目标文件。
(2)通过-filetype=asm或者-filetype=obj来指定输出是汇编文件还是目标文件,若生成是目标文件,llc会调用LLVM中的汇编输出的代码库来工作(注意这个汇编器和gcc的汇编器也不同,它输入的是MI,是一种后端的中间表示)。
(3)除此之外,还可以用-On来指定优化级别(llc默认优化级别是-O2),或者其他一些参数。
llc -filetype=asm main.bc -O0 -o main.s
llc -filetype=obj main.bc -O0 -o main.o
(.bc文件换成.ll文件也可以)
-
llvm-mc
这是微观意义上的LLVM汇编器,它输入汇编文件,输出目标文件。同时,它也可以反汇编,指定特殊参数(–disassemble)就行。
可以发现,llc和llvm-mc都会调用到输出目标文件的库,也就是MCObjectStreamer。
链接 -
lli
这个工具是LLVM IR的解释器,也是一个JIT编译器。
我其实从这里才知道,原来谈C语言是一门编译型语言,并不客观,因为LLVM可以把C语言翻译成LLVM IR,然后解释执行,与Java的那一套类似,这也是最初LLVM编写时的实现(一个虚拟机运行IR) -
llvm-link
第一种是LLVM先通过前端把每个源文件单独翻译成IR级别,然后用llvm-link链接成一个IR,然后再经过优化、后端等步骤生成目标文件,使用llvm-link的同时,可以使用链接时优化。不过需要注意,这种方式同样需要最终调用链接器,将这个目标文件链接成可执行文件。
第二种是LLVM通过前端把每个源文件单独翻译后,再单独经过优化、后端等工作,将每个源文件生成目标文件,之后再调用链接器,将所有目标文件链接成可执行文件。
llvm-link main.bc sum.bc -o sum.linked.bc
-
llvm-as
这是针对LLVM IR的汇编器,其实名字里带as,实际上不是gcc那个as,它的功能是将.ll文件翻译为.bc文件,LLVM项目里,.ll称为LLVM汇编码,所以llvm-as也就是IR的汇编器了。 -
llvm-dis
与llvm-as刚好相反,IR的反汇编器,用来将.bc文件翻译为.ll文件。 -
clang
clang能够调用起来整个编译器的流程,也就是上边其他工具调用的库,它很多都同样会调用。
clang通过指定-emit-llvm参数,可以配合-S或-c生成.ll或.bc文件,这样我们就能把Clang的部分和LLVM的后端分离开来独立运行,对于观察编译器流程来说,很实用。
clang -emit-llvm -c main.c -o main.bc
clang -emit-llvm -S main.c -o main.ll
3.LLVM与gcc的关系
gcc的编译器,输入是源代码,输出是汇编代码,相当于是LLVM中Clang一级加上IR linker再加上LLVM compiler中的生成汇编代码部分
- Clang输出可执行文件的一条龙过程,不会生成汇编文件,内部全部走中间表示,生成汇编码和生成目标文件是并列的。
gcc的汇编器,输入是汇编代码,输出是目标文件,相当于是LLVM中的llvm-mc
- 这是另一个工具,Clang一条龙默认不走这个工具,但会调用相同的库来做汇编指令的下降和发射。
gcc的链接器,输入是目标文件,输出是最终可执行文件,相当于LLVM中的Linker,所以Clang驱动程序调起来的链接器还是系统链接器,可以选择使用gcc的ld。
4. LLVM IR
在编译器理论与实践中,IR是非常重要的一环。IR的全称叫做Intermediate Representation,翻译过来叫“中间表示”。
- 对于一个编译器来说,从上层抽象的高级语言到底层的汇编语言,要经历很多个环节(pass),经历不同的表现形式。
- IR以上的编译优化,不需要关心底层硬件的细节,比如硬件的指令集、寄存器文件大小等。IR以下的编译优化,需要和硬件打交道。
- LLVM最重要的就是其IR设计,LLVM向上可以支持不同的语言,向下可以支持不同的硬件,而且不同的语言可以复用IR层的优化算法
LLVM框架
LLVM把整个编译过程分为三步:
- (1)前端,把高级语言转换为IR。
- (2)中端,在IR层做优化。
- (3)后端,把IR转化为对应的硬件平台的汇编语言。因此LLVM的扩展性很好。
比如你要实现一个名为toyc的语言、希望运行在ARM平台上,你只需要实现一个toyc->LLVM IR的前端,其他部分调LLVM的模块就可以了。
或者你要搞一个新的硬件平台,那么只需要搞定LLVM IR->新硬件这一阶段,然后该硬件就可以支持很多种现存的语言。
因此,IR是LLVM最有竞争力的地方,同时也是学习使用LLVM Codegen的最核心的地方。
IR中最重要的三部分,指令格式、Basic Block & CFG,SSA
- 指令格式
LLVM IR提供了一种类似于汇编语言的三地址码式的指令格式。
eg:LLVM IR实现的函数,函数的功能是计算这5个数的和并返回
LLVM IR中得变量的命名是以 "%"开头,默认%0是函数的第一个参数、%1是第二个参数,依次类推;
LLVM IR的指令格式包括操作符、类型、输入、返回值;
LLVM IR是支持一些基本的数据类型的,比如i8、i32、浮点数等
define i32 @ir_add(i32, i32, i32, i32, i32)
{
%6 = add i32 %0, %1操作符号是"add"、类型是"i32"、输入是"%0"和“%1”、返回值是"%6"
%7 = add i32 %6, %2
%8 = add i32 %7, %3
%9 = add i32 %8, %4
ret i32 %9;
}
-
Basic Block & CFG
Basic Block(基本块,简称BB)和Control Flow Graph(控制流图,CFG)
下图(左)展示了一个简单的C语言函数,下图(中)是使用clang编译出来的对应的LLVM IR,下图(右)是使用graphviz画出来的CFG。 -
C语言中的for, while, if等关键字都代表着分支跳转,
比如在LLVM IR中"br label %7"意味着无论如何都跳转到名为%7的label那里,这是一条无条件跳转指令。
"br i1 %10, label %11, label %22"是有条件跳转,意味着这如果%10是true则跳转到名为%11的label,否则跳转到名为%22的label。 -
除了第一个Basic Block之外,每个Basic Block都会有一个名字(label)。
Basic Block解决了控制逻辑的问题,一个Basic Block是指一段串行执行的指令流。
除了第一个Basic Block之外,每个Basic Block都会有一个名字(label),例如在这段代码当中一共有5个Basic Block。 -
CFG(Control Flow Graph, 控制流图)其实就是由Basic Block以及Basic Block之间的跳转关系组成的一个图。
例如如图所示的代码,一共有5个Basic Block,箭头列出了Basic Block之间的跳转关系,共同组成了一个CFG。
如果一个Basic Block只有一个箭头指向别的Block,那么这个跳转就是无条件跳转,否则是有条件跳转。
CFG是编译理论中一个比较简单而且很基础的概念,CFG更进一步是DFG(Data Flow Graph,数据流图),很多进阶的编译优化算法都是基于DFG的。
-
SSA。
SSA的全称是Static Single Assignment(静态单赋值),每个“变量”只会被赋值一次,这就是SSA的核心思想。
因为从编译器的角度来看,编译器不关心“变量”,编译器是以“数据”为中心进行设计的。
每个“变量”的每次写入,都生成了一个新的数据版本,编译器的优化是围绕数据版本展开的。 -
eg:如图(左)展示了一段简单的C代码,如图(右)是这短代码的SSA版本,也就是“编译器眼中的代码”
编译器只关心数据的流向,因此每次赋值操作都会生成一个新的左值。
在SSA中,每个变量都代表数据的一个版本。也就是说,高级语言以变量为核心,而SSA格式以数据为核心。 -
。Phi节点是SSA中的一个重要概念。
在这个例子当中,a_4的取值取决于之前执行了哪个分支,如果执行了第一个分支,那么a_4 = a_1, 依次类推。
Phi节点通过判断这段代码是从哪个Basic Block跳转而来,选择合适的数据版本。
LLVM IR自然也是需要开发者写Phi节点的,在循环、条件分支跳转的地方,往往需要手写很多phi节点
5.LLVM的简单例子
- 官方手册
- eg:下面是一个循环加法的函数片段。
如下的LLVM IR用clang编译器编译成object文件,然后和C语言写的程序链接到一起,即可正常调用。在上面提到的case中,我们只使用了i32、i64等基本数据类型,LLVM IR中支持struct等高级数据类型,可以实现更为复杂的功能
define i32 @ir_loopadd_phi(i32*, i32)
{
br label %loop
loop:
%i = phi i32 [0,%2], [%newi,%loop_body];
%res = phi i32[0,%2], [%new_res, %loop_body]
%break_flag = icmp sge i32 %i, %1
br i1 %break_flag, label %final, label %loop_body
loop_body:
%addr = getelementptr inbounds i32, i32* %0, i32 %i
%val = load i32, i32* %addr, align 4
%new_res = add i32 %res, %val
%newi = add i32 %i, 1
br label %loop
final:
ret i32 %res;
}
6.使用LLVM API实现Codegen
编译器本质上就是调用各种各样的API,根据输入去生成对应的代码,LLVM Codegen也不例外。
- 在LLVM内部,一个函数是一个class,一个Basic Block是一个class, 一条指令、一个变量都是一个class
- eg:用LLVM API实现codegen的例子
其实这就是个用C++写IR的过程,如果知道如何写IR的话,只需要熟悉下这套API就可以了。
这套API提供了一些基本的数据结构,比如指令、函数、基本块、llvm builder等,然后我们只需要调用相应的函数去生成这些对象即可。 - 一般来说,首先我们先生成函数的原型,包括函数名字、参数列表、返回类型等。
- 然后我们在根据函数的功能,确定都需要有哪些Basic Block以及Basic Block之间的跳转关系,然后生成相应的Basic。
- 最后我们再按照一定的顺序去给每个Basic Block填充指令。
Value *constant = Builder.getInt32(16);
Value *Arg1 = fooFunc->arg_begin();
Value *val = createArith(Builder, Arg1, constant);
Value *val2 = Builder.getInt32(100);
Value *Compare = Builder.CreateICmpULT(val, val2, "cmptmp");
Value *Condition = Builder.CreateICmpNE(Compare, Builder.getInt1(0), "ifcond");
ValList VL;
VL.push_back(Condition);
VL.push_back(Arg1);
BasicBlock *ThenBB = createBB(fooFunc, "then");
BasicBlock *ElseBB = createBB(fooFunc, "else");
BasicBlock *MergeBB = createBB(fooFunc, "ifcont");
BBList List;
List.push_back(ThenBB);
List.push_back(ElseBB);
List.push_back(MergeBB);
Value *v = createIfElse(Builder, List, VL);
7.Codegen技术分析
LLVM IR的性能并不会比C快
- 使用LLVM IR进行Codegen并不会获得比手写C更好的性能,而且使用LLVM Codegen有一些明显的缺点。
- 想要真正用好LLVM,我们还需要熟悉LLVM的特点。
缺点
- 开发难。实际开发中几乎不会有工程使用汇编作为主要开发语言,因为开发难度太大了。另外,LLVM IR很难处理复杂的数据结构,比如结构体、类。除了LLVM IR中的那些基本数据结构外,新增一个复杂的数据结构非常难。因此在实际的开发当中,采用Codegen会导致开发难度指数级上升
- 调试难。开发者通常通过单步跟踪的方式去调试代码,但是LLVM IR是不支持的。
- 生成LLVM IR往往很快,但是生成的IR需要调用LLVM 中的工具进行优化、以及编译成二进制文件,这个过程是需要时间的
在数据库的开发过程中,我们的经验值是每个函数大约需要10ms-100ms的codegen成本。
大部分的时间花在了优化IR和IR到汇编这两步。
适用LLVM Codegen的场景
- Java/python等语言,例如在Java中,有时候为了提升性能,会通过JNI调用一些C的函数提升性能。同理,Java也可以调用LLVM IR生成的函数提升性能。
- 硬件和语言不兼容。LLVM支持多种后端,比如X86、ARM和GPU。对于一些硬件与语言不兼容的场景,可以利用LLVM实现兼容。
例如如果我们的系统是用Java语言开发、想要调用GPU,可以考虑用LLVM IR生成GPU代码,然后通过JNI的方法进行调用。这套方案不仅支持NVIDIA的GPU,也支持AMD的GPU,而且对应生成的IR也可以在CPU上执行。 - 逻辑简化。
以数据库为例,数据库执行引擎在执行过程中需要做大量的数据类型、算法逻辑相关的判断。
这主要是由于SQL中的数据类型和逻辑,很多是在数据库开发时无法确定的,只能在运行时决定。这一部分过程,也被称为“解释执行”。
我们可以利用LLVM在运行时生成代码,由于这个时候数据类型和逻辑已经确定,我们可以在LLVM IR中删除那些不必要的判断操作,从而实现性能的提升
8.LLVM 可优化的点
优化频繁调用的存取层
- 数据库执行器通过存取层装在数据,针对特定的表结构,可以定制读取和解析 tuple 的代码,其中有很多优化点。
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: …
// etc.
}
}
}
当知道表结构后, 动态生成的代码就简单了。
按照顺序解析数据,不需要做数据类型的判断,直接在获取对应偏移的数据,跳过不需要获得的列,是能够优化 CPU 的点。随着处理的行数增加,节省的计算量是惊人的。
void MaterializeTuple(char* tuple)
{
*(tuple + 0) = ParseInt(); // i = 0
*(tuple + 4) = ParseBoolean(); // i = 1
*(tuple + 5) = ParseInt(); // i = 2
}
表达式计算
- 以 PostgreSQL 为例,它的表达式计算基于一套通用的框架,表达式求值计算像一颗二叉树一样,求值过程就是从叶子节点算到根节点。整个步骤是递归方式执行。
- eg:
select funx(((1 + a.b) + a.a )) /2 from a
他的执行过程是
expr(root) // funroot
funx(data) // funx
data (/) 2 // fundivision
data (+) a.a // funadd
1 (+) a.b // funadd
可以看到,表达式越复杂,递归调用越深。执行的函数个数越多。
然而,在获取到查询计划之后再“写”代码,就简单了,只需要这样
llvm_expr_fun1
{
tmp = 1 (+) a.b;
tmp1 = tmp + a.a;
tmp3 = tmp1 / 2;
tmp4 = funx(tmp3);
}
好处:
(1)递归改顺序执行
(2)整个过程一个函数调用完成,性能提高明显。
整个优化方式适合 OLTP 和 OLAP 场景,我们可以像保存查询计划那样保存 LLVM 生成的表达式函数
优化执行器流程
- 这部分是表达式计算的高级版本,改进的单位从每一行数据的处理提高到整个 SQL 的处理流程。
- 传统的流水线执行方式效率不高,如果调整成循环批量处理方式,能充分利用 CPU cache,大幅提高效率。
- eg:
select *
from R1,R3,
( select R2.z,
count(*)
from R2
where R2.y=3
group by R2.z
) R2
where R1.x=7
and R1.a=R3.b
and R2.z=R3.c
- 上面的 SQL 采用传统的方式是 pipeline 方式,如下图
- 如果采用如下“批量”处理的方式
他的核心思想是批量处理,单独处理每个表,把处理结果固化下来 这么做的优势是充分利用现代 CPU 的大缓存,让尽可能多的数据的计算能够在 CPU cache 中完成,避免去相对慢得多的内存中存取数据。
再结合 CPU 向量计算相关指令集,性能会进一步提高。LLVM 会帮我们做到这一点。
initialize memory of ona=b, onc=z, and Gz
for each tuple t in R1
if t:x = 7
materialize t in hash table of ona=b
for each tuple t in R2
if t:y = 3
aggregate t in hash table of Gz
for each tuple t in Gz
materialize t in hash table of onz=c
for each tuple t3 in R3
for each match t2 in onz=c [t3:c]
for each match t1 in ona=b [t3:b]
output t1 ◦t2 ◦t3
结论
- LLVM 降低了 JIT 的工程化难度,使得它最近几年在数据仓库领域得到了广泛的普及
9.LLVM如何在数据库中使用?
PostgreSQL为了实现表达式的解释执行,采用了一套“拼函数”的方案
- PostgreSQL中实现了大量C函数,比如加减法、大小比较等,不同类型的都有。
- SQL在生成执行计划阶段会根据表达式符号的类型和数据类型选择相应的函数、把指针存下来,等执行的时候再调用。
- 因此对于 "a > 10 and b < 5"这样的过滤条件,假设a和b都是int32,PostgreSQL实际上调用了“Int8AndOp(Int32GT(a, 10), Int32LT(b, 5))”这样一个函数组合,就像搭积木一样。
这样的方案有两个明显的性能问题:
一方面,这种方案会带来比较多次数的函数调用,函数调用本身是有成本的;
另一方面,这种方案必须要实现一个统一的函数接口,函数内部和外部都需要做一些类型转换,这也是额外的性能开销。 - Odyssey使用LLVM 进行codegen,可以实现最小化的代码。
因为在SQL下发以后,数据库是知道表达式的符号和输入数据的类型的,因此只需要根据需求选取相应的IR指令就可以了。
因此只需要三条IR指令,就可以实现这个表达式,然后我们把表达式封装成一个函数,就可以在执行的时候调用了。
这次操作,把多次函数调用简化成了一次函数调用,大大减少了指令的总数量。
// 样例SQL select count(*) from table where a > 10 and b < 5;
// PostgreSQL解释执行方案:多次函数调用
result = Int8AndOp(Int32GT(a, 10), Int32LT(b, 5));
// AnalyticDB PostgreSQL方案:使用LLVM codegen生成最小化底层代码
%res1 = icmp ugt i32 %a, 10;
%res2 = icmp ult i32 %b, 5;
%res = and i8 %res1, %res2;
在数据库中,表达式主要出现在几个场景。
- 一类是过滤条件,通常出现在where条件中。
- 一类是输出列表,一般跟在select之后。有些算子,比如join、agg等,它的判断条件中也可能会出现一些比较复杂的表达式。因此表达式的处理是会出现在数据库执行引擎的各个模块的。
- 在AnalyticDB PostgreSQL版中,开发团队抽象出了一个表达式处理框架,通过LLVM Codegen来处理这些表达式,从而提高了执行引擎的整体性能。
10.PG中的JIT
JIT 指的是即时编译,
- 即程序在运行过程中即时进行编译,其中可以把编译的中间代码缓存或者优化。相对于静态编译代码,即时编译的代码可以处理延迟绑定并增强安全性。
- LLVM
就提供了一种在程序运行时编译执行代码的程序框架,它对外提供API,使实现JIT 变得更加简单。
PostgreSQL 需要JIT 技术的原因
- 因为PostgreSQL 代码中实现的都是通用的逻辑,这就导致在执行过程中可能造成大量不必要的跳转和代码分支执行,继而造成大量不必要的指令执行,造成CPU 的压力。
- 使用JIT 技术可以将代码扁平化(inline)执行,直接调用对应的函数,而且如果已经知道具体输入,可以直接删除掉很多间接代码的执行。
- 在PostgreSQL 中实现JIT 选择LLVM 的理由,概括起来就是LLVM 成熟度更高,更稳定,license 更友好,支持C 语言。
PostgreSQL 中JIT 的实现概述
- PostgreSQL 11 中实现的JIT,是把对应的JIT 的提供者封装成了一个依赖库,这避免了JIT 对主体代码的侵入性。
- 不过这样带来的问题就是各个部分使用JIT 技术编译的代码必须和原来的代码位置分开,这样代码易读性可能有所降低。
PostgreSQL 中支持的JIT 功能
- 在数据库实现中LLVM 可优化的点,其中包括:
优化频繁调用的存取层
表达式计算
优化执行器流程 - JIT accelerated operations
利用LLVM 的特性,PostgreSQL 定制化地实现了两方面的加速操作,包括表达式计算优化(expression evaluation)和元组变形优化(tuple deforming)。
(1)表达式计算优化可以针对WHERE 条件,agg 运算等实时将表达式的路径编译为具体的代码执行,在此过程中大量的不必要的调用和跳转会被优化掉
(2)元组变形优化可以将具体元组转化为其在内存中运行的状态,然后根据元组每列的具体类型和元组中列的个数实时编译为具体的代码执行,在此过程中不必要的代码分支会被优化掉。
表达式和元组操作经常会造成分析型场景下的CPU 性能瓶颈,加速这两方面可以提高PostgreSQL 的分析能力。 - inlining
(1)PostgreSQL 源码中含有大量通用的代码,执行时会经过很多不必要的函数调用和操作。
(2)为了提高执行效率,将通用代码重写或维护两份很明显是不可取的。
(3)而JIT 技术带来的好处之一就是执行的时候将代码扁平化,去掉不必要的函数调用和操作。
以LLVM 为例,Clang 编译器可以生成LLVM IR(中间表示代码)并优化,这在一定意义上就代表了两份代码。
在PostgreSQL 中LLVM IR 使用的是bitcode(二进制格式),对应安装在 p k g l i b d i r / b i t c o d e / p o s t g r e s / 中 , 而 对 应 插 件 的 b i t c o d e 会 安 装 在 pkglibdir/bitcode/postgres/ 中,而对应插件的bitcode 会安装在 pkglibdir/bitcode/postgres/中,而对应插件的bitcode会安装在pkglibdir/bitcode/[extension]/ 中,其中extension 为插件名。 - optimization
(1)LLVM 中实现了对产生的中间表示代码的优化,这一定程度上也会提升数据库查询的执行速度。
(2)但是该过程本身是有相应的代价的,有些优化可能代价比较低,可以很好地提高性能,而有些可能只有在大的查询中才会体现其提高性能的作用。
所以,在PostgreSQL 中定制了一些GUC 参数来限制JIT 功能的开启
与JIT 相关的GUC 参数
- jit
该参数为on 的时候代表打开JIT,反之则是不打开JIT。非常棒的一点是这个参数可以在session 中设置,这就给用户更大的主动权。 - jit_provider,该参数表示提供JIT 的依赖库,默认为llvmjit。
其实目前PostgreSQL 11 也只实现了llvmjit 一种方式。如果填写了不存在的依赖库,JIT 不会生效,也没有error 产生。 - jit_above_cost
表示超过多少cost 的查询才会使用JIT 功能,其中不包含开销比较大的optimization。
因为JIT 会增加一定的开销,所以这个参数可以使得满足要求的查询使用JIT,这样更大概率会起到加速的效果。默认为100000,如果设置为-1 则关闭JIT。 - jit_inline_above_cost
表示超过多少cost 的查询使用JIT 的inline 功能。默认为500000,-1则关闭inline 功能。如果把这个值设置的比jit_above_cost 小,则达到了该cost,JIT 还是不会触发,没有意义。 - jit_optimize_above_cost
表示超过多少cost 的查询使用JIT 的optimization 功能。默认为500000,-1则关闭优化功能。
和jit_inline_above_cost 一样,如果把这个值设置的比jit_above_cost 小,没有意义。建议该值设置的比jit_inline_above_cost 大,这样可以在触发inline 功能后,开启optimization 功能。
目前JIT 功能开启所需要的代价没有很好的办法进行建模,也没有很好的方法来估计,所以导致JIT 功能无法作为代价估计模型中一种可量化的代价。
- 不过要想实现查询的某个部分使用JIT 功能需要一些额外的信息输入和判断,这带来的代价是否足够小也是存疑的。
11.PG中启用JIT的对比测试
安装
- 如果你是使用RPM 安装的PostgreSQL 11,则需要另行安装postgresql11-llvmjit 包。
- 如果你使用的是源码编译,则需要在configure 阶段增加–with-llvm 选项,同时指定LLVM_CONFIG 变量,即LLVM 包的llvm-config 位置,还需要指定CLANG 变量,即Clang 的路径,举例如下:
./configure --with-llvm LLVM_CONFIG=/opt/rh/llvm-toolset-7/root/usr/bin/llvm-config CLANG=/opt/rh/llvm-toolset-7/root/usr/bin/clang
不开启JIT与开启JIT的测试
不开启
postgres=# create table test (id serial);
CREATE TABLE
postgres=# insert INTO test (id) select * from generate_series(1, 10000000);
INSERT 0 10000000
postgres=# set jit=off;
SET
postgres=# explain select count(*) from test;
QUERY PLAN
-----------------------------------------------------------------------------------------
Finalize Aggregate (cost=97331.43..97331.44 rows=1 width=8)
-> Gather (cost=97331.21..97331.42 rows=2 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=96331.21..96331.22 rows=1 width=8)
-> Parallel Seq Scan on test (cost=0.00..85914.57 rows=4166657 width=0)
(5 rows)
komablog=# explain analyze select count(*) from test;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=97331.80..97331.81 rows=1 width=8) (actual time=490.420..493.668 rows=1 loops=1)
-> Gather (cost=97331.58..97331.79 rows=2 width=8) (actual time=490.392..493.659 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=96331.58..96331.59 rows=1 width=8) (actual time=488.083..488.084 rows=1 loops=3)
-> Parallel Seq Scan on test (cost=0.00..85914.87 rows=4166687 width=0) (actual time=0.214..292.559 rows=3333333 loops=3)
Planning time: 0.057 ms
Execution time: 493.716 ms
(8 rows)
开启
postgres=# set jit = 'on';
SET
postgres=# show jit_above_cost;
jit_above_cost
----------------
100000
(1 row)
postgres=# show jit_inline_above_cost;
jit_inline_above_cost
-----------------------
500000
(1 row)
postgres=# show jit_optimize_above_cost;
jit_optimize_above_cost
-------------------------
500000
(1 row)
postgres=# explain select count(*) from test;
QUERY PLAN
-----------------------------------------------------------------------------------------
Finalize Aggregate (cost=97331.43..97331.44 rows=1 width=8)
-> Gather (cost=97331.21..97331.42 rows=2 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=96331.21..96331.22 rows=1 width=8)
-> Parallel Seq Scan on test (cost=0.00..85914.57 rows=4166657 width=0)
(5 rows)
postgres=# explain analyze select count(*) from test;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=97331.43..97331.44 rows=1 width=8) (actual time=415.747..415.748 rows=1 loops=1)
-> Gather (cost=97331.21..97331.42 rows=2 width=8) (actual time=415.658..418.129 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=96331.21..96331.22 rows=1 width=8) (actual time=409.043..409.044 rows=1 loops=3)
-> Parallel Seq Scan on test (cost=0.00..85914.57 rows=4166657 width=0) (actual time=0.148..250.496 rows=3333333 loops=3)
Planning Time: 0.054 ms
Execution Time: 418.175 ms
(8 rows)
postgres=# set jit_above_cost = 10; set jit_inline_above_cost = 10; set jit_optimize_above_cost = 10;
SET
SET
SET
postgres=# show max_parallel_workers_per_gather;
max_parallel_workers_per_gather
---------------------------------
2
(1 row)
postgres=# explain analyze select count(*) from test;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=97331.43..97331.44 rows=1 width=8) (actual time=441.672..441.672 rows=1 loops=1)
-> Gather (cost=97331.21..97331.42 rows=2 width=8) (actual time=441.547..446.028 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Partial Aggregate (cost=96331.21..96331.22 rows=1 width=8) (actual time=434.128..434.129 rows=1 loops=3)
-> Parallel Seq Scan on test (cost=0.00..85914.57 rows=4166657 width=0) (actual time=0.161..251.158 rows=3333333 loops=3)
Planning Time: 0.057 ms
JIT:
Functions: 9
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 1.096 ms, Inlining 109.551 ms, Optimization 22.201 ms, Emission 19.127 ms, Total 151.974 ms
Execution Time: 446.673 ms
(12 rows)
postgres=# set max_parallel_workers_per_gather = 0;
SET
postgres=# explain analyze select count(*) from test;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=169247.71..169247.72 rows=1 width=8) (actual time=1172.932..1172.933 rows=1 loops=1)
-> Seq Scan on test (cost=0.00..144247.77 rows=9999977 width=0) (actual time=0.028..745.134 rows=10000000 loops=1)
Planning Time: 0.046 ms
JIT:
Functions: 2
Options: Inlining true, Optimization true, Expressions true, Deforming true
Timing: Generation 0.298 ms, Inlining 0.881 ms, Optimization 3.986 ms, Emission 3.788 ms, Total 8.952 ms
Execution Time: 1173.292 ms
(8 rows)
可以看出:
-
没有达到对应GUC 参数规定的cost,即使jit=on,JIT 也不会起作用。
-
JIT 对应功能的开启是有一定代价的,对于一些SQL 语句可能会有性能的下降,如上面的例子select count(*) from test。
JIT 会继承并行查询带来的性能提升。 -
参考:为什么编译原理被称为龙书?,有说llvmJIT对SQL解析优化提升PG查询性能数倍,SQL解析本身消耗占比不高吧,能有这么高提升?,作为编译优化技术代表的开源编译器框架LLVM,如何在数据库中应用?,PgSQL · 特性分析 · 浅析PostgreSQL 中的JIT,JIT 在数据仓库中的应用价值,LLVM基本概念入门