「译」JS 引擎核心: 原型优化

本文转载于:猿2048网站➱https://www.mk2048.com/blog/blog.php?id=hc01b0jkjb

原文链接:JavaScript engine fundamentals: optimizing prototypes

作者序

本系列主要介绍那些 JS 引擎中用到的核心设计。本文的作者是 V8 引擎的开发者 Benedikt and Mathias ,但不用担心,这些内容是适用于各大 JS 引擎的。作为一个 JS 开发者,深入了解 JS 引擎的工作原理可以有助于你去解读自己代码的一些性能特征。

在上一篇文章中(原文译文),我们讨论了 JS 引擎是如何使用 Shapes(形,V8 中对这种数据结构的命名,具体原理请参考上一篇文章) 和 Inline Caches(暂译内联缓存,一种用于优化访问性能的数据结构,具体原理同样参考上一篇文章) 来优化对对象与数组的访问性能的。本文将会解释优化管线中的权衡以及引擎是如何优化原型属性访问的性能的。

**小贴士:**如果你更倾向于看视频来学习,可以跳过本文直接看这个 视频。(需要梯子,推荐使用酸酸乳)

优化层级与执行权衡(Optimization tiers and execution trade-offs)

我们的上一篇文章讨论了现代 JS 引擎都存在着一个相同的运行管线设计:

js-engine-pipeline

我们同时也指出了虽然引擎之间在优化管线设计上总体来说是相同的,这其中还是依然存在着一些差异点的。为什么呢?**为什么一些引擎会比别的引擎使用更多优化层级呢?**我们了解到在尽快开始执行代码与先花费时间然后最终获得更高性能的执行代码之间存在着权衡。

tradeoff-startup-speed

解释器(interpreter)可以快速地产出字节码(bytecode),但是字节码通常都不是那么的高效。一个优化编译器(optimizing compiler)则会花费多一点的时间,但最终产出相比之下非常高效的机器码(machine code)。

这正是 V8 引擎在使用的模型。V8 引擎中的解释器叫做 Ignition,并且他是目前所有引擎里面最快速的解释器(仅就字节码执行速度而言)。V8 的优化编译器叫做 TurboFan,他最终会产出高度优化后的机器码。

tradeoff-startup-speed-v8

这一启动等待时间与运行速度之间的权衡,正是一些 JS 引擎选择在中间增加优化层级的原因。例如 SpiderMonkey 在解释器与他们的优化编译器 IonMonkey 之间增加了 Baseline 层。

tradeoff-startup-speed-spidermonkey

解释器产出字节码的速度很快,但字节码的执行速度相对较慢。Baseline 会多花一点时间去生成代码,但也提供了更佳的运行时性能。然后 IonMonkey 会花更加多的时间去产出机器码,不过机器码在运行时真的非常高效。

让我们通过一个具体的例子来看下不同引擎的运行管线在处理上有什么不同之处。下面是一段在一个长循环中不断被执行的代码。

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

V8 首先会在 Ignition 解释器上运行字节码。然后在某个时间点,引擎会发现这段代码比较 hot(经常被执行),引擎就会启动 TurboFan 前台(frontend)。TurboFan 前台是 TurboFan 的一部分,他会负责合并处理统计数据并且构造一个这段代码的基础机器端描述结构。TurboFan 前台的产出之后就会发送给在另一个线程上的 TurboFan 优化器(optimizer)用于后续优化。

pipeline-detail-v8

在优化器进行优化的期间,V8 会继续使用 Ignition 执行字节码。当优化器完成了他的工作时我们就能得到可执行的机器码,之后将使用机器码继续执行这段逻辑。

SpiderMonkey 引擎也同样首先会在解释器上运行字节码。不过他拥有一层 Baseline 层,所以 hot 的代码会被先发送给 Baseline。Baseline 编译器(compiler)会在主线程上优化生成 Baseline 代码,之后继续执行逻辑。

pipeline-detail-spidermonkey

如果 Baseline 代码之后被多次执行,SpiderMonkey 最终会启动 IonMonkey 前台(frontend),并唤起他的优化器(optimizer)。这块就和 V8 引擎很相似了。Baseline 代码依然会被执行一段时间,直到 IonMonkey 完成了优化工作。之后优化后的代码将会替代掉 Baseline 代码进行执行。

Chakra 的架构和 SpiderMonkey 非常相似。不同的是 Chakra 尝试让更多的内容并行运行以免阻塞主线程。所以 Chakra 不会在主线程上运行任何编译器的组件,他选择复制一份字节码和那些编译器可能会需要用到的分析数据,然后将这些数据发送给一个专门的编译进程。

pipeline-detail-chakra

