LLVM,一堆积木的故事

如果我说,C可以像Java一样被虚拟机解释执行,也可以对热点代码使用Just-In-Time(JIT)技术编译,也可以实现“一次编译,到处运行”,你信吗?

少侠且慢动手,听我解释!无论是相对高级的语言,如Python、Java(这里说的高级是说接近自然语言,而不是字面意思),还是相对底层的C/C++,甚至是汇编语言,都只不过人们对一个问题描述的文本体现。Java所具备的内存托管、垃圾回收机制,归根到底,是Java虚拟机(JVM)赋予它的,因此只要能让JVM接受,不管是什么语言都能享受Java的待遇,例如Groovy、Kotlin、Scala等语言。

多种不同语言都能运行在JVM上的原因,是因为它们都被编译成了一种JVM能识别的中间表示形式(IR,Intermediate Representation)——字节码(bytecode),JVM识别字节码并翻译到机器码,最终实现了代码的执行。

对于JVM,Java摸得,我C语言就摸不得?只要把C编译到字节码,C++就能实现内存托管、“一次编译,到处运行”。我们先不管为什么要大费周章,也不管这样做得利弊得失,我们只是为一个想法找到了一条切实可行得路线。有趣得是,很多事情当你想到得时候,别人已经在做,或者已经完成了。将C++编译成运行在JVM上得字节码,并非仅仅存在于脑洞中,有好事者已经将它实现了:它就是LLJVM项目,代码开源地址为 https://github.com/davidar/lljvm 有兴趣得小伙伴可以前去观摩。从这个角度来说,那些论坛上喋喋不休的争论哪个语言好哪个语言优秀的可以歇一歇了,这些争论其实是没有意义的,你们所骄傲的是编译器赋予你们的,有什么样的编译器,就会有什么样的语言。语言只是个工具,工具是没有好坏之分的,只有顺不顺手。

LLJVM怎么把C编译到JVM上呢?主要有以下三步:

  1. 使用llvm-gcc或者clang将C源代码编译到LLVM IR;
  2. 将LLVM IR转换到Jasmin汇编代码;
  3. Jasmin汇编代码转换到字节码。

兜兜转转一圈之后,C语言编写的代码逻辑终于在JVM上执行了。除了将C代码编译到字节码,目前已经实现的方案中还有另外一种使得C代码可以解释执行的方案,它就是LLVM提供了一个名叫lli的解释器,专门用来解释执行LLVM IR,与JVM类似,lli也有Just-In-Time(JIT)等功能。由此,我们引出了今天的主角:LLVM。

从编译说起

编译器,就是一个将源代码编译到目标机器代码的一个程序(图1)。
Figure 1  Compiler
Figure 1 Compiler

经典的编译过程分为词法分析、语法分析、优化、代码生成等阶段。而一个经典编译器就可以根据这些阶段分成三部分(图2):

  1. 前端(Frontend):负责此法分析、语法分析,产生一个中间结果;
  2. 优化器(Optimizer):负责代码逻辑优化,比如做一些等效代换、去掉一些无用代码等,使得执行时间更短;
  3. 后端(Backend):负责将优化后的中间结果翻译成目标机器可以执行的机器指令。

其中根据需要,优化部分可以没有。
Figure 2  Simple Compiler
Figure 2 Simple Compiler

这么分的好处显而易见(图3):

  1. 如果出现了新的语言,只需要编写新的前端,复用优化器和后端,就能然新的语言在已有的目标机器上执行;
  2. 如果出现了新的CPU架构或者要让已有的语言支持一种新的机器,只需要编写对应机器的后端复用前端和优化器就可以实现。

Figure 3  Retargetable Compiler
Figure 3 Retargetable Compiler

通过对着三个部分使用不同的方案去实现,就得到了不同的编译器:

  1. 如果输入源代码直接输出的是机器码,那么就是典型的编译器。典型代表是gcc(上图3);
  2. 如果输入的是源代码输出的是结果,那么就是解释器,典型代表是bash(图4);
  3. 如果输入的是源代码,输出的是中间结果,那么就是把编译器拆分成了两部分:前端成了新的编译器,优化器和后端组成了虚拟机。典型代表是javac和JVM(图5)。

有趣的是Python,它介于2和3之间,如果代码一行一行输入,它就是相当于解释器;如果输入的是一个.py文件,其实它也是先编译成它自己的字节码然后在虚拟机上执行,所以一般会有一个.pyc的文件生成,这就是Python的字节码文件。

