【LLVM】内存计算引擎之Llvm

目录

概述

LLVM技术生态之编译器

一、传统编译器的设计

二、传统编译器模式的实现

三、LLVM的三段式实现

四、LLVM's Code Representation:LLVM IR

1、IR的表现形式

2、IR的格式文件类型

3、IR文件的编译处理流程

4、简单的IR布局

5、Llvm IR 编程

五、LLVM 与 GCC有什么区别

LLVM技术生态之JIT

一、JIT概述

二、为什么要使用JIT

三、JIT生成代码时的代码优化技术

1、语言无关的优化技术之一:公共子表达式消除

2、语言相关的优化技术之一:数组边界检查消除

3、最重要的优化技术之一:方法内联

4、最前沿的优化技术之一:逃逸分析

四、JIT运行的简单原理

四、Llvm JIT与C++ Template模板有什么不同

业界不同领域使用LLVM的方式

一、作为编译器使用

二、作为内存计算引擎使用

结论

参考资料


概述

 

个人认为Llvm是一个很”酷”的东西,本文主要从几个方面来讲解Llvm相关内容,Llvm是什么、传统编译器的设计、传统编译器的实现、Llvm的编译器如何实现的、LLVM IR是什么、JIT简单的实现原理、业界多领域是如何使用Llvm的等多个方面来描述LLVM。LLVM命名源自于底层虚拟机(Low Level Virtual Machine)的缩写。他不是一个类似于VMware这种虚拟机项目,他是类似于GCC一样的编译器框架。说到编译器框架就不得不提一提传统的编译器是怎么做的。

 

LLVM技术生态之编译器

 

一、传统编译器的设计

传统编译器采用三段式设计:

  • 前端: 前端组件解析程序源代码,检查语法错误,生成一个基于语言特性的AST(Abstract Syntax Tree)来表示输入代码。
  • 优化器:优化器组件接收到前端生成的AST,进行优化处理。
  • 后端:把优化器优化后的AST翻译成机器能识别的语言。

 

二、传统编译器模式的实现

这种模式的优点在于当编译器决定支持多种语言或者多种目标设备的时候,如果编译器在优化器这里采用普通的代码表示时,前端可以使用任意的语言来进行编译,后端也可以使用任意的目标设备来汇编。如下图:

使用这种设计,使编译器支持一种新的语言需要实现一个新的前端,但是优化器以及后端都是可以复用,不用改变的。那么实现支持新的语言需要从最初的前端设计开始,支持N种设备和M种源代码语言一共需要N*M种编译方式。

这种三段式设计的另一优点是编译器提供了一个非常宽泛的语法集,即对于开源编译器项目来说,这意味着会有更多的人参与进来,自然而然地就提升了项目的质量。这也是为什么一些开源的编译器通常更为流行。

最后一个优点是实现一个编译器前端相对于优化器与后端来说是完全不同的。将他们分离开来对于专注于设计前端来提升编译器的多用性(支持多种语言)来说相对容易点。

 

三、LLVM的三段式实现

在基于LLVM的编译器中,前端的作用是解析、验证和诊断代码错误,将解析后的代码翻译为LLVM IR(通常是这么做,通过生成AST然后将AST转为LLVM IR)。翻译后的IR代码经过一系列的优化过程与分析后,代码得到改善,并将其送到代码生成器去产生原生的机器码。过程如下图所示。这是非常直观的三段式设计的实现过程,但是这简单的描述当然是省去了一些细节的实现。

 

四、LLVM's Code Representation:LLVM IR

1、IR的表现形式

LLVM中最重要的设计模块:LLVM IR(LLVM Intermediate Representation),它是在编译器中用来表示代码的一种形式。它被设计用来在编译器的优化模块中,作为主导中间层的分析与转换。它是经过特殊设计的,包括支持轻量级的运行时优化、过程函数的优化,整个程序的分析和代码完全重构和翻译等等。其中最重要的是,它定义了清晰的语义。参考如下的.ll文件:

define i32 @add1(i32 %a, i32 %b) {entry:  %tmp1 = add i32 %a, %b  ret i32 %tmp1}define i32 @add2(i32 %a, i32 %b) {entry:  %tmp1 = icmp eq i32 %a, 0  br i1 %tmp1, label %done, label %recurserecurse:  %tmp2 = sub i32 %a, 1  %tmp3 = add i32 %b, 1  %tmp4 = call i32 @add2(i32 %tmp2, i32 %tmp3)  ret i32 %tmp4done:  ret i32 %b}

上述这段代码对应的是下面这段C代码,它提供两种方式返回一个整型变量:

unsigned int add1(unsigned int a, unsigned int b) {  return a+b;}// Perhaps not the most efficient way to add two numbers.unsigned int add2(unsigned int a, unsigned int b) {  if (a == 0) return b;  return add2(a-1, b+1);}

从这个例子中可以看出,LLVM IR 是一种底层的类RISC虚拟指令集。正如真正的RISC指令集一样,它提供了一系列线性的简单指令:加、减、比较以及分支结构。这些指令在三种地址形式中:即通过对一些输入的计算,得出的结果存在不同的寄存器中。LLVM IR提供了标签支持,它通常看起来像是一种奇怪的汇编语言一样。

和大多数RISC指令集不同的是,LLVM使用一种简单的类型系统来标记强类型(i32表示32位整型,i32**表示指向32位整型的指针),而一些机器层面的细节都被抽象出去了。例如函数调用使用call作标记,而返回使用ret标记。此外还有个不同是LLVM IR不直接像汇编语言那样直接使用寄存器,它使用无限的临时存储单元,使用%符号来标记这些临时存储单元。

 

2、IR的格式文件类型

形式实现一个阶乘,再在main函数中调用中这个阶乘:

 

// factorial.cint factorial(int n) {    if(n>=1) {        return n * factorial(n-1);    }    return 1;}// main.cppextern "C" int factorial(int);int main(int argc, char** argv) {    return factorial(2) * 7 == 42;}

注:这里的extern "C"是必要的,为了支持C++的函数重载和作用域的可见性的规则,编译器会对函数进行name mangling, 如果不加extern "C",下文中生成的main.ll文件中factorial的函数名会被mangling成类似_Z9factoriali的样子,链接器便找不到要链接的函数。

LLVM IR有两种等价的格式:

  1. 一种是.bc(Bitcode)文件
  2. 一种是.ll文件,.ll文件是Human-readable的格式。 

我们可以使用下面的命令得到这两种格式的IR文件

$ clang -S -emit-llvm factorial.c # factorial.ll$ clang -c -emit-llvm factorial.c # factorial.bc

当然LLVM也提供了将代码文本转为二进制文件格式的工具:llvm-as,它将.ll文件转为.bc格式文件,llvm-dis将.bc文件转为.ll文件。

$ llvm-as factorial.ll # factorial.bc$ llvm-dis factorial.bc # factorial.ll

对于cpp文件,只需将clang命令换成clang++即可。

$ clang++ -S -emit-llvm main.cpp # main.ll$ clang++ -c -emit-llvm main.cpp # main.bc

