去年年底,V8 团队启动了一个名为 V8 Lite 的项目,旨在大幅降低 V8
的内存使用率。最开始,团队准备把 V8 Lite 作为 V8
的独立模式,专门用于低内存的移动设备与嵌入式设备,因为这些设备更关注的是减少内存使用而不是执行速度。

理解JavaScript的工作原理是写出高效JavaScript代码的关键。忘记那些无关紧要的毫秒级改进:错误地使用对象属性可能导致简单的一行代码速度降低7倍。

在这个项目研发的过程中,开发团队发现专门为这个 Lite
模式所做的内存优化其实也可以迁移到原来的 V8 上,直接两开花。V8
团队近日发表了一个文章,就详细分享了在构建 V8 Lite
的过程中将一些关键的优化部分带到现有 V8 上的过程,以及在实际工作负载中对
V8 性能表现的影响。下边简要介绍一下。

考虑到JavaScript在软件堆栈所有级别中的普遍性,即使不是所有级别的基础设施,也可能会出现微不足道的减速,而不仅仅是网站的菜单动画。有许多的方法来编写高效的JavasScript代码,但在这篇文章里面,我们将着重介绍编译器友好的优化方法,这意味着源代码使编译器优化变得简单有效。我们将把讨论范围缩小到V8,即支持electron、node.js和google
chrome的JavaScript引擎。为了理解编译器友好的优化,我们首先需要讨论JavaScript是如何编译的。

Lite Mode

JavaScript在V8中的执行可以分为三个阶段:

分析了 V8 如何使用内存以及哪些对象类型占 V8 堆大小的比例很大之后,V8
团队发现,V8 堆的很大一部分专门用于对 JavaScript
执行来说不必要的对象,比如用于优化 JavaScript
执行和处理异常情况。具体来说比如优化代码;用于确定如何优化代码的类型反馈;用于
C++ 和 JavaScript 对象之间绑定的冗余元数据等。

源代码到抽象语法树:解析器将源代码生成抽象语法树(AST)抽象语法树到字节码:V8的解释器Ignition从抽象语法树生成字节码。请注意,生成字节码这一步在2017年以前是没有的。字节码到机器码:V8的编译器TurboFan从字节码生成一个图,用高度优化的机器代码替换字节码的部分。

图片 1

第一个阶段超出了本文的范围,但是第二个和第三个阶段对编写优化的JavaScript有直接的影响。

所以团队从这一点入手,想通过大幅减少这些可选对象的内存分配来提高内存使用。同时团队提出了
V8 的 Lite Mode。

我们将讨论这些优化方法以及代码如何利用这些优化。通过了解JavaScript执行的基础知识,您不仅可以理解这些性能方面的建议,还可以学习如何发现自己的一些优化点。

图片 2

实际上,第二和第三阶段是紧密耦合的。这两个阶段在即时范式中运行。为了理解JIT的重要性,我们将研究以前将源代码转换为机器代码的方法。

通过配置现有的 V8 设置可以直接应用一些 Lite Mode 的优化,例如禁用 V8 的
TurboFan 优化编译器,但是现有 V8 想支持其它 Lite Mode
优化则需要更多的考虑。

Just-in-Time (JIT) 范式

比如在 Ignition 解释器中执行代码时,V8
收集有关传递给各种操作(例如,+o.foo)的操作数类型的反馈,以便为以后的优化定制这些类型。此信息存储在反馈向量中,这些向量占
V8 堆内存使用量的很大一部分。Lite Mode
不优化代码,所以可以直接不去分配这些反馈向量,但是 V8
的内联缓存基础结构的解释器期望反馈向量可用,因此需要相当多的重构才能支持
Lite Mode 这种无反馈执行。

为了执行任意一段程序,计算机必须将源代码转换成机器可以运行的代码。

Lite Mode 在 V8 v7.3 中推出,与 V8 v7.1
相比,通过禁用代码优化、不分配反馈向量并执行很少执行的字节码老化,典型网页堆大小减少
22%。

有两种方法可以进行转换。

同时在这个过程中,团队还发现,可以通过使 V8 更加“Lazy”来实现 Lite Mode
的大部分内存节省,而不会影响性能。

第一种选择是使用解释器。解释器可以有效地逐行翻译和执行。

Lazy feedback allocation 

第二种方法是使用编译器。编译器在执行之前立即将所有源代码转换为机器语言。

