JIT编译器深度介绍:JIT不是即时的

2020-07-06 03:02:56

如果您熟悉JIT的一般工作方式(如果您理解了标题所指的内容),我建议您略读这篇文章,或者直接阅读JIT编译器是如何实现和快速的:Julia、PyPy、LuaJIT、Graal等。

我的导师克里斯把我从“什么是JIT”带到了我现在所处的位置,他曾经告诉我,编译器只是以字节为单位输出的,根本不是低级的和可怕的。这实际上是相当正确的,了解编译器内部是很有趣的,而且通常对各地的程序员都很有用!

这篇博客文章介绍了编程语言是如何实现的,以及JIT是如何工作的。它将介绍Julia语言的实现细节,但不会讨论更传统的JIT所做的具体实现细节或优化。查看JIT编译器是如何实现和快速的:Julia、PyPy、LuaJIT、Graal等,了解元跟踪是如何实现的、Graal如何支持C扩展、JIT与LLVM的关系等!

当我们运行程序时,它要么以某种方式解释,要么以某种方式编译。编译器/解释器有时被称为一种语言的实现,一种语言可以有多个实现。您可能听说过这样的事情:解释Python,但这实际上意味着Python的引用(标准/默认)实现是解释器。Python是一种语言规范,CPython是Python的解释器和实现。

解释器是直接执行代码的程序。众所周知的解释器通常是用C编写的。Ruby、Python和PHP都是用C编写的。下面是一个松散地模拟解释器工作方式的函数:

函数解释(代码字符串){IF code==";print(';Hello,World!';)";{print(";hello,World";);}Else if code==“x=0;x+=4;print(X)”{Variable_x:=0 Variable_x+=4 print(X)}}。

编译器是将代码从一种语言翻译成另一种语言的程序,尽管它通常指的是目标语言,即机器代码。编译语言的例子有C、GO和Rust。

编译语言和解释语言之间的区别实际上要细微得多。Go和Rust都经过了清晰的编译,因为它们输出的是机器代码文件--这是计算机本身就能理解的。编译和运行步骤完全不同。

但是,编译器可以翻译成任何目标语言(这有时称为转换)。例如,Java有两个步骤的实现。第一种是将Java源代码编译成字节码,这是一种中间表示(IR)。然后对字节码进行JIT编译-这涉及到解释。

Python和Ruby也分两步执行。尽管它们被称为解释型语言,但它们的参考实现实际上将源代码编译成字节码。您可能已经看到包含Python字节码的.pyc文件(在Python3中不再是这样)!然后由虚拟机解释该字节码。这些解释器使用字节码,因为程序员往往不太关心编译时间,而创建字节码语言允许工程师指定尽可能高效的解释字节码。

使用字节码是语言在执行前检查语法的方式(尽管从技术上讲,它们可以只在启动解释器之前执行一遍)。下面的一个示例显示了为什么要在运行前检查语法。

另一个重要的注意事项是,由于各种原因,解释语言通常速度较慢,最明显的原因是它们是用执行时间开销较大的高级语言执行的。主要原因是他们倾向于实现的语言的动态性意味着他们需要很多额外的指令来决定下一步做什么以及如何路由数据。人们仍然选择构建解释器而不是编译器,因为解释器更容易构建,并且更适合处理动态类型、作用域等事情(尽管您可以构建具有相同功能的编译器)。

JIT编译器不会提前编译代码(AOT),但仍会将源代码编译为机器码,因此不是解释器。当您的程序正在执行时,JIT在运行时编译代码。这为JIT提供了动态语言功能的灵活性,同时保持了优化机器代码输出的速度。JIT编译C会使它变慢,因为我们只是将编译时间加到执行时间上。JIT编译Python会更快,因为编译+执行机器码通常比解释更快,特别是因为JIT不需要写入文件(磁盘写入很昂贵,内存/RAM/寄存器写入很快)。JIT还通过能够优化仅在运行时可用的信息来提高速度。

编译语言之间的一个共同主题是它们都是静态键入的。这意味着当程序员创建或使用一个值时,他们会告诉计算机它是什么类型,并且该信息在编译时是有保证的。

这里有一个julia函数的例子,它可以用来乘以整数、浮点数、向量、字符串等(julia允许操作符重载)。由于各种原因,编译出所有这些情况的机器代码都不是很有效率,如果我们想让Julia成为一种编译语言,这就是我们必须做的。惯用编程意味着函数可能只会被几种类型的组合使用,我们不想编译我们还没有用到的东西,因为那不是很紧张(这不是一个真正的术语)。

如果我要编写Multiply(1,2),那么Julia将编译一个整数相乘的函数。如果我随后编写了Multiply(2,3),那么将使用已经编译的代码。如果我随后添加了Multiply(1.4,4),则将编译该函数的另一个版本。我们可以观察到编译对@code_llvm multiply(1,1)做了什么,它生成LLVM位码(不完全是机器码,而是一个较低级别的中间表示)。

