优化JavaScript引擎中的原型工作

该材料由马蒂亚斯·比恩斯(Matthias Binens)和本尼迪克特·梅勒(Benedict Meirer)编写,我们今天将其翻译成本。 他们正在使用Google的V8 JS引擎。 本文专门介绍一些基本机制,这些机制不仅适用于V8,而且适用于其他引擎。 熟悉此类机制的内部结构,使那些参与JavaScript开发的人员可以更好地解决代码性能问题。 特别是,这里我们将讨论引擎优化管道的功能,以及如何加快对对象原型属性的访问。



代码优化级别和权衡


在不同的引擎中,将用JavaScript编写的程序文本转换为合适的代码以执行的过程看起来几乎相同。
将源JS代码转换为可执行代码的过程

详细信息可以在这里找到。 另外,应该注意的是,尽管在较高的层次上,不同引擎将源代码转换为可执行文件的流水线非常相似,但是它们的代码优化系统却常常不同。 为什么会这样呢? 为什么某些引擎比其他引擎具有更高的优化级别? 事实证明,引擎必须以一种或另一种方式进行折衷,这是因为引擎可以快速生成效率最高但适合执行的代码,也可以花费更多时间创建此类代码,实现最佳性能。
快速准备要执行的代码和优化的代码,所需的时间更长,但运行速度更快

解释器能够快速生成字节码,但是这种代码通常效率不高。 另一方面,优化的编译器需要更多时间来生成代码,但最终它得到了优化,更快的机器代码。

V8中使用的正是这种准备执行代码的模型。 V8解释器称为Ignition,它是现有解释器中最快的(就执行源字节码而言)。 经过优化的V8编译器称为TurboFan,它负责创建高度优化的机器代码。
点火解释器和TurboFan优化编译器

在程序启动延迟和执行速度之间进行权衡是某些JS引擎具有其他优化级别的原因。 例如,在SpiderMonkey中,在解释器和优化编译器IonMonkey之间,存在一个由基本编译器表示的中间级别(在Mozilla 文档中称为“基准编译器”,但“基准”不是专有名称)。
SpiderMonkey代码优化级别

解释器快速生成字节码,但是这种代码的执行速度相对较慢。 基本编译器需要更长的时间来生成代码,但是该代码已经更快。 最后,优化的IonMonkey编译器会花费最多的时间来生成机器代码,但是可以非常高效地执行该代码。

让我们看一个特定的例子,看看各种引擎的管道如何处理代码。 在此处显示的示例中,有一个“热”循环,其中包含重复多次的代码。

let result = 0; for (let i = 0; i < 4242424242; ++i) {    result += i; } console.log(result); 

V8开始在Ignition解释器中执行字节码。 在某个时间点,引擎发现代码“很热”,并启动TurboFan前端,该前端是TurboFan的一部分,用于分析数据并创建代码的基本机器表示。 然后将数据传递到TurboFan优化器,并在单独的流中进行操作以进行进一步的改进。
V8中的热代码优化

在优化过程中,V8继续在点火中执行字节码。 优化器完成后,我们将提供可执行的机器代码,供将来使用。

SpiderMonkey引擎也开始在解释器中执行字节码。 但是它还有一个由基本编译器表示的附加级别,这导致“热”代码首先到达此编译器这一事实。 它在主线程中生成基本代码,准备就绪后,将转换为执行该代码。
SpiderMonkey中的热代码优化

如果基本代码运行了足够长的时间,SpiderMonkey最终将启动IonMonkey前端和优化器,这与V8中的情况非常相似。 基本代码将继续运行,作为IonMonkey执行的代码优化过程的一部分。 结果,当优化完成时,将执行优化的代码而不是基本代码。

Chakra引擎的体系结构与SpiderMonkey的体系结构非常相似,但是Chakra努力争取更高级别的并发性,以避免阻塞主线程。 Chakra不会解决主线程中的任何编译任务,而是将编译器可能需要的字节码和配置文件数据复制并发送到一个单独的编译过程中。
Chakra中的热代码优化

当SimpleJIT准备的生成代码准备就绪时,引擎将执行它而不是字节码。 重复执行此过程以继续执行FullJIT准备的代码。 这种方法的优点是与复制数据相关的暂停通常比由成熟的编译器(前端)的操作引起的暂停要短得多。 但是,这种方法的缺点在于,启发式复制算法可能会丢失某些信息,这些信息可能对某种优化很有用。 在这里,我们看到一个在接收到的代码质量和延迟之间折衷的例子。

