# LLVM 架构与设计中
LLVM 的三阶段设计实现
基于三明治架构设计的 LLVM 的编译器,前端负责解析、验证和诊断输入代码中的错误,确保没有问题后将解析的代码转换为 LLVM IR(通过构建 AST 抽象语法树,然后将 AST 转换为 LLVM IR)。该 IR 通过一系列分析和优化过程来对其优化,然后优化后的IR传递到代码生成器以生成本地机器代码,如图所示。这是三明治设计的一个非常简单的实现,但是这个简单的描述掩盖了 LLVM 架构中基于 LLVM IR 产生的一些功能和灵活性。本节我们详细描述由于LLVM IR的引入带来的优势。
图 :LLVM 的三明治设计实现
LLVM IR 是一个完整的代码表示
LLVM IR 既明确前端生成的中间表示又是优化器的唯一入口。这个属性意味着为 LLVM 编写前端所需要知道的就是 LLVM IR 是什么、它是如何工作的以及它的一些使用约束,而不用像GCC一样,前端需要了解后端的数据结构和处理。由于 LLVM IR 具有文本形式的表示形式,因此构建一个将 LLVM IR 输出为文本的编译器前端,然后使用 Unix 管道技术将该LLVM IR的输出作为您选择一组的代码优化器和代码生成器的输入,从而生成目标语言是合情合理的。
这可能令人惊讶,但这实际上是 LLVM 的一个非常新颖的特性,也是它在诸多编译器中脱颖而出取得成功的主要原因之一。即使是广泛使用且架构相对良好的 GCC 编译器也不具备此属性,因为GCC的 GIMPLE 中间表示不是自包含(组件重用时不需要包含其他的可重用组件,也即满足单一原则)的表示。举个简单的例子,当 GCC 代码生成器生成 DWARF 调试信息时,它会返回去查询源语言的AST树,尽管GIMPLE 对代码中的操作使用“元组”表示,但(至少从 GCC 4.5 开始)仍然将操作数表示为对AST树的引用。
这意味着编译器前端开发者需要了解和生成 GCC 的AST树数据结构以及 GIMPLE 来编写 GCC 前端。GCC 后端也有类似的问题,因此他们还需要了解 RTL 后端(GCC的编译器后端低等级的语言)的工作原理。最后,GCC 没有办法分离出各个模块之间的独立代码,也没有办法以文本形式读取和写入 GIMPLE(以及形成代码表示的相关数据结构)。结果是使用 GCC 进行开发相对困难,因此它的编译器前端实现相对较少。
LLVM 是库的集合
在设计 LLVM IR 中间语言之后,LLVM 的下一个最重要的实现就是它被设计为一组库的组合,而不是像 GCC 那样的单应用命令行编译器或像 JVM 或 .NET 虚拟机这样的完全不透明的虚拟机(解释器和JIT编译器在JVM中,并没有独立出来)。LLVM 基础架构实现是一组有用的编译器技术,可以用于解决特定问题的工具链组合,这是它最强大的功能之一,但它也是其最不为人知的设计点之一。
让我们以优化器的设计为例:它读入 LLVM IR,稍加优化,然后发出 LLVM IR,它执行速度非常快。在 LLVM(许多其他编译器也一样)中,优化器被组织为不同优化器通道的流水线(责任链模式),每个优化器通道都在可以在执行时选择是否对LLVM IR进行优化。比如:函数内联、表达式重新关联、循环展开等。根据优化级别。运行不同的优化器通道,例如在 -O0参数(表示不需要优化) Clang 编译器不运行任何优化器通道,在 -O3 参数(最高等级优化)时它在其优化器中运行一系列优化器通道(从 LLVM 2.8 开始这样实现)用这些通道组成流水线对输入的LLVM IR进行高等级的分析和优化。至此,读者可否混沌下Netty的ChannelHandler?
每个 LLVM Pass处理通道都被编写为一个 C++ 类,该类派生自Pass基类。大多数 LLVM 通道Pass类都写在一个 .cpp文件中,并且它们的类的子Pass类在匿名命名空间中定义(这使得它们只在定义文件中可见)。为了使 pass 对外使用,cpp文件的外部的代码必须能够通过某种方式获取它,因此从cpp文件中导出了一个函数(用于创建需要的pass对象,参考下工厂模式)。来看简化后的实例代码。
namespace { // 匿名命名空间,保证以下定义隐藏在定义文件
class Hello : public FunctionPass {
public:
// 打印出正在优化的LLVM IR中的函数名
virtual bool runOnFunction(Function &F) {
cerr << "Hello: " << F.getName() << "\n";
return false;
}
};
}
FunctionPass *createHelloPass() { return new Hello(); } // 创建需要的pass
2
3
4
5
6
7
8
9
10
11
如前面描述的那样,LLVM 优化器提供了数十种不同的Pass类,每一种都以相同的风格编写。这些通道被编译成一个或多个.o可重定向目标文件(静态链接库),然后被组合到到一系列归档库(Unix 系统上的. a 文件,一系列静态库的组合)中。这些库提供了各种分析和优化IR的功能,并且让Pass尽可能松耦合,在编写设计Pass时,应该让Pass满足单一原则尽量不与其他Pass互相耦合,或者如果它们依赖于其他Pass通道来完成工作,则在其他Pass中显式声明它们的依赖关系。当指定一系列需要使用的Pass时,LLVM PassManager 根据显式声明的依赖信息,来创建满足这些依赖关系的Pass Pipeline 流水线来优化处理传入的LLVM IR。
尽管库和中间语言解耦的功能很适用,但它们实际上并不能解决所有问题。当有人想要构建一个基于编译器的新工具时,这时开发人员可以利用现有的LLVM的Pass设计来进行高度扩展,这里我们以用于图像处理语言的 JIT 编译器来说明这一点:图像处理语言的 JIT 编译器的实现者需要考虑一个约束条件:图像处理语言可能对编译时的时间延迟高度敏感,并且具有一些特定语言的属性,这些属性对于性能优化起到关键作用。这时,我们可以使用 LLVM 优化器的基于Pass库的设计来进行扩展,这个流水线的设计允许我们的JIT开发人员挑选并选择Pass执行的顺序,而不是像之前那样需要从一整段完整功能的代码中进行改造,那将是一个噩梦,有时候想想还不如重新编写一个编译器,同时如果处理的指针很少,那么对于别名分析和内存优化的Pass就可以不用放入Pass流水线中。这样的机制就可以让开发者自由的组合Pass来完成自己的目标。
然而,尽管LLVM项目的开发者们尽了最大的努力进行开发,由于 PassManager 本身对 Pass 的实现一无所知,它仅仅只是负责组合和调用Pass处理LLVM IR,所以LLVM 并没有一站式的解决所有的优化问题,当然,由于 pass 子系统是模块化开发的这自然也不是问题,需要使用特殊Pass的开发人员,可以自由地实现他们自己的特定于语言的Pass,以弥补 LLVM 优化器中没有包含的特殊语言的Pass库。下图以一个我们假设的 XYZ 图像处理系统作为一个简单示例,来说明这一点。
图 :使用 LLVM 的 XYZ 系统
如上图所示,一旦开发人员选择了一组Pass来对LLVM IR进行优化,图像处理JIT编译器就会被构建到一个可执行文件或动态链接库中,由于对 LLVM 优化通道的唯一途径是通过每个Pass文件中定义的createXPass函数来获取Pass对象,然后将他们进行组合成流水线来优化代码。并且由于Pass库存在于 .a存档库中,因此只有实际选择参与优化的Pass最终被链接到应用程序,而不是整个 LLVM 所有Pass库。在我们上面的示例中,由于XYZ优化器引用了 PassA 和 PassB,所以最终只有它们将被链接到XYZOptimizer中,这里由于 PassB 依赖 PassD 进行一些分析,因此 PassD 也会被链接。但是,由于没有使用 PassC(和许多其他Pass),它代码未被链接到图像处理JIT编译器中。
这就是 LLVM 基于库的设计并使用Pass 流水线的最有用的地方。这种设计方法允许 LLVM 提供大量的可插拔功能,其中一些可能只对特定的受众有用,而不会影响到只想使用部分Pass来做一些简单事情的开发人员。相比之下,传统的编译器优化器构建通常使用紧密互连的大量代码,这更难于维护和拆分,并利用他们来加快自身的编译器的开发,可能有时候还会增加成本。而使用 LLVM,开发人员只需要了解各个优化器,选择合适自己的,而无需了解整个系统的执行原理。
这种基于函数库的设计也是很多人不清楚 LLVM 到底是什么的原因。LLVM 库有很多功能,但它们实际上并没有自身的功能库,而是由库的实现端(例如 Clang 编译器)的设计者来决定如何更好地进行实现,也即LLVM 提供了架构和API,并没有实际的实现,而需要进行第三方的适配。这种详细的分层架构的设计,也是 LLVM 优化器被不同编译器广泛使用的原因。当然,尽管 LLVM 提供了 JIT 编译的能力,但并不意味着每个LLVM的适配端都使用它,所以对于特定的语言编译器的实现,将会选择是否使用 LLVM 的JIT能力。
多目标平台代码的LLVM 代码生成器的设计
LLVM 代码生成器负责将 LLVM IR 转换为目标特定的机器代码。代码生成器的工作是为LLVM的后端实现在指定目标平台的指令集中,选择合适的指令生成尽可能好的机器代码。理想情况下,每个代码生成器只应该负责自己目标平台代码的生成,但是,每个目标平台代码的生成器又会存在着相同的处理逻辑,例如:每个目标平台代码都需要为寄存器分配值,尽管每个目标都有不同的寄存器文件(不同寄存器名),但在设计LLVM后端时理应让这些不同平台的代码生成器尽可能共享功能相同的算法。
与优化器中的LLVM IR中间代码的优化方法类似(使用Pass组成的流水线),LLVM 的代码生成器将代码生成问题拆分为独立的处理流水线:指令选择、寄存器分配、调度、代码排布优化和目标机器码生成——并提供了许多默认公用的内置处理器,在使用时它们组成一个处理器流水线共同对输入的IR语言进行处理。这样,编写目标平台的代码生成其的开发者就可以根据需要在默认Pass处理器中进行选择使用,覆盖默认流水线并可以根据需要实现完全自定义的特定目标平台的Pass。例如:x86 后端的代码生成器可以使用较少寄存器的调度程序,因为总所周知X86平台的寄存器很少,但对于 PowerPC 后端的代码生成器而言,则可以使用延迟优化调度程序,因为它相较于X86平台而言存在大量的寄存器。x86 后端也可以使用自定义Pass处理器来处理 x87 浮点堆栈的使用(FPU),而 ARM 后端则可以使用自定义的Pass处理器将IR的常量池放置在需要它们的函数中进行处理。至此,可以看到:在很多中间件和底层开发中,为了实现灵活性配置,将会大量使用责任链模式来完成自身的设计。这样的灵活性配置架构设计,可以让后端开发者灵活选取它们所需要的Pass进行组合,而不需要像GCC一样,需要从整段代码中进行分析出需要的代码函数进行重构。
这样的Pass流水线设计允许目标平台代码的开发人员选择对其编译器架构有帮助的代码,并允许在不同目标平台的后端生成器中重用大量代码。但这样也带来了另一个挑战:每个共享组件都需要能够以通用方式处理不同目标平台的特定属性。例如,寄存器分配器需要知道每个不同目标平台的寄存器文件以及指令集和指令集中对于寄存器和操作数之间存在的约束。LLVM 对此的解决方案是让每个目标平台生成其以由 tblgen 工具对特定语言的寄存器和功能属性进行声明描述, tblgen 工具用于处理.td文件,不同目标平台的生成器只需要在td文件中声明,然后tblgen 将会对其进行识别,在生成目标平台的归档文件(.a,一系列.o静态链接库的组合)时进行使用。x86 目标的(简化)构建过程如图所示。
图:简化的 x86 目标平台架构
.td 文件允许不同平台的开发这定义它们特定平台的属性描述,如:寄存器文件。这种设计将变化的不过分通过td文件暴露,保留后端代码生成器的代码共用性。例如,x86 后端定义了一个寄存器类(RegisterClass),该类包含其所有名为“GR32”的 32 位寄存器(在.td文件中,特定于目标平台的定义都是大写的),如下所示:
def GR32 : RegisterClass<[i32], 32,
[EAX, ECX, EDX, ESI, EDI, EBX, EBP, ESP,
R8D, R9D, R10D, R11D, R14D, R15D, R12D, R13D]> { … } // 注:这些寄存器均为X86的寄存器名,通过td的文件,将不同平台的无关性提取出来,考虑下:spring的 XML文件独立出来的意义
2
3
4
5
这个td文件的定义描述了这个在X86平台的RegisterClass类中的寄存器可以保存 32 位整数值(“i32”),在这里描述了指定常用的 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))]>;
2
3
4
5
6
7
8
9
这个定义表示 NOT32r 是一个指令(它使用I tblgen类),指定了编码信息(0xF7, MRM2r),指定它定义了一个32位的输出寄存器(outs GR32:
上述的指令定义是对指令的一个非常详细的描述,LLVM的代码可以通过tblgen工具使用它描述的信息做进一步的操作。 这个定义可以让编译器后端处理输入的IR代码,根据IR的描述来进行模式匹配,并根据定义生成目标机器码。 该定义还告诉了寄存器分配器如何处理它,寄存器分配器可以将该指令进行编码位对应ISA指令集的机器代码,也可以通过文本的形式解析和打印该指令。 这个外部定义特定平台指令的功能允许x86平台的代码生成器作为独立的x86汇编器(GNU 汇编器(GAS)的临时替代品)和反汇编器,JIT可以通过其进行X86平台的指令处理和编码。
使用.td文件外部定义不同平台的不同特性并使用tblgen工具除了提供解析.td文件的功能,让多个目标平台生成器使用同一套处理代码来处理代码生成器,这样做的优势在于:汇编器和反汇编器在汇编语法或二进制编码保持一致,也即反汇编和汇编的代码将几乎保持一致(生成的机器码反汇编出来,跟通过汇编器生成机器码之前的汇编代码几乎完全相同)。它还使目标平台代码更易于调试:指令编码可以进行单元测试,而无需涉及整个代码生成器。
虽然LLVM的目标是以声明的方式将尽可能多的不同平台的目标描述信息放入 .td文件中,让代码生成器的处理代码可以公用,但没有绝对的事情,理想很丰满,现实很果敢,也有一些代码不能共享,也即存在一些平台特定代码来适应不同平台的代码生成器。所以在开发代码生成器时,LLVM 要求目标平台的生成器开发者将不能写入td文件的部分,编写各种支持代码生成的 C++ 代码,并实现他们可能需要的任何特定目标目标平台的Pass处理器(例如 X86FloatingPoint.cpp 用于专门处理 x87 浮点堆栈)。随着 LLVM 不断增加新的目标平台,在td文件中表达的目标平台相关信息的数量变得越来越重要,LLVM 的开发这将继续增加.td文件的可以表示数据的范围来处理这个问题(在TD文件中能容纳更多的平台相关性信息,将不共用部分的代码降到最低)。所以,相信随着时间的发展,在 LLVM 中编写不同平台的代码生成器将会变得越来越容易。