本文为翻译,原文请查看《Is it possible to improve JavaScript “JIT” with an “AOT” pre-step?》
介绍
你对Web前端运行时性能话题感兴趣吗?特别是JavaScript部分?你是否已经应用了很多优化(懒加载、bundle chunks、tree-shaking、优化算法等),但对任然对结果不满意?
有研究表明,如果你的网页在三秒内无法加载完成,40%的访问者将离开。随着加载时间的增加,这个比例会进一步增加。——《WebAssembly实战》,Gerard Gallant
如果你和我一样,正在寻找改进前端Web应用程序运行时性能的其他方法,并且想要打破常规思维,那么,欢迎你阅读本文!
这次我决定在浏览器运行代码之前,添加一个AOT(Ahead Of Time,提前编译)编译预处理步骤的可能性。这一步的目的是减少JIT(即时编译)的时间。
AOT (Ahead Of Time) compilation pre-step (Image by the author)
诚然,JavaScript是一种动态语言,变量在运行时可能有一个或多个类型。但实际上,在开发JS代码时,你是否考虑过变量的类型是动态的?我觉得应是跟我一样——“没有”,每当我操作一个变量时,我都假设它只有一个类型。那么,为什么不提前做出这个假设,而不是即时编译时判断类型?
JavaScript是一种很棒的编程语言,但我们现在要它做的事情比它最初设计的要多——例如游戏时的重度计算,并且我们要求它运行得非常快。——《WebAssembly实战》,Gerard Gallant
让我们开始吧!✨
深入JS
浏览器内部架构概述
典型的浏览器架构长这样:
- HTML和CSS的处理是渲染引擎完成的。然而,JS的处理是在JS引擎的完成的。
- 渲染引擎有:Gecko(Firefox)、Blink(Chrome)、WebKit(Safari)。
- JavaScript引擎有:Spider Monkey(Firefox)、V8(Chrome)、JavaScriptCore(Safari)。
在本文中,我将重点关注JavaScript引擎:如果我们想要提高Web应用程序的效率,首先必须加快JavaScript程序的执行速度。
让我们继续。
JS主要特点
我们看看JavaScript的定义:
JavaScript(JS)是一种轻量级的、解释性的(即时编译的)编程语言,具有一流函数。虽然它最为人所知的是作为Web页面的脚本语言,但许多非浏览器环境也使用它,比如Node.js、Apache CouchDB和Adobe Acrobat。JavaScript是一种基于原型的、多范式的、单线程的、动态的语言,支持面向对象、命令式和声明式(如函数式编程)的风格。JavaScript|MDN (mozilla.org)
有没有引起你注意的词汇?对我来说,这些词引起了我的注意:解释性的、动态的、即时的、多范式和单线程语言。
从开发者的角度来看:
- 动态(无类型)语言可以减少类型约束和复杂性。
- 单线程简化了多线程和并发管理:没有信号量、没有同步等等。
- 解释性和即时编译减轻了开发人员对某些编译问题和错误的责任,并提高了开发和构建工具的性能(时间)。
JavaScript的另一个特点是“尽可能少地报告错误”。例如:函数不必使用声明的参数数量进行调用,允许检索未绑定的属性,可以对未声明的变量进行赋值,等等。
让我们继续揭开JavaScript的神秘面纱,来谈谈另一个重要的话题:“强制类型转换”!
简单来说,强制类型转换是指JavaScript必须自动将一个值转换为另一种类型,以便能够符合开发者潜在要求。
看个例子
// 数字转换
true will be coerced to 1
false will be coerced to 0
"" will be coerced to 0
true + true is coerced to 2
// 字符串转换
true + " blood" is coerced to "true blood"
false + " hope" is coerced to "false hope"
99 + " red balloons" is coerced to "99 red balloons"
// 布尔值转换
!0 is coerced to true
!!0 is coerced to false
!10 is coerced to false
!!10 is coerced to true
!!"banana" is coerced to true
!!"" is coerced to false
是不是很神奇?
所有这些特性都简化了JS的学习曲线,开发者更专注于语言而非编译和工具。然而,这种简单性是要付出代价的!从运行时的角度来看,这些特性非常具有挑战性和复杂性!
为什么JavaScript需要即时编译(JIT)?
JavaScript的简单性只是表面上的。在内部,它的编译(解释)非常困难。事实上,JavaScript源代码接受的可能解释比其他语言要多得多,特别是因为它是无类型的、动态的。
看下面这个例子:
for (i = 0; i < a.length; i++) {
sum += a[i++];
}
对于C或者JAVA来说,这段代码很确定:i 只能是数字,a 是一个数组(数组每一项都是数字),sum也是个数字。但对JS,它有好几种可能:
1️⃣
const a = [1, 2, 3];
let i = 0;
let sum = 0;
for (i = 0; i < a.length; i++) {
sum += a[i++];
}
console.log(sum) // 4
2️⃣
const a = [1, 2, 3];
let i = "0";
let sum = 0;
for (i = 0; i < a.length; i++) {
sum += a[i++];
}
console.log(sum) // 4
3️⃣
const a = {length: 3, 0: 1, 1: 2, 2: 3};
let i = 0;
let sum = 0;
for (i = 0; i < a.length; i++) {
sum += a[i++];
}
console.log(sum) // 4
4️⃣
const a = [1, 2, 3];
let i = "0";
let sum = "0";
for (i = 0; i < a.length; i++) {
sum += a[i++];
}
console.log(sum) // "013"
编译器很难预见所有可能性,JS太动态了!这些困难阻止了传统的静态编译器为如此灵活的语言提供高效的代码。
这时候你可能会问我:如果使用TypeScript呢?
嗯,TypeScript类型有完全不同的目的:TypeScript静态类型是“编译时”属性,是独立于运行时的。它们帮助程序员写出更好的代码,但它们并不直接帮助性能优化。这是因为JavaScript是动态的,动态特性是“运行时”的特性。
因此,完全使用AOT(Ahead Of Time)方法是不可能的,否则编译器性能将很差,并且代码库将庞大且难以维护。因此需要解释器。但是,使用纯JavaScript解释器会带来很高的性能损耗,因为我们只是将复杂性从编译时移动到运行时。
总结一下:
- 解释很容易实现,但性能很差。
- 编译提供更好的性能,但实现成本高。
那我们该怎么办?
现在是引入一些JIT(即时)的魔法的时候了
。使用JIT,代码在程序运行时即时编译。
与静态编译器相反,在执行之前将所有内容转换为机器代码的情况下,JIT编译器在程序执行过程中持续进行转换,同时经常缓存已编译的本机代码块,从而减少了被再次转换为相同本机代码的代码片段。啊哈!
JIT编译器准确地知道用户计算机上安装的CPU,因此可以生成特定于用户计算机的本机指令。又一个啊哈!
过去十年来,改善JIT编译程序的性能一直受到持续关注,特别是对于浏览器引擎。让我们来看一下!
JavaScript引擎的工作流程
?JavaScript引擎分析(解析)源代码并将其转换为抽象语法树(AST)。
?基于这个AST,解释器可以开始工作,并快速生成未经优化的字节码。
?同时,引擎执行JavaScript代码(正如我上面解释的,使用JIT,代码在程序运行时即时编译)。
?为了加快执行速度,字节码可以与分析数据一起发送给优化编译器。
?优化编译器根据其拥有的分析数据进行一些假设,然后生成高度优化的机器代码。
?优化编译器需要一些时间,但最终会生成高度优化的机器代码。
?如果其中一个假设被证明不正确,优化编译器会进行反优化并返回到解释器。
大致流程如下:
首先进行快速的代码执行,没有任何优化;然后进行可能耗CPU和时间的第二个优化步骤。
下面介绍本文的重点
现有的研究
在研究JavaScript的AOT时,我有机会发现了这项研究:Augmenting JavaScript JIT with Ahead-of-Time Compilation。果然我不是唯一一个感觉到JIT的限制的人!
他们开发了一个JavaScript程序的Ahead-of-Time编译框架。它是在JavaScriptCore(JSC)中实现的。
该框架由两个组件组成:
- 命令行编译器,将源JavaScript程序编译成压缩的二进制包,包括字节码(JSC IR)和可选的本机代码(由JSC的基线JIT生成)。
- 第二个组件是打补丁的JSC引擎,具有加载和执行编译器生成的二进制包的能力。
Ahead-of-Time编译框架完全支持ECMA-262标准。此外,当使用预编译的字节码执行时,它为SunSpider、v8-v6和Kraken基准测试分别提供了1%、3%和16%的加速。
然而,二进制文件的大小比原始的JavaScript源代码大1.2到4.4倍。保存生成的本机代码会导致位大小增加2.5到5倍,但由于大部分JIT生成的代码需要重新链接,所以没有额外的加速。《增强JavaScript JIT与Ahead-of-Time编译》
另一项研究也在同样的背景下进行:《Reuse of JIT compiled code based on binary code patching in JavaScript engine》。
我还遇到了这个项目:《EchoJS》。
结论
挺有意思的研究!探索JavaScript执行的世界还挺奇妙!
我们看到,JavaScript引擎通常使用多个层次:低级别的JIT生成效率较低的代码,但几乎可以立即启动,而高级别的JIT旨在为热点代码生成高效的代码,但代价是较长的编译时间。然而,尽管JS引擎和高级优化机制取得了进展,JIT编译器在执行复杂优化方面仍然存在限制,在运行程序之前需要很长时间来编译。
另一个问题是优化是基于代码特征(热点函数)触发的。JavaScript引擎需要多次监视代码,才能将其编译成机器代码,因为JavaScript也是一种动态编程语言。因此,非确定性或非单态函数(单态:一个输入类型,多态:两到四个输入类型,超多态:五个或更多输入类型)对于优化器(如TurboFan)没有帮助。
关于添加AOT步骤或利用WebAssembly的可能性的研究非常乐观,并为优化开辟了新的机会。这就是为什么我决定继续深入研究这种方法,在我的下一篇文章中,我将介绍编译为WebAssembly的编程语言,并将结果与JavaScript程序进行比较。