[LLVM翻译]创建BOLT COMPILER:第9部分 实现并发和我们的运行时库

682 阅读17分钟

原文地址:mukulrathi.co.uk/create-your…

原文作者:mukulrathi.co.uk/

发布时间:2020年12月28日-9分钟阅读

系列。创建BOLT编译器

即将推出


运行时库的作用

到现在为止,我们的语言Bolt中的构造直接翻译成LLVM IR指令。我对并发的最大误解是,它的工作方式是一样的。认为编译器可以发出一些 spawn 指令来创建一个新的线程。事实并非如此。LLVM没有一条指令可以为你创建线程。你问为什么没有?线程有什么特别之处?

创建线程是一个复杂的例行指令,是平台特有的。线程由操作系统内核管理,所以创建线程需要按照该平台的惯例创建系统调用。

LLVM在这里画了一条线。LLVM能提供多少功能而不至于让自己变得庞大,这是一个极限。想想看,从嵌入式系统到移动设备,再到不同的桌面操作系统,它要支持的平台有多少种。

进入你的运行时库。你的语言的运行时库为这些与平台/运行时环境交互的例程提供了实现。编译器在编译我们的Bolt程序时,会插入对这些运行时库函数的调用。一旦我们有了编译后的LLVM IR,我们就会链接到运行时库函数的实现,这样可执行程序就可以调用这些函数。

你知道编译到LLVM IR最好的部分是什么吗?我们不必编写自己的运行时库。C编译到LLVM IR。我们只需要用C函数来引导我们的运行时库,然后用clang把它们链接进去就可以了!

那么,我们的运行时库中存在哪些类型的函数呢?

  • I/O,如printf
  • 内存管理。(记得在上一篇文章中,我提到LLVM没有提供堆。) 或者手动实现(malloc和free),或者通过垃圾收集算法(比如标记和扫除)。要么手动实现(malloc和free),要么通过垃圾收集算法(如mark-and-sweep)。
  • 当然也可以通过C语言的pthread API来实现线程。pthread是POSIX Thread的简称,是这些线程系统调用的标准化API。

我们并不局限于C语言提供的函数,我们可以编写自己的C语言函数,并使用本篇文章中描述的技术将它们连接起来。

我们将看看printf

回顾。LLVM模块

下面是一个LLVM模块的结构草图(来自上一篇文章)。如图所示,我们对模块感兴趣的部分是函数声明。要使用一个C库函数,我们需要在模块的函数声明符号表中插入它的函数签名。

打印

我们用printf来热身。C函数的类型签名是

 int printf ( const char* format, ... );

要将这个C类型签名翻译成LLVM FunctionType。

  • 去掉const限定符
  • 将C类型转换为等价的LLVM类型:int和char分别映射为i32和i8。
  • 的...表示printf是可变的。因此,LLVM的API代码如下。
module->getOrInsertFunction(
  "printf",
  FunctionType::get(
    IntegerType::getInt32Ty(*context),
    Type::getInt8Ty(*context)->getPointerTo(),
    true /* this is variadic func */
  )
);

以及调用printf函数的相应代码。

Value *IRCodegenVisitor::codegen(const ExprPrintfIR &expr) {
  Function *printf = module->getFunction("printf");
  std::vector<Value *> printfArgs;
  Value *formatStrVal = builder->CreateGlobalStringPtr(expr.formatStr);
  printfArgs.push_back(formatStrVal);
  // add variadic arguments
  for (auto &arg : expr.arguments) {
    printfArgs.push_back(arg->codegen(*this););
  }
  return builder->CreateCall(printf, printfArgs);
};

CreateGlobalStringPtr是一个有用的IRBuilder方法,它接收一个字符串并返回一个i8*指针(所以我们有一个正确类型的参数)。

Bolt内存模型

每个线程都有自己的堆。为了在线程之间共享对象,我们将引入一个全局的对象堆。我们将使用堆栈来存储基元,如ints、bool,并在堆上存储指向对象的指针。

Malloc

我们可以使用malloc将对象分配到堆中。(这就是C++的新关键字在引擎盖下的作用!)

malloc的类型签名如下。

void *malloc(size_t size);

换算成等效的LLVM IR类型,void *和size_t映射成i8 *和i64。确定了LLVM类型后,LLVM API代码就掉了出来。

Type *voidPtrTy = Type::getInt8Ty(*context)->getPointerTo();
module->getOrInsertFunction(
  "malloc",
  FunctionType::get(
    voidPtrTy,
    IntegerType::getInt64Ty(*context),
    /* has variadic args */ false
  )
);