3、IR文件的编译处理流程

上图显示了llvm编译代码的一个pipeline, 其利用不同高级语言对应的前端(这里C/C++的前端都是clang)将其transform成LLVM IR,进行优化,链接后,再传给不同target的后端transform成target-specific的二进制代码。IR是LLVM的power所在,我们看下面这条command:

llvm-link factorial.bc main.bc -o linked.bc # lined.bc

 

llvm-link将两个IR文件链接起来了,值得注意的是factorial.bc是C转成的IR,而 main.bc是C++转成的IR,也就是说到了IR这个level,高级语言之间的差异消失了!它们之间可以相互链接。

我们进一步可以将链接得到的IR转成target相关的code

llc --march=x86-64 linked.bc # linked.s

 

下图展示了完整的build过程

4、简单的IR布局

4.1 Target information

我们以linked.ll为例进行解析,文件的开头是

; ModuleID = 'linked.bc'source_filename = "llvm-link"target datalayout = "e-m:e-i64:64-f80:128-n8:16:32:64-S128"target triple = "x86_64-unknown-linux-gnu"

;后面的注释指明了module的标识,source_filename是表明这个module是从什么文件编译得到的(如果你打开main.ll会发现这里的值是main.cpp),如果该modules是通过链接得到的,这里的值就会是llvm-link。

Target information的主要结构如下:

 

4.2 函数定义的主要结构

我们看一下函数factorial的定义

; Function Attrs: noinline nounwind optnone ssp uwtabledefine i32 @factorial(i32 %val) #0 {  %2 = alloca i32, align 4  %3 = alloca i32, align 4  store i32 %0, i32* %3, align 4  %4 = load i32, i32* %3, align 4  %5 = icmp sge i32 %4, 1  br i1 %5, label %6, label %12; <label>:6:                                      ; preds = %1  %7 = load i32, i32* %3, align 4  %8 = load i32, i32* %3, align 4  %9 = sub nsw i32 %8, 1  %10 = call i32 @factorial(i32 %9)  %11 = mul nsw i32 %7, %10  store i32 %11, i32* %2, align 4  br label %13; <label>:12:                                     ; preds = %1  store i32 1, i32* %2, align 4  br label %13; <label>:13:                                     ; preds = %12, %6  %14 = load i32, i32* %2, align 4  ret i32 %14}attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }!llvm.module.flags = !{!0, !1, !2}!llvm.ident = !{!3}!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 10, i32 14]}!1 = !{i32 1, !"wchar_size", i32 4}!2 = !{i32 7, !"PIC Level", i32 2}!3 = !{!"Apple LLVM version 10.0.1 (clang-1001.0.46.4)"}

前面已经提到,;表示单行注释的开始。define i32 @factorial(i32 %val) #0指出了该函数的attribute group,其中第一个i32是返回值类型,对应C语言中的int;%factorial是函数名;第二个i32是形参类型,%val是形参名。llvm中的标识符分为两种类型:全局的和局部的。全局的标识符包括函数名和全局变量,会加一个@前缀,局部的标识符会加一个%前缀。一般地,可用标识符对应的正则表达式为[%@][-a-zA-Z$._][-a-zA-Z$._0-9]*。

#0指出了该函数的attribute group。在文件的下面,你会找到类似这样的代码

attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