当代码生成完成之后,引擎将会运行这些 SimpleJIT(Chakra 中间一层的优化编译器)代码来取代之前的字节码。FullJIT(Chakra 最上层的优化编译器)的运作方式也与之相同。这种方法的好处是进行一次复制所产生的暂停时间会远远比运行一次整个编译器或编译器前台短很多。不过这种方法的不足是 **copy heuristic(启发式复制)**可能会造成一些实际优化过程中有用的数据的缺失。所以在一定程度上也可以看作是代码质量与暂停时间之间的权衡。

在 JavaScriptCore 中,所有的优化编译器都是完全并行于 JS 执行的,不存在任何 copy 环节!主线程仅仅会触发(trigger)一个编译任务给另一个编译线程。之后编译器会使用一个复杂的锁结构去主线程上访问分析数据。

pipeline-detail-javascriptcore

这种方法的好处是他减少了代码优化在主线程上的开销。不足是这种方法会引入复杂的多线程问题,并且在进行许多操作时也会产生线程锁带来的开销。

我们上面讨论了许多关于在使用解释器让代码更早开始执行和使用优化编译器让代码执行更高效之间的权衡。然而,还有另一种权衡存在——内存开销!为了能够更形象地理解这点,我们先看下下面这段代码。这段代码的内容很简单,只是将两个数相加。

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

这可真是一长串代码,特别是如果我们将他和上面四行字节码做一下比较的话!通常来说,字节码的含义会倾向于比机器码更复杂,特别是相对于优化后的机器码来说。另一方面,字节码需要一个解释器来执行他,而优化后的机器码可以被处理器直接执行。

这也是 JS 引擎不会去“优化所有代码”的主要原因之一。我们在之前已经学到,生成优化机器码需要花费很长的时间,在此基础上,我们由上面的案例还可以发现优化后的代码将消耗更多内存

tradeoff-memory

总结: 不同的 JS 引擎之所以会有不同数量的优化层级,是因为需要在使用解释器让代码更早开始执行和使用优化编译器让代码执行更高效之间作出权衡。这是一个尺度抉择问题,而添加更多的优化层级将使你可以在额外的复杂度等成本上作出更细粒度的抉择。在此之上,又存在着优化层级与优化代码的内存开销之间的权衡。这也是为什么 JS 引擎只会尝试去优化那些 hot 的方法。

优化原型属性的访问性能

我们的上一篇文章解释了 JS 引擎是如何使用 Shapes 和 Inline Caches 优化对象属性的加载的。总结一下就是,引擎会将对象的 Shapes 和对象的具体内容分开放置。

shape-2

Shapes 则带来了名叫 Inline Caches(可简写为 ICs)的这种优化方法。两者组合使用之后,可以加速代码中同一地方对对象属性的重复访问。

ic-4

类与基于原型的编程

现在我们知道了如何更快访问对象属性,我们再来看一个 JS 家族中刚加入不久的新人:类(Class)。下面是 JS 中定义类的大致语法。

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

虽然这看起来像是 JS 中的一个新概念,但其实仅仅是基于原型编程的一个语法糖。原型大法依然是基业根深蒂固,万古长青。

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

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

这里我们赋值了名为 getX 的属性给 Bar.prototype 对象。这和赋值给其他对象是一样的,因为 JS 中原型也是对象!在类似 JS 这样的基于原型的编程语言中,对象通过原型共享方法,具体字段则是存储在对象实例本身上。

那让我们深入看一下当我们创建一个名为 fooBar 实例的时候,背后发生了哪些事情。

const foo = new Bar(true);

这句代码生成的实例有一个只有 'x' 一个字段的 Shape。foo 的原型是 Bar.prototype,其归属于类 Bar

class-shape-1

这个 Bar.prototype 有一个自己的 Shape,他有一个 'getX' 字段,其值为功能只是返回 this.x 的方法 getXBar.prototype 的原型是 Object.prototype,他是 JS 语言基础的一部分。Object.prototype 是整个原型树的根,所以他的原型是 null

class-shape-2

如果你创建同一个类的另一个实例,如我们之前所说,两个实例将共享同一个 Shape。两个实例的原型也会指向同一个 Bar.prototype 对象。

访问原型属性

Ok,现在我们了解了当我们定义一个类、创建一个实例的时候背后发生了什么。那么当我们调用一个实例上的方法的时候,背后又发生了什么呢?比如像下面的代码这样:

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);

第一步是加载这个方法,这个方法就只是原型上的一个属性(他的值是一个函数)。 第二步是将当前实例作为 this 来调用这个函数。让我们具体看下第一步,将 getX 方法从 foo 实例上读取出来。

method-load

引擎先从 foo 实例开始,发现在 foo 的 Shape 上并没有找到 'getX' 属性,所以他需要顺着原型链向上追溯。然后我们访问到了 Bar.prototype,在他的原型 Shape 上,我们找到了 'getX' 属性在偏移位0 上。我们访问 Bar.prototype 在这个偏移位上的值,发现了 JSFunction getX,这正是我们想要的!