定义i64@julia_multiply_17232(i64,i64){TOP:;┌@INT。JL:54在`*';%2=mul i64%1,%0;└返回i64%2}。

使用Multiply(1.4,4),您可以看到即使再编译一个函数也会变得多么复杂。在AOT编译的Julia中,所有这些组合(可以进行一些优化以减少)都必须存在于编译的代码中,即使只使用了一个,以及要委托的控制流。

定义DOUBLE@julia_multiply_17042(DOUBLE,i64){TOP:;┌@Promotion。JL:312`*';;│┌@促销。JL:在`Promote';;││┌@Promotion内。jl:259在`_Promote';;│┌@Number内。JL:7在`Convert';;│┌@Float内。JL:60在`Float64';%2=sitofp i64%1到Double;│└;│@促销。JL:312,`*';@Float内。JL:405%3=fmul Double%2,%0;└ret Double%3}。

“假定一个类型并基于该类型进行编译/行为”的一般策略称为类型推理,Julia在上面的示例中温和地使用了它。还有很多其他的编译器优化,尽管它们都不是非常特定于JIT的,因为Julia可以更好地描述为一个懒惰的AOT编译器。

这种跳转的简单性使得Julia也可以轻松地提供AOT编译。它还帮助Julia很好地进行了基准测试,绝对比Python等语言高出一层,并且可以与C语言相媲美(我引用了一些数字,但这些数字总是有细微差别,我不想深入讨论这一点)。

朱莉娅实际上是我将要讨论的最紧张的JIT,但不是最有趣的JIT。它实际上正好在需要使用代码之前编译代码--非常及时。然而,大多数JIT(PyPy、Java、JS引擎)实际上并不是即时编译代码,而是在最佳时间编译最佳代码。在某些情况下,这段时间实际上是永远不会有的。在其他情况下,编译会多次进行。在绝大多数情况下,编译直到源代码被多次执行之后才会发生,JIT将停留在解释器中,因为编译的开销太高而没有价值。

发挥作用的另一个方面是生成最佳代码。汇编指令的生成并不平等,编译器将投入大量精力来生成优化良好的机器码。通常,人类编写比编译器更好的汇编语言是可能的(尽管这需要相当聪明和知识渊博的人类),因为编译器不能动态分析您的代码。我的意思是知道整数的可能范围或映射中的键,因为这些都是计算机只有在(部分)执行程序后才能知道的。JIT编译器实际上可以做这些事情,因为它首先解释您的代码并从执行中收集数据。因此,JIT的代价很高,因为它们进行解释,并增加了执行时间的编译时间,但它们是在高度优化的编译代码中组成的。因此,编译的时间也取决于JIT是否收集了足够有价值的信息。

关于JIT最酷的部分是,当我说C的JIT实现不可能比现有的编译实现更快时,我多少是在撒谎。尝试一下是不可行的,但是以我刚才描述的方式进行JIT编译C并不是编译语言的严格超集,因此在逻辑上不可能以足够快的速度编译代码来弥补编译+配置文件+解释时间。如果我像Julia那样编译C(静态编译每个函数,就像它被调用的那样),那么就不可能使它比编译-C更快,因为编译时间是非负的,并且生成的机器码本质上是相同的。

虽然使用C语言是不可行的,但是可以通过剖面引导优化(PGO,非常可爱地发音为“pogo”)找到一个折中方案。不是在执行时进行性能分析,而是使用PGO性能分析编译程序,运行该程序,然后使用传入的性能分析数据重新编译原始程序。这在减少编译代码大小和改进分支预测方面非常有效。

JIT有热身的概念。因为解释和分析时间很昂贵,JIT将从缓慢执行程序开始,然后朝着性能最高的方向努力。对于像PyPy这样具有解释型副本的JIT,由于性能分析的开销,没有预热的JIT在开始执行时的性能要差得多。这也是JIT将消耗更多内存的原因。

热身增加了测量JIT效率的复杂性!如果您正在测量生成mandelbrot集的性能,这是很好的,但是如果您正在为Web应用程序提供服务,并且前N个请求非常慢,则会很痛苦。由于性能并没有严格提高,情况变得复杂起来。如果PyPy在JIT编译某些函数之后决定需要一次编译很多东西,那么中间可能会变慢。这还会使基准测试结果更加模糊,因为您必须检查是否给了jit语言预热时间,但您还想知道预热时间是否过长。不幸的是,优化编译代码和预热速度本质上是零和游戏(或者至少是小和游戏)。如果您试图更快地编译代码,可用的数据会更少,编译后的代码效率会降低,峰值性能也会降低。当然,追求更高的峰值性能通常意味着更高的性能分析成本。

Java和Javascript引擎都是JIT的例子,它们非常注重预热时间,但是您可能会发现,为学术用途构建的语言有惊人的预热时间,而偏向于时髦的峰值性能。

介绍GraalVM、HotSpot和更深入的Javascript引擎。经过分层、节点海、取消优化和内联。