V8引擎详解(五)——内联缓存

5,374 阅读6分钟

前言

本文是V8引擎详解系列的第五篇,重点内容是关于V8引擎的内联缓存,V8之所以可以高效的运行,其内部实现了很多优化策略,其中 内联缓存 就是其中很重要的一个优化策略,本文会从一个小问题开始一起探究到底什么是 内联缓存(Inline Cache) ,简称 IC。文末会有已经完成的系列文章的链接,本系列文章还在不断更新欢迎持续关注。

先抛一个问题

我们先用一个看一个小例子

let length = 10000;
let obj0 = {x: 1, y: 2, z: 3};
function func(o) { 
    for(let i in o) {
        o[i].toString();
    }
}
console.time('t0');   // 计时开始
for (let i = 0; i < length; i++) {
    let obj1 = {x: 3, y: 2}; // 为了保证创建对象消耗时间保持一致
    obj1[i] = 3;
    func(obj0);
}
console.timeEnd('t0'); // 计时结束

我们先看一下结果
t0: 8.047119140625ms

然后我们再看下一段代码 唯一的区别只是func调用的是obj1 如下:

console.time('t1');   // 计时开始
for (let i = 0; i < length; i++) {
    let obj1 = {x: 3, y: 2};
    obj1[i] = 3;
    func(obj1);
}
console.timeEnd('t1'); // 计时结束

我们再来看一下结果
t1: 14.747314453125ms

我们可以看到消耗的时间差异,而我们今天要说的内容就是产生这种差异的主要原因 内联缓存的机制

内联缓存

什么是内联缓存

首先内联缓存(后面称IC)也并不是V8首创,这项技术也很古老了,最初是应用在Smalltalk虚拟机上。IC的原理简单来说就是在运行过程中,收集一些数据信息,将这部分信息缓存起来然后在再次执行的时候可以直接利用这些信息,有效的节省了再次获取这些信息的消耗,从而提高性能。

举个例子:比如我们的使用一个对象obj = {x: 1, y: 2}的时候,如果我们调用了obj.x 我们会将obj.x缓存起来,当我们再次调用obj.x的时候直接使用缓存好的信息就可以了,而不用再重新获取obj.x的值。

内联缓存是怎么运作的

我们可以通过分析一段字节码的运行来看一下内联缓存的运作,如果不了解字节码执行的同学可以先看V8引擎详解(四)——字节码是如何执行的 先来了解一下。
先来看下面一段代码

function test(obj) {
 obj.y = 4;
 obj.x += 2;
 return obj.x;
}
test({x: 1, y: 2});

将function转成字节码的结构如图:

我们分析一下这段字节码(本文重点在于IC所以会侧重于涉及IC部分):

  • 进入函数先进行栈的检查,然后会将小数字4存入累加器。

  • 将累加器的值传给 a0[0] (obj.y), 同时将 **a0[0] (obj.y)**的信息缓存到 反馈向量 表中的第0个 slot插槽)中。

  • 加载 a0[1] (obj.x) 的值到累加器中同时将 **a0[1] (obj.x)**的信息缓存到 反馈向量 表中的第2个 slot插槽)中。

  • 将累加器中的值加2,将结果值缓存到反馈向量 表中的第4个 slot插槽)中,然后将累加器中的值赋予到a0[1] (obj.x) ,并将信息缓存到反馈向量 表中的第5个 slot插槽)中。

  • 最后当我们将obj.x中的值直接通过缓存取出到累加器中并将累加器中的值返回。

运行过程并不复杂,本质上就是标记一些调用点,然后为他们分配一个插槽缓存起来,当再次调用的时候直接通过缓存获取值。

内联缓存的单态与多态

事实上我们在调用函数的时候,可以通过缓存信息提高函数的执行效率,但是前提是我们传参的结构是固定的,那如果传递参数的结构不是固定的内联缓存要如何处理呢?

这个就涉及到我们开篇的那个问题了,回到代码来看:

console.time('t0');   // 计时开始
for (let i = 0; i < length; i++) {
    let obj1 = {x: 3, y: 2}; // 为了保证创建对象消耗时间保持一致
    obj1[i] = 3;
    func(obj0);
}
console.timeEnd('t0'); // 计时结束

这段代码中func调用的是固定的结构 obj0 = {x: 1, y: 2, y: 3},所以可以通过内联缓存加速在执行上效率大大提高。
但是第二段代码中:

console.time('t1');   // 计时开始
for (let i = 0; i < length; i++) {
    let obj1 = {x: 3, y: 2};
    obj1[i] = 3;
    func(obj1);
}
console.timeEnd('t1'); // 计时结束

func调用的obj2 每次执行结构都是变化的(i的值一直在变),那么v8是如何处理的

这里面就涉及到了多态内联缓存Polymorphic Inline Cache)也就是PIC,所谓的PIC就是在同一个 Slot 位置上,不仅只缓存一份数据如图:

(图片来源:李兵老师的专栏图解V8引擎

第一次执行函数的时候,v8会将对象的一些信息记录到slot中,第二次执行函数会将第一次记录的信息和第二次的信息进行比较,如果相同直接调用,如果不同会将这部分信息记录在同一个位置。依次类推一个slot会记录多份信息(当然也是有一定数量限制的)。 同一个slot记录多个信息的情况就可以称之为PIC多态内联缓存,而多态内联缓存可能会进行多次的比较操作,自然性能上不如单态内联缓存,也就是为什么开篇中第二段函数执行的比第一段来的慢。

总结

本文主要学习了V8引擎中的内联缓存,同时也解释了在分析字节码的时候经常会看到最后面那个额外的值(反馈向量)的作用。实际上,在我们实际的开发过程中内联缓存的多态情况是不可避免的,V8针对这种情况也做了大量的优化,其实绝大部分情况是完全感受不到差异的,我们写代码的时候了解就可以了,没必要针对这个特意进行优化。
如果有什么错误,请在评论中和作者一起讨论,如果您觉得本文对您有帮助请帮忙点个赞,感激不尽。

参考文章

time.geekbang.org/column/arti…

系列文章

V8引擎详解(一)——概述
V8引擎详解(二)——AST
V8引擎详解(三)——从字节码看V8的演变
V8引擎详解(四)——字节码是如何执行的
V8引擎详解(五)——内联缓存
V8引擎详解(六)——内存结构
V8引擎详解(七)——垃圾回收机制
V8引擎详解(八)——消息队列
V8引擎详解(九)——协程&生成器函数