利用取证和侦探工作解决 JavaScript 性能问题

John McCutchan
John McCutchan

简介

近年来,Web 应用显著加快。许多应用现在的运行速度都足够快,我听到有些开发者大声问:“网络足够快吗?”。对于某些应用而言,速度可能不够快,但对于开发高性能应用的开发者来说,我们知道这还不够快。尽管 JavaScript 虚拟机技术取得了惊人进步,但最近的一项研究表明,Google 应用有 50% 到 70% 的时间都花在 V8 上。应用的时长是有限的,减少一个系统的周期意味着另一个系统可以执行更多操作。请记住,以 60fps 运行的应用每帧只有 16 毫秒,否则就会出现卡顿。请继续阅读下文,了解如何优化 JavaScript 并分析 JavaScript 应用。从 V8 团队的性能侦探在 Find Your Way to Oz(找寻前往奥兹国)中寻找一个不为人知的性能问题的故事中,听听故事情节。

2013 年 Google I/O 大会

我在 2013 年 Google I/O 大会上展示了这些资料。请观看下面的视频:

性能为何重要?

CPU 周期属于零和博弈。减少系统某个部分的使用频率,即可在另一个部分使用更多资源,或者提升整体运行速度。提高运行速度和执行更多任务往往是相互竞争的目标。用户既需要新功能,又希望应用运行更顺畅。JavaScript 虚拟机的速度越来越快,但这并不是忽略性能问题的原因。正如许多开发者处理 Web 应用性能问题的开发者已经知晓的那样,目前性能问题是可以解决的。在实时的高帧速率下,应用无卡顿的压力至关重要。失眠游戏研究表明,稳定的帧速率对游戏的成功至关重要:“稳定的帧速率仍然是专业和制作精良的标志”。Web 开发者值得注意。

解决性能问题

解决性能问题就像解决犯罪案件。您需要仔细调查证据,检查疑似原因,并尝试不同的解决方案。在整个过程中,您必须记录测量结果,以确保问题确实得到了解决。这种方法与刑事侦探破解案件的方式没有什么区别。侦探们检查证据,审问嫌疑人,并通过开展实验希望能找到这把烟枪。

V8 CSI:《Oz》

开发 Find Your Way to Oz(找寻前往奥兹国的路线)的神奇向导们找到 V8 团队遇到了一个他们无法自行解决的性能问题。有时 Oz 会卡住,造成卡顿。Oz 开发者使用 Chrome 开发者工具中的时间轴面板完成了一些初步调查。查看内存使用情况后,他们遇到了可怕的“锯齿”图。垃圾回收器每秒收集一次 10MB 的垃圾回收,而垃圾回收暂停与卡顿相对应。类似于 Chrome Devtools 中时间轴的以下屏幕截图:

Devtools 时间表

V8 侦探 Jakob 和 Yang 接手了这件案。这一过程中,V8 团队和 Oz 团队的 Jakob 和 Yang 之间来回交流。我已将此次对话提炼为帮助追踪这一问题的重要事件。

证据

第一步是收集和研究初步证据。

我们正在查看哪种类型的应用?

Oz 演示是一款交互式 3D 应用程序。因此,对由垃圾回收引起的暂停非常敏感。请注意,以 60fps 运行的交互式应用需要 16 毫秒才能完成所有 JavaScript 工作,并且必须留出一些时间,以便 Chrome 处理图形调用和绘制屏幕

Oz 对双精度值执行大量算术计算,并频繁调用 WebAudio 和 WebGL。

我们发现了哪类性能问题?

我们遇到的就是暂停,也就是丢帧,也就是卡顿。这些暂停与垃圾回收运行相关。

开发者是否遵循了最佳实践?

可以,Oz 开发者非常精通 JavaScript 虚拟机性能和优化技术。值得注意的是,Oz 开发者之前一直使用 CoffeeScript 作为源语言,并通过 CoffeeScript 编译器生成 JavaScript 代码。由于 Oz 开发者编写的代码与 V8 使用的代码之间存在脱节,因此一些调查更加棘手。Chrome 开发者工具现在支持源映射,这可让操作变得更简单。

为什么垃圾回收器要运行?

JavaScript 内存由虚拟机自动为开发者管理。V8 使用常见的垃圾回收系统,其中内存分为两代(或更多)generations。新生代会保留最近分配的对象。如果某个对象存在的时间足够长,就会将其移动到旧代。

