首发于System Fp

回顾LLILC的六个月

随着LLVM的不断流行,很多人都在问“为什么不用LLVM优化XXX语言”。这个话题很大也很细节,正好微软的.NET团队在2015年时写了这篇文章,详细地介绍了用LLVM做一个高级语言的编译器的大多数难点。这篇翻译既是帮助大家理解这个问题,也是我自己学习。原文有很多CoreCLR专有的语境和术语,所以我们没有逐字逐句翻译,而是根据我自己的经验加了一些小润色,希望能帮大家更好得理解。以下是原文。

原文发表于2015年10月。

在今年4月我们宣布了LLILC项目,一个基于LLVM的、兼容CoreCLR的开源JIT编译器。至今我们已经努力让LLILC可以编译大部分.NET Core应用的核心代码,既LLILC在Windows和Linux上通过了数千个CoreCLR测试用例,可以用NGEN模式AOT编译,可以成功JIT编译整个Roslyn编译器(译注:Roslyn是用完全用C#写成的C#/VB到MSIL的编译器)。

我们使用LLVM代码以及向社区贡献代码的经验给了我们很多启发。目前的工作重点还是垃圾回收(GC)和异常处理(EH),虽然我们已经取得了较大的进展,但仍有很多要做。这篇文章总结了我们目前的进度以及接下来的挑战。

背景

CoreCLR是一个开源的跨平台.NET 运行时,它包括一个虚拟机(VM)、JIT编译器、以及核心类库(mscorlib)。

LLILC是一个为CoreCLR开发的基于LLVM的代码生成器。我们首先将LLILC作为CoreCLR的JIT编译器,待成熟之后再将其作为AOT编译器使用。我们期待这个项目为我们带来更高的程序运行性能、更快地将CoreCLR移植到新的硬件平台、更方地就行代码生成实验。

在CoreCLR中,JIT编译器是独立于整个核心运行时和类型系统的,这个能力来自于一套双向的JIT/EE接口(EE:execution engine)。除此之外,这套接口使CoreCLR能够支持一种alt-jit机制:运行时可以拥有两个JIT编译器,在一个JIT无法编译某些代码的时候,就启用第二个JIT编译器。在LLILC开发中,我们正式利用了这个机制,当LLILC遇到了还没有实现的特性或编译错误时,就切换到CoreCLR的正统编译器RyuJIT来继续运行程序。这使得还未开发完的JIT编译器可以在更多代码上被测试,而不是卡在最开始的地方。在LLILC最初宣布的时候,一个简单的Hellow World 程序也会让LLILC在370个方法上编译失败而发送给RyuJIT。

当前状态

现在LLILC已经可以JIT编译一些复杂应用的所有方法!Roslyn是一个完全由C#写成的C#编译器,LLILC可以JIT编译整个Roslyn。同时我们也把LLILC扩展到了NGEN和R2R模式的AOT编译。NGEN允许提前编译(pre-jitting)大部分方法、IL Stubs、和泛型类型的实例。我们可以用NGEN事先编译整个Roslyn,并用这个事先编译好的版本去编译Roslyn(译注:既bootstrap)。我们也移植了几千个基础的JIT单元测试,LLILC可以在JIT和NGEN模式下通过大部分的这些测试。LLILC能在Linux和OSX上跨平台运行。LLILC已经可以支持一些高级特性,例如向量化。剩下的工作主要集中在垃圾回收(GC)和异常处理(EH)的支持;生成代码大小和代码质量的权衡;以及提升JIT吞吐量(译注:JIT吞吐量/JIT throughput 是CoreCLR的一个专有术语,它用来衡量JIT编译器的编译速度。因为.NET在.NET Core 3.0 之前一直都是单层JIT编译,既没有解释器和多层JIT编译器,所以一直以来CoreCLR的JIT编译器开发中编译速度本身是一个非常重要的考核标准和限制条件)。我们将会在接下来的几节里详细讨论这些。

利用LLVM

我们维护这一份自己的LLVM拷贝和分支,在这里我们一直跟随着LLVM的主线开发并且我们的更改很少和LLVM正在开发的代码相冲突。我们一直都尽快将自己的更改推回上游代码(up-stream)。 我们仍然在努力保持这个意图,但到目前为止我们必须做出的适量的改变来适应上有代码。

我们尽可能地努力在LLILC中利用LLVM功能。 我们已经学到了很多东西,对LLVM如何工作有了更好的感觉,甚至对LLVM本身做了一些微薄的贡献。事情总体上进展顺利但我们有时会发现还是有些没料到的东西。

接下来的几节将重点介绍一些例子。

用LLVM类型表达CLR类型(Modelling CLR Types as LLVM Types)

CLR有着一个非常丰富的类型系统,但是很大一部分丰富性被JIT编译器隐藏了。而相对的,LLVM的类型系统则有意地保持简单。尽管如此,我们早期决定尝试用LLVM类型来表达CoreCLR类型,这么做有几个原因:

  1. 我们想在生成stack maps 时从LLVM类型中重新导出GC信息;
  2. 这样做的话高层的LLVM IR 会得到更为丰富的语义环境。例如,我们可以使用GEP而不是扁平化地址运算(译注:flattening address math,指将内存地址计算表示为普通的整数算术运算);
  3. 我们期望未来有一天可以在LLVM IR 上做基于类型的优化(type-based optimizations)。

在这个方法中我们遇到了很多问题,但都不是致命的:

  • LLVM类型是按需创建的,我们花了几次迭代来获得正确的算法来处理复杂的类型图(type graphs);
  • LLVM类型无法描述继承关系。所以,每个派生类都包含一些来自其基类的重复字段;
  • Union 通常由字节数组(byte arrays)来处理,除非union 含有GC 引用。这些GC引用不能和非GC引用重叠,所以我们把它们分开表示了。
  • 在CoreCLR 中,相同表示的类型可以有不同的语义。 在64位系统上,native intlong是相同的,但在编译器的某些地方要有不同的处理。我们还没有找到一种在LLVM IR中保持这种区别的好方法。
  • 我们有一些用类型名来引用的命名类型(如stringobject),当想用结构相等性时(structural equivalence) ,我们在这种类型上遇到了一些问题。例如,一个拥有GC指针字段的泛型值类型(value type,既struct)在某些地方可能需要用实际的字段的类型来描述,而在其他地方只需要表示为一个到泛型共享占位符类System.__Canon的引用。这些不同的struct类型的描述可能需要在某个交汇点收敛到同一个类型。因为我们放弃了一等聚合类型(见下节)所以我们可以在这些交汇点直接使用指针类型转换来填补类型表示的差异。

下表演示了一些我们如何用LLVM类型在64位系统上来表达C#类型的例子。 注意,这里我们用addrspace(1)表示GC引用,最开始的[0 x i64*]*字段用来描述vtable(一个未知大小的指针列表)。

此处A定义为

[StructLayout(LayoutKind.Explicit)]
public struct A
{
    [FieldOffset(0)]public string Name;
    [FieldOffset(8)]public int Size;
}	

B定义为

[StructLayout(LayoutKind.Explicit)]
public struct C
{
    [FieldOffset(0)]public A X;
    [FieldOffset(0)]public string Name;
    [FieldOffset(8)]public int Size;
}

还要注意,LLILC生成的LLVM IR值的类型是指向实际类型的指针,对于引用类型(ref classes,总是在GC堆中)和值类型(value classes)都是这样。因为我们的编译过程中这两种类型都总是留在内存里从而避免一等聚合类型。

一等聚合类型(First-Class Aggregates)

在最初的尝试中我们将结构体视为一等公民。我们在之前的编译器中都使用这种方法,CLR官方spec也用一等聚合类型来描述MSIL语义。但是很快我们就发现在LLVM中最好还是把结构体放入内存中然后用指针来引用。这需要一些额外的信息来记录哪个指针实际上是by-value 结构体。这个方法可能没有想象中的那么差,因为我们之前已经需要在函数调用周围把结构体放入内存。

显式异常检测(Explicit Exception Checks)

许多MSIL指令可能会导致语义上有意义的异常(其中一些有不止一种异常)。我们习惯了支持操作符和操作数(operation and operand)异常的IR含有隐式异常检测。例如,在空指针上load和store会抛出NullReference异常 。但是LLVM IR的异常模型更贴近C++,异常只能从函数调用中抛出。虽然LLVM社区已经讨论过在LLVM IR中启用更一般的异常模型,但是现在我们在将IR中的异常检测变为显式检测。

当前LLVM的发展方向对于AOT编译器来说似乎是一个相当不错的折衷方案:在HIR中显式检测,在机器相关级别折叠进隐式检查。但对于JIT来说,我们可能会在未来重新审视的这些问题,哪种模式更可取还远非明确,这需要在许多方面进行权衡。

隐式异常检测可能不会在所有架构上都足够高效。在高层IR中,多种操作能抛出异常的能力给IR的维护和优化带来了很大的负担。例如,移动(hoisting)一个可以抛出异常的load会对控制流造成影响。

显式检测为IR提供了一个很好的“关注分离”方式:load和store不会有复杂的行为。检测异常的IR和其它比较/跳转IR有相似的语义,从而可以适用更多通用优化。但是显式检测增大了IR的体积,这影响了JIT编译器的吞吐量和代码大小。例如,编译Buffer.Memmove时LLILC会产生85个显式检测。LLILC会将throw基本块堆在方法末尾,看起来就像这个样子

ThrowNullRef:                                     ; preds = %20
  call void bitcast (i64* @"AnyJITHelper::JitHelper" to void ()*)() #1, !dbg !22
  unreachable, !dbg !22

ThrowNullRef2:                                    ; preds = %23
  call void bitcast (i64* @"AnyJITHelper::JitHelper" to void ()*)() #1
  unreachable

ThrowNullRef4:                                    ; preds = %28
  call void bitcast (i64* @"AnyJITHelper::JitHelper" to void ()*)() #1, !dbg !23
  unreachable, !dbg !23

; ... (82 more)...

尽管指令相同,但这些基本块不能合并,因为它们携带不同的调试信息,而且保留这些信息很重要,因为它允许用户诊断是哪个null检查失败了。话说回来,这些检查中的许多都可以被优化掉,但这会让我们很为难。如果我们生成这种很naive的IR,巨大的IR体积会让JIT吞吐量很差(译注:因为JIT要处理更多IR),并且JIT在运行时占用很多内存。如果我们加一些优化来减小IR体积,JIT吞吐量同样会变差(译注:因为JIT要跑更多pass)。

定制化调用约定(Custom Calling Conventions)

CoreCLR在内部使用的是非标准的调用约定。比如,Windows x64上一个virtual stub dispatch 调用(译注:既interface调用)必须在R11 中传一个额外参数。我们发现这个在LLVM中很容易

def CC_X86_64_CLR_VirtualDispatchStub : CallingConv<[
  // The first pointer-sized argument is passed in R11
  CCIfType<[i64], CCAssignToReg<[R11]>>,
  // Otherwise, drop to normal X86-64 CC
  CCDelegateTo<CC_X86_64_C>
]>;

CCIfCC<"CallingConv::CLR_VirtualDispatchStub",
         CCDelegateTo<CC_X86_64_CLR_VirtualDispatchStub>>,

Platform Invoke (aka PInvoke)

当托管代码(如C#)调用本地代码(如C)时,JIT编译器必须在调用方(caller)的栈帧上生成额外信息,然后在每个本地代码调用点上使用这些信息。这可以帮助VM决定什么时候把控制权交到VM之外。这些调用点通常都是GC safepoint,所以我们加了额外的参数和定制的扩展到这些状态点来封装必要的状态信息。

内联栈探测(Inline Stack Probes, aka __chkstk)

Windows操作系统要求一个线程的栈段(stack segment)以受控的方式增长。这是通过让编译器在需要大幅栈调整时(在函数入口处或某些alloca)逐页仔细探测栈来实现的。CRT中有一个名为__chkstk的函数就是用来做这个的。 遗憾的是,CRT函数有时在CoreCLR运行时环境中不可用,因此传统上JIT编译器需要通过内联实现栈探测。这个工作有点挑战,因为方法内的alloca内联扩展发生在实际的内存分配之前,而函数头(prolog)扩展发生在分配之后。我们尚不完全清楚机器码中需要什么样的注释信息。

NGEN

NGEN是CoreCLR预先编译并缓存代码的方式,LLILC基于MCJIT使用ORC JIT API来实现。这个框架假设我们马上要运行代码,但是在NGEN的情况下我们并不需要在编译后马上运行而且不需要处理代码重定位问题(相反,它们只是在加载NGEN映像时被记录并执行)。所以我们需要在ORC JIT层做一些特殊的工作。

向量化(Vectorization)

.NET Framework 有一个向量运算包而且新的RyuJIT可以利用硬件SIMD指令将其向量化(译注:这里指的是老的System.Numerics.Vectors API)。我们的实习生Sergey Andreenko 在LLILC中实现了这部分,而且我们发现这相当简单。只需要在MSIL中识别特殊的vector类型和操作并转换成相应的LLVM IR就可以了。

例如需要编译的代码是这样

using Point = System.Numerics.Vector2;

Point a = new Point(10, 50);
Point b = new Point(10, 10);
Point c = a * b;

LLILC为其生成LLVM IR

  %1 = addrspacecast <2 x float>* %loc0 to <2 x float> addrspace(1)*, !dbg !12
  store <2 x float> <float 1.000000e+01, float 5.000000e+01>, 
        <2 x float> addrspace(1)* %1, !dbg !18
  %2 = addrspacecast <2 x float>* %loc1 to <2 x float> addrspace(1)*, !dbg !19
  store <2 x float> <float 1.000000e+01, float 1.000000e+01>, 
        <2 x float> addrspace(1)* %2, !dbg !20
  %3 = load <2 x float>, <2 x float>* %loc0, align 8, !dbg !21
  %4 = load <2 x float>, <2 x float>* %loc1, align 8, !dbg !21
  %5 = fmul <2 x float> %3, %4, !dbg !22
  store <2 x float> %5, <2 x float>* %loc2, !dbg !22
  %6 = addrspacecast <2 x float>* %loc2 to <2 x float> addrspace(1)*, !dbg !23

最终被编译成

  mov         rax,7F909DA0510h  
  mov         rax,qword ptr [rax]  
  mov         qword ptr [rsp+38h],rax   
  mov         rax,7F909DA0520h  
  movaps      xmm0,xmmword ptr [rax]      
  movlps      qword ptr [rsp+30h],xmm0  
  movq        xmm1,mmword ptr [rsp+38h]  
  mulps       xmm1,xmm0  
  movlps      qword ptr [rsp+28h],xmm1

扩展LLVM:GC

这个GC文档概述了我们在LLILC中支持CoreCLR的GC所面临的挑战。到目前为止我们的编译器工作都是在保守式GC模式下完成的,同时我们也在完善准确式GC的实现。(译注:CoreCLR的GC可以配置为保守GC来在新编译器开发的初期降低复杂度,让整个VM先跑起来)

CoreCLR的GC是一种准确式(precise)可重定位(relocating,既移动GC)的回收器。这意味着在GC回收发生的地方(safepoints),所有的堆引用必须准确可知而且引用地址可能会被更改。

LLILC已经为准确式GC的safepoints 建立了状态点(statepoint)的表示。这里的状态点被表示为函数调用,并且将可能被修改的GC引用作为额外参数显式传给IR,返回值是新的GC引用值。这个信息随后被用作显式地将这些引用溢出(spill)到内存中,并且将这些位置通过LLVM创建的stackmaps报告给GC。

而且我们扩展了这个状态点的功能来处理一些CoreCLR的托管指针(managed pointers)的特殊情况,既liveness算法需要沿着integer-pointer 转换来计算生命周期,并且过滤掉那些已知的不可能指向GC堆的指针。

LLILC解析LLVM stackmaps以生成CoreCLR期望的GC 信息格式。CLR格式需要一些我们无法从原始stackmaps中获得的新数据,因此我们与社区一起创建了V2增强型stackmaps。 这一点到目前为止运作良好。

CoreCLR的GC模式(保守或准确)需要在整个运行时初始化时设置。通过利用alt-jit机制,我们可以启用准确式GC并端到端地运行测试,从而允许LLILC放弃无法生成正确GC信息的方法。现在我们已经可以用这种方法JIT编译大多数的方法并使之与准确式GC运行。

但是,仍有许多关键问题需要解决:

  • 我们无法编译那些GC引用不是SSA值的方法,例如GC引用来自栈上struct。目前的计划是将struct中的GC引用报告为未跟踪的生命周期(untracked lifetime),用LLVM 类型来发现struct中的GC引用的位置。
  • 我们在研究如何支持外部指针(exterior pointers),既那些逻辑上引用某个堆对象,但实际上指向对象之前或之后的内存。有可能可以通过设计新的GC信息编码并且修改一点GC代码来实现。
  • 现在我们把状态点很早就放进IR来在优化之前锁定语义。我们还没启动如何优化,所以还不确定将来是否会遇到问题。未来我们可能会把放置状态点的步骤推后,因为状态点可能会抑制某些优化。
  • 目前我们非常担忧JIT吞吐量。为运行准确式GC而插入safepoint会使LLILC的编译比保守式明显变慢(其实保守式GC的编译已经太慢了)。safepoint上可能会有成百上千个GC引用,把它们全部显示放进IR的代价太高了。抽象地说,GC报告是一个liveness问题,我们希望代价不要太高。把liveness问题编码进SSA(就像我们的状态点)是一个很值得怀疑的tradeoff:SSA的优点在于处理稀疏性问题,但状态点上的GC liveness是稠密性问题。目前我们还没有深入研究JIT吞吐量低的原因已经如何改善它,但从根本上说,如果LLILC的JIT吞吐量要与当前的CoreCLR RyuJIT竞争,我们可能需要采用不同的方法。

扩展LLVM:EH

CoreCLR还有一个复杂的异常处理模型,LLILC必须支持多种机制才能正确支持异常的处理、清理和传播。

多种MSIL都能导致异常,如上文所述我们现在将其表示为LLVM IR中的显式异常检测。异常是通过调用VM提供的throw helper方法产生的。LLILC必须为每个try、catch、finally区域生成代码来正确处理与运行时的交互。并创建描述性的表,以便运行时能够展开栈(unwind)并在展开每个栈帧时正确处理异常。

在撰写本文时,我们正在增强LLVM IR以及致力于在Clang和LLVM中兼容MSVC的C++ 和结构化异常处理(SEH)。这项工作进展顺利,应该能为我们提供从IR中恢复EH区域结构所需的灵活性 (这是创建适当表格的必要步骤)。我们的实现还要求将一些EH区域块实现为可由运行时调用的单独的小函数(也称为funclet)。LLILC需要处理的一些复杂的funclet与其父函数共依赖性(co-dependence)。例如,运行时不支持funclet的栈上有GC引用,因此如果funclet需要溢出GC引用,则必须保留并使用父函数中的栈槽来实现此目的。

未来的挑战:吞吐量

我们的目标是使LLILC成为高性能JIT,既快速产生高质量代码。大家普遍的看法是,LLVM可能不够精简从而无法实现这一目标,但我们想看看我们可以走多远。现在的测量结果显示,当LLILC处于保守式GC模式时,LLILC比我们的生产环境编译器RyuJIT慢约4倍,准确式GC更慢。我们还没有花太多时间来研究这个问题,而且我们在确定如何使LLVM运行得更快方面没有多少经验,所以这里需要学习很多东西。

作为备选,我们可能决定只能在AOT或多层JIT(tiered JIT)方案中使用LLILC。

未来的挑战:代码质量与代码大小

吞吐量的另一面是JIT编译产生的代码必须具有一定的质量。对于第一层JIT,必须仔细考虑优化,因为编译时间至关重要。我们目前尚未尝试启用LLVM的大部分优化功能,并且还不知道编译时间与代码尺寸/质量的权衡关系。 初始数据显示,LLILC产生的代码是RyuJIT的大约2倍。 我们应该能够群里一些基本的优化使其更具竞争力。

我们也意识到像MSIL这样的托管代码会产生一些特有的优化挑战。 例如异常检测消除:许多异常检查是冗余的,或者可以通过适当的范围相关优化(range-aware optimizations)来删除。 消除这些检查对于获得良好的性能非常重要。 LLVM当前的优化效果还有待观察。

总结

LLILC在过去的六个月里取得了长足的进步,但是我们在努力使其成为CoreCLR的一流代码生成器方面依然面临着巨大的挑战。

发布于 2019-06-01

文章被以下专栏收录