阅读 194

(译)开源软件架构之 LLVM(The Architecture of Open Source Applications LLVM)

这是一篇译文,原文作者 Chris Lattner

原文地址在这里:The Architecture of Open Source Applications: LLVM

由于译者英文水平有限,文章中有很多翻译的不恰当的地方,请读者见谅

这章节讨论 LLVM 的设计思想,LLVM 是一系列紧密联系的底层工具链组件的统称(例如链接、编译、调试等),同时 LLVM 兼容已有的工具,例如运行在 Unix 系统上。LLVM 这个名字是一个简写,但是现在它是整个项目的一个统称。LLVM 具有一些独有的特性,并以一些很棒的工具著称(如Clang 编译器,Clang 用于编译 C/C++/Objective-C,性能数倍于 GCC 编译器),但令 LLVM 优于其他编译器的最重要的一点是其内部的架构。

自 2000 年 12 月 LLVM 项目开始以来,LLVM 就被设计成一系列接口清晰的可重用库。彼时,开源项目通常还是被设计成整个项目中的一个专用工具。例如,想要把静态编译(例如 GCC)的解析器重用到静态分析或重构器是非常困难的。在大型的应用中,脚本语言通常提供一个内嵌的运行时和解释器,运行时通常是作为一团独立的代码被嵌入进去。想要重用基本没戏,几乎没有跨语言共享。

在编译器之上,各流行语言的编译社区也是两极分化:一种通常提供传统的静态编译器,像 GCC、Free Pascal 和 FreeBASIC;另一种以解释器的形式提供运行时编译或即时编译(JIT)。很少见到一种语言同时支持这两种编译形式,如果有也是几乎没有复用代码。

在过去的十年,LLVM 大大改变了这种状况。LLVM 现在用作通用基础组件,以实现各种静态和运行时编译语言的编译(例如 GCC 家族支持的语言 Java、.NET、Python、Ruby、Scheme、Hashell、D、还有一些小众的语言)。它还取代了一些专用的编译器,例如苹果 OpenGL 专业运行时引擎,还有 Adobe AE 图片处理库产品。LLVM 还被用于创建各种新产品,可能最出名的就是 OpenCL 项目的 GPU 程序语言和运行时。

11.1 传统编译器设计一览

最流行的传统静态编译器(像大多数 C 编译器)设计就是「三段式的设计」,即前端、优化器和后端(下图)。前端解析源码,检查错误,然后编译成指定语言的抽象语法树(Abstract Syntax Tree)。可以选择性地将 AST 转换为新的中间码以进行优化,优化器和后端生成机器码。

优化器负责做各种转化尝试提高代码的执行效率,如消除冗余的计算,优化器通常或多或少地独立于语言和目标机器。后端(也叫代码生成器)负责将代码映射到目标指令集。除了生成正确的代码,它还负责利用目标架构的特殊功能生成性能更好的代码。编译器后端的通用功能包括指令选择、寄存器分配和指令调度。

该模型同样适用于解释器和JIT 编译器。Java 虚拟机(JVM)也是实践了该模型,它使用Java 字节码作为前端和优化器之间的接口。

11.1.1 该设计的影响

当编译器决定支持多语言或多目标架构时,这种经典设计的优势就很明显了。如果编译器在优化时使用一种通用的中间码,那么前端可以用任意的可编译语言编写,且后端可以编译成任何目标机器码。如下图所示。

在这个架构设计下,想要把编译器移植给新的语言(例如 Algol 或 BASIC)只需要实现一个新的编译前端,已有的优化和后端都能实现复用。如果前后端和解析器没有相互解耦,给新语言实现编译器就需要重头开始,支持 N 个目标机和 M 种语言需要实现 N*M 个编译器。

「三段式设计」的另一个优势是相比于支持语言和目标机,编译器更多的是为广大程序员服务。对于开源项目而言,社区中有大量的潜在贡献者在一起维护、提升和改进编译器的性能。这就是为什么开源编译器越是向更多的社区提供服务(像 GCC),最后生成的机器码比那些没有社区的编译器质量(像 FreePASCAL)要高。而私有编译器的质量却直接和项目预算相关。例如,尽管英特尔的 ICC 编译器只有少数人参与,但是却以机器码性能而著名。

