[译] 作好准备:新的 V8 即将到来,Node.js 的性能正在改变。

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

本文由 David Mark ClementsMatteo Collina 共同撰写,负责校对的是来自 V8 团队的 Franziska HinkelmannBenedikt Meurer。起初,这个故事被发表在 nearForm 的 blog 板块。在 7 月 27 日文章发布以来就作了一些修改,文章中对这些修改有所说起。前端

更新:Node.js 8.3.0 将会和 Turbofan 一块儿发布在 V8 6.0 中 NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/rc nvm i 8.3.0-rc.0 来验证应用程序node

自诞生之日起,node.js 就依赖于 V8 JavaScript 引擎来为咱们熟悉和喜好的语言提供代码执行环境。V8 JavaScipt 引擎是 Google 为 Chrome 浏览器编写的 JavaScipt VM。起初,V8 的主要目标是使 JavaScript 更快,至少要比同类竞争产品要快。对于一种高度动态的弱类型语言来讲,这可不是容易的事情。文章将介绍 V8 和 JS 引擎的性能演变。android

容许 V8 引擎高速运行 JavaScript 的是其中一个核心部分:JIT(Just In Time) 编译器。这是一个能够在运行时优化代码的动态编译器。V8 第一次建立 JIT 编译器的时候, 它被称为 FullCodegen。以后 V8 团队实现了 Crankshaft,其中包含了许多 FullCodegen 未实现的性能优化。ios

编辑:FullCodegen 是 V8 的第一个优化编译器,感谢 Yang Guo 的报告c++

做为 JavaScript 自 90 年代以来的关注者和用户,JavaScript(无论是什么引擎)中快速或者缓慢的方法彷佛每每是违法直觉的,JavaScript 代码缓慢的缘由也经常难以理解。git

最近几年,Matteo Collina 致力于研究如何编写高性能 Node.js 代码。固然,这意味着咱们在用 V8 JavaScript 引擎执行代码的时候,知道哪些方法是高效的,哪些方法是低效的。github

如今是时候挑战全部关于性能的假设了,由于 V8 团队已经编写了一个新的 JIT 编译器:Turbofan。算法

从更常见的 "V8 Killers"(致使优化代码片断的 bail-out-- 在 Turbofan 环境下失效) 开始,Matteo 和我在 Crankshaft 性能方面所获得的模糊发现,将会经过一系列微基准测试结果和对 V8 进展版本的观察来获得答案。npm

固然,在优化 V8 逻辑路径前,咱们首先应该关注 API 设计,算法和数据结构。这些微基准测试旨在显示 JavaScript 在 Node 中执行时是如何变化的。咱们可使用这些指标来影响咱们的通常代码风格,以及改进在进行经常使用优化以后性能提高的方法。编程

咱们将在 V8 5.一、5.八、5.九、6.0 和 6.1 中查看微基准测试下它们的性能。

将上述每一个版本都放在上下文中:V8 5.1 是 Node 6 使用的引擎,使用了 Crankshaft JIT 编译器,V8 5.8 是 Node 8.0 至 8.2 的引擎,混合使用了 Crankshaft Turbofan。

目前,5.9 和 6.0 引擎将在 Node 8.3(也多是 Node 8.4)中,而 V8 6.1 是 V8 最新版本 (在编写本报告时),它在 node-v8 仓库 github.com/nodejs/node… 的实验分支中与 Node 集成。换句话说,V8 6.1 版本将在后继 Node 版本中使用。

让咱们看下微基准测试,另外一方面,咱们将讨论这对将来意味着什么。全部的微基准测试都由 benchmark.js](https://www.npmjs.com/package/benchmark) 执行,绘制的值是每秒操做数,所以在图中越高越好。

TRY/CATCH 问题

最著名的去优化模式之一是使用 try/catch 块。

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

  • 带有 try/catch 的函数 (带 try catch 的 sum)
  • 不含 try/catch 的函数 (不带 try catch 的 sum)
  • 调用 try 块中的函数 (sum 在 try 中)
  • 简单的函数调用, 不涉及 try/catch (sum 函数)

代码 github.com/davidmarkcl…

咱们能够看到,在 Node 6 (V8 5.1) 围绕 try/catch 引起性能问题是真实存在的,可是对 Node 8.0-8.2 (V8 5.8) 的性能影响要小得多。

值得注意的是,在 try 块内部调用函数比从 try 块以外调用函数慢得多 - 在 Node 6 (V8 5.1) 和 Node 8.0-8.2 (V8 5.8) 都是如此。

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

尽管如此,不要掉以轻心。在整理性能工做报告时,Matteo 和我发现了一个性能 bug,在特殊状况下 Turbofan 中可能会致使出现去优化/优化的无限循环 (被视为“killer” — 一种破坏性能的模式)。

从 Objects 中删除属性

多年来,delete 已经限制了不少但愿编写出高性能 JavaScript 的人(至少是咱们试图为热路径编写最优代码的地方)。

delete 的问题归结于 V8 在原生 JavaScript 对象的动态性质以及(可能也是动态的)原型链的处理方式上。这使得查找在实现层面上的属性查询更加复杂。

V8 引擎快速生成属性对象的技术是基于对象的“形状”在 c++ 层建立类。形状本质上是属性所具备的键、值(包括原型链键值)。这些被称为“隐藏类”。可是这是在运行时对对象进行优化,若是对象的类型不肯定,V8 有另外一种属性检索的模型:hash 表查找。hash 表的查找速度很慢。历史上, 当咱们从对象中 delete 一个键时,后续的属性访问将是一个 hash 查找。 这是咱们避免使用  delete 而将属性设置为  undefined 以防止在检查属性是否已经存在时,致使结果与值相同的问题的产生的缘由。 但对于预序列化已经足够了,由于  JSON.stringify 输出中不包含 undefined (undefined 不是 JSON 规范中的有效值) 。

如今,让咱们看看更新 Turbofan 实现是否解决了 delete 问题。

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

  • 在对象属性设置为 undefined 后,序列化对象
  • delete 对象属性后,序列化对象
  • delete 已被移出对象的最近添加的属性后,序列化对象

代码 github.com/davidmarkcl…

在 V8 6.0 和 6.1 (还没有在任何 Node 发行版本中使用)中,Turbofan 会建立一个删除最后一个添加到对象中的属性的快捷方式,所以会比设置 undefined 更快。这是好消息,由于它代表 V8 团队正努力提升 delete 的性能。然而,若是从对象中删除了一个不是最近添加的属性, delete 操做仍然会对属性访问的性能带来显著影响。所以,咱们仍然不推荐使用 delete

编辑: 在以前版本的帖子中,咱们得出结论 elete 能够也应该在将来的 Node.js 中使用。可是 Jakob Kummerow 告诉咱们,咱们的基准测试只触发了最后一次属性访问的状况。感谢 Jakob Kummerow!

显式而且数组化 ARGUMENTS

普通 JavaScript 函数 (相对于没有 arguments 对象的箭头函数 )可用隐式 arguments对象的一个常见问题是它相似数组,实际上不是数组。

为了使用数组方法或大多数数组行为,arguments 对象的索引属性已被复制到数组中。在过去 JavaScripters 更倾向于将 less codefaster code 相提并论。虽然这一经验规则对浏览器端代码产生了有效负载大小的好处,但可能会对在服务器端代码大小远不如执行速度重要的状况形成困扰。所以将arguments 对象转换为数组的一种诱人的简洁方案变得至关流行: Array.prototype.slice.call(arguments)。调用数组 slice 方法将 arguments 对象做为该方法的this 上下文传递, slice 方法从而将对象看作数组同样。也就是说,它将整个参数数组对象做为一个数组来分割。

然而当一个函数的隐式 arguments 对象从函数上下文中暴露出来(例如,当它从函数返回或者像 Array.prototype.slice.call(arguments)时,会传递到另外一个函数时)致使性能降低。 如今是时候验证这个假设了。

下一个微基准测量了四个 V8 版本中两个相互关联的主题:arguments 泄露的成本和将参数复制到数组中的成本 (随后 函数做用域代替了 arguments 对象暴露出来).

这是咱们案例的细节:

  • arguments 对象暴露给另外一个函数 - 不进行数组转换 (泄露 arguments)
  • 使用 Array.prototype.slice 特性复制 arguments 对象 (数组的 prototype.slice arguments)
  • 使用 for 循环复制每一个属性 (for 循环复制参数)
  • 使用 EcmaScript 2015 扩展运算符将输入数组分配给引用 (扩展运算符)

代码: github.com/davidmarkcl…

让咱们看一下线性图形中的相同数据以强调性能特征的变化:

要点以下:若是咱们想要将函数输入做为一个数组处理,写在高性能代码中 (在个人经验中彷佛至关广泛),在 Node 8.3 及更高版本应该使用 spread 运算符。在 Node 8.2 及更低版本应该使用 for 循环将键从 arguments 复制到另外一个新的(预分配) 数组中 (详情请参阅基准代码)。

在 Node 8.3+ 以后的版本中,咱们不会由于将 arguments对象暴露给其余函数而受到惩罚, 所以咱们不须要完整数组并能够以使用相似数组结构的状况下,可能会有更大的性能优点。

部分应用 (CURRYING) 和绑定

部分应用(或 currying)指的是咱们能够在嵌套闭包做用域中捕获状态的方式。

例如:

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

这里 add 的参数 aadd10 函数中数值 10 部分应用。

从 EcmaScript 5 开始,bind 方法就提供了部分应用的简洁形式:

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

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

这个基准测试了目标 V8 版本中 bind 和闭包之间的差别,并以之直接函数调用做为控件。

这是咱们使用的四个案例:

  • 函数调用另外一个第一个参数部分应用的函数 (curry)
  • 箭头函数 (箭头函数)
  • 经过 bind 部分应用另外一个函数的第一个参数建立的函数 (bind)。
  • 直接调用一个没有任何部分应用的函数 (直接调用)

代码: github.com/davidmarkcl…

基准测试结果的可视化线性图清楚地说明了这些方法在 V8 或者更高版本中是如何合并的。有趣的是,使用箭头函数的部分应用比使用普通函数要快(至少在咱们微基准状况下)。事实上它跟踪了直接调用的性能特性。在 V8 5.1 (Node 6) 和 5.8(Node 8.0–8.2)中 bind 的速度显然很慢,使用箭头函数进行部分应用是最快的选择。然而 bind 速度比 V8 5.9 (Node 8.3+) 提升了一个数量级,成为 6.1 (Node 后继版本) 中最快的方法( 几乎能够忽略不计) 。

使用箭头函数是克服全部版本的最快方法。后续版本中使用箭头函数的代码将偏向于使用 bind ,由于它比普通函数更快。可是,做为警告,咱们可能须要研究更多具备不一样大小的数据结构的部分应用类型来获取更全面的状况。

函数字符数

函数的大小,包括签名、空格、甚至注释都会影响函数是否能够被 V8 内联。是的:为你的函数添加注释可能会致使性能降低 10%。Turbofan 会改变么?让咱们找出答案。

在这个基准测试中,咱们看三种状况:

  • 调用一个小函数 (sum small function)
  • 一个小函数的操做在内联中执行,并加上注释。(long all together)
  • 调用已填充注释的大函数 (sum long function)

Code: github.com/davidmarkcl…

在 V8 5.1 (Node 6) 中,sum small functionlong 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+)以后 空格, 变量名字符数, 函数名和注释再也不是影响函数是否内联的因素。

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

这里的优势应该仍然是保持函数较小。目前咱们必须避免函数内部过多的注释(甚至是空格)。并且若是您想要绝对最快的速度,手动内联(删除调用)始终是最快的方法。固然还要与如下事实保持平衡:函数不该该在大小(实际可执行代码)肯定后被内联,所以将其余函数代码复制到您的代码中可能会致使性能问题。换句话说,手动内联是一种潜在方法:大多数状况下,最好让编译器来内联。

32BIT 整数 VS 64BIT 整数

众所周知,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)

Code: github.com/davidmarkcl…

咱们能够从图中看出,不管是在 Node 6 (V8 5.1) 仍是 Node 8 (V8 5.8) 甚至是 Node 的后继版本,这些观察都是正确的。使用大于 2147483647 数字(整数)的操做将致使函数运行速度在一半到三分之二之间。所以,若是您有很长的数字 ID—将他们放在字符串中。

一样值得注意的是,在 32 位范围内的数字操做在 Node 6 (V8 5.1) 和 Node 8.1 以及 8.2 (V8 5.8) 有速度增加,可是在 Node 8.3+ (V8 5.9+)中速度明显下降。然而在 Node 8.3+ (V8 5.9+)中,double 运算变得更快,这极可能是(32位)数字处理速度缓慢,而不是函数或与 for 循环 (在基准代码中使用)速度有关

编辑: 感谢 Jakob Kummerow Yang Guo 已经 V8 团队对结果的准确性和精确性的更新。

迭代对象