完全禁用反馈向量分配不仅会阻止 V8 的 TurboFan 编译器优化代码,还会阻止
V8 执行常见操作的内联缓存,例如 Ignition
解释器中的对象属性加载。因此,这样做会导致 V8
的执行时间显著回退,页面加载时间减少 12%,典型交互式网页方案中 V8
使用的 CPU 时间增加 120%。

下面,我们将阐述两种方法的优点和缺点。

为了在没有这些回退的情况下将大部分内存节省带到常规 V8
中,开发团队设计了在函数执行了一定量的字节码(目前为
1KB)之后,延迟分配反馈向量。由于大多数函数不经常执行,因此在大多数情况下避免使用反馈向量分配,但会在需要的地方快速分配它们以避免性能回退,并仍然允许优化代码。

解释器的优点、缺点

使用这种方法,会产生另一个问题。反馈向量会形成树结构,内部函数的反馈向量被保留为外部函数的反馈向量中的条目。这是必要的,以便新创建的函数闭包接收与为同一函数创建的所有其它闭包相同的反馈向量数组。在延迟分配反馈向量的情况下,无法使用反馈向量来形成这棵树,因为无法保证外部函数会在内部函数分配其反馈向量之前就对其进行分配。

解释器使用read-eval-print loop(REPL,交互式解释器)的方式工作 ——
这种方式有许多的优点:

如下图,为了解决这个问题,开发团队创建了一个新的
ClosureFeedbackCellArray 来维护这个树,当一个函数的
ClosureFeedbackCellArray 变为 Hot 时将其与一个完整的 FeedbackVector
交换掉。

易于实现和理解及时反馈更合适的编程环境

图片 3

然而,这些好处是以缓慢执行为代价的:

实验显示桌面延迟反馈没有出现性能回退,而在移动端,由于垃圾回收减少,性能有所提升,因此
V8 所有版本中都启用了延迟反馈分配。

eval的开销,而不是运行机器代码。

Lazy source positions

无法跨程序的对各个部分进行优化。

从 JavaScript 编译字节码时,会生成源位置表,将字节码序列绑定到
JavaScript
源代码中的字符位置。但是,仅在表示异常或执行开发人员任务(如调试)时才需要此信息,因此很少使用。

更正式地说,解释器在处理不同的代码段时不能识别重复的工作。如果你通过解释器运行同一行代码100次,解释器将翻译并执行同一行代码100次,没有必要地重新翻译了99次。

为了避免这种浪费,现在编译字节码而不收集源位置(假设没有附加调试器或分析器)。仅在实际生成堆栈跟踪时收集源位置,例如在调用
Error.stack
或将异常的堆栈跟踪打印到控制台时。这需要一些成本,因为生成源位置需要重新分析和编译函数,但是大多数网站不会在生产中对堆栈跟踪进行符号化,因此不会看到什么性能影响。

总结一下,解释器简单、启动快,但是执行慢。

Bytecode flushing

编译器的优点、缺点

从 JavaScript 源编译的字节码,包括相关的元数据占用了大量的 V8
堆空间,通常约为
15%。但是有许多函数只在初始化期间执行,或者在编译后很少使用。所以,V8
针对最近没有执行的情况,添加了在垃圾回收期间支持从函数中刷新已编译字节码的功能。

编译器会在执行前翻译所有的源代码。

具体机制是,跟踪函数字节码的 age,每次主要(mark-compact)垃圾回收 age
都递增,并在执行函数时将其重置为零。任何超过老化阈值的字节码都可以被下一次垃圾回收回收,如果它被回收然后再次执行,则会重新编译。

随着复杂性的增加,编译器可以进行全局优化。这为编译器提供了比解释器唯一的优势
—— 更快的执行时间。

这么设计的难点在于确保字节码仅在不再需要的情况下才可以被刷新。例如,如果函数
A 调用另一个长时间运行的函数 B,函数 A
可能会在它仍然在堆栈上时老化。这时候我们并不想刷新函数 A
的字节码,即使它达到了老化阈值,因为还需要在长时间运行的函数 B
返回时返回它。

总结一下,编译器是复杂的、启动慢,但是执行快。

解决这个问题的方法是,当字节码达到其老化阈值时,将字节码视为弱保持状态(weakly
held),但是在堆栈或其它地方对字节码的任何引用将强烈保持(strongly
held),并且只在没有强链接的情况下才可以刷新代码。

即时编译(JIT)

