当对不同对象中的函数使用相同的键时,V8 中的函数调用缓慢

slow function call in V8 when using the same key for the functions in different objects

可能不是调用慢,而是查找慢;我不确定,但这里有一个例子:

var foo = {};
foo.fn = function() {};

var bar = {};
bar.fn = function() {};

console.time('t');

for (var i = 0; i < 100000000; i++) {
    foo.fn();
}

console.timeEnd('t');

在win8.1上测试

现在这是有趣的部分,如果我将 bar.fn 更改为 bar.somethingelse

最近 v8 出了什么问题?这是什么原因造成的?

对象字面量按结构 I.E. 共享隐藏 class("map" in v8 内部术语)相同顺序的相同命名键,而对象 从不同的构造函数创建将有不同的隐藏 class,即使构造函数将它们初始化为完全相同的字段。

foo.fn() 生成代码时,在编译器中您通常无法访问特定的 foo 对象,只能访问其隐藏的 class。从隐藏的 class 你可以访问 fn 函数但是因为 共享隐藏 class 实际上可以在 fn 属性 具有不同的功能,这是不可能的。所以因为你不知道在编译时会调用哪个函数,所以你不能 inline the call.

如果您 运行 带有跟踪内联标志的代码:

$ /c/etc/iojs.exe --trace-inlining test.js
t: 651ms

但是,如果您更改任何内容,使 .fn 始终具有相同的功能,或者 foobar 具有不同的隐藏 class:

$ /c/etc/iojs.exe --trace-inlining test.js
Inlined foo.fn called from .
t: 88ms

(我通过在 bar.fn 赋值 bar.asd = 3 之前 来做到这一点,但是有很多不同的方法可以实现它,例如您肯定知道的构造函数和原型是实现高性能的途径 javascript)

要查看版本之间的变化,运行 此代码:

var foo = {};
foo.fn = function() {};

var bar = {};
bar.fn = function() {};

foo.fn();
console.log("foo and bare share hidden class: ", %HaveSameMap(foo, bar));

如您所见,node10 和 iojs 的结果不同:

$ /c/etc/iojs.exe --allow-natives-syntax test.js
foo and bare share hidden class:  true

$ node --allow-natives-syntax test.js
foo and bare share hidden class:  false

我最近没有详细关注 v8 的开发,所以我无法指出确切原因,但这些启发式算法通常一直在变化。

IE11 是闭源的,但从他们记录的所有内容来看,它实际上看起来与 v8 非常相似。

第一基础知识。

V8 使用 hidden classestransitions 相连接来发现蓬松无形中的静态结构 JavaScript对象。

hidden classes 描述了对象的结构,transitions linkhidden classes 一起描述了如果执行某个动作应该使用哪个hidden class在对象上。

例如,下面的代码将导致以下隐藏 classes 链:

var o1 = {};
o1.x = 0;
o1.y = 1;
var o2 = {};
o2.x = 0;
o2.y = 0;

这条链是在您构造 o1 时创建的。当构建 o2 时,V8 简单地遵循已建立的转换。

现在,当 属性 fn 用于存储函数时,V8 会尝试对 属性 进行特殊处理:而不是仅在隐藏 class 中声明该对象包含一个 属性 fn V8 puts 函数到隐藏的 class

var o = {};
o.fn = function fff() { };

现在这里有一个有趣的结果:如果您将不同的函数存储到具有相同名称的字段中,V8 将无法再简单地跟随转换,因为函数的值 属性 与预期值不匹配:

var o1 = {};
o1.fn = function fff() { };
var o2 = {};
o2.fn = function ggg() { };

在评估 o2.fn = ... 赋值时,V8 会看到有一个标记为 fn 的转换,但它会导致不合适的隐藏 class:它包含 ffffn 属性 中,而我们正在尝试存储 ggg。注意:我给出函数名称只是为了简单起见——V8 内部不使用它们的名称,而是使用它们的 identity.

因为 V8 无法遵循此转换,所以 V8 将决定将函数提升到隐藏 class 的决定是不正确和浪费的。图片会变

V8 将创建一个新的隐藏 class,其中 fn 只是一个简单的 属性 而不是常量函数 属性。它将重新路由转换并将旧的转换目标标记为 已弃用 。请记住 o1 仍在使用它。但是下次代码触及 o1 例如当从中加载 属性 时 - 运行时会将 o1 从已弃用的隐藏 class 中迁移出来。这样做是为了减少多态性——我们不希望 o1o2 有不同的隐藏 classes.

为什么在隐藏的 classes 上设置函数很重要?因为这为 V8 提供了用于 内联方法调用 的优化编译器信息。如果调用目标存储在隐藏 class 本身,它只能内联方法调用。

现在让我们将这些知识应用到上面的例子中。

因为转换 bar.fnfoo.fn 成为正常属性之间存在冲突 - 函数直接存储在这些对象上并且 V8 无法内联 foo.fn 的调用导致性能较慢。

它可以内联之前的调用吗? 。这是变化的地方:在旧的 V8 中 没有弃用机制 所以即使我们发生冲突并重新路由 fn 转换,foo 也没有迁移到隐藏class 其中 fn 变为正常的 属性。相反 foo 仍然保留隐藏 class 其中 fn 是一个常量函数 属性 直接嵌入到隐藏 class 允许优化编译器内联它。

如果您尝试在旧节点上计时 bar.fn,您会发现它更慢:

for (var i = 0; i < 100000000; i++) {
    bar.fn();  // can't inline here
}       

正是因为它使用了隐藏的class,不允许优化编译器内联bar.fn调用。

这里最后要注意的是,该基准测试不衡量函数调用的性能,而是衡量优化编译器是否可以通过内联调用将循环减少为空循环。