V8 中的 JavaScript 性能提示

Chris Wilson
Chris Wilson

简介

Daniel Clifford 在 Google I/O 大会上发表了一场精彩演讲,介绍了有关如何在 V8 中提升 JavaScript 性能的技巧。Daniel 鼓励我们“要求更快”- 仔细分析 C++ 和 JavaScript 之间的性能差异,并在编写代码时注意 JavaScript 的运作方式。本文总结了 Daniel 演讲中最重要的要点,我们还会根据效果指南的变化更新本文。

最重要的建议

请务必根据具体情况来解读任何效果建议。效果建议很容易让人上瘾,有时先专注于深入的建议,可能会分散对真正问题的注意力。您需要全面了解 Web 应用的性能。在专注于这些性能提示之前,您可能应该使用 PageSpeed 等工具分析代码并提高得分。这有助于避免过早优化。

关于如何在 Web 应用中获得良好性能,最实用的一般建议是:

  • 在出现(或发现)问题之前做好准备
  • 然后,确定并了解问题的核心
  • 最后,解决重要问题

为了完成这些步骤,了解 V8 如何优化 JS 可能非常重要,这样您就可以在编写代码时考虑 JS 运行时设计。了解可用的工具及其功能也很重要。Daniel 在演讲中详细介绍了如何使用开发者工具;本文档仅介绍了 V8 引擎设计的一些重要要点。

接下来,我们来看看 V8 提示!

隐藏的课程

JavaScript 具有有限的编译时类型信息:类型可以在运行时更改,因此在编译时推理 JS 类型的开销自然较高。这可能会让您质疑 JavaScript 性能如何能接近 C++。不过,V8 会在运行时为对象在内部创建隐藏类型;然后,具有相同隐藏类的对象可以使用相同的优化生成代码。

例如:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

在对象实例 p2 添加额外的成员“.z”之前,p1 和 p2 在内部具有相同的隐藏类,因此 V8 可以为操控 p1 或 p2 的 JavaScript 代码生成单个版本的优化汇编。您越能避免导致隐藏类发生分歧,性能就越好。

因此

  • 在构造函数中初始化所有对象成员(以便实例日后不会更改类型)
  • 始终按相同的顺序初始化对象成员

Numbers

当类型可能会发生变化时,V8 会使用标记来高效地表示值。V8 会根据您使用的值推断您所处理的数字类型。V8 进行推理后,会使用标记来高效地表示值,因为这些类型可能会发生动态变化。不过,更改这些类型标记有时会带来成本,因此最好始终使用一致的数字类型,通常,在适当的情况下使用 31 位有符号整数是最优选择。

例如:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

因此

  • 请优先使用可表示为 31 位有符号整数的数值。

数组

为了处理大型稀疏数组,内部提供了两种类型的数组存储:

  • Fast Elements:适用于紧凑键集的线性存储
  • 字典元素:否则使用哈希表存储

最好不要让数组存储从一种类型切换到另一种类型。

因此

  • 为数组使用从 0 开始的连续键
  • 请勿为大型数组(例如大于 64K 个元素)预分配其最大大小,而应随时增长
  • 请勿删除数组中的元素,尤其是数值数组
  • 请勿加载未初始化或已删除的元素:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

此外,双精度数数组的速度更快 - 数组的隐藏类会跟踪元素类型,并且仅包含双精度数的数组会取消封装(这会导致隐藏类发生变化)。不过,如果不小心地操作数组,可能会因封装和取消封装而导致额外的工作 - 例如

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

效率低于:

var a = [77, 88, 0.5, true];

因为在第一个示例中,各个赋值是依次执行的,而对 a[2] 的赋值会导致数组转换为一个未封装的双精度数数组,但对 a[3] 的赋值会导致其重新转换为一个可以包含任何值(数字或对象)的数组。在第二种情况下,编译器知道字面量中所有元素的类型,并且可以预先确定隐藏的类。

  • 使用数组字面量初始化小型固定大小的数组
  • 在使用小型数组(小于 64k)之前,预先分配正确大小
  • 请勿在数值数组中存储非数值(对象)
  • 如果您在没有使用字面量的情况下进行初始化,请务必注意不要导致小数组重新转换。

JavaScript 编译

