即时编译器(JIT) 速成课

Posted 貘鱼译

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了即时编译器(JIT) 速成课相关的知识,希望对你有一定的参考价值。

本文为翻译

原文标题:A crash course in just-in-time (JIT) compilers

这是 WebAssembly 文章系列的第二部分。如果你还没有读过其他的,我建议你 从头开始。

一开始 javascript 运行很慢,但是由于一个名为 JIT 的东西,它变快了。那么 JIT 到底是怎么起作用的呢?

在浏览器中 JavaScript 如何运行

当你作为一个开发者,将 JavaScript 添加到页面的时候,你就有了一个目标 和 一个难题。

目标:告诉计算机应该做什么。

难题:你和计算机说的是不同的语言。

你说的是人类语言,而计算机说机器语言。虽然很多人不愿意相信,但 JavaScript 或 其他高级编程语言 其实都是人类语言。它们是为人类认知而设计,而不是机器。

所以 JavaScript 引擎的工作就是将你的人类语言 翻译 1 为机器能够理解的东西。

我觉得这就像是电影 《降临》2 。片中人类和外星人试图进行交流。

在这部电影中,人类和外星人并不是 一个字一个字的进行 翻译 。这两个群体对世界有着不同的看法。人类和机器也是如此(我将在下一篇文章中进一步解释)。

那么, 翻译 是如何进行的呢?

在编程中,通常有两种 翻译 为机器语言的方式。我们可以使用 解释器(interpreter) 或者 编译器(compiler)。

在使用 解释器 时,这种 翻译 几乎是 一行一行 实时进行的。

即时编译器(JIT) 速成课

编译器 使用另一种方式,它会提前 翻译 好 并且记录下来,而不是动态进行 翻译 。

即时编译器(JIT) 速成课

这两种 翻译 的方式 各有优缺点。

解释器 的 优缺点

解释器 可以迅速开始工作 并 运行代码。不必完成整个编译阶段,就能开始运行代码了。可以一行一行地 翻译 并运行。

解释器似乎很自然地适用于 JavaScript。因为对于 Web 开发者来说,能够快速运行自己的代码是很重要的。

这确实是浏览器最初使用 JavaScript 解释器的原因。

但是,当不止一次地运行相同代码时,解释器的缺点就出现了 。例如,在一个循环中,它将不得不 一次又一次的做出同样的 翻译 。

编译器 的 优缺点

编译器 权衡利弊的方式 和 解释器是相反的。

因为必须先进行编译阶段,所以它启动需要花更多的时间。但由于不需要在循环中重复进行 翻译 ,在循环中的代码会运行的更快。

另外一个不同之处是,编译器有更多的时间对代码进行分析和修改,以便代码能够运行的更快。这种修改被称为 优化(optimization)。

解释器 作用在 运行时(runtime),所以在 翻译 时没有足够的时间来计算这些 优化 。

即时编译器(Just-in-time compilers):两全其美

为了避免解释器的低效性,即每次经过循环都需要重新 翻译 代码,浏览器开始混合加入编译器。

不同的浏览器以略微不同的方式实现这一点,但基本思想是一致的。它们将 监视器(又名剖析器)3 作为一个新部件 添加到浏览器引擎中。监视器 在代码开始运行时开启监控,并记录代码 运行的次数 和 使用到的类型。

最初 ,监视器 只通过 解释器 运行所有代码。

即时编译器(JIT) 速成课

如果同一行代码运行过几次,那么这段代码就被称为 warm 。如果运行了非常多次,那么这段代码就被称为 hot 4

基线编译器(Baseline compiler)

当一个函数开始变 warm,JIT 就将它发送出去进行编译。之后 JIT 会将编译结果存储下来。

即时编译器(JIT) 速成课

函数的每一行代码都会分别被编译成一段 “桩代码”5,桩代码 根据 行号 和 变量类型(我会稍后介绍为什么这很重要) 进行索引。如果 监视器 发现代码执行中出现了 执行过的相同代码(且使用的变量类型相同),那么就会取出 已编译 的版本。

这有助于加速代码运行。但是就像我在上文提到的,编译器能做到的不仅仅是这些。它还能花些时间来计算出代码最有效率的执行方式,然后进行优化。

基线编译器 会执行我上面提到的优化(我会在之后给出示例)。但优化花费时间不应该太长,这会导致代码执行被阻塞。

可是,如果代码真的非常 hot ,即代码运行了非常多次,那么花费额外的时间去做些优化就是值得的。

优化编译器(Optimizing compiler)

当一部分代码非常 hot 的时候,监视器 就会将它发送到 优化编译器。这将创建并保存另一个版本的函数代码,这份代码会比原版的运行更快。

即时编译器(JIT) 速成课

为了使代码运行更快,优化编译器不得不做出一些假设。

例如,如果可以假设 所有的对象都由一个 特定形式构造函数 创建 — 即对象总是具有相同且添加顺序相同的属性名,那么就可以基于这个假设,走一些捷径。

通过监视器观测代码执行收集到的信息,优化编译器就可以做出判断。如果某件事在之前的循环都是发生的,那么假定这件事还会继续发生。

但是,当然,对于 JavaScript,这是得不到保证的。就算有 99 个对象都有相同的结构,第 100 个也有可能缺失一个属性。

