准备:新V8即将到来,Node.js的性能正在改变

V8的Turbofan的性能特色将如何对咱们优化的方式产生影响

  审阅:来自V8团队的Franziska HinkelmannBenedikt Meurer.css

  **更新:Node.js 8.3.0已经发布了V8 6.0和Turbofan.html

  Node.js依靠V8 JavaScript引擎来运行代码,其语言自己也是咱们熟悉和喜好的。V8 JavaScript引擎是Google为Chrome浏览器编写的JavaScript虚拟机。从一开始,V8的一个主要目标是让JavaScript运行地更快,或者至少比竞争对手更快。而对于一个高动态的松散类型的语言来讲,这并不容易。本文介绍了有关V8和JS引擎性能的演变。node

  JIT(Just In Time)编译器是V8引擎的核心部分,它容许高速执行JavaSctipt代码。它是一个动态编译器,能够在运行时对代码进行优化。在一开始的V8引擎中JIT编译器被称为FullCodegen,后来V8团队实现了Crankshaft,其中包含了不少在FullCodegen中没有实现的性能优化。ios

  修正:FullCodegen是V8引擎的第一个优化编译器,感谢Yang Guo提供。git

  做为JavaScript的局外人和用户,从90年代开始,彷佛JavaSciprt中的快慢路径(不管何种引擎)看起来都违背常理,而JavaScript代码很慢的缘由一般也难以理解。github

  近几年,Matteo Collina一直关注如何编写高性能的Node.js代码,这意味着咱们必须知道在用V8 JavaScript引擎运行代码时哪些方法要快哪些方法要慢。算法

  如今是时候挑战这些有关性能方面的假设了,由于V8团队已经编写了一个新的JIT编译器:Turbofan.npm

  从众所周知的“V8杀手”(一段会致使optimazation bail-out的代码——该术语在Turbofan中已经没有意义)开始,以及Matteo和我围绕Crankshaft性能方面的一些发现,咱们将对V8版本的进展进行一系列的观察并给出微基准测试结果。编程

  固然,在进行V8的逻辑路径优化以前,咱们应该首先关注API设计,算法和数据结构。这些微基准测试用来标识JavaScript在Node中的执行过程如何被改变。咱们可使用这些指示器来改变咱们的代码风格以及在应用优化以后提升性能的方式。数组

  咱们将在V8的5.1,5.8,5.9,6.0和6.1版本上查看微基准测试的性能。

  咱们将把每一个不一样的版本放到对应的环境中:V8 5.1引擎使用Node 6和Crankshaft JIT编译器,V8 5.8使用Node 8.0和8.2并混合使用Crankshaft和Turbofan。

  当前的6.0引擎属于Node 8.3(或者多是Node 8.4),而V8的6.1是最新版(在编写本文时),它被集成到Node中,能够查看实验中的node-v8 repo。也就是说,V8 6.1版本最终将会出如今将来的Node版本中,有多是Node.js 9。

  咱们来看看微基准测试,而另外一方面咱们也将讨论这些微基准测试对将来都意味着什么。全部的这些微基准测试都是经过benchmark.js来执行的,而且数值都是按秒绘制的,所以值越高越好。

