当前位置: 首页 > 科技观察

V8如何解析快速JavaScript惰性解析

时间:2023-03-11 22:50:32 科技观察

解析是将源代码转换为供编译器使用的中间表示的步骤(在V8中,字节码编译器Ignition)。解析和编译发生在网页启动的关键路径上,并不是所有提供给浏览器的函数都需要在启动时调用。虽然开发人员可以使用异步和延迟脚本来延迟这些代码的加载,但这并不总是可行的。另外,很多网页都有只能被特定功能使用的代码,使得用户在每个页面独立运行时根本无法访问这些代码。急于编译不必要的代码会产生实际的资源成本:创建这些不必要的代码会占用一小部分CPU时间,这会导致在启动时延迟加载实际需要的代码。代码对象占用内存,至少直到回收机制确定不再需要当前代码并允许垃圾收集器回收为止。***脚本完成执行时编译的代码最终会缓存在磁盘上,占用磁盘空间。由于这些原因,所有主流浏览器都实现了惰性解析。与为每个函数生成抽象语法树(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的内部使用。这意味着对于函数中声明的每个变量,我们需要知道该变量是否被内部函数引用,以便决定是在堆栈上分配变量还是在堆分配的上下文中分配变量。当我们评估一个函数字面值时,我们分配了一个闭包,它指向函数的代码和当前上下文:一个包含函数可能需要访问的变量值的对象。长话短说,我们至少需要在预解析器中跟踪变量引用。如果我们只跟踪引用,我们会高估引用的变量。在外部函数中声明的变量可以通过内部函数中的重新声明来隐藏,从该内部函数创建一个引用并将其指向内部声明而不是外部声明。如果我们无条件地在上下文中分配外部变量,程序性能将受到影响。因此,为了变量赋值能够正确处理预解析,我们需要确保预解析函数正确跟踪变量引用和声明。顶层代码是这个规则的一个例外。脚本的顶层始终是堆分配的,因为变量在脚本之间是可见的。接近运行良好的体系结构的一种简单方法是简单地运行预解析器而无需变量跟踪以快速解析顶级函数;并对内部函数使用完整的解析器,但在编译时跳过它们。这比预解析过程更昂贵,因为我们不需要构建整个AST,但它让我们启动并运行。这正是V8在新版本V8v6.3/Chrome63中所做的。向预解析器解释变量在预解析器中跟踪变量声明和引用很复杂,因为在JavaScript中,某些部分表达式的含义从一开始就不清楚。例如,假设我们有一个带有参数d的函数f,它有一个内部函数g,并且从表达式看来g可能引用d。它最终可能确实引用了d,因为我们看到的标记是析构函数赋值表达式的一部分。它也可能最终成为带有析构函数参数d的箭头函数,在这种情况下,f中的d不会被g引用。最初,我们的预解析器是作为解析器的独立副本实现的,没有太多共享,这导致两个解析器随着时间的推移出现分歧。通过将解析器和预解析器重写为基于实现单一递归模板模式的ParserBase,我们设法最大化共享,同时还保留了单独副本的性能优势。这极大地简化了向预解析器添加完整变量跟踪的过程,因为大部分实现可以在解析器和预解析器之间共享。事实上,忽略变量声明和对顶级函数的引用是不正确的。ECMAScript规范要求在第一次解析脚本时检测各种类型的变量冲突。例如,如果一个变量在同一范围内被声明为词法变量两次,则它被认为是早期语法错误。因为我们的预解析器只是跳过变量声明,所以它会允许代码在预解析期间运行不正确。在这一点上,我们考虑性能获胜来证明违反规范是合理的。既然预解析器正确地跟踪了变量,我们仍然应该在没有明显性能成本的情况下消除这种与变量解析相关的规范违规。跳过内部函数如前所述,当第一次调用预解析函数时,我们会对其进行完整解析并将生成的AST编译为字节码。函数直接指向外层上下文,里面包含了内层函数需要使用的变量声明的值。为了允许延迟编译函数(并支持调试器),上下文指向一个名为ScopeInfo的元数据对象。ScopeInfo对象描述上下文中列出的变量。这意味着在编译内部函数时,我们可以计算变量在上下文链中的位置。然而,要弄清楚延迟编译函数本身是否需要上下文,我们需要再次执行范围解析:我们需要知道嵌套在延迟编译函数中的函数是否引用延迟函数声明的变量。我们可以通过重新解析这些函数来弄清楚。这正是V8在升级到V8v6.3/Chrome63之前所做的。然而,这并不是理想的最佳性能方法,因为它使资源大小和解析成本之间的关系成为非线性关系:我们将解析尽可能多的嵌套函数。除了动态程序的自然嵌套之外,JavaScript打包器还经常以“立即调用的函数表达式”(IIFE)的形式包装代码,这使得大多数JavaScript程序都可以有多层嵌套。每次重新解析都会为解析函数增加至少一次成本。为了避免非线性性能开销,我们甚至在准备期间执行范围范围的解析。我们存储了足够的元数据,以便稍后可以简单地跳过内部函数,而不必重新解析它们。一种方法是存储内部函数引用的变量名。这样做有很高的存储成本,并且需要我们仍然做重复的工作:我们已经在准备过程中执行了变量解析。相反,我们会将每个变量序列化为分配变量的密集标记数组。当我们懒惰地解析一个函数时,变量会按照预解析器看到它们的顺序重新创建,我们可以简单地将元数据应用于这些变量。现在该函数已编译,不需要变量分配元数据,因此可以进行垃圾回收。由于我们只需要为实际包含内部函数的函数使用此元数据,因此大多数函数甚至不需要此元数据,从而显着减少了内存开销。通过跟踪预解析的函数元数据,我们可以完全跳过内部函数。跳过内在函数的性能影响是非线性的,重新准备内在函数的开销也是如此。一些站点将其所有功能提升到顶级范围。由于它们的嵌套层级始终为0,因此开销始终为0。但是,许多现代站点实际上具有许多深度嵌套的功能。当V8v6.3/Chrome63推出该功能时,我们将在这些网站上看到显着的改进。启用此功能的主要优点是,现在代码嵌套的深度无关紧要:任何函数最多预解析一次,完全解析一次[1]。优化了主线程和非主线程的解析时间,以及运行“SkipIntrinsics”前后的解析时间。随时调用函数表达式如前所述,打包器通常通过将模块代码封装在一个闭包中来将多个模块合并到一个文件中,这些模块可以动态调用。这为模块提供了隔离,使它们的行为就像它们是脚本中的唯一代码一样。这些函数本质上是嵌套脚本;执行脚本时会立即调用这些函数。包装器通常以带括号的函数形式提供即时调用函数表达式(IIFE,读作“iffies”),即(function(){…})()。由于在脚本执行过程中会立即需要这些函数,因此预先解析这些函数并不理想。在脚本的顶层执行过程中,我们急需编译这些函数,所以我们将对这些函数进行完整的解析和编译。这意味着我们可以越快地预先解析,代码在运行时启动的速度就越快,而无需不必要的额外成本。你可能会问,为什么不直接编译调用函数呢?虽然开发人员在调用函数时很容易注意到函数,但解析器却并非如此。在解析器开始解析一个函数之前,它需要决定这个函数是需要立即编译还是稍后编译。语法中的歧义使得难以简单地快速扫描到函数的末尾,并且成本很快变得与常规准备相同。所以V8有两种简单的模式,将函数识别为刚刚调用的函数表达式(PIFE,发音为“piffies”),以便快速解析和编译函数:...}),我们假设它会被调用。我们一看到这个模式的开头就做出这个假设,它是(function。在V8v5.7/Chrome57中,我们还检测由UglifyJS生成的模式!function(){...}(),function(){…}(),function(){…}()。一旦我们看到!function或function后面跟着一个PIFE,检测就会起作用。由于V8会立即编译PIFE,因此它们可以用作面向配置文件的反馈[2]通知浏览器启动需要哪些函数。虽然V8还在预解析内部函数,但一些开发者已经注意到JS解析对启动有相当大的影响。optimize-js包会根据静态启发式将函数转换为PIFE.这个包的创建是为了对V8的加载性能产生很大的影响。我们通过在V8v6.1上运行optimize-js提供的基准测试来复制这些结果,你只需要检查缩小的脚本。急切地解析和编译PIFEs结果冷启动和热启动稍快(***和第二页加载,测量总解析+编译+执行时间)。但是,由于显着改进,与V8v6.1相比,它在V8v7.5上的优势要小得多。尽管如此,我们现在不再需要重新解析内在函数,而且由于解析器变得更快,使用optimize-js获得的性能提升也低得多。事实上,v7.5的默认配置已经比v6.1上运行的优化版本快得多。即使在v7.5中,对于启动期间需要的代码,少量的UsingPIFEs仍然有用:我们避免了预解析,因为我们很早就知道会需要这个功能。尽管如此,我们现在不再需要重新解析内部函数,而且由于解析器变得更快了,通过optimize-js获得的性能提升也明显降低了。事实上,v7.5的默认配置已经比v6.1上运行的优化版本快得多。即使在v7.5中,对启动期间需要的代码少量使用PIFE仍然很有用:我们避免预解析,因为我们很早就知道将需要该函数。optimize-js基准测试结果不能准确反映现实。脚本是同步加载的,整个解析+编译时间都计入加载时间。在真实环境中,您可能会使用