FIgure 4  Interpreter
FIgure 4 Interpreter
Figure 5  javac&JVM
Figure 5 javac&JVM

LLVM

LLVM,最开始是(Low Level Virtual Machine)的缩写,但是现在表示的就是LLVM项目本身。LLVM项目下面有多个子项目,包括clang、libc++以及LLVM Core等。正如其名字所体现的那样,LLVM Core处于LLVM项目的核心。

LLVM Core框架主要有以下几部分组成:

  1. 一个独立于源代码语言和目标机器代码的优化器,这个优化器的输入和输出都是一个叫LLVM IR(LLVM Intermediate Representation)的东西;
  2. 主流CPU的机器码生成器,也就是所谓的后端。

LLVM Core是LLVM项目的核心,而LLVM IR又是LLVM Core的核心。它们的关系如下(图6):
Figure 6 LLVM Compiler
Figure 6 LLVM Compiler

通过图6可以看到,clang是一个C类型语言(C/C++/Ojective-C)前端,也称之为驱动。clang以及其他的前端通过此法分析、语法分析、构造抽象语法树(AST)等过程,最终生成了LLVM IR。这个LLVM IR输入到优化器中进行优化,优化器经过一系列通用的优化后,输出一个优化了的IR给后端进行代码生成,后端可以根据目标机器继续进行一些特殊的优化。

LLVM IR用一种类RISC的指令集表示源代码,它的特点如下:

  1. 有着类似于RISC指令集的加、减、比较、分支等指令;
  2. 操作数是强类型的,例如它可以使用i32表示一个32位的整数;
  3. 可使用的寄存器是无限的,毕竟它只是一种类RISC指令,最终寄存器的分配是后端来做,因此它只需要用类似%tmp等方式表示除操作数的存取是寄存器就可以,数量是不限的。

例如下面一段源代码

unsigned add1(unsigned a, unsigned b) {
  return a+b;
}

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

使用类RISC的IR 表示则如下所示:

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 %recurse

recurse:
  %tmp2 = sub i32 %a, 1
  %tmp3 = add i32 %b, 1
  %tmp4 = call i32 @add2(i32 %tmp2, i32 %tmp3)
  ret i32 %tmp4

done:
  ret i32 %b
}

LLVM IR有三种表现形式:

  1. 文本文件表示形式,如上面的例子中所示;
  2. 二进制文件表示形式,称为比特码(bitcode);
  3. 内存中的表现形式。

LLVM编译器和gcc的区别

有一种说法,gcc编译器的代码,很难被复用到其他项目中。但是我们从图3和6中看到,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会被链接进入目标程序,这就实现了“用多少取多少”的目标,不搞“搭售”。而第三方如果还有自己独特的优化要求,只要按照同样的方法实现一个优化即可(图7)。
Figure 7 Pass Linkage
Figure 7 Pass Linkage

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

到了这里,我们终于可以回答LLVM和gcc的区别了:
LLVM本身只是一堆库,它提供的是一种机制(mechanism),一种可以将源代码编译的机制,但是它自己本身并不能编译任何代码。也就是说编译什么代码、怎么编译、怎么优化、怎么生成这些策略(strategy)是由用户自己定的。例如clang就使用LLVM提供的这些机制制定了编译C代码的策略,因此前文中说clang可以称之为驱动(driver)。还拿电脑做例子:一堆电脑零件本身并不能做任何事情,这么将它们组装起来让它们工作是使用者的事儿。例如散热风扇,它提供的是一种可以散热的机制,它自己是不能给任何东西散热的,需要使用者把它拿去吹散热片或者吹自己的脑门。

而gcc,它并没有将机制和策略分的很清楚,它只做了一件事:我这有个工具可以编译C代码。

总结

强行总结的话,LLVM中由两个重要的东西:

  1. LLVM IR;
  2. 完全模块化。

这两个特性组合在一起,使得它具备了提供编译能力的机制。

公众号二维码

首发于个人微信公众号TensorBoy。微信扫描上方二维码或者微信搜索TensorBoy并关注,及时获取更多最新文章!
C++ | Python | 推理引擎 | AI框架源码,有一起玩耍的么?

References

[1] http://www.aosabook.org/en/llvm.html
[2] https://llvm.org/

发布了45 篇原创文章 · 获赞 4 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/ZM_Yang/article/details/105315977