微信号:frontshow

介绍:InfoQ大前端技术社群:囊括前端、移动、Node全栈一线技术,紧跟业界发展步伐。

新的V8是如何重构提速的?

2018-08-30 18:01 小大非 译
作者|Thorsten Lorenz
译者|小大非

V8 进行了一次重大架构改造,包括对整个 V8 编译器体系结构以及大部分垃圾收集器的重构,用 TurboFan 取代了 Crankshaft,Orinoco 采用并行机制进行垃圾回收等,本文对这次升级改造进行了简单的介绍。

Node.js 社区中的很多人为最近 V8 的更新感到兴奋,这次更新包括整个 V8 编译器体系结构以及大部分垃圾收集器。TurboFan 取代了 Crankshaft,Orinoco 采用并行机制进行垃圾回收,当然还有一些其它的改进。

第 8 版 Node.js 附带了这个新改进的 V8 引擎,这意味着我们可以放心编写惯用的声明式 JavaScript,而不必担心由于编译器的缺陷引入的性能开销,V8 的开发团队也对这点进行了说明。

因为工作中使用 NodeSource 的缘故,我研究了这些最新的更改,包括查阅 V8 团队发布的博客文章、阅读 V8 源代码以及构建工具来验证特定的性能指标。

我将整理的资料放到了 github 上的 v8-perf 仓库下,以方便大家查看。这些资料也是我本周在 NodeSummit 演讲以及我的系列博客文章的素材基础。

因为这次升级更改较多且比较复杂,我打算在这篇文章中提供一个简单介绍,在本系列的后续博客文章中再对这个主题进行更详细地探讨。

