作者:V8开发团队

内存和性能之间始终存在争斗。作为用户,我们希望执行得更快并且消耗的内存更少。不幸的是,通常提高性能的代价是消耗内存(反之亦然)。

早在 2014 年,Chrome 就从 32 位进程转换为 64 位进程。这为 Chrome 提供了更好的安全性,稳定性和性能,但它为每个指针所消耗的内存由 4 个字节变为 8 个字节。我们面临的挑战是要减少 V8 中的这种开销,去尝试尽可能多地获取被浪费的那 4 个字节。

在深入研究具体实现之前,需要知道我们所处的位置来对情况进行正确的评估。为了衡量我们的内存和性能,我们使用了一组反映现实中流行网站的 web 页面。数据显示,V8 的内存消耗占到桌面版 Chrome 渲染器进程的 60%,平均占 40% 。

Chrome 渲染器内存中的 V8 内存消耗百分比

指针压缩(Pointer Compression)是 V8 中为减少内存消耗而进行的多项努力之一。这个想法很简单:我们可以存储一些“基”地址的 32 位偏移量,而不是存储 64 位指针。有了这样一个简单的想法,那么可以从 V8 的这种压缩中获得多少收益?

V8 堆包含一整套项目,例如浮点值、字符串字符、解释器字节码和标记值(有关详细信息,请参见下一节)。在检查堆之后,我们发现在现实世界的网站上,这些标记值约占 V8 堆的 70% 之多!

让我们仔细看看什么是标记值。

V8 中的 Value tagging

V8 中的 JavaScript 值表示为对象,并在 V8 堆上进行分配,无论它们是对象、数组、数字还是字符串。这使我们可以把任何值都表示为指向对象的指针。

许多 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 位:

  • 确保所有 V8 对象都分配在 4 GB 的内存范围内
  • 将指针表示为该范围内的偏移量

如此严格的限制是不幸的,但是 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 内存的限制。

堆布局,v1

如果我们将 V8 的堆放在地址空间的连续 4 GB 区域中的其他位置,则从基址(base)开始的无符号 32 位偏移量将会唯一地标识指针。

堆布局,以 base 为基准开始

如果我们还确保基址是 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);
}

下面让我们尝试更改压缩方案以简化解压缩代码。

堆布局,v2

如果不是将基址放在 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 版本,绿线则是指针压缩版本。

Octane 的第一轮改进

在第一个可行的实施方案中,我们性能损失约为 35%。

优化 (1), +7%

首先,通过将无分支解压缩与有分支解压缩相比较,验证了“无分支更快”的假设。事实证明,我们的假设是错误的,在 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 中的分支预测器非常好,并且代码大小(尤其是执行路径长度)对性能的影响更大。

优化(2),+ 2%

