V8 Lite - 轻量级的 V8 引擎 (译)

本贴最后更新于 1682 天前,其中的信息可能已经物是人非

在 2018 年晚些时候,我们启动了一个全新的项目 - V8 Lite,旨在大幅度减少 V8 引擎对于内存的使用。最初这个项目被设想成一个独立于 V8 引擎的 Lite 模式,从而专门针对于那些低内存的移动或者是嵌入式设备,这些设备相较于执行速度而言,更加关心减少内存的占用。然而,在进行这项工作的过程中,我们重新意识到,我们对于 Lite 模式做的很多内存优化能够带到普通的 V8 引擎中,从而使所有 V8 引擎的用户受益。

在这篇文章中,我们将重点介绍我们在开发过程中的关键优化,以及它们在真实的工作中节省的内存!

如果你更喜欢看演讲 可以看下面的视频,否则请继续向下阅读

Lite Mode (轻量模式)

为了降低内存使用率,我们需要做的第一件事就是了解在 V8 中内存是如何被使用的,以及到底什么对象会在 V8 的堆空间中占用更多的空间。我们使用了 V8 的 内存可视化 工具来追踪一个典型的 Web 页面中堆内存的组成。

不同类型的对象在页面加载过程中的占比

在这样做的过程中,我们查明 V8 堆空间中很大一部分被非 JavaScript 执行所必须的对象所占用,不过这些对象将会用于优化 JavaScript 的执行以及处理执行过程中的异常。举几个例子: 已经优化的代码; 用于查明如何优化代码的 type feedback;用于 C ++ 和 JavaScript 对象之间进行绑定的冗余 Metadata(元数据);仅在特殊情况下(例如堆栈跟踪符号化)需要元数据;在页面加载期间只允许几次的函数的字节码。

由于上述原因,我们启动了在 V8 Lite 上的研究,通过大幅减少这些可选对象的分配,我们权衡(降低)了 JavaScript 的执行速度,并获得了更重要的内存占用降低。

大部分对于 Lite Mode 的更改都能通过改变当前 V8 引擎的配置做到,比如说关闭 V8 引擎 TurboFan 的优化编译,不过剩下的还是需要针对 V8 做特定的修改。

尤其是我们确认当 Lite Mode 不需要执行代码优化的时候,我们需要放弃收集优化代码时依赖的 type feedback 信息。当 Ignition 执行代码的时候,V8 会收集关于传递给各种操作的操作数类型的反馈,以便在之后对这些类型进行特定的优化。这些信息将会被保存在名为 feedback vectors 的空间中,并在 heap size 中占据大量的空间。Lite Mode 需要放弃这个 feedback vectors ,不过解释器和一部分内联缓存(inline-cache)所需要的 feedback vectors 仍然需要保留。所以为了实现这种无 feedback 的执行还是需要大量的代码重构才能做到。

通过关闭代码优化、取消分配 feedback vectors 以及淘汰那些较少执行的字节码等手段,在典型的 Web 页面中 V8 v7.3 版本相较于 V8 V7.1 版本减少了 22% 的内存占用。对于那些明确想用性能换取更低的内存使用的应用程序来说,这是个不错的结果。不过在工作的过程中,我们重新意识到我们可以通过一种惰性的手段来做到大部分的内存节省,并且不会影响性能。

Lazy feedback allocation (惰性反馈分配)

完全关闭 feedback vector 的分配不仅会阻止 V8 TurboFan 编译器对代码的优化,还会阻止 V8 一些通用操作的内联缓存,比如说 ignition 解释器中对象属性的加载。这样会造成 V8 的执行时间显著增加,在一个典型的 WebPage 情况下,减少了 12% 的页面加载时间,同时也增加了 120% 的 CPU 使用率。

为了在避免执行速度降低的情况下带来更多的内存节省,我们换了一种方式,我们只有在函数的字节码被执行一部分之后(目前是 1kb),才惰性的分配 feedback vector,由于大部分的函数都不会经常执行,所以我们完全可以在大部分情况下避免 feedback vector 的分配,不过我们依然会在需要的地方快速分配,以便进行代码优化,从而避免性能下降。

