图说 WebAssembly(二):JIT 编译器

82921934 2019-06-27

本文是图说 WebAssembly 系列文章的第二篇,如果你还没阅读其它的,建议您从第一篇开始。

JavaScript 的运行,一开始是很慢的,但是后面会变得越来越快,背后的功臣就是 JIT 。
但是 JIT 是如何工作的呢?

JS 如何在浏览器中运行

作为开发者,我们给网页写 JavaScript 代码是有明确目标的,当然也会伴随着问题。

目标:告诉计算机要做什么。
问题:我们和计算机使用着不同语言。

我们使用的是人类语言,而计算机则使用机器语言。
虽然你可能不同意把 JavaScript 或者其他高级编程语言称为人类语言,但它们也确确实实是人类语言。
因为它们是按照人类认知被设计出来的,而不是机器认知。

所以,JavaScript 引擎的工作就是接收人类语言,然后输出机器语言。
这就像电影《降临》中所描述的一样,人类试图和外星人进行交流。

图说 WebAssembly(二):JIT 编译器

电影中,人类和外星人的交流并不是通过逐个文字翻译来实现的。这两个群体有不同的世界观,这种差异也同样适用于人类和机器。

那么,人类和机器之间的“翻译”又是怎么实现的呢?

在编程领域,翻译成机器语言有两种通用的方式:解释器和编译器。

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

图说 WebAssembly(二):JIT 编译器

而对于编译器,却不是实时的,它需要提前翻译并保存起来。

图说 WebAssembly(二):JIT 编译器

这两种翻译方式各有利弊。

解释器优缺点

解释器可以快速启动并运行代码。
我们不需要等待整个编译步骤结束之后才开始运行代码。翻译一行,运行一行。

基于此,解释器看起来非常适合像 JavaScript 这样的语言。因为对于一个互联网开发者来说,快速开始并运行代码是非常重要的。

这也是为什么浏览器从一开始就使用 JavaScript 解释器的原因。

但是,当需要多次运行相同代码时,解释器的弊端就凸显出来了。
比如,在一个循环中,解释器得重复的翻译相同的代码。

编译器优缺点

与解释器相比,编译器有着相反的优缺点。

编译器会在开始时耗费比较多的时间,因为他需要经历整个编译过程。不过,一旦编译好,循环中的代码就可以跑得更快,因为它不再需要重复的翻译相同代码。

另一个不同点是,编译器有更多的时间来分析代码,然后修改代码,使它能跑得更快。这个修改过程称为优化
而解释器就不同了,它是运行时进行代码翻译的,所以它没法在这个过程中做优化。

集大成者:JIT 编译器

为了解决解释器重复翻译相同代码低效行为,浏览器开始把编译器引入进来。

不同浏览器的做法有略微不同,但是基本做法是相同的。
它们为 JavaScript 引擎新增了一个组件,称为监视器(Monitor,或者 Profiler)。
监视器的工作就是观察代码运行,然后记录代码的运行次数,以及它们使用的数据类型。

最开始时,监视器会观察解释器运行的所有代码。

图说 WebAssembly(二):JIT 编译器

如果某一处的几行代码运行了好几次,那么该处的几行代码就被标记为暖代码(warm)。
如果运行了非常多次,那么就会被标记为热代码(hot) 。

基准编译器

当一个函数被标记为暖代码,JIT 就会把它发送给基准编译器(Baseline Compiler)进行编译,并把编译结果保存下来。

图说 WebAssembly(二):JIT 编译器

函数中的每一行代码都被编译成一个存根(Stub)。这些存根在存储时,使用代码行号和变量类型作为索引。
如果监视器发现相同的代码运行使用的是相同变量类型,那么它会取出已编译好的代码来运行。

可以看出,这已经加快了运行速度。
不过,编译器还可以做得更好。它可以花点时间来分析代码,以便找出最高效的方式,也就是做优化。

基准编译器也是能够做一些优化的(下文会举例说明)。
但是它不能花费太多时间在优化上,因为我们并不希望它长时间阻塞代码运行。

优化编译器

当有些代码变成热代码,监视器就会把它发送给优化编译器(Optimizing Compiler)。优化编译器会把它编译成另一种更快版本的函数,并且保存起来。

图说 WebAssembly(二):JIT 编译器

为了生成更快的代码,优化编译器必须作出一些前提假设。

