#LLVM简介
LLVM是一款对应用程序开发者透明的、终身程序分析的、可转换任意程序的编译器框架。它通过以下两点来达到上述要求:
编译器开发者从各个方面出发,提出各种各样的方法来对程序进行优化,最终达到使程序性能最优的目的。一般来说,编译器开发者将利用如下3种技术中的一种或多种来开发编译器。然而,这些技术并不适合所有的编译器,并使之达到性能最优、编译时间最短的效果。
连接时的过程间优化是编译优化的关键一环,因为连接过程中将所有分别编译好的文件整合到一起,此时的优化是对程序的全局优化,也是优化效率最高的优化阶段。换句话说,连接时的过程间优化往往决定了一个编译器的优化效果的关键。而往往决定连接时优化的关键在于编译器是在哪一层做优化。是非常低层次的机器码层还是非常高层次的抽象语法树层?
有一大部分研究者选择在连接时或者运行时在机器码上做过程间的优化。这样的一个好处在于它们在不被改变的前端编译器上工作的很好,可以是开发者使用任何他们想使用的编译器。但是这种编译器系统有很多的限制:机器代码不能够提供足够的高层信息来支持主动的过程中间分析和转换。
为了解决这个问题,一些开发者提出新的技术来保存源代码级的信息直到连接时。通常,是通过将高层编译器的中间表示在编译时写到硬盘上来实现的。在连接时,连接器通过读取一系列版本的抽象语法树,对其进行组合、优化,最终生成可执行代码。但是这种技术的代价是高昂的,因为几乎所有的编译工作都被延迟到连接时,这就导致任何的程序改变都将导致所有的程序都必须重新编译。
总上所述,传统编译器的连接时时优化,要么存在无法提供高层信息,要么代价高昂的问题,这些时现代编译器不能承受的。
对于运行时的动态优化,一个最为通用,也是最简单的方法就是***直接完全忽略动态优化***。然而,这毕竟是一种***懒政***,随着用户对程序性能的要求提升,这已不再是一个可以直接忽略的问题了。目前,运行时优化已经成为现代系统中一个频繁被使用的提高程序性能的技术。
提及运行时的动态优化,不能不提虚拟机。这里的虚拟机指的是高级语言运行时虚拟机(如:JVM等)。通过使用虚拟机和高层次的程序表示输入,这些系统能够提高可移植的、安全的服务来提高程序的性能,并且,它可以提供更多的、有用的高层信息给运行时优化器使用。不幸的是,高层次的连接时优化一样,这种系统使得编译时不能够做实质性的优化工作。例如,JIT编译器,这种动态编译器必须占用非常多的处理器周期来进行优化操作。
另一方面,机器代码级的运行时优化器以及指令翻译器通过直接操作本地机器代码来提高程序性能或者动态的翻译机器代码。很明显,这种方式有着和机器代码级过程间优化类似的弊端,在需要高精度的概要信息(profiling information)的轨迹生成和优化方面共工作的很好,但是对于高层次的重构转换应对不足。
概要信息导向优化是一种利用收集来的程序运行时行为信息来提高程序性能的一种技术。传统的概要信息导向优化需要经历五个阶段:
LLVM利用其代码表示提供了一组5种功能来实现程序终身分析和对任意程序的转换。而且这5种功能是其他编译器不能同时具备的。
根据LLVM(Low Level Virtual Machine)的名字很容易让人觉得它是一个虚拟机,但是它事实上是一个模块化的、可重用的编译器和工具集合。它和传统的虚拟机关系不大,但是可以通过它提供的库来实现一个虚拟机。总之,LLVM不再是首字母缩写,它这个项目的全称。
由于设计目标和中间表示不同,LLVM (Low Level Virtual Machine)是高层次虚拟机(如:JVM、Microsoft CLI)的补充,而非另外的一种选择。它们的区别主要在于以下3点:
LLVM系统架构这是为了解决这些传统编译器所存在的问题而设计的。简单的说,在LLVM系统中静态编译器将源代码编译成低层次的表示(为方便后面将称为“中间表示”或IR或”LLVM代码表示“)——LLVM虚拟指令集并且包含了高层次的类型信息。这样静态编译将可以在编译时做实质性的优化并且可以保留高层信息提供给连接器使用。
LLVM是一个多阶段优化的编译器框架。这种策略的独到之处在于其可以在程序的整个生命周期内做优化。和传统编译器相比,LLVM可以在连接时、运行时甚至在程序安装之后做复杂的转换和分析。图-1 是LLVM的概要图。
图-1 LLVM概要图
传统编译器只有两个阶段——编译和连接。LLVM依旧保留着这两个阶段。这样做的目的是为了利用分段编译的优点——当程序发生改变时只需重新编译被改变的部分。与传统编译器不同的是,传统编译器在编译阶段生成的.o文件已经是机器代码了,而LLVM则是将源代码翻译成LLVM虚拟指令集(就是IR,这些代码将和最终生成的本地机器代码一起保存,在运行时可以通过JIT翻译器对其进行翻译),而后由连接器将其连接并做优化,最终生成可执行的本地代码存到磁盘上。本地代码生成器可以将轻量级的指令插到本地机器代码中以此来频繁的探测循环、执行路径、函数调用,在运行时,可以以此来收集概要信息进行再优化。根据程序的行为,在运行时可以动态的再编译和再优化,然而这种优化有时候是代价巨大的,所有这部分优化可以在程序运行的空闲时间来完成。这便是LLVM的大概流程。
LLVM编译器是支持多种编程语言前端的,每一种前端都必须将源程序翻译成LLVM虚拟指令集,同时,在这个过程中可行做尽可能多的优化工作,以此来减少连接器的工作量和工作时间。例如:对于C/C++的前端可以将printf("hell\n");
转换成puts("hello");
。
总之,前端静态编译器可以做三件事:
连接时是一个做全程序主动过程间优化的天然时机,应该在该阶段是第一次将整个程序的各个部分整合到一起。连接时的优化是在IR上完成的,同时它还利用了高层信息来使得优化更加高效。在此期间,编译器做了大量的优化工作:
在程序执行之前,使用代码生成器将LLVM代码翻译成本地代码。这种代码生成器在连接时和安装时静态运行的,通常可以产生较高性能的可执行程序但是这种方式需要较高代价。如果用户使用的是后连接优化,那么LLVM代码必须和可执行程序打包在一起,并且程序中还需要插入轻量级的指令以识别循环和热点函数等,这无疑加大了编译难度。
JIT执行引擎技术:该技术可以在程序运行时调用合适的代码生成器每次翻译一个函数来执行。当然这种技术也可以像离线代码生成技术那样,在程序中插入相应的指令来收集对应信息。但这种逐条翻译执行的方案无疑会影响程序运行。
总之,两种方式各有利弊。
在传统编译器的设计策略中大多只考虑前两个阶段的优化,而在LLVM中引入了一种新的优化策略——运行时优化。这种策略通过收集运行时的概要信息并且利用这些收集来的信息指导对LLVM字节码的再优化和再编译。
正如在上文中提到的,传统编译器收集概要信息(如果使用这种优化策略的话,一般情况下开发者不愿意使用)是由开发者来完成,然后开发者再利用收集来的信息作为程序运行的反馈来做优化。但是LLVM运行时优化则不同:
在程序运行的时候频繁被执行的执行路径通过离线和在线的指令被(如上文所述,本地机器代码生成器在生成本地机器代码是插入的)识别。例如:由本地代码生成器插入的离线指令可以识别代码中的频繁被执行的循环区域,进而识别频繁被执行的执行路径,一旦这种路径被识别,就可以从原始的LLVM代码中将其拷贝出来对其在优化生成本地代码,并将其存放到software-managed trace cache中。这次运行时优化策略的优点在于:
有些程序并不适合运行时优化,这些程序主要有这些特点:代码量大、没有调用特别频繁的区块。由上文可知,运行时优化主要在于提高热点函数和循环的性能。
LLVM表示是永久保留的,所以科研在运行的空闲时利用目标机器和用户信息对应于程序再优化。一个离线的、空闲时的优化器具有以下几个优点:
“代码表示”是LLVM区别于其他系统的一个主要特征。“代码表示”被设计为提供高层次程序信息以支持复杂分析和转换,同时,也足够底层来支持对任意代码的表示和静态编译。
LLVM指令集被设计为一种带有高层次类型信息的底层表示。即LLVM指令集(或者中间表示IR)有两个特点:底层表示和高层次信息。
LLVM虚拟指令集并不关心运行时和操作系统函数(如,I/O、内存管理、信号量等)。但是与传统编译器中间表示(如GCC的RTL)不同的是它是可读的,类似文本文件可以存在磁盘上的。LLVM虚拟指令集主要有以下几个特点:
语言无关类型系统是LLVM的基本特性。LLVM是一个严格的指定类型的表示。每一个SSA寄存器和明确的内存对象都有一个相应的类型,这个类型信息用于连接指令操作码和指令语法定义。它 提供一个语言独立的类型系统,包括四个简单类型:void、布尔、整数和浮点数, 以及四个导出类型:指针、数组、结构和函数。这个简单的类型系统可以实现绝大多数高级语言的类型,比如,C++中的类可以使用结构、函数以及函数指针的数 组的组合实现。
cast
指令:类型转换指令。该指令是进行类型转换的唯一方法。换而言之,LLVM代码中类型转换都是显式的。
getelementptr
指令:用来实现指针算术运算,它可以计算组合类型(结 构或者数组)数据的成员的地址。getelementptr使得在LLVM代码中附带类型信息成为可能。例如:X[i].a = 1;
将被转换成如下代码:
%p = getelementptr %xty* %X, long %i, ubyte 3;
store int 1, int* %p;
一些程序之所以难以充分优化,关键在于在内存分配是在堆中大量使用复杂的数据结构。为了解决这个问题LLVM为指定类型内存分配提供了指令:
malloc
指令:在堆上分配一个或多个特定类型单元,并且返回指向新开辟内存的指定类型的指针。free
指令:释放有malloc开辟的内存空间。alloca
指令:和malloc类似,只不过alloca在函数栈空间开辟内存空间,并且在函数结束时自动回收,无需使用free,同样也返回指定类型的指针。LLVM提供了两种函数调用指令,这些指令抽象了底层机器相关的函数调用约定、简化了程序分析并且为异常处理提供支持。call
指令使用一个指针指向一个函数来进行函数调用。另外一个指令invoke
通常被用来做异常处理相关工作。
LLVM实现了一种栈展开机制,该机制使得异常处理“零代价”。该机制说明,但异常没有被抛出时程序无需执行额外的指令。相反,如果指令被抛出,栈将会被展开,直到函数调用的返回地址。LLVM运行时保存了一个静态的返回地址到异常处理块的映射,这样,当栈展开是就可调用异常处理器了。为了建立这种异常处理器信息的映射,invoke
指令是在call
指令追加了异常处理的标签。当异常产生时,invoke
的返回地址和异常处理标签联系在一起,这样就可以执行异常处理代码了。
invoke
指令可以直接使用LLVM底层概念表示高级语言的异常处理语法,这样就使得LLVM表示独立于源代码的异常处理语法了。在这种表示中,异常被直接转换并且对LLVM框架是可见的,以此确保所有的关于异常处理的LLVM翻译都是正确的。
LLVM的中间表示是一种first class language,和文本文件、可执行的二进制文件一样,可以保存到硬盘中。这也是为什么有些人会将LLVM中间表示直接翻译成LLVM汇编的原因。LLVM中间表示是可以可读的,如同汇编代码一样。其他编译器的中间表示大多是种内存中的复杂数据结构,以至于很难写出来,这让其他编译器既难懂又难以实现。
LLVM中间表示的这种特性使得其可以无损的、很简单的进行调试转换,测试用例的编写更加容易,同时也减少了理解内存内表示的时间。