作者:Mythri Alle, Dan Elphick, and Ross McIlroy

在 2018 年末,为了大幅减少 V8 的内存使用量,我们启动了一个名为 V8 Lite 的项目。该项目最初被设想为 V8 的一个独立的 *精简模式(Lite mode)*,专门针对低内存移动设备或嵌入式用例,这些用例更关心的是减少内存的使用而不是吞吐量的执行速度。但是在进行这项工作的过程中,我们意识到为*Lite 模式*所做的许多内存优化都可以转移到常规 V8 中,从而使 V8 的所有用户受益。

本文重点介绍了我们开发的一些关键优化以及它们在实际工作负载中对内存所做的优化。

注意:如果您不喜欢阅读文章,请欣赏下面的视频!

Ross McIlroy在BlinkOn 10上发表的 “V8 Lite⁠ – 减少 JavaScript 内存”

https://www.youtube.com/embed/56ogP8-eRqA

Lite 模式

为了优化 V8 的内存使用,我们首先需要了解 V8 如何使用内存以及哪些对象类型在 V8 堆中占了很大的比例。我们用了 V8 的内存可视化工具来跟踪许多典型网页的堆内容的构成。

加载印度时报时,不同对象类型使用的 V8 堆的百分比

加载印度时报时,不同对象类型所使用的 V8 堆的百分比

为此,我们确定了对 JavaScript 执行并不是必不可少的对象在 V8 堆中占了很大一部分 ,但是这些对象被用于优化 JavaScript 执行,并处理特殊情况。例如:优化的代码;类型反馈,用于确定如何优化代码;用于在 C++ 和 JavaScript 对象之间进行绑定的冗余元数据;仅在特殊情况下才需要元数据,如堆栈跟踪符号;还有在页面加载期间仅执行几次的函数的字节码。

结果,我们开始在 V8 的 精简模式 上进行工作,该模式通过大幅减少这些可选对象的分配来权衡 JavaScript 执行的速度与节省的内存。

img

通过配置现有的 V8 设置,可以对*精简模式*进行许多更改,例如禁用 V8 的 TurboFan 优化编译器。但是其他的优化还需要对 V8 进行更多的修改。

特别是,由于我们决定在*精简模式*下无法优化代码,因此可以避免收集优化编译器所需的类型反馈。在 Ignition 解释器中执行代码时,V8 会收集有关传递给各种操作的操作数类型(例如,+o.foo)的反馈,以便针对这些类型调整以后的优化。这些信息存储在*反馈向量*中,这些向量在 V8 堆内存中使用了很大的一部分。 *精简模式*可以避免分配这些反馈向量,但是 V8 的解释器和部分内联缓存基础结构却希望反馈向量可用,因此还需要进行大量重构才能支持这种无反馈执行。

在 V8 的 v7.3 版本中启动的*精简模式*与 v7.1 相比,通过禁用代码优化,不分配反馈矢量以及执行很少执行的字节码老化(如下所述),使典型的网页堆大小减少了 22%。对于那些明显想要权衡性能以提高内存使用率的程序而言,这是一个非常不错的结果。但是在执行此项工作的过程中,我们意识到通过使 V8 变得更懒惰,可以实现节省*精简模式*的大部分内存,而不会影响性能。

惰性反馈分配

完全禁用反馈向量分配,不仅会阻止 V8 的 TurboFan 编译器对代码进行优化,而且还会阻止 V8 执行常见操作(例如对象)的 inline caching 属性在 Ignition 解释器中的加载。所以这样做会大大降低 V8 的执行时间,在典型的交互式网页方案中,页面加载时间减少了 12%,而 V8 使用的 CPU 时间增加了120%。

为了在不进行这些回归的情况下将节省的大部分内存用于常规 V8,我们转而采用了另一种方法,在该函数执行了一定数量的字节码(当前为1KB)之后,开始惰性分配反馈向量。由于大多数函数并不是要经常执行,因此在大多数情况下,我们避免分配反馈矢量,而是在需要的地方快速分配它们,以避免性能下降,并且仍然可以对代码进行优化。