因为attribute group可能很包含很多attribute且复用到多个函数,所以我们IR使用attribute group ID(即#0)的形式指明函数的attribute,这样既简洁又清晰。

在一对花括号里的就是函数体,函数体是由一系列basic blocks(BB)组成的,这些BB形成了函数的控制流图(Control Flow Graph, CFG)。每个BB都有一个label,label使得该BB有一个符号表的入口点,在函数factorial中,这些BB的label就是entry、if.then、if.end,BB总是以terminator instruction(e.g. ret、br、callbr)结尾的。

 

4.3 IR是一个强类型语言

看一下函数main的定义

; Function Attrs: noinline norecurse optnone ssp uwtabledefine i32 @main(i32, i8**) #0 {  %3 = alloca i32, align 4  %4 = alloca i32, align 4  %5 = alloca i8**, align 8  store i32 0, i32* %3, align 4  store i32 %0, i32* %4, align 4  store i8** %1, i8*** %5, align 8  %6 = call i32 @factorial(i32 2)  %7 = mul nsw i32 %6, 7  %8 = icmp eq i32 %7, 42  %9 = zext i1 %8 to i32  ret i32 %9}

LLVM的IR是一个强类型语言,每一条指令都显式地指出了实参的类型,例如mul nsw i32 %6, 7表明要将两个i32的数值相乘,icmp eq i32 %7, 42, icmp eq 表明要将两个i32的数据类型进行相等比较。此外,我们还很容易推断出返回值的类型,比如i32的数相乘的返回值就是i32类型,比较两个数值的相等关系的返回值就是i1类型。

强类型不但使得IR很human readable,也使得在优化IR时不需要考虑隐式类型转换的影响。在main函数的结尾,zext i1 %8 to i32将%8从1位整数扩展成了32位的整数(即做了一个类型提升)。

如果我们把最后两行用以下代码替代

ret i32 %8

 

那么这段IR就变成illegal的,检查IR是否合法可以使用opt -verify <filename>命令

$ opt -verify linked.llopt: linked.ll:45:11: error: '%8' defined with type 'i1' but expected 'i32'  ret i32 %8

4.3 terminator instruction介绍

ret指令语法ret <type> <value>       ; Return a value from a non-void functionret void                 ; Return from void function概述ret用来将控制流从callee返回给callerExampleret i32 5                       ; Return an integer value of 5ret void                        ; Return from a void functionret { i32, i8 } { i32 4, i8 2 } ; Return a struct of values 4 and 2
br指令语法br i1 <cond>, label <iftrue>, label <iffalse>br label <dest>          ; Unconditional branch概述br用来将控制流转交给当前函数中的另一个BB。ExampleTest:  %cond = icmp eq i32 %a, %b  br i1 %cond, label %IfEqual, label %IfUnequalIfEqual:  ret i32 1IfUnequal:  ret i32 0
switch指令语法switch <intty> <value>, label <defaultdest> [ <intty> <val>, label <dest> ... ]概述switch根据一个整型变量的值,将控制流交给不同的BB。Example; Emulate a conditional br instruction%Val = zext i1 %value to i32switch i32 %Val, label %truedest [ i32 0, label %falsedest ]; Emulate an unconditional br instructionswitch i32 0, label %dest [ ]; Implement a jump table:switch i32 %val, label %otherwise [ i32 0, label %onzero                                    i32 1, label %onone                                    i32 2, label %ontwo ]

4.4 unreachable 介绍

语法unreachable概述unreachable告诉optimizer控制流时到不了这块代码,就是说这块代码是dead code。Example在展示unreachable的用法的之前,我们先看一下undef的用法。undef表示一个未定义的值,只要是常量可以出现的位置,都可以使用undef。(此Example标题下的代码为伪代码)%A = or %X, undef%B = and %X, undefor指令和and指令分别是执行按位或和按位与的操作,由于undef的值是未定义的,因此编译器可以随意假设它的值来对代码进行优化,譬如说假设undef的值都是0%A = %X%B = 0可以假设undef的值是-1%A = -1%B = %X也可以假设undef的两处值是不同的,譬如第一处是0,第二处是-1%A = -1%B = 0为什么undef的值可以不同呢?这是因为undef对应的值是没有确定的生存期的,当我们需要一个undef的值的时候,编译器会从可用的寄存器中随意取一个值拿过来,因此并不能保证其值随时间变化具有一致性。下面我们可以看unreachable的例子了%A = sdiv undef, %X%B = sdiv %X, undefsdiv指令是用来进行整数/向量的除法运算的,编译器可以假设undef的值是0,因为一个数除以0是未定义行为,因此编译器可以认为其是dead code,将其优化成%A = 0unreachable

4.5 静态单一赋值介绍

在IR中,每个变量都在使用前都必须先定义,且每个变量只能被赋值一次(如果套用C++的术语,就是说每个变量只能被初始化,不能被赋值),所以我们称IR是静态单一赋值的。举个例子的,假如你想返回a*b+c的值,你觉得可能可以这么写

 

%0 = mul i32 %a, %b%0 = add i32 %0, %cret i32 %0但是这里%0被赋值了两次,是不合法的,我们需要把它修改成这样%0 = mul i32 %a, %b%1 = add i32 %0, %cret i32 %1

4.6 SSA

SSA可以简化编译器的优化过程,譬如说,考虑这段代码

d1: y := 1

d2: y := 2

d3: x := y

 

我们很容易可以看出第一次对y赋值是不必要的,在对x赋值时使用的y的值时第二次赋值的结果,但是编译器必须要经过一个定义可达性(Reaching definition)分析才能做出判断。编译器是怎么分析呢?首先我们先介绍几个概念:

变量x的定义是指一个会给x赋值或可能给x赋值的语句,譬如d1就是对y的一个定义

当一个变量x有新的定义后 ,旧的的定义会被新的定义kill掉,譬如d2就kill掉了d1。

一个定义d到达点p是指存在一条d到p路径,在这条路径上,d没有被kill掉

t1是t2的reaching definition是指存在一条t1到t2路径,沿着这条路径走就可以得到t1要赋值的变量的值,而不需要额外的信息。

按照上面的代码写法,编译器是很难判断d3的reaching definition的。因为d3的reaching definition可能是d1,也可能是d2,要搞清楚d1和d2谁kill了谁很麻烦。但是,如果我们的代码是SSA的,则代码就会长成这样

d1: y1 := 1

d2: y2 := 2

d3: x := y2

编译发现x是由y2赋值得到,而y2被赋值了2,且x和y2都只能被赋值一次,显然得到x的值的路径就是唯一确定的,d2就是d3的reaching definition。

SSA带来的问题

假设你想用IR写一个用循环实现的factorial函数

int factorial(int val) {

  int temp = 1;

  for (int i = 2; i &lt;= val; ++i)

  temp *= i;

  return temp;

}按照C语言的思路,我们可能大概想这样写

然而跑opt -verify &lt;filename>命令我们就会发现%temp和%i被多次赋值了,这不合法。但是如果我们把第二处的%temp和%i换掉,改成这样

那返回值就会永远是1。于是引入phi指令处理。

 

4.6 PHI指令

语法<result> = phi <ty> [<val0>, <label0>], [<val1>, <label1>] …概述根据前一个执行的是哪一个BB来选择一个变量的值。有了phi指令,我们就可以把代码改成这样

这样的话,每个变量就只被赋值一次,并且实现了循环递增的效果。

 

4.7 alloca指令

语法<result> = alloca [inalloca] <type> [, <ty> <NumElements>] [, align <alignment>] [, addrspace(<num>)]概述在当前执行的函数的栈帧上分配内存并返回一个指向这片内存的指针,当函数返回时内存会被自动释放(一般是改变栈指针)。有了alloca指令,我们也可以通过使用指针的方式间接多次对变量赋值来骗过SSA检查

 

4.8 全局变量

IR中的全局变量定义了一块在编译期分配的内存区域,其类型是一个指针,跟指令alloca的返回值用法一样。我们看一下一段使用全局变量简单的C代码对应的IR是什么样子

// a.cstatic const int a=0;const int b=1;const int c=1;int d=a+1;; a.ll@b = dso_local constant i32 1, align 4@c = dso_local constant i32 1, align 4@d = dso_local global i32 1, align 4

前面已经讲过dso_local是一个Runtime Preemption,表明该变量会在同一个链接单元内解析符号,align 4表示4字节对齐。global和constant关键字都可以用来定义一个全局变量,全局变量名必须有@前缀,因为全局变量会参与链接,所以除去前缀外,其名字会跟你用C语言定义时的相同。

因为我们定义变量a时使用了C语言的static关键字,也就是说a是local to file的,不参与链接,因此我们可以在生成的IR中可以看到,其被优化掉了。

// b.cextern const int b;extern const int c;extern const int d;int f() {  return b*c+d;}; b.ll@b = external dso_local constant i32, align 4@c = external dso_local constant i32, align 4@d = external dso_local constant i32, align 4define dso_local i32 @f() #0 {entry:  %0 = load i32, i32* @b, align 4  %1 = load i32, i32* @c, align 4  %mul = mul nsw i32 %0, %1  %2 = load i32, i32* @d, align 4  %add = add nsw i32 %mul, %2  ret i32 %add}

从函数f的IR可以看到,全局变量其实是一个指针,在使用其时需要load指令(赋值时需要store指令)。那gloal和constant有什么区别呢?constant相比global,多赋予了全局变量一个const属性(对应C++的底层const的概念,表示指针指向的对象是一个常量)。

跟C/C++类似,IR中可以在定义全局变量时使用global,而在声明全局变量时使用constant,表示该变量在本文件内不改变其值。

我们可以使用opt -S --globalopt <filename>命令对全局变量进行优化$ opt -S --globalopt a.ll -o a-opt.ll@b = dso_local local_unnamed_addr constant i32 1, align 4@c = dso_local local_unnamed_addr constant i32 1, align 4@d = dso_local local_unnamed_addr global i32 1, align 4

可以看到优化过,全局变量前多了local_unnamed_addr的attribute, 该属性表明在这个module内,这个变量的地址是不重要的,只要关心它的值就好。有什么作用呢?譬如说这里b和c都是常量且等于1,又有local_unnamed_addr属性,编译器就可以把b和c合并成一个变量。

 

4.9 Aggregate Types

这里我们使用英文Aggregate Types主要是想跟C++的Aggregate Class区分开。IR的Aggregate Types包括数组和结构体。

数组语法[<elementnumber> x <elementtype>]概述跟C++的模板类template<class T, std::size_t N > class array类似,数组元素在内存中是连续分布的,元素个数必须是编译器常量,未被提供初始值的元素会被零初始化,只是下标的使用方式有点区别。Example@array = global [17 x i8] ; 17个i8都是0%array2 = alloca [17 x i8] [i8 1, i8 2] ; 前两个是1、2,其余是0%array3 = alloca [3 x [4 x i32]] ; 3行4列的i32数组@array4 = global [2 x [3 x [4 x i16]]] ; 2x3x4的i16数组
结构体语法%T1 = type { <type list> }     ; Identified normal struct type%T2 = type <{ <type list> }>   ; Identified packed struct type概述与C语言中的struct相同,不过IR提供了两种版本,normal版元素之间是由padding的,packed版没有。Example%struct1 = type { i32, i32, i32 } ; 一个i32的triple%struct2 = type { float, i32 (i32) * } ; 一个pair,第一个元素是float,第二个元素是一个函数指针,该函数有一个i32的形参,返回一个i32 %struct3 = type <{ i8, i32 }> ; 一个packed的pair,大小为5字节
getelementptr指令(GEP)我们可以使用 getelementptr指令来获得指向数组的元素和指向结构体成员的指针。语法<result> = getelementptr <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}*<result> = getelementptr inbounds <ty>, <ty>* <ptrval>{, [inrange] <ty> <idx>}*概述第一个ty是第一个索引使用的基本类型第二个ty表示其后的基址ptrval的类型。 <ty> <idx>是第一组索引的类型和值,<ty> <idx>可以出现多次,其后出现的就是第二组、第三组等等索引的类型和值。要注意索引的类型和索引使用的基本类型是不一样的,索引的类型一般为i32或i64,而索引使用的基本类型确定的是增加索引值时指针的偏移量。