「三段式设计」还有一个优势是实现一个编译器前端和实现一个编译器后端及优化器需要不同的技能栈。将其区分开来更易于前端同学维护和提升前端的性能。这是一个社会分工问题而不是一个技术问题,这在实践中非常重要,特别是对于那些想要尽量减少协作障碍的开源项目。

11.2 已有实践

虽然编译教材中的「三段式设计」的优点令人向往,但是几乎没有人完全按照这个设计实践过。纵观开源软件(在 LLVM 之前),像 Perl、Python、Ruby 和 Java 都没有共用过编译器代码。此外,像格拉斯哥Haskell 编译器(GHC)和 FreeBASIC 项目都支持多个不同 CPU,但是它们都只支持一种指定的编程语言。还部署了各种各样的特定编译器来实现用于图像处理的 JIT 编译器、正则表达式、显卡驱动和其他需要 CPU 计算的子域。

尽管如此,此模型还是有三个主要的成功案例,第一个是 Java 和 .NET 虚拟机。这些系统提供一个即时编译(JIT)编译器、运行时支持和定义明确的字节码规范。这意味着任何可以编译为该字节码的语言都能够复用即时编译(JIT)和运行时的成果。但它们强制使用即时编译、垃圾回收机制和一些不兼容的对象模型。当编程语言与该模型不完全匹配时,将导致性能下降,例如 C 语言。

第二个成功案例也许是最不幸的,也是最流行的复用编译器技术的方式:将非 C 语言代码转成 C 语言代码(或其他语言),然后用 C 语言编译器来编译。这样就能复用优化和机器码生成的代码,不乏灵活性和运行时特性,编译器前端同学很容易理解、编码和维护。不幸的是,这将会降低异常处理的效率,导致调试体验差、编译速度慢,同时对于要求尾调用(tail calls 尾调用 - 维基百科,自由的百科全书)的语言可能会出现问题(或其他 C 语言不支持的特性)。

最后一个成功案例就是 GCC。GCC支持许多编译器前端和后端,社区拥有大量活跃贡献者。GCC 作为 C 编译器的历史由来已久,它支持多个目标机,并且对相关的其他几种语言都提供了强大的支持。随着时间的流逝,GCC 社区正在发展更为整洁的设计。从 GCC 4.4开始,它开发了针对优化器的新中间码(称为“GIMPLE元组”),该中间码比以前更接近于编译器前端语言。另外,其 Fortran 和 Ada 编译器前端将使用更整洁的抽象语法树(AST)。

虽然非常成功,但是这三种方法在使用上都受到很大限制,因为它们被设计为一个整体。例如,将 GCC 嵌入其他编译程序、将GCC用作运行时或者即时编译的(JIT)编译器、或只使用 GCC 的部分功能,而不引入整个编译器,这些都是不可能的。如果想要使用 GCC 的 C++ 编译前端用作文档生成器、代码索引、重构、静态分析,就必须使用 GCC 整个编译系统生成 XML 文件之后再做处理,或者以插件形式往 GCC 注入代码。

不能将 GCC 部分功能作为库重用的原因有很多,包括滥用全局变量、不可变变量不能更改限制不严、设计不良的数据结构、庞大的代码库、以及使用宏来防止代码库一次编译可以支持多个编译前端-目标机对。不过,最难解决的问题是其早期设计和那个时代所固有的架构设计。具体来说,GCC 饱受分层问题和抽象漏洞的困扰:编译后端遍历编译前端抽象语法树(AST)来生成调试信息(debug info),编译前端生成编译后端数据的结构,整个编译器依赖命令行设置的全局数据结构。

11.3 LLVM 中间码:LLVM IR

忘掉上面讲的这些历史问题,让我们进入 LLVM 的世界: LLVM 设计中最重要的一环就是「LLVM IR(中间码)」,LLVM IR 是代码在编译器中的表达形式。LLVM IR 被设计成在编译优化层用来做中间分析和转换的载体。它的设计考虑了许多具体目标,包括支持轻量运行时优化、跨功能/过程间优化、全程序分析、重构转换等等。但其最重要的一点是,它本身被定义为具有明确语义的语言。具体来说,下面是 .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 %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 对应下面这段 C 代码,提供了两种不同的方式来求整数的和。

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);
}
复制代码

从这个例子中可以看到,LLVM IR 是类似于精简指令集(RISC)的底层虚拟指令集。和真实的精简指令集一样,它支持简单指令的线性序列,例如添加、相减、比较和分支。这些指令都是三地址形式(Three-address code - Wikipedia),它们接受一定数量的输入然后在不同的寄存器中存储计算结果。LLVM IR 支持标签,并且通常看起来就像是一种奇怪的汇编语言。

与大多数精简指令集不同,LLVM 使用强类型的简单类型系统(例如 i32 是 32 位整形,i32** 是指向 32 位整形的指针)并剥离了机器差异。例如,约定通过 callret 指令以及显式参数来进行调用。与机器码的另一个重要区别是 LLVM IR 不使用固定的命名寄存器,它使用以 % 字符命名的临时寄存器。

除了被实现为一种语言,LLVM IR 实际上有三种等价形式:上面的文本格式、优化器自身检查和修改的内存数据结构形式,以及高效磁盘二进制“位码”形式。LLVM 项目还提供了将磁盘格式的文本转换为二进制的工具:llvm-as命令 将 .ll文本文件转成以.bc作为后缀的二进制流文件,llvm-dis命令将 .bc 文件转成.ll文件。

编译器的中间表示很有趣,因为对于编译优化器来说,它可以是“完美的世界”:不同于编译器前端和后端,优化器不受特定编程语言或特定目标机的限制。另一方面,它必须兼顾以下两点:第一必须将其设计为易于编译前端生成。第二,需要具有足够的表现力,让那些重要的优化可以执行到目标机上。

11.3.1 实践编写 LLVM IR

为了让大家对优化工作是如何进行的有一些感性的认识,有必要一起来看一些例子。因为有许多不同的编译器优化类型,所以很难用一个通用方法来讲清楚所有优化工作。但是,大多数都遵循下面三个步骤:

  • 转换模式查找
  • 验证对目标机的转换是否安全和正确
  • 执行转换,更新代码

最简单的优化是算术恒等式的替换,例如,对于任何整形数 XX-X等于 0,X-0等于X(X*2)-X等于 X。第一个问题是如何用 LLVM IR 来表达。例如:

⋮    ⋮    ⋮
%example1 = sub i32 %a, %a
⋮    ⋮    ⋮
%example2 = sub i32 %b, 0
⋮    ⋮    ⋮
%tmp = mul i32 %c, 2
%example3 = sub i32 %tmp, %c
⋮    ⋮    ⋮
复制代码

对于这些转换,LLVM 提供了一个简化接口,LLVM 同时还有其他同类型高级转换的工具。这些特殊的转换在SimplifySubInst函数中,如下所示:

// X - 0 -> X
if (match(Op1, m_Zero()))
  return Op0;

// X - X -> 0
if (Op0 == Op1)
  return Constant::getNullValue(Op0->getType());

// (X*2) - X -> X
if (match(Op0, m_Mul(m_Specific(Op1), m_ConstantInt<2>())))
  return Op1;

…

return 0;  // Nothing matched, return null to indicate no transformation.
复制代码

在此代码中,Op0 和 Op1 绑定到整数减法指令的左右操作数(注意,这些不适用与 IEEE 浮点数!)。LLVM 用C++实现,它的模式匹配能力不是很出名(相比与Objective Caml等函数式语言),但是它提供了非常通用的模板系统,使我们可以实现类似的功能。match 函数和m_函数允许我们能够对LLVM IR 代码执行声明性模式匹配操作。例如,仅当乘法的左侧与Op1相同时,m_Specific断言才匹配。

这三种情况都是符合替换条件,如果可以,函数将返回替换项,如果无法进行替换,则返回 null 指针。此函数的调用者(SimplifyInstruction)负责调度,对指令操作码进行选择,分发到每个操作码帮助函数。各类优化器都会调用它。一个简单的驱动程序看起来是这样的:

for (BasicBlock::iterator I = BB->begin(), E = BB->end(); I != E; ++I)
  if (Value *V = SimplifyInstruction(I))
    I->replaceAllUsesWith(V);
复制代码

这段代码只是循环遍历一个代码块中的每条指令,检查它们是否可以做简化。如果可以(SimplifyInstruction返回非空),将调用replaceAllUsesWith函数来替换代码中可以简化的任何指令。

11.4 LLVM 的三段式设计实践

在基于 LLVM 的编译器中,编译器前端负责解析、验证和诊断输入代码中的错误,然后将解析的代码转换为 LLVM IR(通常意义上来说,先构建抽象语法树,然后将抽象语法树翻译成 LLVM IR)。LLVM IR可以选择通过一系列分析和优化从而改进代码,然后输入代码生成器生成目标机机器码,如下图所示。这就是三段式设计的简单实现,描述起来很简单,但是用 LLVM IR 来驱动 LLVM 的架构很强大同时又很灵活。

11.4.1 LLVM IR是完整的代码形式

LLVM IR既有清晰地规范,又是与优化器的唯一接口。这就意味着为 LLVM 编写编译前端唯一要做的就是生成 LLVM IR。由于LLVM IR具有一流的文本格式,构建一个将 LLVM IR 作为文本输出的前端既可行又合理,然后在 Unix系统下,将 LLVM IR 输入到编译优化器,然后将优化器产物编译为需要的目标机机器码。

可能令人惊讶,但 LLVM 有一个非常新颖的特性,也是它在各种不同应用程序中获得成功的主要原因之一。那就是,即使是很成功且相对完善的 GCC编译器也没有的属性:它的 GIMPLE 中间码不是自包含的。举个简单的例子,当 GCC 代码生成器生成 debug info 的时候,它将返回遍历编译器前端的抽象语法树。GIMPLE 本来使用元祖来表示源码中的操作(不用再耦合编译器前端的数据结构),但是至少 GCC 4.5 仍然还是使用源码中的抽象语法树(后端还是耦合了前端)。

这就是说一个写编译器前端的同学还必须要像编译器后端的同学一样清楚后端需要什么样的抽象语法树数据结构(震惊!!)。后端同学也有一样的困扰,他们还需要了解 RTL(电阻晶体管逻辑)后端的工作原理。最终,GCC 没有一种方式可以清晰地“表达我的代码”,或人类可理解的 GIMPLE 文本表达形式(或是一种数据结构)。结果开发 GCC 相对困难,因此做 GCC 前端的人很少。

11.4.2 LLVM 由一系列库组成

在设计完 LLVM IR 之后,LLVM 下一个重要的点就是要把 LLVM 设计成一系列独立的库,而不是像 GCC 那样的不可分离命令行编译器,或是像 JVM 那样的不透明虚拟机和 .Net 虚拟机。LLVM是基础架构,是编译器技术的集合,可用于解决特定问题(比如搭建 C 编译器,或特殊领域的优化器)。这是 LLVM 最强大的功能之一,也是其鲜为人知的设计要点之一。

以优化器的设计为例:它将 LLVM IR 作为输入,然后逐步处理输入并生成执行效率更高的 LLVM IR。LLVM 优化器(和其他编译器一样)以流水线的方式为输入做不同的优化。常见的例子是内联(替换调用位置的函数实体)、重新组合表达式、移动循环不变代码等等。根据优化级别,运行不同程度的优化:例如 -O0 是不做优化,-O3 将运行 67 道优化程序(LLVM 2.8 版本)。

每个 LLVM 优化程序都独立出一个类,都继承自Pass父类。大多数优化程序都独立出.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(); }
复制代码

前面提到,LLVM优化器提供了数十种不同的优化程序,每个都以相似的风格编写。这些优化程序被编译成一个或多个.o文件,然后将其内置到一系列库文件中(Unix 系统的.a文件)。这些库提供了各种分析和转换功能,并且这些优化程序彼此间都尽量的松耦合:它们彼此间相互独立,或者当依赖其他模块的功能的时候,需要显式地声明依赖关系。当执行一系列优化程序时,LLVM 的 PassManager 会读取依赖信息生成依赖并优化优化程序的执行过程。

