Skip to content

极速解析,第二部分:惰性解析

这是我们系列文章的第二部分,解释 V8 如何尽可能快速地解析 JavaScript。第一部分解释了我们如何让 V8 的 扫描器(scanner) 变得更快。

解析是将源代码转换为中间表示形式的步骤,以供编译器使用(在 V8 中,即字节码编译器 Ignition)。解析和编译发生在网页启动的关键路径上,但并非所有发送到浏览器的函数都会在启动期间立即被需要。尽管开发者可以通过异步(async)和延迟(deferred)脚本来推迟此类代码的执行,但这并不总是可行的。此外,许多网页会发送仅由某些特性使用的代码,而这些特性在页面的任何单次运行中可能根本不会被用户访问。

不必要地急切编译代码会带来真实的资源成本:

  • CPU 周期被用于创建代码,从而延迟了启动实际需要的代码的可用性。

  • 代码对象占用内存,至少在 字节码刷新 机制判定代码当前不需要并允许其被垃圾回收之前一直如此。

  • 在顶层脚本执行完成时编译的代码最终会被缓存到磁盘上,占用磁盘空间。

出于这些原因,所有主流浏览器都实现了惰性解析(lazy parsing)。解析器可以选择"预解析"(pre-parse)遇到的函数,而不是为每个函数生成抽象语法树(AST)然后将其编译为字节码。它通过切换到 预解析器(preparser) 来实现这一点,预解析器是解析器的副本,只做能够跳过该函数所需的最少工作。预解析器验证它跳过的函数在语法上是有效的,并生成外部函数正确编译所需的所有信息。当预解析的函数稍后被调用时,它会按需进行完整解析和编译。

变量分配

使预解析变得复杂的主要因素是变量分配。

出于性能原因,函数激活(function activations)在机器栈上管理。例如,如果函数 g 使用参数 12 调用函数 f

javascript
function f(a, b) {
  const c = a + b;
  return c;
}

function g() {
  return f(1, 2);
  // The return instruction pointer of `f` now points here
  // (because when `f` `return`s, it returns here).
}

首先,接收者(receiver,即 fthis 值,由于这是一个松散模式(sloppy mode)函数调用,所以是 globalThis)被压入栈,然后是被调用的函数 f。接着参数 12 被压入栈。此时调用函数 f。为了执行调用,我们首先将 g 的状态保存到栈上:f 的"返回指令指针"(rip;我们需要返回的代码位置)以及"帧指针"(fp;返回时栈应该是什么样子)。然后我们进入 f,它为局部变量 c 以及可能需要的任何临时空间分配空间。这确保了当函数激活超出作用域时,函数使用的任何数据都会消失:它只是从栈中弹出。

stack-1.svg

参数 a、b 参数函数 f 的调用堆栈布局和在堆栈上分配的局部变量 c。

这种设置的问题在于,函数可以引用在外部函数中声明的变量。内部函数的生存期可能超过创建它们的激活:

javascript
function make_f(d) { !/** ← 声明 d */
  return function inner(a, b) {
    const c = a + b + d; // ← 引用 `d`
    return c;
  };
}

const f = make_f(10);

function g() {
  return f(1, 2);
}

在上面的例子中,从 inner 到在 make_f 中声明的局部变量 d 的引用是在 make_f 返回之后求值的。为了实现这一点,具有词法闭包的语言的虚拟机会在堆上分配从内部函数引用的变量,放在一个名为"上下文"(context)的结构中。

stack-2

调用 make_f 的栈布局,参数被复制到堆上分配的上下文中,供稍后捕获 d 的 inner 使用

这意味着对于函数中声明的每个变量,我们需要知道内部函数是否引用该变量,以便我们可以决定是在栈上还是在堆分配的上下文中分配该变量。当我们求值一个函数字面量时,我们分配一个闭包,它既指向函数的代码,也指向当前上下文:包含它可能需要访问的变量值的对象。

长话短说,我们确实需要在预解析器中至少追踪变量引用。

然而,如果我们只追踪引用,我们会高估哪些变量被引用。外部函数中声明的变量可能被内部函数中的重新声明所遮蔽(shadow),使得来自该内部函数的引用指向内部声明,而不是外部声明。如果我们无条件地在上下文中分配外部变量,性能将受到影响。因此,为了使变量分配能够与预解析正确配合工作,我们需要确保预解析的函数正确地追踪变量引用以及声明。

顶层代码是此规则的例外。脚本的顶层总是在堆上分配的,因为变量在脚本之间是可见的。一种接近良好工作架构的简单方法是,在不进行变量追踪的情况下运行预解析器来快速解析顶层函数;对于内部函数使用完整解析器,但跳过编译它们。这比预解析更昂贵,因为我们不必要地构建了整个 AST,但它让我们能够启动并运行。这正是 V8 在 V8 v6.3 / Chrome 63 之前所做的。

教会预解析器处理变量

在预解析器中追踪变量声明和引用是复杂的,因为在 JavaScript 中,从一开始并不总是清楚部分表达式的含义。例如,假设我们有一个带有参数 d 的函数 f,它有一个内部函数 g,其中有一个看起来可能引用 d 的表达式。