GEP的几个要点

理解第一个索引

  1. 第一个索引不会改变返回的指针的类型,也就是说ptrval前面的<ty>*对应什么类型,返回就是什么类型

  2. 第一个索引的偏移量的是由第一个索引的值和第一个ty指定的基本类型共同确定的。

下面看个例子

上图中第一个索引所使用的基本类型是[6 x i8],值是1,所以返回的值相对基址@a_gv前进了6个字节。由于只有一个索引,所以返回的指针也是[6 x i8]*类型。

理解后面的索引

后面的索引是在 Aggregate Types内进行索引每增加一个索引,就会使得该索引使用的基本类型和返回的指针的类型去掉一层下面看个例子

我们看%elem_ptr = getelementptr [6 x i8], [6 x i8]* @a_gv, i32 0, i32 0这一句,第一个索引值是0,使用的基本类型[6 x i8], 因此其使返回的指针先前进0 x 6 个字节,也就是不前进,第二个索引的值是1,使用的基本类型就是i8([6 x i8]去掉左边的6),因此其使返回的指针前进一个字节,返回的指针类型为i8*([6 x i8]*去掉左边的6)。

GEP如何作用于结构体

只有一个索引情况下,GEP作用于结构体与作用于数组的规则相同,%new_ptr = getelementptr %MyStruct*, %MyStruct* @a_gv, i32 1使得%new_ptr相对@a_gv偏移一个结构体%MyStruct的大小。

在有两个索引的情况下,第二个索引对返回指针的影响跟结构体的成员类型有关。譬如说在上图中,第二个索引值是1,那么返回的指针就会偏移到第二个成员,也就是偏移1个字节,由于第二个成员是i32类型,因此返回的指针是i32*。

如果结构体的本身也有Aggregate Type的成员,就会出现超过两个索引的情况。第三个索引将会进入这个Aggregate Type成员进行索引。譬如说上图中的第二个索引是2,指针先指向第三个成员,第三个成员是个数组。再看第三个索引是0,因此指针就指向该成员的第一个元素,指针类型也变成了i32*。

 

5、Llvm IR 编程

使用LLVM IR编程要涉及到Module, Function, BasicBlock, Instruction, ExecutionEngine等概念。下面对这些概念进行一个简单的说明。

  • 可以将LLVM中的Module类比为C程序中的源文件。一个C源文件中包含函数和全局变量定义、外部函数和外部函数声明,一个Module中包含的内容也基本上如此,只不过C源文件中是源码来表示,Module中是用IR来表示。
  • Function是LLVM JIT操作的基本单位。Function被Module所包含。LLVM的Function包含函数名、函数的返回值和参数类型。Function内部则包含BasicBlock。
  • BasicBlock与编译技术中常见的基本块(basic block)的概念是一致的。BasicBlock必须以跳转指令结尾。
  • Instruction就是LLVM IR的最基本单位。Instruction被包含在BasicBlock中。

ExecutionEngine是用来运行IR的。运行IR有两种方式:解释运行和JIT生成机器码运行。相应的ExecutionEngine就有两种:Interpreter和JIT。ExecutionEngine的类型可以在创建ExecutionEngine时指定。

 

LLVM IR编程基本流程

  1. 创建一个Module
  2. 在Module中添加Function
  3. 在Function中添加BasicBlock
  4. 在BasicBlock中添加指令
  5. 创建一个ExecutionEngine
  6. 使用ExecutionEngine来运行IR

 

LLVM IR编程示例与说明

创建Module

Module创建时需要一个context,通常使用global context。在例子中,Module的name被设置为test。

LLVMContext & context = llvm::getGlobalContext();Module* module = new Module("test", context);

在Module中添加Function

在Module中添加Function的方法比较多,这里介绍一种比较简洁的方法。下面的代码生成了一个函数void foo(void)。

Constant* c = module->getOrInsertFunction("foo",/*ret type*/                           Type::getVoidTy(context),/*args*/                               Type::getVoidTy(context),/*varargs terminated with null*/       NULL);Function* foo = cast<Function>(c); /* cast is provided by LLVMfoo->setCallingConv(CallingConv::C);

到目前为止,还没有添加BasicBlock,函数foo仅仅是一个函数原型。第6行设置foo遵循C函数调用的规则。LLVM中的函数支持多种调用规则,通常使用C的调用规则即可。更多调用规则可以参考llvm::CallingConv::ID。

在Function中添加BasicBlock

创建BasicBlock可以使用BasicBlock类的静态函数Create。

BasicBlock* block = BasicBlock::Create(context, "entry", foo);

 

第三个参数foo表示将block插入到Function foo中。

在BasicBlock中添加指令

下面介绍一个在BasicBlock中添加指令的简洁方法。这个方法使用了一个工厂类IRBuilder的实例builder。

首先,初始化builder。

IRBuilder<> builder(block);

 

这里将block作为参数表示接下来的指令将被插入到block中。

接下来的一段代码开始向block中插入代码。含义包含在注释中。

//Create three constant integer x, y, z.Value *x = ConstantInt::get(Type::getInt32Ty(context), 3);Value *y = ConstantInt::get(Type::getInt32Ty(context), 2);Value *z = ConstantInt::get(Type::getInt32Ty(context), 1);//addr = &value/* we will check the value of 'value' and see** whether the function we construct is running correctly.*/long value = 10;Value * addr = builder.CreateIntToPtr(    ConstantInt::get(Type::getInt64Ty(context), (uint64_t)&value),    Type::getInt64PtrTy(context),    "addr");// mem = [addr]Value* mem = builder.CreateLoad(addr, "mem");// tmp = 3*memValue* tmp = builder.CreateBinOp(Instruction::Mul,                                 x, mem, "tmp");// tmp2 = tmp+2Value* tmp2 = builder.CreateBinOp(Instruction::Add,                                  tmp, y, "tmp2");// tmp3 = tmp2-1Value* tmp3 = builder.CreateBinOp(Instruction::Sub,                                  tmp2, z, "tmp3");// [addr] = membuilder.CreateStore(tmp3, addr); // retbuilder.CreateRetVoid();