除了刷新字节码,同时还刷新与这些刷新函数相关的反馈向量。但是,无法在与字节码相同的
GC
周期内刷新反馈向量,因为它们不会被同一对象保留。字节码由本地上下文独立的
SharedFunctionInfo 保留,而反馈向量由本地上下文相关的 JSFunction
保留。因此,会在随后的 GC 循环中刷新反馈向量。

即时编译器尝试结合了解释器和编译器的优点,使代码转换和执行都变得更快。

图片 4

基本思想是避免重复转换。首先,探查器会通过解释器先跑一遍代码。在代码执行期间,探查器会跟踪运行几次的热代码段和运行很多次的热代码段。

Additional optimizations

JIT将热代码片段发送给基线编译器,尽可能的复用编译后的代码。

此外开发团队还通过减小 FunctionTemplateInfo 对象的大小和对 TurboFan
优化代码进行去优化等方面的优化减少了内存使用。

JIT同时将热代码片段发送给优化编译器。优化编译器使用解释器收集的信息来进行假设,并且基于这些假设进行优化。

Results

但是,如果这些假设无效,优化编译器将执行 去优化,丢弃优化的代码。

目前已经在 V8 的最新七个版本中发布了上述优化,通常它们会先应用在 Lite
Mode 中,然后再进入 V8 的默认配置。

优化和去优化的过程是昂贵的。由此产生了一类JavaScript的优化方法,下面将详细描述。

经过测试,在一系列典型网站上将 V8 堆大小平均减少了 18%,相当于低端
AndroidGo 移动设备平均减少 1.5 MB。

JIT需要存储优化的机器代码和探查器的执行信息等,自然会引入内存开销。尽管这一点无法通过优化的JavaScript来改善,但激发了V8的解释器。

通过禁用功能优化,Lite Mode 可以以一定的开销进一步为 JavaScript
执行吞吐量提供内存节省。平均而言,Lite Mode 可节省 22%
的内存,有些页面可节省高达 32% 的内存。这相当于 AndroidGo 设备上 V8
堆大小减少了 1.8 MB。

V8的编译

图片 5

V8的解释器和编译器执行以下功能:

图片 6

解释器将抽象语法树转换为字节码。字节码队列随后会被执行,并且通过内联缓存收集反馈。这些反馈会被解释器本身用于随后的解析,同时,编译器会利用这些反馈来做推测性的优化。编译器根据反馈将字节码转换为特定于体系结构的机器码,从而推测性地优化字节码。V8的解释器

具体分析每一种优化技术带来的影响,结果如下:

  • Ignition

图片 7

JIT编译器显示了开销内存消耗。Ignition通过实现三个目标来解决这个问题:减少内存使用、减少启动时间和降低复杂性。

完整优化介绍查看原博客:

这三个目标都是通过将AST转换为字节码并在程序执行期间收集反馈来实现的。

字节码被当做源代码对待,省去了在编译期间重新解析JavaScript的需要。这意味着使用字节码,TurboFan的去优化过程不再需要原始的代码了。作为基于程序执行反馈的优化示例,内联缓存允许V8优化对具有相同类型参数的函数的重复调用。具体来说,内联缓存存储函数的输入类型。类型越少,需要的类型检查就越少。减少类型检查的数量可以显著提高性能。

(文/开源中国)    

AST和字节码都会暴露给TurboFan。

V8的编译器 – TurboFan

在2008年发布时,V8引擎最初直接将源代码编译为机器代码,跳过了中间字节码表示。在发布时,V8就比竞争对手快了10倍。

然而,到今天,TurboFan接受了Ignition的字节码,比它发布的时候快了10倍。V8的编译器经过了一系列的迭代:

2008 – Full-Codegen

具有隐藏类和内联缓存,快速遍历AST的编译器缺点:无优化的即时编译

2010 – Crankshaft

使用类型反馈和去优化,优化即时编译器。缺点:
不能扩展到现代JavaScript,严重依赖去优化,有限的静态类型分析,与Codegen紧密耦合,高移植开销

2015 – TurboFan

用类型和范围分析优化即时编译器

根据Google慕尼黑技术讲座,TurboFan优化了峰值性能、静态类型信息使用、编译器前端、中间和后端分离以及可测试性。最终沉淀出一个关键的贡献:”节点海”。

在节点海中,节点表示计算,变表示依赖关系。

与控制流图不同的是,节点海可以放宽大多数操作的评估顺序。与CGF一样,有状态操作的控制边和效果边在需要时会约束执行顺序。

Titzer进一步完善了这个定义,使之成为一个节点汤,其中控制流子图进一步放宽。这提供了许多优点—例如,这避免了冗余代码的消除。

通过自下而上或自上而下的图转换,图缩减被应用于这一系列节点。

TurboFan遵循4个步骤将字节码转换为机器码。请注意,以下管道中的优化是根据Ignition的反馈执行的。

将程序表示为JavaScript操作符。将程序表示为中间运算符。将程序表示为机器操作符。使用顺序约束安排执行顺序。创建一个传统的控制流图。

TurboFan的在线JIT风格的编译和优化意味着 V8从源代码到机器代码的转换
结束了。

如何优化你的JavaScript

TurboFan的优化通过减轻糟糕的JavaScript的影响来提高JavaScript的网络性能。然而,了解这些优化可以提供进一步的加速。

下面是利用V8中的优化来提高性能的7个技巧。前四个重点是减少去优化。

Tip1: 在构造函数中声明对象属性

更改对象属性会产生新的隐藏类。以google i/o 2012中的以下示例为例。

class Point { constructor(x, y) { this.x = x; this.y = y; }}var p1 = new Point(11, 22); // hidden class Point createdvar p2 = new Point(33, 44);p1.z = 55; // another hidden class Point created

正如你所见,p1和p2现在有不同的隐藏类了。这阻碍了TurboFan的优化尝试:具体来说,任何接受Point对象的方法现在都是去优化的。

所有这些函数都使用两个隐藏类重新优化。对对象形状的任何修改都是如此。

Tip2: 保持对象属性不变

更改对象属性的顺序会导致新的隐藏类,因为对象形状中是包含顺序的。

const a1 = { a: 1 }; # hidden class a1 createda1.b = 3;const a2 = { b: 3 }; # different hidden class a2 createda2.a = 1;

上面的代码中,a1和a2有不同的隐藏类。修复顺序允许编译器重用同一个隐藏类。因为添加的字段用于生成隐藏类的id

Tip3:修复函数参数类型

函数根据特定参数位置的值类型更改对象形状。如果此类型发生更改,则函数将去优化并重新优化。

在看到四种不同的对象形状后,该函数会变成megamorphic,TurboFan将不会再尝试优化这个函数。

看下面这个例子:

function add(x, y) { return x + y}add(1, 2); # monomorphicadd("a", "b"); # polymorphicadd(true, false);add([], []);add({}, {}); # megamorphic

第9行过后,TurboFan将不会再优化add这个函数。

Tip4:在脚本作用域中声明类

不要在函数作用域中声明类。以下面这个例子为例:

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函数都会看到一个新的point的对象形状。

跟之前一样,当看到4个不同的对象形状的时候,函数会变得megamorphic,TurboFan将不会再尝试优化

length函数。

在脚本作用域中声明class
Point,我们可以避免每一次调用createPoint的时候,生成不同的对象形状。

下一个tip是V8引擎里的奇淫巧技。

Tip5:使用for…in

这是V8引擎中的一个怪异行为。这一特性之前包含在最初的Crankshaft里面,后来被移植到了Ignition
and Turbofan.

for…in循环比函数迭代、带箭头函数的函数迭代和for循环中的object.keys快4-6倍。

接下来两个Tip是对之前两种说法的反驳。由于现代V8引擎的改变,这两种说法已经不成立了。

Tip6:无关字符不影响性能

Crankshaft过去是使用一个函数的字节数来决定是否内联一个函数的。而TurboFan是建立在AST上的,他使用AST节点的数量来决定函数的大小。

因此,无关的字符,比如空白,注释,变量名长度,函数签名等,不会影响函数的性能。

Tip7:Try/catch/finally 不是毁灭性的

Try代码块以前容易出现高昂的优化-去优化的周期。如今,当在Try块中调用函数时,turbofan不再显示出显著的性能影响。

结论

总之,优化方法通常集中在减少去优化和避免不可优化的megamorphic函数上。

通过对V8引擎框架的理解,我们还可以推断出上面没有列出的其他优化方法,并尽可能重用方法来利用内联。现在您已经了解了JavaScript编译及其对日常JavaScript使用的影响。

原文链接:原文标题:How JavaScript works: Optimizing the V8 compiler
for efficiency本文首发于公众号:符合预期的CoyPan