JavaScript V8 性能小贴士

jopen 9年前

原文:Performance Tips for JavaScript in V8
译文:JavaScript V8性能小贴士


简介

关于如何巧妙提高 V8 JavaScript 性能的话题,Daniel Clifford 在 Google I/O 上做了一次非常精彩的分享。Daniel 鼓励我们“追求更快”,认真的分析 C++ 和 JavaScript 之间的性能差距,根据 JavaScript 的工作原理撰写代码。在 Daniel 的分享中,有一个核心要点的归纳,我们也会根据性能指导的变化保持对这篇文章的更新。

最重要的建议

最重要的是要把任何性能建议放在特定的情境当中。性能建议是附加的东西,有时一开始就特别注意深层的建议反而会对我们造成干扰。你需要从一个综合的角度看待你的 Web 应用的性能——在关注这些性能建议之前,你应该找 PageSpeed 之类的工具大概分析一下你的代码,也算是跑个分先。这会防止你过度优化。

对Web应用的性能优化,几个原则性的建议是:

  • 首先,未雨绸缪
  • 然后,找到症结
  • 最后,修复它

为了完成这几个步骤,理解 V8 如何优化 JS 是一件很重要的事情,这样你就可以根据其对 JS 运行时的设计撰写代码。同样重要的是掌握一些帮得上忙的工具。Daniel 也交代了一些开发者工具的用法,它们刚好抓住了一些V8引擎设计上最重要的部分。

OK。开始 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);  // 这里的 p1 和 p2 拥有共享的隐藏类  p2.z = 55;  // 注意!这时 p1 和 p2 的隐藏类已经不同了!

在我们为p2添加z这个成员之前,p1和p2一直共享相同的内部隐藏类——所以 V8 可以生成一段单独版本的优化汇编码,这段代码可以同时封装p1和p2的 JavaScript 代码。我们越避免隐藏类的派生,就会获得越高的性能。

结论

  • 在构造函数里初始化所有对象的成员(所以这些实例之后不会改变其隐藏类)
  • 总是以相同的次序初始化对象成员

数字

当类型可以改变时,V8 使用标记来高效的标识其值。V8 通过其值来推断你会以什么类型的数字来对待它。因为这些类型可以动态改变,所以一旦 V8 完成了推断,就会通过标记高效完成值的标识。不过有的时候改变类型标记还是比较消耗性能的,我们最好保持数字的类型始终不变。通常标识为有符号的 31 位整数是最优的。

比如:

var i = 42; // 这是一个31位有符号整数  var j = 4.2; // 这是一个双精度浮点数

结论

尽量使用可以用 31 位有符号整数表示的数。

数组

为了掌控大而稀疏的数组,V8 内部有两种数组存储方式:

  • 快速元素:对于紧凑型关键字集合,进行线性存储
  • 字典元素:对于其它情况,使用哈希表

最好别导致数组存储方式在两者之间切换。

结论

  • 使用从 0 开始连续的数组关键字
  • 别预分配大数组(比如大于 64K 个元素)到其最大尺寸,令其尺寸顺其自然发展就好
  • 别删除数组里的元素,尤其是数字数组
  • 别加载未初始化或已删除的元素: ```javascript a = new Array(); for (var b = 0; b < 10; b++) { a[0] |= b; // 杯具! }

// vs.

a = new Array(); a[0] = 0; for (var b = 0; b < 10; b++) { a[0] |= b; // 比上面快 2 倍 } ```

同样的,双精度数组会更快——数组的隐藏类会根据元素类型而定,而只包含双精度的数组会被拆箱(unbox),这导致隐藏类的变化。对数组不经意的封装就可能因为装箱/拆箱(boxing/unboxing)而导致额外的开销。比如:

var a = new Array();  a[0] = 77; // 分配  a[1] = 88;  a[2] = 0.5; // 分配,转换  a[3] = true; // 分配,转换

下面的写法效率更高:

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

