迹忆客 专注技术分享

当前位置:主页 > 学无止境 > 编程语言 >

JavaScript 引擎基础:优化原型链

作者:迹忆客 最近更新:2022/12/27 浏览次数:

本文描述了所有 JavaScript 引擎共有的一些关键基础知识——而不仅仅是 V8,作者(Benedikt 和 Mathias)使用的引擎。 作为一名 JavaScript 开发人员,深入了解 JavaScript 引擎的工作原理有助于我们推断代码的性能特征。

这篇文章中我们讨论了 JavaScript 引擎如何通过使用形状和内联缓存来优化对象和数组访问。 本文解释了优化管道的权衡,并描述了引擎如何加速对原型属性的访问。

我们之前的文章讨论了现代 JavaScript 引擎如何拥有相同的整体管道:

js 引擎管道

我们还指出,尽管引擎之间的高层流水线相似,但优化流水线往往存在差异。 这是为什么? 为什么有些引擎比其他引擎有更多的优化层? 事实证明,在快速运行代码和花费更多时间但最终以最佳性能运行代码之间存在权衡。

js 引擎 tradeoff startup speed

解释器可以快速生成字节码,但字节码通常效率不高。 另一方面,优化编译器需要更长的时间,但最终会生成更高效的机器代码。

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

js 引擎 tradeoff startup speed v8

启动延迟和执行速度之间的这种权衡是一些 JavaScript 引擎选择在两者之间添加优化层的原因。 例如,SpiderMonkey 在解释器和完整的 IonMonkey 优化编译器之间添加了一个基线层:

js engine tradeoff startup speed spidermonkey

解释器生成字节码很快,但是字节码执行起来比较慢。 Baseline 生成代码的时间稍长,但它提供了更好的运行时性能。 最后,IonMonkey 优化编译器生成机器代码的时间最长,但该代码可以非常高效地运行。

让我们看一个具体的例子,看看不同引擎中的管道是如何处理的。 这是一些在热循环中经常重复的代码。

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

V8 开始在 Ignition 解释器中运行字节码。 在某个时候,引擎确定代码是热的并启动 TurboFan 前端,这是 TurboFan 的一部分,用于处理集成分析数据和构建代码的基本机器表示。 然后将其发送到不同线程上的 TurboFan 优化器以进行进一步改进。

js 引擎 pipeline detail v8

当优化器运行时,V8 继续在 Ignition 中执行字节码。 在某个时候优化器完成并且我们有可执行的机器代码,并且可以继续执行。

更新 :从 Chrome 91(2021 年发布)开始,V8 在 Ignition 解释器和 TurboFan 优化编译器之间增加了一个名为 Sparkplug 的编译器。

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

js engine pipeline detail spidermonkey

如果 Baseline 代码运行了一段时间,SpiderMonkey 最终会启动 IonMonkey 前端,并启动优化器——与 V8 非常相似。 在 IonMonkey 进行优化时,它一直在 Baseline 中运行。 最后,当优化器完成时,将执行优化代码而不是基线代码。

Chakra 的架构与 SpiderMonkey 的非常相似,但 Chakra 试图并发运行更多的东西以避免阻塞主线程。 Chakra 不是在主线程上运行编译器的任何部分,而是复制编译器可能需要的字节码和分析数据,并将其发送到专用的编译器进程。

js engine pipeline detail chakra

当生成的代码准备就绪时,引擎开始运行此 SimpleJIT 代码而不是字节码。 FullJIT 也是如此。 这种方法的好处是,与运行完整编译器(前端)相比,发生复制的暂停时间通常要短得多。 但这种方法的缺点是复制启发式可能会遗漏某些优化所需的信息,因此它在某种程度上以代码质量换取延迟。

JavaScriptCore 中,所有优化编译器都与主 JavaScript 执行完全并发运行; 没有复制阶段! 相反,主线程只是在另一个线程上触发编译作业。 然后,编译器使用复杂的锁定方案从主线程访问分析数据。

js engine pipeline detail javascriptcore

这种方法的优点是它减少了主线程上 JavaScript 优化引起的卡顿。 缺点是需要处理复杂的多线程问题,需要为各种操作付出一定的锁开销。

