增强版 V8,支持可变堆数字
在 V8 中,我们始终致力于提升 JavaScript 性能。作为此项工作的一个部分,我们最近重新审视了 JetStream2 基准测试套件,以消除性能瓶颈。本篇文章详细介绍了我们进行的一项优化,该优化使 async-fs
基准测试的性能提升了显著的 2.5 倍
,并对整体得分产生了显著影响。这项优化源于基准测试,但类似的模式确实存在于 真实代码中。
async-fs
基准测试,如其名字所示,是一个 JavaScript 文件系统实现,专注于异步操作。然而,存在一个令人惊讶的性能瓶颈:Math.random
的实现。它使用了一个自定义的确定性 Math.random
实现,以确保每次运行结果一致。其实现如下:
let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
return (seed & 0xfffffff) / 0x10000000;
};
})();
这里的关键变量是 seed
。它在每次调用 Math.random
时都会更新,从而生成伪随机序列。重要的是,seed
保存在 ScriptContext
中。
ScriptContext
是一个存储位置,用于保存特定脚本中可访问的值。在内部,这个上下文被表示为一个包含 V8 的带标签值的数组。对于 64 位系统的默认 V8 配置,每个带标签值占据 32 位,其中每个值的最低有效位用作标签。0
表示 31 位 小整数 (SMI
),实际整数值直接存储并左移一位。1
表示指向堆对象的 压缩指针,其中压缩指针值加一。
这种标签区分了数字存储方式。SMI
直接保存在 ScriptContext
中。更大的数字或带有小数部分的数字则间接存储为不可变的堆数字 (HeapNumber
) 对象(以 64 位双精度表示),ScriptContext
中保存指向它们的压缩指针。这种方法有效地处理了各种数字类型,同时为常见的 SMI
情况进行了优化。
性能瓶颈
对 Math.random
的分析揭示了两个主要性能问题:
-
HeapNumber
分配: 脚本上下文中seed
变量所在的槽位指向标准的、不可变的HeapNumber
。每次Math.random
函数更新seed
时,都需要在堆上分配一个新的HeapNumber
对象,导致显著的分配和垃圾回收压力。 -
浮点运算: 尽管
Math.random
中的计算本质上是整数操作(使用位移和加法),编译器却无法充分利用这一特点。由于seed
作为通用的HeapNumber
存储,生成的代码使用了较慢的浮点指令。编译器无法证明seed
始终是可以用整数表示的值。即使编译器可能推测为 32 位整数范围,仍然需要进行可能昂贵的从 64 位浮点到 32 位整数的转换,以及无损检查。
解决方案
为了解决这些问题,我们实施了两部分优化:
-
插槽类型跟踪 / 可变堆数字插槽: 我们扩展了脚本上下文常量值跟踪(已初始化但从未修改的let变量)以包含类型信息。我们追踪该插槽值是否是常量、
SMI
、HeapNumber
或是通用的标记值。我们还在脚本上下文中引入了类似于为JSObjects
设置的可变堆数字字段的可变堆数字插槽概念。脚本上下文插槽不再指向不可变的HeapNumber
,而是拥有HeapNumber
,并且不应该泄漏其地址。这消除了优化代码在每次更新时分配新的HeapNumber
的需要。这种情况下,被拥有的HeapNumber
本身会就地修改。 -
可变堆
Int32
: 我们增强了脚本上下文插槽类型以追踪数值是否在Int32
范围内。如果是,表示可变的HeapNumber
存储值作为原始Int32
。如果需要转换为double
,它具有无需重新分配HeapNumber
的额外好处。在Math.random
的情况下,编译器现在可以观察到seed
变量始终通过整数操作更新,并将插槽标记为含有可变Int32
。
需要注意的是,这些优化会引入代码对存储在上下文插槽中的值类型的依赖关系。JIT编译器生成的优化代码依赖于插槽包含特定类型(此处为Int32
)。如果有任何代码将改变其类型的值写入seed
插槽(例如,写入浮点数或字符串),优化代码需要进行去优化。这是为了确保正确性。因此,插槽中存储的类型稳定性对于维持最佳性能至关重要。在Math.random
的情况下,算法中的位掩码操作确保seed变量始终持有Int32
值。
结果
这些变化显著加速了特殊的Math.random
函数:
-
无分配 / 快速就地更新:
seed
值直接在其脚本上下文中的可变插槽内更新。在Math.random
执行期间没有分配新的对象。 -
整数操作: 编译器借助插槽包含
Int32
的信息,可以生成高度优化的整数指令(移位、加法等)。这避免了浮点运算的开销。
这些优化的综合效果使得async-fs
基准测试达到了惊人的~2.5x
加速。这又进一步推动了整体JetStream2评分的~1.6%
提升。这表明看似简单的代码可能会造成意外的性能瓶颈,而针对性的微调优化能够在不只是基准测试方面产生巨大影响。