即时编译器(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)。
在使用 解释器 时,这种 翻译 几乎是 一行一行 实时进行的。
编译器 使用另一种方式,它会提前 翻译 好 并且记录下来,而不是动态进行 翻译 。
这两种 翻译 的方式 各有优缺点。
解释器 的 优缺点
解释器 可以迅速开始工作 并 运行代码。不必完成整个编译阶段,就能开始运行代码了。可以一行一行地 翻译 并运行。
解释器似乎很自然地适用于 JavaScript。因为对于 Web 开发者来说,能够快速运行自己的代码是很重要的。
这确实是浏览器最初使用 JavaScript 解释器的原因。
但是,当不止一次地运行相同代码时,解释器的缺点就出现了 。例如,在一个循环中,它将不得不 一次又一次的做出同样的 翻译 。
编译器 的 优缺点
编译器 权衡利弊的方式 和 解释器是相反的。
因为必须先进行编译阶段,所以它启动需要花更多的时间。但由于不需要在循环中重复进行 翻译 ,在循环中的代码会运行的更快。
另外一个不同之处是,编译器有更多的时间对代码进行分析和修改,以便代码能够运行的更快。这种修改被称为 优化(optimization)。
解释器 作用在 运行时(runtime),所以在 翻译 时没有足够的时间来计算这些 优化 。
即时编译器(Just-in-time compilers):两全其美
为了避免解释器的低效性,即每次经过循环都需要重新 翻译 代码,浏览器开始混合加入编译器。
不同的浏览器以略微不同的方式实现这一点,但基本思想是一致的。它们将 监视器(又名剖析器)3 作为一个新部件 添加到浏览器引擎中。监视器 在代码开始运行时开启监控,并记录代码 运行的次数 和 使用到的类型。
最初 ,监视器 只通过 解释器 运行所有代码。
如果同一行代码运行过几次,那么这段代码就被称为 warm 。如果运行了非常多次,那么这段代码就被称为 hot 4。
基线编译器(Baseline compiler)
当一个函数开始变 warm,JIT 就将它发送出去进行编译。之后 JIT 会将编译结果存储下来。
函数的每一行代码都会分别被编译成一段 “桩代码”5,桩代码 根据 行号 和 变量类型(我会稍后介绍为什么这很重要) 进行索引。如果 监视器 发现代码执行中出现了 执行过的相同代码(且使用的变量类型相同),那么就会取出 已编译 的版本。
这有助于加速代码运行。但是就像我在上文提到的,编译器能做到的不仅仅是这些。它还能花些时间来计算出代码最有效率的执行方式,然后进行优化。
基线编译器 会执行我上面提到的优化(我会在之后给出示例)。但优化花费时间不应该太长,这会导致代码执行被阻塞。
可是,如果代码真的非常 hot ,即代码运行了非常多次,那么花费额外的时间去做些优化就是值得的。
优化编译器(Optimizing compiler)
当一部分代码非常 hot 的时候,监视器 就会将它发送到 优化编译器。这将创建并保存另一个版本的函数代码,这份代码会比原版的运行更快。
为了使代码运行更快,优化编译器不得不做出一些假设。
例如,如果可以假设 所有的对象都由一个 特定形式构造函数 创建 — 即对象总是具有相同且添加顺序相同的属性名,那么就可以基于这个假设,走一些捷径。
通过监视器观测代码执行收集到的信息,优化编译器就可以做出判断。如果某件事在之前的循环都是发生的,那么假定这件事还会继续发生。
但是,当然,对于 JavaScript,这是得不到保证的。就算有 99 个对象都有相同的结构,第 100 个也有可能缺失一个属性。
所以,需要 在编译后的代码运行之前,对其进行检查,确认之前的假设是否还有效。如果通过检查,那么就执行代码。但如果没有通过,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 甚至在这方面进一步优化。例如,在 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) 速成课的主要内容,如果未能解决你的问题,请参考以下文章