跳到主要内容

V8中的指针压缩

· 阅读需 22 分钟
Igor Sheludko 和 Santiago Aboy Solanes,*指针压缩专家*

在内存和性能之间总是存在不断的斗争。作为用户,我们希望既能快速又尽量少地使用内存。不幸的是,通常提升性能会带来内存消耗的代价(反之亦然)。

早在2014年,Chrome就从一个32位进程切换为一个64位进程。这为Chrome带来了更好的安全性、稳定性和性能,但也因此付出了内存代价,因为每个指针现在占用8个字节,而不是4个字节。我们在V8中接受了减少这一开销的挑战,试图尽可能多地收回这些浪费的4个字节。

在深入实施之前,我们需要知道我们当前所处的情况,以便正确评估问题。为了度量我们的内存和性能,我们使用了一组网页,这些网页反映了流行的真实网站。数据显示,V8在桌面端贡献了Chrome的渲染器进程内存消耗的最高达60%,平均为40%。

V8在Chrome渲染器的内存中的消耗比例

指针压缩是V8中减少内存消耗的几项持续努力之一。这个想法非常简单:与其存储64位指针,我们可以存储32位偏移量,从某个“基地址”开始。有了这样一个简单的想法,那么在V8中我们能从这样的压缩中获得多少收益呢?

V8堆包含许多项目,例如浮点值、字符串字符、解释器字节码和标记值(详见下一节)。检查堆后,我们发现,在真实网站中,这些标记值占据了V8堆的大约70%!

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

V8中的值标记

V8中的JavaScript值被表示为对象并分配在V8堆上,无论它们是对象、数组、数字还是字符串。这使我们能够将任何值表示为指向对象的指针。

许多JavaScript程序在整数值上进行计算,例如在循环中递增索引。为了避免每次整数递增时都必须分配一个新的数字对象,V8使用了一种众所周知的指针标记技术,在V8堆中指针中存储额外或替代的数据。

标记位发挥双重作用:它们表示强/弱指针指向位于V8堆中的对象,或者表示一个小整数。因此,整数的值可以直接存储在标记值中,而不需要为其分配额外的存储空间。

V8总是在堆上为对象分配字对齐地址,这使其能够使用2个(或3个,取决于机器字大小)最低有效位进行标记。在32位架构上,V8使用最低有效位区分Smis和堆对象指针。对于堆指针,它使用第二低位区分强引用和弱引用:

|----- 32 位 -----| Pointer: |地址w1| Smi: |int31_value_0|

其中 w 是用于区分强指针和弱指针的位。

请注意,Smi值只能携带一个31位的负载(包括符号位)。对于指针,我们有30位可以用作堆对象地址负载。由于字对齐,分配的粒度为4字节,这为我们提供了4GB的可寻址空间。

在64位架构上,V8的值看起来如下所示:

|----- 32 位 -----|----- 32 位 -----| Pointer: |__地址w1| Smi: |int32_value|0000000000000000000|

您可能注意到,与32位架构不同,在64位架构上,V8可以使用32位作为Smi值的负载。在接下来的部分中将讨论32位Smis对指针压缩的影响。

压缩标记值和新堆布局

通过指针压缩,我们的目标是在64位架构上以某种方式将两种标记值都压缩到32位中。我们可以通过下列方式把指针压缩到32位:

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

有一个这样的硬限制确实不幸,但即使在64位架构上,Chrome中的V8已经对V8堆的大小有2GB或4GB的限制(取决于底层设备的性能)。其他V8嵌入器,例如Node.js,可能需要更大的堆。如果我们强制最大限制为4GB,这将意味着这些嵌入器无法使用指针压缩。

现在的问题是如何更新堆布局以确保32位指针唯一标识V8对象。

简单的堆布局

一种简单的压缩方案是将对象分配到地址空间的第一个4GB。

简单的堆布局

不幸的是,这对于V8来说是不现实的,因为Chrome的渲染进程可能需要在同一个渲染进程中创建多个V8实例,例如用于Web/服务工作者。否则,这种方案会导致所有这些V8实例争夺相同的4GB地址空间,从而对所有V8实例整体施加一个4GB的内存限制。

堆布局,版本1

如果我们将V8的堆安排在地址空间中的某个连续4GB区域,则从基地址起的无符号32位偏移量可以唯一标识指针。

堆布局,基地址对齐到开始

如果我们还确保基地址是4GB对齐的,那么对于所有指针,上位32位是相同的:

            |----- 32位 -----|----- 32位 -----|
指针: |________基地址______|______偏移______w1|

我们还可以通过限制Smi的有效负载为31位并将其放置到低32位,使Smi可压缩。基本上,使它们类似于32位架构上的Smi。

         |----- 32位 -----|----- 32位 -----|
Smi: |sssssssssssssssssss|____int31值____0|