独立库和抽象能力做好了,但这并不能解决问题。有趣的是,当有人基于已有的编译技术构建新的编译工具时,例如用于图像处理语言的 JIT 编译器。此 JIT 编译器的实现者要考虑:例如,也许图像处理语言对编译时延迟非常敏感,并且一些约定俗成的语言习惯对性能非常重要。

LLVM 优化器基于库的设计可以让我们可以灵活选择要组合的优化程序以及自定义优化程序之间的执行顺序,这两点在图像处理领域很有意义:如果在对编译延时很敏感的图片处理上弄一个很臃肿的编译器,那么时间就都被浪费在内联功能上了。可以预见,指针越少,那么别名分析和内存优化就不会成为性能点。然而,尽管我们尽了最大努力,但是 LLVM 在解决优化器的性能问题上也并不是万能的!由于优化程序是模块化的,同时PassManager本身也是和优化程序解耦的,所以 PassManager对优化程序的内部实现细节一无所知,因此 LLVM 并不能把优化程序的执行性能发挥到极致,所以只能编译器前端的开发者自己去实现绑定编程语言的优化程序来弥补 LLVM 的这方面的遗憾。下图展示了一个假想的 XYZ 图像处理系统的简单示例:

一旦选择了优化程序集(代码生成器也是如此),图像处理编译器将被打包成可执行文件或动态库。由于 LLVM 优化过程对优化程序的唯一引用就是每个 .o文件的create函数,同时优化程序存放在.a库中,因此只有那些真正使用到的优化程序,才会最终被链接进最终优化程序应用中,而不是整个 LLVM 优化程序。在我们上面的例子中,因为只有 PassA 和 PassB 被引用,所以这两个优化程序会被链接进最终的产物。因为 PassB 使用了 PassD 来做一些分析,PassD 也会被链接进去。然而,PassC(和其他数十个优化程序)没有被使用,它们不会被链接进图片处理程序中。

这就是 LLVM 基于库设计的强大之处。这种直接的设计使 LLVM 能够组合出大量功能,虽然其中一些可能仅对特定受众有用,但也使得一些简单的使用场景不需要引入太多繁杂的库。相比之下,那些传统的编译器优化器由大量紧耦合的代码组成,很难子集化、理解并优化执行速度。有了 LLVM ,你不需要理解整个编译系统是如何协作的就可以使用优化器。

这种基于库的设计也是为什么这么多人误解 LLVM 的原因:LLVM 库具有许多功能,但有些根本没有用到,所以看起来是多余的。但这取决于设计者如何利用这些库以达到优化器优化最佳效果(例如 Clang 的 C 编译器)。这种细致的分层、分解和对子集能力的关注也是为什么 LLVM 优化器在不同使用场景中都能兼容的原因。此外,LLVM 还提供了即时编译能力(JIT),虽然并不是所有人都是用这个功能。

11.5 多目标机兼容的 LLVM 代码生成器的设计

LLVM 代码生成器负责将 LLVM IR 转换成指定的目标机机器码。一方面,代码生成器的工作是为任何给定目标机生成最佳的机器代码。理想情况下,每个代码生成器都应该是为目标机的完全自定义代码,但是另一方面,每个代码生成器都在解决非常相似的问题。例如,虽然每个目标机都有不同的寄存器文件,但每个目标机都有寄存器赋值操作,因此这部分算法应该尽可能的复用。

和优化器类似,LLVM 的代码生成器将代码生成分为多个独立的过程——指令选择、寄存器分配、调度、代码布局优化和代码组装——并提供许多默认运行的内置实现。代码生成器的开发者可以选择默认的实现或者用完全自定义的实现来覆盖默认实现。例如 x86 架构寄存器很少,所以使用减缓寄存器压力调度策略,但是 PowerPC 因为没有寄存器压力,所以后端使用延迟优化调度策略。x86 编译后端使用自定义实现来处理 x87 浮点堆栈,ARM 编译后端使用自定义实现在需要的函数中存放独立常量池。这种灵活性允许编译器开发者不必为目标机从头编写整个代码生成器就可以生成良好的目标机机器码。

11.5.1 LLVM 目标机描述文件

