yanchuncheng 2019-05-28
解析是将源代码转换成一个中间表示形式供编译器使用的步骤(在V8中,是字节码编译器Ignition)。解析和编译发生在web页面启动的关键路径上,在启动期间,并不是所有提供给浏览器的函数都需要被调用。尽管开发人员可以使用异步和延迟脚本来延迟这些代码的加载,但这并不总是可行的。此外,许多web页面的代码只能被特定的特性使用,这样一来,在每个页面单独运行期间,用户是根本无法访问这些代码的。
急切地编译不必要的代码会产生实际的资源成本:
由于这些原因,所有主流浏览器都实现了延迟解析。以前的做法是为每个函数生成一个抽象语法树(AST),然后将其编译为字节码,而使用了延迟解析之后,解析器就可以“预解析”它遇到的函数,而不需要对这些函数进行完全解析。它通过切换到预解析器来实现这一点,而预解析器是解析器的一个副本,它只做最基本的工作,否则就会跳过该函数。预解析器验证它跳过的函数在语法上是否是有效的,并生成正确编译外部函数所需的所有信息。在后边调用预解析的函数时,将按需对其进行完全解析和编译。
变量分配
使预解析复杂化的主要问题是变量分配。
出于性能原因,函数激活是在机器堆栈上进行管理的。例如,如果函数g调用了参数为1和2的函数f:
首先将接收器(即f的this值,由于它是一个草率的函数调用,所以它是globalThis)推入堆栈,接着是被调用的函数f。然后再将参数1和2推入堆栈。此时函数f被调用。为了执行调用,我们首先将g的状态保存在堆栈上: 包括f的“返回指令指针”(rip;我们需要返回什么代码)以及“帧指针”(fp;返回时堆栈应该是什么样子的)。然后我们输入f,它为局部变量c分配空间,以及它可能需要的任何临时空间。这确保了当函数激活超出作用域时,函数使用的任何数据都会消失: 它只是从堆栈中弹出。
对带有参数a,b和局部变量c的函数f的调用的堆栈分配布局。
这种设置的问题是函数可以引用在外部函数中声明的变量。内部函数存活的时间可能会比它们被创建时的激活时间要长:
在上面的例子中,从inner到make_f中声明的变量d的引用会在make_f返回后进行计算。为了实现这一点,使用词法闭包的语言的虚拟机会在一个称为“上下文”的结构中分配从堆上的内部函数中引用的变量。
通过将make_f的参数复制到一个上下文中来对它进行调用,该调用的堆栈布局会在堆上进行分配,供捕捉d的inner稍后使用。
这意味着对于函数中声明的每个变量,我们需要知道内部函数是否引用了该变量,以便决定是在栈上分配该变量,还是在堆上分配的上下文中分配该变量。当我们计算一个函数的字面量时,我们分配一个闭包,它指向函数的代码和当前上下文: 包含函数可能需要访问的变量值的对象。
长话短说,我们至少需要跟踪预解析器中的变量引用。
如果我们只跟踪引用,就会过多估计引用的变量。在外部函数中声明的变量可以通过内部函数中的重新声明来隐藏,从而创建一个来自该内部函数的引用,并将其指向内部声明,而不是外部声明。如果我们无条件地在上下文中分配外部变量,程序性能就会受到影响。因此,要使变量分配能正确地处理预解析过程,我们需要确保预解析后的函数正确地跟踪变量引用和声明。
顶层代码是这条规则的一个例外。一个脚本的顶层总是堆分配的,因为变量在脚本之间是可见的。接近良好工作的体系结构的一个简单方法是简单地运行预解析器,而不需要对快速解析的顶层函数进行变量跟踪;并为内部函数使用完整的解析器,但在编译的时候跳过它们。这比预解析过程成本更高,因为我们不需要构建整个AST,但它使我们启动并运行。这正是V8在新版本V8 v6.3 / Chrome 63中所做的。
向预解析器说明变量的情况
跟踪预解析器中的变量声明和引用是非常复杂的,因为在JavaScript中,某些部分表达式的含义从一开始就不清楚。例如,假设我们有一个带参数d的函数f,它有一个内部函数g,从表达式看起来g可能引用了d。
它最终可能确实会引用d,因为我们看到的tokens标记是析构赋值表达式的一部分。
它最终也可能是一个带有析构参数d的箭头函数,在这种情况下,f中的d就没有被g引用。
最初,我们的预解析器是作为解析器的独立副本实现的,没有太多的共享,这导致两个解析器会随着时间的推移而产生分歧。通过将解析器和预解析器重写为基于实现了奇异递归模板模式的ParserBase,我们成功地最大化了共享,同时也保留了单独副本的性能优势。这大大简化了向预解析器添加全部变量跟踪的工作,因为这个实现的大部分内容可以在解析器和预解析器之间共享。
实际上,忽略变量声明和顶层函数的引用是不正确的。ECMAScript规范要求在第一次解析脚本时要检测各种类型的变量冲突。例如,如果一个变量在同一作用域内被两次声明为词法变量,则被认为是early SyntaxError。因为我们的预解析器只是跳过了变量声明,所以在预解析过程中它将允许代码错误地运行。此时我们认为性能上的胜利使对规范的违反情有可原。现在预解析器 能正确地跟踪变量,尽管如此,我们还是应该在没有明显性能代价的情况下消除这类与变量解析相关的违反规范的行为。
跳过内部函数
如前所述,当第一次调用一个预解析的函数时,我们将对其进行完全解析,并将生成的AST编译为字节码。
该函数直接指向外部上下文,其中包含内部函数需要使用的变量声明的值。为了允许函数的延迟编译(并支持调试器),上下文会指向一个名为ScopeInfo的元数据对象。ScopeInfo对象描述了上下文中列出的变量。这意味着在编译内部函数时,我们可以计算变量在上下文链中的位置。
但是,要计算延迟编译的函数本身是否需要上下文,我们需要再次执行范围解析: 我们需要知道嵌套在延迟编译的函数中的函数是否引用了由延迟函数声明的变量。我们可以通过重新解析这些函数来计算出来。这正是V8在升级到V8v6.3/Chrome63之前所做的。但是,这并不是理想的性能最优的方法,因为它使资源大小和解析成本之间的关系变成非线性: 我们将尽可能多地解析嵌套函数。除了动态程序的自然嵌套之外,JavaScript打包器通常用“即时调用函数表达式”(IIFEs)的方式来包装代码,这使得大多数JavaScript程序具有多个嵌套层。
每次重新解析至少会增加解析函数的成本。
为了避免非线性性能开销,我们甚至在预解析过程中执行全作用域解析。我们存储了足够的元数据,这样我们稍后就可以简单地跳过内部函数,而不必重新解析它们。一种方法是存储由内部函数引用的变量名。这样做的存储成本很高,并要求我们仍然进行重复工作:我们已经在预解析期间执行了变量解析。
相反,我们将在变量分配的地方将每一个变量序列化为它的一个密集标记数组。当我们延迟解析一个函数时,变量按照预解析器看到的顺序被重新创建,我们可以简单地将元数据应用于这些变量。现在函数已经编译完成,已经不再需要变量分配元数据了,这样它就可以被当做垃圾进行回收。由于我们只需要这个元数据来处理实际包含内部函数的函数,所以大部分函数甚至不需要这个元数据,从而显著地降低了内存开销。
通过跟踪预解析的函数的元数据,我们可以完全跳过内部函数。
跳过内部函数的性能影响是非线性的,就像重新预解析内部函数的开销一样。有些站点将它们的所有函数都提升到了顶层范围。因为它们的嵌套层数总是0,所以开销也总是0。然而,许多现代的站点实际上都有许多深层嵌套函数。当V8 v6.3 / Chrome 63启动该特性时,我们就会在这些站点上看到显著的改进。启用该特性的主要优点是,现在代码的嵌套深度已经无关紧要: 任何函数最多只预解析一次,完全解析一次[1]。
主线程和非主线程的解析时间,以及运行“跳过内部函数”前后都得到了优化。
随时调用函数表达式
如前所述,打包器通常通过将模块代码封装在一个它们即时调用的闭包中,来将多个模块组合到一个文件中。这为模块提供了隔离,允许它们像脚本中唯一的代码一样运行。这些函数本质上是嵌套的脚本;脚本执行时这些函数会立即被调用。打包器通常以带圆括号的函数,即 (function(){…})(),的形式提供即时调用函数表达式(IIFEs,发音为“iffies”)。
由于这些函数在脚本执行期间是立即需要的,所以预解析这些函数并不理想。在脚本的顶层执行过程中,我们急需这些函数被编译,所以我们会完全解析和编译这些函数。这意味着,我们在前期解析越快,代码运行时启动就越快,并且不会产生不必要的额外成本。
你可能会问,为什么不直接编译调用的函数呢?虽然开发人员在一个函数被调用时能很容易注意到它,但是对于解析器情况则不同。解析器在开始解析函数之前需要决定该函数是需要立即编译还是推迟编译。语法中存在的歧义使得简单地快速扫描到函数末尾变得很困难,而且成本很快就与常规预解析的成本一样。
因此V8有两个简单的模式,它可以将函数识别为随时调用函数表达式(PIFEs,发音为“piffies”),这样它会快速解析并编译一个函数:
如果一个函数是一个带圆括号的函数表达式,即(function(){…}),我们假设它将被调用。我们一看到这个模式的开始,即(function,就立即做出这个假设。
在V8 v5.7 / Chrome 57中我们也检测了由UglifyJS生成的模式!function(){…}(),function(){…}(),function(){…}()。一旦我们看到!function或者function后面如果紧跟着一个PIFE,那么这个检测就起作用了。
由于V8会立即编译PIFEs,所以它们可以被用作配置文件导向的反馈[2],通知浏览器启动需要哪些函数。
当V8还在预解析内部函数时,一些开发人员已经注意到JS解析对启动的影响相当大。optimize-js包会基于静态启发式将函数转换为PIFEs。这个包的创建对V8的负载性能有很大的影响。通过在V8 v6.1上运行optimize-js提供的基准测试,我们复制了这些结果,你只需要查看缩小的脚本。
急切地解析和编译PIFEs会导致冷启动和热启动稍微快一些 (第一和第二页加载,测量总的解析+编译+执行时间)。但是,由于对解析器的显著改进,这在V8 v7.5上的好处要比在V8 v6.1上使用的好处小得多。
尽管如此,但我们现在不再需要重新解析内部函数,而且由于解析器变得更快,通过optimize-js获得的性能改进也大大降低。实际上,v7.5的默认配置已经比运行在v6.1上的优化版本快得多。即使在v7.5中,对于启动期间需要的代码,少量使用PIFEs仍然很有用: 我们避免了预解析,因为我们很早就知道会需要这个函数。
尽管如此,但我们现在不再需要重新解析内部函数,而且由于解析器变得更快,通过optimize-js获得的性能改进也大大降低。实际上,v7.5的默认配置已经比运行在v6.1上的优化版本快得多。即使在v7.5中,对于启动期间需要的代码,少量使用PIFEs仍然很有用: 我们避免了预解析,因为我们很早就知道会需要这个函数。
optimize-js基准测试结果并不能准确地反映实际情况。脚本是同步加载的,整个解析+编译时间都被计入加载时间。在实际环境中,你可能会使用<script>标记来加载脚本。这使得Chrome的预加载器能够在脚本被计算之前就发现它,并在不阻塞主线程的情况下下载、解析和编译该脚本。我们决定急切地编译的所有东西都是在主线程之外自动编译的,这样就会确保计入启动时间的值最小化。使用非主线程脚本编译来运行会放大使用PIFEs的影响。
但是,这样做仍然有成本,特别是内存成本,所以急切地编译所有东西并不是一个好主意:
急切地编译所有JavaScript会付出巨大的内存代价。
虽然在启动期间为需要的函数添加圆括号是一个好主意(例如,基于配置的启动),但是使用像optimize-js这样的包来应用简单的静态启发式并不是一个好主意。例如,它假设一个函数在启动期间被调用,如果它是一个函数调用的参数。但是,如果这样一个函数实现了一个只需要很长时间的完整模块,那么最终会编译太多。过于急切地编译对性能没有好处: 没有延迟编译的V8会显著地降低加载时间。此外,当UglifyJS和其它minifiers(最小化器)从不是IIFEs的PIFEs中删除括号时,也就删除了本可以应用于通用模块定义样式模块的有用提示,这样一来,optimize-js的一些好处就带来了问题。这可能是minifiers应该修复的一个问题,以便在急切地编译PIFEs的浏览器上获得最大的性能。
结论
延迟解析加快了启动速度,并减少了应用程序的内存开销,这些应用程序带有的代码比它们需要的多。能够正确地跟踪预解析中的变量声明和引用对于正确(根据规范)快速地进行预解析是必要的。在预解析器中分配变量还允许我们序列化变量分配信息,以便后续在解析器中使用,这样我们就可以完全避免必须重新预解析内部函数,避免深度嵌套函数的非线性解析行为。
解析器可以识别的PIFEs避免了启动过程中立即需要的代码的初始预解析的开销。谨慎地使用配置文件导向的PIFEs,或使用打包器,可以提供一个有用的冷启动减速带。但是,应该避免为了触发这种启发式而将函数封装在括号中这样的没必要的操作,因为这会导致更多的代码被急切地编译,从而导致更差的启动性能和更大的内存使用量。
1.出于内存方面的原因,如果V8在一段时间内没有被使用,它就会刷新字节码。如果代码运行结束后,稍后又需要重新运行,我们将重新解析并编译它。由于我们允许变量元数据在编译期间死亡,这就需要在延迟重新编译的过程中重解析内部函数。此时,我们就需要为代码的内部函数重新创建元数据,因此就不需要再一次重新预解析代码内部函数中的内部函数。??(https://v8.dev/blog/preparser#fnref1 )
2.PIFEs也可以看作是基于配置文件通知的的函数表达式。??(https://v8.dev/blog/preparser#fnref2 )