其中s是Smi有效负载的符号值。如果我们有一个符号扩展表示,那么我们能够通过对64位字进行一次一位的算术移位来压缩和解压Smi。

现在,我们可以看到,指针和Smi的上半字是由下半字完全定义的。然后,我们只需要在内存中存储后者,从而将存储标记值所需的内存减少一半:

                    |----- 32位 -----|----- 32位 -----|
压缩指针: |______偏移______w1|
压缩Smi: |____int31值____0|

鉴于基地址是4GB对齐的,压缩仅仅是一个截断操作:

uint64_t 未压缩标记;
uint32_t 压缩标记 = uint32_t(未压缩标记);

然而,解压缩代码稍微复杂一点。我们需要区分Smi的符号扩展和指针的零扩展,以及是否需要加上基地址。

uint32_t 压缩标记;

uint64_t 未压缩标记;
if (压缩标记 & 1) {
// 指针情况
未压缩标记 = 基地址 + uint64_t(压缩标记);
} else {
// Smi情况
未压缩标记 = int64_t(压缩标记);
}

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

堆布局,版本2

如果我们将基地址放在4GB的中间而不是开始处,我们可以将压缩值视为从基地址起的有符号32位偏移量。请注意,整个预留区域不再是4GB对齐的,但基地址仍然是。

堆布局,基地址对齐到中间

在这种新的布局中,压缩代码保持不变。

然而,解压缩代码变得更简洁。对于Smi和指针情况,符号扩展现在是通用的,唯一的分支仅在指针情况下是否添加基地址。

int32_t 压缩标记;

// 指针和Smi情况的通用代码
int64_t 未压缩标记 = int64_t(压缩标记);
if (未压缩标记 & 1) {
// 指针情况
未压缩标记 += 基地址;
}

代码中的分支性能取决于CPU中的分支预测单元。我们认为如果采用无分支方式实现解压缩,可以获得更好的性能。通过少量位运算,我们可以编写上述代码的无分支版本:

int32_t 压缩标记;

// 指针和Smi情况的相同代码
int64_t 符号扩展标记 = int64_t(压缩标记);
int64_t 选择器掩码 = -(符号扩展标记 & 1);
// 掩码在Smi情况下为0,在指针情况下为全1
int64_t 未压缩标记 =
符号扩展标记 + (基地址 & 选择器掩码);

然后,我们决定从无分支实现开始。

性能演变

初始性能

我们在Octane——一个我们过去使用的峰值性能基准上测量了性能。尽管我们在日常工作中不再专注于提高峰值性能,但我们也不希望在峰值性能上出现回退,特别是像_所有指针_这样性能敏感的内容。Octane仍然是这个任务的良好基准。

此图显示了我们优化和完善指针压缩实现时,Octane在x64架构上的得分。在图中,分数越高越好。红线表示现有的全尺寸指针x64版本,绿线表示指针压缩版本。

Octane改进的第一轮

在第一轮有效的实现中,我们有约35%的回归差距。

提升(1),+7%

首先,我们验证了“无分支更快”的假设,通过比较无分支解压与有分支解压的性能。结果表明我们的假设是错误的,有分支版本在x64上快了7%。这是一个相当显著的差异!

让我们看看x64汇编代码。

解压方式无分支有分支
代码```asm```asm \
movsxlq r11,[…]movsxlq r11,[…] \
movl r10,r11testb r11,0x1 \
andl r10,0x1jz done \
negq r10addq r11,r13 \
andq r10,r13done: \
addq r11,r10
``````
总结20字节13字节
^^执行6条指令执行3或4条指令
^^无分支一个分支
^^额外使用1个寄存器

这里的r13是一个专用寄存器,用于存储基值。注意无分支代码既更大,也需要更多寄存器。

在Arm64上,我们观察到同样的现象——有分支版本在功能强大的CPU上明显更快(虽然两种情况的代码大小相同)。

解压方式无分支有分支
代码```asm```asm \
ldur w6, […]ldur w6, […] \
sbfx x16, x6, #0, #1sxtw x6, w6 \
and x16, x16, x26tbz w6, #0, #done \
add x6, x16, w6, sxtwadd x6, x26, x6 \
done: \
``````
总结16字节16字节
^^执行4条指令执行3或4条指令
^^无分支一个分支
^^额外使用1个寄存器

在低端Arm64设备上,我们几乎没有观察到性能差异。

我们的结论是:现代CPU中的分支预测功能非常好,而代码大小(特别是执行路径长度)对性能的影响更大。

提升(2),+2%

TurboFan是V8的优化编译器,基于一种叫“节点海”的概念构建。简单来说,每个操作都在图中表示为一个节点(详见此博客文章)。这些节点具有多种依赖关系,包括数据流和控制流。

对指针压缩至关重要的两个操作是“加载”和“存储”,因为它们连接了V8堆和其他管道。如果我们每次从堆加载压缩值时解压,并在存储前压缩它,那么管道就可以像在全指针模式下工作一样继续运行。因此,我们在节点图中添加了新的显式值操作——解压和压缩。

在某些情况下,解压实际上是不必要的。例如,如果从某处加载一个压缩值,然后立即存储到一个新位置。

为了优化不必要的操作,我们在TurboFan中实现了一个新的“解压消除”阶段。其工作是消除直接跟随压缩的解压。由于这些节点可能并不紧挨在一起,它还尝试在图中传播解压,希望能遇到后续的压缩并同时消除它们。这为我们带来了Octane得分2%的提升。

提升(3),+2%

在查看生成的代码时,我们注意到刚加载的值的解压操作生成的代码有些冗长:

movl rax, <mem>   // 加载
movlsxlq rax, rax // 符号扩展

当我们修正它以直接从内存加载的值进行符号扩展后:

movlsxlq rax, <mem>

因此,又提升了2%的性能。

提升(4),+11%

TurboFan 的优化阶段通过在图上使用模式匹配工作:一旦子图匹配某种模式,就会被替换为语义上等价(但更优)的子图或指令。

未能找到匹配项并不是显式的失败。图中显式的解压缩/压缩操作导致先前成功的模式匹配尝试不再成功,从而导致优化静默失败。

一个“被损坏”的优化的例子是内存分配预分类。一旦我们更新了模式匹配以适应新的压缩/解压缩节点,又带来了11%的性能提升。

进一步的改进

第二轮 Octane 的改进

提升(5),+0.5%

在 TurboFan 中实现解压缩消除时,我们学到了很多。显式解压缩/压缩节点方法有以下特点:

优点:

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

然而,在继续实现过程中,我们发现了缺点:

  • 由于新的内部值表示导致可能的转换操作组合爆炸,变得不可管理。我们现在可以有压缩指针、压缩 Smi 以及任何压缩值(压缩值可能是指针或 Smi),再加上现有的一组表示(标记 Smi、标记指针、标记任何、word8、word16、word32、word64、float32、float64、simd128)。
  • 一些基于图模式匹配的现有优化悄然失效,导致这里和那里出现了性能下降。尽管我们找到了并修复了一些问题,但 TurboFan 的复杂性持续增加。
  • 寄存器分配器对图中节点的数量越来越不满意,经常生成低质量的代码。
  • 较大的节点图减慢了 TurboFan 的优化阶段,并增加了编译期间的内存消耗。

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

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

新实现与初始版本一样高效,并带来了额外的0.5%提升。

提升(6),+2.5%

我们逐渐接近性能平衡,但差距依然存在。我们不得不想出更新的想法。其中之一是:如果确保任何处理 Smi 值的代码从不“查看”高32位会如何?

让我们回顾解压缩实现:

// 旧的解压缩实现
int64_t uncompressed_tagged = int64_t(compressed_tagged);
if (uncompressed_tagged & 1) {
// 指针情况
uncompressed_tagged += base;
}

如果忽略 Smi 的高32位,我们可以假设它们是未定义的。然后,我们可以避免在指针和 Smi 情况之间的特殊处理,并在解压缩时无条件地添加 base,即使是 Smi!我们称这种方法为“Smi 冲突”。

// 新的解压缩实现
int64_t uncompressed_tagged = base + int64_t(compressed_tagged);

另外,由于我们不再关心符号扩展 Smi,这一改变允许我们回归到堆布局 v1。这是指针基地址指向4GB保留区开头的版本。

堆布局,基地址对齐到起点

在解压缩代码方面,它将符号扩展操作更改为零扩展,这同样廉价。然而,这简化了运行时(C++)方面的操作,例如地址空间区域保留代码(参见某些实现细节部分)。

以下为汇编代码的对比:

解压有分支修改 Smi
代码```asm```asm \
movsxlq r11,[…]movl r11,[rax+0x13] \
testb r11,0x1addq r11,r13 \
jz done
addq r11,r13
done:
``````
概要13 字节7 字节
^^执行 3 或 4 条指令执行 2 条指令
^^1 条分支无分支

因此,我们将 V8 中所有使用 Smi 的代码片段适配为新的压缩方案,这带来了额外 2.5% 的改进。

剩余差距

剩余的性能差距可以通过我们因与指针压缩的基本不兼容性而必须禁用的 64 位构建优化来解释。

Octane 最后一轮的性能改进

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

让我们回顾一下,在 64 位架构中的完整指针模式下,Smi 的表现方式。

        |----- 32 位 -----|----- 32 位 -----|
Smi: |____int32_value____|0000000000000000000|

32 位 Smi 具有以下优点:

  • 可以表示更大范围的整数,无需将其装箱为数字对象;并且
  • 这种形状在读/写时可以直接访问 32 位值。

由于 32 位压缩指针中没有区分指针和 Smi 的位,无法通过指针压缩实现此优化。如果我们在 64 位完整指针版本中禁用 32 位 Smi,我们会看到 Octane 分数下降 1%。

双字段解除装箱 (8), -3%

此优化尝试在特定假设下将浮点值直接存储在对象的字段中。其目的是比单独使用 Smi 更大程度地减少数字对象分配的数量。

设想以下 JavaScript 代码:

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

通常来说,如果我们查看对象 p 在内存中的样子,我们会看到如下内容:

内存中的对象 p

你可以在本文中阅读有关隐藏类和属性及元素支持存储的更多内容。

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

优化后内存中的对象 p

如果此假设在某些字段上被打破,例如在执行以下代码后:

const q = new Point(2, 'ab');

那么 y 属性的数字值就必须改为进行装箱存储。此外,如果某些地方有利用此假设进行推测性优化的代码,这些代码必须停止使用并丢弃(取消优化)。进行这种“字段类型”泛化的原因是为了尽量减少从同一个构造函数创建的对象形状的数量,而这反过来对实现更加稳定的性能是必要的。

内存中的对象 p 和 q

如果应用此优化,双字段解除装箱带来的好处如下:

  • 通过对象指针直接访问浮点数据,避免经由数字对象的额外解引用;并且
  • 能够为进行许多双字段访问的紧凑循环(例如数值运算应用)生成更小且更快的优化代码。

启用指针压缩后,双精度值不再适合压缩字段。然而未来我们可能会为指针压缩适配此优化。

请注意,即使没有此双字段解除装箱优化(兼容指针压缩的方式),需要高吞吐量的数值计算代码也可以通过将数据存储在 Float64 类型数组中,甚至使用 Wasm 来以可优化的方式重写。

更多改进 (9), 1%

最后,对 TurboFan 中解压消除优化的微调带来了额外 1% 的性能提升。

一些实现细节

为了简化指针压缩与现有代码的集成,我们决定在每次加载时解压值,在每次存储时压缩值。因此,仅改变了标记值的存储格式,而执行格式保持不变。

原生代码部分

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

因此,我们将根表放入 V8 堆的保留区域,这样,根寄存器便可以同时用于两个目的——作为根指针和解压的基值。

C++ 部分

V8 运行时通过 C++ 类访问 V8 堆中的对象,这些类提供了对堆中存储数据的便捷视图。请注意,V8 对象更像是 POD 结构,而不是 C++ 对象。这些辅助“视图”类仅包含一个 uintptr_t 字段,代表相应的标记值。由于视图类是字大小的,我们可以将它们通过值传递而零开销(多亏了现代 C++ 编译器)。

以下是一个辅助类的伪示例:

// 隐藏类
class Map {
public:

inline DescriptorArray instance_descriptors() const;

// 实际存储在 Map 视图对象中的标记指针值。
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) {
// 将地址向下舍入到 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++ 编译器不知道 GetBaseForPointerCompression() 的调用结果对于 V8 堆中的任何地址都是相同的,因此编译器无法合并基值计算。鉴于代码由多个指令和一个 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);

// 不需要舍入,因为 Isolate 指针已经是基值。
uintptr_t base = reinterpret_cast<uintptr_t>(isolate);
uintptr_t da = DecompressTagged(base, compressed_value);
return DescriptorArray(da);
}

结果

让我们来看看指针压缩的最终数据!对于这些结果,我们使用了在本文开头介绍的浏览测试。提醒一下,这些是我们发现能代表真实网站使用情况的浏览用户行为。

在这些测试中,我们观察到指针压缩最多减少了 43% 的 V8 堆大小!从而在桌面上最多减少了 20% 的 Chrome 渲染器进程内存

浏览时的内存节省(Windows 10)

另一个需要注意的重要点是,并不是每个网站都能有相同的改进。例如,V8 堆内存在 Facebook 上过去比纽约时报更大,但使用指针压缩后实际上情况反转了。这差异可以用某些网站拥有更多标记值来解释。

除了这些内存改进,我们还看到了实际网站上的性能改进。我们在真实网站上使用较少的 CPU 和垃圾回收时间!

CPU 和垃圾回收时间的改进

结论

到达这里的旅程并非轻松顺利,但这一切都值得。300多次提交之后,启用了指针压缩的V8所使用的内存与运行32位应用程序时所需的内存一样少,但性能却达到了64位应用程序的水平。

我们始终致力于改进,并且在我们的计划中还有以下相关任务:

  • 提高生成汇编代码的质量。我们知道在某些情况下可以生成更少的代码,这将提高性能。
  • 解决相关的性能回退问题,包括提供一种机制,用指针压缩友好的方式重新支持解包双字段。
  • 探索支持更大堆内存的可能性,范围在8到16 GB之间。