try/catch的问题

   其中一个比较著名的去优化模式是使用try/catch块。

  在这个微基准测试中,咱们比较了如下四种状况:

  • try/catch的function(sum try catch
  • 没有try/catch的function(sum without try catch
  • try块中调用function(sum wrapped
  • 简单调用一个function,没有try/catchsum function

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/try-catch.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() function sum (base, max) { var total = 0

  for (var i = base; i < max; i++) { total += i } } suite.add('sum with try catch', function sumTryCatch () { try { var base = 0
    var max = 65535

    var total = 0

    for (var i = base; i < max; i++) { total += i } } catch (err) { console.log(err.message) } }) suite.add('sum without try catch', function noTryCatch () { var base = 0
  var max = 65535

  var total = 0

  for (var i = base; i < max; i++) { total += i } }) suite.add('sum wrapped', function wrapped () { var base = 0
  var max = 65535

  try { sum(base, max) } catch (err) { console.log(err.message) } }) suite.add('sum function', function func () { var base = 0
  var max = 65535 sum(base, max) }) suite.on('complete', require('./print')) suite.run()

  能够看到,在Node 6(V8 5.1)中围绕try/catch所产生的性能问题是真实存在的,可是对Node 8.0-8.2(V8 5.8)版本的性能影响要小得多。

   另外值得注意的是,从try块内部调用一个函数要比从try块外部调用一个函数慢得多——这一点在Node 6(V8 5.1)和Node8.0-8.2(V8 5.8)中都是同样的。

  不过,对于Node 8.3+而言,在try块内部调用函数的性能能够忽略不计。

  但也别高兴得太早。在研究一些性能研讨会的材料时,Mattero和我发现了一个性能问题,就是在某个特定的状况下会致使Turbofan的无限去优化/从新优化循环(这个被称之为“杀手”——一种破坏性能的模式)。

移除Objects中的属性

  多年来,delete限制了不少但愿能写出高性能JavaScript代码的人(至少对于咱们正试图编写一个热路径的最优代码来讲是这样的)。

  Delete的问题被归结为V8在处理JavaScript objects的动态特性和原型链(也多是动态的)时,对于属性的查找在实现级别上变得更加复杂。

  对于快速生成一个属性对象,V8引擎所采用的技术是在C++层根据对象的“形状”来建立一个类。形状本质上是一个属性的key和value(包括原型链的key和value)。它们被称之为“隐藏类”。可是,若是对象的形状存在不肯定性,V8会采用另外一种属性检索模式:哈希表查找。这是对运行时对象的一种优化。哈希表查找方式明显要慢许多。从以往来看,当咱们将一个key从object中delete时,后续的属性访问将变成哈希表查找方式。这就是为何咱们要避免delete一个属性,而是将值设置为undefined。就属性的值而言,这样操做的结果是同样的,但在查看属性是否存在时会有问题。不过,这对于对象的序列化操做来讲一般都是没问题的,由于JSON.stringify在输出时不会包含undefined值(在JSON规范中undefined不是有效值)。

  如今,让咱们来看看新的Turbofan是否解决了delete问题。

  在这个微基准测试中咱们比较了如下三种状况:

  • 将一个对象的属性设置为undefined,而后序列化对象。
  • delete一个对象中非最后添加的属性,而后序列化对象。
  • delete一个对象中最后添加的属性,而后序列化对象。

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/property-removal.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() function MyClass (x, y) { this.x = x this.y = y } function MyClassLast (x, y) { this.y = y this.x = x } // You can tell if an object is in hash table mode by calling console.log(%HasFastProperties(obj)) when the flag --allow-natives-syntax is enabled in Node.JS. // you can convert back to fast properties using // https://www.npmjs.com/package/to-fast-properties
 suite.add('setting to undefined', function undefProp () { var obj = new MyClass(2, 3) obj.x = undefined JSON.stringify(obj) }) suite.add('delete', function deleteProp () { var obj = new MyClass(2, 3) delete obj.x JSON.stringify(obj) }) suite.add('delete last property', function deleteProp () { var obj = new MyClassLast(2, 3) delete obj.x JSON.stringify(obj) }) suite.add('setting to undefined literal', function undefPropLit () { var obj = { x: 2, y: 3 } obj.x = undefined JSON.stringify(obj) }) suite.add('delete property literal', function deletePropLit () { var obj = { x: 2, y: 3 } delete obj.x JSON.stringify(obj) }) suite.add('delete last property literal', function deletePropLit () { var obj = { y: 3, x: 2 } delete obj.x JSON.stringify(obj) }) suite.on('complete', require('./print')) suite.run()

   在V8 6.0和6.1中(还没有在任何Node的发行版中使用),删除对象中最后一个添加的属性会在V8中命中快速路径,所以这个操做会比直接将属性值设置为undefined要快。这是一个好消息,由于这代表V8团队正在努力提升delete操做的性能。可是,若是删除的不是最后添加的属性,delete操做仍然会致使其他属性的查找性能降低。因此总的来讲,咱们仍是要推荐继续使用delete

  修正:以前咱们认为delete可能而且应该在将来的Node.js版本中使用。感谢Jakob Kummerow告知咱们,咱们的基准测试只触发了最后一个属性被访问的状况!

显式并数组化Arguments

   对普通JavaScript函数来讲(ES6中的箭头函数“=>”没有arguments对象),一个常见的问题是隐式arguments对象为类数组,它不是一个真正的数组。

  为了使用数组的方法和数组的大部分特性,arguments对象的索引属性被复制到了数组中。在之前,JavaScripters倾向于将代码量与运行速度等同起来,即代码量越少则执行越快。这条规则会有效地减小浏览器端的代码量,但对于服务端来讲代码的执行速度更重要。所以这样一种简单有效地将arguments对象转换成数组的方式变得很流行:Array.prototype.slice.call(arguments). 调用数组的slice方法并将arguments对象做为该方法的this上下文传入,该方法会将整个arguments对象做为一个数组来分割。

  可是当一个函数的隐式arguments对象从上下文中被暴露出来时(例如,当它从函数返回或者经过Array.prototype.slice.call(arguments)传递给另外一个函数时),一般会致使性能降低。如今是时候来挑战这个假设了。

  在下一个微基准测试中,咱们测试了四个V8版本中的两个相互关联的问题:即暴露arguments参数所产生的开销,以及将arguments参数复制到数组中的开销(随后能够从函数内部访问该数组,从而替代暴露arguments对象)。

  下面是具体的测试用例:

  • arguments对象暴露给另外一个函数——没有数组转换(leaky arguments
  • 使用Array.prototype.slice方式拷贝arguments对象的副本(Array.prototype.slice arguments
  • 使用for循环复制arguments中的每个值到数组中(for loop copy arguments
  • 使用EcmaScript 2015的展开运算符将输入的参数列表赋值给一个数组(spread operator

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/arguments.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() function leakyArguments () { return other(arguments) } function copyArgs () { var array = new Array(arguments.length) for (var i = 0; i < array.length; i++) { array[i] = arguments[i] } return other(array) } function sliceArguments () { var array = Array.prototype.slice.apply(arguments) return other(array) } function spreadOp(...args) { return other(args) } function other (toSum) { var total = 0
  for (var i = 0; i < toSum.length; i++) { total += toSum[i] } return total } suite.add('leaky arguments', () => { leakyArguments(1, 2, 3) }) suite.add('Array.prototype.slice arguments', () => { sliceArguments(1, 2, 3) }) suite.add('for loop copy arguments', () => { copyArgs(1, 2, 3) }) suite.add('spread operator', () => { spreadOp(1, 2, 3) }) suite.on('complete', require('./print')) suite.run()

  让咱们来看看对应的折线图,以着重观察性能特征的变化:

  重点是:将函数的输入处理成一个数组,若是想要提升性能的话(依据个人经验这个需求应该很常见),在Node 8.3及以上版本中咱们应当使用扩展运算符。而在Node 8.2及如下版本中,应当使用for循环将arguments中的每个值复制到新(预分配的)数组中(详情可见代码)。

  更进一步,在Node 8.3+中,将arguments暴露给其它函数不会引发任何问题,所以当咱们不须要一个完整的数组并处理类数组结构时,性能还可能有进一步的提高。

偏函数应用(柯里化)和函数绑定

  偏函数应用(或者柯里化)使得咱们能够捕获嵌套闭包内的状态。

  例如:

function add (a, b) { return a + b } const add10 = function (n) { return add(10, n) } console.log(add10(20))

  在函数add中,参数a被函数add10部分地设置成了10

  在EcmaScript 5中,偏函数应用能够经过bind方法来实现:

function add (a, b) { return a + b } const add10 = add.bind(null, 10) console.log(add10(20))

  可是咱们一般不会使用bind,由于它比使用闭包要慢。

  这个基准测试使用函数的直接调用比较了bind和闭包在目标V8版本中的区别。

  下面是咱们的四个测试用例:

  • 一个函数经过柯里化的方式调用另外一个函数(curry
  • 箭头函数“=>”经过柯里化的方式调用另外一个函数(fat arrow curry
  • 经过bind建立的函数以柯里化的方式调用另外一个函数(bind
  • 不用柯里化的方式直接调用一个函数(direct call

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/currying.js

 

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() function sum (base, max) { var total = 0

  for (var i = base; i < max; i++) { total += i } } var bind = sum.bind(null, 0) var curry = function (max) { return sum(0, max) } var fatCurry = (max) => sum(0, max) suite.add('curry', function smallSum () { var max = 65535 curry(max) }) suite.add('fat arrow curry', function bigSum () { var max = 65535 fatCurry(max) }) suite.add('bind', function smallSum () { var max = 65535 bind(max) }) suite.add('direct call', function bigSum () { var base = 0
  var max = 65535 sum(base, max) }) suite.on('complete', require('./print')) suite.run()

  这个基准测试的折线图可视化结果清楚地说明了这些方法在V8的更高版本中是如何融合的。有意思的是,使用箭头函数的偏函数应用要比正常函数快得多(至少在咱们的微基准测试中是这样的)。事实上它彻底能够媲美函数直接调用。对比来看在V8 5.1(Node 6)和5.8(Node 8.0-8.2)中bind方法是很慢的,显然在偏函数应用中箭头函数是最快的选择。不过,从V8 5.9(Node 8.3+)开始,在将来的6.1版本中,bind的速度提升了一个数量级,成了最快的方法(几乎能够忽略不计)。

  在全部的版本中,柯里化最快的方法是使用箭头函数。在后来的版本中使用箭头函数的代码将尽量地接近使用bind方法的代码,而目前它是比普通函数最快的方法。但须要说明的一点是,咱们可能须要用不一样的数据结构来测试更多类型的偏函数应用,以得到更全面的了解。

函数字符数

  函数的大小,包括签名、空格甚至注释都会影响函数是否可使用V8内联。是的,给函数添加注释可能会致使性能下降10%。Turbofan会改变这个吗?让咱们来看看。

  在这个基准测试中咱们查看了如下三种状况:

  • 调用一个小函数(sum small function
  • 调用一个内联代码的小函数,其中填充了注释(long all together
  • 调用一个用注释填充的大函数(sum long function

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/function-size.js

'use strict'

// inlining example, v8 inlines sum() but cannot inline longSum because it is too long // Use --trace_inlining to show this // Output: "Did not inline longSum called from long (target text too big)."
var benchmark = require('benchmark') var suite = new benchmark.Suite() function sum (base, max) { var total = 0

  for (var i = base; i < max; i++) { total += i } } function longSum (base, max) { // Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  // Vestibulum vel interdum odio. Curabitur euismod lacinia ipsum non congue.
  // Suspendisse vitae rutrum massa. Class aptent taciti sociosqu ad litora torquent
  // per conubia nostra, per inceptos himenaeos. Morbi mattis quam ut erat vestibulum,
  // at laoreet magna pharetra. Cras quis augue suscipit, pulvinar dolor a, mollis est.
  // Suspendisse potenti. Pellentesque egestas finibus pulvinar.
  // Vestibulum eu rhoncus ante, id viverra eros. Nunc eget tempus augue.

  var total = 0

  for (var i = base; i < max; i++) { total += i } } suite.add('sum small function', function short () { var base = 0
  var max = 65535 sum(base, max) }) suite.add('long all together', function long () { var base = 0
  var max = 65535

  // Lorem ipsum dolor sit amet, consectetur adipiscing elit.
  // Vestibulum vel interdum odio. Curabitur euismod lacinia ipsum non congue.
  // Suspendisse vitae rutrum massa. Class aptent taciti sociosqu ad litora torquent
  // per conubia nostra, per inceptos himenaeos. Morbi mattis quam ut erat vestibulum,
  // at laoreet magna pharetra. Cras quis augue suscipit, pulvinar dolor a, mollis est.
  // Suspendisse potenti. Pellentesque egestas finibus pulvinar.
  // Vestibulum eu rhoncus ante, id viverra eros. Nunc eget tempus augue.

  var total = 0

  for (var i = base; i < max; i++) { total += i } }) suite.add('sum long function', function long () { var base = 0
  var max = 65535 longSum(base, max) }) suite.on('complete', require('./print')) suite.run()

  在V8 5.1(Node 6)中sum small function和long all together是相同的。这足以说明内联代码是如何工做的。当咱们调用这个小函数时,就如同V8将它的内容写入到被调用的地方。所以当咱们编写一个函数时(即便有额外的注释填充),实际上咱们已经手动将这些内容写入到调用的函数内联中,因此这二者的性能是相同的。另外咱们在V8 5.1(Node 6)中也看到,调用一个填充了大量注释的函数会致使执行速度慢不少。

  在Node 8.0-8.2(V8 5.8)中,除了调用小函数的开销明显增大以外,其它几乎没有变化。这多是因为Crankshaft和Turbofan同时做用产生的碰撞,当一个函数在Crankshaft中时另外一个可能在Turbofan中,从而致使内联代码的分离(即在一组连续的内联函数中产生跳跃)。

  在5.9及更高版本(Node 8.3+)中,任何由不相关的字符例如空格或注释引发的大小都不会对函数性能产生影响。这是由于Turbofan使用了AST(抽象语法树Abstract Syntax Tree)来肯定函数的大小,而不是像在Crankshaft中是经过字符数来计算的。它考虑函数的有效代码,而不是检查函数的字节数。所以从V8 5.9(Node 8.3+)开始,空格,变量名的字符数,函数的签名以及注释都再也不做为函数是否内联的因素

  值得注意的是,咱们再次看到函数的总体性能在降低。

  要点是应该依然保持小函数。目前咱们仍然须要避免在函数内部添加大量的注释(甚至是空白)。另外,若是你想要绝对的快速,手动内联(去掉函数调用)是最快的方法。固然,这得在函数内联与函数大小(实际可执行代码)之间找到平衡,所以将其它函数的代码复制到本身的函数中有可能会引发性能问题。也就是说,手动内联也存在潜在的风险。在大多数状况下,最好把内联的工做留给编译器。

32位整数与double类型的整数

  众所周知,JavaScript仅有一个数字类型:Number.

   可是,V8是用C++实现的,所以对于JavaScript数字来讲,必须在底层进行类型选择。

  对整数而言(在JS中即没有小数的数字),V8假定全部的数字都适合32位,除非不是。这看起来彷佛是一个公平的选择,由于大部分状况下数字都是在-2147483648和2147483647之间。假如一个JavaScript整数超过2147483647,JIT编译器会动态地将数字的底层类型改为double(双精度浮点数)——这可能也会对其它的优化产生潜在的影响。

  这个基准测试包含了下面三个用例:

  • 处理32位之内数字的函数(sum small
  • 处理32位和double类型数字的函数(from small to big
  • 处理double类型数字的函数(all big

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/numbers.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() function sum (base, max) { var total = base for (var i = base + 1; i < max; i++) { total += i } return total } suite.add('sum small', function smallSum () { var base = 0
  var max = 65535
  // 0 + 1 + ... + 65535 = 2147450880 < 2147483647
 sum(base, max) }) suite.add('from small to big', function bigSum () { var base = 32768
  var max = 98303
  // 32768 + 32769 + ... + 98303 = 4294934528 > 2147483647
 sum(base, max) }) suite.add('all big', function bigSum () { var base = 2147483648
  var max = 2147549183
  // 2147483648 > 2147483647
 sum(base, max) }) suite.on('complete', require('./print')) suite.run()

  从图中咱们能够看到,不管是Node 6(V8 5.1)仍是Node 8(V8 5.8),甚至是未来的Node版本,该测试结果都是成立的。操做大于2147483647的整数将致使函数的运行速度为1/2~2/3。因此,若是你有一个很长的数字ID,将它们放到字符串中。

  一样值得注意的是,对32位之内的数字操做,在Node 6(V8 5.1)和Node 8.1(V8 5.8)之间速度增长,但在Node 8.3+(V8 5.9+)中速度明显变慢。可是,对于double类型数字的操做在Node 8.3+ (V8 5.9+)中变得更快。这极可能是32位的数字处理速度变慢,而不是与函数调用的速度或者循环(在测试代码中使用的)有关。

  修正:感谢Jakob KummerowYang Guo以及V8团队给出了精确的测量结果。

对象的迭代

  获取一个对象的全部值并进行相关的操做十分常见,并且有不少方法能够实现。让咱们来看看在V8(和Node)版本中哪一个是最快的。

  这个基准测试针对全部的V8版本包含了如下四个用例:

  • for-in循环中经过hasOwnProperty方法检查以获取对象的值(for in
  • 使用Object.keys以及Array的reduce方法来遍历全部的key,而后获取迭代器函数内部reduce方法提供的对象值(Object.keys functional
  • 与上面的方法相似,只不过将迭代器函数提供的reduce方法换成了箭头函数(Object.keys functional with arrow
  • 使用for循环遍历从Object.keys返回的数组,在循环中获取对象的值(Object.keys with for loop

  咱们还对V8 5.8,5.9和6.1作了另外的三个测试:

  • 使用Object.values以及Array的reduce方法来遍历全部的值(Object.values functional
  • 与上面的方法相似,只不过将迭代器函数提供的reduce方法换成了箭头函数(Object.values functional with arrow
  • 使用for循环遍历从Object.values返回的数组(Object.values with for loop

  咱们没有在V8 5.1(Node 6)中跑这些测试用例,由于不支持原生的EcmaScript 2017 Object.values方法。

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-iteration.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() suite.add('for-in', function forIn () { var obj = { x: 1, y: 1, z: 1 } var total = 0
  for (var prop in obj) { if (obj.hasOwnProperty(prop)) { total += obj[prop] } } }) suite.add('Object.keys functional', function forIn () { var obj = { x: 1, y: 1, z: 1 } var total = Object.keys(obj).reduce(function (acc, key) { return acc + obj[key] }, 0) }) suite.add('Object.keys functional with arrow', function forIn () { var obj = { x: 1, y: 1, z: 1 } var total = Object.keys(obj).reduce((acc, key) => { return acc + obj[key] }, 0) }) suite.add('Object.keys with for loop', function forIn () { var obj = { x: 1, y: 1, z: 1 } var keys = Object.keys(obj) var total = 0
  for (var i = 0; i < keys.length; i++) { total += obj[keys[i]] } }) if (process.versions.node[0] >= 8) { suite.add('Object.values functional', function forIn () { var obj = { x: 1, y: 1, z: 1 } var total = Object.values(obj).reduce(function (acc, val) { return acc + val }, 0) }) suite.add('Object.values functional with arrow', function forIn () { var obj = { x: 1, y: 1, z: 1 } var total = Object.values(obj).reduce((acc, val) => { return acc + val }, 0) }) suite.add('Object.values with for loop', function forIn () { var obj = { x: 1, y: 1, z: 1 } var vals = Object.values(obj) var total = 0
    for (var i = 0; i < vals.length; i++) { total += vals[i] } }) } suite.on('complete', require('./print')) suite.run()

  在Node 6(V8 5.1)和Node 8.0-8.2(V8 5.8)中,使用for-in循环来遍历对象的key和value是迄今为止最快的方法。每秒大约操做4千万次,比排第二位的Object.keys方法快5倍,后者每秒大约操做800万次。

  在V8 6.0(Node 8.3)中,for-in循环有时候会出现一些问题,致使其性能会降到以前版本的1/4,但仍比其它方法都快。

  在V8 6.1(将来的Node版本)中,Object.keys的速度有了一个飞跃,变得比for-in循环还要快。但在V8 5.1和5.8(Node 6,Node 8.0-8.2)中速度没有接近for-in循环。

  可见Turbofan背后的工做原理是对最直观的编码行为进行优化。即优化对开发人员来讲最熟悉的代码。

  使用Object.values直接获取值比用Object.keys遍历对象的key而后再获取值要慢。重要的是,程序循环比函数式编程要快。所以在对象迭代过程当中可能会作不少事情。

  还有,对于那些使用for-in循环来提升程序性能的人而言,若是速度受到影响而又没有任何可用的替代方法时,那将会很是痛苦。

  注解:在V8中for-in循环的性能问题已经被修复,更多细节请参见http://benediktmeurer.de/2017/09/07/restoring-for-in-peak-performance/。这个修改将会被整合进Node 9中。

对象分配

  对象的分配是无可避免的,因此这是一个重要的测试部分。

  咱们将查看如下三个测试用例:

  • 经过对象的迭代进行对象分配(literal
  • 使用EcmaScript 2015的Class进行对象分配(class
  • 经过构造函数进行对象分配(constructor

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-creation.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() var runs = 0

// the for loop is needed otherwise V8 // can optimize the allocation of the object // away
var max = 10000 class MyClass { constructor (x) { this.x = x } } function MyCtor (x) { this.x = x } suite.add('noop', function noop () {}) suite.add('literal', function literalObj () { var obj = null

  for (var i = 0; i < max; i++) { obj = { x: 1 } } return obj }) suite.add('class', function classObj () { var obj = null
  for (var i = 0; i < max; i++) { obj = new MyClass(1) } return obj }) suite.add('constructor', function constructorObj () { var obj = null
  for (var i = 0; i < max; i++) { obj = new MyCtor(1) } return obj }) suite.on('cycle', () => runs = 0) suite.on('complete', require('./print')) suite.run()

  对象分配在全部V8版本的测试中都有相同的结果,除了Node 8.2(V8 5.8)中的class,它比其它的方式都慢。这是因为V8 5.8中的混合Crankshaft/Turbofan特性所致,在包含V8 6.0的Node 8.3中将解决这个问题。

  修正:Jakob Kummerow在http://disq.us/p/1kvomfk中指出,在特定的微基准测试中Turbofan能够优化对象分配,从而致使不正确的测试结果,因此本文作了相应的调整。

对象分配的清除

  在对本文的结果进行整理时,咱们发现Turbofan会始终对某一类对象分配进行优化。起初咱们还一直觉得这个优化会针对全部的对象分配,感谢V8团队的加入,使得咱们可以更好地理解该优化所涉及的部分。

  在以前的对象分配微基准测试中,咱们分配了一个变量,将值设置为null,而后屡次从新分配该变量,以免触发咱们如今要查看的特殊优化操做。

  与上面同样,这里的微基准测试也包含如下三个测试用例:

  • 经过对象的迭代进行对象分配(literal
  • 使用EcmaScript 2015的Class进行对象分配(class
  • 经过构造函数进行对象分配(constructor

  不一样之处在于,对象的引用不会被其它对象的分配所覆盖,而是将该对象传递给另外一个操做该对象的函数。

  咱们来看看测试结果!

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/object-creation-inlining.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() var runs = 0 class MyClass { constructor (x) { this.x = x } } function MyCtor (x) { this.x = x } var res = 0

function doSomething (obj) { res = obj.x } suite.add('literal', function base () { var obj = { x: 1 } doSomething(obj) }) suite.add('class', function allNums () { var obj = new MyClass(1) doSomething(obj) }) suite.add('constructor', function allNums () { var obj = new MyCtor(1) doSomething(obj) }) suite.add('create', function allNums () { var obj = Object.create(Object.prototype) obj.x = 1 doSomething(obj) }) suite.on('cycle', () => runs = 0) suite.on('complete', require('./print')) suite.run()

  咱们注意到在这个微基准测试中V8 6.0(Node 8.3)和6.1(Node 9)的速度大大提升,每秒超过5亿次,主要由于一旦Turbofan应用优化,没有其它任何额外的代码须要执行。在这种特殊状况下,Turbofan可以优化对象分配,由于它不须要对象实际存在就可以肯定后续的逻辑能够被执行。

  微基准测试的代码仍然没有彻底说明如何触发这个优化,并且这个优化应用的条件很是复杂。

  可是咱们知道的其中一个条件是绝对不会让对象被Turbofan优化掉的:

  对象不能超出建立它的函数。意思是说,在堆栈中的每一个函数完成以后,不该该再出现对该对象的引用。对象能够传递给其它函数,可是若是咱们将该对象添加到this上下文中,或者将其分配给一个外部变量,又或者在堆栈完成以后将其添加到另外一个对象,则没法应用优化。

  这个影响很酷,可是很难预测这种优化发生的全部条件。尽管如此,当复杂的条件获得知足时,它有可能会产生加速。

  修正:感谢Jakob Kummerow和V8团队的其余成员帮助咱们发现此特定行为的根本缘由。做为这项研究的一部分,咱们发现了在V8新GC中的性能回归,Orinoco,若是你对此有兴趣能够查看https://v8project.blogspot.it/2016/04/jank-busters-part-two-orinoco.html and https://bugs.chromium.org/p/v8/issues/detail?id=6663

多态与单态代码

  当咱们老是将同一类型的参数传递给一个函数时(好比老是传递一个string),咱们就是以单态的方式使用这个函数。

  有一些函数被写成是多态的。咱们能够把多态函数想象成这样一个函数,它在同一参数位置上能够接受不一样类型的值。例如,一个函数的第一个参数能够接受一个字符串或者一个对象。不过,这里咱们所说的“类型”不是指string,number和object,而是指对象的形状(虽然JavaScript的类型实际上也算做不一样的对象形状)。

  一个对象的形状由其属性和值来定义。例如,在下面的代码片断中,obj1obj2是相同的形状,但obj3和obj4其他的形状不一样:

const obj1 = { a: 1 } const obj2 = { a: 5 } const obj3 = { a: 1, b: 2 } const obj4 = { b: 2 }

  用同一段代码来处理不一样形状的对象,在某些状况下这是很是不错的代码接口,可是每每会影响程序性能。

  让咱们来看看在咱们的微基准测试中单态与多态的测试用例。

  这里咱们测试如下两种状况:

  • 一个处理具备不一样属性对象的函数(polymorphic
  • 一个处理具备相同属性对象的函数(monomorphic

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/polymorphic.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() var runs = 0 suite.add('polymorphic', function polymorphic() { var objects = [{a:1}, {b:1, a:2}, {c:1, b:2, a:3}, {d:1, c:2, b:3, a:4}]; var sum = 0; for (var i = 0; i < 10000; i++) { var o = objects[i & 3]; sum += o.a; } return sum; }) suite.add('monomorphic', function monomorphic() { var objects = [{a:1}, {a:2}, {a:3}, {a:4}]; var sum = 0; for (var i = 0; i < 10000; i++) { var o = objects[i & 3]; sum += o.a; } return sum; }) suite.on('complete', require('./print')) suite.run()

  上图的可视化数据明确地显示出,在全部测试的V8版本中,单态函数的性能要优于多态函数。不过,从V8 5.9+开始(也就是从使用V8 6.0的Node 8.3开始),多态函数的性能有了必定的改进。

  在Node.js的代码中,多态函数十分广泛,它们以APIs的形式提供了很大的灵活性。因为对多态交互的这种改进,咱们能够看到在更复杂的Node.js应用程序中的性能有所提高。

  若是咱们正在编写的代码须要优化,函数须要被屡次调用,那么咱们应该调用具备相同“形状”参数的函数。另外一方面,若是一个函数只被调用一两次,例如instantiating function或者setup function,那么就能够选择一个多态的API。

  修正:感谢Jakob Kummerow提供了这个微基准测试的可靠版本。

Debugger关键字

  最后,让咱们来讨论一下debugger关键字。

  确保将debugger语句从你的代码中去掉。多余的debugger语句会影响程序的性能。

  咱们来看如下两个测试用例:

  • 一个包含debugger关键字的函数(with debugger
  • 一个不包含debugger关键字的函数(without debugger

  代码:https://github.com/davidmarkclements/v8-perf/blob/master/bench/debugger.js

'use strict'

var benchmark = require('benchmark') var suite = new benchmark.Suite() // node --trace_opt --trace_deopt --trace_inlining --code-comments --trace_opt_verbose debugger.js > out // look for [disabled optimization for 0x34e65f73db01 <SharedFunctionInfo withDebugger>, reason: DebuggerStatement]
 suite.add('with debugger', function withDebugger () { var base = 0
  var max = 65535

  var total = 0

  for (var i = base; i < max; i++) { debugger total += i } }) suite.add('without debugger', function withoutDebugger () { var base = 0
  var max = 65535

  var total = 0

  for (var i = base; i < max; i++) { total += i } }) suite.on('complete', require('./print')) suite.run()

  是的 ,只要debugger关键字出现,在全部测试的V8版本中,性能都会严重受到影响。

  对于没有debugger关键字的状况,性能出现了连续的降低,咱们将在结论一节讨论这个问题。

一个真实的基准测试:Logger的比较

  除了咱们的微基准测试外,咱们还能够经过使用Mattero和我在建立Pino时放在一块儿的最流行的Node.js的logger做为基准测试来查看V8版本的总体效果。

  下面的条形图记录了在Node.js 6.11(Crankshaft)中使用最流行的logger记录一万行日志所花的时间(越少越好):

  而下面是使用V8 6.1(Turbofan)的测试结果:

  尽管全部的logger基准测试的速度都有所提升(大约是2倍),可是在新的Turbofan JIT编译器中Winston logger的性能提高最明显。这彷佛论证了在咱们的微基准测试中,从各类不一样的方法所看到的速度趋同性:在Crankshaft中速度较慢的方法在Turbofan中明显变快,而在Crankshaft中速度较快的方法在Turbofan中趋近于缓慢。Winston是最慢的,可能在Crankshaft中使用的方法要慢而在Turbofan中则要快一些,而在Crankshaft的方法中Pino被优化为最快。另外咱们观察到Pino的速度有提升,可是不明显。

总结

  一些基准测试代表,V8 5.1, V8 5.8和5.9中缓慢的状况随着V8 6.0和V8 6.1中Turbofan的全面启用而变得更快,而速度较快的方法其增加速度也会减慢,这一般与缓慢状况的增加速度相匹配。

  其中很大一部分是取决于Turbofan(V8 6.0及以上)中函数调用的成本。Turbofan的作法是优化那些常见的场景并消除“V8杀手”。这为浏览器(Chrome)和服务器应用程序(Node)带来了很大的好处。这种权衡(至少在一开始)是在性能最好的状况下会下降速度。咱们的logger基准测试对比显示出,Turbofan特性的整体净效应即便在代码基数明显不一样的状况下(例如Winston与Pino)也能够全面改善性能。

  若是你已经关注JavaScript性能一段时间了,而且为了适应底层引擎的怪异而对编码行为作了调整,那么差很少是时候要去了解一些新的技术了。 若是你专一于最佳实践,但愿编写出优秀的JavaScript代码,则要感谢V8团队的不懈努力,对于性能方面的改善即将到来。

  本文由David Mark ClementsMatteo Collina撰写,并由V8团队的Franziska HinkelmannBenedikt Meurer进行了审阅。

 

  本文的全部源代码以及副本能够查看https://github.com/davidmarkclements/v8-perf

  本文的原始数据能够在这里找到:https://docs.google.com/spreadsheets/d/1mDt4jDpN_Am7uckBbnxltjROI9hSu6crf9tOa2YnSog/edit?usp=sharing

  大部分微基准测试的运行环境为Macbook Pro 2016,3.3 GHz Intel Core i7,16 GB 2133 MHz LPDDR3,其它如numbers,对象属性移除,多态性,对象建立等部分的微基准测试的运行环境为MacBook Pro 2014,在不一样Node.js版本之间的测试是在同一台机器上进行的。 咱们很谨慎以确保没有其它程序的干扰。

 

原文地址:GET READY: A NEW V8 IS COMING, NODE.JS PERFORMANCE IS CHANGING.

相关文章
相关标签/搜索