“组合和匹配”的方案允许开发者聚焦其编译器的核心,同时能为多目标机复用大量代码。这带来了另一个挑战:每个通用组件都需要能够以通用的方式推断出目标机特定的属性。例如,通用的寄存器分配器需要知道每个目标机的寄存器文件,以及各个目标机指令集及和寄存器操作数之间的区别。LLVM 的解决方案是,为每个目标机提供由 tblgen 工具处理的特定域的声明语言(一组.td文件)。x86 架构简化的构建过程如下:

.td文件各个子系统支持目标机开发者为其目标机建立不同的定义。例如,x86 编译后端定义了叫做 “GR32”寄存器类,这个类持有如下所有的 32 位寄存器(在.td文件中,所有的目标机定义都是大写的)。

def GR32 : RegisterClass<[i32], 32,
  [EAX, ECX, EDX, ESI, EDI, EBX, EBP, ESP,
   R8D, R9D, R10D, R11D, R14D, R15D, R12D, R13D]> { … }
复制代码

此定义表名这个类中的寄存器可以保存32位整数值(“i32”),使用 32 位进行对齐,有指定的16个寄存器(在.td文件的其他地方定义),并且有更多的信息来指定内存分配顺序和其他内容。给定此定义,特定指令可以将其用作操作数参考。例如,“补充 32 位寄存器”指令定义为:

let Constraints = "$src = $dst” in
def NOT32r : I<0xF7, MRM2r,
               (outs GR32:$dst), (ins GR32:$src),
               “not{l}\t$dst”,
               [(set GR32:$dst, (not GR32:$src))]>;
复制代码

定义中表明 NOT32 是一个指令(使用Itblgen 类),指定编码信息(0xF7MRM2r),“输出”32 位寄存器$dst,“输入”32 位寄存器$src(上面定义的GR32寄存器类定义了哪些寄存器对操作数有效),指定指令的汇编语法(使用{}语法同时处理 AT&T 和 Intel 语法),指定指令的效果并在最后一行指定应该使用的匹配模式。第一行的“let”约束告诉寄存器分配器,必须将输入和输出寄存器分配给同一物理寄存器。

上面集中解释了这段代码的含义,LLVM 可以处理从中获得的信息(通过tblgen工具)。对于编译器而言,这个定义足以让指令选择通过对输入的 IR 进行模式匹配生成指令。它还告诉寄存器分配器如何处理它,足以将指令编码和解码为机器字节码,并且足以解析成文本形式。这些功能支持为 x86 架构生成独立的x86汇编程序(这是“gas” GNU汇编器的直接替代品),为目标机进行反汇编以及处理 JIT 指令的编码。

除了提供有用的功能之外,从同一个“事实”生成多个信息片段还有其他好处。这种方法使得汇编程序和反汇编程序在汇编语法或二进制编码方面几乎不可能彼此不一致。还使得目标机描述文件变得更容易测试:指令编码可以在不涉及整个代码生成器的情况下进行单元测试。

尽管我们旨在以一种很好的声明格式将尽可能多的目标机信息存进.td文件中,但我们仍然不具备所有功能。相反,我们要求目标机开发者为各种支持程序编写一些C++代码,并开发他们可能需要的任何特定目标机程序(像处理 x87 浮点堆栈的X86FloatingPoint.cpp文件)。随着 LLVM 不断支持新的目标机架构,增加新的目标架构的.td描述文件变得越来越重要,并且我们会继续提高.td文件的表现力来解决这一问题。随着时间的推移,在 LLVM 中支持新的目标架构会变得越来越容易。

11.6 模块化设计带来的有趣功能

模块化除了是一种优雅的设计外,还为 LLVM 库的客户端带来了一些有趣的功能。这些功能源于 LLVM 提供的功能,却是让用户决定使用它的最多策略。

11.6.1 决定每个阶段何时何地运行

如前所述,LLVM IR 可以高效地序列化为一种称为 LLVM 位码的二进制格式,或从该序列中反序列化。由于 LLVM IR 是独立的,序列化是一个无损的过程,我们可以先只进行部分编译,将进度保存到磁盘上,然后在将来的某个时候从保存的进度继续工作。此功能提供了许多有趣的功能,包括对链接时间和安装时间优化的支持,这两种方法都延迟了代码生成的时机。