得到对象的全部值并对它们进行处理是常见的操做,并且有不少方法能够实现。让咱们找出在 V8 (和 Node) 中最快的那个版本。

这个基准测试的四个案例针对全部 V8 版本:

  • for-in 循环中使用 hasOwnProperty 方法来检查是否已经得到对象值。 (for in)
  • 使用 Object.keys 并使用数组的 reduce 方法迭代键,访问 iterator 函数中提供给的对象值 (函数式 Object.keys)
  • 使用 Object.keys 并使用数组的 reduce 方法迭代键,访问 iterator 函数中的对象值,提供给 reduce 的迭代函数中对象值,以减小 iterator 是箭头函数的位置 (函数式箭头函数 Object.keys)
  • 循环访问使用 for 循环从 Object.keys 返回的数组的每一个对象值 (**for 循环 Object.keys **)

咱们还为V8 5.八、5.九、 6.0 和 6.1 增长了三个额外的基准测试案例

  • 使用 Object.values 和数组 reduce方法遍历值, (函数式 Object.values)
  • 使用 Object.values 和数组 reduce 方法遍历值,其中提供给 reduce 的 iterator 函数是箭头函数 (函数式箭头函数 Object.values)
  • 使用 for 循环遍历从 Object.values 中返回的数组 (for 循环 Object.values)

在 V8 5.1 (Node 6)中,咱们不会支持这些状况,由于它不支持原生 EcmaScript 2017 Object.values 方法。

Code: github.com/davidmarkcl…

在 Node 6 (V8 5.1) 和 Node 8.0–8.2 (V8 5.8) 中,遍历对象的键而后访问值使用  for-in 是迄今为止最快的方法。4 千万 op/s 比下一个接近 Object.keys 的方法(大约 8 百万 op/s)快了近5倍。

在 V8 6.0 (Node 8.3) 中 for-in 发生了改变,它下降至以前版本速度的四分之三,但仍然比任何方法速度都快。

在 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 并访问对象值要慢。最重要的是,程序循环比函数式编程要快。所以在迭代对象时可能要作更多的工做。

此外,对那些为了提高性能而使用 for-in 却由于没有其余选择而失去大部分速度的人来讲,这是一个痛苦的时刻。

建立对象

咱们始终在建立对象,因此这是一个很好的测量领域。

咱们要看三个案例:

  • 建立对象时使用对象字面量 (literal)
  • 建立对象时使用 ECMAScript 2015 类 (class)
  • 建立对象时使用构造函数 (constructor)

Code: github.com/davidmarkcl…

在 Node 6 (V8 5.1) 中全部方法都同样。

在 Node 8.0–8.2 (V8 5.8)中,从 EcmaScript 2015 类建立实例的速度不及用对象字面量或者构造函数速度的一半。因此,你知道后要注意这一点。

在 V8 5.9 中,性能再次均衡。

而后在 V8 6.0 (多是 Node 8.3,或者是 8.4) 和 6.1 (目前还没有发布在任何 Node 版本) 中对象建立速度 简直疯狂!!超过了 500 百万 op/s!使人难以置信。

咱们能够看到由构造函数建立对象稍慢一些。所以,为了对将来友好的高性能代码,咱们最好的选择是始终使用对象字面量。这很适合咱们,由于咱们建议从函数(而不是使用类或构造函数)返回对象字面量做为通常的最佳编码实践。

编辑:Jakob Kummerow 在 http://disq.us/p/1kvomfk 中指出,Turbofan 能够在这个特定的微基准中优化对象分配。考虑这一点,咱们会尽快从新进行更新。

单态函数与多态函数

当咱们老是将相同类型的 argument 输入到函数中(例如,咱们老是传递一个字符串)时,咱们就以单态形式使用该函数。一些函数被编写成多态 --  这意味着相同的参数能够做为不一样的隐藏类处理 -- 因此它可能能够处理一个字符串、一个数组或一个具备特定隐藏类的对象,并相应地处理它。在某些状况下,这能够提供良好的接口,但会对性能产生负面影响。

让咱们看看单态和多态在基准测试的表现。

在这里,咱们研究五个案例:

  • 函数同时传递对象字面量和字符串 (多态字面量)
  • 函数同时传递构造函数实例和字符串 (多态构造函数)
  • 函数只传递字符串 (单态字符串)
  • 函数只传递字面量 (单态字面量)
  • 函数只传递构造函数实例 (带构造函数的单例对象)

代码: github.com/davidmarkcl…

