奇技淫巧学 V8 之三,多态内联缓存 PICs

1,066 阅读4分钟

先划重点:单态调用(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,可以简写成类似如下的过程:

  1. 从对象的 <Map> 中获得 instance_descriptors。
  2. instance_descriptors 遍历获取到 key 存储的位置(在对象内还是在 properties 中)。
  3. 调用特定的方法读取属性值。

用(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 的存取位置,节省了大量的查找过程。

也就是说,可以简写成类似如下的过程:

  1. 判断对象的 <Map> 与之前缓存的是否相同。
  2. 如相同,直接从缓存的位置读取。
  3. 如不同,调用 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

至于为什么通过将对象设置成为函数原型后会被优化成为快速模式?以及与内联缓存的关系?欢迎收看下一章(主线):