4.1 Chapter 4 Introduction
欢迎来到“用LLVM实现语言”教程的第4章。第1-3章描述了一种简单语言的实现,并添加了对生成LLVM IR的支持。本章描述了两种新技术:向语言添加优化器支持和添加JIT编译器支持。这些新增内容将演示如何为万花筒语言获得良好、高效的代码。
4.2 Trivial Constant Folding
我们对第3章的演示是优雅的,易于扩展。不幸的是,它不能生成出色的代码。然而,在编译简单代码时,IRBuilder确实为我们提供了明显的优化:
这段代码不是通过解析输入构建的AST的文字转录。这将是:
正如上面所看到的,常量折叠是一种非常常见且非常重要的优化:以至于许多语言实现者在其AST表示中实现了常量折叠支持。
由于所有构建LLVM IR的调用都要经过LLVM IR构建器,所以构建器本身会检查您调用它时是否有一个固定的折叠机会。如果是这样,它只执行常量折叠并返回常量,而不是创建一条指令。
在实践中,我们建议在生成这样的代码时始终使用IRBuilder。它的使用没有“语法开销”(您不需要在任何地方使用常量检查来丑化编译器),而且它可以在某些情况下显著减少生成LLVM IR的数量(特别是对于使用宏预处理程序或使用大量常量的语言)。
另一方面,IRBuilder在构建时将所有分析都内联到代码中,这一事实限制了它。如果你举一个稍微复杂一点的例子:
在这种情况下,乘法的LHS和RHS是相同的值。我们很想看到它生成" tmp = x+3;结果= tmp*tmp; "而不是计算" x+3 "两次。
不幸的是,没有任何局部分析能够检测并纠正这一点。这需要两个转换:表达式的重新组合(使add的词法相同)和公共子表达式消除(CSE)来删除冗余的add指令。幸运的是,LLVM以“pass”的形式提供了广泛的优化,您可以使用这些优化。
4.3 LLVM Optimization Passes
LLVM提供了许多优化遍历,这些遍历执行许多不同的任务,并具有不同的权衡。与其他系统不同,LLVM并不坚持一组优化适用于所有语言和所有情况的错误概念。LLVM允许编译器实现者对使用什么优化、以什么顺序、在什么情况下进行优化做出完整的决定。
作为一个具体的例子,LLVM支持“整个模块”的传递,它们可以查看尽可能大的代码体(通常是整个文件,但如果在链接时运行,这可能是整个程序的一个重要部分)。它还支持并包含“每个函数”传递,每次只操作一个函数,而不查看其他函数。有关通行证及其运行方式的更多信息,请参见如何编写通行证文档和LLVM通行证列表。
对于万花筒,我们目前正在动态地生成函数,每次一个,用户输入它们。在这种情况下,我们并不追求最终的优化体验,但我们也希望尽可能地捕捉简单和快速的东西。因此,我们将选择在用户键入函数时对每个函数运行一些优化。如果我们想做一个“静态万花筒编译器”,我们将使用我们现在拥有的代码,除了我们将延迟运行优化器直到整个文件被解析。
为了进行每个函数的优化,我们需要设置FunctionPassManager来保存和组织我们想要运行的LLVM优化。一旦我们有了这些,我们就可以添加一组优化来运行。我们将为每个要优化的模块新增一个新的FunctionPassManager,所以我们将写一个函数来创建并初始化模块和pass manager:
这段代码初始化全局模块模块和函数pass manager TheFPM,后者附加到模块上。一旦设置了pass管理器,我们将使用一系列“add”调用来添加一系列LLVM pass。
在本例中,我们选择添加四个优化遍历。我们在这里选择的pass是一组相当标准的“清理”优化,对各种代码都很有用。我不会深入研究它们的工作,但是,相信我,它们是一个很好的开始:)。
设置好PassManager之后,我们需要使用它。我们在构造完新创建的函数之后运行它(在FunctionAST::codegen()中),但是在它返回给客户机之前:
如您所见,这非常简单。FunctionPassManager对LLVM函数*进行了优化和更新,希望改进了它的主体。有了这些,我们可以再次尝试上面的测试:
正如所期望的那样,我们现在得到了优化得很好的代码,从这个函数的每次执行中保存了一个浮点add指令。
LLVM提供了各种各样的优化,可以在特定的环境中使用。有一些关于各种通行证的文档,但不是很完整。另一个很好的想法来源可以从查看Clang开始运行的传球开始。“opt”工具允许您尝试从命令行传递,这样您就可以查看它们是否执行了任何操作。
现在我们有了来自前端的合理代码,让我们来讨论执行它!
4.4 Adding a JIT Compiler
LLVM IR中可用的代码可以应用多种工具。例如,您可以对其运行优化(正如我们在上面所做的),您可以以文本或二进制形式将其转储出来,您可以将代码编译为某个目标的汇编文件(.s),或者您可以JIT编译它。LLVM IR表示的好处是它是编译器许多不同部分之间的“通用货币”。
在本节中,我们将向解释器添加JIT编译器支持。我们希望万花筒的基本思想是让用户像现在一样输入函数体,但是立即计算他们输入的顶级表达式。例如,如果他们输入“1 + 2;”,我们应该计算并输出3。如果他们定义了一个函数,他们应该能够从命令行调用它。
为此,我们首先准备环境来为当前本机目标创建代码,并声明和初始化JIT。这是通过调用一些InitializeNativeTarget\*函数,添加一个全局变量TheJIT,并在main中初始化它来实现的:
我们还需要设置JIT的数据布局:
KaleidoscopeJIT类是专门为这些教程构建的简单JIT,可以在LLVM源代码中的LLVM -src/examples/Kaleidoscope/include/KaleidoscopeJIT.h中找到。在后面的章节中,我们将研究它是如何工作的,并使用新的特性对其进行扩展,但是现在我们将把它当作给定的。它的API非常简单:addModule向JIT添加了一个LLVM IR模块,使其函数可以执行;removeModule删除一个模块,释放与该模块中的代码相关的任何内存;findSymbol允许我们查找指向编译代码的指针。
我们可以使用这个简单的API,将解析顶级表达式的代码改为如下:
如果解析和codegen成功,下一步是将包含顶层表达式的模块添加到JIT。为此,我们调用addModule,它将触发模块中所有函数的代码生成,并返回一个句柄,该句柄可用于稍后从JIT中删除模块。一旦模块被添加到JIT中,就不能再修改它了,因此我们还打开一个新模块,通过调用InitializeModuleAndPassManager()来保存后续代码。
一旦我们将模块添加到JIT中,就需要获得指向最终生成代码的指针。为此,我们调用JIT的findSymbol方法,并传递顶级表达式函数的名称:_anon_expr。因为我们刚刚添加了这个函数,所以我们断言findSymbol返回了一个结果。
接下来,我们通过调用符号上的getAddress()来获得_anon_expr函数的内存地址。回想一下,我们将顶级表达式编译成一个自包含的LLVM函数,该函数不接受任何参数,并返回计算得到的double。因为LLVM JIT编译器匹配本机平台ABI,这意味着您可以将结果指针转换为该类型的函数指针并直接调用它。这意味着,JIT编译的代码与静态链接到应用程序中的本机代码之间没有区别。
最后,由于不支持重新计算顶级表达式,所以在释放相关内存时将模块从JIT中删除。但是,请记住,我们前面创建的模块(通过InitializeModuleAndPassManager)仍然处于打开状态,正在等待添加新代码。
通过这两个更改,现在让我们看看万花筒是如何工作的!
这看起来基本上是可行的。该函数的转储显示了“始终返回double的无参数函数”,我们为键入的每个顶级表达式合成了该函数。这演示了非常基本的功能,但是我们还能做得更多吗?