这种方法的另一个复杂性与以下事实有关:反馈向量形成一棵树,内部函数的反馈向量被保留为外部函数的反馈向量中的条目。这是非常必要的,这样可以使新创建的函数闭包与为同一函数创建的所有闭包一样,接收相同的反馈矢量数组。在惰性分配反馈向量的情况下,我们无法用反馈向量来形成这棵树,因为无法保证外部函数会在内部函数分配其反馈向量之前就对其进行分配。为了解决这个问题,我们创建了一个新的 ClosureFeedbackCellArray 来维护这棵树,然后在函数变热时用一个完整的 FeedbackVector 换出一个函数的 ClosureFeedbackCellArray

img

惰性反馈分配前后的反馈矢量树

我们实验和现场测试结果表明,在台式机上的惰性反馈没有出现性能下降的趋势,而在移动平台上,由于减少了垃圾收集,实际上在低端设备上性能有所提高。因此我们在所有 V8 版本中都启用了惰性反馈分配,其中包括*精简模式*,与我们原始的无反馈分配方法相比,内存模式略有退步,但是实际性能却得到了很大的提高。

惰性源位置

从 JavaScript 编译字节码时,会生成把字节码序列与 JavaScript 源码中的字符位置相关联的源位置表。但是仅在符号化异常或执行开发人员任务(例如调试)时才需要此信息,因此很少使用。

为了避免这种浪费,现在编译字节码时不收集源位置(假设未连接调试器或分析器),仅在实际生成堆栈跟踪时(例如,在调用 Error.stack 或将异常的栈跟踪打印到控制台时)才收集源。这确实需要付出一些代价,因为生成源位置需要重新解析和编译函数,但是大多数网站并未在生产中使用栈跟踪符号,所以看不到什么能够观察到的性能影响。

我们必须解决的一个问题是需要可重复的字节码生成,而这是以前无法保证的。如果 V8 在收集源位置时与原始代码生成不同的字节码,则源位置不对齐,并且堆栈跟踪可能指向源代码中的错误位置。

在某些情况下,由于在函数在先急速解析再延迟编译时丢失了一些解析信息,V8 可能会根据某个函数是急速还是延迟编译来生成不同的字节码。这些不匹配大多是良性的,例如,忘记了变量是不可变的事实,因此无法对其进行优化。但是,这项工作发现的某些不匹配在某些情况下确实有可能导致代码错误的执行。因此,我们修复了这些不匹配问题,并添加了检查和压力模式,以确保函数的急速和惰性编译始终能够产生一致的输出,从而使我们对 V8 解析器和预解析器的正确性和一致性更具信心。

字节码刷新

从 JavaScript 源码编译的字节码占据了 V8 堆空间的很大一部分,通常大约为 15%,其中包括相关的元数据。有许多函数仅在初始化的时候执行,或者在编译后很少被使用。

所以我们添加了对垃圾回收期间从函数中清除编译后的字节码的支持,如果它们最近没有执行过的话。为此我们要跟踪函数字节码的 *age*,增加每个 major(mark-compact)垃圾回收的 *age*,并在执行该函数时将其重置为零。任何超过老化阈值的字节码都可以在下一次垃圾回收中被收集。如果已收集了,但是稍后需要再次执行,那么将会重新编译它。

要确保只在不再需要字节码时才刷新它存在着技术难题。如果函数 A 调用另一个长期运行的函数 B,则函数 A 可能会在其仍在堆栈中时老化。即使函数 A 达到了老化阈值我们也不希望刷新它的字节码,因为我们需要在长时间运行的函数 B 返回到 A。因此当字节码达到函数的老化阈值时,我们会将其视为函数的弱保留,而堆栈或其他位置对它的任何引用都作为强保留。我们仅在没有强链接剩余时才刷新代码。