在JavaScriptCore中,所有优化编译任务均与负责执行JavaScript代码的主线程并行执行。 但是,没有复制阶段。 相反,主线程只是在另一个线程中调用编译任务。 然后,编译器使用复杂的锁定方案来访问主线程中的概要分析数据。
JavaScriptCore中“热”代码的优化

这种方法的优点是,它减少了由于执行代码优化任务而导致的主线程的强制阻塞。 该体系结构的缺点是其实现需要解决多线程数据处理的复杂任务,并且在工作过程中要执行各种操作,必须诉诸于锁。

我们刚刚讨论了引擎被迫做出的权衡,在使用解释器的快速代码生成与使用优化的编译器创建快速代码之间进行选择。 但是,这些远非发动机面临的所有问题。 使用内存时,内存是另一个系统资源,您必须诉诸于妥协解决方案。 为了说明这一点,请考虑一个简单的JS程序,该程序会加上数字。

 function add(x, y) {   return x + y; } add(1, 2); 

这是由V8中的Ignition解释器生成的add函数的字节码:

 StackCheck Ldar a1 Add a0, [0] Return 

您无法理解此字节码的含义,实际上,它对我们而言并不是特别有意义。 这里最主要的是它只有四个指令。

当这样的代码很“热”时,TurboFan被占用,它会生成以下高度优化的机器代码:

 leaq rcx,[rip+0x0] movq rcx,[rcx-0x37] testb [rcx+0xf],0x1 jnz CompileLazyDeoptimizedCode push rbp movq rbp,rsp push rsi push rdi cmpq rsp,[r13+0xe88] jna StackOverflow movq rax,[rbp+0x18] test al,0x1 jnz Deoptimize movq rbx,[rbp+0x10] testb rbx,0x1 jnz Deoptimize movq rdx,rbx shrq rdx, 32 movq rcx,rax shrq rcx, 32 addl rdx,rcx jo Deoptimize shlq rdx, 32 movq rax,rdx movq rsp,rbp pop rbp ret 0x18 

如您所见,与上面的四个指令示例相比,代码量非常大。 通常,字节码比机器代码(尤其是优化的机器代码)紧凑得多。 另一方面,需要一个解释器来执行字节码,优化的代码可以直接在处理器上执行。
这是JavaScript引擎不能完全优化所有代码的主要原因之一。 正如我们前面所看到的,创建优化的机器代码需要花费很多时间,而且,正如我们刚刚发现的那样,它需要更多的内存来存储优化的机器代码。
内存使用和优化级别

结果,我们可以说JS引擎具有不同的优化级别的原因在于以下基本问题:在快速代码生成(例如,使用解释器)和快速代码生成(通过优化编译器执行)之间进行选择。 如果我们讨论引擎中使用的代码优化级别,那么代码越多,代码可以进行的优化就越细微,但这是由于引擎的复杂性和系统上的额外负担而实现的。 另外,在这里我们不能忘记代码的优化级别会影响该代码占用的内存量。 这就是为什么JS引擎尝试仅优化“热”功能的原因。

优化对对象原型属性的访问


JavaScript引擎通过使用所谓的对象表单(Shape)和内联缓存(Inline Cache,IC)来优化对对象属性的访问。 可以在材料中阅读有关此内容的详细信息,但简而言之,可以说引擎将对象的形状与对象的值分开存储。
具有相同形状的对象

使用对象形式可以执行称为内联缓存的优化。 对象形式和内联缓存的共同使用使您可以加快在代码的同一位置执行访问对象属性的重复操作。
加快对对象属性的访问

类和原型


现在,我们知道如何加快对JavaScript中对象属性的访问,让我们看一下最近的JavaScript创新之一-类。 这是类声明的样子:

 class Bar {   constructor(x) {       this.x = x;   }   getX() {       return this.x;   } } 

尽管看起来像是JS中一个全新概念的出现,但类实际上只是用于构造对象的原型系统的语法糖,JavaScript一直存在该类:

 function Bar(x) {   this.x = x; } Bar.prototype.getX = function getX() {   return this.x; }; 

在这里,我们将函数写入getX对象的getX属性。 此操作与创建任何其他对象的属性时完全相同,因为JavaScript中的原型是对象。 在基于原型使用的语言(例如JavaScript)中,可以由某种类型的所有对象共享的方法存储在原型中,而各个对象的字段存储在其实例中。

让我们看一下在创建Bar对象的新实例并将其分配给常量foo时在后台发生的情况。

 const foo = new Bar(true); 

执行此类代码后,此处创建的对象的实例将具有包含单个属性x的形式。 foo对象的原型是Bar.prototype ,它属于Bar类。
对象及其原型

Bar.prototype具有自己的形式,其中包含单个getX属性,该属性的值是一个函数,该函数在调用时将返回this.x的值。 原型Bar.prototype的原型是Object.prototype ,它是语言的一部分。 Object.prototype是原型树的根元素,因此其原型为null

现在让我们看看如果创建另一个Bar类型的对象会发生什么。
几个相同类型的对象

如您所见,正如我们已经说过的,作为Bar类的实例的foo对象和qux对象都使用相同形式的对象。 他们两个都使用相同的原型Bar.prototype对象。

访问原型属性


因此,现在我们知道了在声明一个新类并实例化它时会发生什么。 那么调用对象方法呢? 考虑以下代码片段:

 class Bar {   constructor(x) { this.x = x; }   getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); //        ^^^^^^^^^^ 

方法调用可以理解为包含两个步骤的操作:

 const x = foo.getX(); //         : const $getX = foo.getX; const x = $getX.call(foo); 

第一步,加载方法,这只是原型的属性(其值为函数)。 在第二步中,使用this集合调用一个函数。 考虑从foo对象加载getX方法的第一步:
从foo对象加载getX方法

引擎分析foo对象,并发现没有foo对象形式的getX属性。 这意味着引擎需要查看对象的原型链才能找到此方法。 引擎访问Bar.prototype原型并查看该原型的对象形状。 在这里,他在偏移量0处找到了所需的属性。接下来, Bar.prototype存储在Bar.prototype中此偏移量的值,在Bar.prototype检测到JSFunction这正是我们要寻找的。 这样就完成了对方法的搜索。

JavaScript的灵活性使更改原型链成为可能。 例如,像这样:

 const foo = new Bar(true); foo.getX(); // true Object.setPrototypeOf(foo, null); foo.getX(); // Uncaught TypeError: foo.getX is not a function 

在此示例中,我们两次调用foo.getX()方法,但是这些调用中的每一个都有完全不同的含义和结果。 这就是为什么尽管JavaScript原型只是对象,但对于JS引擎而言,加速对原型属性的访问比加速对普通对象自身属性的访问更为困难。

如果我们看现实生活中的程序,事实证明加载原型属性是一种非常常见的操作。 每次调用方法时执行。

 class Bar {   constructor(x) { this.x = x; }   getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); //        ^^^^^^^^^^ 

之前,我们讨论了引擎如何通过使用对象表单和内联缓存来优化对象常规,自定义属性的加载。 如何优化具有相同形状的对象的重复原型属性加载? 上面,我们看到了如何加载属性。
从foo对象加载getX方法

为了通过重复调用该方法来加快对方法的访问,在我们的示例中,您需要了解以下内容:

  1. foo对象的形状不包含getX方法,并且不会更改。 这意味着不能通过向其添加属性,删除它们或更改属性的属性来修改foo对象。
  2. foo原型仍然是原始的Bar.prototype 。 这意味着原型foo不会使用Object.setPrototypeOf()方法或通过将新的原型分配给特殊的_proto_属性来_proto_
  3. Bar.prototype表单包含getX并且不会更改。 也就是说,不会通过删除属性,添加属性或更改其属性来更改Bar.prototype

在一般情况下,这意味着我们需要对对象本身进行1次检查,对每个原型进行2次检查,直到存储所需属性的原型为止。 也就是说,您需要进行1 + 2N次检查(其中N是经过测试的原型的数量),在这种情况下,看起来并不那么糟糕,因为原型链非常短。 但是,引擎通常必须使用更长的原型链。 例如,这是普通DOM元素的典型代表。 这是一个例子:

 const anchor = document.createElement('a'); // HTMLAnchorElement const title = anchor.getAttribute('title'); 

在这里,我们有HTMLAnchorElement ,我们将其称为getAttribute()方法。 表示HTML链接的这个简单元素的原型链包括6个原型! 最有趣的DOM方法不在其自己的原型HTMLAnchorElement 。 它们位于位于链下端的原型中。
原型链

getAttribute()方法可以在Element.prototype找到。 这意味着每次anchor.getAttribute()方法时,引擎都会被强制执行以下操作:

  1. 检查anchor对象本身是否有getAttribute
  2. 验证对象的直接原型是HTMLAnchorElement.prototype
  3. 发现HTMLAnchorElement.prototype没有getAttribute方法。
  4. 验证下一个原型是HTMLElement.prototype
  5. 发现这里没有必要的方法。
  6. 最后,发现下一个原型是Element.prototype
  7. 发现有一个getAttribute方法。