JS 的灵活性让我们有办法修改原型链,例如下面这段代码:

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

Object.setPrototypeOf(foo, null);
foo.getX();
// → Uncaught TypeError: foo.getX is not a function

在这个范例中,我们调用了 foo.getX() 两次,但是每次的含义和结果都是完全不同的。这就是为什么虽然原型在 JS 中也只是普通的对象,但是对 JS 引擎来说,加速原型属性的访问速度要比加速对象自己的属性访问速度更具挑战性。

在那些 JS 程序中,加载原型属性是一个非常常见的操作:每次你调用实例方法就会发生!

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

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

之前我们已经讨论过了引擎是如何通过使用 Shapes 和 Inline Caches 这两种方法来优化普通对象对自身属性的加载速度的。那我们如何优化对拥有类似 Shape 的对象的原型上的属性的重复调用呢?我们在上面已经解释过属性的访问是如何完成的。

prototype-load-checks-1

为了能加速在这种特定条件下的重复调用,我们首先需要确定下面三件事:

  1. foo 对象的 Shape 没有 'getX' 属性,并且不会改变。意思是对象 foo 未发生添加删除一个属性或者是改变其属性内容这样的变更。
  2. foo 的原型依然是初始的 Bar.prototype。意思是未通过 Object.setPrototypeOf() 或是对其 _proto_ 属性赋值来改变 foo 的原型。
  3. Bar.prototype 的 Shape 拥有 'getX' 属性,并且未发生变更。意思是 Bar.prototype 未发生添加删除一个属性或者是改变其属性内容这样的变更。

通常来说,这意味着我们需要先在实例本身上做一次检查,然后再加上对原型链上直到我们找到这个属性之前的每个原型做两次检查。也许你觉得 1+2N 次检查(N 是中间涉及的原型的数量)感觉上并没有什么问题,但你需要知道范例的这个原型链其实是相对较浅的,引擎经常会面对比这长得多的原型链。打个比方,通用的 DOM 类就是一个例子。请看如下代码:

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

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

我们创建了一个 HTMLAnchorElement 然后调用了他的 getAttribute() 方法。这个简单的 anchor 元素的原型链就已经引入了 6 个原型!大多数有意思的 DOM 操作方法并不是直接来自于 HTMLAnchorElement 原型的,而是在原型链中更上层的位置。

anchor-prototype-chain

你会在 Element.prototype 上找到 getAttribute() 方法。这意味着每次我们调用 anchor.getAttribute() 的时候,JS 引擎都需要...

  1. 检查 'getAttribute' 并不存在于 anchor 对象上,
  2. 获取到下一级的原型是 HTMLAnchorElement.prototype
  3. 检查 'getAttribute' 不在这层原型上,
  4. 获取到下一级的原型是 HTMLElement.prototype
  5. 检查 'getAttribute' 也不再这层原型上,
  6. 最后获取到下一级的原型是 Element.prototype
  7. 在他上面终于找到了 'getAttribute'

总共七步检查!因为这一类型的代码在 web 编程中真的非常常见,引擎使用了一些小技巧去减少原型属性访问所必须的检查次数。

让我们回到之前的一个例子,我们访问 foo'getX' 属性时总共进行了三次检查:

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

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

在最终找到那个带有我们需要的属性的原型之前,每一个对象我们都需要进行 Shape 的属性检查。如果我们能通过将原型检查收纳进属性检查的方式减少检查的次数,就能优化一部分性能。而这也大体上就是引擎正在使用的技巧:引擎会将原型的引用存放在 Shape 里而不是实例本身上

prototype-load-checks-2

每一个 Shape 都会指向原型。这也意味着每当 foo 的原型发生改变,引擎将会为他替换一个新的 Shape。现在我们不管是确认属性是否存在还是获取原型的引用,都只需要检查对象的 Shape 就可以了。

通过这一方法,我们可以加速原型属性访问,将所必须的检查次数从 1+2N 次减少到 1+N 次。但是这开销依然挺大的,毕竟这还是相对原型链深度线性递增的。引擎实现了另外一些方法去尽可能得将检查次数减少到一个固定数量,特别是针对那些之后会经常运行的属性访问操作。

有效性验证单元(Validity cells)

V8 为了这一目标,对原型的 Shapes 做了特别的处理。每个原型都有一个独一无二的 Shape,这个 Shape 不和其他对象共享(特别是不与其他原型共享),每一个原型 Shape 有一个特殊的 ValidityCell 与之关联。

validitycell

每当这个 ValidityCell 关联的原型发生了变更,或是更上层的原型发生了变更,他就会被标记无效。让我们具体看下整个过程是怎么样的。

为了加速后续的原型属性访问,V8 创建了一个带有四个字段的 Inline Caches:

ic-validitycell

在第一次运行代码对 Inline Cache 做预热的时候,V8 记录下了原型中该属性的位置偏移量、该属性所属的原型对象(该例子中就是 Bar.prototype)、当前实例的 Shape(该例子中就是 foo 的 Shape)以及当前实例的 Shape 最相近的那个原型当前的有效性验证单元的引用(该例子中是 Bar.prototype 的有效性验证单元)。

当下一次这个 Inline Cache 被命中生效时,引擎会首先检查当前实例的 Shape 以及他的 ValidityCell。如果依然是标记有效的,引擎就可以直接跳过多余的查询,通过记录的属性位置偏移量(Offset)属性所属原型对象(Prototype)访问到这个属性。

validitycell-invalid

当原型发生了变更,则会导致一个新的 Shape 被分配到该原型,而原来的那个 ValidityCell 也会被标记为无效的。这样下次运行时 Inline Cache 就无法起到作用了,从而造成一个低下的访问性能。

让我们再回到之前那个 DOM 元素的例子。如果我们对 Object.prototype 进行了修改,这不光光会使 Object.prototype 自己的 Inline Caches 被标记无效,还会使他下面的 EventTarget.prototype, Node.prototype, Element.prototype 等等直到 HTMLAnchorElement.prototype 为止的这些原型的 Inline Caches 都被标记无效。

prototype-chain-validitycells

由此可见,在运行时去修改 Object.prototype 基本等于是放弃性能了。所以千万不要这么做!

让我们通过一个具体的例子来再了解一下。现在有一个 Bar 类,还有一个函数 loadX 会去调用 Bar 上的一个方法。我们会传入这个类的几个实例来多次调用 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 函数内的 Inline Cache 现在指向了 Bar.prototypeValidityCell。如果你后面做了类似修改 Object.prototype 这样的操作,Object.prototype 在 JS 中是所有原型链的根,这样操作会使我们的 ValidityCell 变无效,下次再命中现在这个 Inline Caches 时,他就已经没有作用了。失去了 Inline Caches 的优化,我们只能回到低下的访问性能了。

修改 Object.prototype 永远都是一个不建议的做法,这会让引擎在那个时间点之前创建的所有原型属性访问 Inline Caches 都失效。我们再来看看另一个关于不要做什么的例子:

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

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

delete Object.prototype.foo;

我们首先扩展了 Object.prototype,这会导致引擎在这之前创建的所有原型 Inline Caches 都失效。然后我们运行了一些调用了这个新的原型方法的代码。在这期间引擎将从头开始查询并且为所有的原型属性访问创建 Inline Caches。最后,我们进行清场,删除了之前添加的那个原型方法。

清场听起来是一个很好的做法,对吧?但是在这个场景下他只会让情况变得更糟!删除属性的操作再次修改了 Object.prototype,因此所有刚才创建的 Inline Caches 又再一次失效了,引擎需要从查询开始再做一遍。

**总结:**虽然原型就只是对象,但是 JS 引擎为了优化原型方法查找性能对其有一些特殊的处理方式。请不要去动这些原型!如果你真的需要去改动原型,那么请在其他代码运行之前完成改动,这样你至少不会在代码运行的时候无效掉引擎所有的优化。

小结(Take-aways)

我们已经学习了 JS 引擎是如何存储对象与类的,已经引擎是如何利用 Shapes, Inline Caches 和 ValidityCells 来优化原型上的操作的。基于这些知识,我们可以归纳出一条能帮助我们优化代码性能的 JS 编程实践技巧:不要随意改动原型(如果你真的真的需要,那么至少在其他代码运行之前干这个事)。

译者记

这篇文章大体上可以分成两块。

第一部分向我们解释了为什么 JS 引擎不会优化所有的代码,以及为什么每个 JS 引擎的设计会有一些不同。这些知识点在译者的视角上有几点比较有意思。

  1. V8 的解释器是最高效的。
  2. 优化的一个点在如何应用并发优势。
  3. 那些经常被执行的代码更加有机会被引擎优化,所以编程时要做好逻辑的设计规划。

第二部分通过一些简单情况下的引擎具体行为,向我们展示了引擎在对原型链访问时是怎么进行优化的。最重要的是这些优化都是基于某些场景假设下才成立的,所以如果你希望你的代码运行得更高效,请尽量遵守这些假设,保持良好的代码习惯。

Class 这种语法很好地包装了原型链,语法还更简洁易懂,推荐大家使用。

JS 作为动态语言很灵活,但是有时候这种灵活的代价是性能。如果你在意运行时性能请保持克制,不要随意修改原型链内容。

全文完,谢谢观众老爷们。

猜你喜欢

转载自www.cnblogs.com/qianduanwriter/p/11784313.html
今日推荐