更轻量的 V8
2018 年末,我们启动了一个名为 V8 Lite 的项目,旨在显著减少 V8 的内存使用量。起初,这个项目被设想为 V8 的一种独立的 轻量模式,专门针对低内存移动设备或更注重内存使用而非吞吐执行速度的嵌入场景。然而,在进行这项工作时,我们发现许多针对这个 轻量模式 的内存优化可以迁移到常规 V8,从而让所有 V8 的用户受益。
在本文中,我们重点介绍了一些关键优化以及它们在实际工作负载中提供的内存节约。
注意: 如果你更喜欢观看演讲而非阅读文章,那么请欣赏下面的视频!如果不是,请跳过视频继续阅读。 ::
轻量模式
为了优化 V8 的内存使用,我们首先需要了解 V8 的内存使用情况以及哪些对象类型占据了 V8 堆的很大比例。我们使用 V8 的 内存可视化工具 来追踪一些典型网页的堆组成。
通过这样做,我们发现 V8 堆的很大一部分是用于不必需的对象,这些对象虽然不是 JavaScript 执行所必需的,但被用来优化 JavaScript 执行和处理异常情况。例如:优化代码;用于确定如何优化代码的类型反馈;用于 C++ 和 JavaScript 对象间绑定的冗余元数据;仅在异常情况下才需要的元数据,例如堆栈追踪符号化;以及加载页面期间仅执行了几次的函数字节码。
因此,我们开始开发 V8 的一种 轻量模式,通过大幅减少这些可选对象的分配来在 JavaScript 执行速度和内存节约之间进行权衡。
很多 轻量模式 的变化可以通过配置现有的 V8 设置来实现,例如禁用 V8 的 TurboFan 优化编译器。然而,其他变化则需要对 V8 进行更复杂的改动。
特别是,我们决定,由于 轻量模式 不对代码进行优化,因此可以避免收集优化编译器所需的类型反馈。执行代码时,Ignition 解释器会收集关于传递给各种操作(例如 +
或 o.foo
)的操作数类型的反馈,以便随后根据这些类型进行优化调整。这些信息存储在 反馈向量 中,这些向量占用了 V8 堆内存使用的一大部分。轻量模式 可以避免分配这些反馈向量,但解释器和 V8 的部分内联缓存基础结构需要反馈向量的可用,因此需要进行大量重构以支持这种无反馈的执行。
轻量模式 于 V8 v7.3 中推出,与 V8 v7.1 相比,通过禁用代码优化、不分配反馈向量以及不常执行的字节码老化(下文描述)实现了网页堆大小的 22% 减少。这对于那些明确希望在性能和更好的内存使用之间进行权衡的应用来说是个不错的结果。然而,在进行这项工作时,我们意识到,通过使 V8 更加延迟化,我们可以在没有性能影响的情况下实现 轻量模式 的大部分内存节约。
延迟反馈分配
完全禁用反馈向量分配不仅会阻止V8的TurboFan编译器对代码进行优化,还会阻止V8在Ignition解释器中对常见操作(如对象属性加载)执行内联缓存。因此,这样做会显著降低V8的执行时间性能,使页面加载时间延长12%,并在典型的交互式网页场景中使V8的CPU使用时间增加120%。
为了在不引发这些性能下降的情况下将大部分增益带到常规V8中,我们转而采用了一种方法,即在函数执行了一定数量的字节码(目前为1KB)后懒惰地分配反馈向量。由于大多数函数不会被频繁执行,我们在大部分情况下避免了反馈向量分配,但在需要时迅速分配以避免性能下降并仍然允许代码优化。
此方法的一个额外复杂性与反馈向量形成树形结构有关,内部函数的反馈向量作为对应项存储在其外部函数的反馈向量中。这是必要的,以便新创建的函数闭包接收到与同一函数创建的所有其他闭包相同的反馈向量数组。由于反馈向量的懒惰分配,我们无法用反馈向量形成这个树,因为无法保证外部函数在内部函数分配其反馈向量之前就已分配了反馈向量。为了解决这个问题,我们创建了一个新的ClosureFeedbackCellArray
来维护这个树,然后在函数变得热(频繁被调用)时用完整的FeedbackVector
替换函数的ClosureFeedbackCellArray
。
我们的实验室实验和实际数据统计结果显示,桌面端的懒惰反馈分配并未引发性能下降,而在移动平台上,由于垃圾回收的减少,我们实际上看到低端设备的性能有所提升。因此,我们在所有V8构建中启用了懒惰反馈分配,包括Lite模式,在该模式下,与我们最初的无反馈分配方法相比,尽管略微增加内存开销,但实际性能提升更为显著。
懒惰源代码位置
在从JavaScript编译字节码时,生成了将字节码序列与JavaScript源代码中的字符位置关联的源代码位置表。然而,只有在符号化异常或执行开发者任务(如调试)时,这些信息才会用到,因此很少被使用。
为了避免这种浪费,我们在编译字节码时不再收集源代码位置(假设无调试器或分析器附加)。只有在实际生成堆栈跟踪时(例如调用Error.stack
或将异常的堆栈跟踪打印到控制台)才会收集源代码位置。这确实会有一些成本,因为生成源代码位置需要重新解析和编译函数,但由于大多数网站在生产环境中不会符号化堆栈跟踪,因此不会看到任何可观察到的性能影响。
我们在这项工作中需要解决的一个问题是要求字节码生成具有可重复性,而之前并没有保证这一点。如果V8在收集源代码位置时生成的字节码与原始代码不同,那么源代码位置不匹配,堆栈跟踪可能会指向源代码中的错误位置。
在某些情况下,根据函数是立即编译还是懒惰编译,V8可能生成不同的字节码,因为函数的最初立即解析与其后期的懒惰编译之间丢失了一些解析器信息。这些不匹配大多是无害的,例如丢失了某变量是不可变的事实,因此无法对其进行优化。然而,这项工作揭示的一些不匹配在某些情况下可能会导致代码错误执行。因此,我们修复了这些不匹配,并添加了检查和压力模式以确保函数的立即编译和懒惰编译始终生成一致的输出,从而增强了我们对V8解析器和前解析器正确性和一致性的信心。
字节码清理
从JavaScript源代码编译的字节码占用了V8堆空间的很大一部分,通常约为15%,包括相关的元数据。有许多函数仅在初始化期间执行,或者在编译后很少使用。
因此,我们增加了在垃圾回收过程中从函数中清理编译字节码的支持,如果这些函数最近没有被执行的话。为此,我们跟踪函数字节码的年龄,每进行一次主要(标记压缩)垃圾回收时增加年龄,并在函数被执行时将其重置为零。任何超过老化阈值的字节码都可以在下次垃圾回收时被收集。如果它被清理了但随后再次被执行,它将重新编译。
为确保字节码仅在不再需要时才被清除,我们遇到了一些技术挑战。例如,如果函数A
调用了另一个长时间运行的函数B
,函数A
可能会在仍在调用栈上时进入老化状态。即使函数A
达到其老化阈值,我们也不希望清除它的字节码,因为在长时间运行的函数B
返回时,我们需要回到函数A
。因此,当字节码达到老化阈值时,我们将其视为弱引用,但如果栈或其他地方仍有强引用,则将其视为强引用。只有在没有强链接时,我们才会清除代码。
除了清除字节码外,我们还清除与这些已清除函数相关的反馈向量。然而,我们无法在与字节码相同的垃圾回收周期内清除反馈向量,因为它们并不是由同一个对象保存的——字节码由原生上下文独立的SharedFunctionInfo
保存,而反馈向量则由原生上下文相关的JSFunction
保存。因此,我们会在后续的垃圾回收周期中清除反馈向量。
额外优化
除了这些较大的项目外,我们还发现并解决了几个效率问题。
第一个优化是减少FunctionTemplateInfo
对象的大小。这些对象存储有关FunctionTemplate
的内部元数据,用于使嵌入者(如Chrome)能够提供可由JavaScript代码调用的函数的C++回调实现。Chrome引入了大量的FunctionTemplate
以实现DOM Web API,因此FunctionTemplateInfo
对象占用了V8的堆大小。通过分析FunctionTemplate
的典型使用情况,我们发现,在FunctionTemplateInfo
对象的十一字段中,只有三个通常设置了非默认值。因此,我们将FunctionTemplateInfo
对象拆分开来,使稀有字段存储在一个侧表中,只在需要时按需分配。
第二个优化与我们如何从TurboFan优化的代码中回退有关。由于TurboFan执行推测性优化,如果某些条件不再满足,它可能需要回退到解释器(反优化)。每个反优化点都有一个ID,允许运行时确定应返回解释器执行的位置。在之前,通过让优化代码跳转到一个大型跳转表中的特定偏移量,从而将正确的ID加载到寄存器中,然后跳入运行时执行反优化。这种方法的优点是,对于每个反优化点,只需在优化代码中加入单个跳转指令。然而,反优化跳转表是预先分配的,必须足够大以支持整个反优化ID范围。我们改进了TurboFan,使优化代码中的反优化点在调用运行时之前直接加载反优化ID。这使我们能够完全移除这个大型跳转表,但代价是优化代码大小略有增加。
成果
过去的七次V8版本发布中,我们推出了上述优化。这些优化通常首先在精简模式中启用,随后被引入到V8的默认配置中。
在此期间,我们将V8堆大小平均降低了18%,覆盖了范围广泛的典型网站,这相当于为低端AndroidGo移动设备平均减少了1.5 MB。这些优化并未显著影响JavaScript性能,包括基准测试和实际网页交互。
精简模式通过禁用函数优化可以进一步节省内存,但会稍微影响JavaScript执行性能。平均而言,精简模式可以节省22%的内存,一些网页甚至可达到32%的减少。这相当于在AndroidGo设备上减少V8堆大小1.8 MB。
当按每个独立优化的影响进行划分时,可以清楚地看到不同网页从每种优化中获得的收益比例不同。展望未来,我们将继续识别潜在的优化,进一步减少V8的内存使用,同时保持JavaScript执行的高速表现。