V8 引擎新特性——静态根


你是否曾想过undefinedtrue等核心JavaScript对象是从哪里来的?这些对象是任何用户定义对象的原子,需要首先存在。V8将它们称为不可移动的不可变根,并且它们存在于自己的堆中——只读堆。由于它们被频繁使用,快速访问至关重要。能比在编译时正确猜测它们的内存地址更快的是什么呢?

举个例子,考虑极为常见的IsUndefined API函数。与其需要查找未定义对象的地址以供参考,不如我们简单地检查对象指针是否以0x61结尾,以确定它是否未定义。这正是V8的静态根功能所实现的。本文探讨了我们必须克服的障碍。该功能已在Chrome 111中推出,并带来了整个虚拟机的性能优势,特别是加快了C++代码和内置函数的速度。

引导只读堆

创建只读对象需要一些时间,因此V8在编译时创建它们。要编译V8,首先编译一个名为mksnapshot的最小原型V8二进制文件。该文件会创建所有共享的只读对象,以及内置函数的本地代码,并将它们写入快照。然后,实际的V8二进制文件被编译并与快照捆绑在一起。启动V8时,将快照加载到内存中,我们可以立即开始使用其中的内容。下图显示了独立d8二进制文件的简化构建过程。

一旦d8启动并运行,所有只读对象都在内存中有固定的位置,永远不会移动。当我们进行JIT(即时编译)代码时,例如,我们可以直接通过其地址引用未定义。然而,在构建快照和编译用于libv8C++代码时,地址尚不为人知。它取决于两个在构建时未知的因素。首先,只读堆的二进制布局,其次是只读堆在内存空间中的位置。

如何预测地址?

V8使用指针压缩。我们不使用完整的64位地址,而是通过一个32位偏移量引用内存中4GB区域中的对象。对于许多操作,如属性加载或比较,这个32位偏移量就足以唯一标识一个对象。因此,我们的第二个问题——不知道只读堆在内存空间中的位置——实际上并不是问题。我们只需将只读堆放置在每个指针压缩区域的开头,从而给它一个已知的位置。例如,在V8的堆中,未定义始终具有最小的压缩地址,从0x61字节开始。这就是我们知道如果任何JS对象的完整地址的低32位是0x61,那么它必须是未定义的方式。

这已经很有用了,但我们希望能够在快照和libv8中使用这个地址——这似乎是一个循环的问题。然而,如果我们确保mksnapshot确定性地创建一个位相同的只读堆,那么我们可以在不同版本中重用这些地址。要在libv8中使用它们,我们基本上需要构建两次V8

第一次调用mksnapshot时,生成的唯一产物是一个文件,其中包含了只读堆中每个对象相对于笼子基址的地址。在构建的第二阶段,我们再次编译libv8,并通过一个标志确保每当我们引用未定义时,我们确实使用cage_base + StaticRoot::kUndefined;当然,未定义的静态偏移量是在static-roots.h文件中定义的。在许多情况下,这将允许C++编译器创建libv8和mksnapshot中的内置函数编译器创建更高效的代码,因为替代方案是总是从全局根对象数组中加载地址。我们最终得到的d8二进制文件中,未定义的压缩地址被硬编码为0x61。

嗯,道理上这就是一切的运作方式,但实际上我们只构建一次V8——没人有时间这么做。生成的static-roots.h文件被缓存在源代码仓库中,只有在更改只读堆的布局时才需要重新创建。

进一步的应用

说到实用性,静态根甚至可以实现更多的优化。例如,我们自那时起已经将常见对象分组在一起,从而使我们能够将一些操作实现为对其地址的范围检查。例如,所有字符串映射(即描述不同字符串类型布局的隐藏类元对象)都彼此相邻,因此如果一个对象的映射具有压缩地址在0xdd0x49d之间,那么它就是一个字符串。或者,真值对象必须具有至少为0xc1的地址。

并不是所有关于V8中JIT代码性能的事情。正如这个项目所展示的,对C++代码的相对较小的改变也可能具有重大的影响。例如,Speedometer 2,一个对V8 API进行测试并测试V8与其嵌入器之间交互的基准测试,在M1 CPU上由于静态根而使得分数增加了约1%