让我们谈谈JavaScript单态(二)
未讨论的:
在撰写本文时,故意略去了一些实现细节,以免它在内容上过去宽泛。
形状
我们没有讨论对象的形状(即隐藏类)是如何表示,如何得到的以及如何和对象绑定在一起的。可以看下过去写的这篇 post on inline caches 以及我过去的一些会谈,比如AWP2014 来获得一些基本的了解。
这里需要你知道的一件重要的事是:JavaScript VMs中的形状是一种启发式的近似(实际并不存在,但类比),它试图动态的发掘出程序中隐藏的静态结构。并且有时候,对于人来说某些对象具有相似的形状,但是VMs来说则不然。
function A() { this.x = 1 }
function B() { this.x = 1 }
var a = new A,
b = new B,
c = { x: 1 },
d = { x: 1, y: 1 }
delete d.y
// a, b, c, d 在V8中都具有不同的形状。
JavaScript对象的易扩展性,使得创建意外的多态变得异常容易
function A() {
this.x = 1;
}
var a = new A(), b = new A(); // same shape
if (something) {
a.y = 2; // shape of a no longer matches b.
}
故意的多态
即使你使用的编程语言只允许你创建固定类型的对象(Java, C#, Dart, C++, etc),你仍然可以实现多态代码:
interface Base {
void doX();
}
class A implements Base {
void doX() { }
}
class B implements Base {
void doX() { }
}
void handle(Base obj) {
obj.foo();
}
handle(new A());
handle(new B());
// obj.foo() callsite is polymorphic
能够针对接口编程,并且通过继承实现不同的对象行为是一种很重要的抽象机制。静态类型编程语言的多态实现与上面我们说的具有相似的性能表现。
并非所有的缓存都是一样的
记住并非所有的缓存都是基于(对象)形状的,以及它们的容量都比较低。例如, 与函数调用关联的缓存可能是未初始化,单态或者megamorphic态的,但不存在位于它们中间的多态状态。如果去缓存函数的形状,就会与函数的调用无关,所以对于函数会去缓存函数的调用目标—即函数本身。
function inv(cb) {
return cb(0)
}
function F(v) { return v }
function G(v) { return v + 1 }
inv(F)
// inline cache is monomorpic, points to F
inv(G)
// inline cache is megamorphic
如果在cb(…)调用处的内联缓存还是单态的时候去优化inv,这时候优化器可会内联这个调用(对于小的,并且调用频繁的函数这个技术具有重要的作用)(译注:即内联函数体)。当这个缓存是megamorphic 态,优化器就无法内联任何函数了(它不知道该内联哪一个,目标存在多个),转而在IR中留下一个通用的调用操作。
还有一种函数调用情况,就是o.m(…) 这种和属性访问相似的形式。这种函数调用情况下,内联缓存会存在单态和megamorphic态中间的多态形式。V8能够和属性访问相同的方式构建IR:选择决策树或者一个位于内联函数体之前的多态类型守卫。然而这里有一个限制:V8要能够内联函数调用,就需要把函数看作某种形状,就像对象形状一样。
(译注:实际上o.m(…)会编译成两个IC,一个LoadIC负责属性加载,另外一个CallIC 负责这个消息的接受者)
function inv(o) {
return o.cb(0)
}
var f = {
cb: function F(v) { return v },
};
var g = {
cb: function G(v) { return v + 1 },
};
inv(f)
inv(f)
// here inline cache is monomorpic, have seen only objects with
// a shape like f.
inv(g)
// here inline cache is polymorphic, seen objects with two different
// shapes: like f and like g
你可能会奇怪上面的f和g拥有不同的形状(译注:因为会类比上面单态的例子)。出现这种情况是因为当我们把函数分配给一个属性时,V8会尝试(如果可能)将函数附加到对象的形状是,而不是将其保存在对象说(译注:就像保存属性的值一样)。在这个例子中,f的形状是这样:{cb: F},即形状本身指向闭包。在我们之前的例子中,我们所说的形状,只是通过一定偏移量去访问属性的存在,并同时获取到属性的值。这使得V8的形状类似于像Java或者C++这样的语言中的类,其中类本质上一组子段和方法。
当然,如果稍后你用不同的函数去覆盖函数属性,V8会切换它的形状像这样:
var f = {
cb: function F(v) { return v },
};
// Shape of f is {cb: F}
f.cb = function H(v) { return v - 1 }
// Shape of f is {cb: *}
总的来说,V8如何构建并维护形状(隐藏类)这个话题本身值得好好讨论研究。
属性的路径
在此刻,与属性访问关联的内联缓存似乎是一个将形状映射到属性偏移的字典上,就像Dictionary
// pseudo-code reimagining o = { x: 1 }
var o = {
get x () {
return $LoadByOffset(this, offset_of_x)
},
set x (value) {
$StoreByOffset(this, offset_of_x, value)
}
// both getter and setter are generated internally by VM
// and are invisible to normal JS code.
};
$StoreByOffset(this, offset_of_x, 1)
根据这一观察结果,很明显IC应该更类似Dictionary
预单态状态:
在V8中,还存在一种介于未初始化和单态之间的预单态(premonomorphic )状态。这是为了避免只执行一次的内联缓存桩代码。这里不讨论这个,因为这是一个有点模糊的实现细节(译注:想了解可以参考扩展阅读二,PIC)
最后关于性能的建议
最好的性能建议隐藏类 Dale Carnegie’s的书名中:“如何停止担忧并开始生活”(译注:原文为How to Stop Worrying and Start Living)
事实上,担心多态性通常是徒劳的。你应该在你真实的项目中进行基准测试,对有问题的热点进行分析,如何它和JS有关–去看看编译器生成的IR。
如果你看到了诸如XYZGeneric 的IR指令,或者任何标有红色的[*] (又名可以成为一切)的标记,然后(只有这样)你应该开始担心多态或什么的。
扩展阅读:
一,V8中的多态内联缓存PIC
二,Explaining JavaScript VMs in JavaScript - Inline Caches
三, Optimizing Dynamically-Typed Object-Oriented Languages WithPolymorphic Inline Caches