链接时优化(LTO)解决了传统编译器一次只能处理一个转换单元(例如一个.c文件和其所有头文件),因此无法跨文件进行优化(例如内联)的问题。LLVM 编译器 Clang 通过-flto-O4命令行可选参数支持此功能。该编译选项命令编译器往.o文件中写入 LLVM 字节码而不是原生对象文件,并将代码生成时间延迟到链接时,如下图所示:

操作系统不同可能细节有所不同,但是重要的是链接程序在.o文件中检测到 LLVM 字节码而不是原生对象文件。当链接程序检测到 LLVM 字节码的时候,它将所有的字码读入内存进行链接,然后对链接产物执行 LLVM 优化程序。由于优化器现在可以看到更多的代码,它就可以进行内联、合并常量、更进一步执行冗余代码消除和跨文件执行更多操作。虽然许多现代编译器都支持链接时优化(LTO),但大多数编译器(例如 GCC,Open64,Intel 编译器等)都通过昂贵且缓慢的序列化过程来支持 LTO。在 LLVM 中,LTO 是独立的设计,并且可以跨不同的编程语言(和其他许多编译器不同),因为 LLVM IR 是真实的独立于编程语言的。

安装时优化是将代码生成延迟到甚至比链接还晚,直到安装时,如下图所示。安装时是一个非常有趣的时间(软件安装、下载、上传到移动设备等),因为此时你可以清楚地知道目标机的详细信息。例如,在 x86系列中,有各种各样的芯片和特性。通过延迟,你可以为应用程序最终运行的硬件的指令集选择、调度和代码生成的其他方面选择最佳答案。

11.6.2 优化器的单元测试

编译器非常复杂,质量又很重要,因此测试至关重要。例如,修复导致优化器崩溃的错误之后,应添加回归测试用例以确保崩溃不再发生。测试此问题的传统方法是编写一个通过编译器运行的.c文件,并需要一个测试工具来验证编译器不会崩溃。这就是 GCC 测试套件所使用的方法。

这种方法的问题在于,编译器由许多不同的子系统组成,甚至在优化器中还包含许多不同的子优化程序,所有这些都有机会在输入到达原先有 bug 地方之前修改代码。如果编译器前端或是较早的优化程序发生变更,测试用例很容易就不符合预定的测试内容。

由于支持在模块化的优化器下使用文本形式的 LLVM IR,LLVM 测试套件可以为单个的优化程序编写回归测试用例,并能够从磁盘加载 LLVM IR,为单个优化程序运行测试用例并验证测试结果是否符合预期。除了崩溃之外,还需要给更复杂的情况编写测试用例并验证优化是否生效。下面是一个简单的测试用例,用于检查常量相加是否生效:

; RUN: opt < %s -constprop -S | FileCheck %s
define i32 @test() {
  %A = add i32 4, 5
  ret i32 %A
  ; CHECK: @test()
  ; CHECK: ret i32 9
}
复制代码

RUN指定要执行的命令:在这例子中,使用optFileCheck命令行工具。opt是 LLVM 程序管理器的简单包装,所有标准优化程序中的链接都能在命令行中调用(并能动态加载包含的优化程序插件)。FileCheck工具验证其输入是否与CHECK指令匹配。在这个例子中,这个测试用例用于验证constprop程序将 4 和 5 相加的结果是否是9。

这个例子看起来很简单,但是却很难通过编写.c文件来进行测试:编译器前端在解析时通常会不断折叠,因此,很难编写代码来跟踪后续的不断折叠优化。而 LLVM 可以加载文本形式的 LLVM IR 然后作为优化程序的输入,然后我们可以将优化结果输出为另外一个文本文件,所以不管是进行回归测试还是功能测试都将变得很简单。

11.6.3 使用 BugPoint 减少测试用例

当在编译器或者 LLVM 库中发现了 bug,修复的第一步是获取复现 bug 的测试用例。有了测试用例后,最好将其缩小为复现问题的最小用例,并将其范围缩小到发生问题的 LLVM 部分,比如优化程序出错。当你熟稔于心时,就会感觉这个手动的过程很繁琐,尤其那些编译器生成了错误但是又不会崩溃的代码会更加令人痛苦。