收集年轻代的频率远高于收集老代的频率。这是设计使然,因为年轻一代的藏品要便宜得多。通常可以放心地认为频繁的 GC 暂停是由年轻代回收引起的。

在 V8 中,新生代内存空间被划分为两个大小相等的连续内存块。在任意给定时间,这两个内存块中只有一个被使用,称为“to-space”。虽然向空间中还有剩余内存,但分配新对象的成本很低。移至空间中的光标向前移动新对象所需的字节数。此过程会一直持续到目标空间用尽。此时,程序停止,收集开始。

V8 年轻记忆

此时,“from”和“to”的方向进行了交换。系统会从头至尾扫描“从”空间到现在的“从”空间,并将任何仍在活动的对象复制到空间或提升到旧一代堆。如果您想了解详情,建议您参阅 Cheney's Algorithm

直观地说,您应该明白,每次以隐式或显式方式(通过调用 new、[] 或 {})分配对象时,您的应用都会越来越接近垃圾回收,可怕的应用会暂停。

此应用预计会消耗 10MB/秒的垃圾吗?

简而言之,不是。开发者不会执行任何操作来预期会产生 10MB/秒的垃圾回收。

嫌疑人

调查的下一阶段是确定潜在的嫌疑人,然后削减他们。

嫌疑人 1

在帧期间调用新值。请记住,分配的每个对象都会离 GC 暂停更近一步。特别是以高帧速率运行的应用,应努力让每帧为零分配。通常情况下,这需要一个经过仔细考虑且针对特定应用的对象回收系统。V8 侦探与 Oz 团队核实过,他们没有打电话给新成员。事实上,Oz 团队已经很清楚这项要求,并表示“那会令人尴尬”。请从列表中将其删除。

嫌疑人 2

在构造函数之外修改对象的“形状”。每当向构造函数外部的对象添加新属性时,就会发生这种情况。此操作会为该对象创建一个新的隐藏类。当优化的代码发现这个新的隐藏类时,就会触发去优化,直到该代码被归类为热代码并再次优化时,才会执行未优化的代码。这种去优化和再优化的流失会导致卡顿,但与垃圾回收的过度创建并没有严格关联。经过仔细审校代码,确定对象形状是静态的,因此排除了疑似 2。

嫌疑人 3

未优化代码中的算术部分。在未优化的代码中,所有计算都会导致实际分配对象。例如,以下代码段:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

会导致创建 5 个 HeapNumber 对象。前三个用于变量 a、b 和 c。第 4 个表示匿名值 (a * b),第 5 个来自 #4 * c;第 5 个最终分配给了 point.x。

Oz 在每一帧中会执行数千次此类操作。如果其中任何计算发生在从未优化的函数中,则可能会导致出现垃圾回收。因为未优化中的计算会为临时结果分配内存。

嫌疑人 4

将双精度数字存储到属性中。必须创建 HeapNumber 对象来存储数字,并将属性更改为指向此新对象。将属性更改为指向 HeapNumber 不会产生垃圾。但是,有可能有许多双精度数字存储为对象属性。代码中充满了如下所示的语句:

sprite.position.x += 0.5 * (dt);

在优化代码中,每次为 x 分配新计算的值(一个看似无害的语句)时,都会隐式分配新的 HeapNumber 对象,使我们更接近垃圾回收暂停。

请注意,使用类型化数组(或仅包含双精度数值的常规数组)可以完全避免此特定问题,因为双精度数字的存储仅分配一次且重复更改该值不需要分配新的存储空间。

嫌疑人 4 有可能。

取证服务

此时,侦探有两个可能的嫌疑:将堆数字存储为对象属性,以及发生在未优化函数内的算术计算。是时候去实验室弄清楚哪位嫌疑人有罪了。注意:在本部分中,我将重现在实际 Oz 源代码中发现的问题。这种重现比原始代码小几个数量级,因此更易于推断。

实验 1

检查是否存在可疑问题 3(未优化函数内的算术计算)。V8 JavaScript 引擎具有内置的日志记录系统,可让您深入了解后台发生的情况。

从 Chrome 根本没有运行开始,使用标记启动 Chrome:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

然后完全退出 Chrome,系统会在当前目录中生成一个 v8.log 文件。

要解读 v8.log 的内容,您必须下载您的 Chrome 当前使用的 v8 版本(请查看 about:version),然后进行构建