所以,需要 在编译后的代码运行之前,对其进行检查,确认之前的假设是否还有效。如果通过检查,那么就执行代码。但如果没有通过,JIT 会认为 自己之前做出了错误的假设,并清除优化版本的代码。

即时编译器(JIT) 速成课

然后代码 会回退到 解释器 或者 基线编译器编译的版本 进行执行。这个过程被称为 去优化6

通常,优化编译器会加速代码运行,但是有时也会导致意外的性能问题。如果代码持续不断的进行 优化 ,然后 去优化,那么最终 这会比直接执行 基线编译器编译的代码 还要慢。

大多数浏览器会增加一些限制,用来打断可能出现的 优化/去优化 循环周期。如果 JIT 已经做了 比方说 10 次优化,并且不断的将优化代码清除,那么它就会放弃再进行优化了。

一个优化示例:类型特例化(Type specialization)

优化有很多不同的类型,但我想介绍其中一种 来向你解释优化是如何发生的。在 优化编译器 中,类型特例化 就是一类效果突出的优化方式。

JavaScript 使用的动态类型系统 依赖 运行时去做一些额外处理。比方说,思考下面这段代码:

function arraySum(arr) {
var sum = 0;
for (var i = 0; i < arr.length; i++) {
sum += arr[i];
}
}

循环中的 += 运算步骤看似很简单,可以一步就将计算完成,但由于使用了动态类型,所以实际步骤会比想象中更多。

假设 arr 是一个由 100 个整数组成的数组。一旦代码变得 warm,基线编译器将为函数中的每个操作都创建一段 桩代码。因此,对于 sum += arr[i] 将会有一段 桩代码,它将把 += 作为整数加法来处理。

然而, sum 和 arr[i] 不一定是整数。因为类型在 JavaScript 中是动态的,所以在后续循环中,arr[i] 就可能被修改为一个字符串。整数加法 和 字符串拼接 是两个完全不同的操作,因此它们会编译为完全不同的机器码。

JIT 处理这个问题的方法是编译多段 基线桩代码。如果一段代码是单一形态的(多次的代码调用,变量类型总是保持一致),那么将为它生成一段桩代码;如果他是多态的(多次的代码调用,变量类型可能不同),那么将为可能出现的变量组合各生成一段桩代码。

这意味着 JIT 不得不在选择一段 桩代码 之前,确认很多问题。

即时编译器(JIT) 速成课

因为每行代码在基线编译器中都有一组自己的 桩代码,所以 JIT 需要在每次执行到某行代码时都检查类型。这样一来,循环中的每一次迭代中,JIT 都不得不询问相同的问题。

如果 JIT 无需做这种重复性的检查,那么代码执行会更快。而这正是一项 优化编译器 会做的优化。

在优化编译器中,整个函数所有的语句被一同编译。类型检查就可以被转移到循环之前。

一些 JIT 甚至在这方面进一步优化。例如,在 Firefox 中,只包含整数的数组是一个特殊的分类。如果 arr 被判定为这种数组,那么 JIT 就不需要检查 arr[i] 是否为整数。这意味着在循环之前,就能完成全部的类型检查。

总结

简而言之,这就是 JIT。它 监控代码运行情况 并 将 hot 代码进行优化,以此让 JavaScript 运行得更快。是它的出现让大多数 JavaScript 应用程序的性能提高了许多倍。

尽管有了这些改进,JavaScript 的性能还是不稳定的。为了让性能整体更快,JIT 运行时会有许多开销,包括:

  • 优化 和 去优化

  • 监视器 记录监视信息 所消耗的内存

  • 去优化 的恢复信息 消耗的内存

  • 存储 基线编译器 和 优化编译器 不同的函数编译版本 所消耗的内存

其实这方面还存在改进的空间,开销可以被消除,性能也能更稳定。而这就是 WebAssembly 能够做到的事情。

在下一篇文章中,我将进一步解释 汇编(assembly) 以及 编译器 如何和汇编进行协作。

  • 原文为 translation,有 转化,翻译,编译 的意思 ↩︎

  • 降临 Arrival (2016年丹尼斯·维伦纽瓦执导美国电影)  ↩︎

  • 原文为 Monitor (aka a profiler) ↩︎

  • 文中用 warm 和 hot 来表示代码执行频次。hot 是 热的意思,引申为引人瞩目、受欢迎。在中文中我们也会使用 “热门” 、“热点” 等词语来做类似的形容。 ↩︎

  • 桩(Stub / Method Stub )是指用来替换一部分功能的程序段。桩程序可以用来模拟已有程序的行为(比如一个远端机器的过程)或是对将要开发的代码的一种临时替代。因此,打桩技术在程序移植、分布式计算、通用软件开发和测试中用处很大。

    在这里应该可以被理解为 “能够被调用的一小段代码”,更多关于 桩 的讨论,可以参考 https://www.zhihu.com/question/24844900 ↩

  • 原文为 deoptimization 或 bailing out ↩︎


以上是关于即时编译器(JIT) 速成课的主要内容,如果未能解决你的问题,请参考以下文章

浅析 JIT 即时编译技术

如何获得即时编译器(JIT)的汇编代码(linux环境下)

jit即时编译

jit即时编译

JVM - JIT即时编译器

Zend JIT 即时编译器开源