我们已经讨论了像使用解释器一样快速生成代码或使用优化编译器快速生成代码之间的权衡。 但是还有另一个权衡:内存使用! 为了说明这一点,这里有一个简单的 JavaScript 程序,可以将两个数字相加。

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 引擎 tradeoff memory

简介 :JavaScript 引擎之所以具有不同的优化层,是因为在像使用解释器一样快速生成代码或使用优化编译器生成快速代码之间进行基本权衡。 这是一个规模,添加更多优化层允许您以额外的复杂性和开销为代价做出更细粒度的决策。 此外,在优化级别和生成代码的内存使用之间存在权衡。 这就是为什么 JavaScript 引擎试图只优化热门函数。


优化原型属性访问

我们之前的文章解释了 JavaScript 引擎如何使用形状和内联缓存优化对象属性加载。 回顾一下,引擎将对象的形状与对象的值分开存储。

js 引擎shape 2

形状启用称为内联缓存或简称 IC 的优化。 Shapes 和 IC 相结合可以加快代码中同一位置的重复属性访问。

js 引擎 ic 4

类和基于原型的编程

现在我们知道了如何快速访问 JavaScript 对象的属性,让我们看看最近添加到 JavaScript 中的一项:类。 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”的形状。 foo 的原型是属于 Bar 类的 Bar.prototype

js 引擎类 shape 1

这个 Bar.prototype 有自己的形状,包含一个属性“getX”,其值为函数 getX,调用时只返回 this.xBar.prototype 的原型是 Object.prototype,它是 JavaScript 语言的一部分。 Object.prototype 是原型树的根,因此它的原型为空。

js 引擎类 shape 2

如果我们创建同一个类的另一个实例,这两个实例共享对象形状,正如我们之前讨论的那样。 两个实例都指向同一个 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();

// is actually two steps:

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

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

js 引擎方法加载

引擎从 foo 实例开始,并意识到 foo shape 上没有“getX”属性,因此它必须为它沿着原型链向上走。 我们到达 Bar.prototype,查看其原型形状,并看到它在偏移量 0 处具有“getX”属性。我们在 Bar.prototype 中查找此偏移量处的值并找到我们正在寻找的 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();
//        ^^^^^^^^^^

这篇文章中我们讨论了引擎如何通过使用形状和内联缓存来优化加载常规的自有属性。 我们如何优化具有相似形状的对象的原型属性的重复加载? 我们在上面看到了属性加载是如何发生的。

js engine prototype load checks 1

为了在这种特殊情况下快速重复加载,我们需要知道这三件事:

  1. foo 的形状不包含 'getX' 并且没有改变。 这意味着没有人通过添加或删除属性或更改属性属性之一来更改对象 foo。
  2. foo 的原型还是最初的 Bar.prototype。 这意味着没有人通过使用 Object.setPrototypeOf() 或分配给特殊的 __proto__ 属性来更改 foos 原型。
  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() 方法。 这个简单的锚元素的原型链已经涉及了6个原型! 大多数有趣的 DOM 方法不在直接的 HTMLAnchorElement 原型上,而是在链的上层。

js engine anchor 原型链

getAttribute() 方法位于 Element.prototype 中。 这意味着每次我们调用 anchor.getAttribute() 时,JavaScript 引擎都需要……

  • 检查“getAttribute”不在锚对象本身上,
  • 检查直接原型是否为 HTMLAnchorElement.prototype
  • 断言那里没有'getAttribute',
  • 检查下一个原型是 HTMLElement.prototype
  • 断言那里也没有'getAttribute',
  • 最终检查下一个原型是Element.prototype
  • 并且那里存在“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 上。

js engine prototype load checks 2

每个形状都指向原型。 这也意味着每次 foo 的原型发生变化时,引擎都会转换为新的形状。 现在我们只需要检查对象的形状来断言某些属性不存在,同时保护原型链接。

通过这种方法,我们可以将所需的检查次数从 1+2N 减少到 1+N,以便更快地访问原型属性。 但这仍然非常昂贵,因为它在原型链的长度上仍然是线性的。 引擎实施不同的技巧以进一步将此减少到恒定数量的检查,特别是对于相同属性加载的后续执行。

有效单元