如您所见,这里执行了7次检查。 由于此类代码在Web编程中非常常见,因此引擎使用优化来减少加载原型属性所需的检查次数。

如果我们回到前面的示例之一,我们可以回想一下,当我们调用getX对象的getX方法时,我们执行3次检查:

 class Bar {   constructor(x) { this.x = x; }   getX() { return this.x; } } const foo = new Bar(true); const $getX = foo.getX; 

对于原型链中的每个对象(直到包含所需属性的对象),我们只需要检查对象的形状即可发现是否缺少所需的内容。 如果我们可以通过减少原型检查来检查我们要查找的内容是否存在来减少检查的数量,那将是很好的。 这就是引擎通过一个简单的动作来完成的:引擎不是将原型链接存储在实例本身中,而是以对象的形式存储它。
原型参考存储

每个表格都有一个原型链接。 这也意味着,只要原型foo发生变化,引擎就会移动到对象的新形状。 现在,我们只需要检查对象的形状以了解其中是否存在属性,并注意保护原型链接。

通过这种方法,我们可以将检查次数从1 + 2N减少到1 + N,这将加快对原型属性的访问。 但是,由于此类操作的数量与原型链的长度之间存在线性关系,因此此类操作仍会占用大量资源。 引擎已经实现了各种机制,旨在确保检查次数不取决于原型链的长度(表示为常数)。 在多次加载同一属性的情况下尤其如此。

ValidityCell属性


V8是指专门用于上述目的的原型形式。 每个原型都具有不与其他对象(特别是与其他原型)共享的唯一形状,并且每个原型对象形式都有与之关联的ValidityCell属性。
ValidityCell属性

更改与表单关联的原型或任何覆盖的原型时,此属性被声明为无效。 更详细地考虑此机制。

为了加快从原型加载属性的顺序操作,V8使用了一个内联缓存,其中包含四个字段: ValidityCellPrototypeShapeOffset
内联缓存字段

在第一次运行代码时, Bar.prototype联缓存“预热”期间,V8会记住在原型中找到属性的偏移量,在其中找到属性的原型(在此示例中为Bar.prototype ),对象的形状(在此情况下为foo ) ,此外,还有指向即时原型的当前ValidityCell参数的链接,该链接以对象的形式(在这种情况下,它也是Bar.prototype )。

下次访问内联缓存时,引擎将需要检查对象和ValidityCell的形状。 如果ValidityCell仍然有效,则引擎可以直接利用原型中先前保存的偏移量,而无需执行其他搜索操作。

当原型发生更改时,将创建一个新表单,并将先前的ValidityCell属性声明为无效。 因此,下次尝试访问嵌入式缓存时,它不会带来任何好处,从而导致性能降低。
改变原型的后果

如果我们返回带有DOM元素的示例,这意味着任何更改,例如Object.prototype的原型, Object.prototype会导致Object.prototype本身的内联缓存无效,还会使原型链中位于其下方的任何原型无效。包括EventTarget.prototypeNode.prototypeElement.prototype等,一直到HTMLAnchorElement.prototype
更改Object.prototype的含义

实际上,在代码执行期间修改Object.prototype意味着严重损害性能。 不要这样做。

我们以一个例子研究上述内容。 假设我们具有Bar类和loadX函数,该函数调用从Bar类创建的对象的方法。 我们多次调用loadX函数,并将相同类的实例传递给它。

 function loadX(bar) {   return bar.getX(); // IC  'getX'   `Bar`. } loadX(new Bar(true)); loadX(new Bar(false)); // IC  `loadX`    `ValidityCell`  // `Bar.prototype`. Object.prototype.newMethod = y => y; // `ValidityCell`  IC `loadX`   //    `Object.prototype`  . 

现在, loadXloadX缓存指向Bar.prototype 。 , , Object.prototype — JavaScript, ValidityCell , - , .

Object.prototype — , - , . , :

 Object.prototype.foo = function() { /* … */ }; //    : someObject.foo(); //     . delete Object.prototype.foo; 

Object.prototype , - , . , . - , . , « », , .

, , . . Object.prototype , , - .

, — , JS- - , . . , , . , , , .

总结


, JS- , , , -, ValidityCell , . JavaScript, , ( , , , ).

亲爱的读者们! , - , JS, ?

Source: https://habr.com/ru/post/zh-CN422321/


All Articles