我们遇到的另一个难题是 feedback vector 的构成事实上是一棵树,每一个内部函数的 feedback vector 事实上会作为外部函数的 feedback vector 的一个属性存在,我们必须这么做以便让新建立的函数闭包和其他相同函数创建的闭包得到相同的 feedback vector。但是一旦引入延迟分配 feedback vector 的机制之后,我们就无法使用这棵树了,因为一旦引入延迟机制,你就无法保证这里面父子的生成顺序(原文有点不同,我按照含义简化了一下)。为了达成这个目标,我们创建一个全新 ClosureFeedbackCellArray 来维持这个树,等到该对象进入 HOT(可以认为是达成上面 1kb 的阈值 进入分配阶段)阶段后,我们再使用一个完整的 feedback vector 来代替它。

在我们的实验环境下,使用 lazy feedback 在桌面平台上没有明显的性能退化,并且在低配的移动设备上,由于垃圾收集的减少,我们甚至还看到了一些性能提升。我们已经在普通 V8,以及 V8 - Lite 中启用了 lazy feedback, 相比较我们最初的全部关闭 feedback vector 的方案相比,我们虽然稍微增加了一点内存占用,但是获得了更高额的性能提升(相当划算)。

Lazy source positions

当我们把 JavaScript 编译成字节码的时候,JavaScript 中的字符与字节码序列将会生成一个 source positions tables (源定位表)(这个应该和 source map 类似 通过建立对应关系 从而让字节码产生的错误能够定位到原始 JS 代码),但是,这些信息只会使用在错误处理或者是开发过程中 debug 的情况,所以这些信息的使用率很低。

为了避免这种浪费,我们现在不会在编译字节码期间收集 sources positions(debug 或者 profiler 情况下除外),它现在只会在进行堆栈跟踪的时候才会进行收集,举个例子,比如当调用 Error.stack 方法或者是打印错误的堆栈信息到控制台的时候。这样看起来还是会有一定的成本,因为收集 source positions 需要对函数进行重新解析和编译,但是绝大部分的网站都不会在生产环境下收集堆栈信息所以这并不会造成任何显著的性能下降。

还有一件我们必须在这项工作中做到的目标是可重复的字节码生成,这在以前是没有保证的。如果 V8 在给原始代码收集 source position 的时候生成不同的字节码,那么就有可能导致 source position 指向不同的行号,从而指向原始代码中错误的位置。

在某些情况下,V8 可以生成不同的字节码,这取决于函数是快速编译还是延迟编译,有一些信息可能会在初始的急切解析与后续的延迟编译之间丢失,这些信息的丢失在大多数情况下是良性的,例如,忘记了变量是不可变的,因此无法对其进行优化。然而,在工作中我们发现一些信息丢失确实有可能在某些情况下导致错误的代码执行。因此,我们修正了这些不匹配,并添加了检查和压力模式,以确保函数的快速和延迟编译总是产生一致的输出,这让我们对 V8 的解析和预解析的正确性和一致性有了更大的信心。

Bytecode flushing (字节码更新)

JavaScript 编译成的字节码会占据 V8 堆内存的很大一部分,这个比值通常是 15% 左右,但是有很多函数只会在初始化的时候运行,或者在编译后运行的次数很少。

因此,我们在 GC(垃圾回收)过程中增加了更新字节码的功能(如果这些函数执行的次数非常少),为了做到这点,我们开始持续跟踪字节码的生命周期,每当进行 GC(标记清除)的时候,我们增加字节码的生存时间,并在函数被执行的时候将生存时间重设为 0,一旦该字节码的超过一个老化的阈值,那么他都有资格在下次 GC 过程中被回收。如果他被回收,然后需要再次执行,那么会执行重新编译。

未完待续

名词

  • Ignition V8 引擎的核心解释器 通过将 AST 转化为字节码后运行 于 V8 5.9 版本正式投入运行 相比之前 V8 走编译器路线而言,拥有更快的启动速度,更小的内存占用

  • TurboFan 本质就是 JIT(运行时编译)的一个实现,就如同的他的名字涡轮增压,一旦介入威力惊人,通过将高频率运行的字节码进一步转化为机器语言的方法,提高代码的运行速度 跟上面的解释器一组合,就是 字节码解释器 +JIT 的黄金技术

  • heap 程序为了避免堆栈切换因为一些较大的对象放置在上下文环境中影响上下文切换速度,所以把一些较大的对象分配到堆空间中,与之相对的概念是 stack 栈空间

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...