原文连接:v8.dev/blog/pointe…html
内存和性能之间的斗争始终存在。做为用户,咱们但愿速度又快占用内存又少。然而一般状况下,提升性能须要消耗更多的内存(反之亦然)。node
时间回到2014年,那时Chrome从32位切换到64位。这个变化带给了Chrome更好的安全性、稳定性和性能,但同时也带来了更多内存的消耗,由于以前每一个指针占用4个字节而如今占用是8个字节。咱们面临在V8中尽量减小这种多出来4个字节开销的挑战。git
在实施改进以前,咱们须要知道咱们目前的情况,从而正确的评估如何改进。为了测量当前的内存和性能,咱们使用一组能够表明目前流行站点的页面。数据显示在桌面端Chrome渲染进程内存占用中V8占用了60%,平均为40%。github
指针压缩是改进V8内存占用的多项工做之一。想法很简单:咱们能够存储一些“基”地址的32位偏移量而不是存储64位指针。这样一个简单的想法,咱们能够从V8中的这种压缩得到多少收益?web
V8的堆区包含大量的项目(items),例如浮点值(floating point values),字符串字符(string characters),解析器字节码(interpreter bytecode)和标记值(tagged values)。在检查堆区时,咱们发如今现实使用的网站中,这些标记值占了V8堆区的70%!windows
下面咱们具体看看这些标记值是什么。数组
在V8中JavaScript的对象,数组,数字或者字符串都用对象表示,分配在V8堆区。这使得咱们能够用一个指向对象的指针表示任何值。安全
许多JavaScript程序都会对整数进行计算,例如在循环中增长索引。为了不每次整数递增时从新分配一个新的number对象,V8使用著名的指针标记技术(pointer tagging)在V8的堆指针中存储其余或替代数据。bash
标记位(tag bits)有双重做用:用于指示位于V8堆中对象的强/弱指针或一个小整数的信号。所以,整数可以直接存储在标记值中,而没必要为其分配额外的存储空间。架构
V8在堆中按字对齐的地址分配对象,这使得它可使用2(或3,取决于机器字大小)最低有效位进行标记。在32位架构中,V8使用最低有效位去区分Smis和堆对象指针。对于堆指针,它使用第二个最低有效位去区分强引用和弱引用:
|----- 32 bits -----|
Pointer: |_____address_____w1|
Smi: |___int31_value____0|
复制代码
这里的 w 用来区分强指针和弱指针。
*注意:*一个Smi值只能携带一个31bit有效载荷(payload),包括符号位。对于指针,咱们有30bit用来做为堆对象地址有效载荷(payload)。因为字对齐,分配粒度为4个字节,这给了咱们4GB的寻址空间。
在64位架构中,V8的值看起来像这样:
|----- 32 bits -----|----- 32 bits -----|
Pointer: |________________address______________w1|
Smi: |____int32_value____|0000000000000000000|
复制代码
不一样于32位架构,在64位架构中V8能够将32位用于Smi值有效载荷(payload)。如下各节将讨论32位Smis对指针压缩的影响。
使用指针压缩,咱们的目标是以某种方式在64位架构中将两种标记值转换为32位。咱们经过如下方式将指针调整为32位:
这样严格的限制是很是不幸的,可是Chrome中的V8已经将堆限制到2GB或4GB大小(具体限制到多少取决于设备),即便在64位架构上也是如此。其余V8嵌入程序,例如Node.js可能须要更大的堆。若是咱们添加最大4GB的限制,就会让这些嵌入V8的程序没法使用指针压缩。
如今的问题是如何更新堆布局才能让32位指针惟一标识V8对象。
简单的压缩方案是在前4GB的地址空间分配对象。
可是很惋惜V8不能这样作,由于Chrome的渲染进程可能须要在同一渲染器进程中建立多个V8的实例,例如对于Web/Service Workers。除此以外,用这个方案会致使全部的V8实例竞争相同的4GB地址空间从而致使全部的V8实例都受到4GB内存的限制。
若是咱们将V8堆(heap)放在其余地方的连续4GB地址空间,那么一个从base开始的无符号32位偏移量将惟一标识一个指针。
若是咱们确保base是4GB对齐(4-GB-aligned),则全部指针的高位32位都相同。
|----- 32 bits -----|----- 32 bits -----|
Pointer: |________base_______|______offset_____w1|
复制代码
经过将Smi的有效载荷(payload)限制为31位并将其放在低32位,咱们还能够压缩Smis。基本上,使它和在32位架构中相似。
|----- 32 bits -----|----- 32 bits -----|
Smi: |sssssssssssssssssss|____int31_value___0|
复制代码
这里 s 是Smi有效载荷的符号值。若是再有使用符号扩展表示,咱们就能够仅用64位字的一位算数移位来压缩和解压Smis。
如今,咱们能够看到指针和Smis的上半字(upper half-word)彻底由下半字定义。这样,咱们就能够只将后者存储在内存中,从而将存储标记值所需的内存减小一半。
|----- 32 bits -----|----- 32 bits -----|
Compressed pointer: |______offset_____w1|
Compressed Smi: |____int31_value___0|
复制代码
假设base是4GB对齐的,则压缩就是截断:
uint64_t uncompressed_tagged;
uint32_t compressed_tagged = uint32_t(uncompressed_tagged);
复制代码
可是解压代码要复杂一些。咱们须要区分符号扩展(sign-extending)Smi和零扩展(zero-extending)指针,以及是否要添加base。
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);
}
复制代码
尝试改变压缩方案来简化解压代码。
若是将base不是放在4GB的开头,而是中间,就能够将压缩值视为从base开始的一个有符号32位偏移量。注意,整个保留再也不是4GB对齐(4-GB-aligned),可是base依然是对齐的。
在这个新的布局中,压缩代码和上面堆内存布局v1中的相同。
然而解压代码变得更好了。如今对Smi和指针来讲,符号扩展是相同的,惟一的分支在于若是是指针,须要添加base。
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是咱们过去使用的性能基准测试。尽管咱们在平常工做中再也不专一于提升性能峰值(improving peak performance),但咱们也不但愿下降它,特别是一些像指针这样对性能敏感的东西。Octane依然是完成这个任务的好的基准测试。
图形显示了在使用指针压缩时Octane在x64架构上的得分。在图中,线越高越好。红色的线是未压缩指针的x64构建,绿色的线是指针压缩的版本。
在第一个方案中,咱们的回归差约为35%。
首先咱们经过比较无分支解压和有分支解压,验证了“无分支会更快”的假设。事实证实,咱们的假设是错误的,在x64上,有分支版本的速度提升了7%。这是很是大的不一样!
下面看一下x64汇编
r13是base值的专用寄存器。注意,无分支代码在这里代码量更多且须要的寄存器也更多。
在Arm64,咱们观察到相同的现象——在强大的CPU上,有分支版本明显更快(尽管这两种状况的代码大小是同样的)。
在低端Arm64设备上咱们发如今任一方向上几乎没什么性能差别。
咱们的收获是:在现代CPU中分支预测器很是的好,代码的大小(code size)(尤为是执行路径的长度)对性能影响更大。
TurboFan是V8的优化编译器,围绕“Sea of Nodes”概念构建。简单来讲就是每个操做在graph中用一个Node表示(更详细的解释能够查看这篇博客。这些节点有各类依赖,包括数据流和控制流。
有两个对指针压缩相当重要的操做:加载和存储,由于它们将V8堆内存和管道(pipeline)的其他部分连起来。若是咱们每次从堆内存加载压缩值的时候都解压,而且在存储以前对其压缩,那么管道(pipeline)就能够像在全指针模式(full-pointer mode)下工做了。所以咱们在节点图中添加了新的显式操做——压缩和解压。
在某些状况下解压是不须要的,例如,若是一个压缩值仅仅是从某个位置被加载而后存储到新的位置。
为了优化没必要要的操做,咱们在TurboFan中实施了一个新的“消除解压”阶段。它的工做就是消除直接压缩后的解压。因为这些节点可能不会直接相连,所以它会尝试经过graph传播解压,以期遇到压缩问题并消除。这使咱们的Octane的值提升了2%。
在查看生成代码时,咱们注意到解压一个刚刚被加载的值会致使代码的冗长:
movl rax, <mem> // load
movlsxlq rax, rax // sign extend
复制代码
一旦咱们修复了标志扩展的问题,value就能够直接从内存中加载。
movlsxlq rax, <mem>
复制代码
咱们获得了另外2%的改善。
TurboFan优化阶段经过在graph上使用模式匹配工做:一旦一个sub-garph与一个特定模式匹配,就会被替换为语义上等效(可是更好)的sub-graph或指令(instruction)。
尝试匹配不成功并不会有明确的失败提示。在graph中显式的压缩/解压操做致使以前成功的模式匹配尝试失败,从而致使优化失败且没有提示。
“中断”优化的其中一个例子是分配预配置(allocation preternuring)。一旦咱们更新匹配模式(pattern matching)使其可以匹配到新的压缩 / 解压 node,咱们就能够获得另外11%的改进。
在TurboFan中使用解压去除(Decompression Elimination)咱们学到了不少。显式的解压 / 压缩node方法具备如下特性:
优势:
可是,随着咱们进一步的实施,咱们发现缺点:
新的内部值的表示可能会致使转换操做变的难以管理。除了现有的表示集(tagged Smi, tagged pointer, tagged any, word8, word16, word32, float32, float64, simd128),咱们还有压缩指针,压缩Smi,压缩任何值(压缩值能够是指针或Smi)。
现有的基于graph的模式匹配(pattern-matching)的优化并无生效,这致使了一些地方的回退(regressions)。尽管咱们找到并修复其中的问题,但TurboFan的复杂性仍在不断增长。
寄存器分配器(register allocator)对graph中的node数量愈来愈不满意,而且常常生成错误的代码。
较大的node graph会减缓TurboFan优化阶段,并增长编译期间的内存消耗。
咱们决定回退一步,考虑在TurboFan中实现一种更简单的指针压缩方式。新的方法是删除压缩指针/Smi/任何表示,而后让全部显式的压缩/解压 node 隐藏在存储和加载中,并假设咱们始终在加载以前压缩,在存储以前解压。
咱们还在TurboFan中添加新的阶段,该阶段将替代“解压消除(Decompression Elimination”。这个新的阶段可以识别咱们何时不须要压缩或解压并相应地更新“加载和存储”。这种方法显著下降了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之间的特殊case,而且能够在解压的时候无条件的添加base,即便是对Smis也能够!咱们称这个方法为“Smi-corrupting”。
// New decompression implementation
int64_t uncompressed_tagged = base + int64_t(compressed_tagged);
复制代码
因为咱们不关注Smi的符号扩展(sign extending),所以这个改变容许咱们回到堆内存布局v1。这是一个base指向4GB预留空间的开始位置。
就解压代码而言,这个改变将符号扩展(sign-extension)变为零扩展(zero-extension),这也一样简单。可是这简化了运行时(C++)端的工做。例如,例如地址空间区域保留代码(查看一些细节实现部分)。
这是用于比较的汇编:
所以咱们更将8中全部的使用Smi的代码块调整为新的压缩方案,这给咱们另外2.5%的性能提高。
剩余的性能差距能够用对64位构建的两个优化来解释,这些优化因为与指针压缩不兼容而禁用。
咱们回顾一下,Smis在64位架构全指针模式中看起来是这样:
|----- 32 bits -----|----- 32 bits -----|
Smi: |____int32_value____|0000000000000000000|
复制代码
32-bit Smi有以下好处:
因为使用指针压缩后会具备区分指针和Smis的bit,致使在32-bit压缩指针中没有空间,因此致使该优化没法使用。若是咱们在64-bit版本中禁用32-bit smis,将会看到Octane值降低1%。
译者注:装箱(boxing)是指编译器自动将基本数据类型值转换成对应的包装类的对象,拆箱(unboxing)则是反过来。
在某些假设下,这种优化尝试直接将浮点值存储在对象的字段中。这样作的目的是减小数字对象分配的数量,这比单独用Smis减小的更多。
想象一下下面这段代码:
function Point(x, y) {
this.x = x;
this.y = y;
}
const p = new Point(3.1, 5.3);
复制代码
通常来讲,对象p在内存中的样子以下:
关于更多存储中的隐藏类,属性和元素能够阅读此文
在64位架构中,双精度值和指针的大小相同。因此若是咱们假设Point字段老是包含number值,则能够将它们直接存储在对象中。
若是某个字段致使假设不成立,例如执行下面这段代码:
const q = new Point(2, 'ab');
复制代码
y属性的number值必须装箱存储(store boxed instead)。另外,若是某处的优化的代码依赖此假设,则该优化必须舍弃。进行这些“字段类型”泛化的缘由是为了尽可能减小经过同一构造函数建立的对象的Shapes(译者注:在 JavaScript 程序中,多个对象具备相同的key,JS引擎会将这些key单独存储在一个地方,从而优化存储,具体能够查看[译] JavaScript 引擎基础:Shapes 和 Inline Caches)数量,反过来这对于具备稳定的性能是很必要的。
若是应用该优化,双精度字段拆箱给咱们以下好处:
启用指针压缩后,双精度值再也不适合压缩字段。然而,在将来咱们可能为指针压缩适配该优化。
注意,即便没有双精度字段拆箱优化(以与指针压缩兼容的方式),也能够经过将数据存储在Float64 TypedArrays,甚至是使用Wasm重写要求高吞吐量的数字运算代码。
最后,对TurboFan中的解压消除优化进行微调又获得另外1%的性能提高。
为了简化将指针压缩整合到现有代码中,咱们决定在每次加载values的时候解压而且在每次存储的时压缩它们。所以只是改变标志值的存储格式,而执行格式保持不变。
为了在解压的时候生成有效的代码,必须保证始终提供base值。幸运的是V8已经有一个专用的寄存器指向一个“根表(roots table)”,该表包含JavaScript和V8内部对象的引用,这些对象必须始终可用(例如:undefined
,null
,true
,false
等)。该寄存器被称为“根寄存器”,它用来生成较小的,能够共享的内部代码。
因此,咱们将根表放在V8堆保留区,根寄存器能够同时有两种用途:
V8运行时经过C++类访问在V8堆区的对象,从而提供对堆中存储的数据的便捷访问。请注意,V8对象比C++对象更相似于POD的结构。助手(helper)“view”类仅仅包含一个带有相应标记值的uintptr_t
字段。因为view类是字大小的(word-size),所以咱们能够将它按值传递,开销为零(这样感谢现代C++编译器)。
这里是一个helper类的伪代码:
// Hidden class
class Map {
...
inline DescriptorArray instance_descriptors() const;
...
// The actual tagged pointer value stored in the Map view object.
cosnt 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);
}
复制代码
为了尽可能减小首次运行指针压缩版本的所需的更改次数,咱们将解压必须的base值的计算集成到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);
}
复制代码
性能测量结果证明,在每次加载的时候计算base值会影响性能。缘由在于C++编译器不知道,对于V8堆区的任何地址调用GetBaseForPointerCompression()
的结果是相同的,所以编译器没法合并base值的计算。鉴于代码包含多个指令和一个64位常量,这将致使代码显著膨胀。
为了处理这个问题,咱们重用V8实例指针做为解压时用的base(记住,V8实例数据在堆区布局中)。该指针一般在运行时函数中可用,因此咱们经过要求使用V8实例指针简化getters代码,并恢复来了性能:
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堆区内存比纽约时报要多,可是使用该优化后,使用堆内存状况变得相反。这个不一样能够经过如下事实解释:某些网站具备比其余网站更多的标记值(Tagged values)。
除了这些内存改进,咱们还看到了实际性能的改进。在真实网站上,咱们使用更少的CPU和垃圾回收时间!
这一路上尽管没有鸟语花香,可是值得度过。300+的提交后,指针压缩让V8拥有64位应用的性能,同时拥有32位的内存占用。
咱们一直期待着性能的改进,并在流程中完成如下相关任务: