# LLVM 架构与设计下

模块化设计的优势

总所周知,在模块化设计中,将各层之间互相解耦,各层之间独立开发,彼此之间互不影响,这样的架构设计可以说是一个普适而优雅的设计, LLVM 采用模块化架构的设计为基于LLVM 库的开发者(前端或者后端)提供了一些可扩展的功能。这些功能源于 LLVM 基于各个功能库的开发提供了高度灵活性配置的功能扩展,但让开发者根据实际场景选择使用那些功能,同时可以自定义化自己特定的功能。

选择阶段运行的时机

如前所述,LLVM IR 可以轻易的通过工具序列化 LLVM 位码的二进制格式,同时也可以使用工具将位码格式反向生成文本形式的描述。由于 LLVM IR 拥有自包含特性(也即高内聚低耦合,所有代码均不涉及到其他模块),并且将LLVM IR序列化为位码时将会保留原始LLVM IR的信息,因为位码紧紧只是将IR的格式从文本格式转为更加节约空间的二进制形式,所以我们可以对LLVM IR进行部分编译(比如:通过JIT 编译目标代码),将编译进度保存到磁盘,然后在将来的某个时间继续工作。此功能提供了许多有趣的功能,包括对代码链接时优化和目标代码安装(目标代码生成)时优化的支持,这两者都延迟了“编译时”的代码生成。

链接时优化 (LTO) 解决了编译器传统上一次只能优化一个翻译单元(例如一个独立的.c源文件)因此无法跨多源文件进行优化(如:方法内联)问题,也即像Gcc那样的编译器,只能对单个c源文件进行编译时(Compile Time)优化,如果一个程序由多个源文件组成,那么不能在编译时进行优化,而对于 LLVM 的前端编译器 Clang 可以通过指定 -flto 或者 -O4 命令行选项支持这一点,该选项指示编译器将 LLVM IR 的二进制位码编译输出为 .o 目标文件而不是编译输出为程序目标代码文件,并将程序目标平台的代码生成延迟到链接时间,如下图所示,多个源文件将会输出为不同的.o文件,此时该文件还不是目标平台的程序代码文件(在unix的系统上为a.out可执行文件)。图中,我们可以将.c源文件在编译时部分优化,将未优化部分放到.o文件中,由链接器(LLVM Linker)来进行更进一步的优化。

img

图 11.6:链接时间优化

具体的实现细节取决于你所使用的操作系统,但重要的是链接器一旦检测到在.o文件中存在未编译的LLVM位码,而不是本地对象文件(传统ELF的.o静态链接库文件)。当它看到这样的文件时,它将所有的位码文件读入内存,将它们链接在一起,然后再聚合在一起运行LLVM优化器。 由于优化器现在可以看到更大的代码部分,它可以内联、传播常量、执行更积极的死代码消除(无用代码),以及更多的跨文件边界检查。 虽然许多现代编译器支持LTO,但它们中的大多数(例如GCC、Open64、Intel编译器等)都是通过昂贵且缓慢的序列化过程来实现这一点的。 在LLVM中由于LLVM IR 的部分编译技术存在,LTO非常自然地从系统设计中分离出来,并且可以跨不同的源语言工作(不像许多其他编译器只能针对某个特定语言),因为LLVM IR是独立于源语言的中间表示。

安装时优化的思想是将代码生成延迟到链接时间之后,一直到安装时间,如下图所示。 安装时间是一个非常有趣的时刻,因为这是后端特定平台代码生成器必须要了解目标ISA指令集的具体时间。 例如,在x86中,有各种各样的寄存器描述和特性。 通过延迟指令的选择、调度和代码生成的步骤,目标平台开发者可以为目标平台代码生成器最终运行的特定平台选择最佳的处理方式,也即,我们将部分链接器未处理的部分交给后端生成器生成目标代码时进行优化,这时我们将有效LLVM IR的位图信息放到了最后,我们可以根据剩下的这些信息来更加贴近于目标平台的特性进行优化。

img

图 11.7:安装时间优化

单元测试优化器

