关于前端:JavaScript-Weekly-399JavaScript引擎基础下优化原型

40次阅读

共计 7943 个字符,预计需要花费 20 分钟才能阅读完成。

🥳 欢送有趣味的小伙伴,一起做点有意义的事!本文译者:道道里

我发动了一个 周刊翻译打算,仓库地址,拜访地址

当初还很缺气味相投的小伙伴,纯属个人兴趣,当然对于晋升英语和前端技能也会有帮忙,要求:英语不要差的离谱、github 纯熟应用、有恒心、虚心、对本人做的事负责。

想参加的小伙伴,能够 wx 私信,也能够给仓库发 issue 留言,我博客也有具体的集体联系方式:daodaolee.cn

本文形容 JavaScript 引擎中通用的一些要害的基础知识——不仅仅是 V8。作为一名 JavaScript 开发人员,对 JavaScript 引擎的工作原理深刻理解一下有助于你更好的编写代码。

如果你没有看之前的文章:JavaScript 引擎根底(上):状态和内联缓存,请务必看下,本篇有很多相干名词在前文都有介绍。

之前的文章,咱们探讨了 JavaScript 引擎如何通过应用 Shape内联缓存 来优化对象和数组拜访。本文来说一下如何优化 管道(pipeline) 的衡量,并会讲述引擎如何放慢对原型属性的拜访。

优化层级和执行衡量

之前的文章探讨了古代 JavaScript 引擎如何领有雷同的管道:

咱们还提出了,尽管引擎之间的高层管道有点相似,但优化管道这方面往往存在差别。这是为什么?为什么有些引擎比其余引擎有更多的优化层级?事实证明,在运行代码的最快速度和最佳性能之间存在着某种衡量:

解释器能够疾速生成字节码,但字节码通常效率不高。另一方面,优化编译器须要花更长的工夫执行,不过最终会产生更高效的机器代码。

这正是 V8 应用的模型。V8 的解释器称为 火花塞 (Ignition),它是所有引擎中最快的解释器(就原始字节码执行速度而言)。V8 的优化编译器名为 涡轮风扇(TurboFan),它最终会生成高度优化的机器代码:

启动提早和执行速度之间的衡量是一些 JavaScript 引擎抉择在两者之间增加优化层的起因。例如,SpiderMonkey 在解释器和 IonMonkey 优化编译器之间增加了一个基准层:

解释器疾速生成字节码,但字节码执行速度绝对较慢。Baseline 生成代码尽管须要更长的工夫,但它提供了更好的运行时性能。最初,IonMonkey 优化编译器生成机器代码的工夫最长,不过该代码能够十分高效地运行。

让咱们看一个具体的例子,看看不同引擎中的管道如何解决它。这是一些在热循环中常常反复的代码:

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

V8 开始在 火花塞 (Ignition) 解释器中运行字节码。在某个时刻,引擎确定代码达到了 hot 的水平,并启动 涡轮风扇 (TurboFan),这是 涡轮风扇 (TurboFan) 解决集成剖析数据和构建代码的根本机器示意的局部。而后将其发送到不同线程上的 涡轮风扇(TurboFan) 优化器以进行进一步改良:

当优化器运行时,V8 持续在 火花塞(Ignition) 中执行字节码。在某些时候优化器实现了,咱们有了可执行的机器代码,并且能够继续执行。

从 Chrome 91(2021 年公布)开始,V8 在 Ignition 解释器和 TurboFan 优化编译器之间减少了一个名为 Sparkplug 的编译器。

SpiderMonkey 引擎也开始在解释器中运行字节码。但它有额定的基准层,这意味着热代码首先发送到基准层,基准编译器在主线程上生成基准代码并在筹备好后继续执行。

基准代码运行一段时间后,SpiderMonkey 最终会启动 IonMonkey,并启动优化器 — 这与 V8 十分类似。在 IonMonkey 进行优化时,它会持续在基准层中运行。最初,当优化器实现时,将会执行优化代码而不是基准代码。

