你是否曾想过undefined
、true
等核心JavaScript
对象是从哪里来的?这些对象是任何用户定义对象的原子,需要首先存在。V8
将它们称为不可移动的不可变根,并且它们存在于自己的堆中——只读堆。由于它们被频繁使用,快速访问至关重要。能比在编译时正确猜测它们的内存地址更快的是什么呢?
举个例子,考虑极为常见的IsUndefined API
函数。与其需要查找未定义对象的地址以供参考,不如我们简单地检查对象指针是否以0x61
结尾,以确定它是否未定义。这正是V8的静态根功能所实现的。本文探讨了我们必须克服的障碍。该功能已在Chrome 111
中推出,并带来了整个虚拟机的性能优势,特别是加快了C++
代码和内置函数的速度。
引导只读堆
创建只读对象需要一些时间,因此V8在编译时创建它们。要编译V8
,首先编译一个名为mksnapshot
的最小原型V8
二进制文件。该文件会创建所有共享的只读对象,以及内置函数的本地代码,并将它们写入快照。然后,实际的V8
二进制文件被编译并与快照捆绑在一起。启动V8时,将快照加载到内存中,我们可以立即开始使用其中的内容。下图显示了独立d8
二进制文件的简化构建过程。
一旦d8
启动并运行,所有只读对象都在内存中有固定的位置,永远不会移动。当我们进行JIT
(即时编译)代码时,例如,我们可以直接通过其地址引用未定义。然而,在构建快照和编译用于libv8
的C++
代码时,地址尚不为人知。它取决于两个在构建时未知的因素。首先,只读堆的二进制布局,其次是只读堆在内存空间中的位置。
如何预测地址?
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
文件被缓存在源代码仓库中,只有在更改只读堆的布局时才需要重新创建。
进一步的应用
说到实用性,静态根甚至可以实现更多的优化。例如,我们自那时起已经将常见对象分组在一起,从而使我们能够将一些操作实现为对其地址的范围检查。例如,所有字符串映射(即描述不同字符串类型布局的隐藏类元对象)都彼此相邻,因此如果一个对象的映射具有压缩地址在0xdd
到0x49d
之间,那么它就是一个字符串。或者,真值对象必须具有至少为0xc1的地址。
并不是所有关于V8中JIT代码性能的事情。正如这个项目所展示的,对C++
代码的相对较小的改变也可能具有重大的影响。例如,Speedometer 2
,一个对V8 API
进行测试并测试V8
与其嵌入器之间交互的基准测试,在M1 CPU
上由于静态根而使得分数增加了约1%
。