JavaScript 的工作原理:优化 V8 编译器以提高效率
作者:Alvin Wan✏️
了解 Javascript 的工作原理是编写高效 Javascript 的关键。
忘记微不足道的毫秒改进:错误使用对象属性可能会导致简单的一行代码的速度降低 7 倍。
鉴于 Javascript 在软件堆栈各个层级的普遍性(例如MEAN或替代品1、2、3 ),轻微的速度下降可能会困扰您的基础设施的任何层级(如果不是所有层级)— — 而不仅仅是您网站的菜单动画。
有很多方法可以编写更高效的 Javascript,但在本文中,我们将重点介绍编译器友好的 Javascript 优化方法,这意味着源代码使编译器优化变得简单而有效。
我们将讨论范围缩小到 V8——支持Electron、Node.js和Google Chrome 的JavaScript 引擎。为了理解编译器友好的优化,我们首先需要讨论 JavaScript 是如何编译的。
Javascript 在 V8 中的执行分为三个阶段:
- 源到语法树:解析器从源生成抽象语法树( AST )
- 语法树到字节码: V8 的解释器Ignition根据语法树生成字节码。请注意,此字节码生成步骤在 2017 年之前不存在。2017 年之前的 V8 版本在此处描述。
- 字节码到机器码: V8 的编译器TurboFan从字节码生成图表,用高度优化的机器码替换字节码的部分内容
第一阶段超出了本文的范围,但第二和第三阶段对编写优化的 Javascript 有直接影响。
我们将讨论这些优化方法,以及您的代码如何利用(或滥用)这些优化。通过了解 JavaScript 执行的基础知识,您不仅可以理解这些性能建议,还可以学习如何发现一些您自己的优化建议。
实际上,第二阶段和第三阶段是紧密耦合的。这两个阶段都采用即时 (JIT) 范式运行。为了理解 JIT 的重要性,我们将研究以往将源代码转换为机器码的方法。
即时(JIT)模式
要执行任何程序,计算机必须将源代码翻译成机器可以运行的机器语言。
有两种方法可以完成此翻译。
第一个选项涉及使用解释器。解释器可以有效地逐行翻译和执行。
第二种方法是使用编译器。编译器会在执行之前立即将所有源代码翻译成机器语言。每种方法都有其适用之处,并分别列出其优缺点。
解释器的优缺点
解释器使用读取-求值-打印循环(REPL)进行操作——此方法具有许多有利的特性:
- 易于实施和理解
- 立即反馈
- 更适宜的编程环境
然而,这些好处是以执行速度慢为代价的,因为 (1) 与运行机器代码相比,eval 的开销很大,以及 (2) 无法跨程序的各个部分进行优化。
更正式地说,解释器在处理不同的代码段时无法识别重复的工作。如果你通过解释器运行同一行代码 100 次,解释器就会翻译并执行同一行代码 100 次——无谓地重新翻译了 99 次。
总而言之,解释器启动简单、快捷,但执行速度较慢。
编译器的优缺点
相比之下,编译器在执行之前会一次性翻译所有源代码。
随着复杂性的增加,编译器可以进行全局优化(例如,为重复的代码行共享机器码)。这赋予了编译器相对于解释器的唯一优势——更快的执行时间。
本质上,编译器很复杂,启动缓慢,但执行速度很快。
即时编译
即时编译器试图结合解释器和编译器的优点,使翻译和执行都变得快速。
其基本思想是尽可能避免重新翻译。首先,分析器只需通过解释器运行代码即可。在执行过程中,分析器会跟踪运行次数较少的“热”代码段和运行次数很多的“热”代码段。
JIT 将热代码段发送到基线编译器,尽可能重用已编译的代码。
JIT 还会将热门代码段发送给优化编译器。该编译器利用解释器收集的信息来 (a) 做出假设,以及 (b) 基于这些假设进行优化(例如,对象属性始终以特定顺序出现)。
然而,如果这些假设无效,优化编译器就会执行反优化,这意味着它会丢弃优化的代码。
优化和去优化周期的代价是昂贵的,并由此产生了一类下面详细描述的 Javascript 优化方法。
JIT 还会引入与存储优化的机器码和分析器执行信息相关的内存开销。虽然优化的 JavaScript 无法解决这个问题,但这种内存开销正是 V8 解释器 Ignition 的灵感来源。
V8 编译
V8 的点火和 TurboFan 执行以下功能:
- Ignition 将 AST 转换为字节码。然后执行字节码序列,并通过内联缓存收集反馈。这些反馈由 (a) Ignition 本身用于后续解释,以及 (b) TurboFan 用于推测优化。
- TurboFan 根据反馈,通过将字节码转换为特定于体系结构的机器代码来推测性地优化字节码。
点火
JIT 编译器存在内存开销。Ignition 通过实现三个目标(幻灯片)来解决这个问题:减少内存使用量、缩短启动时间以及降低复杂性。
所有三个目标都是通过将 AST 编译为字节码并在程序执行期间收集反馈来实现的。
- 此字节码被用作可信来源,无需在编译期间重新解析 JavaScript。这意味着,有了字节码,TurboFan 的去优化不再需要原始源代码。
- 举一个基于程序执行反馈进行优化的例子,**内联缓存** 允许 V8 优化对具有相同类型参数的函数的重复调用。具体来说,内联缓存存储函数输入的类型。类型越少,需要的类型检查就越少。减少类型检查的次数可以显著提升性能。
AST 和字节码都暴露给 TurboFan 优化编译器。
涡轮风扇
V8 引擎于 2008 年发布,最初直接将源代码编译为机器码,跳过了中间字节码表示。根据谷歌伦敦主题演讲(Mcllroy,2016 年 10 月),V8 发布时的速度比竞争对手快 10 倍。
然而,TurboFan 如今接受 Ignition 的中间字节码,速度比 2008 年快 10 倍。同一主题演讲介绍了 V8 编译器的过去迭代及其缺点:
- 2008 年 – Full-Codegen
- 2010 年 – 曲轴
- 使用类型反馈(幻灯片)和去优化(幻灯片)优化 JIT 编译器
- 缺点:无法扩展到现代 Javascript,严重依赖于去优化,有限的静态类型分析,与 Codegen 紧密耦合,移植开销高
- 2015 年 – TurboFan
- 使用类型和范围分析、节点海优化 JIT 编译器
根据Google 慕尼黑技术讲座(Titzer,2016 年 5 月),TurboFan 针对峰值性能、静态类型信息使用、编译器前中后端分离以及可测试性进行了优化。这最终带来了一项关键贡献,即所谓的“节点之海”(或“汤”)。
在节点的海洋中,节点代表计算,边代表依赖关系。
与控制流图 (CFG)不同,大量的节点放宽了大多数操作的执行顺序。与 CFG 类似,有状态操作的控制边和效果边会在必要时限制执行顺序。
Titzer 进一步细化了此定义,使其成为一个节点集合,其中控制流子图进一步放宽。这提供了许多优势——例如,避免了冗余代码的消除。
图形简化应用于此节点汤,采用自下而上或自上而下的图形转换。
TurboFan 流水线遵循 4 个步骤将字节码转换为机器码。请注意,以下流水线中的优化是根据 Ignition 收集的反馈执行的:
- 将程序表达为 Javascript 运算符(例如 JSAdd)
- 将程序表达为中间运算符(VM 级运算符;与数字表示无关,例如 NumberAdd)
- 将程序表达为机器运算符(对应于机器指令,例如 Int32Add)
- 使用顺序约束来安排执行顺序。创建一个传统的CFG。
TurboFan 的在线 JIT 风格编译和优化完成了 V8 从源代码到机器码的转换。
如何优化你的 JavaScript
TurboFan 的优化功能通过减轻不良 JavaScript 代码的影响来提升 JavaScript 的最终性能。然而,了解这些优化机制可以进一步提升速度。
以下是利用 V8 优化提升性能的 7 个技巧。前四个技巧侧重于减少去优化。
技巧 1:在构造函数中声明对象属性
更改对象属性会导致新的隐藏类。以下是来自2012 年 Google I/O 大会的示例。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
var p1 = new Point(11, 22); // hidden class Point created
var p2 = new Point(33, 44);
p1.z = 55; // another hidden class Point created
如你所见,p1
现在p2
有了不同的隐藏类。这阻碍了 TurboFan 的优化尝试:具体来说,任何接受该Point
对象的方法现在都不再优化了。
所有这些函数都针对两个隐藏类进行了重新优化。对于对象形状的任何修改都是如此。
技巧 2:保持对象属性顺序不变
改变对象属性的顺序会产生新的隐藏类,因为排序包含在对象形状中。
const a1 = { a: 1 }; # hidden class a1 created
a1.b = 3;
const a2 = { b: 3 }; # different hidden class a2 created
a2.a = 1;
上面的例子,现在a1
也a2
拥有不同的隐藏类。修复顺序允许编译器重用相同的隐藏类,因为添加的字段(包括顺序)用于生成隐藏类的 ID。
技巧 3:修复函数参数类型
函数会根据特定参数位置的值类型更改对象形状。如果此类型发生变化,函数就会被取消优化并重新优化。
在看到四种不同的物体形状后,该函数变得超形态,因此 TurboFan 不会尝试优化该函数。
举以下面的例子。
function add(x, y) {
return x + y
}
add(1, 2); # monomorphic
add("a", "b"); # polymorphic
add(true, false);
add([], []);
add({}, {}); # megamorphic
TurboFan 在 L9 之后将不再优化add
。
技巧 4:在脚本范围内声明类
不要在函数作用域内定义类。以下示例说明了这种病态情况:
function createPoint(x, y) {
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
return new Point(x, y);
}
function length(point) {
...
}
每次createPoint
调用该函数时,Point
都会创建一个新的原型。
每个新原型都对应一个新的物体形状,因此length
函数会通过每个新点看到一个新的物体形状。
与以前一样,在看到 4 种不同的物体形状后,该函数变得超形态,并且 TurboFan 不会尝试进行优化length
。
通过放置class Point
在脚本范围内,我们可以避免每次createPoint
调用时都创建新的对象形状。
下一个提示是 V8 引擎的一个怪癖。
提示 5:使用for ... in
这是 V8 发动机的一个怪癖,该功能包含在原始的 Crankshaft 中,后来移植到 Ignition 和 Turbofan。
该for…in
循环比函数迭代、带箭头函数的函数迭代和Object.keys
for 循环快 4-6 倍。
以下是对以前的神话的 2 个驳斥,由于现代 V8 的变化,这些神话已不再适用。
提示 6:不相关的字符不会影响性能
Crankshaft 以前使用函数的字节数来确定是否内联函数。然而,TurboFan 构建在 AST 之上,并使用 AST 节点数来确定函数的大小。
因此,空格、注释、变量名长度和函数签名等不相关的字符不会影响函数的性能。
提示 7:Try/catch/finally 不会造成破坏
Try 块以前容易出现代价高昂的优化-去优化循环。不过,现在 TurboFan 在块内调用函数时不再会出现明显的性能损失try
。
结论
总之,优化方法通常集中于减少去优化和避免无法优化的超态函数。
通过了解 V8 引擎框架,我们可以进一步推导出上面未列出的其他优化方法,并尽可能地复用这些方法以利用内联。现在,您已经了解了 JavaScript 编译及其对日常 JavaScript 使用的影响。
编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本。
插件:LogRocket,一个用于 Web 应用的 DVR
LogRocket是一款前端日志工具,可让您重播问题,就像它们发生在您自己的浏览器中一样。您无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 允许您重播会话以快速了解问题所在。它可与任何应用程序完美兼容,无论使用哪种框架,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的更多上下文。
除了记录 Redux 操作和状态之外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
免费试用。
JavaScript 的工作原理:优化 V8 编译器以提高效率一文首先出现在LogRocket 博客上。
鏂囩珷鏉ユ簮锛�https://dev.to/bnevilleoneill/how-javascript-works-optimizing-the-v8-compiler-for-efficiency-16m1