假如你想要立刻了解更多的资料,请直接访问 v8-perf(https://github.com/thlorenz/v8-perf)。

新的 V8 编译器管道

众所周知,以前的 V8 版本遭遇了所谓的优化杀手,似乎已经无法在引擎中修复。V8 团队也很难实现具有良好性能特征的新 JavaScript 语言特性。

其主要原因是 V8 的架构已经变得非常难以更改和扩展。优化编译器 Crankshaft 并没有考虑使用一种有发展前景的语言来实现,编译器管道中层与层之间缺乏隔离也是一个问题。在某些极端情况下,开发人员必须为这四个基础体系结构手工编写汇编代码。

V8 团队意识到了这并不是一个可持续发展的系统,特别是随着 JavaScript 本身的发展速度加快,它也需要添加许多新的语言特性。因此,他们便重新设计了一种新的编译器体系结构。它被划分为三个清晰分离的层:前端层、优化层和后端。

前端主要负责生成由 Ignition 解释器运行的字节码,而优化层则通过 TurboFan 优化编译器改进代码的性能。后端执行较低级的任务,如机器级优化、调度、为受支持的体系结构生成机器代码等。

仅后端分离就比原体系结构代码减少了 29%,尽管新架构可以支持 9 个体系结构。

较小的性能抖动

这个新的 V8 架构的主要目标包括:

  • 较小的性能抖动

  • 提高启动速度

  • 改进基线性能

  • 减少内存使用

  • 支持新的语言特性

前三个目标与 Ignition 译码器的实现有关,第四个目标也通过该部分的改进实现了一部分。

首先,我将聚焦在架构这一部分,并结合上面的这些目标对其进行解释。

在过去,V8 团队专注于优化代码的性能,而忽略了解释字节码的性能;这导致了剧烈的性能抖动,使得应用程序的运行时特性总体上非常不可预测。某个应用程序在运行正常的情况下,假如代码中某个地方触发了 Crankshaft,引起了它的优化进程,那么这就会导致巨大的性能下降——在某些情况下,部分执行速度会慢 100 倍。为了避免这种状况,开发人员采用了编写 Crankshaft 脚本进行编译器优化的方式。

然而,结果显示,对于大多数 web 页面来说,优化编译器并没有优化解释器重要,因为代码需要快速地运行,没有时间来等待代码加载,而且由于基于预测性的优化并不容易,优化编译器在某些情况下甚至会影响性能。

解决方案是改进解释器字节码的基线性能。这是在生成字节码时通过行内优化阶段来实现的,从而产生高度优化的解释器代码,这些代码可以执行指令并以低开销的方式与 V8 VM 的其余部分进行交互。

由于字节码很小,内存使用也减少了,而且运行速度也非常快,因此进一步的优化可以暂缓一下了。另外,在尝试优化之前,可以通过内联缓存收集更多的信息,从而减少由于对代码执行方式的假设而导致的去优化和重新优化的成本。

采用运行字节码的方式,而不是用 TurboFan 优化代码的方式,便不会产生以前的有害影响了,因为它的性能更接近最优的代码;这意味着任何性能抖动都要小得多。

确保代码运行在最佳性能下

在使用新的 V8 时,大多数情况下你只要考虑编写声明式 JavaScript 和使用优良的数据结构和算法就可以了。但是,在应用程序的热代码运行时,你可能希望确保它能在最佳性能下运行。

TurboFan 优化编译器使用高级技术使热代码尽可能快地运行。这些技术包括连接海量节点的方法,创新的调度方式,更多的技术点我会在后续的博文中解释。

TurboFan 依赖于通过内联缓存收集的输入类型信息,相关功能通过 Ignition 解释器运行。使用这些信息,可以生成足以处理各种情况的最优的代码。

编译器需要考虑的函数输入类型变化越少,生成的代码就越小、越快。因此,你可以通过保持函数的单态或最小化的多态来帮助 TurboFan 提升代码运行速度。

  • 单态性:一个输入类型

  • 多态性:两到四种输入类型

  • 变态性:五种或更多的输入类型

用 Deoptigate 检查性能特性

与其盲目地追求最佳性能,我建议你首先了解下优化编译器是如何处理代码的,并检查会导致性能下降的代码情况。

为了更方便地实现这一点,我创建了 deoptigate 项目,这个项目的目的是为你的函数提供优化、反优化和处理函数的单态 / 多态 / 变态特性。

首先来看一个简单的示例脚本,我会使用 deoptigate 对其进行配置。

我定义了两个向量函数:add 和 subtract。

function add(v1, v2) {
  return {
    x: v1.x + v2.x
  , y: v1.y + v2.y
  , z: v1.z + v2.z
  }
}

function subtract(v1, v2) {
  return {
    x: v1.x - v2.x
  , y: v1.y - v2.y
  , z: v1.z - v2.z
  }
}

接下来,我在循环体中使用相同类型 (相同的属性以相同的顺序分配) 的对象执行这些函数。

const ITER = 1E3
let xsum = 0
for (let i = 0; i < ITER; i++) {
  for (let j = 0; j < ITER; j++) {
    xsum += add({ x: i, y: i, z: i }, { x: 1, y: 1, z: 1 }).x
    xsum += subtract({ x: i, y: i, z: i }, { x: 1, y: 1, z: 1 }).x
  }
}

这样 add 和 subtract 两个函数应该会运行得比较消耗性能了,同时也会得到对应的优化。

现在再次执行它们,将对象传递给 add 函数,此时已经不存在和之前相同类型的对象了,因为它们的属性是按照不同的顺序分配的 ({y: I, x: I, z: I})。

给 subtract 函数传递和之前一样的对象值。

for (let i = 0; i < ITER; i++) {
  for (let j = 0; j < ITER; j++) {
    xsum += add({ y: i, x: i, z: i }, { x: 1, y: 1, z: 1 }).x
    xsum += subtract({ x: i, y: i, z: i }, { x: 1, y: 1, z: 1 }).x
  }
}

运行此代码并使用 deoptigate 检查它。

node --trace-ic ./vector.js
deoptigate

在使用 -trace-ic 标志执行我们的脚本时,V8 会将我们需要的信息写入 isolate-v8.log 日志文件。当 deoptigate 在该文件夹下运行时,它将处理该文件并使用可视化的方式显示所包含的数据。

它是一个 web 应用程序,所以你可以在浏览器中打开它以进行后续操作。

deoptigate 为我们提供了所有文件的摘要,在我们的示例中就是 vector.js。对于每个文件,它显示相关的优化、反优化和内联缓存信息。这里绿色表示没有问题,蓝色是次要问题,红色是潜在的重要问题,应该调查。只需单击文件的名称,就可以展开文件的细节。

左侧提供了文件的源代码,其中的注释指出了潜在的性能问题。在右边,我们可以了解每个问题的更多细节。两个视图的功能是串联的;单击左边的注释会突出显示右边注释的更多细节,反之亦然。

快速浏览一下,我们可以看到,subtract 显示不存在潜在的问题,但是 add 是存在的。单击代码中的红色三角形将突出显示右边的相关反优化信息。请注意,对于使用 Map 错误的原因。

点击任何一个蓝色的电话图标就会显示更多的信息。我们发现函数变成了多态性。正如我们所看到的,这也是由于 Map 不匹配造成的。

在页面顶部检查轻度告警信息可以获得更多有关优化的建议,这次我们还介绍了包括用于 add 函数的时间戳在内的优化。

我们看到 add 在 32ms 后得到了优化。在大约 40ms 时,它被提供了一个输入类型,优化后的代码没有考虑到这个类型——因此出现了错误的映射——并且在这个时候被降级为 Ignition 字节码,同时收集更多的内联缓存信息,并在 41 毫秒后很快又进行了优化。

总之,add 函数最终通过优化的代码执行,但是该代码需要处理两种类型的输入 (不同的映射),它更庞大了但不像以前那样最优。

相反,subtract 函数只优化了一次,我们可以通过点击函数签名中的绿色三角形进行验证。

为什么是不同的 Maps?

有些人可能想知道为什么 V8 认为通过{x, y, z}赋值创建的对象与通过{y, x, z}赋值创建的对象不同,既然它们具有完全相同的属性,只是以不同的顺序赋值而已。

这要归因于在初始化 JavaScript 对象时创建映射的方式,这也会是我另一篇文章将要介绍的主题 (我将在 NodeSummit 会议上更详细地解释这一点)。希望后续继续关注我的系列博客。

  原文链接

https://nodesource.com/blog/why-the-new-v8-is-so-damn-fast

  活动推荐

ArchSummit 全球架构师峰会将于 12 月 7-8 日在北京国际会议中心举办,会议专题聚集了微服务金融架构、微服务架构、数据基础平台建设、短视频架构、区块链、信息隐私安全等话题。邀请了阿里巴巴、Netflix、百度、LinkedIn 等公司的技术专家来分享。

大会 7 折报名中,立减 2040 元,有任何问题欢迎咨询票务经理 Lachel- 灰灰,电话 / 微信:17326843116。


 
前端之巅 更多文章 专访尤雨溪:先别管4.0了,Vue CLI重构了解一下 Chrome十周年版更新了,你第一次用它是什么时候? 前端要凉?微软开源Sketch2Code,草图秒变代码 JS可以写操作系统?Windows 95被装进Electron App 从TensorFlow.js入手了解机器学习
猜您喜欢 Android-热修复总结 《架构师成长之路》连载之NO.0 —— 获赠技术书和新年周边礼包 推荐几个权威且免费的人工智能学习资源 《白鹿原》简读小记 无人机之父谈无人机思想,电影《长城》暗合无人机策略