V8 为此专门处理原型形状。 每个原型都有一个独特的形状,不与任何其他对象共享(特别是不与其他原型共享),并且每个原型形状都有一个与之关联的特殊 ValidityCell

js engine validitycell

只要有人更改关联的原型或它上面的任何原型,此 ValidityCell 就会失效。 让我们来看看它是如何工作的。

为了加快原型的后续加载速度,V8 放置了一个内联缓存,其中包含四个字段:

js 引擎 ic validitycell

在此代码的第一次运行期间预热内联缓存时,V8 会记住在原型中找到该属性的偏移量、找到该属性的原型(本例中为 Bar.prototype)、实例的形状 (在这种情况下是 foo 的形状),以及从实例形状(在这种情况下也恰好是 Bar.prototype)链接到的直接原型的当前 ValidityCell 的链接。

下次命中 Inline Cache 时,引擎必须检查实例的形状和 ValidityCell。 如果它仍然有效,引擎可以直接访问原型上的偏移量,跳过额外的查找。

js engine validitycell invalid

当原型改变时,分配一个新的形状,并且使之前的 ValidityCell 失效。 因此,内联缓存在下次执行时会丢失,从而导致性能下降。

回到之前的 DOM 元素示例,这意味着对例如 Object.prototype 不仅会使 Object.prototype 本身的内联缓存失效,还会使下面的任何原型失效,包括 EventTarget.prototypeNode.prototypeElement.prototype 等等,一直到 HTMLAnchorElement.prototype

js engine prototype chain validitycells

实际上,在运行代码时修改 Object.prototype 意味着将性能抛到窗外。 不要这样做!

让我们用一个具体的例子来进一步探讨这个问题。 假设我们有 Bar 类,我们有一个调用 Bar 对象方法的函数 loadX。 我们使用同一类的实例多次调用此 loadX 函数。

class Bar { /* … */ }

function loadX(bar) {
    return bar.getX(); // IC for 'getX' on `Bar` instances.
}

loadX(new Bar(true));
loadX(new Bar(false));
// IC in `loadX` now links the `ValidityCell` for
// `Bar.prototype`.

Object.prototype.newMethod = y => y;
// The `ValidityCell` in the `loadX` IC is invalid
// now, because `Object.prototype` changed.

loadX 中的内联缓存现在指向 Bar.prototypeValidityCell。 如果您随后执行诸如改变 Object.prototype 之类的操作——它是 JavaScript 中所有原型的根——ValidityCell 将变得无效,并且现有的内联缓存在下次被命中时会丢失,从而导致性能下降。

改变 Object.prototype 总是一个坏主意,因为它会使引擎在此之前放置的原型加载的任何内联缓存无效。 这是不该做的另一个例子:

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

// Run critical code:
someObject.foo();
// End of critical code.

delete Object.prototype.foo;

我们扩展了 Object.prototype,它使引擎在此之前放置的任何原型内联缓存无效。 然后我们运行一些使用新原型方法的代码。 引擎必须从头开始并为任何原型属性访问设置新的内联缓存。 最后,我们“自行清理”并删除我们之前添加的原型方法。

清理听起来是个好主意,对吧‽好吧,在这种情况下,它会使糟糕的情况变得更糟! 删除该属性会修改 Object.prototype,因此所有 Inline Caches 都将再次失效,引擎必须再次从头开始。

总结 :虽然原型只是对象,但它们被 JavaScript 引擎特殊对待,以优化原型上方法查找的性能。 别管你的原型了! 或者,如果你真的需要接触原型,那么在其他代码运行之前进行,这样你至少不会在代码运行时使引擎中的所有优化无效。

最后

我们了解了 JavaScript 引擎如何存储对象和类,以及形状、内联缓存和 ValidityCells 如何帮助优化原型操作。 基于这些知识,我们确定了一个有助于提高性能的实用 JavaScript 编码技巧:不要弄乱原型(或者如果你真的、真的需要,那么至少在其他代码运行之前这样做)。

转载请发邮件至 1244347461@qq.com 进行申请,经作者同意之后,转载请以链接形式注明出处

本文地址:

相关文章

扫一扫阅读全部技术教程

社交账号
  • https://www.github.com/onmpw
  • qq:1244347461

最新推荐

教程更新

热门标签

扫码一下
查看教程更方便