轻量版 V8 最新优化成果:内存开销平均减少 22%

我们在 2018 年末启动了一个名为 V8 Lite 的项目,项目的目标在于大幅降低 V8 引擎的内存使用。这个项目原本被设想为 V8 的独立轻量版本,专门为低内存的移动或嵌入式设备设计。在这种场景中我们更在乎减少内存的使用,而不是执行的吞吐量。不过,在工作进行过程中我们意识到,我们为 Lite 模式所做的许多内存优化工作可以被移植到常规版本的 V8 引擎,从而使所有 V8 用户收益。

这篇文章重点介绍了项目中的关键优化策略,以及这些优化如何在实际中节省工作负载内存。

注意:如果你更喜欢看演讲而不是阅读文章,请欣赏 这个视频

轻量模式

为了优化 V8 的内存使用,首先需要了解 V8 如何使用内存,以及中哪些对象类存在大比例的堆占用。我们使用 V8 的内存可视化工具 分析了许多典型网页,追踪其中堆大小的使用情况。

V8 引擎加载印度时报网站时不同对象类型的堆占用情况

分析得知,V8 的堆很大一部分被非关键对象占用,这些对象专门用于优化 JavaScript 的执行和异常处理。比如:优化代码结构;用于确定如何优化代码的反馈类型;C++ 和 JavaScript 对象绑定需要的冗余元数据;堆栈符号化过程中需要的元数据;只在页面加载期间执行了几次的函数的字节码。

得到以上结果后,我们开始研究 V8 的轻量模式。主要策略是:以降低 JavaScript 的执行速度为代价,大幅减少可选对象的分配量,从而达到节省内存使用的目标。

许多轻量模式的改动可以通过配置现有的 V8 引擎达到目的,比如禁用 TurboFan 优化编译器。但其他的改动需要针对 V8 本身进行修改。

值得注意的是,既然轻量模式不需要优化代码,V8 就可以不再收集优化编译器所需的反馈类型。当 Ignition 解释器执行代码时,V8 会收集各种运算(如 + 或 o.foo)中传递的操作数的反馈类型,以便在稍后中进行优化。此类信息储存在 反馈向量 中,这些向量占用了堆内存很大一部分。轻量模式可以避免分配反馈向量,但解释器与 V8 的内联缓存基础架构期望来自于反馈向量的值,因此 V8 需要相当多的代码重构才能支持移除反馈向量的执行。

轻量模式在 v7.3 的 V8 版本中推出。与 v7.1 版本相比,通过禁用代码优化、禁止分配反馈向量、老化极少执行的代码(后文中会介绍),新版本在典型网页场景中减少了 22% 的栈内存使用。对于明确希望以牺牲性能为代价以获得更好的内存使用率的应用程序而言,这是个好结果。但在完成这项工作的过程中,我们意识到可以通过把 V8 变“lazy”,在不影响性能的情况下实现轻量模式,以得到节省内存的结果。

惰性反馈向量分配

完全禁用反馈向量分配不仅阻止了 V8 TurboFan 编译器为代码进行优化,还阻止 V8 为常见的运算做 内联缓存 ,比如 Ignition 解释器对于对象属性的加载。这会造成 V8 的执行时间显著回归,页面加载时间减少 12%,同时使典型交互式网页场景中 V8 的 CPU 使用时间增加 120%。

为了在没有性能回归的情况下,将大部分节省内存的提升效果带到常规版本的 V8 版本,我们采用了一种新的策略:在函数执行了一定量字节码的代码(目前为 1KB)后延迟地分配反馈向量。由于大多数函数不经常执行,因此惰性策略可以在大多数情况下避免反馈向量的分配,但仍然会在需要的地方快速分配它们,让代码得到优化的同时也避免了性能回归问题。

这种方法还有一个复杂因素与反馈向量组成的树有关。在这个树中,内嵌函数的反馈向量被储存为外部函数反馈向量的条目。这是因为新创建的函数闭包需要与同一个函数创建的所有其他闭包获得相同的反馈向量数组。由于反馈向量被延迟分配,我们不再能使用反馈向量生成此树,因为无法保证内部函数分配反馈向量时,外部函数已经分配好了反馈向量。为了解决这个问题,我们为函数创建了新的 ClosureFeedbackCellArray 结构维护这个树。当函数被调用时,代表它的 ClosureFeedbackCellArray 将会与一个完整的 FeedbackVector 交换。

反馈向量树在惰性分配前后的比对

实验结果与实际用户反馈表明,惰性反馈在桌面端没有性能回归问题。而在移动平台上,由于垃圾回收的减少,我们在某些低端设备中同样观察到了性能提升。因此,我们已经在包含轻量模式的所有 V8 版本中启用了惰性反馈分配。与最初不做惰性反馈的轻量模式,新方法有一些轻微的内存提升,不过这个代价可以被实际性能提升补偿。

惰性源码定位

在 JavaScript 编译为字节码后,源码定位表会随之生成,它记录了字节码序列对应的 JavaScript 源码中字符的位置信息。但源码定位表只会在符号化异常或者执行如程序调试等开发人员任务时才需要此信息,因此很少会被使用。

为了避免这种浪费,现在 V8 编译源码为字节码时不再收集源码的位置信息(假如没有附属调试器或分析器)。源码位置的收集仅发生在生成堆栈跟踪时,比如调用 Error.stack 方法,或者将异常堆栈跟踪打印到控制台时。这的确有一些成本,因为源码定位需要重新分析和编译函数,但是大多数网站并不会在生产中符号化堆栈跟踪,因此这不会产生任何可感知到的性能影响。