虽然 JavaScript 是一种非常动态的语言,并且其最初的实现是解释器,但现代 JavaScript 运行时引擎使用编译。事实上,V8(Chrome 的 JavaScript)具有两个不同的即时 (JIT) 编译器:

  • “完整”编译器,可为任何 JavaScript 生成优质代码
  • 优化编译器,可为大多数 JavaScript 生成出色的代码,但编译时间较长。

完整编译器

在 V8 中,完整编译器会针对所有代码运行,并尽快开始执行代码,从而快速生成良好但不是极佳的代码。该编译器在编译时几乎不对类型做出任何假设,它预计变量的类型可以在运行时发生变化。完整编译器生成的代码会使用内嵌缓存 (IC) 在程序运行时优化对类型的了解,从而动态提高效率。

内嵌缓存的目标是通过缓存操作的类型依赖代码来高效处理类型;当代码运行时,它会先验证类型假设,然后使用内嵌缓存来简化操作。不过,这意味着接受多种类型的操作的性能会较差。

因此

  • 建议使用单态操作,而不是多态操作

如果输入的隐藏类始终相同,则操作是单态的;否则,操作是多态的,这意味着在对操作进行不同的调用时,某些参数可能会更改类型。例如,此示例中的第二个 add() 调用会导致多态性:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

优化编译器

与完整编译器并行,V8 使用优化编译器重新编译“热门”函数(即运行多次的函数)。此编译器使用类型反馈来加快编译代码的速度 - 事实上,它使用的是刚才提到的 IC 中提取的类型!

在优化编译器中,操作会被推测性内嵌(直接放置在调用它们的位置)。这可以加快执行速度(但会增加内存占用量),但也支持其他优化。单态函数和构造函数可以完全内嵌(这也是在 V8 中采用单态性是一个好主意的另一个原因)。

您可以使用 V8 引擎的独立“d8”版本记录要优化的内容:

d8 --trace-opt primes.js

(这会将优化函数的名称记录到标准输出。)

不过,并非所有函数都可以进行优化,因为某些功能会阻止优化编译器在给定函数上运行(即“放弃”)。特别值得注意的是,优化编译器目前会针对包含 try {} catch {} 块的函数中止!

因此

  • 如果您有 try {} catch {} 块,请将对性能敏感的代码放入嵌套函数中: ```js function perf_sensitive() { // Do performance-sensitive work here }

try { perf_sensitive() } catch (e) { // Handle exceptions here } ```

随着我们在优化编译器中启用 try/catch 块,此指南未来可能会发生变化。您可以将“--trace-opt”选项与 d8 搭配使用,如上所示,以检查优化编译器如何对函数进行终止处理,从而详细了解哪些函数被终止处理:

d8 --trace-opt primes.js

脱优化

最后,此编译器执行的优化是推测性的 - 有时它不起作用,我们会退回。"deoptimization" 流程会舍弃优化后的代码,并在“完整”编译器代码中的正确位置恢复执行。系统可能会稍后再次触发重新优化,但在短期内,执行速度会变慢。特别是,在函数经过优化后导致隐藏类变量发生更改,会导致发生这种去优化。

因此

  • 避免在函数经过优化后出现隐藏的类更改

与其他优化一样,您可以使用日志记录标志获取 V8 必须取消优化的函数的日志:

d8 --trace-deopt primes.js

其他 V8 工具

顺便提一下,您还可以在启动时将 V8 跟踪选项传递给 Chrome:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

除了使用开发者工具进行性能分析外,您还可以使用 d8 进行性能分析:

% out/ia32.release/d8 primes.js --prof

这会使用内置的抽样性能分析器,该分析器会每毫秒抽取一个样本并写入 v8.log。

总结

请务必识别并了解 V8 引擎如何与您的代码协同工作,以便为构建高性能 JavaScript 做好准备。再次强调一下,基本建议是:

  • 在出现(或发现)问题之前做好准备
  • 然后,确定并了解问题的核心
  • 最后,解决重要问题

这意味着,您应先使用 PageSpeed 等其他工具确保问题出在 JavaScript 中;可能需要先缩减为纯 JavaScript(无 DOM),然后再收集指标,然后使用这些指标来查找瓶颈并消除重要的瓶颈。希望 Daniel 的演讲(以及本文)能帮助您更好地了解 V8 如何运行 JavaScript,但也请务必专注于优化您自己的算法!

参考