除了刷新字节码,我们还刷新与这些刷新函数关联的反馈向量,但是我们无法在与字节码相同的 GC 周期内刷新它们,因为它们没有被同一对象保留。字节码由与本机上下文无关的 SharedFunctionInfo 保留,而反馈向量则由依赖于本机上下文的 JSFunction 保留。最后我们在随后的 GC 周期中刷新反馈向量。

经过两个 GC 循环后,老化的函数的对象布局

经过两个GC循环后,老化的函数的对象布局

其他优化

除了这些较大的项目,我们还发现并解决了一些导致效率低下的问题。

第一个是减小 FunctionTemplateInfo 对象的大小。这些对象存储与 FunctionTemplate 有关的内部元数据,这些元数据用于使嵌入程序(例如 Chrome)提供可被调用的函数的 C++ 回调实现。通过 JavaScript 代码。 Chrome 浏览器引入了许多 FunctionTemplates 以实现 DOM Web API,因此,FunctionTemplateInfo 对象对 V8 的堆大小有所贡献。在分析 FunctionTemplates 的典型用法之后,我们发现在 FunctionTemplateInfo 对象上的11个字段中,通常只有 3 个被设置为非默认值。因此我们拆分了 FunctionTemplateInfo 对象,以便将稀有字段存储在边表中,该边表仅在需要时才按需分配。

第二个优化与如何取消 TurboFan 的代码优化有关。由于 TurboFan 执行推测性优化,所以如果某些条件不再成立,则可能需要回退到解释器(取消优化)。每个取消点都有一个 ID,该 ID 可以使运行时能够确定字节码应该把执行返回到解释器中的哪个位置上。以前通过优化代码跳转到大型跳转表中的特定偏移量来计算这个 ID,然后再将正确的 ID 加载到寄存器中,最后跳转到运行时以执行反优化。这样做的好处是,对于每个取消点,在优化代码中只需要一条跳转指令。但是,取消优化跳转表已经预先分配,并且它必须足够大,这样才能支持整个取消优化 id 的范围。所以我们修改了 TurboFan,使优化代码中的 deopt 点在调用运行时之前可以直接加载 deopt id。这样我们就能够完全删除这个大型跳转表,但是代价是需要略微增加优化代码的大小。

结果

我们已经在 V8 最后七个版本中发布了上述优化。通常,它们首先以*精简模式*开始,然后又被带到 V8 的默认配置。

AndroidGo设备上一组典型网页的 V8 堆的平均大小

AndroidGo设备上一组典型网页的 V8 堆的平均大小

与v7.1(Chrome 71)相比,V8 的 v7.8(Chrome 78)版本每种页面的内存节省情况详情

与v7.1(Chrome 71)相比,V8 的 v7.8(Chrome 78)版本每种页面的内存节省情况详情

在这段时间里,我们在一系列典型网站上将 V8 堆大小平均减少了 18%,这对应于低端 AndroidGo 移动设备,平均减少了 1.5 MB。在基准测试或实际的网页交互中,这对 JavaScript 性能可能并没有什么重大影响。

*精简模式*可以通过禁用函数优化来进一步节省内存,但会以一定的成本提高 JavaScript 执行吞吐量。平均而言,*精简模式*可节省 22% 的内存,而某些页面最多可节省 32%。这对应于 AndroidGo 设备上的 V8 堆大小减少了 1.8 MB。

与 v7.1(Chrome 71)相比,V8 v7.8(Chrome 78)的内存用量减少了

与 v7.1(Chrome 71)相比,V8 v7.8(Chrome 78)的内存用量减少了

当把每个优化的影响分开来看时,很明显,不同的页面会从每一个优化中获得不同比例的收益。展望未来,我们将继续寻找潜在的优化方案,这些优化方案可以进一步减少 V8 对内存的使用量,同时仍然保持 JavaScript 惊人的执行速度。