关于javascript:Google-V8系列三V8提升函数执行效率的策略Inline-Cache内联缓存

42次阅读

共计 4170 个字符,预计需要花费 11 分钟才能阅读完成。

咱们曾经晓得,V8 在查找对象的属性(例如 o.x)时流程是这样的:查找对象 o 的暗藏类,再通过暗藏类查找 x 属性偏移量,而后依据偏移量获取属性值。
剖析如下代码:

function loadX(o) {return o.x}
var o = {x: 1,y:3}
var o1 = {x: 3 ,y:6}
for (var i = 0; i < 90000; i++) {loadX(o)
    loadX(o1)
}

在这段代码中 loadX 函数会被重复执行,那么获取 o.x 流程也须要重复被执行。有没有方法再度简化这个查找过程,最好能一步到位查找到 x 的属性值呢?答案是,有的。
能够看到,函数 loadX 在一个 for 循环外面被反复执行了很屡次,因而 V8 会想尽一切办法来压缩这个查找过程,以晋升对象的查找效率。这个减速函数执行的策略就是内联缓存 (Inline Cache),简称为 IC。

Inline Cache 原理
在 V8 执行函数的过程中,会察看函数中一些调用点 (CallSite) 上的要害的两头数据,而后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就能够间接利用这些两头数据,节俭了再次获取这些数据的过程,因而 V8 利用 IC,能够无效晋升一些反复代码的执行效率。
IC 会为每个函数保护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些要害的两头数据。
对于函数和反馈向量的关系图:

反馈向量其实就是一个表构造,它由很多项组成的,每一项称为一个插槽 (Slot),V8 会顺次将执行 loadX 函数的两头数据写入到反馈向量的插槽中。比方上面这段函数:

function loadX(o) { 
 o.y = 4
 return o.x
}

当 V8 执行这段函数的时候,它会判断 o.y = 4 和 return o.x 这两段是调用点 (CallSite),因为它们应用了对象和属性,那么 V8 会在 loadX 函数的反馈向量中为每个调用点调配一个插槽。每个插槽中包含了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、暗藏类 (map) 的地址、还有属性的偏移量,比方下面这个函数中的两个调用点都应用了对象 o,那么反馈向量两个插槽中的 map 属性也都是指向同一个暗藏类的,因而这两个插槽的 map 地址是一样的。

当 V8 执行 loadX 函数时,loadX 函数中的要害数据是如何被写入到反馈向量中的?
loadX 的代码如下所示:

function loadX(o) {return o.x}
loadX({x:1})

咱们将 loadX 转换为字节码:

StackCheck
LdaNamedProperty a0, [0], [0]
Return

loadX 函数的这段字节码很简略,就三句:

  • 第一句是查看栈是否溢出;
  • 第二句是 LdaNamedProperty,它的作用是取出参数 a0 的第一个属性值,并将属性值放到累加器中;
  • 第三句是返回累加器中的属性值。

这里重点关注 LdaNamedProperty 这句字节码,它有三个参数。
a0 就是 loadX 的第一个参数。
第二个参数 [0] 示意取出对象 a0 的第一个属性值。
第三个参数就和反馈向量无关了,它示意将 LdaNamedProperty 操作的两头数据写入到反馈向量中,方括号两头的 0 示意写入反馈向量的第一个插槽中。具体你能够参看下图:

察看上图,咱们能够看出,在函数 loadX 的反馈向量中,曾经缓存了数据:

  • 在 map 栏,缓存了 o 的暗藏类的地址;
  • 在 offset 一栏,缓存了属性 x 的偏移量;
  • 在 type 一栏,缓存了操作类型,这里是 LOAD 类型。(在反馈向量中,咱们把这种通过 o.x 来拜访对象属性值的操作称为 LOAD 类型)

V8 除了缓存 o.x 这种 LOAD 类型的操作以外,还会缓存存储 (STORE) 类型和函数调用 (CALL) 类型的两头数据。
为了剖析前面两种存储模式,咱们再来看上面这段代码:

function foo(){}
function loadX(o) { 
    o.y = 4
    foo()
    return o.x
}
loadX({x:1,y:4})

相应的字节码如下所示:

StackCheck
LdaSmi [4]
StaNamedProperty a0, [0], [0]
LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]
LdaNamedProperty a0, [2], [6]
Return

下图是这段字节码的执行流程:

从图中能够看出,o.y = 4 对应的字节码是:

LdaSmi [4]
StaNamedProperty a0, [0], [0]

这段代码是先应用 LdaSmi [4],将常数 4 加载到累加器中,而后通过 StaNamedProperty 的字节码指令,将累加器中的 4 赋给 o.y,这是一个存储 (STORE) 类型的操作,V8 会将操作的两头后果寄存到反馈向量中的第一个插槽中。
调用 foo 函数的字节码是:

LdaGlobal [1], [2]
Star r0
CallUndefinedReceiver0 r0, [4]

解释器首先加载 foo 函数对象的地址到累加器中,这是通过 LdaGlobal 来实现的,而后 V8 会将加载的两头后果寄存到反馈向量的第 3 个插槽中,这是一个存储类型的操作。接下来执行 CallUndefinedReceiver0,来实现 foo 函数的调用,并将执行的两头后果放到反馈向量的第 5 个插槽中,这是一个调用 (CALL) 类型的操作。
最初就是返回 o.x,return o.x 仅仅是加载对象中的 x 属性,所以这是一个加载 (LOAD) 类型的操作,咱们在下面介绍过的。最终生成的反馈向量如下图所示:

当初有了反馈向量缓存的数据,那 V8 是如何利用这些数据的呢?当 V8 再次调用 loadX 函数时,比方执行到 loadX 函数中的 return o.x 语句时,它就会在对应的插槽中查找 x 属性的偏移量,之后 V8 就能间接去内存中获取 o.x 的属性值了。这样就大大晋升了 V8 的执行效率。

多态和超态
通过缓存执行过程中的根底信息,就可能晋升下次执行函数时的效率,然而这有一个前提,那就是屡次执行时,对象的形态是固定的,如果对象的形态不是固定的,那 V8 会怎么解决呢?
咱们调整一下下面这段 loadX 函数的代码,调整后的代码如下所示:

function loadX(o) {return o.x}
var o = {x: 1,y:3}
var o1 = {x: 3, y:6,z:4}
for (var i = 0; i < 90000; i++) {loadX(o)
    loadX(o1)
}

咱们能够看到,对象 o 和 o1 的形态是不同的,这意味着 V8 为它们创立的暗藏类也是不同的。
第一次执行时 loadX 时,V8 会将 o 的暗藏类记录在反馈向量中,并记录属性 x 的偏移量。
当再次调用 loadX 函数时,V8 会取出反馈向量中记录的暗藏类,并和新的 o1 的暗藏类进行比拟,发现不是一个暗藏类,那么此时 V8 就无奈应用反馈向量中记录的偏移量信息了。
面对这种状况,V8 会抉择将新的暗藏类也记录在反馈向量中,同时记录属性值的偏移量,这时,反馈向量中的第一个槽里就蕴含了两个暗藏类和偏移量。如下图所示:

当 V8 再次执行 loadX 函数中的 o.x 语句时,同样会查找反馈向量表,发现第一个槽中记录了两个暗藏类。这时,V8 须要额定做一件事,那就是拿这个新的暗藏类和第一个插槽中的两个暗藏类来一一比拟,如果新的暗藏类和第一个插槽中某个暗藏类雷同,那么就应用该命中的暗藏类的偏移量。如果都不雷同,就在反馈向量的第一个插槽中再增加新的信息。
一个反馈向量的一个插槽中能够蕴含多个暗藏类的信息,那么有如下定义:

  • 如果一个插槽中只蕴含 1 个暗藏类,那么咱们称这种状态为单态 (monomorphic);
  • 如果一个插槽中蕴含了 2~4 个暗藏类,那咱们称这种状态为多态 (polymorphic);
  • 如果一个插槽中超过 4 个暗藏类,那咱们称这种状态为超态 (magamorphic)。

如果函数 loadX 的反馈向量中存在多态或者超态的状况,其执行效率必定要低于单态的,比方当执行到 o.x 的时候,V8 会查问反馈向量的第一个插槽,发现外面有多个 map 的记录,那么 V8 就须要取出 o 的暗藏类,来和插槽中记录的暗藏类一一比拟,如果记录的暗藏类越多,那么比拟的次数也就越多,这就意味着执行效率越低。
比方插槽中蕴含了 2~4 个暗藏类,那么能够应用线性构造来存储,如果超过 4 个,那么 V8 会采取 hash 表的构造来存储,这无疑会拖慢执行效率。单态、多态、超态等三种状况的执行性能如下图所示:

尽量放弃单态

IC 就是 V8 为每个函数增加了一个缓存,当第一次执行该函数时,V8 会将函数中的存储、加载和调用相干的两头后果保留到反馈向量中。当再次执行时,V8 就要去反馈向量中查找相干两头信息,如果命中了,那么就间接应用两头信息。
理解了 IC 的根底执行原理,咱们就能得出一些最佳实际:单态的性能优于多态和超态,所以须要尽量避免多态和超态的状况。要防止多态和超态,那么就尽量默认所有的对象属性是不变的,比方你写了一个 loadX(o) 的函数,那么当传递参数时,尽量不要应用多个不同形态的 o 对象。

总结

  • 尽管暗藏类可能减速查找对象的速度,然而在 V8 查找对象属性值的过程中,仍然有查找对象的暗藏类和依据暗藏类来查找对象属性值的过程。
  • V8 引入了 IC,IC 会监听每个函数的执行过程,并在一些要害的中央埋下监听点,这些包含了加载对象属性 (Load)、给对象属性赋值 (Store)、还有函数调用 (Call),V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的构造中,同时 V8 会为每个执行的函数保护一个反馈向量。有了反馈向量缓存的长期数据,V8 就能够缩短对象属性的查找门路,从而晋升执行效率。
  • 反馈向量就是一个表构造,它由很多项组成的,每一项称为一个插槽 (Slot);V8 为每一个调用点 (CallSite) 调配一个插槽(Slot)。
  • 针对函数中的同一段代码,如果对象的暗藏类是不同的,那么反馈向量也会记录这些不同的暗藏类,这就呈现了多态和超态的状况。在理论我的项目中,要尽量避免呈现多态或者超态的状况。
  • 尽管暗藏类和 IC 能晋升代码的执行速度,然而在理论的我的项目中,影响执行性能的因素十分多,找出那些影响性能瓶颈才是至关重要的,你不须要适度关注微优化,你也不须要适度担心你的代码是否毁坏了暗藏类或者 IC 的机制,因为绝对于其余的性能瓶颈,它们对效率的影响可能是微不足道的。

正文完
 0