图中的可视化数据代表,在全部的 V8 测试版本中单态函数性能优于多态函数。

这进一步说明了在 V8 6.1(Node 后继版本)中,单态函数和多态函数之间的性能差距会更大。不过值得注意的是,这个基于使用了一种 nightly-build 方式构建 V8 版本的 node-v8 分支的版本 -- 可能最终不会成为 V8 6.1 中的一个具体特性

若是咱们正在编写的代码须要是最优的,而且函数将被屡次调用,此时咱们应该避免使用多态。另外一方面,若是只调用一两次,好比实例化/设置函数,那么多态 API 是能够接受的。

编辑:V8 团队已经通知咱们,使用其内部可执行文件 _d8_ 没法可靠地重现此特定基准测试的结果。然而,这个基准在 Node 上是可重现的。所以,应该考虑到结果和随后的分析,可能会在以后的 Node 更新中发生变化(基于 Node 和 V8 的集成中)。不过还须要进一步分析。感谢 Jakob Kummerow 指出了这一点

DEBUGGER 关键词

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

确保从代码中删除了 debugger 语句。散乱的 debugger 语句会破坏性能。

咱们看下如下两种案例:

  • 包含 debugger 关键词的函数 (带有 debugger)
  • 不包含 debugger 关键词的函数 (不含 debugger)

Code: github.com/davidmarkcl…

是的,debugger 关键词的存在对于测试全部 V8 版本的性能来讲都很糟糕。

没有 debugger 行的那些 V8 版本中,性能显著提高。咱们将在总结中讨论这一点。

真实世界的基准: LOGGER 比较

除了微基准测试,咱们还能够经过使用 Node.js 最流行的日志(Matteo 和我建立的 Pino 时编写的)来查看 V8 版本的总体效果。

下面的条形图代表在Node.js 6.11 (Crankshaft) 中最受欢迎的 logger 记录1万行(更低些会更好) 日志所用时间:

如下是使用 V8 6.1 (Turbofan) 的相同基准:

虽然全部的 logger 基准测试速度都有所提升 (大约是 2 倍),但 Winston logger 重新的 Turbofan JIT 编译器中得到了最大的好处。这彷佛证实了咱们在微基准测试中看到的各类方法之间的速度趋于一致:Crankshaft 中较慢的方法在 Turbofan 中明显更快,而在 Crankshaft 的快速方法在 Turbofan 中每每会稍慢。Winston 是最慢的,多是使用了在 Crankshaft 中较慢而在 Turbofan 中更快的方法,然而 Pino 使用最快的 Crankshaft 方法进行优化。虽然在 Pino 中观察到速度有所增长,可是效果不是很明显。

总结

一些基准测试代表,随着 V8 6.0 和 V8 6.1中所有启用 Turbofan,在 V8 5.1, V8 5.8 和 5.9 中的缓慢状况有所加速 ,但快速状况也有所降低,这每每与缓慢状况的增速相匹配。

很大程度上是因为在 Turbofan (V8 6.0 及以上) 中进行函数调用的成本。Turbofan 的核心思想是优化常见状况并消除“V8 Killers”。这为 (Chrome) 浏览器和服务器 (Node)带来了净效益。 对于大多数状况来讲,权衡出如今(至少是最初)速度降低。基准日志比较代表,Turbofan 的整体净效应即便在代码基数明显不一样的状况下(例如:Winston 和 Pino) 也能够全面提升。

若是您关注 JavaScript 性能已经有一段时间了,也能够根据底层引擎改善编码方式,那么是时候放弃一些技术了。若是您专一于最佳实践,编写通常的 JavaScript,那么很好,感谢 V8 团队的不懈努力,高效性能时代即将到来。

本文的做者是 David Mark ClementsMatteo Collina, 由来自 V8 团队的 Franziska HinkelmannBenedikt Meurer 校对。


本文的全部源代码和文章副本均可以在 github.com/davidmarkcl… 上找到。

文章的原始数据能够在docs.google.com/spreadsheet…

大多数的微基准测试是在 Macbook Pro 2016 上进行的,16 GB 2133 MHz LPDDR3 的 3.3 GHz Intel Core i7,其余的 (数字、属性已经删除) 则运行在 MacBook Pro 2014,16 GB 1600 MHz DDR3的 3 GHz Intel Core i7 。Node.js 不一样版本之间的测试都是在同一台机器上进行的。咱们已经很是当心地确保不受其余程序的干扰。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索