javascript
function f(d) {
  function g() {
    const a = ({ d }

它确实可能最终引用 d,因为我们看到的 token 是解构赋值表达式的一部分。

javascript
function f(d) {
  function g() {
    const a = ({ d } = { d: 42 });
    return a;
  }
  return g;
}

它也可能最终成为一个带有解构参数 d 的箭头函数,在这种情况下,f 中的 d 不会被 g 引用。

javascript
function f(d) {
  function g() {
    const a = ({ d }) => d;
    return a;
  }
  return [d, g];
}

最初,我们的预解析器被实现为解析器的独立副本,几乎没有共享,这导致两个解析器随时间推移而分化。通过将解析器和预解析器重写为基于实现了 奇异递归模板模式(curiously recurring template pattern)ParserBase,我们成功地最大化了共享,同时保持了独立副本的性能优势。这极大地简化了向预解析器添加完整变量追踪的工作,因为大部分实现可以在解析器和预解析器之间共享。

实际上,即使对于顶层函数,忽略变量声明和引用也是不正确的。ECMAScript 规范要求 在脚本首次解析时检测各种类型的变量冲突。例如,如果一个变量在同一作用域内被声明为词法变量两次,这被视为早期语法错误。由于我们的预解析器只是跳过变量声明,它会在预解析期间错误地允许该代码。当时我们认为性能提升值得违反规范。然而,现在预解析器能够正确追踪变量,我们在没有显著性能成本的情况下消除了这整类与变量解析相关的规范违规行为。

跳过内部函数

如前所述,当预解析的函数首次被调用时,我们会完整地解析它并将生成的 AST 编译为字节码。

javascript
// This is the top-level scope.
function outer() {
  // preparsed
  function inner() {
    // preparsed
  }
}

outer(); // Fully parses and compiles `outer`, but not `inner`.

函数直接指向外部上下文,该上下文包含需要对内部函数可用的变量声明的值。为了允许函数的惰性编译(并支持调试器),上下文指向一个名为 ScopeInfo 的元数据对象。ScopeInfo 对象描述了上下文中列出了哪些变量。这意味着在编译内部函数时,我们可以计算变量在上下文链中的位置。

然而,为了计算惰性编译的函数本身是否需要上下文,我们需要再次执行作用域解析:我们需要知道嵌套在惰性编译函数中的函数是否引用了惰性函数声明的变量。我们可以通过重新预解析这些函数来弄清楚这一点。这正是 V8 在 V8 v6.3 / Chrome 63 之前所做的。然而,这在性能上并不理想,因为它使源代码大小和解析成本之间的关系变成非线性的:我们会预解析函数的次数与它们的嵌套次数一样多。除了动态程序的自然嵌套之外,JavaScript 打包器通常将代码包装在"立即调用函数表达式(immediately-invoked function expressions)"(IIFE)中,使得大多数 JavaScript 程序都有多个嵌套层级。

parse-complexity-before

每次重新解析都至少增加解析函数的成本

为了避免非线性的性能开销,我们即使是在预解析期间也同样执行完整的作用域解析。我们存储足够的元数据,以便稍后可以简单地跳过内部函数,而不必重新预解析它们。一种方法是存储内部函数引用的变量名。这样存储成本很高,并且仍然需要我们重复工作:我们已经在预解析期间执行了变量解析。

相反,我们将变量分配位置序列化为每个变量的密集标志数组。当我们惰性解析一个函数时,变量会按照预解析器看到它们的相同顺序重新创建,我们可以简单地将元数据应用到变量上。现在函数已经编译完成,变量分配元数据不再需要,可以被垃圾回收。由于我们只需要为实际包含内部函数的函数提供这些元数据,大部分函数甚至不需要这些元数据,从而显著减少了内存开销。

parse-complexity-after

通过追踪预解析函数的元数据,我们可以完全跳过内部函数

跳过内部函数的性能影响,就像重新预解析内部函数的开销一样,是非线性的。有些网站将所有函数提升到顶层作用域。由于它们的嵌套级别始终为 0,开销也始终为 0。然而,许多现代网站确实会深度嵌套函数。在这些网站上,当此功能在 V8 v6.3 / Chrome 63 中推出时,我们看到了显著的改进。主要优势在于,现在无论代码嵌套得有多深都无关紧要了:任何函数最多预解析一次,完整解析一次。

TIP

出于内存原因,V8 在字节码长时间未使用时会 刷新字节码。如果稍后再次需要该代码,我们会重新解析并编译它。由于我们允许变量元数据在编译期间消亡,这会导致在惰性重新编译时重新解析内部函数。然而,此时我们会重新创建其内部函数的元数据,因此我们不需要再次重新预解析其内部函数的内部函数。

skipping-inner-functions

启动"跳过内部函数"优化前后的主线程和非主线程解析时间

可能被调用的函数表达式

如前所述,打包器通常通过将模块代码包装在立即调用的闭包中,将多个模块组合在单个文件中。这为模块提供了隔离,允许它们运行得就像它们是脚本中唯一的代码一样。这些函数本质上是嵌套的脚本;函数在脚本执行时立即被调用。打包器通常将立即调用函数表达式(IIFE;读作"iffies")以括号包裹函数形式存在 (function(){…})()

由于这些函数在脚本执行期间立即需要,预解析此类函数并不理想。在脚本的顶层执行期间,我们立即需要编译该函数,因此我们会完整解析并编译该函数。这意味着我们之前为了加速启动而进行的更快解析,肯定会成为启动的额外不必要成本。

你可能会问,为什么不简单地编译被调用的函数呢?虽然开发者通常很容易注意到函数何时被调用,但对于解析器来说情况并非如此。解析器需要在甚至开始解析函数之前就决定是急切编译函数还是延迟编译。语法中的歧义使得简单地快速扫描到函数末尾变得困难,而且成本很快就会类似于常规预解析的成本。

因此,V8 有两个简单的模式,它将其识别为可能被调用的函数表达式(PIFEs;读作"piffies"),据此它会急切地解析和编译函数:

  • 如果函数是括号包裹的函数表达式,即 (function(){…}),我们假设它会被调用。我们一看到这个模式的开始,即 (function,就做出这个假设。

  • 从 V8 v5.7 / Chrome 57 开始,我们还检测 UglifyJS 生成的模式 !function(){…}(),function(){…}(),function(){…}()。这种检测在我们看到 !function,function(如果它紧跟在 PIFE 之后)时就会启动。

由于 V8 急切编译 PIFEs,它们可以用作 配置文件引导反馈(profile-directed feedback),告知浏览器启动时需要哪些函数。

TIP

PIFEs 也可以被认为是配置文件信息函数表达式(profile-informed function expressions)。

在 V8 仍然重新解析内部函数的时候,一些开发者注意到 JS 解析对启动的影响相当大。软件包 optimize-js 基于静态启发式将函数转换为 PIFEs。在该软件包创建时,这对 V8 的加载性能产生了巨大影响。我们通过在 V8 v6.1 上运行 optimize-js 提供的基准测试复现了这些结果,仅查看压缩后的脚本。

eager-parse-compile-pife

急切解析和编译 PIFEs 会导致稍快的冷启动和热启动(首次和第二次页面加载,测量总解析 + 编译 + 执行时间)。然而,由于解析器的显著改进,V8 v7.5 上的收益比 V8 v6.1 上小得多

尽管如此,现在我们不再重新解析内部函数,而且解析器已经变得更快,通过 optimize-js 获得的性能改进大大减少了。事实上,v7.5 的默认配置已经比在 v6.1 上运行的优化版本快得多。即使在 v7.5 上,对于启动期间需要的代码,谨慎使用 PIFEs 仍然有意义:我们避免了预解析,因为我们早早就知道该函数会被需要。

optimize-js 基准测试结果并不完全反映现实世界。脚本是同步加载的,整个解析 + 编译时间都计入加载时间。在现实世界的设置中,你可能会使用 <script> 标签加载脚本。这允许 Chrome 的预加载器在脚本被求值之前发现它,并在不阻塞主线程的情况下下载、解析和编译脚本。我们决定急切编译的所有内容都会自动在主线程外编译,对启动的影响应该是最小的。使用主线程外脚本编译会放大使用 PIFEs 的影响。

然而仍然有成本,尤其是内存成本,因此急切编译所有内容并不是一个好主意:

eager-compilation-overhead

急切编译所有 JavaScript 会带来显著的内存成本

虽然在启动期间需要的函数周围添加括号是一个好主意(例如,基于性能分析启动),但使用像 optimize-js 这样应用简单静态启发式的软件包并不是一个好主意。例如,它假设如果函数是函数调用的参数,它将在启动期间被调用。然而,如果这样的函数实现了一个仅在稍后才需要的整个模块,你最终会编译太多。过度急切编译对性能不利:没有惰性编译的 V8 会显著降低加载时间。此外,optimize-js 的一些好处来自 UglifyJS 和其他压缩器的问题,它们从不是 IIFE 的 PIFEs 中删除括号,删除了本可以应用于例如 通用模块定义(Universal Module Definition) 风格模块的有用提示。这可能是压缩器应该修复的问题,以在急切编译 PIFEs 的浏览器上获得最大性能。

结论

惰性解析加速了启动,并减少了发送比实际需要更多代码的应用程序的内存开销。能够在预解析器中正确追踪变量声明和引用对于能够正确(符合规范)且快速地预解析是必要的。在预解析器中分配变量还允许我们序列化变量分配信息供解析器稍后使用,以便我们可以完全避免重新预解析内部函数,从而避免深度嵌套函数的非线性解析行为。

解析器可以识别的 PIFEs 避免了启动期间立即需要的代码的初始预解析开销。谨慎地、基于配置文件引导使用 PIFEs,或由打包器使用,可以提供有用的冷启动速度提升。尽管如此,应该避免不必要地用括号包裹函数来触发这种启发式,因为它会导致更多代码被急切编译,从而导致更差的启动性能和增加的内存使用。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (134a8ec)