Chakra 的架构与 SpiderMonkey 的架构十分类似,但 Chakra 会尝试同时运行更多的货色以防止阻塞主线程。Chakra 没有在主线程上运行编译器的任何局部,而是复制出编译器可能须要的字节码和数据,并将其发送到专用的编译器过程:

当生成的代码筹备好后,引擎开始运行这个 SimpleJIT 代码而不是字节码。FullJIT 也是如此。这种办法的益处是,与运行 FullJIT 编译器相比,复制产生的暂停工夫通常要短得多。但这种办法的毛病是这种复制可能会脱漏某些优化所需的信息,因而它会在某种程度上升高代码品质来换取提早。

在 JavaScriptCore 中,所有优化编译器与主线程 JavaScript 齐全并发 运行,留神!这里没有复制!相同,主线程仅触发另一个线程上的编译作业。而后编译器应用简单的锁定计划从主线程拜访剖析数据:

这种办法的长处是它缩小了因为 JavaScript 优化在主线程上引起的卡顿,毛病是它须要解决简单的多线程问题并为各种操作付出一些锁定的老本。

到这里为止,咱们曾经探讨了应用解释器疾速生成代码或应用优化编译器生成疾速代码之间的衡量。然而还有另一个衡量:内存应用!为了阐明这一点,上面是一个代码例子,它会将两个数字相加:

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

add(1, 2);

这是咱们应用 V8 中的 Ignition 解释器为 add 函数生成的字节码:

StackCheck
Ldar a1
Add a0, [0]
Return

不必齐全看懂字节码,能够简略看出它只有四个指令。

当代码状态变成 hot 时,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 引擎不只有“优化所有代码”。正如咱们之前看到的,生成优化的机器码须要很长时间,最重要的是,咱们刚刚理解到 优化的机器码也须要更多的内存

简介:JavaScript 引擎具备很多的优化层的起因是因为须要应用解释器疾速生成代码,也须要应用优化编译器生成疾速代码。这是一个范畴化的货色,增加更多优化层能够让你从 额定的复杂性 / 开销 更细粒度的决策 之间做出本人的抉择。此外, 优化的级别 生成代码的内存应用 之间也存在衡量。这就是 JavaScript 引擎尝试 只优化热函数(hot function) 的起因。

优化原型属性拜访

之前的文章 解释了 JavaScript 引擎如何应用 Shapes 和 IC 优化对象属性加载。回顾一下,引擎将对象的 Shape 与 对象的值 离开存储:

Shapes 反对一种称为 行内缓存(Inline Caches,IC) 的优化。联合起来,Shapes 和 IC 能够放慢代码中同一地位的反复属性拜访。

类 (Classes) 和基于原型 (prototype) 的编程

当初咱们晓得了如何在 JavaScript 对象上快速访问属性,让咱们看看 JavaScript 中最近增加的一个:类(Classes)。JavaScript 类语法如下所示:

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

只管这仿佛是 JavaScript 中的一个新概念,但它只是基于原型的编程的语法糖,始终在 JavaScript 中应用:

function Bar(x) {this.x = x;}

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

在这里,咱们在 Bar.prototype 对象上调配了一个 getX 属性。这与任何其余对象的工作形式完全相同,因为原型也只是 JavaScript 中的对象!在 JavaScript 等一系列基于原型的编程语言中,办法通过原型共享,而字段存储在具体的实例中。

当咱们创立一个名为 foo 的新 Bar 实例时,看看幕后产生了什么:

const foo = new Bar(true);

运行下面的代码创立的实例具备单个属性 “x” 的 Shape。foo 的原型是 Bar 类的 Bar.prototype

Bar.prototype 有它本人的 Shape,蕴含一个属性 “getX”,属性的值是函数 getX,它在调用时只返回 this.xBar.prototype 的原型是 JavaScript 语言的 Object.prototype Object.prototype 是原型树的根,因而它的原型为 null

如果你创立了另一个类的另一个实例,则两个实例都在咱们之前探讨的状况下共享对象 Shape:两个实例都指向同一个 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);

第 1 步是加载办法,它只是原型上的一个属性(其值恰好是一个函数),第 2 步是以值为 this 的实例调用函数。让咱们来看看第一步,即从实例 foo 加载办法 getX

引擎从 foo 实例开始,并发现 foo 的 Shape 上没有 'getX' 属性,因而它必须遍历原型链。咱们达到 Bar.prototype,查看它的原型 Shape,看到它在 offset 0 处具备 “getX” 属性。咱们在 Bar.prototype 中查找此 offset 处的值,并找到咱们正在寻找的 JSFunction getX。整个过程就是这样!

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 中的对象,但对于 JavaScript 引擎来说,减速原型属性拜访比减速惯例对象上本人的属性拜访更有难度。

看看这个代码,加载原型属性是一个十分频繁的操作:每次调用办法时都会产生!

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

const foo = new Bar(true);
const x = foo.getX();
//        ^^^^^^^^^^

之前,咱们探讨了引擎如何通过应用 Shape 和 内联缓存 来优化加载惯例的、本人的属性。那咱们如何优化反复加载具备类似 Shape 的对象的原型属性?下面的图中咱们看到了属性加载是如何产生的:

为了在这种非凡状况下放慢反复加载速度,咱们须要晓得这三点:

  1. foo 的 Shape 不蕴含 'getX' 并且没有做扭转。这意味着没有人通过增加、删除或更改属性来更改对象 foo
  2. foo 的原型依然是最后的 Bar.prototype。这意味着没有人通过应用 Object.setPrototypeOf() 或设置非凡的 __proto__ 属性来更改 foo 原型。
  3. Bar.prototype 的 Shape 蕴含 “getX” 并且没有做扭转。这意味着没有人通过增加、删除、或更改属性之来批改 Bar.prototype

在个别状况下,这意味着咱们必须对实例自身执行 1 次查看,再加上每个原型的 2 次查看,直到领有咱们正在寻找的属性的原型。1+2N 查看(N 是波及的原型的数量)在这种状况下可能听起来并不算太坏,因为原型链绝对较浅 — 但引擎通常必须解决更长的原型链,例如在常见 DOM 的状况下类。就像上面这个例子:

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

const title = anchor.getAttribute('title');

咱们有一个 HTMLAnchorElement 并在其上调用 getAttribute() 办法。这曾经波及到 6 个原型!大多数好用的 DOM 办法不在间接的 HTMLAnchorElement 原型上,而是在链的更高层:

getAttribute() 办法能够在 Element.prototype 上找到。这意味着每次咱们调用 anchor.getAttribute() 时,JavaScript 引擎须要做 …..

  1. 查看 “getAttribute” 是否不在 anchor 对象自身上
  2. 查看间接原型是 HTMLAnchorElement.prototype
  3. 确定那里没有“getAttribute”
  4. 查看下一个原型是 HTMLElement.prototype
  5. 确定那里也没有 “getAttribute”
  6. 最终查看下一个原型是 Element.prototype
  7. 并且那里存在 “getAttribute”

总共有 7 次查看!这种代码在网络上很常见,引擎会利用一些技巧来缩小原型上属性加载所需的查看次数。

再回到后面的例子,咱们在 foo 上拜访 'getX' 时总共执行了 3 次查看:

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

const foo = new Bar(true);
const $getX = foo.getX;

每个携带该属性的原型对象,都须要进行 Shape 查看以查找是否缺失。如果咱们能够通过把 原型查看 变成 缺勤查看,以此来缩小查看次数,那就太棒了。实质上这就是引擎应用了一个简略技巧做的事件:引擎不是将原型链存储在实例自身上,而是将其存储在 Shape 中。

每个 Shape 都指向了原型。这也意味着每次 foo 的原型发生变化时,引擎都会转换为新的 Shape。当初咱们只须要查看一个对象的 Shape 来断言某些属性是否缺失并爱护原型链接。

通过这种办法,咱们能够将所需的查看次数从 1+2N 缩小到 1+N,从而更快地拜访原型的属性。但这种形式代价也不小,原型链越长,代价也越高。引擎实现了不同的技巧来进一步缩小查看的数量,特地是对于雷同属性加载的后续执行。

