LLVM
这一章节讨论了一些对 LLVM((http://llvm.org )) 有重大影响的设计决策。LLVM 是涵盖一系列紧密结合的底层工具链组件(比如汇编器、编译器、调试器等)的维护和开发的总项目,设计目标是与现有的工具兼容,特别是 Unix 系统的工具。”LLVM” 曾经是一个缩写词,但是现在仅仅是这个项目的代名词。虽然 LLVM 有一些独特的功能,并且因其优秀的工具而出名(比如 Clang 编译器((http://clang.llvm.org )),一个比 GCC 更好用的 C/C++/Objective-C 的编译器),但 LLVM 的内部架构才是它与其它编译器区分开来的主要原因。
自 2000 年 12 月项目诞生起,LLVM 便致力于提供一组接口清晰的的可复用库。当时,开源编程语言的具体实现只针对特定用途,往往是单体的可执行文件。在静态分析和代码重构时,这不利于复用静态编译器(比如 GCC)的语法分析器。虽然脚本语言经常提供将运行时和解释器嵌入更大规模的应用中的方法,但是它们的运行时也是不可拆分的、臃肿的代码,只能整体包含。当时没有办法复用这些具体实现的特定功能,同时语言具体实现工程之间也极少进行代码共享。
除了编译器本身的组合问题之外,对于一个具体实现是提供一个像 GCC、Free Pascal 和 FreeBASIC 那样传统的静态编译器,还是实现一个解释器或 JIT 形式的运行时编译器,流行编程语言具体实现社区的观点常常两极分化。很少有语言具体实现同时支持上面两种方式,就算他们真这么做了,也几乎没有共享的代码。
过去十年间,LLVM 带来了翻天覆地的变化。LLVM 现在是一系列静态或运行时编译语言实现的共通基础设施(比如,GCC 支持的语言集合、Java、.NET、Python、Ruby、Scheme、Haskell、D,以及无数不知名的语言)。它同时取代了一系列专用编译器,比如 Apple 的 OpenGL 软件栈和 Adobe 的 After Effects 产品的图像处理库的所使用的运行时专用引擎。最后 LLVM 还用于创造各式各样的新产品。最著名的恐怕要属 OpenCL 编程语言及它的运行时。
11.1. 经典编译器设计简介
三段式设计在传统静态编译器中最为流行(比如大多数 C 编译器),它的主要组件是前端、优化器和后端(
优化器负责用各种各样的转换来提高代码的运行效率,比如消除冗余计算。这一行为或多或少独立于源语言和目标格式。之后后端(又叫代码生成器)将代码映射到目标指令集。保证代码正确性之余,后端还要生成高质量的代码,使得它们能够利用目标架构的特性。编译器后端的常见部分包括指令选择、寄存器分配和指令排序。
三段模型同样能很好地适用于解释器和 JIT 编译器。Java 虚拟机(JVM)也是这一模型的具体实现。JVM 采用 Java 字节码作为前端和优化器之间的接口。
11.1.1. 三段式设计的启示
当编译器希望支持多种语言或目标架构时,经典的三段设计的价值就凸显出来了。如果编译器在优化器中使用通用代码表示,那么就能为任何语言编写前端,为任何目标编写后端,只要它们能转化到通用代码或者由通用代码生成,如
这个设计下,移植编译器以支持新的源语言(比如 Algol 或 BASIC)需要实现一个新的前端,但是现存的优化器和后端可以被复用。如果这些部分没有分离,实现一个新的源语言需要从零开始,于是支持 N 个目标和 M 个源语言就需要 N × M 个编译器。
相较于只支持单一源语言与目标平台,三段设计的编译器可以服务更多的程序员,这是该设计的另一个优势(直接来自其可重定目标能力)。而对一个开源项目来说,这意味着可以召集到更多潜在的贡献者。同时这也自然而然地给编译器带来了改进和增强。这正说明了为什么相比于受众面窄的编译器如 FreePASCAL,服务大量社区的开源编译器(如 GCC)更易产生更加优化的代码。但是商业编译器并不适用这个道理,因为它们的质量是与项目预算直接挂钩的。例如,尽管受众面窄,但是 Intel ICC Compiler 凭借其高质量的生成代码而声名远扬。
三段式设计的最后一个主要优势在于实现前端和优化器以及后端所需要的技能不同,将它们分离有利于简化前端人员的改进和维护工作。虽然这只是一个社会分工问题,而不是技术难题,但是它在实践中非常重要,对于想要尽可能减少贡献门槛的开源项目来说更是如此。
11.2. 已有的语言实现
尽管三段式设计的好处在编译教材中都有所提及,但是在实践中人们几乎没有认真地考虑过它。看看那些开源编程语言的具体实现(在 LLVM 项目开始之前的),你会发现 Perl、Python、Ruby 和 Java 的实现没有共享任何代码。后来,Glasgow Haskell Compiler (GHC) 和 FreeBasic 等项目有所进步,能支持多种不同的 CPU,但是它们的实现只针对单一的源语言。还有许多特殊用途的编译器技术用来 JIT 编译器的实现,这些编译器用于支持如图像处理、正则表达式、显卡驱动以及其它一些对运算性能有需求的细分领域。
即便如此,这个模型也有三个成功案例。第一个是 Java 和 .NET 虚拟机。这些系统提供了 JIT 编译器、运行时支持以及经过精心设计的字节码格式。这意味着只要能编译到字节码,任何语言都能享受到优化器和 JIT 以及运行时带来的好处(这样的语言有很多((http://en.wikipedia.org/wiki/List_of_JVM_languages ))。遗憾的是这些实现不能灵活地选择运行时:它们都在事实上强制即时编译、垃圾回收以及使用特定的对象模型。这导致编译不匹配这一模型的语言只能获得次优的性能,如 C 语言(具体来说,配合 LLJVM 项目的 C 语言)。
第二个成功案例也许是最不幸的,但也是最常用的复用编译器的技术:将输入源代码翻译成 C 代码(或者其他语言)然后把它交给已有的 C 编译器。这个方法能够复用优化器和代码生成器,能灵活选择运行时并加以控制,同时对前端实现者来说十分易于理解、实现和维护。不幸的是,这种方法不能实现高效的异常处理机制,调试体验糟糕,编译速度慢,并且这手段对需要尾递归优化(或其他一些 C 不支持的特性)的语言不大行得通。
三段式模型的最后一个成功实现是 GCC((现在是 “GNU Compiler Collection” 的反向缩略语。))。GCC 支持许多前段和后端,同时有一群活跃的贡献者。很长一段时间里,GCC 是一个支持多目标的 C 编译器,同时用很偏门的方法支持了其他个别语言。年复一年,GCC 缓慢地演变成一个更加干净的设计。到了 GCC 4.4,它有一个新的优化器(叫做“GIMPLE Tuples”),越来越独立于前端。另外,它的 Fortan 和 Ada 前端用上了一个整洁的抽象语法树。
尽管这三个案例很成功,但是它们还是在使用场景上有局限,因为它们是单体应用。举个例子,将 GCC 作为运行时或 JIT 编译器嵌入其他应用,或者在不整体嵌入的情况下提取复用 GCC 的部分功能是不切实际的。需要使用 GCC 的 C++ 前端做文档生成、代码索引、重构以及静态分析工具的人需要单独使用 GCC 以 xml 格式产生需要的信息,或者写补丁往 GCC 进程注入代码。
GCC 的部分功能不能作为库加以复用的原因有很多:肆意使用全局变量、不使用常量、设计糟糕的数据结构、杂乱无章的代码库,以及使用宏导致的编译后无法同时支持多个前端后端配对。然而,最难处理的问题是其内部架构,这个问题源自它的早期设计。具体来说,GCC 的层次问题和抽象泄露令人头痛:后端要遍历前端的抽象语法树来产生调试信息,前端产生后端的数据结构,并且整个编译器依赖命令行接口设置的全局数据结构。
11.3. LLVM 的代码表示形式:LLVM IR
暂时忘记历史背景,我们来深入 LLVM 内部:其设计最重要的层面是 LLVM 中间表示形式 (Intermediate Representation, IR),这是 LLVM 在编译器中表示代码的形式。LLVM IR 能够支持中等水平的分析和转换,你能在编译器的优化器中找到对应的部分。它在设计时包含了很多独特的想法,包括支持运行时优化、过程间优化、全程序分析以及激进的重构变换等等。然而,LLVM IR 最重要的层面是它自身是有明确语义的语言。这里有一个简单的 ‘’.ll’’ 文件来具体说明这一点:
1 | define i32 @add1(i32 %a, i32 %b) { |
这段 LLVM IR 对应下面这段 C 代码,这份代码实现了两种做整数加法的方法:
1 | unsigned add1(unsigned a, unsigned b) { |
从这个例子可以看出,LLVM IR 是一个底层的 RISC 风格的虚拟指令集。就像真实的 RISC 指令集一样,它支持简单指令如加法、减法、比较和分支的顺序执行。这些指令采用三地址形式,即它们接受一些输入并在另一个不同的寄存器产生一个输出。((这与二地址指令集和一地址机器不同,前者如 X86,它破坏性地更新输入寄存器;后者如一地址机器,它接受一个显式操作数并作用于一个累加器或者栈顶(如果是栈式机)。)) LLVM IR 支持标签并且看起来基本就是一种格式古怪的汇编语言。
不同于大多数的 RISC 指令集,LLVM 是强类型的,它有一个简单的类型系统(比如,’’i32’’ 是一个 32 位的整型,’’i32**’’ 是一个指向 32 位整型的指针),并且与机器相关的细节都被屏蔽了。例如,调用规约以 ‘’call’’ 和 ‘’ret’’ 以及显式的参数列表的形式抽象地表示。另一个重要的不同于机器码的地方是 LLVM IR 并不使用固定数量的具名寄存器,而是使用无限的临时寄存器,它们的名字都含有 % 字符。
不仅仅作为一门语言来实现,LLVM IR 事实上有三种同构的形式:上述的文本格式,一种内存数据结构,在优化过程中分析和修改,以及一种存储在磁盘上的密集的二进制格式,叫做 “bitcode”。LLVM 项目同时提供工具,将文本格式转换到二进制格式:llvm-as。它将文本格式的 ‘’.ll’’ 文件汇编成 ‘’.bc’’ 文件,’’.bc’’ 文件中包含所谓的 bitcode。llvm-dis 则将 ‘’.bc’’ 文件反汇编成 ‘’.ll’’ 文件。
编译器的中间表示形式十分有趣,因为它为优化器提供了一个完美世界:不想编译器的前端和后端,优化器并不被特定源语言或目标机器所限制。但是另一方面,中间表示形式必须同时考虑到前端和后端:它要让前端能够容易地生成,并且要有一定的可读性,使得针对真实目标的重要优化能够实施。
11.3.1. 编写 LLVM IR 的优化策略
观察一些具体的例子,有助于对优化的工作原理有一个直观的认识。编译器可以做很多种优化,所以很难给出一个通解。也就是说,大多数优化遵循下面三步流程:
- 寻找要转换的模式。
- 确认能安全正确地对找到实体进行转化。
- 实施转换,更新代码。
最简单的优化是数学恒等式的模式匹配,比如:对任意整数 ‘’X’’ , ‘’X - X’’ 即 0 , ‘’X - 0’’ 即 ‘’X’’ , ‘’(X * 2) - X’’ 即 ‘’X’’ 。首当其冲的问题是这些在 LLVM IR 下长什么样。下面是一些例子:
1 | ? ? ? |
对于这类窥孔优化,LLVM 提供了指令化简接口,作为其他各种各样的高级变换的工具。这些细小的变换在 ‘’SimplifySubInst’’ 函数中,形式如下:
1 | * X - 0 -> X |
在这份代码中,Op0 和 Op1 分别绑定了整数减法指令的左右操作数(需要注意,这些恒等式对 IEEE 浮点数不一定成立)。LLVM 用 C++ 实现,C++ 并不以模式匹配能力见长(与 OCaml 这类函数式语言相比的话),但是它提供了非常泛化的模板系统,这使得我们能够实现类似(模式匹配)的东西。’’match’’ 函数和 ‘’m_’’ 开头的函数使得我们能以声明式的风格,对 LLVM IR 进行模式匹配。例如,’’m_Specific’’ 谓词只有在乘法的左操作数与 Op1 提供的情况下才会匹配到。
这三个情形都是被匹配后返回替换内容,如果没有就返回空指针。这个函数的调用者(’’SimplifyInstruction’’)是一个分派器,它根据指令的操作码,转发到对应操作码的辅助函数。它在各种各样的优化场景下被调用。下面是它的使用方法:
1 | for (BasicBlock::iterator I = BB->begin(), E = BB->end(); I != E; ++I) |
这份代码简单地遍历基本块中的所有指令,检查是否发生化简。如果发生了(因为 ‘’SimplifyInstruction’’ 返回了非空指针),它使用 ‘’replaceAllUsesWith’’ 方法,用简化的形式去更新代码中所有使用这个可化简操作的地方。
11.4. LLVM 的三段式设计实现
在基于 LLVM 的编译器中,前端负责对输入代码进行语法分析,语义验证和错误诊断,然后把分析后的代码翻译成 LLVM IR (通常是构造 AST 然后将 AST 转换成 LLVM IR,但不一定)。中间代码可以经过送入一系列分析和优化流程得到改进,然后传入代码生成器,产生本地机器码,如
11.4.1. LLVM IR 是一个完备的代码表示
特别地,LLVM IR 是指定的优化器的唯一接口。这意味着你只需要知道 LLVM IR 以及它的工作原理、期望的不变量(?)就可以为 LLVM 编写前端。因为 LLVM IR 有优秀的文本格式,让前端生成文本形式的 LLVM IR 不仅可行,更是合理的做法,之后可以使用 Unix 的管道将它送入优化器序列以及你选择的代码生成器。
这可能令人感到惊讶,但是这确实是 LLVM 的新颖之处,也是它在许多不同的应用中获得成功的的主要原因之一。即便是获得广泛成功并且相对来说架构良好的 GCC 也没有这个特点:它的 GIMPLE 中间表示形式不是完备的。举一个简单的例子,当 GCC 代码生成器要生成 DWARF 调试信息时,它会回过头去遍历源码级的语法树。GIMPLE 本身用元组形式表示代码中的操作,但是(至少到 GCC 4.5)仍然将操作数表示成对源码树的引用。
GCC 这种做法意味着前端作者需要同时了解并生成 GCC 的树形数据结构和 GIMPLE,才能编写 GCC 的前端。GCC 后端也有类似的问题,所以他们也需要了解一点 RTL(Register Transfer Level?) 后端的工作原理。最终,GCC 没有办法转储源码的所有信息,也没有办法以文本形式读写 GIMPLE(以及形成中间代码的相关数据结构)。结果就是用 GCC 来做实验相对困难,并且它的前端也因此相对地少。
11.4.2. LLVM 是代码库集合
在 LLVM IR 的设计之后,下一个关于 LLVM 的重要的层面是它是一组代码库,而不是像 GCC 那样的单体的命令行编译器,或者像 JVM 或 .NET 那样封闭的虚拟机。LLVM 是基础设施,是实用编译技术的集合,可以应用于特定问题(像构建 C 编译器,或者特效管道中的优化器)。尽管这是 LLVM 最强大的特点之一,它也是最不被理解的设计要素。
作为一个例子,我们来看一下优化器的设计:它读入 LLVM IR,进行些处理,然后生成期望能执行得更快的 LLVM IR。在 LLVM 中(就像在其它编译器中那样),优化器多个不同的优化过程串联起来的管道,每个优化过程的执行依赖输入,并且会做些事情。常见的优化过程的例子有内联器(它在函数调用处直接展开函数体)、表达式重组、循环不变代码移动等等。根据优化等级,会执行不同的过程:比如在 -O0 (无优化)级,Clang 编译器部执行任何优化过程,在 -O3 级它在优化器中执行 67 个优化过程(以 LLVM 2.8 为准)。
LLVM 的优化过程是一个 C++ 的类,间接继承自 ‘’Pass’’ 类。大多数过程写在单独的 ‘’.cpp’’ 文件中,并在匿名名空间中定义 ‘’Pass’’ 的子类(这使得其完全私有化)。为了让这个过程有实用价值,外部代码要能实用它,于是要从代码文件中导出一个单独的函数(来创建过程)。为了具体化前面的内容,这里有一个略微简化的过程的例子。((更多的细节请参见 http://llvm.org/docs/WritingAnLLVMPass.html |Writing an LLVM Pass .))
1 | namespace { |
前面提到过,LLVM 优化器提供许多不同的优化过程,每一个都用相似的风格编写。这些过程被编译到一个或多个 ‘’.o’’ 文件,这些文件之后会装入若干归档库(在 Unix 系统上是 ‘’.a’’ 文件)。这些库提供各种各样的分析和转换功能,并且过程是松耦合的:它们应该独立工作,或者显式地声明它们对其它过程的依赖,如果它们需要其它一些分析来完成工作的话。当给定一系列要执行的过程时,LLVM PassManager 使用显式的依赖信息来满足这些依赖并优化过程的执行。
代码库和抽象功能很棒,但是它们不能实际地解决问题。当某人想要利用编译器技术,来构建一个新工具,比如为图像处理语言所写的 JIT 编译器时,有趣的事情才会出现。这个 JIT 编译器的实现者在脑海中有种种限制:比如,也许图像处理语言对编译时延迟高度敏感,并且处于性能考虑,一些惯用的语言特性需要被优化掉。
LLVM 优化器基于代码库的设计使得实现者不仅可以定制过程执行的顺序,还可以只选择对图像处理有意义的过程:如果所有事物都被定义成单一的庞大的函数,花时间在内联上就没有意义了;如果基本没有指针,那么别名分析和内存优化不值得去考虑。尽管我们尽了最大的努力,但是 LLVM 并不能魔幻般地解决所有的优化问题!因为过程子系统是模块化的并且 PassManager 本身不知道过程的内部情况,实现者可以自由地实现他们自己的优化过程,针对特定语言,以弥补 LLVM 优化器的不足,或者明确针对特定语言的优化时机。
一旦一组优化策略被选定(并且也对代码生成器做了相似的决策),图像处理编译器会构建成一个可执行文件或动态链接库。因为对 LLVM 优化过程的引用只有简单的创建函数,它们被定义在对应的 ‘’.o’’ 文件中,并且因为优化器位于 ‘’.a’’ 归档库中,只有实际用到的优化过程会真正地链接到最终的应用,而不是链接上整个 LLVM 优化器。在上述例子中,因为有对 PassA 和 PassB 的引用,它们会被链接进来。因为 PassB 使用了 PassD 去做一些分析,PassD 也会被链接进来,因为 PassC(以及许多其它的优化)没有被使用,那么它的代码不会被链接进图像处理应用。
这便是 LLVM 的基于库的设计起作用的地方。这个直观的设计方法使得 LLVM 能提供大量的功能,一些可能只对特定用户有用,但不会强制只想实现简单功能的客户包含整个库。相反地,传统编译器的优化器由大量紧密耦合的代码构建而来,很难提取子集,导出并得以提高效率。用 LLVM 你能理解独立的优化器,而不需要知道整个系统是如何组织的。
基于库的设计同时也是为什么这么多人误解 LLVM 性质的原因。LLVM 库有许多功能,但是它们实际上并不独立工作。是由依赖库的客户端(日布 Clang 编译器)的设计者来决定如何利用好这些部分。这种审慎的分层、考察因素以及专注功能子集也是为什么 LLVM 优化器能在如此广泛的,不同目的的应用中得以使用的原因。同样地,仅仅因为 LLVM 提供了 JIT 编译功能并不意味着每一个客户端都要使用它。
11.5. 可重定目标的 LLVM 代码生成器的设计
LLVM 代码生成器负责将 LLVM 转换成特定目标的机器码。一方面,代码生成器的职责是为任何给定的目标生成尽可能最优的机器码。理想情况下,每个代码生成器应该为目标所定制,但是另一方面,每个代码生成器需要解决非常相似的问题。例如,每个目标都需要为寄存器赋值,尽管每个目标有不同的寄存器堆,使用的(分配)算法也应该尽可能共享。
类似优化器使用的方法,LLVM 的代码生成器将代码生成问题划分为独立的过程:指令选择,寄存器分配,(指令)调度,代码布局优化,以及汇编代码生成。同时提供许多内置的过程在默认情况下执行。之后目标平台的作者便可以根据需求在使用默认过程、覆写默认过程和实现完全定制的针对性过程间进行选择。例如,因为寄存器很少,x86 后端使用寄存器压力下降调度器(register-pressure-reducing scheduler)。但是 PowerPC 的寄存器很多,所以它的后端使用延迟优化调度器(latency optimizing scheduler)。x86 后端使用定制的过程来处理 x87 浮点栈,同时 ARM 后端使用定制过程,在需要时将常量池放入函数中(?)。这一灵活性使得目标平台作者不必从头实现完整的代码生成器,就可以为其平台生成优质代码。
11.5.1. LLVM 目标描述文件
mix 和 match 方法使得目标作者可以选择对架构有意义的内容并且允许跨平台复用大量的代码。这带来了另一个挑战:每个共享的组件需要能够以通用的方式导出目标相关的属性。比如,一个共享的寄存器分配器需要知道每个目标的寄存器堆以及在指令和寄存器操作数之间存在的约束。LLVM 的解决方案是使用声明式的领域专用语言(domain-specific language, DSL)(一组 ‘’.td’’ 文件)为每一个目标提供目标描述,这个 DSL 由 tblgen 工具处理。(简化的)构建 x86 目标的过程如
‘’.td’’ 文件支持的不同子系统使得目标作者能够构建目标的不同部分。比如,x86 后端定义了一个寄存器类,包含所有的 32 位寄存器,命名为 “GR32” (在 ‘’.td’’ 文件,目标专用定义都是大写的),像下面这样:
1 | def GR32 : RegisterClass<[i32], 32, |
这个定义描述了这个类里的寄存器可以存储 32 位整型数(”i32”),期望按 32 位对齐,有专用的 16 位寄存器(定义在另一个 ‘’.td’’ 文件中)并且有一些额外信息来规定偏好的分配顺序,以及其它一些内容。有了这个定义,专用指令能够使用它作为寄存器操作数。比如,”complement a 32-bit register”(对 32 位寄存器取补)定义如下:
1 | let Constraints = "$src = $dst" in |
这个定义说明 NOT32r 是一条指令(它使用 tblgen 的类 ‘’I’’),规定了编码信息(’’0xF7’’,’’MRM2r’’),它定义了一个 32 位寄存器输出 ‘’$dst’’ 并且有一个 32 位寄存器输入叫做 ‘’$src’’ (上面定义的 ‘’GR32’’ 寄存器类规定了适用于操作数的寄存器类型),规定了指令的汇编语法(使用 ‘’{}’’ 语法来处理 AT&T 和 Intel 语法),规定了指令的效果并且在最后一样提供了应满足的格式。第一行的 “let” 约束告诉寄存器分配器输入和输出寄存器必须被分配到同一个物理寄存器上(寄存器重命名你怕不怕)。
这个定义是对这条指令的非常浓缩的描述,并且通用的 LLVM 代码可以根据其产生的信息做很多事情(通过 ‘’tblgen’’ 工具)。一个这样的定义足以让指令选择通过匹配中间代码来生成这条指令。它也告知了寄存器分配器如何处理这条指令,足以用来编码指令及将指令解码到机器码,足以用来分析和打印出指令的文本形式。这些能力使得 x86 目标平台能够从目标描述中产生独立的汇编器(”gas” GNU 汇编器的取代者)和反汇编器,同时能处理 JIT 的指令编码(?)。
在提供有用功能之外,从相同的“事实”产生多个部分的信息在另一些方面有好处。这个方法使得汇编器和反汇编器在汇编语言或二进制码上不一致的情况不可能出现。它也使得目标描述更容易测试:指令编码可以在不被包含进整个代码生成器的情况下进行单元测试。
尽管致力于尽可能多地往 ‘’.td’’ 文件中写入目标信息,并保持一个优雅的声明形式,我们仍不能知道所有信息。作为替代,我们要求目标作者为各种支撑过程编写一些 C++ 代码,并自行实现他们需要的目标专用过程(像 ‘’X86FloatingPoint.cpp’’,它处理 x87 浮点栈)。在 LLVM 持续支持新的目标的同时,增加 ‘’.td’’ 文件所能表达的目标也越来越重要。我们持续增强 ‘’.td’’ 文件的表达能力来保证这点。这样的好处是随着时间前进,为 LLVM 编写目标也越来越容易。
11.6. 模块化设计带来的有趣功能
除了设计的通用优雅,模块化使得使用 LLVM 库的客户端具有一些有趣的功能。这些功能衍生自一个事实,即 LLVM 虽然提供功能,但是允许客户端决定使用这些功能的策略
11.6.1. 选择每个阶段执行的时间和位置
之前提到过,LLVM IR 能高效地序列化到一种叫 LLVM bitcode 的二进制格式(或从其反序列化)。因为 LLVM IR 是自包含的,并且序列化是一个无损的过程,所以我们能够做一部分编译工作,将进度保存到磁盘上,然后在未来某个时刻继续工作。这个特性带来了许多有趣的功能,包括对链接和安装时刻优化的支持,它们都将代码生成延迟到编译之后。
链接时优化解决了这样一个问题:传统的编译器因为每次只能观察到一个翻译单元(比如,一个 ‘’.c’’ 文件和它依赖的所有头文件),所以不能跨文件进行优化(比如内联)。LLVM 编译器比如 Clang 通过 ‘’-flto’’ 或 ‘’-O4’’ 命令行选项来支持链接时优化。这个选项告知编译器产生 LLVM bitcode,存储在 ‘’.o’’ 文件中,而不是本地目标文件,并且把代码生成时间延迟到链接时,如
不同的操作系统可能在细节上有差异,但是要点是链接器能识别 ‘’.o’’ 文件是 LLVM bitcode 而不是本地目标文件。当发现这点,它将所有的 bitcode 文件读入内存,把它们链接起来,然后在这个聚集上运行 LLVM 优化器。因为优化器现在能看到更多的代码,它可以跨文件地进行内联、常量传播、更激进的死码删除,以及更多的优化。尽管现代编译器也支持 LTO(链接时优化),它们中的大多数(比如 GCC、Open64、ICC 等等)通过代价昂贵的并且耗时的序列化过程来实现这个功能。在 LLVM 中,LTO 从系统的设计中自然而然地产生,并且能在不同的源语言上发挥作用(不像其它编译器),因为 IR 真正地独立于源语言。
安装时优化的思想是把代码生成延迟,甚至是在链接时之后,一直到安装时再做。如
11.6.2. 对优化器做单元测试
因为编译器十分复杂,并且质量至关重要,所以测试是非常关键的。比如,在修复了一个导致优化器崩溃的 bug 后,需要增加回归测试以确保它不会再发生。传统测试手段的一个例子是编写一个 ‘’.c’’ 文件贯穿编译器,并且使用测试框架验证编译器不会崩溃。这种方法的具体例子是 GCC 的测试套件。
这个方法的问题是在一个包含许多不同子系统,甚至优化器中有许多不同的过程的编译器中,在测试代码到达之前有问题的代码之前,任何部件都有机会修改输入的内容。如果前端或早期优化器发生了变化,一个测试样例很容易无法测试它本应该测试的内容。
通过配合模块化的优化器使用文本格式的 LLVM IR,LLVM 测试套件的回归测试高度专注:它们将 LLVM IR 从磁盘读入,精确地在一个优化过程中运行它,并验证期望的行为。除了崩溃之外,更加复杂的行为测试要能验证一个优化被确实执行了。这里有一个简单的测试用例,它检查常量传播在加法指令上的运作。
1 | ; RUN: opt < %s -constprop -S | FileCheck %s |
‘’RUN’’ 行确定了要执行的命令:在这个例子中,即 ‘’opt’’ 和 ‘’FileCheck’’ 命令工具。’’opt’’ 程序是 LLVM 的过程管理器的简单封装,它链接了所有标准过程(并且可以动态加载包含其他过程的补丁)并将它们暴露到命令行。’’FileCheck’’ 工具验证它的标准输入是否匹配一系列 ‘’CHECK’’ 指令。在这个例子中,这个简单的测试验证 ‘’constprop’’ 过程将 4 和 5 的 ‘’add’’ 折叠成 9.
尽管这个例子看起来很不起眼,这对写 ‘’.c’’ 文件来测试是十分困难的:因为前端经常在分析时做常量折叠,所以写代码让其抵达常量折叠优化过程是困难且不可靠的。因为我们能以文本载入 LLVM IR 并传送给我们感兴趣的特定优化过程,然后把结果导出到另一个文本文件,它确实能直观地精确测试我们想测试的,包括回归测试和特性测试。
11.6.3. 用 BugPoint 自动减少测试用例
当在编译器或其他 LLVM 库的客户端中发现了 bug,修复它的第一步就是构造一个复现问题的测试样例。一旦你有了这样一个测试样例,你最好将其最小化到复现问题的最小样例,并且同时细化到 LLVM 中发生问题的部分,比如出错的优化过程。尽管你最终知道如何去做这些,这个过程是乏味的,手工的,并且在编译器产生错误代码但是没有崩溃时尤其痛苦。
LLVM BugPoint 工具((http://llvm.org/docs/Bugpoint.html))使用 LLVM IR 序列化和模块化设计来自动化这一过程。例如,给定一个 ‘’.ll’’ 或 ‘’.bc’’ 输入文件以及导致优化器崩溃的一系列优化过程,BugPoint 把输入精简到一个小的测试用例并确定哪个优化器出错了。然后它输出精简的测试用例以及复现错误的 ‘’opt’’ 命令。BugPoint 通过使用类似 “delta debugging” 的方法来精简输入和优化过程列表以发现出错点。因为知道 LLVM IR 的结构,BugPoint 不会像标准的 “delta” 命令行工具那样浪费时间产生无效 IR 输送给优化器。
在更加复杂的编译错误的场景中,你能指定传递给可执行文件的输入、代码生成器信息和命令行,以及一个参考输出。BugPoint 会首先确定问题是由优化器还是代码生成器造成的,然后重复将测试样例划分成两部分:一部分送入“已知正常”的部分,另一部分送入“已知有问题”的部分。它通过迭代地将越来越多的代码移出已知有问题的部分来精简测试用例。
BugPoint 是一个非常简单的工具,并且通过精简测试样例在 LLVM 的发展过程中节省了无以计数的时间。其它的编译器的工具没有一款能做到同等强大,因为这依赖于一个定义良好的中间表现形式。即便如此,BugPoint 也不是完美的,并且重写一下会比较好。它可以追溯到 2002 年,并且只在某个人发现了非常难以追踪的 bug 并且现有的工具无法很好地处理时才会得到改进。它经历了长时间的发展并添加新的功能(比如 JIT 调试),但是缺少一致性设计和负责人。
11.7. 反思与未来展望
LLVM 的模块化一开始并不是为了达到上述的功能而设计的。它是一种自我防范机制:很明显我们不可能一开始就做好所有的事情。以模块化的过程流水线为例,它的存在是为了简化过程隔离,这样它们被更好的实现替换时能很容易的废弃掉((我经常说 LLVM 的这些子系统只有至少重写一遍才真正算是优秀的。))。
LLVM 保持灵活性的另一个主要原因(同时也是使用这些库的客户端的一个争议话题)是我们希望重新考虑之前的决策并大范围地改动 API,同时不用担心破坏向后兼容性。比如,破坏性地改动 LLVM IR 本身需要更新所有的优化过程,并对 C++ API 造成潜在的混乱。我们会在个别情况下这么干。尽管这会让客户感到痛苦,但是为了保持快速进步,这是正确的。为了减少外围用户的痛苦(同时支持绑定其他语言),我们为许多 API 提供了 C 封装(它们应该是极度稳定的),同时新版本的 LLVM 也会致力于继续支持旧的 ‘’.ll’’ 和 ‘’.bc’’ 文件。
展望未来,我们期望 LLVM 能更加模块化并且易于剪裁。以代码生成器为例,它现在还是太臃肿了:目前还不太可能根据功能来剪裁 LLVM。具体地说,如果你想用 JIT,但是不需要内联汇编、异常处理或者生成调试信息,应该有办法构建没有这些功能的代码生成器才对。我们也持续地改进优化器和代码生成器生成的代码质量,增加 IR 特性来支持新的语言和目标构件,以及在 LLVM 中为高层次的语言特定优化提供更好的支持。
LLVM 项目以多种方式持续地成长和改进。LLVM 在其他项目中得到丰富的应用,它持续在设计者所不曾料想的新场景下出现,这着实令人兴奋。新的 LLDB 调试器就是一个很好的例子:它使用 Clang 中的 C/C++/Objective-C parser 来分析表达式,使用 LLVM JIT 生成目标代码,使用 LLVM 反汇编器,此外,还使用 LLVM 目标来处理调用规约。能复用现有代码使得开发调试器的人能专注于调试器逻辑,而不是重复实现另一个(比较正确)的 C++ parser。
不管 LLVM 迄今为止多么成功,还是有许多遗留问题需要解决。同时 LLVM 成长过程中僵化的风险也是时刻存在的。尽管这个问题没有一蹴而就的答案,我希望持续暴露在新的问题领域中,愿意重新评估先前的决策并重新设计和抛弃代码能够有所帮助。毕竟我们的目标不是成就完美,而是随着时间的流逝,持续进步。