发布时间05二月2024·标记为JavaScript
你有没有想过undefined、true和其他核心JavaScript对象是从哪里来的?这些对象是任何用户定义对象的原子,需要首先在那里。V8称它们为不可移动的不可变根,它们生活在自己的堆中-只读堆。由于它们经常被使用,快速访问至关重要。还有什么能比在编译时正确猜测它们的内存地址更快呢?
例如,考虑极其常见的IsUndefinedAPI函数。如果我们可以简单地检查一个对象的指针是否以undefined结尾来知道它是否未定义,而不是必须查找0x61对象的地址来引用,会怎么样呢?这正是V8的静态根功能所实现的。这篇文章探讨了我们必须采取的障碍到达那里。该功能在Chrome 111中登陆,并在整个VM中带来了性能优势,特别是加快了C++代码和内置函数。
引导只读堆
创建只读对象需要一些时间,因此V8在编译时创建它们。要编译V8,首先要编译一个名为mksnapshot的最小原型V8二进制文件。它创建所有共享的只读对象以及内置函数的本机代码,并将它们写入快照。然后,编译实际的V8二进制文件并将其与快照捆绑在一起。要启动V8,快照将加载到内存中,我们可以立即开始使用其内容。下图显示了独立的d8二进制文件的简化构建过程。
一旦d8启动并运行,所有只读对象在内存中都有固定的位置,永远不会移动。当我们JIT编码时,我们可以,例如,undefined2的地址。然而,当构建快照和编译C++ for libv8时,地址还不知道。这取决于构建时未知的两件事。首先是只读堆的二进制布局,其次是只读堆在内存空间中的位置。
V8使用指针压缩。而不是完整的64位地址,我们引用对象的32位偏移到一个4GB的内存区域。对于许多操作(如属性加载或比较),唯一标识对象所需的全部内容就是该框架中的32位偏移量。因此,我们的第二个问题--不知道只读堆在内存空间中的位置--实际上不是问题。我们简单地把只读堆放在每个指针压缩笼的开始,这样就给了它一个已知的位置。例如,在V8堆中的所有对象中,undefined总是具有最小的压缩地址,从0x 61字节开始。这就是我们如何知道,如果任何JS对象的完整地址的低32位是0x 61,那么它必须是undefined。
这已经很有用了,但是我们希望能够在快照和libv 8中使用这个地址-这似乎是一个循环问题。但是,如果我们确保mksnapshot确定性地创建了一个有点相同的只读堆,那么我们可以在构建过程中重用这些地址。为了在libv 8中使用它们,我们基本上构建了两次V8:
第一次调用mksnapshot时,生成的唯一工件是一个文件,其中包含与只读堆中每个对象的cage base相关的地址。在构建的第二阶段,我们再次编译libv 8,一个标志确保每当我们引用undefined时,我们实际上使用cage_base + StaticRoot::kUndefined;当然,静态偏移量undefined在static-roots.h文件中定义。在许多情况下,这将允许创建libv 8的C++编译器和mksnapshot中的内置编译器创建更有效的代码,因为替代方案是始终从根对象的全局数组加载地址。我们最终得到一个d8二进制文件,其中undefined的压缩地址被硬编码为0x61。
好吧,从道德上讲,这是一切工作的方式,但实际上,我们只建立V8一次-是不是没有人有时间。生成的static-roots.h文件被缓存在源存储库中,只有在我们更改只读堆的布局时才需要重新创建。
进一步应用
说到实用性,静态根可以实现更多的优化。例如,我们已经将公共对象分组在一起,允许我们实现一些操作,如对其地址的范围检查。例如,所有字符串映射(即,描述不同字符串类型的布局的隐藏类Meta对象)彼此相邻,因此如果对象的映射具有在0xdd和0x49d之间的压缩地址,则对象是字符串。或者,truthy对象的地址必须至少为0xc1。
并不是所有的事情都与V8中JIT代码的性能有关。正如这个项目所展示的,对C++代码的一个相对较小的更改也会产生重大影响。例如,Speedometer 2,一个测试V8 API和V8与其嵌入器之间交互的基准测试,由于静态根,在M1 CPU上获得了约1%的分数。