LLVM BugPoint 工具使用 IR 序列化和 LLVM 的模块化设计来自动执行此过程。例如,将给定的.ll或者.bc文件输入到一系列优化程序中引发了优化器崩溃,BugPoint 将输入缩小到一个小的测试用例上来确定是哪个优化器程序出了故障。然后它输出简单的测试用例和opt命令来复现故障。它通过使用一种称作“增量调试”的技术来减少输入和优化器列表来定位问题。因为它知道 LLVM IR 的结构,所以与标准的“增量”命令行工具不同,BugPoint 不会浪费时间生成无效的 IR 输入到优化器。

在更复杂的情况下,如果编译错误,可以指定输入和代码生成器的信息,命令行工具将会把它传递给可执行文件和产物。BugPoint 首先将确定问题是由于优化器还是代码生成器引起的,然后分两步执行测试用例:一部分送往“没问题”的组件,一部分送往“有问题”的组件。通过迭代地往有 bug 的代码生成器输送测试用例来减少测试用例的范围,从而生成测试用例。

BugPoint 是一个非常简单的工具,在 LLVM 的整个生命周期中节省了无数用于编写测试用例的时间。由于 LLVM 具有定义良好的中间码,所以 LLVM 拥有其他开源编译器没有的强大工具 BugPoint。时间回到2002年,通常只有当人们需要跟踪一个非常棘手的 bug 而现有工具又不能很好地应对时,人们才回去改进它。随着时间的推移,开源协作让它不断变强,开始有新的功能,比如支持即时编译的调试。

11.7 回顾和未来方向

LLVM的模块化最初并不是为了直接实现本文所述的任何目标而设计的。而是一种自我防卫的机制:很明显,我们很难在第一次尝试时就能把所有事情都做到最好。而模块化优化程序是为了使隔离更加容易,以便用更好的实现方式替换掉旧的实现。

LLVM 保持敏捷的另一个主要方面是当我们重新思考之前的实现决定对 API 进行大改的时候不需要担心向后兼容性。LLVM IR 本身的更改,例如,需要更新所有的优化器程序导致大量 C++ API 失效。我们已经多次这样做了,尽管这会给开发者带来痛苦,但是这是保持快速进步的正确做法。为了使开发者(并支持其他语言的绑定)的更轻松,我们为许多流行的 API 提供了C 语言包装(这看起来比较稳定)并且 LLVM 的新版也会继续支持旧版的.ll.bc文件。

展望未来,我们希望继续推进 LLVM 的模块化和更易于子集化。例如,代码生成器仍然过于单一:目前无法基于功能对 LLVM 代码生成器进行子集化。假设你想要使用即时编译(JIT),但是不需要内联汇编、异常处理或者生成 debug 信息,现在还不支持,但是应该可以达成建造一个不使用这些功能的代码生成器。我们还在不断提高优化器和代码生成器生成代码的质量,添加 IR 特性以更好地支持新的语言和目标机,并添加在 LLVM 中执行高级语言特定优化的更好支持。

LLVM项目以多种方式不断发展壮大。看到 LLVM 在其他项目中以不同的方式被应用,以及它如何在其设计人员从未想到的领域被应用,真是令人兴奋。新的 LLDB 调试工具就是一个很好的例子:LLDB 使用 Clang 的 C/C++/Objective-C 语法解析器来解析表达式,并使用 LLVM 即时编译将其转换为机器码,使用 LLVM 反汇编程序,并使用 LLVM 目标机来处理调用约定等。LLVM 使开发调试器的人员可以专注于编写调试器逻辑,重用现有代码,而不必重新实现一个 C++ 解析器。

尽管如今 LLVM 小有成就,但仍有许多工作要做,以及随着时间流失,LLVM 存在变得越来越不灵活的风险。尽管没有解决这个问题的灵丹妙药,但我希望继续面对挑战,重新评估先前的决定,重新设计并更新代码将有所裨益。毕竟,完美并非目标,而是要跟随岁月一起变的更好。