至此,我们通过LLVM的IR生成一个Module test,这个Module中包含一个Function foo,而foo中包含一个BasicBlock entry。

展示已经生成的IR

我们可以使用Module的dump方法先展示目前的成果。

module->dump();

 

输出结果

; ModuleID = 'test'define void @foo(void) {entry:  ; the number '140735314124408' maybe different on your machine.  %mem = load i64* inttoptr (i64 140735314124408 to i64*)   %tmp = mul i32 3, i64 %mem  %tmp2 = add i32 %tmp, 2  %tmp3 = sub i32 %tmp2, 1; the number '140735314124408' maybe different on your machine.  store i32 %tmp3, i64* inttoptr (i64 140735314124408 to i64*)  ret void}

创建ExecutionEngine

接下来就要使用ExecutionEngine来生成代码了。

创建一个JIT类型的ExecutionEngine,为了便于观察IR生成的机器码,设置为不优化。

InitializeNativeTarget();    ExecutionEngine *ee = EngineBuilder(module).setEngineKind(EngineKind::JIT)        .setOptLevel(CodeGenOpt::None).create();

生成机器指令。

JIT生成机器指令以Function为单位。

void * fooAddr = ee->getPointerToFunction(foo);std::cout <<"address of function 'foo': " << std::hex << fooAddr << std::endl;

如果用gdb跟踪函数执行,待输出fooAddr后,用x/i命令,即可查看foo对应的机器指令。

例如,我的X86_64机器上输出为:

0x7ffff7f6d010:  movabs $0x7fffffffe2b0,%rax0x7ffff7f6d01a:  mov    $0x3,%ecx0x7ffff7f6d01f:  mov    (%rax),%edx0x7ffff7f6d021:  imul   %ecx,%edx0x7ffff7f6d024:  add    $0x2,%edx0x7ffff7f6d02a:  sub    $0x1,%edx0x7ffff7f6d030:  mov    %edx,(%rax)0x7ffff7f6d032:  retq

运行机器指令

使用类型转换将fooAddr转换成一个函数fooFunc,然后调用。

//Run the functionstd::cout << std::dec << "Before calling foo: value = " << value <<  std::endl;typedef  (*FuncType)(void);FuncType fooFunc = (FuncType)fooAddr;fooFunc();std::cout << "After calling foo: value = " << value <<  std::endl;

我们使用value的值来检验foo构造的正确性。运行之后的输出

Before calling foo: value = 10After calling foo: value = 31

经过验算,foo的功能是正确的。

直接生成并运行机器指令

ExecutionEngine还提供一个接口runFunction直接JIT并运行机器指令。具体做法可以参考LLVM::ExecutionEngine::runFunction的文档。

 

五、LLVM 与 GCC有什么区别

有一种说法,gcc编译器的代码,很难被复用到其他项目中。gcc和基于LLVM实现的编译器其实都是分为前端、优化器、后端等模块,为什么gcc就不能被复用呢?

这就是LLVM设计的精髓所在:完全模块化。就拿优化器来说,典型的优化类型(LLVM优化器中称为Pass)有代码重排(expression reassociation)、函数内联(inliner)、循环不变量外移( loop invariant code motion)等。在gcc的优化器中,这些优化类型是全部实现在一起形成一个整体,你要么不用,要么都用;或者你可以通过配置只使用其中一些优化类型。而LLVM的实现方式是,每个优化类型自己独立成为一个模块,而且每个模块之间尽可能的独立,这样就可以根据需要只选择你需要的优化类型编译进入你的程序中而不是把整个优化器都编译进去。

LLVM实现的方法是用一个类来表示一个优化类型,所有优化类型都直接或者间接继承自一个叫做Pass的基类,并且大多数都是自己占用一个.cpp文件,并且位于一个匿名命名空间中,这样别的.cpp文件中的类便不能直接访问它,只提通过一个函数获取到它的实例,这样pass之间就不会存在耦合,如下面代码所示:

namespace {  class Hello : public FunctionPass {  public:    // Print out the names of functions in the LLVM IR being optimized.    virtual bool runOnFunction(Function &F) {      cerr << "Hello: " << F.getName() << "\n";      return false;    }  };}FunctionPass *createHelloPass() { return new Hello(); }

每个.cpp会被编译成一个目标文件.o文件,然后被打包进入一个静态链接库.a文件中。当第三方又需要使用到其中一些优化类型,它只需要选择自己需要的。由于这些类型都是自己独立于.a的一个.o中,因此的只有真正被用到的.o会被链接进入目标程序,这就实现了“用多少取多少”的目标,不搞“搭售”。而第三方如果还有自己独特的优化要求,只要按照同样的方法实现一个优化即可

 

打个比方,如果将优化器比作卖电脑的,那么gcc的优化器相当于卖笔记本,称为A;而LLVM的优化器相当于卖组装的台式机的,称为B。或许你自己有了其他合适的部件,就差一颗强劲的CPU。你去A店里要么不买,要么就买一个功能齐全的笔记本,A店不允许你只买某台笔记本上的一颗芯片;而你去B店里可以做到只买一颗芯片。

到了这里,我们终于可以回答LLVM和gcc的区别了:

LLVM本身只是一堆库,它提供的是一种机制(mechanism),一种可以将源代码编译的机制,但是它自己本身并不能编译任何代码。也就是说编译什么代码、怎么编译、怎么优化、怎么生成这些策略(strategy)是由用户自己定的。例如clang就使用LLVM提供的这些机制制定了编译C代码的策略,因此前文中说clang可以称之为驱动(driver)。还拿电脑做例子:一堆电脑零件本身并不能做任何事情,这么将它们组装起来让它们工作是使用者的事儿。

 

LLVM技术生态之JIT

一、JIT概述

说到JIT可能学过JAVA的同学不那么陌生,JAVA中就是使用JIT技术来进行计算的。JIT(just-in-time)即时编译技术是在运行时(runtime)将调用的函数或程序段编译成机器码载入内存,以加快程序的执行。所以,JIT是一种提高程序时间和空间有效性的方法。程序运行时编译和执行的概念最早出自John McCarthy在1960年发表的论文《Recursive functions of symbolic expressions and their computation by machine》,James Gosling在1993年在关于Java的论文中使用了”JIT”这个术语。JIT可以分为两个阶段:

  1. 在运行时生成机器码。
  2. 在运行时执行机器码。

第一个阶段的生成机器码方式与静态编译并无本质不同,只不过生成的机器码被保存在内存中,而静态编译是在程序运行前将整个程序完全编译为机器码保存在二进制文件中。

第二个阶段运行时 JIT 缓存编译后的机器码,当再次遇到该函数时,则直接从缓存中执行已编译好的机器。因此,从理论上来说,JIT编译技术的性能会越来越接近静态编译技术。

 

二、为什么要使用JIT

计算机并不能直接地接受和执行用高级语言编写的源程序,源程序在输入计算机时,通过"翻译程序"翻译成机器语言形式的目标程序,计算机才能识别和执行。这种"翻译"通常有两种方式,静态编译方式(AOT)和动态编译方式。动态编译方式又分为解释执行和JIT执行。

解释执行:

  •     效率低。
  •     代码暴露。

AOT(静态编译):

  •     不够灵活,无法热更新。
  •     平台兼容性差。

JIT:

  •     效率:高于解释执行,低于静态编译。
  •     安全性:一般都会先转换成字节码。
  •     热更新:无论源码还是字节码本质上都是资源文件。
  •     兼容性:虚拟机会处理平台差异,对用户透明。

在JAVA这种类似的字节码编译的系统中,源代码被转换为称为字节码的中间表示形式。字节码不是任何特定计算机的机器代码,可以在计算机体系结构之间移植。然后可以在虚拟机上解释或运行字节码。JIT编译器在许多部分(或全部、很少)读取字节码,并将它们动态编译成机器代码,以便程序能够更快地运行。这可以针对每个文件、每个函数甚至任何任意代码片段进行编译; 代码可以在即将执行时进行编译(因此称为“即时”),然后缓存并在以后重用,无需重新编译。

相比之下,传统的解释型虚拟机只解释字节码,通常性能要低得多。有些解释器甚至不需要首先编译成字节码就可以解释源代码,但性能更差。静态编译的代码或本地代码在部署之前编译。动态编译环境是在执行期间可以使用编译器的环境。 使用JIT技术的一个共同目标是达到或超过静态编译的性能,同时保持字节码解释的优势:解析原始源代码和执行基本优化的许多“繁重工作”通常是在编译时处理的,在部署之前:从字节码编译到机器码要比从源代码编译快得多。与本地代码不同,部署的字节码是可移植的。由于运行时可以控制编译,比如解释字节码,所以它可以在安全的沙箱中运行。从字节码到机器码的编译器更容易编写,因为便携式字节码编译器已经完成了大部分工作。

JIT代码通常比解释器性能更好。另外,在某些情况下,它的性能可以比静态编译更好,因为许多优化只在运行时可行:

  1. 编译可以针对目标CPU和应用程序运行的操作系统模型进行优化。例如,JIT可以在检测到CPU支持SSE2矢量CPU指令时选择它们。要使用静态编译器获得这种优化级别的特殊性,必须为每个预期的平台/体系结构编译一个二进制文件,或者在一个二进制文件中包含多个版本的部分代码。
  2. 该系统能够收集关于程序在其所在环境中实际运行情况的统计信息,并且能够重新排列和重新编译以获得最佳性能。但一些静态编译器也可以将概要信息作为输入。
  3. 该系统可以进行全局代码优化(例如内联库函数),同时不失去动态链接的优点,也不会失去静态编译器和链接器固有的开销。具体来说,在进行全局内联替换时,静态编译过程可能需要运行时检查,并确保如果对象的实际类重写了内联方法,就会发生虚拟调用,并且对数组访问的边界条件检查可能需要在循环中处理。在许多情况下,使用即时编译,这种处理可以从循环中移出,通常会大大提高速度。
  4. 尽管使用静态编译的垃圾收集语言可以做到这一点,但字节码系统可以更容易地重新排列执行的代码,以获得更好的缓存利用率。

由于JIT必须在运行时呈现和执行本地二进制映像,因此真正的机器代码JIT需要允许在运行时执行数据的平台,这使得在基于哈佛结构的机器上使用这种JIT成为不可能的事情——对于某些操作系统和虚拟机也是如此。然而,一种特殊类型的“JIT”可能并不针对物理机器的CPU体系结构,而是一种优化的VM字节码,在这种情况下,对原始机器代码的限制占了上风,特别是在字节码的VM最终将JIT用于本机代码的情况下。

 

三、JIT生成代码时的代码优化技术

1、语言无关的优化技术之一:公共子表达式消除

意思是如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。

int d = (c * b) * 12 + a + (a + b * c);//优化为:int d = X * 12 + a + (a+X); // X = (c*b | b*c) 乘法满足交换律

2、语言相关的优化技术之一:数组边界检查消除

我们知道Java是动态安全的语言,如果访问一个超出数组边界的元素会抛出异常,如果没有优化,那么每一次对数组元素的读写都会进行判断是否越界,这显然是很消耗性能的。但毫无疑问的是数组边界检查是必须做的。

//1.如果数组下标是常量array[3]//编译器根据数据流分析确定foo.length的值,并判断下标"3"没有越界,执行的时候就无须判断了//2.数组下标是循环变量for(int i...)    array[i]/**如果编译器通过数据流分析就可以判定循环变量"0<=i< foo.length",那在整个循环中就可以把数组的上下界检查消除掉,这可以节省很多次的条件判断操作。*/

3、最重要的优化技术之一:方法内联

方法内联可以理解为将目标方法的代码“复制”到发起调用的方法之中,避免真实的方法调用。但实际上,我们平时所说的面向接口编程,会使用多态等特性,而多态是要在运行时才能判定到底使用哪个方法的(实际上Java的默认实例方法是虚方法,而虚方法做内联时根本无法确定使用哪个版本),所以我们就可以知道要达到方法内联的条件是比较苛刻的。

那么,方法内联之后能够进行哪些优化呢?

  • 去除方法调用的成本
  • 为其他优化建立良好的基础,方法内联后可以便于在更大范围上采取后续的优化手段

下面举个例子:

//内联前的代码static class B{    int value;    final int get(){        return value;    }}public void foo(){    y = b.get();    z = b.get();    sum = y + z;}
//内联后的代码public void foo(){    y = b.value;    z = b.value;    sum = y + z;}

内联后采取的其他优化

//冗余访问消除public void foo(){    y = b.value;    z = y;    sum = y + z;}
//复写传播public void foo(){    y = b.value;    y = y;    sum = y + y;}
//无用代码消除public void foo(){    y = b.value;    sum = y + y;}

4、最前沿的优化技术之一:逃逸分析

它的基本行为就是分析对象状态作用域:例如如果在一个方法内返回了一个方法内生成的新对象,如果被引用,这是方法逃逸;如果被外部线程访问到,这是线程逃逸。

那么JIT又是如何对逃逸分析进行优化的呢?