编译器非常复杂,编译质量很重要,因此编译测试是至关重要的。 例如,在修复了某个导致优化器崩溃的Bug之后,应该添加回归测试,以确保它不会再次发生。 传统的测试方法是编写一个.c文件通过编译器运行并使用一个测试工具来验证编译器是否会崩溃(GCC 测试套件所使用的测试方法)。这种测试方法的问题在于:编译器由许多不同的子系统组成,甚至在优化器中有许多不同的Pass处理器,所有这些处理器都可能改变输入代码的原来的样子(代码优化),当它到达之前出现Bug的修复代码时, 如果前端或更早的代码优化器发生了变化,那么测试用例将无法测试它应该测试的内容,比如:Pass处理流水线:A->B->C,那么C除了问题,对其进行代码修复后,需要再次回归测试,那么如果此时替换为: A->D->C,那么将不能完整测试C是否已经修复了Bug。

而LLVM 通过模块化设计的优化器并且使用了 LLVM IR 独立中间语言的文本形式表示源语言,因此LLVM测试套件具有高度集中的回归测试,当Bug修复后,我们可以从磁盘重新加载 LLVM IR,再次运行修复后的优化器运行它,那么就能够达到验证Bug是否修复的效果(我们不需要从头开始经过整个Pass流水线了)。除了编译崩溃之外,更复杂的行为测试还希望能够验证代码优化器是否实际执行。以下是一个简单的测试用例,它检查常量优化Pass是否完成工作:

RUN: opt < %s -constprop -S | FileCheck %s

define i32 @test() {

 %A = add i32 4, 5 // 将两个立即数相加

 ret i32 %A // 返回相加值

  CHECK: @test()

  CHECK: ret i32 9

}
1
2
3
4
5
6
7
8
9
10
11
12
13

RUN标识的这一行指定要执行的命令:在本例中为opt和FileCheck命令行工具。opt程序是 LLVM 通道管理器的简单包装器,它链接了所有标准的代码处理Pass(并且可以动态加载包含其他Pass的插件)并将它们通过命令行暴露出来。FileCheck工具验证其标准输入是否与一系列CHECK指令匹配(考虑下编程代码的assert断言机制)。在这种情况下,这个简单的测试验证constprop是否通过将 4 和 5 相加(add)成 9。

虽然这看起来像是一个非常不足以验证的例子,因为从肉眼就能看出它能有什么Bug,但我们需要考虑的是该处理模块在LLVM编译器内部,如果编写的代码较多后,一旦出现Bug将很难通过编写 .c 文件来进行测试:Clang前端在解析源代码时(在AST分析,生成LLVM IR时)时经常会进行常量优化,因此编写将下游转换为常量的代码使其能够发生效果将会很难,但是我们可以将 LLVM IR 作为文本加载到优化器中(注意:LLVM IR是一个独立的语言,我们甚至可以手动编写LLVM IR)并将该 LLVM IR 作为需要测试的特定优化Pass 输入测试即可,然后将验证结果作为另一个文本LLVM IR文件转储出来,所以无论是回归测试还是特定的Pass测试,都可以直接准确地测试我们想要的内容。

使用 BugPoint 自动减少测试用例

当在 LLVM 库的编译器或其他客户端中发现错误时,修复它的第一步是获取可以重现问题的测试用例(也即导致出现Bug的那个输入用例)。一旦有了测试用例,最好将其最小化为重现问题的最小示例,并将其测试用例数量缩小到导致 LLVM 中导致问题发生的用例,例如:Pass处理器优化某段代码时的错误。虽然你最终会很轻易的学习如何执行此操作,但该过程非常乏味且需要手动操作,并且对于编译器虽然生成了错误代码但不会崩溃的情况尤其痛苦。

LLVM BugPoint工具7使用了LLVM的IR序列化和模块化设计来自动化这个过程。 例如,给定一个输入.ll或.bc文件,以及导致优化器崩溃的优化Pass处理器链,BugPoint将输入减少到一个最小的测试用例,并确定是哪个优化器出了问题。 然后输出简化的测试用例和用于重现失败的opt命令。 它通过使用类似于“增量调试”的技术来逐步减少输入测试用例和Pass处理器链来发现这一点。 因为它知道LLVM IR的结构,BugPoint不会浪费时间生成无效的IR来输入到优化器。

在其他编译时发生Bug更复杂情况下,您可以手动指定输入、代码生成器信息、传递给可执行文件的命令行来指定自定义的测试需求。 BugPoint将首先确定问题是由优化器还是代码生成器造成的,然后将测试用例分成两部分:一部分发送到工作正常的组件,另一部分发送到出现Bug的组件, 通过重复地迭代地将越来越多的代码从被发送到错误代码生成器的集合中移出,这样在不断对比过程中,我们去除了那些在Bug组件和正常组件中都正常执行的代码,只留下导致Bug出现的测试用例,从而减少了测试用例。

BugPoint是一个非常简单的工具,在LLVM 工作的整个生命周期中为减少测试用例节省相当多的工作量。目前还没有其他开源编译器具有类似的强大调试工具,因为它依赖于定义明确的LLVM IR 的中间表示语言。当然,即便BugPoint如此优秀,但并不完美,它将随着时间发展而不断改进。 BugPoint的出现可以追溯到2002年,通常只有当有人们遇到一个非常棘手的Bug需要跟踪,而恰好现有的工具不能很好地处理时,新东西才会出现,于是乎LLVM 就出现了BugPoint调试工具,随着时间的推移而发展,它还增加了更多的新特性(例如:JIT调试),当然相信在未来如果 BugPoint 遇到不能解决的问题时它还会不断的添加进更多的特性。

回顾过去和展望未来

LLVM 的模块化设计最初并不是为了直接实现本文描述的任何优势而设计的。很明显,这其实是最开始设计 LLVM 时,我们并不会在第一次尝试时就做好一切,所以预留了大量的扩展接口,并且将一切实现都模块化,为了在后面进行补充,不巧,正是由于这种设计导致了LLVM拥有本文描述的这些优势。例如:模块化Pass流水线的存在是为了更容易分离不同功能的Pass,以便在被更好的实现替换后可以丢弃它们。

LLVM 保持灵活性的另一个主要方面是我们愿意重新考虑之前的决定,并在不担心向后兼容性的情况下对LLVM 的 api进行广泛的修改。 例如,对LLVM IR本身的更改,就需要更新之前设计的所有的Pass优化处理器(因为所有的一切均以来LLVM IR中间语言来进行的),并导致C++ api的大量变动。 当然,我们已经这样做过几次了,尽管这会给基于LLVM的开发人员带来痛苦,但这是保持快速前进的最正确做法。 当然,为了方便外部使用LLVM 组件的开发人员不会骂街,我们为许多使用较多的 api 提供了C 封装库,旨在当我们内部变动时,这些封装代码将不会改变,而LLVM的新版本也将继续支持读取未进行修改LLVM IR结构前的.ll和.bc文件。

展望未来,我们希望继续使LLVM更模块化,更容易子集化(保持更多的灵活性,有更多的子组件可以独立抽离使用)。 例如,代码生成器的设计还过于单一,目前还不可能根据不同平台的特性对LLVM进行子集化, 再比如:如果您想使用JIT,但不需要内联优化、异常处理或调试信息的生成,那么LLVM 应该支持抽离出这些不需要的组件来构建自定义的代码生成器,所以在使用时我们不需要链接支持这些特性,但是目前由于代码生成器没有提供这样的功能。 当然,我们还在不断的提高由优化器和代码生成器生成的代码的质量,增加更多IR特性以更好地支持新的语言和新的目标平台,并在LLVM中增加对执行某些高级语言的特定优化更好支持。

当然,LLVM项目将继续以多种方式发展和改进。 目前看到LLVM在其他项目中有这么多不同的使用方式,以及不断出现在令人惊讶的新的环境中,这是它的设计者从未想过的,这真的很令人兴奋。 新的LLDB调试器的出现是一个很好的例子,LLDB调试器可以使用于从clang的c / c++ / objective-c 解析器解析的表达式,使用于 LLVM JIT将这些转换为的目标代码,使用于 LLVM 反汇编器等。 能够重用这些现有代码使开发调试器的人能够专注于编写调试器逻辑,而不是重新实现另一个(感觉上稍微正确的)c++解析器。

尽管迄今为止LLVM取得了成功,但仍有很多工作要做,而且随着时间的发展和新功能的不断引入,LLVM将变得越来越不灵活,越来越僵化,这是一直存在的风险。 虽然这个问题没有确切的解决方法,但我希望LLVM 不断暴露新的问题、愿意重新评估以前的决策、重新设计并丢弃代码将会有所帮助。毕竟,我们的目标不是变得完美,而是随着时间的推移变得更好。