比如,如果假设使用特定构造函数创建的对象都有相同的结构,即有相同的属性名并且添加顺序也是一致的,那么优化编译器就可以基于此删除一些代码。

优化编译器会基于监视器记录的代码运行信息来作出一些判断。比如,如果在一个循环中,之前运行时某个变量一直是 true,那么它就会假设它在未来仍然是 true

当然,在 JavaScript 中,其实是没有任何保证可言的。
可能之前的 99 个对象都有着相同的结构,但是到第 100 个对象时,它仍可能会缺少某个属性。

因此,编译后的代码在运行之前需要检查原先的假设是否成立。
如果成立,那么直接运行编译后的代码;如果不成立,那么 JIT 会认为它作出了错误假设,于是它会把优化的代码废弃掉。

图说 WebAssembly(二):JIT 编译器

然后,代码的运行会返回去使用解释器运行或者采用基准编译器编译的代码。这个过程称为去优化(Deoptimization)。

通常来说,优化编译器会使得代码跑的更快。不过有时候,它也可能会导致意料之外的性能问题。
如果有一部分代码一直在优化和去优化之间切换,那么它其实比直接使用基准编译器的编译的代码还更慢。

大多数浏览器已经增加了一些限制,来及时打破这种优化/去优化的循环过程。
比如说,当 JIT 尝试了 10 次优化之后仍然发生了去优化,那么它就不再尝试对其进行优化。

优化举例:类型特定化

有很多种不同的优化方式,这里我们只举例说明其中一种,来帮助理解整个优化过程。
在众多优化方式中,类型特定化(Type Specialization)取得的优化是最明显的。

JavaScript 采用的动态类型系统使得代码在运行时需要做些额外的检查工作。
比如,对于以下代码:

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

其中的 += 操作看起来非常简单,似乎只需要进行一次操作就能完成计算。
但是,因为是动态类型,实际上进行的操作次数远不足一次那么简单。

让我们假设 arr 是一个包含 100 个整数的数组。一旦该函数被标记为暖代码,基准编译器就会为该函数中的每一个操作创建一个存根。所以 sum += arr[i] 也会对应一个存根,它会把 += 操作作为整数加法。

然而,我们并不能保证 sumarr[i] 都是整数。
因为 JavaScript 中的数据类型是动态的,所以在后续的循环中,arr[i] 可能就变成了字符串。
而整数加法和字符串连接是两种完全不同的操作,所以它们会被编译为完全不同的机器代码。

对于这种情况,JIT 的处理方式是编译成多种不同的基准存根。
如果每次调用代码都使用相同的数据类型,那么只会生成一种存根;如果每次调用使用不同的数据类型,那么会生成每种类型组合起来的存根。

也就意味着,JIT 在选择一个存根之前必须先做好多判断。

图说 WebAssembly(二):JIT 编译器

因为每一行代码在基准编译器中都会有它自己的存根集合,所以每行代码运行时 JIT 需要一直进行类型判断。因此,在该循环中的每一次遍历,它都要进行相同的类型判断过程。

图说 WebAssembly(二):JIT 编译器

如果 JIT 不需要每次都重复这些类型判断,那么代码跑起来就会更快。而这正是优化编译器所做的优化之一。

在优化编译器中,整个函数是一起编译的。所以可以把类型判断移到循环之前。

图说 WebAssembly(二):JIT 编译器

一些 JIT 对此做了更深的优化。比如,在 Firefox 中,我们把只包含整数的数组划分为特殊的数组分类。如果 arr 是这种数组,那么 JIT 就不需要检查 arr[i] 是否是一个整数了。这样的话,JIT 可以在进入循环之前就做完所有的类型判断。

结束

以上就是对 JIT 的简短介绍。
通过监视代码运行,编译热代码等方式,JIT 使得 JavaScript 代码跑的更快。这为大多数 JavaScript 应用带来了许多性能改进。

尽管做了这些优化,但是 JavaScript 的性能可能仍然无法预测。
因为做这些优化的同时,我们也给运行时增加了额外的开销,包括:

  • 优化和去优化过程
  • 监视器记录和恢复信息占用的内存
  • 用于保存基准、优化后函数的内存

不过,对于这些仍然有改进的空间,我们可以消除这些额外开销,使得性能提升更具可预测性。
而这就是 WebAssembly 所做的一件事情!

在下一篇文章中,我们将更详细介绍 WebAssembly ,以及它是如跟编译器一起工作的。

相关推荐