  • 栈上分配:一般来说,JVM都将对象创建在堆上,但如果确定一个对象不会逃逸出方法外,那么就将这个对象创建在栈上,随着出栈死亡
  • 同步消除:如果确定一个变量不会逃逸出线程,无法被其他线程访问,那么同步措施也可以取消
  • 标量替换:如果一个对象不会被外部访问,并且这个对象可以被拆散,那程序执行时可以不创建这个对象,而直接创建若干个被这个方法使用到的成员变量替代

事实上,逃逸分析还未成熟,原因也很简单,如果要判断一个对象是否会逃逸,需要进行数据流的一系列分析,而这个逃逸分析带来的消耗未必比逃逸分析带来的优化小。

 

四、JIT运行的简单原理

为了模拟JIT的运行原理,如下代码演示了如何在内存中动态生成add函数并执行,该函数的C语言原型如下:

long add4(long num) {  return num + 4;}

进行编译,然后看下反汇编代码

g++ f.cppobjdump -S  a.out
00000000004005b0 <_Z4add4l>:  4005b0:  55                     push   %rbp  4005b1:  48 89 e5               mov    %rsp,%rbp  4005b4:  48 89 7d f8            mov    %rdi,-0x8(%rbp)  4005b8:  48 8b 45 f8            mov    -0x8(%rbp),%rax  4005bc:  48 83 c0 04            add    $0x4,%rax  4005c0:  5d                     pop    %rbp  4005c1:  c3                     retq

然后在内存中动态地执行它:

#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/mman.h>// Allocates RWX memory of given size and returns a pointer to it. On failure,// prints out the error and returns NULL.void* alloc_executable_memory(size_t size) {    void* ptr = mmap(0, size,                     PROT_READ | PROT_WRITE | PROT_EXEC,                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);    if (ptr == (void*)-1) {        perror("mmap");        return NULL;    }    return ptr;}void emit_code_into_memory(unsigned char* m) {    unsigned char code[] = {        0x55,                               // push   %rbp        0x48, 0x89, 0xe5,                   // mov    %rsp,%rbp        0x48, 0x89, 0x7d, 0xf8,             // mov    %rdi,-0x8(%rbp)        0x48, 0x8b, 0x45, 0xf8,             // mov    -0x8(%rbp),%rax        0x48, 0x83, 0xc0, 0x04,             // add    $0x4,%rax        0x5d,                               // pop    %rbp        0xc3                                // retq    };    memcpy(m, code, sizeof(code));}const size_t SIZE = 1024;typedef long (*JittedFunc)(long);// Allocates RWX memory directly.void run_from_rwx() {    void* m = alloc_executable_memory(SIZE);    emit_code_into_memory(reinterpret_cast<unsigned char*>(m));    JittedFunc func = reinterpret_cast<JittedFunc>(m); // function: 4+m    int result = func(3);    printf("result = %d\n", result);}int main() {    run_from_rwx();    return 0;}

此代码执行的主要3个步骤是:

  1. 使用mmap在堆上分配可读,可写和可执行的内存块。
  2. 将实现add4函数的汇编/机器代码复制到此内存块中。
  3. 将该内存块首地址转换为函数指针,并通过调用这一函数指针来执行此内存块中的代码。

请注意,步骤3能发生是因为包含机器代码的内存块是可执行的,如果没有设置正确的权限,该调用将导致OS的运行时错误(很可能是segmentation fault)。如果我们通过对malloc的常规调用来分配内存块,则会发生这种情况,malloc分配可读写但不可执行的内存。而通过mmap来分配内存块,则可以自行设置该内存块的属性。

上面示例的代码有一个问题,它有安全漏洞。原因是RWX(可读可写可执行)的内存块,是易受攻击和利用的天堂。我们对它做一些改进,下面是稍作修改的源码,

#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/mman.h>const size_t SIZE = 1024;typedef long (*JittedFunc)(long);// Allocates RW memory of given size and returns a pointer to it. On failure,// prints out the error and returns NULL. Unlike malloc, the memory is allocated// on a page boundary so it's suitable for calling mprotect.void* alloc_writable_memory(size_t size) {    void* ptr = mmap(0, size,                     PROT_READ | PROT_WRITE,                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);    if (ptr == (void*)-1) {        perror("mmap");        return NULL;    }    return ptr;}// Allocates RWX memory of given size and returns a pointer to it. On failure,// prints out the error and returns NULL.void* alloc_executable_memory(size_t size) {    void* ptr = mmap(0, size,                     PROT_READ | PROT_WRITE | PROT_EXEC,                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);    if (ptr == (void*)-1) {        perror("mmap");        return NULL;    }    return ptr;}void emit_code_into_memory(unsigned char* m) {    unsigned char code[] = {        0x55,                               // push   %rbp        0x48, 0x89, 0xe5,                   // mov    %rsp,%rbp        0x48, 0x89, 0x7d, 0xf8,             // mov    %rdi,-0x8(%rbp)        0x48, 0x8b, 0x45, 0xf8,             // mov    -0x8(%rbp),%rax        0x48, 0x83, 0xc0, 0x04,             // add    $0x4,%rax        0x5d,                               // pop    %rbp        0xc3                                // retq    };    memcpy(m, code, sizeof(code));}// Sets a RX permission on the given memory, which must be page-aligned. Returns// 0 on success. On failure, prints out the error and returns -1.int make_memory_executable(void* m, size_t size) {    if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) {        perror("mprotect");        return -1;    }    return 0;}// Allocates RW memory, emits the code into it and sets it to RX before// executing.void emit_to_rw_run_from_rx() {    void* m = alloc_writable_memory(SIZE);    emit_code_into_memory(reinterpret_cast<unsigned char*>(m));    make_memory_executable(m, SIZE);    JittedFunc func = reinterpret_cast<JittedFunc>(m); // function : 4+m    int result = func(2);    printf("result = %d\n", result);}int main() {    emit_to_rw_run_from_rx();    return 0;}

它和前面的代码片段是等效的除了一点,内存分配的时候赋予是RW权限,就像普通的malloc一样。在实际中,我们将机器码写入内存,执行之前使用mprotect将内存块的权限从RW修改RX,可执行但不能写入。这和前面的代码一样,但是程序运行中所在的内存段不允许同时可执行可写。

 

四、Llvm JIT与C++ Template模板有什么不同

使用llvm工作的这段时间,被人问的最多的就是,Llvm与C++模板有什么区别? 这个问题的准确问法应该是 Llvm JIT的技术与C++模板技术有什么区别。不都是减少内存的读取,增加CPU Cache的利用率吗?为什么要使用Llvm JIT的技术处理问题呢,用C++模板不也一样吗?我想这也是大多数刚接触Llvm JIT技术头疼的一个问题。

模板实例化时候,编译器会用模板实参或者通过模板实参推导出参数类型带入可能的模板集(模板备选集合)中一个一个匹配,找到最优匹配的模板定义。上述推导出模板的实现是由编译期在程序的编译期做的,也就是说是静态编译的时候实现的。

Llvm JIT是一个动态编译技术。动态的组装机器码,最后执行机器码。所以这个就是他们的区别。上文中描述出程序动态编译技术可以比静态编译技术优化的空间更大。例如公共子表达式消除、数组边界检查消除、方法内联、逃逸分析等等技术。

其次模板是通过静态编译实现的,则程序可执行文件是要比动态编译要大很多的。在某些场景下可执行文件的大小也成了瓶颈。

再次模板一般处理相同的算法逻辑,仅仅参数不一样,否则需要通过模板的特化处理,增加了程序的复杂度。

 

业界不同领域使用LLVM的方式

 

一、作为编译器使用

对于iOS平台而言,Chris Latter现在就职于Apple,而且Apple目前也是LLVM主要赞助者之一。在iOS之前,LLVM的诞生就给OSX本身的开发带来极大的帮助,因为当时Apple困于GCC无法提供给他们完善的支持,特别是和Object-C相关的编译部分,而当时LLVM项目的出现帮助他们解决燃眉之急,包括后面Grand Dispatch这样的核心功能都有LLVM的影子。到了iOS时代,LLVM已经是整个iOS平台最底层的核心,无论从iOS系统本身的编译,以及XCode的调试支持,还是最新的语言Swift也都是由LLVM之父Chris Latter本人设计的。

尽管Android平台是iOS平台的对手,但Android平台对LLVM依赖更盛,虽然4.4之前都是用自建的Dalvik VM,使用类似JIT(Just-in-time Compiler)这样即时编译的形式来执行,但是在性能和用户体验方面被大家所诟病,所以在Android的4.4版本推出了ART模式,ART是直接使用LLVM去做AOT(Ahead of Time)编译,也就是在安装的时候直接将程序编译为机器码,平时Android用户使用App的时候能像iOS用户一样直接执行本地代码,这样使Android的整体性能已经接近iOS的水准,虽然在安装的时候,可能需要多花点时间用于做预编译。

可以看到LLVM在移动时代两大最主流的平台IOS,Android 都是默默奉献的英雄。

苹果XCode的核心编译器已经通过LLVM来实现了,并且鼎鼎大名的Clang编译器也是通过LLVM来实现的。

 

二、作为内存计算引擎使用

现在越来越多的数据库使用LLVM作为他们核心的计算引擎来使用,比如商业的CirroData, OceanBase, SAP HANA, 开源数据库的Postgres, Cloudera Impala, ClickHouse等等。那么为什么这些多优秀的数据库要使用Llvm来当做他们的计算引擎呢,那么我们就来看看传统的数据库计算引擎有哪些痛点吧。

图1 繁琐的数据处理引擎代码