因为第一个例子是一个一个分配赋值的,并且对a[2]的赋值导致数组被拆箱为了双精度。但是对a[3]的赋值又将数组重新装箱回了任意值(数字或对象)。第二种写法时,编译器一次性知道了所有元素的字面上的类型,隐藏隐藏类可以直接确定。

结论

  • 初始化小额定长数组时,用字面量进行初始化
  • 小数组(小于 64k)在使用之前先预分配正确的尺寸
  • 请勿在数字数组中存放非数字的值(对象)
  • 如果通过非字面量进行初始化小数组时,切勿触发类型的重新转换

JavaScript 编译

尽管 JavaScript 是个非常动态的语言,且原本的实现是解释性的,但现代的 JavaScript 运行时引擎都会进行编译。V8(Chrome 的 JavaScript)有两个不同的运行时(JIT)编译器:

  • “完全”编译器,可以为任何 JavaScript 生成优秀的代码
  • 优化编译器,可以为大部分 JavaScript 生成伟大(汗一下自己的翻译)的代码,但会更耗时。

完全编译器

在 V8 中,完全编译器会以最快的速度运行在任何代码上,快速生成优秀但不伟大的代码。该编译器在编译时几乎不做任何有关类型的假设——它预测类型在运行时会发生改变。完全编译器的生成码通过内联缓存(ICs)在程序运行时提炼类型相关的知识,以便将来改进和优化。

内联缓存的目的是,通过缓存依赖类型的代码进行操作,更有效率的掌控类型。当代码运行时,它会先验证对类型的假设,然后使用内联缓存快速执行操作。这也意味着可以接受多种类型的操作会变得效率低下。

结论

  • 单态操作优于多态操作

如果一个操作的输入总是相同类型的,则其为单态操作。否则,操作调用时的某个参数可以跨越不同的类型,那就是多态操作。比如add()的第二个调用就触发了多态操作:

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

add(1, 2); // add 中的 + 操作是单态操作 add("a", "b"); // add 中的 + 操作变成了多态操作

优化编译器

V8 有一个和完全编译器并行的优化编译器,它会重编那些最“热门”(即被调用多次)的函数。优化编译器通过类型反馈来使得编译过的代码更快——事实上它就是使用了我们之前谈到的 ICs 的类型信息!

在优化编译器里,操作都是内联的(直接出现在被调用的地方)。它加速了执行(拿内存空间换来的),同时也进行了各种优化。单态操作的函数和构造函数可以整个内联起来(这是 V8 中单态操作的有一个好处)。

你可以使用单独的“d8”版本的 V8 引擎来获取优化记录:

d8 --trace-opt primes.js

(其会把被优化的函数名输出出来)

不是所有的函数都可以被优化,有些特性会阻止优化编译器运行一个已知函数(bail-out)。目前优化编译器会排除有 try/catch 的代码块的函数。

结论

  • 如果存在 try/catch 代码快,则将性能敏感的代码放到一个嵌套的函数中: ```javascript function perf_sentitive() { // 把性能敏感的工作放置于此 }

try { perf_sentitive() } catch (e) { // 在此处理异常 } ```

这个建议可能会在未来发生改变,因为我们会在优化编译器里开启 try/catch 代码块。你可以通过使用上述的 d8 选项--trace-opt得到更多有关这些函数的信息来检验优化编译器如何排除这些函数。

d8 --trace-opt primes.js

取消优化

最终,编译器的性能优化是有针对性的——有时它的变现并不好,我们就不得不回退。“取消优化”的过程实际上就是把优化过的代码扔掉,恢复执行完全编译器的代码。重优化可能稍后再打开,但是短期内性能会下降。尤其是取消优化的发生会导致其函数的变量的隐藏类的变化。

结论

  • 回避在优化过后函数内隐藏类改变

你可以像其它优化一样,通过 V8 的一个日志标识来取消优化。

d8 --trace-deopt primes.js

其它V8工具

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

"/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 ——但是也要确保专注于优化你自身的算法!

参考资料