在本章中,您将学习到即时编译(JIT)编译器的应用,以及LLVM JIT编译器的工作原理。您将探索LLVM动态编译器和解释器,并学习如何实现自己的JIT编译器工具。此外,您还将了解如何将JIT编译器作为静态编译器的一部分使用,以及相关的挑战。
本章将涵盖以下主题:
- LLVM JIT实现和用例概览
- 使用JIT编译进行直接执行
- 从现有类实现自己的JIT编译器
- 从头开始实现自己的JIT编译器
到本章结束时,您将理解并知道如何开发JIT编译器,无论是使用预配置的类还是适合您需求的定制版本。
技术要求 您可以在
https://github.com/PacktPublishing/Learn-LLVM-17/tree/main/Chapter09 找到本章中使用的代码。
LLVM的JIT实现和用例 到目前为止,我们只看了提前编译(AOT)编译器。这些编译器编译整个应用程序。只有在编译完成后,应用程序才能运行。如果编译在应用程序运行时执行,那么编译器就是JIT编译器。JIT编译器有一些有趣的用例:
- 虚拟机的实现:可以使用AOT编译器将编程语言转换为字节码。在运行时,JIT编译器用于将字节码编译为机器代码。这种方法的优点是字节码是硬件独立的,并且由于JIT编译器,与AOT编译器相比没有性能损失。Java和C#今天使用这种模型,但这不是一个新想法:1977年的USCD Pascal编译器已经使用了类似的方法。
- 表达式评估:电子表格应用程序可以使用JIT编译器编译经常执行的表达式。例如,这可以加速金融模拟。lldb LLVM调试器使用这种方法在调试时评估源表达式。
- 数据库查询:数据库从数据库查询创建执行计划。执行计划描述了对表和列的操作,当执行时,这些操作导致查询答案。JIT编译器可以用来将执行计划转换为机器代码,从而加速查询的执行。
LLVM的静态编译模型与JIT模型并不像人们想象的那么远。LLVM静态编译器llc将LLVM IR编译成机器代码,并将结果保存为磁盘上的对象文件。如果对象文件不是存储在磁盘上而是在内存中,代码可以直接执行吗?不直接,因为对全局函数和全局数据的引用使用重定位而不是绝对地址。从概念上讲,重定位描述了如何计算地址 - 例如,作为已知地址的偏移量。如果我们将重定位解析为地址,就像链接器和动态加载器所做的那样,那么我们就可以执行对象代码。运行静态编译器将IR代码编译成内存中的对象文件,在内存中的对象文件上执行链接步骤,并运行代码,这给了我们一个JIT编译器。LLVM核心库中的JIT实现是基于这个想法的。
在LLVM的发展历史中,有几种JIT实现,具有不同的功能集。最新的JIT API是按需编译(ORC)引擎。如果你好奇这个缩写,它是主要开发人员的意图,继可执行和链接格式(ELF)和调试标准(DWARF)之后,再发明一个基于托尔金宇宙的缩写。
ORC引擎建立在并扩展了使用静态编译器和动态链接器对内存中对象文件的想法。实现采用了分层方法。两个基本层是编译层和链接层。在这之上是一个支持按需编译的层。可以在懒编译层的顶部或底部堆叠一个转换层,允许开发人员添加任意转换或简单地通知某些事件。此外,这种分层方法的优点是JIT引擎可以根据不同的需求进行定制。例如,高性能虚拟机可以选择预先编译所有内容并不使用懒编译层。另一方面,其他虚拟机会强调启动时间和对用户的响应性,并通过懒编译层的帮助实现这一点。
旧的MCJIT引擎仍然可用,其API源自一个更旧的、已经被移除的JIT引擎。随着时间的推移,这个API逐渐变得膨胀,并且缺乏ORC API的灵活性。目标是移除这个实现,因为ORC引擎现在提供了MCJIT引擎的所有功能,新的发展应该使用ORC API。
在下一节中,我们将在深入实现JIT编译器之前,看看lli(LLVM解释器)和动态编译器。
使用JIT编译进行直接执行 直接运行LLVM IR是想到JIT编译器时出现的第一个想法。lli工具(LLVM解释器)和动态编译器就是这样做的。我们将在下一节中探索lli工具。
探索lli工具 让我们用一个非常简单的例子尝试lli工具。下面的LLVM IR可以存储为一个名为hello.ll的文件,它是C hello world应用程序的等效项。该文件声明了C库中printf()函数的原型。hellostr常量包含要打印的消息。在main()函数内部,生成了对printf()函数的调用,这个函数包含一个将被打印的hellostr消息。该应用程序总是返回0。
完整的源代码如下:
declare i32 @printf(ptr, ...)
@hellostr = private unnamed_addr constant [13 x i8] c"Hello world\0A\00"
define dso_local i32 @main(i32 %argc, ptr %argv) {
%res = call i32 (ptr, ...) @printf(ptr @hellostr)
ret i32 0
}
这个LLVM IR文件足够通用,以至于对所有平台都是有效的。我们可以使用lli工具直接执行IR,命令如下:
$ lli hello.ll
Hello world
这里有趣的一点是printf()函数是如何找到的。IR代码被编译成机器代码,并触发了对printf符号的查找。由于IR中没有找到该符号,因此搜索当前进程中的该符号。lli工具动态链接到C库,在那里找到了该符号。
当然,lli工具不会链接到您创建的库。为了启用使用这些函数,lli工具支持加载共享库和对象。以下C源代码只是打印一个友好的消息:
#include
void greetings() {
puts("Hi!");
}
存储在greetings.c中,我们使用它来探索使用lli加载对象。以下命令将编译此源代码为共享库。-fPIC选项指示clang生成位置无关代码,这对于共享库是必需的。此外,编译器使用–shared创建了一个名为greetings.so的共享库:
$ clang greetings.c -fPIC -shared -o greetings.so
我们还编译文件为greetings.o对象文件:
$ clang greetings.c -c -o greetings.o
现在我们有greetings.so共享库和greetings.o对象文件的两个文件,我们将它们加载到lli工具中。
我们还需要一个调用greetings()函数的LLVM IR文件。为此,创建一个包含单个对函数调用的主.ll文件:
declare void @greetings(...)
define dso_local i32 @main(i32 %argc, i8** %argv) {
call void (...) @greetings()
ret i32 0
}
请注意,在执行时,以前的IR会崩溃,因为lli无法定位greetings符号:
$ lli main.ll
JIT session error: Symbols not found: [ _greetings ]
lli: Failed to materialize symbols: { (main, { _main }) }
greetings()函数定义在外部文件中,为了修复崩溃,我们必须告诉lli工具需要加载的附加文件。为了使用共享库,您必须使用–load选项,该选项将共享库的路径作为参数:
$ lli –load ./greetings.so main.ll
Hi!
重要的是要指定共享库的路径,如果包含共享库的目录不在动态加载器的搜索路径中。如果省略,那么库将找不到。
或者,我们可以指示lli使用–extra-object加载对象文件:
$ lli –extra-object greetings.o main.ll
Hi!
其他支持的选项是–extra-archive(加载归档)和–extra-module(加载另一个位码文件)。这两个选项都需要文件的路径作为参数。
现在您知道如何使用lli工具直接执行LLVM IR。在下一节中,我们将实现我们自己的JIT工具。
使用LLJIT实现我们自己的JIT编译器 lli工具只不过是LLVM API的一个薄包装。在第一段中,我们了解到ORC引擎使用分层方法。ExecutionSession类表示一个运行中的JIT程序。除了其他项目,此类持有信息,例如使用的JITDylib实例。JITDylib实例是一个符号表,将符号名称映射到地址。例如,这些可以是LLVM IR文件中定义的符号,或者是加载的共享库的符号。
对于执行LLVM IR,我们不需要自己创建JIT堆栈,因为LLJIT类提供了这个功能。当从旧的MCJIT实现迁移时,您也可以使用这个类,因为这个类基本上提供了相同的功能。
为了说明LLJIT实用程序的功能,我们将创建一个交互式计算器应用程序,同时合并JIT功能。我们的JIT计算器的主要源代码将从第2章的calc示例扩展而来。
我们交互式JIT计算器的主要思想如下:
允许用户输入函数定义,例如def f(x) = x*2。 然后,用户输入的函数由LLJIT实用程序编译成函数 - 在这种情况下,是f。 允许用户使用数值调用他们定义的函数:f(3)。 使用提供的参数评估函数,并将结果打印到控制台:6。
在我们讨论将JIT功能整合到计算器源代码之前,有一点主要的区别需要指出,与原始计算器示例相比:
首先,我们之前只输入并解析以with关键字开头的函数,而不是前面描述的def关键字。对于这一章,我们只接受以def开头的函数定义,并将其表示为AST类中的一个特定节点,称为DefDecl。DefDecl类知道它定义的参数及其名称,函数名称也存储在此类中。 其次,我们还需要AST知道函数调用,以表示LLJIT实用程序消耗或JIT的函数。每当用户输入函数名称,后跟括号中的参数时,AST将这些识别为FuncCallFromDef节点。这个类本质上知道与DefDecl类相同的信息。 由于添加了这两个AST类,很明显,语义分析、解析器和代码生成类将相应地适应我们AST中的更改。另外要注意的是,添加了一个新的数据结构,称为JITtedFunctions,所有这些类都了解这个数据结构。这个数据结构是一个映射,定义的函数名称作为键,函数定义的参数数量作为值存储在映射中。我们将在后面看到如何在我们的JIT计算器中使用这个数据结构。
有关我们对calc示例所做的更改的更多详细信息,包含calc的更改和本节JIT实现的完整源代码可以在lljit源目录中找到。
将LLJIT引擎整合到计算器中 首先,让我们讨论如何在交互式计算器中设置JIT引擎。所有与JIT引擎相关的实现都存在于Calc.cpp中,该文件有一个main()循环来执行程序:
我们必须包含几个头文件,除了包含我们的代码生成、语义分析器和解析器实现的头文件。LLJIT.h头文件定义了LLJIT类和ORC API的核心类。接下来,需要InitLLVM.h头文件来进行工具的基本初始化,TargetSelect.h头文件用于初始化本地目标。最后,我们还包括了
#include "CodeGen.h"
#include "Parser.h"
#include "Sema.h"
#include "llvm/ExecutionEngine/Orc/LLJIT.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/TargetSelect.h"
#include
接下来,我们将llvm和llvm::orc命名空间添加到当前范围:
using namespace llvm;
using namespace llvm::orc;
我们的LLJIT实例的许多调用返回错误类型,Error。ExitOnError类允许我们在丢弃由LLJIT实例的调用返回的错误值时,将它们记录到stderr并退出应用程序。我们如下声明一个全局ExitOnError变量:
ExitOnError ExitOnErr;
然后,我们添加main()函数,该函数初始化工具和本地目标:
int main(int argc, const char **argv) {
InitLLVM X(argc, argv);
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
我们使用LLJITBuilder类创建一个LLJIT实例,如果发生错误,则用之前声明的ExitOnErr变量包装。可能的错误来源是平台尚未支持JIT编译:
auto JIT = ExitOnErr(LLJITBuilder().create());
接下来,我们声明我们的JITtedFunctions映射,该映射跟踪函数定义,如我们之前所述:
StringMap JITtedFunctions;
为了便于创建一个等待用户输入的环境,我们添加一个while()循环,并允许用户输入一个表达式,将用户输入的行保存在名为calcExp的字符串中:
while (true) {
outs() << "JIT calc > ";
std::string calcExp;
std::getline(std::cin, calcExp);
之后,初始化LLVM上下文类和一个新的LLVM模块。模块的数据布局也相应设置,我们还声明了一个代码生成器,它将用于为用户在命令行上定义的函数生成IR:
std::unique_ptr Ctx = std::make_unique();
std::unique_ptr M = std::make_unique("JIT calc.expr", *Ctx);
M->setDataLayout(JIT->getDataLayout());
CodeGen CodeGenerator;
我们必须解释用户输入的行,以确定用户是定义了一个新函数还是调用了他们之前定义的函数。定义一个Lexer类,同时输入用户提供的输入行。我们将看到,有两种主要情况是lexer关心的:
Lexer Lex(calcExp);
Token::TokenKind CalcTok = Lex.peek();
lexer可以检查用户输入的第一个标记。如果用户正在定义一个新函数(由def关键字表示,或Token::KW_def标记),那么我们解析它并检查其语义。如果解析器或语义分析器检测到用户定义的函数有任何问题,将相应地发出错误,计算器程序将停止。如果解析器或语义分析器没有检测到任何错误,这意味着我们有一个有效的AST数据结构,DefDecl:
if (CalcTok == Token::KW_def) {
Parser Parser(Lex);
AST *Tree = Parser.parse();
if (!Tree || Parser.hasError()) {
llvm::errs() << "Syntax errors occured\n";
return 1;
}
Sema Semantic;
if (Semantic.semantic(Tree, JITtedFunctions)) {
llvm::errs() << "Semantic errors occured\n";
return 1;
}
CodeGenerator.compileToIR(Tree, M.get(), JITtedFunctions);
ExitOnErr(
JIT->addIRModule(ThreadSafeModule(std::move(M), std::move(Ctx))));
相反,如果用户使用参数调用函数,这由Token::ident标记表示,我们还需要在将输入转换为有效的AST之前解析并语义检查用户输入是否有效。这里的解析和检查与之前略有不同,因为它可以包括检查,例如确保用户提供给函数调用的参数数量与函数最初定义的参数数量匹配:
} else if (CalcTok == Token::ident) {
outs() << "Attempting to evaluate expression:\n";
Parser Parser(Lex);
AST *Tree = Parser.parse();
if (!Tree || Parser.hasError()) {
llvm::errs() << "Syntax errors occured\n";
return 1;
}
Sema Semantic;
if (Semantic.semantic(Tree, JITtedFunctions)) {
llvm::errs() << "Semantic errors occured\n";
return 1;
}
llvm::StringRef FuncCallName = Tree->getFnName();
CodeGenerator.prepareCalculationCallFunc(Tree, M.get(), FuncCallName, JITtedFunctions);
auto RT = JIT->getMainJITDylib().createResourceTracker();
auto TSM = ThreadSafeModule(std::move(M), std::move(Ctx));
ExitOnErr(JIT->addIRModule(RT, std::move(TSM)));
auto CalcExprCall = ExitOnErr(JIT->lookup("calc_expr_func"));
int (*UserFnCall)() = CalcExprCall.toPtr();
outs() << "User defined function evaluated to: " << UserFnCall() << "\n";
ExitOnErr(RT->remove());
在完成函数调用后,先前与我们的函数关联的内存然后由ResourceTracker释放。
支持JIT编译的代码生成更改 现在,让我们简要看一下我们在CodeGen.cpp中所做的一些更改,以支持我们的基于JIT的计算器:
如前所述,代码生成类有两个重要的方法:一个将用户定义的函数编译成LLVM IR并打印IR到控制台,另一个准备计算评估函数calc_expr_func,其中包含对原始用户定义函数的调用以进行评估。这个第二个函数还将生成的IR打印给用户:
void CodeGen::compileToIR(AST *Tree, Module *M,
StringMap &JITtedFunctions) {
ToIRVisitor ToIR(M, JITtedFunctions);
ToIR.run(Tree);
M->print(outs(), nullptr);
}
void CodeGen::prepareCalculationCallFunc(AST *FuncCall,
Module *M, llvm::StringRef FnName,
StringMap &JITtedFunctions) {
ToIRVisitor ToIR(M, JITtedFunctions);
ToIR.genFuncEvaluationCall(FuncCall);
M->print(outs(), nullptr);
}
如上源代码所述,这些代码生成函数定义了一个ToIRVisitor实例,该实例在初始化时接受我们的模块和一个JITtedFunctions映射:
class ToIRVisitor : public ASTVisitor {
Module *M;
IRBuilder<> Builder;
StringMap &JITtedFunctionsMap;
...
public:
ToIRVisitor(Module *M,
StringMap &JITtedFunctions)
: M(M), Builder(M->getContext()),
JITtedFunctionsMap(JITtedFunctions) {
这些信息用于生成IR或评估先前生成IR的函数。在生成IR时,代码生成器期望看到一个DefDecl节点,该节点表示定义了一个新函数。函数名称以及它定义的参数数量存储在函数定义映射中:
virtual void visit(DefDecl &Node) override {
llvm::StringRef FnName = Node.getFnName();
llvm::SmallVector FunctionVars = Node.getVars();
(JITtedFunctionsMap)[FnName] = FunctionVars.size();
Function *DefFunc = genUserDefinedFunction(FnName);
...
在genUserDefinedFunction()中,第一步是检查模块中是否存在该函数。如果不存在,我们确保函数原型存在于我们的映射数据结构中。然后,我们使用名称和参数数量构建一个函数,该函数具有用户定义的参数数量,并使函数返回单个整数值:
Function *genUserDefinedFunction(llvm::StringRef Name) {
if (Function *F = M->getFunction(Name))
return F;
Function *UserDefinedFunction = nullptr;
auto FnNameToArgCount = JITtedFunctionsMap.find(Name);
if (FnNameToArgCount != JITtedFunctionsMap.end()) {
std::vector IntArgs(FnNameToArgCount->second, Int32Ty);
FunctionType *FuncType = FunctionType::get(Int32Ty, IntArgs, false);
UserDefinedFunction =
Function::Create(FuncType, GlobalValue::ExternalLinkage, Name, M);
}
return UserDefinedFunction;
}
生成用户定义的函数后,创建一个新的基本块,并将我们的函数插入到基本块中。每个函数参数还与用户定义的名称相关联,因此我们还相应地设置所有函数参数的名称,并生成在函数内对参数进行操作的数学运算:
BasicBlock *BB = BasicBlock::Create(M->getContext(), "entry", DefFunc);
Builder.SetInsertPoint(BB);
unsigned FIdx = 0;
for (auto &FArg : DefFunc->args()) {
nameMap[FunctionVars[FIdx]] = &FArg;
FArg.setName(FunctionVars[FIdx++]);
}
Node.getExpr()->accept(*this);
在评估用户定义的函数时,我们示例中预期的AST称为FuncCallFromDef节点。首先,我们定义评估函数并将其命名为calc_expr_func(不接受任何参数并返回一个结果):
virtual void visit(FuncCallFromDef &Node) override {
llvm::StringRef CalcExprFunName = "calc_expr_func";
FunctionType *CalcExprFunTy = FunctionType::get(Int32Ty, {}, false);
Function *CalcExprFun = Function::Create(
CalcExprFunTy, GlobalValue::ExternalLinkage, CalcExprFunName, M);
...
接下来,我们创建一个新的基本块将calc_expr_func插入到其中:
BasicBlock *BB = BasicBlock::Create(M->getContext(), "entry", CalcExprFun);
Builder.SetInsertPoint(BB);
与之前类似,通过genUserDefinedFunction()检索用户定义的函数,并将要评估的原始函数的数值参数传递到我们刚刚重新生成的原始函数中:
llvm::StringRef CalleeFnName = Node.getFnName();
Function *CalleeFn = genUserDefinedFunction(CalleeFnName);
一旦我们有了实际的llvm::Function实例,我们就使用IRBuilder创建对定义函数的调用,并将结果返回,以便在最终将结果打印给用户时可以访问:
auto CalleeFnVars = Node.getArgs();
llvm::SmallVector IntParams;
for (unsigned i = 0, end = CalleeFnVars.size(); i != end; ++i) {
int ArgsToIntType;
CalleeFnVars[i].getAsInteger(10, ArgsToIntType);
Value *IntParam = ConstantInt::get(Int32Ty, ArgsToIntType, true);
IntParams.push_back(IntParam);
}
Builder.CreateRet(Builder.CreateCall(CalleeFn, IntParams, "calc_expr_res"));
构建基于LLJIT的计算器 最后,要编译我们的JIT计算器源代码,我们还需要创建一个CMakeLists.txt文件,其中包含构建描述,该文件保存在Calc.cpp和我们的其他源文件旁边:
我们设置所需的CMake最小版本号为LLVM所需的版本,并给项目命名:
cmake_minimum_required (VERSION 3.20.0)
project ("jit")
需要加载LLVM包,并将LLVM提供的CMake模块目录添加到搜索路径中。然后,我们包含DetermineGCCCompatible和ChooseMSVCCRT模块,这些模块检查编译器是否具有GCC兼容的命令行语法,并确保使用与LLVM相同的C运行时:
find_package(LLVM REQUIRED CONFIG)
list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})
include(DetermineGCCCompatible)
include(ChooseMSVCCRT)
我们还需要添加来自LLVM的定义和包含路径。使用函数调用将使用的LLVM组件映射到库名称:
add_definitions(${LLVM_DEFINITIONS})
include_directories(SYSTEM ${LLVM_INCLUDE_DIRS})
llvm_map_components_to_libnames(llvm_libs Core OrcJIT
Support native)
之后,如果确定编译器具有GCC兼容的命令行语法,我们还检查运行时类型信息和异常处理是否已启用。如果它们没有启用,我们将向我们的编译中添加C++标志以相应地关闭这些功能:
if(LLVM_COMPILER_IS_GCC_COMPATIBLE)
if(NOT LLVM_ENABLE_RTTI)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-rtti")
endif()
if(NOT LLVM_ENABLE_EH)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-exceptions")
endif()
endif()
最后,我们定义可执行文件的名称、要编译的源文件以及要链接的库:
add_executable (calc
Calc.cpp CodeGen.cpp Lexer.cpp Parser.cpp Sema.cpp)
target_link_libraries(calc PRIVATE ${llvm_libs})
上述步骤是创建我们的基于JIT的交互式计算器工具所需的全部。接下来,创建并进入构建目录,然后运行以下命令创建并编译应用程序:
$ cmake –G Ninja
$ ninja
这将编译calc工具。然后,我们可以启动计算器,开始定义函数,并看到我们的计算器如何能够评估我们定义的函数。
以下示例调用显示了首先定义的函数的IR,然后是创建calc_expr_func函数以生成对我们最初定义的函数的调用,以便用传入它的任何参数评估函数:
$ ./calc
JIT calc > def f(x) = x*2
define i32 @f(i32 %x) {
entry:
%0 = mul nsw i32 %x, 2
ret i32 %0
}
JIT calc > f(20)
Attempting to evaluate expression:
define i32 @calc_expr_func() {
entry:
%calc_expr_res = call i32 @f(i32 20)
ret i32 %calc_expr_res
}
declare i32 @f(i32)
User defined function evaluated to: 40
JIT calc > def g(x,y) = x*y+100
define i32 @g(i32 %x, i32 %y) {
entry:
%0 = mul nsw i32 %x, %y
%1 = add nsw i32 %0, 100
ret i32 %1
}
JIT calc > g(8,9)
Attempting to evaluate expression:
define i32 @calc_expr_func() {
entry:
%calc_expr_res = call i32 @g(i32 8, i32 9)
ret i32 %calc_expr_res
}
declare i32 @g(i32, i32)
User defined function evaluated to: 172
就是这样!我们刚刚创建了一个基于JIT的计算器应用程序!
由于我们的JIT计算器旨在成为描述如何将LLJIT纳入我们项目中的简单示例,值得注意的是,存在一些限制:
- 此计算器不接受负数或小数值
- 我们不能多次重新定义同一个函数
对于第二个限制,这是设计使然,因此是预期的,并且由ORC API本身强制执行:
$ ./calc
JIT calc > def f(x) = x*2
define i32 @f(i32 %x) {
entry:
%0 = mul nsw i32 %x, 2
ret i32 %0
}
JIT calc > def f(x,y) = x+y
define i32 @f(i32 %x, i32 %y) {
entry:
%0 = add nsw i32 %x, %y
ret i32 %0
}
Duplicate definition of symbol '_f'
请记住,除了为当前进程或共享库公开符号之外,还有许多其他公开名称的可能性。例如,
StaticLibraryDefinitionGenerator类公开在静态存档中找到的符号,并且可以在
DynamicLibrarySearchGenerator类中使用。
此外,LLJIT类还有一个addObjectFile()方法来公开对象文件的符号。如果现有的实现不适合您的需求,您还可以提供自己的DefinitionGenerator实现。
正如我们所看到的,使用预定义的LLJIT类很方便,但它可能限制了我们的灵活性。在下一节中,我们将探讨如何使用ORC API提供的层实现JIT编译器。
从头开始构建JIT编译器类 使用ORC的分层方法,可以很容易地构建一个针对需求定制的JIT编译器。没有一种JIT编译器适合所有情况,本章的第一节给出了一些示例。让我们来看看如何从头开始设置JIT编译器。
ORC API使用堆叠在一起的层。最低层是对象链接层,由
llvm::orc::RTDyldObjectLinkingLayer类表示。它负责链接内存中的对象并将其转换为可执行代码。为此任务所需的内存由MemoryManager接口的实例管理。有一个默认实现,但如果我们需要,也可以使用自定义版本。
在对象链接层之上是编译层,负责创建内存中的对象文件。llvm::orc::IRCompileLayer类以IR模块为输入并将其编译为对象文件。IRCompileLayer类是IRLayer类的子类,IRLayer是接受LLVM IR的层实现的通用类。
这两个层已经构成了JIT编译器的核心:它们将LLVM IR模块作为输入,将其编译并链接在内存中。为了添加额外的功能,我们可以在这两个层之上合并更多层。
例如,CompileOnDemandLayer类将模块分割,以便仅编译请求的函数。这可以用于实现按需编译。此外,CompileOnDemandLayer类也是IRLayer类的子类。以非常通用的方式,IRTransformLayer类,也是IRLayer类的子类,允许我们对模块应用转换。
另一个重要的类是ExecutionSession类。这个类代表一个运行中的JIT程序。从本质上讲,这意味着该类管理JITDylib符号表,为符号提供查找功能,并跟踪使用的资源管理器。
JIT编译器的通用配方如下:
- 初始化ExecutionSession类的实例。
- 初始化至少包含RTDyldObjectLinkingLayer类和IRCompileLayer类的层。
- 创建第一个JITDylib符号表,通常使用main或类似的名称。
JIT编译器的一般用法也非常简单:
- 将IR模块添加到符号表中。
- 查找一个符号,触发相关函数的编译,可能还会编译整个模块。
- 执行函数。
在下一个子节中,我们按照通用配方实现一个JIT编译器类。
创建JIT编译器类 为了保持JIT编译器类的实现简单,一切都放在JIT.h中,我们在可以创建的名为jit的源目录中。然而,与使用LLJIT相比,类的初始化有点更复杂。由于需要处理可能的错误,我们需要一个工厂方法来预先创建一些对象,然后才能调用构造函数。创建类的步骤如下:
我们首先使用JIT_H预处理器定义保护头文件免受多重包含:
#ifndef JIT_H
#define JIT_H
首先,需要一些包含文件。它们中的大多数提供了与头文件同名的类。Core.h头文件提供了一些基本类,包括ExecutionSession类。此外,ExecutionUtils.h头文件提供了
DynamicLibrarySearchGenerator类来搜索库中的符号。此外,CompileUtils.h头文件提供了ConcurrentIRCompiler类:
#include "llvm/Analysis/AliasAnalysis.h"
#include "llvm/ExecutionEngine/JITSymbol.h"
#include "llvm/ExecutionEngine/Orc/CompileUtils.h"
#include "llvm/ExecutionEngine/Orc/Core.h"
#include "llvm/ExecutionEngine/Orc/ExecutionUtils.h"
#include "llvm/ExecutionEngine/Orc/IRCompileLayer.h"
#include "llvm/ExecutionEngine/Orc/IRTransformLayer.h"
#include "llvm/ExecutionEngine/Orc/JITTargetMachineBuilder.h"
#include "llvm/ExecutionEngine/Orc/Mangling.h"
#include "llvm/ExecutionEngine/Orc/RTDyldObjectLinkingLayer.h"
#include "llvm/ExecutionEngine/Orc/TargetProcessControl.h"
#include "llvm/ExecutionEngine/SectionMemoryManager.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Support/Error.h"
声明一个新类。我们的新类将被称为JIT:
class JIT {
// ...
};
私有数据成员反映了ORC层和一些辅助类。ExecutionSession、ObjectLinkingLayer、CompileLayer、OptIRLayer和MainJITDylib实例分别代表正在运行的JIT程序、层和符号表,如上所述。此外,TargetProcessControl实例用于与JIT目标进程交互。这可以是相同的进程、同一台机器上的另一个进程,或者是不同机器上的远程进程,可能具有不同的架构。DataLayout和MangleAndInterner类需要以正确的方式对符号名称进行mangling。此外,符号名称被内部化,这意味着所有相等的名称具有相同的地址。这意味着要检查两个符号名称是否相等,只需比较地址就足够了,这是一个非常快速的操作:
std::unique_ptr TPC;
std::unique_ptr ES;
llvm::DataLayout DL;
llvm::orc::MangleAndInterner Mangle;
std::unique_ptr ObjectLinkingLayer;
std::unique_ptr CompileLayer;
std::unique_ptr OptIRLayer;
llvm::orc::JITDylib &MainJITDylib;
初始化分为三个部分。在C++中,构造函数不能返回错误。简单且推荐的解决方案是创建一个静态工厂方法,在构造对象之前进行错误处理。层的初始化更为复杂,因此我们也为它们引入了工厂方法。
在create()工厂方法中,我们首先创建一个SymbolStringPool实例,该实例用于实现字符串内部化,并由几个类共享。为了控制当前进程,我们创建了一个SelfTargetProcessControl实例。如果我们想针对一个不同的进程,那么我们需要更改这个实例。
接下来,我们构造一个JITTargetMachineBuilder实例,为了知道JIT进程的目标三元组。然后,我们向目标机器构建器查询数据布局。这一步可能会失败,如果构建器无法根据提供的目标三元组实例化目标机器,例如,因为LLVM库中没有编译对此目标的支持:
public:
static llvm::Expected> create() {
auto SSP =
std::make_shared();
auto TPC =
llvm::orc::SelfTargetProcessControl::Create(SSP);
if (!TPC)
return TPC.takeError();
llvm::orc::JITTargetMachineBuilder JTMB(
(*TPC)->getTargetTriple());
auto DL = JTMB.getDefaultDataLayoutForTarget();
if (!DL)
return DL.takeError();
// ...
};
此时,我们已经处理了所有可能失败的调用。现在我们可以初始化ExecutionSession实例。最后,调用JIT类的构造函数,将所有实例化的对象传递给它,并将结果返回给调用者:
return std::make_unique(
std::move(*TPC), std::move(ES), std::move(*DL),
std::move(JTMB));
JIT类的构造函数将传递的参数移动到私有数据成员。层对象是通过调用带有create前缀的静态工厂方法构建的。此外,每个层工厂方法都需要对ExecutionSession实例的引用,这将层连接到正在运行的JIT会话。除了对象链接层(它在层栈的底部)之外,每层都需要对前一层的引用,说明了堆叠顺序:
JIT(std::unique_ptr
EPCtrl,
std::unique_ptr
ExeS,
llvm::DataLayout DataL,
llvm::orc::JITTargetMachineBuilder JTMB)
: EPC(std::move(EPCtrl)), ES(std::move(ExeS)),
DL(std::move(DataL)), Mangle(*ES, DL),
ObjectLinkingLayer(std::move(
createObjectLinkingLayer(*ES, JTMB))),
CompileLayer(std::move(createCompileLayer(
*ES, *ObjectLinkingLayer,
std::move(JTMB)))),
OptIRLayer(std::move(
createOptIRLayer(*ES, *CompileLayer))),
MainJITDylib(
ES->createBareJITDylib("")) {
// ...
}
在构造函数主体中,我们向MainJITDylib添加一个生成器,以搜索当前进程中的符号。GetForCurrentProcess()方法很特殊,因为返回值被包裹在Expected<>模板中,表明也可以返回Error对象。然而,由于我们知道当前进程最终会运行,所以我们使用cantFail()函数解开结果,如果发生错误,该函数将终止应用程序:
MainJITDylib.addGenerator(llvm::cantFail(
llvm::orc::DynamicLibrarySearchGenerator::
GetForCurrentProcess(DL.getGlobalPrefix())));
要创建对象链接层,我们需要提供一个内存管理器。这里,我们坚持使用默认的SectionMemoryManager类,但如果需要,我们也可以提供不同的实现:
为了创建一个对象链接层,我们需要提供一个内存管理器。在这里,我们坚持使用默认的SectionMemoryManager类,但如果需要,我们也可以提供一个不同的实现:
static std::unique_ptr<
llvm::orc::RTDyldObjectLinkingLayer>
createObjectLinkingLayer(
llvm::orc::ExecutionSession &ES,
llvm::orc::JITTargetMachineBuilder &JTMB) {
auto GetMemoryManager = []() {
return std::make_unique<
llvm::SectionMemoryManager>();
};
auto OLLayer = std::make_unique<
llvm::orc::RTDyldObjectLinkingLayer>(
ES, GetMemoryManager);
// 对于在Windows上使用的通用对象文件格式(COFF)对象文件格式,存在一些复杂性。
// 这种文件格式不允许将函数标记为导出的。
// 这随后导致在对象链接层内的检查中出现故障:存储在符号中的标记与IR中的标记进行比较,
// 由于缺少导出标记而导致不匹配。
// 解决方案是仅为此文件格式覆盖标记。
// 这完成了对象层的构建,并将对象返回给调用者:
if (JTMB.getTargetTriple().isOSBinFormatCOFF()) {
OLLayer
->setOverrideObjectFlagsWithResponsibilityFlags(
true);
OLLayer
->setAutoClaimResponsibilityForObjectSymbols(
true);
}
return OLLayer;
}
要初始化编译层,需要一个IRCompiler实例。IRCompiler实例负责将IR模块编译成对象文件。如果我们的JIT编译器不使用线程,那么我们可以使用SimpleCompiler类,该类使用给定的目标机器编译IR模块。TargetMachine类不是线程安全的,因此SimpleCompiler类也不是。为了支持多线程编译,我们使用ConcurrentIRCompiler类,它为要编译的每个模块创建一个新的TargetMachine实例。这种方法解决了多线程的问题:
static std::unique_ptr
createCompileLayer(
llvm::orc::ExecutionSession &ES,
llvm::orc::RTDyldObjectLinkingLayer &OLLayer,
llvm::orc::JITTargetMachineBuilder JTMB) {
auto IRCompiler = std::make_unique<
llvm::orc::ConcurrentIRCompiler>(
std::move(JTMB));
auto IRCLayer =
std::make_unique(
ES, OLLayer, std::move(IRCompiler));
return IRCLayer;
}
我们不直接将IR模块编译成机器代码,而是安装一个首先优化IR的层。这是一个深思熟虑的设计决策:我们将我们的JIT编译器变成了一个优化的JIT编译器,它产生的代码运行速度更快,但生成速度更长,意味着用户的延迟。我们没有添加延迟编译,所以当查找一个符号时,整个模块都被编译了。这可能会增加用户看到代码执行之前的显著时间。
注意
引入延迟编译并不是所有情况下的合适解决方案。延迟编译是通过将每个函数移动到自己的模块中来实现的,当查找函数名时进行编译。这阻止了像内联这样的跨过程优化,因为内联器通过需要访问被调用函数的主体来进行内联。结果,用户在使用延迟编译时看到更快的启动,但产生的代码并不像它可以的那样最优。这些设计决策取决于预期的用途。在这里,我们决定使用快速代码,接受较慢的启动时间。此外,这意味着优化层本质上是一个转换层。
IRTransformLayer类将转换委托给一个函数——在我们的情况下,是optimizeModule函数:
static std::unique_ptr
createOptIRLayer(
llvm::orc::ExecutionSession &ES,
llvm::orc::IRCompileLayer &CompileLayer) {
auto OptIRLayer =
std::make_unique(
ES, CompileLayer,
optimizeModule);
return OptIRLayer;
}
optimizeModule()函数是对IR模块进行转换的一个示例。该函数将模块作为参数进行转换,并返回转换后的IR模块版本。由于JIT编译器可能以多线程运行,因此IR模块被包装在ThreadSafeModule实例中:
static llvm::Expected
optimizeModule(
llvm::orc::ThreadSafeModule TSM,
const llvm::orc::MaterializationResponsibility
&R) {
// 为了优化IR,我们从第七章《优化IR》中回忆一些信息,在“为你的编译器添加优化管道”部分。
// 我们需要一个`PassBuilder`实例来创建一个优化管道。首先,我们定义几个分析管理器并将它们注册到传递构建器中。
// 之后,我们使用O2级别的默认优化管道填充一个`ModulePassManager`实例。
// 这再次是一个设计决策:O2级别已经产生快速的机器代码,但它在O3级别产生更快的代码。
// 接下来,我们在模块上运行管道,最后,优化后的模块返回给调用者:
TSM.withModuleDo([](llvm::Module &M) {
bool DebugPM = false;
llvm::PassBuilder PB(DebugPM);
llvm::LoopAnalysisManager LAM(DebugPM);
llvm::FunctionAnalysisManager FAM(DebugPM);
llvm::CGSCCAnalysisManager CGAM(DebugPM);
llvm::ModuleAnalysisManager MAM(DebugPM);
FAM.registerPass(
[&]() { return PB.buildDefaultAAPipeline(); });
PB.registerModuleAnalyses(MAM);
PB.registerCGSCCAnalyses(CGAM);
PB.registerFunctionAnalyses(FAM);
PB.registerLoopAnalyses(LAM);
PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
llvm::ModulePassManager MPM =
PB.buildPerModuleDefaultPipeline(
llvm::PassBuilder::OptimizationLevel::O2,
DebugPM);
MPM.run(M, MAM);
});
return TSM;
}
JIT类的客户端需要一种添加IR模块的方法,我们通过addIRModule()函数提供:
llvm::Error addIRModule(
llvm::orc::ThreadSafeModule TSM,
llvm::orc::ResourceTrackerSP RT = nullptr) {
if (!RT)
RT = MainJITDylib.getDefaultResourceTracker();
return OptIRLayer->add(RT, std::move(TSM));
}
同样,我们的JIT类的客户端需要一种查找符号的方法。我们将其委托给ExecutionSession实例,传入主符号表的引用和请求符号的名称的名称:
llvm::Expected
lookup(llvm::StringRef Name) {
return ES->lookup({&MainJITDylib},
Mangle(Name.str()));
}
正如我们所看到的,初始化这个JIT类可能是棘手的,因为它涉及到JIT类的工厂方法和构造函数调用,以及每个层的工厂方法。尽管这种分配是由C++的限制引起的,但代码本身是直接的。
接下来,我们将使用新的JIT编译器类来实现一个简单的命令行实用程序,它接受LLVM IR文件作为输入。
使用我们的新JIT编译器类 我们首先创建一个名为JIT.cpp的文件,在JIT.h文件相同的目录下,并添加以下内容到这个源文件中:
首先,包括几个头文件。我们必须包括JIT.h以使用我们的新类,以及IRReader.h头文件,因为它定义了一个读取LLVM IR文件的函数。CommandLine.h头文件允许我们以LLVM风格解析命令行选项。接下来,InitLLVM.h是需要的,用于工具的基本初始化。最后,TargetSelect.h是需要的,用于初始化本地目标:
#include "JIT.h"
#include "llvm/IRReader/IRReader.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/InitLLVM.h"
#include "llvm/Support/TargetSelect.h"
接下来,我们将llvm命名空间添加到当前范围:
using namespace llvm;
我们的JIT工具期望在命令行上恰好有一个输入文件,我们通过cl::opt<>类声明它:
static cl::opt
InputFile(cl::Positional, cl::Required,
cl::desc(""));
为了读取IR文件,我们调用parseIRFile()函数。该文件可以是文本IR表示或字节码文件。该函数返回创建的模块的指针。此外,错误处理有些不同,因为可以解析文本IR文件,这不一定是语法正确的。最后,SMDiagnostic实例在语法错误的情况下保存错误信息。如果出现错误,将打印错误消息并退出应用程序:
std::unique_ptr
loadModule(StringRef Filename, LLVMContext &Ctx,
const char *ProgName) {
SMDiagnostic Err;
std::unique_ptr Mod =
parseIRFile(Filename, Err, Ctx);
if (!Mod.get()) {
Err.print(ProgName, errs());
exit(-1);
}
return Mod;
}
jitmain()函数放在loadModule()方法之后。这个函数设置我们的JIT引擎并编译一个LLVM IR模块。该函数需要带有IR的LLVM模块。还需要这个模块的LLVM上下文类,因为上下文类包含重要的类型信息。目标是调用main()函数,所以我们也传递了通常的argc和argv参数:
Error jitmain(std::unique_ptr M,
std::unique_ptr Ctx,
int argc, char *argv[]) {
// ...
}
接下来,我们创建了我们之前构建的JIT类的实例。如果发生错误,那么我们相应地返回错误消息:
auto JIT = JIT::create();
if (!JIT)
return JIT.takeError();
然后,我们将模块添加到主JITDylib实例中,再次将模块和上下文包装在ThreadSafeModule实例中。如果发生错误,那么我们返回错误消息:
if (auto Err = (*JIT)->addIRModule(
orc::ThreadSafeModule(std::move(M),
std::move(Ctx))))
return Err;
接下来,我们查找main符号。这个符号必须在命令行上给出的IR模块中。查找触发了该IR模块的编译。如果IR模块内引用了其他符号,那么它们使用前一步中添加的生成器进行解析。结果是ExecutorAddr类的地址,它表示执行程序的地址:
llvm::orc::ExecutorAddr MainExecutorAddr = MainSym->getAddress();
auto *Main = MainExecutorAddr.toPtr();
现在,我们可以在IR模块中调用main()函数,并传递函数期望的argc和argv参数。我们忽略返回值:
(void)Main(argc, argv);
我们报告函数执行后的成功:
return Error::success();
在实现jitmain()函数之后,我们添加了一个main()函数,该函数初始化工具和本地目标并解析命令行:
int main(int argc, char *argv[]) {
InitLLVM X(argc, argv);
InitializeNativeTarget();
InitializeNativeTargetAsmPrinter();
InitializeNativeTargetAsmParser();
cl::ParseCommandLineOptions(argc, argv, "JIT\n");
// ...
}
之后,初始化LLVM上下文类,并加载命令行上指定的IR模块:
auto Ctx = std::make_unique();
std::unique_ptr M =
loadModule(InputFile, *Ctx, argv[0]);
加载IR模块后,我们可以调用jitmain()函数。为了处理错误,我们使用ExitOnError实用类在遇到错误时打印错误消息并退出应用程序。我们还设置了应用程序名称的横幅,它会在错误消息之前打印:
ExitOnError ExitOnErr(std::string(argv[0]) + ": ");
ExitOnErr(jitmain(std::move(M), std::move(Ctx),
argc, argv));
如果控制流到达这一点,那么IR已成功执行。我们返回0以表示成功:
return 0;
现在,我们可以通过编译一个简单的示例来测试我们新实现的JIT编译器,该示例将Hello World!打印到控制台。在幕后,新类使用固定的优化级别,因此对于足够大的模块,我们可以注意到启动和运行时间的差异。
要构建我们的JIT编译器,我们可以按照在“实现我们自己的JIT编译器与LLJIT部分”的末尾所做的相同CMake步骤,并且我们只需要确保JIT.cpp源文件正在与正确的库一起编译:
add_executable(JIT JIT.cpp)
include_directories(${CMAKE_SOURCE_DIR})
target_link_libraries(JIT ${llvm_libs})
然后,我们进入构建目录并编译应用程序:
$ cmake –G Ninja
$ ninja
我们的JIT工具现在可以使用了。一个简单的Hello World!程序可以用C语言编写,如下所示:
$ cat main.c
#include
int main(int argc, char** argv) {
printf("Hello world!\n");
return 0;
}
接下来,我们可以使用以下命令将Hello World C源代码编译成LLVM IR:
$ clang -S -emit-llvm main.c
记住——我们将C源代码编译成LLVM IR,因为我们的JIT编译器接受IR文件作为输入。最后,我们可以使用我们的IR示例调用我们的JIT编译器,如下所示:
$ JIT main.ll
Hello world!
总结 在本章中,您学习了如何开发一个JIT编译器。您从了解JIT编译器的可能应用开始,并探索了lli,LLVM动态编译器和解释器。使用预定义的LLJIT类,您构建了一个交互式基于JIT的计算器工具,并了解了查找符号和向LLJIT添加IR模块。为了能够利用ORC API的分层结构,您还实现了一个优化的JIT类。
在下一章中,您将学习如何利用LLVM工具进行调试。