静态根:具有编译时恒定地址的对象
你是否曾想过 undefined
、true
和其他核心JavaScript对象是从哪里来的?这些对象是任何用户定义对象的原子,并且必须首先存在。V8将它们称为不可移动的不可变根,它们位于自己的堆——只读堆中。由于它们被频繁使用,快速访问至关重要。而什么比在编译时正确猜测它们的内存地址更快呢?
举个例子,考虑极其常见的 IsUndefined
API函数。如果我们不需要查找undefined
对象的地址,而可以简单地检查一个对象的指针是否以例如 0x61
结尾来判断它是否是undefined
会怎么样呢?这正是V8的静态根功能所实现的。本文探讨了我们为实现这一目标所遇到的难题。该功能在Chrome 111中上线,并为整个虚拟机带来了性能提升,尤其是加速了C++代码和内置函数。
引导只读堆
创建只读对象需要一些时间,因此V8在编译时创建这些对象。为了编译V8,首先会编译一个最小的原型V8二进制文件 mksnapshot
。该文件创建所有共享的只读对象以及内置函数的本地代码,并将它们写入快照中。然后,实际的V8二进制文件被编译并与快照捆绑在一起。启动V8时,会将快照加载到内存中,然后我们可以立即开始使用其中的内容。下图展示了独立二进制文件d8
的简化构建过程。
一旦d8
启动并运行,所有只读对象都在内存中拥有固定位置且永不移动。当我们执行JIT代码时,例如可以直接通过它的地址引用undefined
。然而,在构建快照以及编译用于libv8的C++代码时,地址尚未确定。它取决于构建时未知的两个因素。首先是只读堆的二进制布局,其次是只读堆在内存空间中的确切位置。
如何预测地址?
V8使用指针压缩。我们使用32位偏移量指向一个4GB内存区域,而不是完整的64位地址。对于许多操作,例如属性加载或比较,进入该区域的32位偏移量已经足够唯一标识一个对象。因此,我们在内存空间中不知道只读堆放置位置的问题实际上并不是问题。我们简单地将只读堆放置在每个指针压缩区域的起始处,从而赋予它一个已知位置。例如,在V8的堆中的所有对象中,undefined
始终具有最小的压缩地址,从0x61字节开始。这就是我们如何知道一个JS对象的完整地址的低32位为0x61时,它一定是undefined
。
这已经非常有用了,但我们希望能够在快照和libv8中使用这个地址——一个看似循环的问题。然而,如果我们确保mksnapshot
以确定性方式创建一个位完全相同的只读堆,那么我们可以在构建之间重复使用这些地址。为了在libv8本身中使用它们,我们实际上需要编译V8两次:
第一次调用mksnapshot
生成的唯一工件是一个文件,其中包含只读堆中每个对象相对于区域基址的地址。在构建的第二阶段,我们再次编译libv8,并设置一个标志,以确保在引用undefined
时我们确实只是使用cage_base + StaticRoot::kUndefined
;undefined
的静态偏移量当然是在static-roots.h文件中定义的。在许多情况下,这将允许编译libv8的C++编译器以及mksnapshot
中的内置编译器生成更高效的代码,因为替代方案是总是从根对象的全局数组加载地址。最终我们会得到一个d8
二进制文件,其中undefined
的压缩地址被硬编码为0x61
。
好吧,从道义上说,这就是一切的工作原理,但实际上我们只构建一次V8——没人有精力做两次。生成的static-roots.h文件缓存在源代码仓库中,只有在我们更改只读堆的布局时才需要重新生成。
其他应用
说到实际应用,静态根使得更多优化成为可能。例如,我们已经将常见的对象分组在一起,这使得我们可以通过对其地址的范围检查实现某些操作。例如,所有字符串映射(即描述不同字符串类型布局的隐藏类元对象)是彼此相邻的,因此如果一个对象的映射地址在0xdd
到0x49d
之间,它就是一个字符串。或者,真值对象的地址必须至少为0xc1
。
并非所有事情都与V8中JIT代码的性能有关。正如该项目所展示的,对C++代码的一个相对较小的更改也可以产生显著的影响。例如 Speedometer 2(一个测试V8 API以及V8与其嵌入器之间交互的基准测试)在M1 CPU上的分数因为静态根提高了大约1%。