不过只有一个问题。当我们在堆上创建一个结构时,malloc要求我们指定要分配的字节数。然而该结构的大小是机器特定的信息(它取决于数据类型的大小,结构填充等)。在与机器无关的LLVM IR中,我们如何做到这一点呢?

我们可以通过下面的hack来计算。我们知道,一个Foo类型的结构数组只是一个连续的内存块。指向相邻索引的指针相隔size(Foo)字节。因此,如果我们以地址0x0000(特殊的NULL地址)开始一个数组,那么数组的第一个索引就在地址size(Foo)。

我们可以使用getelementptr(GEP)指令来计算这个数组地址,并将数组的基指针作为空值传入。你可能会想,等等,这个数组不存在。这不会引起seg故障吗?

记住,GEP指令的作用只是计算指针偏移量。而不是检查结果指针是否有效。而不是实际访问内存。只是执行这个计算。没有访问内存=没有seg故障。

// calculate index of array[1] using GEP instruction
Value *objDummyPtr = builder->CreateConstGEP1_64(
    Constant::getNullValue(objType->getPointerTo()), 1, "objsize");
// cast to i64 for malloc
Value *objSize =
    builder->CreatePointerCast(objDummyPtr,Type::getInt64Ty(*context));

我们将objSize传给malloc,malloc返回一个void 指针,然而由于我们以后要访问结构的字段,我们需要将类型转为objType。记住,LLVM需要显式类型!

// allocate the object on the heap
Value *objVoidPtr =
    builder->CreateCall(module->getFunction("malloc"), objSize);

// cast (void *)  to  (objType *)
Value *obj =
    builder->CreatePointerCast(objVoidPtr, objType->getPointerTo());

奖励:垃圾收集

把malloc换成GC malloc。如果我们使用GC_malloc,我们就可以得到免费的垃圾回收! 这多酷啊! 我们不需要free()我们的对象。

如果你想实现自己的垃圾收集器,请查看这个LLVM页面

用Pthreads实现硬件线程

到目前为止,我们已经研究了printf和malloc。我们发现,声明它们的函数签名的最大障碍是将C类型翻译成LLVM IR类型。一旦你有了LLVM IR类型,一切都会迎刃而解。在pthread API中,这个过程是一样的,只是从C类型翻译成LLVM IR类型的过程更复杂一些。

了解Pthread API

我们要使用的两个函数是pthread_createpthread_join。链接到的Linux手册页面给出了对这两个函数的完整描述,但它们有点密集。

让我们解开相关信息,从函数的签名开始。

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);

int pthread_join(pthread_t thread, void **retval);

pthread_create和pthread_join都是习惯性的C语言函数。

  • 它们返回一个int,其中0=成功,其他值=错误代码。
  • 如果要返回额外的值,例如一个类型为Foo的val,我们传入一个类型为Foo*的指针p作为参数。函数会将指针的值更新为返回的值(*p=val)。然后,我们可以通过解除引用指针(*p)来访问返回的值。如果你不熟悉这种 "逐个传递指针 "模式,请参见本教程

如果你不熟悉C指针语法,虚空*(*start_routine)(void *)是很拗口的。这说明start_routine是一个指向函数的指针,该函数接收一个void *参数并返回一个void *值。void *是代表任何指针的通用类型(它超级灵活--我们可以把它投向任何我们喜欢的类型,例如int *或Foo *)。

pthread_create创建了一个线程,它将异步执行start_routine指向的函数和参数。不透明的pthread_t类型代表一个线程对象的句柄(可以把它想象成一个线程id)。我们传递一个pthread_t *指针,pthread_create将把创建的线程对应的pthread_t句柄分配给这个对象。不透明的pthread_attr_t类型表示我们希望线程拥有的任何属性。pthread_attr_t *参数让我们指定我们希望创建的线程拥有的属性。传递NULL将用默认属性初始化线程,这对我们来说已经足够好了。

我们把pthread_t句柄传递给pthread_join,告诉它我们要加入(等待完成)的线程。pthread_join用在该线程上执行的start_routine(arg)的void *返回值更新void **指针参数。如果我们不想要这个返回值,我们可以传递NULL。

这里有一个很好的最小化C例子,演示了Pthreads的使用。

将Pthread类型翻译成LLVM IR。

我们之前已经看到过int和void :它们可能是i32和i8。void *则是i8。我们在pthread_t和pthread_attr_t上遇到了一点麻烦,因为它们的类型定义是不透明的。