这个策略带来的一个新的挑战。我们需要找到一个生成可重复字节码的方法,不过这在之前是不做保证的。如果 V8 在收集源码定位时生成的字节码与原始代码不同,则产生的定位信息无法使字节码与源码对齐,并且堆栈跟踪可能指向源码中错误的位置。

某些情况下,由于某些解析器信息在函数初始预解析和之后的懒编译之间丢失,V8 可能会生成不同的字节码,这取决于函数是 预编译还是懒编译 。这些不匹配大多数是无害的,例如丢失掉不可变变量的跟踪信息,导致无法优化这类代码。但这种不匹配的事实,确实揭露了这项优化某些可能导致代码错误执行的潜在问题。因此,我们修复了这些不匹配问题,增加了额外的检查和压力模式,以确保函数预编译与懒编译总是产生一致的输出,让我们对 V8 解析器和预解释器的正确性和一致性有了更多信心。

字节码冲刷

JavaScript 源码编译产生的字节码会占用大量 V8 的堆内存,通常占约 15%,其中包含相关代码的元数据。许多函数只在初始化阶段执行,或者编译后很少再使用。

因此,我们增加了一个新特性,假如某个函数最近一段时间没有执行过,那么将会在垃圾回收期间将其编译的字节码冲刷掉。为了达成这个目标,我们为函数的字节码增加了老化标记:在每个 主要(mark-compact) 的垃圾回收期间增加老化数值,并在执行函数的时候将数值重置为零。任何超过老化阈值的字节码都可能在下一次的垃圾回收期间回收。如果字节码被回收后函数再次被执行,那么它将会被重新编译。

确保字节码只有真正不再被需要的时候冲刷是个技术挑战。例如,如果函数 A 调用了另一个长时间运行的函数 B,那么有可能函数 A 仍在调用栈上的时候被增加老化标记。在这种情况下,即使函数 A 达到了老化阈值我们也不想冲刷掉函数 A 的字节码,因为长时间运行的函数 B 在返回的时候需要继续执行函数 A。因此,字节码在达到寿命期限时被保留为弱链接,但如果函数引用存在于调用栈或者其他地方的时候,其字节码会保留为强链接。字节码在没有任何强链接的时候才会被冲刷。

除了冲刷字节码,V8 同时冲刷了这些函数相关的反馈向量。不过,冲刷字节码的内存回收周期内没法同时冲刷反馈向量,这是因为它们由同不同的对象保持。字节码由本机上下文独立的 SharedFunctionInfo 保持,而反馈向量由本机上下文依赖的 JSFunction 保持。因此,反馈向量会在随后的垃圾回收循环中冲刷。

图为一个老化函数在两个 GC 循环后的对象布局

其他优化策略

除了上面提到的主要优化点之外,我们还发现并解决了两个引发效率低下的问题。

一个是降低了 FunctionTemplateInfo 对象的大小。这些对象储存了与 FunctionTemplate 有关的内部元数据,使得 Chrome 等 V8 的嵌入程序可以在 JavaScript 中访问以 C++ 实现的函数回调。为了实现 DOM Web API, Chrome 引入了许多 FunctionTemplate 对象,而这些对象会增加 V8 的堆占用。在分析了 FunctionTemplate 的典型用法后,我们发现 FunctionTemplateInfo 对象的 11 个字段中,通常只有 3 个字段被修改为非默认值。因此,我们拆分了 FunctionTemplateInfo 对象,使得很少使用的字段储存在一个只会按需分配的副表中。

另一个与如何反优化 TurboFan 产生的优化代码有关。由于 TurboFan 会进行推测优化,如果某些优化条件不再成立,那么可能就需要回退(反优化)到使用解释器执行。每个反优化点都有一个 ID,帮助运行时决定解释器返回到字节码哪个执行位置。优化前,此 ID 通过将优化后的代码位置跳转到大型跳转表的一个特定位置计算,这个跳转表会载入正确的 ID 到寄存器中,然后跳转到运行时去处理反优化。这样做的好处是是优化代码的每个反优化点只需一个跳转指令。然而反优化跳转表是预先分配的,并且必须足够大到支持整个反优化 ID 范围。我们现在选择修改 TurboFan 的代码,使得优化代码的反优化点在调用运行时之前直接加载反优化 ID。这样我们就可以移除整个大型跳转表,代价是优化后的代码大小略有增加。

结果

以上是我们在最近七个版本的 V8 引擎中做的优化。通常来说,这些优化首先引入到轻量模式里,随后被带到 V8 的默认配置。

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

图为 V8 引擎 v7.8 (Chrome 78) 与 v7.1 (Chrome 7.1) 的单页内存节省对比差异

在此期间,从一系列经典网站上的结果来看,V8 的堆大小的堆大小平均减小了 18%。这对低端的 AndroidGo 移动设备来说,平均内存占用减少了 1.5 MB。这些优化没有对性能基准测试产生重大影响,同时也在实际网页交互中经受住了考验。

通过禁用函数优化,轻量模式可以以 JavaScript 执行吞吐量为代价进一步节省内存。平均来说,轻量模式可以为设备节省 22% 的内存,甚至在有些页面可以节省到高达 32%。这对于 AndroidGo 设备来说相当于 1.8 MB 的 V8 堆内存。

图为 V8 引擎 v7.8 (Chrome 78) 与 v7.1 (Chrome 7.1) 的内存节省对比差异

将每个优化点独立拆分来看时,容易看出不同页面从这些优化中得到了不同比例的优化提升。未来我们将继续探索潜在的优化策略,在保证 JavaScript 快速执行的情况下,进一步降低 V8 内存使用率。

原文: https://v8.dev/blog/v8-lite