成功构建 v8 后,您可以使用 tick 处理器处理日志:

$ tools/linux-tick-processor /path/to/v8.log

(请将 mac 或 Windows 替换为 linux,具体取决于您使用的平台。) (在 v8 中,该工具必须从顶级源目录运行。)

tick 处理器显示一个基于文本的表格,其中包含 tick 最多的 JavaScript 函数:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

您可以看到 demo.js 有三个函数:opt、unopt 和 main。优化函数的名称旁边会有一个星号 (*)。您会发现函数 opt 已优化,而 unopt 未优化。

V8 侦探工具包中的另一个重要工具是 plot-timer-event。可按如下方式执行:

$ tools/plot-timer-event /path/to/v8.log

运行后,当前目录中有一个名为 Timer-events.png 的 png 文件。打开该文件,您应该会看到如下内容:

计时器事件

在底部的图表之外,数据以行的形式显示。X 轴表示时间(毫秒)。左侧包含每行的标签:

计时器事件 Y 轴

在 V8.Execute 行上,在 V8 执行 JavaScript 代码的每个配置文件 tick 处,都有一条黑色竖线。在 V8.GCScavenger 执行新一代集合时,在每个配置文件刻度上绘制了一条蓝色竖线。对 V8 的其他状态也是如此。

最重要的行之一是“正在执行的代码种类”。优化代码会在执行时显示为绿色,执行未经优化的代码时会同时显示红色和蓝色。以下屏幕截图显示了从优化代码到未优化代码的过渡过程:

正在执行的代码种类

理想情况下,此行会变为绿色实心,但绝不能立即出现。这意味着您的程序已转换为优化的稳定状态。未优化代码的运行速度始终比优化代码慢。

如果您完成了上述操作,那么值得注意的是,您可以重构应用,使其能够在 v8 调试 shell 中运行,从而加快工作速度:d8。使用 d8 可以借助 tick-processor 和 plot-timer-event 工具缩短迭代时间。使用 d8 的另一个副作用是可以更轻松地隔离实际问题,从而减少数据中存在的噪声量。

查看 Oz 源代码中的计时器事件图表,显示从优化代码到未优化代码的转换,在执行未优化代码时,触发了许多新生成集合,类似于以下屏幕截图(请注意,中间已删除时间):

计时器事件图

如果仔细观察,您会发现,指示 V8 何时执行 JavaScript 代码的黑线在与新生成集合(蓝线)完全相同的配置文件 tick 时间上丢失。这清楚地表明,在收集垃圾回收时,脚本会暂停。

查看 Oz 源代码中的 tick 处理器输出,未优化顶层函数 (updateSprites)。换句话说,程序花费最多时间的函数也未优化。这清楚地表明,嫌疑人 3 是罪魁祸首。updateSprites 的来源包含如下所示的循环:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

他们对 V8 和 V8 都了如指掌,立即意识到 for-i-in 循环结构有时并未经过 V8 优化。换言之,如果函数包含 for-i-in 循环结构,则可能不会被优化。这是目前一种特殊情况,并且将来可能会发生变化,也就是说,V8 有一天可能会优化此循环构造。既然我们不是 V8 侦探,也不知道 V8 就像我们的手背,那么,我们如何确定 updateSprite 未优化的原因呢?

实验 2

运行带有此标志的 Chrome:

--js-flags="--trace-deopt --trace-opt-verbose"

显示优化和去优化数据的详细日志。搜索 updateSprite 数据,找到以下内容:

[停用了 updateSprite 的优化功能,原因:ForInStatement 不快使用方式]

就像侦探所假设的一样,for-i-in 循环结构就是原因所在。

已结案

在发现 updateSprites 未优化的原因后,我们做出了简单的修正,只需将计算移到自己的函数中,即:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite 将被优化,从而显著减少 HeapNumber 对象数量,从而减少 GC 暂停频率。应当通过使用新代码执行相同的实验即可轻松确认这一点。仔细的读者会发现,双数数字仍然被存储为属性。如果分析表明这是值得的,将位置更改为双精度数组或类型化数据数组会进一步减少正在创建的对象数量。

结语

Oz 的开发者并未止步于此。利用 V8 侦探分享的工具和技术,他们得以找到另外几个陷入去优化环节的函数,并将计算代码分解为经过优化的叶函数,从而提升了性能。

马上行动起来,解决一些表演犯罪!