啊,糟糕,我们被卡住了。解决的办法(就像大多数被LLVM IR卡住的情况一样)是用C语言进行实验,看看编译后的LLVM IR输出。

我们可以使用clang将那个优秀的最小化C例子编译成LLVM IR。对foo.c文件的编译命令是。

clang -S -emit-llvm -O1 foo.c

Clang LLVM IR的输出相当混乱。阅读输出的最好方法是找到LLVM IR中与C程序中有趣的代码行相对应的行,并忽略它们周围的噪声。在这篇优秀的Reddit评论中,有更多关于用C和C++实验来理解LLVM IR的信息。

对我们来说,有趣的行是那些分配pthread_t堆栈变量的行,以及pthread_create和pthread_join调用。

// C
pthread_t inc_x_thread;
...
pthread_create(&inc_x_thread, NULL, inc_x, &x)
...
pthread_join(inc_x_thread, NULL)

// LLVM IR
%4 = alloca %struct._opaque_pthread_t*, align 8
...
%9 = call i32 @pthread_create(%struct._opaque_pthread_t** %4, %struct._opaque_pthread_attr_t* null, i8* (i8*)* @inc_x, i8* %8)
...
%23 = call i32 @pthread_join(%struct._opaque_pthread_t* %22, i8** null)

如果我们把函数的类型定义匹配起来。

// C type -> LLVM IR type
pthread_t = %struct._opaque_pthread_t*
pthread_attr_t = %struct._opaque_pthread_attr_t

很好,我们已经确定pthread_t是一个指向%struct._opaque_pthread_t类型结构的指针。这个结构体的类型是什么呢?让我们看看前面文件中定义的类型定义。

struct.__sFILE = type { i8*, i32, i32, i16, i16, %struct.__sbuf, i32, i8*, i32
(i8*)*, i32 (i8*, i8*, i32)*, i64 (i8*, i64, i32)*, i32 (i8*, i8*, i32)*, %struc
t.__sbuf, %struct.__sFILEX*, i32, [3 x i8], [1 x i8], %struct.__sbuf, i32, i64 }
%struct.__sFILEX = type opaque
%struct.__sbuf = type { i8*, i32 }
%struct._opaque_pthread_t = type { i64, %struct.__darwin_pthread_handler_rec*, [
8176 x i8] }
%struct.__darwin_pthread_handler_rec = type { void (i8*)*, i8*, %struct.__darwin
_pthread_handler_rec* }
%struct._opaque_pthread_attr_t = type { i64, [56 x i8] }

呀,这是一个烂摊子。事情是这样的。我们不需要声明结构的内部结构,因为我们在程序中不使用它们。所以就像上面定义的%struct.__sFILEX是一个不透明的结构一样,我们可以定义自己的不透明结构。pthread库的文件会在实际操作结构类型的内部时指定结构类型的主体。

Type *pthread_t = StructType::create(*context, "struct_pthread_t") ->getPointerTo();

Type *pthread_attr_t = StructType::create(*context,"struct_pthread_attr_t")

眼尖的人可能会注意到这些结构的名字与文件中的名字不一致,例如 struct_pthread_t 与 struct._opaque_pthread_t。是什么原因呢?

LLVM的类型是结构化解析的,而不是按名字解析的。因此,即使我们的程序有两个独立的结构Foo和Bar,如果它们的字段类型相同,LLVM也会把它们当作一样的。名字并不重要--我们可以用一个结构代替另一个结构,而不会出现任何错误。

// Foo == Bar
%Foo = type {i32, i1}
%Bar = type {i32, i1}

原来我们可以利用LLVM的类型系统的结构特性,进一步简化我们的类型。

请看pthread_attr_t只在一个地方使用:pthread_create,在那里我们传递NULL作为pthread_attr_t *参数。NULL不管是什么类型都是一样的值,所以我们可以用void *来表示一个通用的NULL指针,而不是定义pthread_attr_t *类型。

接下来我们来看一下pthread_t。我们知道pthread_t是一个指向某个不透明结构的指针,但是我们在程序中从来没有在任何地方访问过这个结构。事实上,pthread_t的类型唯一重要的地方是当我们在堆栈上为它分配内存的时候--我们需要知道它的类型才能知道要分配多少字节。

Type *pthreadTy = codegenPthreadTy();
Value *pthreadPtr =
  builder->CreateAlloca(pthreadTy, nullptr, "pthread");

问题是:所有的指针都有相同的大小,不管类型如何,因为它们都存储内存地址。所以我们也可以为pthread_t使用一个通用指针类型void *。

