先划重点:单态调用(MONOMORPHIC)性能优于多态(POLYMORPHIC)与复态(MEGAMORPHIC)!
此章节虽非主线(对象)部分,但对于了解 V8 机制(以及对象访问模式)还是很有帮助的,建议阅读。
如对此不感兴趣,也可直接跳过阅读下一章:奇技淫巧学 V8 之四,迁移对象至快速模式
我们先来看一个读取对象属性值的方法 getX:
function getX(point) {
return point.x;
}
我们可以粗略的认为上述代码在 V8 中等同于如下的代码:
function getX(point) {
return Runtime_Load(point, "x");
}
假设传入值始终为普通对象(非 Proxy 、undefined 等),用伪代码来表达 V8 Runtime_Load,可以简写成类似如下的过程:
- 从对象的 <Map> 中获得 instance_descriptors。
- instance_descriptors 遍历获取到 key 存储的位置(在对象内还是在 properties 中)。
- 调用特定的方法读取属性值。
用(JavaScript)伪代码来表达:
function Runtime_Load(obj, key) {
var desc = obj.map().instance_descriptors();
var desc_number = -1;
for (var i = 0; i < desc.length; i++) {
if (desc.GetKey(i) === key) {
desc_number = i;
break;
}
}
if (desc_number === -1) {
return undefined;
}
var detail = desc.GetDetails(desc_number);
if (detail.is_inobject()) {
return obj.READ_FIELD(detail.offset());
} else {
return obj.properties().get(detail.outobject_array_index());
}
}
单次这么执行并没有任何问题,但如果它被执行 100次、1000 次、10000 次 ……
for (var i = 0; i < 10000; i++) {
getX({x : i});
}
我们发现绝大情况下调用 getX 所传入的对象结构 JSObject<Map> (Hidden Class)都相同 ,属性 #x 的存取位置也相同。
基于这个发现,我们将属性 #x 的存取位置与其对应的 JSObject<Map> (引用)存储下来,这样下回就无需调用 Runtime_Call 即可获得属性 #x 的存取位置,节省了大量的查找过程。
也就是说,可以简写成类似如下的过程:
- 判断对象的 <Map> 与之前缓存的是否相同。
- 如相同,直接从缓存的位置读取。
- 如不同,调用 Runtime_Load。
用(JavaScript)伪代码来表达:
function getX(point) {
return LoadIC_x(point);
}
其中 LoadIC_x:
function LoadIC_x(obj) {
if (obj.map() === cache.map) {
if (cache.offset >= 0) {
return obj.READ_FIELD(cache.offset);
} else {
return obj.properties().get(cache.index);
}
} else {
return Runtime_LoadIC_Miss(obj, "x");
}
}
同时也修改下 Runtime_Load 的实现(不仅获取属性值,还缓存其找到的属性值存取位置)命名为 Runtime_LoadIC_Miss:
function Runtime_LoadIC_Miss(obj, key) {
// … …
cache = {map : obj.map()};
if (detail.is_inobject()) {
cache.offset = detail.offset();
// … …
} else {
cache.index = detail.outobject_array_index();
// … …
}
}
这样是不是这样就可以满足所有需要优化的场景了?
我们来看个例子:
for (var i = 0; i < 10000; i++) {
if (i % 2) {
getX({x : 1});
} else {
getX({x : 1, y : 2});
}
}
在例子中,由于对象的 <Map> 不停变化导致上述缓存方案命中率为 0。
我们修改下 Runtime_LoadIC_Miss 将缓存由单一存储改为哈希表模式,将其遇到的 <Map> 及其属性值存取位置全部缓存下来。
function Runtime_LoadIC_Miss(obj, key) {
// … …
var cache = {map : obj.map()};
if (detail.is_inobject()) {
cache.offset = detail.offset();
caches.push(cache);
// … …
} else {
cache.index = detail.outobject_array_index();
caches.push(cache);
// … …
}
}
并修改下 LoadIC_x 的实现:
function LoadIC_x(obj) {
for (var i = 0; i < caches.length; i++ ) {
if (obj.map() === caches[i].map) {
if (caches[i].offset >= 0) {
return obj.READ_FIELD(caches[i].offset);
} else {
return obj.properties().get(caches[i].index);
}
}
}
return Runtime_LoadIC_Miss(obj, "x");
}
这样无论对象结构( <Map> )如何变化总能命中缓存,缓存的命中率就上升了,可与此同时( LoadIC_x )判断分支数量的上升也会导致(比起单一缓存)花费的时间增多。
小知识:
我们把 point.x 称之为一个 callsite,把 "x" 称之为一条名为 "x" 的消息,而实际执行的 point (无论是 {x : 1},还是 {x : 1, y : 2})称之为这个 callsite 的 receiver。
我们来总结下,JavaScript 中很多操作的执行过程异常复杂,但对于特定调用(callsite) 来说 receiver 类型(<Map>)的变化一般很小,V8 采用内联缓存(Inline Caches,简称 IC)来缓存调用的实现以优化这些操作的执行过程。
V8 采用不同类型的内联缓存(IC)对不同类型的代码执行进行优化,有如下类型的 IC:
- AnyLoad
- LoadIC
- KeyedLoadIC
- LoadGlobalIC
- AnyStore
- StoreIC
- KeyedStoreIC
- StoreGlobalIC
- CallIC
- BinaryOpIC
- CompareIC
- ToBooleanIC
obj[expression] 会创建 Keyed 相关 IC
obj.x // LoadIC(x)
obj["x"]; // LoadIC(x)
obj.x = 1; // StoreIC(x)
obj["x"] = 1; // StoreIC(x)
var key = "_";
obj[key + "x"]; // KeyedLoadIC(x)
obj[key + "x"] = 1; // KeyedStoreIC(x)
IC 根据(遇到)接收到的类型信息(receiver type)不同而呈现不同的状态:
- 当 IC 刚被创建时为初态(没有调用过),没有接收到任何一种类型信息。
- 当被调用时:
- 如果接收到 1 种类型信息会迁移到单态模式(MONOMORPHIC)。
- 收到大于 1 种小于 4 种类型信息会迁移到多态模式(POLYMORPHIC)。
- 当接收到大于 4 种类型信息时会迁移到复态模式(MEGAMORPHIC)。
由于 V8 定义 kMaxPolymorphicMapCount = 4,kMaxKeyedPolymorphism = 4,所以多态与复态模式模式的界限在接收到 4 种类型信息时。
从文章开头的 2 个例子中可以看出,单态调用(MONOMORPHIC)性能优于多态(POLYMORPHIC)与复态(MEGAMORPHIC)!
最后,多态内联缓存(PICs)并非由 V8 发明,理论部分在 Optimizing Dynamically-Typed Object-Oriented Languages With Polymorphic Inline Caches [PDF]。
同时这也是 PGO(FDO)优化很重要的一部分(为优化编译器 TurboFan 搜集类型信息),理论部分在 Profile-guided optimization。
至于为什么通过将对象设置成为函数原型后会被优化成为快速模式?以及与内联缓存的关系?欢迎收看下一章(主线):