TurboFan 是V8的优化编译器,其构建基于“Sea of Nodes”的概念。简而言之,每个操作都在节点图中表示为一个节点(请参见 https://v8.dev/blog/turbofan-jit 更详细的版本)。这些节点具有各种依赖性,包括数据流和控制流。

对于指针压缩,有两个至关重要的操作:加载和存储,因为它们把 V8 堆与管道的其余部分连接起来。如果每次在堆中加载压缩值时都进行解压缩,然后在存储之前对其进行压缩,则管道就能够像在全指针模式下一样继续工作。因此我们在节点图中添加了新的显式操作——解压缩和压缩。

在某些情况下,实际上不需要解压缩。例如仅从某处加载压缩值,然后将其存储到新位置。

为了优化不必要的操作,我们在 TurboFan 中实现了一个新的“解压消除”阶段。它的工作是消除直接进行压缩后的解压缩。由于这些节点可能不会彼此直接相邻,因此它还会尝试通过图传播解压缩,以期遇到压缩问题并消除它们。这使我们的 Octane 得分提高了2%。

优化(3),+2%

在查看生成的代码时,我们注意到对刚加载的值进行解压缩会产生一些过于冗长的代码:

movl rax, <mem>   // load
movlsxlq rax, rax // sign extend

一旦我们修复了签名问题,就可以直接扩展从内存加载的值:

movlsxlq rax, <mem>

因此又提高了2%。

优化(4),+ 11%

TurboFan 优化阶段通过在图上使用模式匹配来工作:一旦子图与某个特定模式匹配,它将被语义上等效(但更好)的子图或指令替换。

找不到匹配项的失败尝试不是明确的失败。图中显式的“解压缩/压缩”操作的存在导致先前成功的模式匹配尝试不再成功,从而导致优化无提示地失败。

“中断”优化的一个例子是分配预选。一旦我们更新了模式匹配,意识到新的压缩/解压缩节点,我们又获得了11%的改进。

进一步改进

Octane 值的第二轮改进

优化(5),+ 0.5%

在 TurboFan 中实施消除解压缩功能时,我们学到了很多东西。显式“解压缩/压缩”节点方法具有以下属性: 优点:

  • 此类操作的明确性使我们能够通过对子图进行规范的模式匹配来优化掉不必要的解压缩。

但是,随着我们继续实施,发现了缺点:

  • 由于新的内部值表示形式,可能的转换操作组合爆炸变得难以管理。除了现有的表示集(带标签的 Smi、带标签的指针、带标签的 any、word8、word16、word32、word64、float32、float64、simd128)。
  • 某些基于图模式匹配的现有优化没有成功执行,从而导致了一些地方的恶化。尽管我们找到并修复了其中一些问题,但 TurboFan 的复杂性仍在增加。
  • 寄存器分配器对图中的节点数量越来越不满意,并且经常会生成错误代码。
  • 较大的节点图会减缓 TurboFan 优化阶段,并增加编译期间的内存消耗。

我们决定退一步,考虑一种在 TurboFan 中支持指针压缩的更简单方法。新方法是删除 Compressed Pointer/Smi/Any 表示,并使所有显式的 压缩/解压缩节点隐含在“加载和存储中,并假设我们始终在加载之前进行解压缩,并在存储之前进行压缩。

我们还在 TurboFan 中增加了一个新阶段,它将取代“解压消除”阶段。这个新阶段可以识别出我们何时实际上不需要压缩或解压缩,并相应地更新“加载和存储”。这种方法显着降低了 TurboFan 中指针压缩支持的复杂性,并提高了生成代码的质量。

新的实现与原始版本一样有效,并且又提高了0.5%。

优化(6),+ 2.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 位构建的两次优化来解释,这些优化由于与指针压缩根本不兼容而不得不禁用。

Octane的最后一轮改进

32 位 Smi 优化(7),-1%

回想一下 Smis 在 64 位架构上全指针模式下的样子。

        |----- 32 bits -----|----- 32 bits -----|
Smi:    |____int32_value____|0000000000000000000|

32 位 Smi 具有以下优点:

  • 它可以表示更大范围的整数,而无需将它们装箱成数字对象;
  • 这样的形态可以在读取/写入时直接访问 32 位值。

指针压缩无法完成这种优化,因为 32 位压缩指针中没有空格,它具有区分指针和 Smis 的位。如果在全指针 64 位版本中禁用 32 位 smis,我们将看到 Octane 分数降低 1%。

双字段拆箱(8),-3%

在某些假设下,这种优化尝试将浮点值直接存储在对象的字段中。其目的是减少数量对象分配的数量,甚至比 Smis 单独执行的数量更多。

思考以下 JavaScript 代码:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
let p = new Point(3.1, 5.3);

一般来说,如果我们看一下对象 p 在内存中的样子,就会看到以下内容:

内存中的对象 p

你可以在这篇文章中(https://v8.dev/blog/fast-properties)了解关于隐藏的类与属性以及元素后备存储的更多信息。

在 64 位体系结构上,双精度值的大小与指针的大小相同。因此,如果我们假设 Point 的字段始终包含数字值,则可以将其直接存储在对象字段中。

img

如果对某个字段的假设成立,会执行这行代码:

let q = new Point(2, “ab”);

然后必须将 y 属性的数字值装箱保存。此外,如果某个地方的推测优化代码依赖这个假设,则必须不再使用它,并且必须将其丢弃(取消优化)。进行这种“字段类型”泛化的原因是要最小化从同一构造函数创建的对象的图数量,而这又对于更稳定的性能是必需的。

内存中的对象 p 和 q

如果生效,则双字段拆箱有以下好处:

  • 通过对象指针提供对浮点数据的直接访问,避免通过数字对象进行额外的取消引用操作;
  • 允许我们为执行大量双字段访问的紧密循环生成更小、更快的优化代码(例如在数字运算程序中)

启用指针压缩后,双精度值根本不再适合压缩字段。但是将来我们可能会将这种优化用于指针压缩。

请注意,即使没有这种双字段拆箱优化(以与指针压缩兼容的方式),也可以通过将数据存储在 Float64 TypedArrays 中,甚至用于 Wasm

更多改进(9),1%

最后,在 TurboFan 中对解压消除优化进行了一些微调,使性能又提高了1%。

一些实现细节

为了简化指针压缩与现有代码的集成,我们决定在每次加载时对值进行解压缩,并在每个存储中对它们进行压缩。所以仅更改标记值的存储格式,同时保持执行格式不变。

本机代码

为了能够在需要解压缩时生成有效的代码,必须始终提供基址值。幸运的是,V8 已经有一个专用寄存器,始终指向“根表”,其中包含对 JavaScript 和 V8 内部对象的引用,这些引用必须始终可用(例如:undefined、null、true、false 等)。该寄存器称为“根寄存器”,用于生成较小的可共享的内置代码

所以我们将根表放入 V8 堆保留区,根寄存器可同时用于两个目的——作为根指针和解压缩的基址。

C++ 层面

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%

在 Windows 10 中浏览时可节省内存

另一个需要注意的是,并非每个网站都能得到相同的提高。例如,访问 Facebook 时的 V8 堆内存曾经比《纽约时报》大,但使用指针压缩时是相反的。这种差异可以通过以下事实来解释:某些网站有比其他网站更多的标记值。

除了这些内存改进之外,我们还看到了实际性能的改进。在真实的网站上,我们得到了更少的 CPU 消耗和垃圾回收时间!

CPU和垃圾回收时间的改善

结论

尽管一路上没有鸟语花香,但值得我们经历。在 300+次提交之后,使用指针压缩的 V8 所使用的内存与运行 32 位程序时一样,而具有 64 位程序的性能。

我们一直持续改进,并在流程中完成以了下相关任务:

  • 提高生成的汇编代码的质量。我们知道,在某些情况下可以生成更少的代码来提高性能。
  • 解决了相关的性能下降问题,包括一种机制,该机制允许以指针压缩友好的方式再次对双字段取消装箱。
  • 探索支持 8 到 16 GB 范围内的更大堆的想法。