Type *voidPtrTy = Type::getInt8Ty(*context)->getPointerTo();
Type *pthreadTy = codegenPthreadTy();
Type *pthreadPtrTy = pthreadTy->getPointerTo();

// (void *) fn (void * arg)
FunctionType *funVoidPtrVoidPtrTy = FunctionType::get(
  voidPtrTy, ArrayRef<Type *>({voidPtrTy}),
  /* has variadic args */ false);

// int pthread_create(pthread_t * thread, const pthread_attr_t * attr,
//                  void * (*start_routine)(void *), void * arg)
// we use a void * in place of pthread_attr_t *
FunctionType *pthreadCreateTy = FunctionType::get(
  Type::getInt32Ty(*context),
  ArrayRef<Type *>({pthreadPtrTy, voidPtrTy,
                (funVoidPtrVoidPtrTy)->getPointerTo(),
                voidPtrTy}),
  /* has variadic args */ false);
module->getOrInsertFunction("pthread_create", pthreadCreateTy);

// int pthread_join(pthread_t thread, void **value_ptr)
FunctionType *pthreadJoinTy = FunctionType::get(
  Type::getInt32Ty(*context),
  ArrayRef<Type *>({pthreadTy, voidPtrTy->getPointerTo()}),
  /* has variadic args */ false);
module->getOrInsertFunction("pthread_join", pthreadJoinTy);

在运行时库中进行链接

当我们编译foo.ll文件到./foo可执行文件时,我们可以使用clang来链接库。

我们在pthread中使用-pthread标志进行链接。

要链接GC_malloc,我们需要做两件事。

  • 包含它的头文件(这里它们在/usr/local/include/gc/文件夹中)。我们使用-I标志来添加它的文件夹。
  • 添加静态库.a文件。将静态库.a文件:/usr/local/lib/libgc.a添加到正在编译的文件列表中。
clang -O3 -pthread -I/usr/local/include/gc/ foo.ll  /usr/local/lib/libgc.a  -o foo

使用C函数的通用引导方法

我们已经看到了在引导我们的运行库时使用的3个C函数的例子:printf、malloc和pthread API。通用方法(如果你已经了解了函数,可以跳过第2-4步

  1. 获取C函数类型签名
  2. 用C语言写一个最小的例子
  3. 将C语言的例子编译成LLVM IR,并将例子中的关键行与IR中的相应行进行匹配,例如函数调用。
  4. 将任何不透明的类型简化为更通用的类型:只定义你所需要的类型信息!例如,如果你不需要知道它是一个struct *指针(因为你没有加载它或执行GEP指令),使用void *
  5. 将C类型翻译成LLVM的IR类型
  6. 在你的模块中声明函数原型。
  7. 在LLVM IR中,无论你在哪里需要它,都可以调用该函数。
  8. 在编译可执行文件时,将该函数链接进去。

在Bolt中实现并发

完成-同步结构

Bolt使用finish-async结构进行并发。async关键字生成(创建)一个线程,并设置它执行该async块中的表达式。finish块使用async关键字对其内部产生的任何线程的寿命进行了限定--因此所有产生的线程必须在块的最后加入。这样,我们已经明确定义了线程的寿命是完成块的寿命。

before();
finish{
  async{
    f();
  }
  async{
    g();
    h();
  }
  during();
}
after();

以图示方式说明执行情况。

Bolt并发程序图解--每种颜色代表不同的线程。

创建我们的线程

计算自由变量

当在我们的异步块内部,我们可以访问完成块开始时(异步线程被生成之前)作用域内的任何对象。例如

let a = new Foo();
let b = new Bar();
let y = true;
let z = 2;
finish{
  async{
    // This thread accesses a, b
    let w = 1;
    f(a, b, w);
  }
  ...
}

但是,有一个问题。在Bolt的内存模型中,每个线程都有自己的栈。let a = ... 的定义发生在主线程上,所以指向a的对象的指针就存储在主线程的栈中。当我们使用async生成第二个线程时,这个新线程有自己的栈,而这个栈是空的(所以不包含a或b)。

第一步是计算需要复制到新栈中的自由变量。如果你有兴趣的话,这里有代码的链接;我们将跳过细节,因为它很机械。如果你想回顾一下去ugaring阶段,请链接到之前关于去ugaring的文章

async{
  //  a, b are free variables
    let w = 1;
    f(a, b, w);
  }

将异步块转换为函数调用。

现在我们需要以某种方式将这个表达式转换为pthread_create可以运行的函数。

async{
    let w = 1;
    f(a, b, w);
  }
//  need to convert this to a function
void *asyncFun(void *arg){
  let w = 1;
  f(a, b, w);
  return null; // we return null but
  // you could return the last value instead
}

由于我们需要定义函数体中的所有变量,所以我们需要将自由变量作为参数传递给函数。

function void *asyncFun(Foo a, Bar b){
  let w = 1;
  f(a, b, w);
}
let a = new Foo();
let b = new Bar();
let y = true;
let z = 2;
finish{
  asyncFun(a, b);
  ...
}

然而,这不符合我们正在寻找的参数类型:asyncFun只能接受一个void *参数。

解决方案:创建一个包含所有值的单一结构。我们可以将该struct *投向一个void *指针,并从该指针投出,以匹配类型。

ArgStructType *argStruct = {a, b};
// we can cast ArgStructType * to void *
asyncFun(argStruct);

很好,现在我们有了void *asyncFun(void *argStruct)函数类型,正如pthread_create所要求的那样。

我们需要将其解包到函数中。

function void *asyncFun(void * arg){
  // cast pointer
  ArgStructType *argStruct = (ArgStructType *) arg;
  // unpack variables
  let a = argStruct.a;
  let b = argStruct.b;
  // execute body of function
  let w = 1;
  f(a, b, w);
}

创建Pthreads

最后,在定义了我们的arg和异步函数后,我们可以调用pthread_create。

其高层结构如下。

// create async function and argument
  StructType *argStructTy = codegenAsyncFunArgStructType(freeVarList);
  Value *argStruct = codegenAsyncFunArgStruct(asyncExpr, argStructTy);
  Function *asyncFun = codegenAsyncFunction(asyncExpr, argStructTy);
  ...
  // spawn thread
  Function *pthread_create =
      module->getFunction("pthread_create");
  Value *voidPtrNull = Constant::getNullValue(
      Type::getInt8Ty(*context)->getPointerTo());
  Value *args[4] = {
      pthread,
      voidPtrNull,
      asyncFun,
      builder->CreatePointerCast(argStruct, voidPtrTy),
  };
  builder->CreateCall(pthread_create, args);

codegenAsyncFunArgStructType、codegenAsyncFunArgStruct和codegenAsyncFunction只是实现了我们用散文概述的步骤。

加入Pthreads

我们将每个异步表达式的线程的pthread_t句柄连接起来。 正如我们前面提到的,我们并没有从asyncFun中返回任何东西,所以我们可以传入NULL作为第二个参数。

void IRCodegenVisitor::codegenJoinPThreads(
    const std::vector<Value *> pthreadPtrs) {
  Function *pthread_join =
      module->getFunction("pthread_join");
  Type *voidPtrPtrTy =
      Type::getInt8Ty(*context)->getPointerTo()->getPointerTo();
  for (auto &pthreadPtr : pthreadPtrs) {
    Value *pthread = builder->CreateLoad(pthreadPtr);
    builder->CreateCall(pthread_join,
                        {pthread, Constant::getNullValue(voidPtrPtrTy)});
  }

在LLVM IR中实现Finish-Async功能

现在我们已经讲了如何创建线程和如何加入线程,我们可以给出finish-async并发构造的整体代码生成。

Value *IRCodegenVisitor::codegen(
    const ExprFinishAsyncIR &finishAsyncExpr) {
  std::vector<Value *> pthreadPtrs;
  // spawn each of the pthreads
  for (auto &asyncExpr : finishAsyncExpr.asyncExprs) {
    Type *pthreadTy = codegenPthreadTy();
    Value *pthreadPtr =
        builder->CreateAlloca(pthreadTy, nullptr, Twine("pthread"));
    pthreadPtrs.push_back(pthreadPtr);
    codegenCreatePThread(pthreadPtr, *asyncExpr);
  };
  // execute the current thread's expressions
  Value *exprVal;
  for (auto &expr : finishAsyncExpr.currentThreadExpr) {
    exprVal = expr->codegen(*this);
  }
  // join the threads at the end of the finish block
  codegenJoinPThreads(pthreadPtrs);
  return exprVal;

收尾

在这篇文章中,我们已经研究了运行时库的作用,以及如何用C函数来引导我们的Bolt运行时。我们看了一种通用的方式,将C函数声明添加到我们的模块中,然后将它们与我们编译的.ll文件链接起来。我鼓励你在运行时库中添加更多的C函数,例如scanf来配合我们的printf函数。

这篇文章的后半部分是对Bolt如何做并发的深入探讨。我们已经使用pthread为每个async表达式生成一个硬件线程。一个扩展可能是使用线程池来代替!


通过www.DeepL.com/Translator(免费版)翻译