做者:V8开发团队 Lgor Sheludko & Santiago Aboy Solanes翻译:疯狂的技术宅javascript
原文:https://v8.dev/blog/pointer-c...html
未经容许严禁转载前端
内存和性能之间始终存在争斗。做为用户,咱们但愿执行得更快而且消耗的内存更少。不幸的是,一般提升性能的代价是消耗内存(反之亦然)。java
早在 2014 年,Chrome 就从 32 位进程转换为 64 位进程。这为 Chrome 提供了更好的安全性,稳定性和性能,但它为每一个指针所消耗的内存由 4 个字节变为 8 个字节。咱们面临的挑战是要减小 V8 中的这种开销,去尝试尽量多地获取被浪费的那 4 个字节。git
在深刻研究具体实现以前,须要知道咱们所处的位置来对状况进行正确的评估。为了衡量咱们的内存和性能,咱们使用了一组反映现实中流行网站的 web 页面。数据显示,V8 的内存消耗占到桌面版 Chrome 渲染器进程的 60%,平均占 40% 。程序员
指针压缩(Pointer Compression)是 V8 中为减小内存消耗而进行的多项努力之一。这个想法很简单:咱们能够存储一些“基”地址的 32 位偏移量,而不是存储 64 位指针。有了这样一个简单的想法,那么能够从 V8 的这种压缩中得到多少收益?github
V8 堆包含一整套项目,例如浮点值、字符串字符、解释器字节码和标记值(有关详细信息,请参见下一节)。在检查堆以后,咱们发如今现实世界的网站上,这些标记值约占 V8 堆的 70% 之多!web
让咱们仔细看看什么是标记值。面试
V8 中的 JavaScript 值表示为对象,并在 V8 堆上进行分配,不管它们是对象、数组、数字仍是字符串。这使咱们能够把任何值都表示为指向对象的指针。segmentfault
许多 JavaScript 程序都会对整数值执行计算,例如在循环中增长索引。为了不每次整数递增时都必须分配新的数字对象,V8 使用了众所周知的 pointer tagging 技术在 V8 堆指针中存储额外的或替代数据。
标记位具备双重目的:用于指示位于 V8 堆中对象的强/弱指针或者一个小整数的信号。所以,整数值能够直接存储在标记值中,而没必要为其分配额外的存储空间。
V8 老是在堆中按照字对齐的地址分配对象,这使它可使用 2 个(或3个,取决于机器字的大小)最低有效位进行标记。在 32 位体系结构上,V8 使用最低有效位将 Smis 与堆对象指针区分开。对于堆指针,它使用第二个最低有效位来区分强引用和弱引用:
|----- 32 bits -----| Pointer: |_____address_____w1| Smi: |___int31_value____0|
w 是用于区分强指针和弱指针的位。
请注意,Smi 值只能携带31位有效负载,包括符号位。对于指针,咱们有 30 位能够用做堆对象地址有效负载。因为字对齐的缘由,分配粒度为4个字节,这就给了咱们 4 GB 的可寻址空间。
在 64 位体系结构上,V8 值以下所示:
|----- 32 bits -----|----- 32 bits -----| Pointer: |________________address______________w1| Smi: |____int32_value____|0000000000000000000|
你可能会注意到,与 32 位体系结构不一样,在 64 位体系结构上,V8 能够将 32 位用于 Smi 值的有效负载。如下各节将讨论 32 位 Smis 对指针压缩的影响。
咱们的目标是使用指针压缩,以某种方式使两种标记值在64 位架构上都适合32 位。能够经过如下方式将指针调整为 32 位:
如此严格的限制是不幸的,可是 Chrome 中的 V8 对 V8 堆的大小已经有 2 GB 或 4 GB 的限制(取决于基础设备的功能),即便在 64 位架构上也是如此。其余嵌入 V8 的程序,例如 Node.js,可能须要更大的堆。若是咱们施加最大 4 GB 的空间,则意味着这些嵌入器没法使用指针压缩。
如今的问题是如何更新堆布局,以确保 32 位指针可以惟一标识 V8 对象。
简单的压缩方案是在前 4 GB 的地址空间中分配对象。
不幸的是,这不是 V8 的选项,由于 Chrome 的渲染器进程可能须要在同一渲染器进程中建立多个 V8 实例,例如,针对 Web/Service Workers。不然使用此方案,全部这些 V8 实例都会争夺相同的 4 GB 地址空间,所以全部 V8 实例一块儿受到 4 GB 内存的限制。
若是咱们将 V8 的堆放在地址空间的连续 4 GB 区域中的其余位置,则从基址(base)开始的无符号 32 位偏移量将会惟一地标识指针。
若是咱们还确保基址是 4 GB 对齐的,则全部指针的高 32 位相同:
|----- 32 bits -----|----- 32 bits -----| Pointer: |________base_______|______offset_____w1|
经过将 Smi 有效负载限制为 31 位并将其放置在低 32 位,咱们还可使 Smis 可压缩。基本上使它们相似于 32 位体系结构上的 Smis。
|----- 32 bits -----|----- 32 bits -----| Smi: |sssssssssssssssssss|____int31_value___0|
其中 s 是 Smi 有效负载的符号值。若是咱们使用符号扩展表示,则只需对 64 位字进行一次位算术移位就能够对 Smis 进行压缩和解压缩。
如今咱们能够看到指针和 Smis 的上半字彻底由下半字定义。而后咱们能够将后者仅存储在内存中,从而将存储标记值所需的内存减小一半:
|----- 32 bits -----|----- 32 bits -----| Compressed pointer: |______offset_____w1| Compressed Smi: |____int31_value___0|
假定基址是 4 GB 对齐的,则压缩只是一个截断:
uint64_t uncompressed_tagged; uint32_t compressed_tagged = uint32_t(uncompressed_tagged);
可是,解压缩代码要复杂一些。咱们须要区分符号扩展的 Smi 和零扩展的指针,以及是否要添加基址。
uint32_t compressed_tagged; uint64_t uncompressed_tagged; if (compressed_tagged & 1) { // pointer case uncompressed_tagged = base + uint64_t(compressed_tagged); } else { // Smi case uncompressed_tagged = int64_t(compressed_tagged); }
下面让咱们尝试更改压缩方案以简化解压缩代码。
若是不是将基址放在 4 GB 的开头,而是放在 中间 ,则能够将压缩值视为距离基址的 32 位有符号偏移量。请注意,整个预留再也不是 4 GB 对齐的,而是基址的。
在这种新布局中,压缩代码保持不变。
可是解压缩代码变得更好了。如今符号扩展在 Smi 和指针状况下都是常见的,惟一的分支是是否在指针状况下添加基址。
int32_t compressed_tagged; // Common code for both pointer and Smi cases int64_t uncompressed_tagged = int64_t(compressed_tagged); if (uncompressed_tagged & 1) { // pointer case uncompressed_tagged += base; }
代码中分支的性能取决于 CPU 中的分支预测单元。咱们认为,若是以无分支方式实施减压,则能够获得更好的性能。借助少许的魔术,咱们能够编写上述代码的无分支版本:
int32_t compressed_tagged; // Same code for both pointer and Smi cases int64_t sign_extended_tagged = int64_t(compressed_tagged); int64_t selector_mask = -(sign_extended_tagged & 1); // Mask is 0 in case of Smi or all 1s in case of pointer int64_t uncompressed_tagged = sign_extended_tagged + (base & selector_mask);
而后,咱们决定从无分支的实现开始。
咱们在 Octane 上测量了性能,这是咱们过去使用的最高性能基准。尽管再也不专一于在平常工做中提升峰值性能,但也不想下降峰值性能,特别是对于性能敏感的事物,例如全部的指针。Octane 值仍然是这个任务的良好基准。
该图显示了 Octane 在优化和优化指针压缩时在 x64 架构上的得分。在图中,得分越高越好。红线是现有的无压缩指针 x64 版本,绿线则是指针压缩版本。
在第一个可行的实施方案中,咱们性能损失约为 35%。
首先,经过将无分支解压缩与有分支解压缩相比较,验证了“无分支更快”的假设。事实证实,咱们的假设是错误的,在 x64上,分支版本的速度提升了 7%。这区别是很大的!
让咱们看一下 x64 汇编代码。
Decompression | 无分支 | Branchful |
---|---|---|
Code | movsxlq r11,[…] movl r10,r11 andl r10,0x1 negq r10 andq r10,r13 addq r11,r10 |
movsxlq r11,[…] testb r11,0x1 jz done addq r11,r13 done: |
总结 | 20 bytes | 13 bytes |
执行了 6 条指令 | 执行了 3 或 4 条指令 | |
no branches | 1 branch | |
1 个额外的寄存器 |
r13 是用于基址值的专用寄存器。请注意,无分支代码量更大且须要更多寄存器。
在 Arm64上,咱们观察到了相同的结果——分支版本在功能强大的 CPU 上明显更快(尽管两种代码大小相同)。
Decompression | Branchless | Branchful |
---|---|---|
Code | ldur w6, […] sbfx x16, x6, #0, #1 and x16, x16, x26 add x6, x16, w6, sxtw |
ldur w6, […] sxtw x6, w6 tbz w6, #0, #done add x6, x26, x6 done: |
Summary | 16 bytes | 16 bytes |
执行了 4 条指令 | 执行了 3 或 4 条指令 | |
没有分支 | 1 个分支 | |
1个额外的寄存器 |
咱们观察到在低端 Arm64 设备上几乎没有性能差别。
咱们的收获是:现代 CPU 中的分支预测器很是好,而且代码大小(尤为是执行路径长度)对性能的影响更大。
TurboFan 是V8的优化编译器,其构建基于“Sea of Nodes”的概念。简而言之,每一个操做都在节点图中表示为一个节点(请参见 https://v8.dev/blog/turbofan-jit 更详细的版本)。这些节点具备各类依赖性,包括数据流和控制流。
对于指针压缩,有两个相当重要的操做:加载和存储,由于它们把 V8 堆与管道的其他部分链接起来。若是每次在堆中加载压缩值时都进行解压缩,而后在存储以前对其进行压缩,则管道就可以像在全指针模式下同样继续工做。所以咱们在节点图中添加了新的显式操做——解压缩和压缩。
在某些状况下,实际上不须要解压缩。例如仅从某处加载压缩值,而后将其存储到新位置。
为了优化没必要要的操做,咱们在 TurboFan 中实现了一个新的“解压消除”阶段。它的工做是消除直接进行压缩后的解压缩。因为这些节点可能不会彼此直接相邻,所以它还会尝试经过图传播解压缩,以期遇到压缩问题并消除它们。这使咱们的 Octane 得分提升了2%。
在查看生成的代码时,咱们注意到对刚加载的值进行解压缩会产生一些过于冗长的代码:
movl rax, <mem> // load movlsxlq rax, rax // sign extend
一旦咱们修复了签名问题,就能够直接扩展从内存加载的值:
movlsxlq rax, <mem>
所以又提升了2%。
TurboFan 优化阶段经过在图上使用模式匹配来工做:一旦子图与某个特定模式匹配,它将被语义上等效(但更好)的子图或指令替换。
找不到匹配项的失败尝试不是明确的失败。图中显式的“解压缩/压缩”操做的存在致使先前成功的模式匹配尝试再也不成功,从而致使优化无提示地失败。
“中断”优化的一个例子是分配预选。一旦咱们更新了模式匹配,意识到新的压缩/解压缩节点,咱们又得到了11%的改进。
在 TurboFan 中实施消除解压缩功能时,咱们学到了不少东西。显式“解压缩/压缩”节点方法具备如下属性:
优势:
可是,随着咱们继续实施,发现了缺点:
咱们决定退一步,考虑一种在 TurboFan 中支持指针压缩的更简单方法。新方法是删除 Compressed Pointer/Smi/Any 表示,并使全部显式的 压缩/解压缩节点隐含在“加载和存储中,并假设咱们始终在加载以前进行解压缩,并在存储以前进行压缩。
咱们还在 TurboFan 中增长了一个新阶段,它将取代“解压消除”阶段。这个新阶段能够识别出咱们什么时候实际上不须要压缩或解压缩,并相应地更新“加载和存储”。这种方法显着下降了 TurboFan 中指针压缩支持的复杂性,并提升了生成代码的质量。
新的实现与原始版本同样有效,而且又提升了0.5%。
尽管已经接近性能均等,可是差距仍然存在。咱们不得不提出新的想法。其中之一是:若是咱们确保处理 Smi 值的代码都不会处理高 32 位怎么办?
让咱们记住解压实现:
// Old decompression implementation int64_t uncompressed_tagged = int64_t(compressed_tagged); if (uncompressed_tagged & 1) { // pointer case uncompressed_tagged += base; }
若是忽略了 Smi 的高 32 位,则能够假定它们是 undefined。这样,咱们就能够避免在指针和 Smi 之间使用特殊的大小写,而且在解压缩时无条件地添加基址,即便是对于 Smi!咱们称这种方法为“Smi-corrupting”。
// New decompression implementation int64_t uncompressed_tagged = base + int64_t(compressed_tagged);
另外因为咱们再也不关心扩展 Smi 的标志了,因此这种更改使咱们能够回到堆布局 v1。这是一个指向 4GB 预留基址的位置。
就解压代码而言,它会将符号扩展操做更改成零扩展,这代价一样很小。可是这简化了运行时(C++)端的工做。例如,地址空间区域保留代码(请参见本文“一些细节实现”这一部分)。
这是用于比较的汇编代码:
Decompression | Branchful | Smi-corrupting |
---|---|---|
Code | movsxlq r11,[…] testb r11,0x1 jz done addq r11,r13 done: |
movl r11,[rax+0x13] addq r11,r13 |
总结 | 13 bytes | 7 bytes |
执行了 3 或 4 条指令 | 执行 2 条指令 | |
1个分支 | 没有分支 |
因此咱们把 V8 中全部使用 Smi 的代码段调整为新的压缩方案,这又提升了2.5%。
剩余的性能差距能够经过对 64 位构建的两次优化来解释,这些优化因为与指针压缩根本不兼容而不得不由用。
回想一下 Smis 在 64 位架构上全指针模式下的样子。
|----- 32 bits -----|----- 32 bits -----| Smi: |____int32_value____|0000000000000000000|
32 位 Smi 具备如下优势:
指针压缩没法完成这种优化,由于 32 位压缩指针中没有空格,它具备区分指针和 Smis 的位。若是在全指针 64 位版本中禁用 32 位 smis,咱们将看到 Octane 分数下降 1%。
在某些假设下,这种优化尝试将浮点值直接存储在对象的字段中。其目的是减小数量对象分配的数量,甚至比 Smis 单独执行的数量更多。
思考如下 JavaScript 代码:
function Point(x, y) { this.x = x; this.y = y; } let p = new Point(3.1, 5.3);
通常来讲,若是咱们看一下对象 p 在内存中的样子,就会看到如下内容:
你能够在这篇文章中(https://v8.dev/blog/fast-prop...)了解关于隐藏的类与属性以及元素后备存储的更多信息。
在 64 位体系结构上,双精度值的大小与指针的大小相同。所以,若是咱们假设 Point 的字段始终包含数字值,则能够将其直接存储在对象字段中。
若是对某个字段的假设成立,会执行这行代码:
let q = new Point(2, “ab”);
而后必须将 y 属性的数字值装箱保存。此外,若是某个地方的推测优化代码依赖这个假设,则必须再也不使用它,而且必须将其丢弃(取消优化)。进行这种“字段类型”泛化的缘由是要最小化从同一构造函数建立的对象的图数量,而这又对于更稳定的性能是必需的。
若是生效,则双字段拆箱有如下好处:
启用指针压缩后,双精度值根本再也不适合压缩字段。可是未来咱们可能会将这种优化用于指针压缩。
请注意,即便没有这种双字段拆箱优化(以与指针压缩兼容的方式),也能够经过将数据存储在 Float64 TypedArrays 中,甚至用于 Wasm。
最后,在 TurboFan 中对解压消除优化进行了一些微调,使性能又提升了1%。
为了简化指针压缩与现有代码的集成,咱们决定在每次加载时对值进行解压缩,并在每一个存储中对它们进行压缩。因此仅更改标记值的存储格式,同时保持执行格式不变。
为了可以在须要解压缩时生成有效的代码,必须始终提供基址值。幸运的是,V8 已经有一个专用寄存器,始终指向“根表”,其中包含对 JavaScript 和 V8 内部对象的引用,这些引用必须始终可用(例如:undefined、null、true、false 等)。该寄存器称为“根寄存器”,用于生成较小的可共享的内置代码。
因此咱们将根表放入 V8 堆保留区,根寄存器可同时用于两个目的——做为根指针和解压缩的基址。
V8 运行时经过 C++ 类访问 V8 堆中的对象,从而能够方便地查看堆中存储的数据。请注意,V8 对象比 C++ 对象更像 POD 结构。助手类 view
仅包含一个带有相应标记值的 uintptr_t
字段。因为view
类是字大小的,所以咱们能够零开销将它们按值传递(这要感谢现代 C++ 编译器)。
这是辅助类的伪代码示例:
// Hidden class class Map { public: … inline DescriptorArray instance_descriptors() const; … // The actual tagged pointer value stored in the Map view object. const uintptr_t ptr_; }; DescriptorArray Map::instance_descriptors() const { uintptr_t field_address = FieldAddress(ptr_, kInstanceDescriptorsOffset); uintptr_t da = *reinterpret_cast<uintptr_t*>(field_address); return DescriptorArray(da); }
为了最大程度地减小首次运行指针压缩版本所需的更改次数,咱们将解压缩所需的基址值的计算集成到 getter 中。
inline uintptr_t GetBaseForPointerCompression(uintptr_t address) { // Round address down to 4 GB const uintptr_t kBaseAlignment = 1 << 32; return address & -kBaseAlignment; } DescriptorArray Map::instance_descriptors() const { uintptr_t field_address = FieldAddress(ptr_, kInstanceDescriptorsOffset); uint32_t compressed_da = *reinterpret_cast<uint32_t*>(field_address); uintptr_t base = GetBaseForPointerCompression(ptr_); uintptr_t da = base + compressed_da; return DescriptorArray(da); }
性能测量结果证明,在每一个负载中计算基址都会损害性能。缘由是C++ 编译器不知道 V8 堆中的任何地址,GetBaseForPointerCompression()
调用的结果都是相同的,因此编译器没法合并基址值的计算。假定代码由多个指令和 64 位常量组成,这会致使代码膨胀严重。
为了解决这个问题,咱们重用了 V8 实例指针做为减压的基础(请记住堆布局中的 V8 实例数据)。该指针一般在运行时函数中可用,所以咱们经过要求使用 V8 实例指针来简化 getter 代码,并恢复了性能:
DescriptorArray Map::instance_descriptors(const Isolate* isolate) const { uintptr_t field_address = FieldAddress(ptr_, kInstanceDescriptorsOffset); uint32_t compressed_da = *reinterpret_cast<uint32_t*>(field_address); // No rounding is needed since the Isolate pointer is already the base. uintptr_t base = reinterpret_cast<uintptr_t>(isolate); uintptr_t da = DecompressTagged(base, compressed_value); return DescriptorArray(da); }
让咱们来看看指针压缩的最终数字!对于这些结果,咱们用与本文开头介绍的相同的网站测试。提醒一下,他们表明用户在真实世界网站使用状况。
咱们观察到指针压缩将 V8堆的大小减小了多达 43%!反过来,它能够将桌面版的 Chrome 渲染器进程内存减小多达 20%。
另外一个须要注意的是,并不是每一个网站都能获得相同的提升。例如,访问 Facebook 时的 V8 堆内存曾经比《纽约时报》大,但使用指针压缩时是相反的。这种差别能够经过如下事实来解释:某些网站有比其余网站更多的标记值。
除了这些内存改进以外,咱们还看到了实际性能的改进。在真实的网站上,咱们获得了更少的 CPU 消耗和垃圾回收时间!
尽管一路上没有鸟语花香,但值得咱们经历。在 300+次提交以后,使用指针压缩的 V8 所使用的内存与运行 32 位程序时同样,而具备 64 位程序的性能。
咱们一直持续改进,并在流程中完成以了下相关任务: