V8 Lite - 轻量级的V8引擎 [译]

Posted by Yinode on Friday, September 13, 2019

TOC


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

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

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

Youtube 地址

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过程中被回收。如果他被回收,然后需要再次执行,那么会执行重新编译。

确保函数只有在不需要的时候才被淘汰是一个技术挑战,一个实例,假设函数 A 内部调用了另一个运行时间非常长的内部函数 B,这时候很可能在 A 还在堆栈上的时候,就到了老化阈值 ,虽然这时候 A 已经到达老化阈值,但事实上我们并不想回收他,因为在 B 返回的时候,我们还需要继续运行函数 A , 这里再次发生重新编译显然是一种浪费。因此,我们把函数达到老化阈值的时候视为弱引用,并且所有在堆栈中的函数,我们都视为强引用,我们只会淘汰那些处于弱引用的函数。

除了淘汰字节码,我们也会淘汰与这个函数相关的 feedback vector , 但是我们无法在同一个垃圾回收周期中一起回收字节码和feedback vector,因为他们与不同的对象相关 - 字节码依赖于 原生上下文不相关(native-context independent)的 sharedFunctionInfo 中,而 feedback vector则依赖于原生上下文相关(native-context dependent)的JSFunction中,因此我们在下次 GC 周期再回收feedback vector

> 老化的函数在经过两次 GC 后的对象布局

额外的优化

除了这些较大的工程之外,我们还发现并解决了一些性能低下的问题。

首先是减小FunctionTemplateInfo对象的大小。这些对象用于存储函数模板的内部元数据,这些元数据用于支持嵌入程序(如Chrome),以提供函数的c++回调实现,这些函数可以通过JavaScript代码调用。为了实现DOM Web api, Chrome引入了很多函数模板,因此FunctionTemplateInfo对象增加了V8 s堆大小。在分析了functiontemplate的典型用法之后,我们发现FunctionTemplateInfo对象上的11个字段中,只有3个通常设置为非默认值,因此,我们分割FunctionTemplateInfo对象,以便将稀有字段存储在一个侧表中,只有在需要时才按需分配。

第二个优化与如何从涡扇优化代码中去优化有关。由于TurboFan执行投机性优化,如果某些条件不再适用,它可能需要回到解释器(去优化)。每个去优化点都有一个id,它使运行时能够确定应该将执行返回到解释器中字节码的哪个位置,以前,这个id是通过让优化后的代码跳转到大型跳转表中的特定偏移量来计算的,该表将正确的id加载到注册器中,然后跳转到运行时执行反优化。这样做的好处是,对于每个去优化点,优化后的代码中只需要一条跳转指令。然而,反优化跳转表是预分配的,必须足够大,以支持整个反优化id范围。于是,我们修改了TurboFan,使优化代码中的去优化点在调用运行时之前直接加载deopt id。这使我们能够完全删除这个大的跳转表,代价是优化代码大小略有增加。

结果

我们已经在V8的前7个版本中发布了上面描述的优化。通常情况下,它们首先在 Lite模式投入使用,随后应用到普通的 V8。

AndroidGo设备上一组典型web页面的平均 V8堆占用

V8 v7.8 (chrome78)与v7.1 (chrome71) 所节省的内存占比

在这段时间里,我们在一系列典型的网站上平均减少了18%的V8堆大小,这相当于低端android移动设备平均减少了1.5 MB。并且不会对JavaScript性能产生任何显著影响,无论是在基准测试上,还是在实际的页面交互上。

通过禁用函数优化,Lite模式可以在一定程度上降低JavaScript执行吞吐量,从而进一步节省内存。平均而言,Lite模式节省了22%的内存,有些页面甚至减少了32%。这相当于AndroidGo设备上V8堆大小减少1.8 MB。

当根据每个优化的影响进行划分时,很明显,不同的页面从每个优化中获得的好处所占的比例不同。接下来,我们将继续识别潜在的优化,这些优化可以进一步减少V8的内存使用量,同时在JavaScript执行时仍然保持惊人的速度。

名词

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

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

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

初次尝试翻译,还望包涵!