  • 其一是条件逻辑冗余,数据处理引擎代码非常繁琐,因为SQL语句本身非常复杂,所以数据引擎为了支持那些复杂的SQL语句,使得数据处理引擎需要复杂的条件逻辑来处理,就像图那样,甚至一个Switch循环里面会有成百上千的case这样的选择逻辑,虽然Switch循环本身会被编译器进行一定程度的优化,但是最终机器码中的分支指令会一定程度上阻止指令的管道化(instruction pipelining)和并行执行(instruction-level parallelism)。
  • 其二是虚函数的调用,和第一个问题的原因类似,因为数据处理引擎要支持极为复杂的SQL语句,还有十几种的数据类型,比如,程序在处理add这个逻辑的时候,此时数据处理引擎需要根据来源数据是INT还是BIGINT来选择不同的函数来处理,所以在实际处理时,肯定只能用虚函数来转给具体的执行函数,这个对CPU的影响肯定是非常明显的,因为很多时候虚函数调用本身的运行成本,比这个函数本身执行成本更高。更因为如此,内联函数这个最常见的性能优化方式也无法被使用。
  • 其三是需要不断地从内存中调用数据,而无法一次性将数据从内存加载至Cache上,比如,常见的For循环,虽然知道下一个数据就在下一个偏移地址,但还是要从内存上面读取,这样读取开销很大而且阻止整个CPU管道化的操作。
  • 其四是因为不同x86 CPU年代不同,所以支持不同扩展指令集,而这些新的指令集对很多操作都能提升100%以上的性能,比如,有些比较新的CPU支持SSE 4.2,而有些却不支持,为了保证数据引擎能跨不同的硬件平台,所以数据引擎很少支持一些扩展的指令集,这导致浪费了本来可以提升的性能没有得到支持。

 

为了解决上述哪些问题,引用了Llvm Codegen技术(Llvm JIT)

 

其一是简化了条件分支,因为在生成代码的时候,程序已经获知运行时的信息,通过展开for循环(因为我们已经知道循环次数)和解析数据类型,所以可以像图2那样if/switch这些分支指令这样的语句就能优化掉,这是非常简单有效的。

图2 if/switch分支指令语句

其二是内存加载,我们可以使用代码生成来替代数据加载,这样极大减少内存的读取,增加CPU Cache的利用率,这对CPU性能而言非常有帮助。

 

图3 用代码生成取代虚函数的调用

其三是内联虚函数的调用,因此当对象实例的类型在运行时可知,我们可以如图3所示使用代码生成来取代虚函数的调用,并做内联化,这样表达式可以无需函数调用而直接求值。此外,内联后的函数使编译器做进一步的优化,例如子表达式消除等。

其四是能利用最新的指令集,在Codegen的时候,由于Codegen本身是在即将执行的那个节点执行,所以它很方便就能感知到其底层CPU到底支持那个版本最新的指令集,比如是SSE 4.2还是SSE4.1,所以Codegen完全会根据具体的指令集支持来编译具体的执行代码,使其能尽可能地利用最新的指令集。

图4 基于TPCH-Q1的基准测试

基于图的测试结果(跑TPCH-Q1,数据量2.7GB,单节点,Codegen时间150ms),通过这样的Codegen方式的确能有效地提升数据处理引擎利用CPU的效率,整体性能提升达到3倍以上,并且Cache Miss率和之前比重大提升。当然Codegen技术就像其他技术一样,它也有自己的成本和不足,那就是在Codegen本身的代码生成操作也是需要一定的时间,包括For循环展开和多层次pass优化。

使用LLVM Codegen技术给数据库的计算引擎的计算处理带来了非常大的提升。所以被广泛的使用。

 

结论

 

LLVM的架构设计,使我们可以很轻易的抽取LLVM的组件(以库的形式)出来用于其它领域,如抽取LLVM JIT用于MapD这样的GPU数据库,或者抽取LLVM的整个后端(优化与CodeGen)用于一些高性能计算框架。这样带来的好处就是LLVM不再仅仅是用于给Clang等编译器前端提供服务的编译器后端,而是可以为需要JIT / CodeGen 功能的所有领域服务,比如提到的GPU数据库、高性能计算框架,还包括安全、区块链等应用领域。

LLVM在众多领域上面大放异彩,LLVM是值得软件从业者学习和研究的,能让我们了解更多代码底层的本质,为编写高质量代码奠定基础。

 

参考资料

  1. A Brief History of Just-In-Time
  2. IBM:深入浅出 JIT 编译器
  3. <<Recursive functions of symbolic expressions and their computation by machine>>
  4. 基于LLVM的内存计算
  5. llvm.org/docs
  6.  
分享大数据行业的一些前沿技术和手撕一些开源库的源代码
微信公众号名称:技术茶馆
微信公众号ID    :    Night_ZW
  • 5
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值