无效单元格(ValidityCell)

V8 专门为这种原型 Shape 做解决。每个原型都有一个独特的 Shape,它不与任何其余对象共享(特地是不与其余原型共享),并且这些原型 Shape 中的每一个都有一个与之关联的非凡的 无效单元格(ValidityCell)

每当有人更改相干原型或它下面的任何原型时,此 ValidityCell 都会生效。让咱们来看看它是如何工作的。

为了放慢原型的后续加载,V8 搁置了一个内联缓存,其中蕴含四个字段:

在这段代码的第一次运行到预热内联缓存时,V8 会记住在原型中找到属性的 offset、找到属性的原型(以后是 Bar.prototype)、实例的 Shape(以后是 foo 的 Shape),以及从实例 Shape 链接到的间接原型的以后 ValidityCell 的链接(以后是 Bar.prototype)。

下次命中内联缓存时,引擎必须查看实例的 Shape 和 ValidityCell。如果它依然无效,引擎能够间接拜访原型上的 offset,跳过额定的查找:

当原型发生变化时,调配一个新的 Shape,之前的 ValidityCell 就生效了。所以 Inline Cache 在下次执行时会失落,导致性能变差。

回到之前的 DOM 元素示例,这意味着对 Object.prototype 来说,不仅会使 Object.prototype 自身的内联缓存生效,还会使上面的任何原型生效,包含 EventTarget.prototypeNode.prototypeElement.prototype 等,始终到 HTMLAnchorElement.prototype

实际上,在运行代码时批改 Object.prototype 意味着优先不思考性能。尽量不要这样做!

让咱们通过一个具体的例子来进一步探讨这一点。假如咱们有咱们的类 Bar,并且咱们有一个函数 loadX,它调用 Bar 对象的办法。咱们用同一个类的实例屡次调用这个 loadX 函数:

class Bar {/* … */}

function loadX(bar) {return bar.getX(); // Bar 实例上的 getX 的 IC。}

loadX(new Bar(true));
loadX(new Bar(false));
// loadX 中的 IC 当初为 Bar.prototype 链接 ValidityCell。Object.prototype.newMethod = y => y;
// loadX IC 中的 ValidityCell 当初有效,// 因为 Object.prototype 产生了变动。

loadX 中的内联缓存当初指向 Bar.prototype 的 ValidityCell。如果你之后做一些像扭转 Object.prototype 之类的事件。ValidityCell 就会生效,并且现有的内联缓存在下次被命中时会失落,从而导致性能降落。

尽量不要扭转 Object.prototype,因为它会使引擎在此之前搁置的原型加载的任何内联缓存生效。这是另一个谬误的例子:

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

someObject.foo();

delete Object.prototype.foo;

咱们扩大了 Object.prototype,它使引擎在此之前搁置的任何原型内联缓存都生效。而后咱们运行一些应用新原型办法的代码。引擎必须从头开始并为所有原型属性拜访设置新的内联缓存。最初,咱们“自行清理”并删除咱们之前增加的原型办法。

“自行清理”看起来不错,然而在这种状况下,它会使状况变得更糟!删除该属性会批改 Object.prototype,因而所有内联缓存都会再次生效,引擎必须再次从头开始。

总结:尽管原型只是对象,但它们被 JavaScript 引擎非凡解决,从而优化原型上办法查找的性能。别管你的原型了!或者,如果你真的须要接触原型,那么在其余代码运行之前进行操作,这样你至多不会在代码运行时使引擎中的所有优化有效。

最初

咱们曾经理解了 JavaScript 引擎如何存储对象和类,以及 Shapes、Inline Caches 和 ValidityCells 如何帮忙优化原型操作。基于这些常识,咱们确定了一个实用的 JavaScript 编码技巧,能够帮忙进步性能:不要弄乱原型(或者如果你真的十分须要,那么至多在其余代码运行之前这样做)。

相干材料

JavaScript engine fundamentals: optimizing prototypes

翻译打算原文

正文完
 0