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

John McCutchan
John McCutchan

简介

近年来,Web 应用的速度大幅加快。现在,许多应用的运行速度都足够快,我听到一些开发者在疑惑“Web 的速度够快吗?”对于某些应用来说,这可能足够了,但对于开发高性能应用的开发者来说,我们知道这还不够快。尽管 JavaScript 虚拟机技术取得了惊人的进步,但一项近期研究表明,Google 应用在 V8 中花费的时间介于 50% 到 70% 之间。应用的时间是有限的,从一个系统中缩减周期意味着另一个系统可以执行更多操作。请注意,以 60fps 运行的应用每帧只有 16 毫秒的时间,否则就会出现卡顿。请继续阅读,了解如何优化 JavaScript 和分析 JavaScript 应用,并在 Find Your Way to Oz 中了解 V8 团队的性能侦探员是如何追踪一个不起眼的性能问题的。

2013 年 Google I/O 大会演讲

我在 2013 年 Google I/O 大会上就此内容进行了演讲。请观看以下视频:

为什么说效果很重要?

CPU 周期是一种零和博弈。减少系统某个部分的使用量,可以让您在另一个部分使用更多资源,或者让整个系统运行得更顺畅。运行速度更快和功能更强大通常是相互冲突的目标。用户需要新功能,同时也希望应用运行更流畅。JavaScript 虚拟机的速度在不断提高,但这并不是忽略您现在就可以解决的性能问题的理由,正如许多处理 Web 应用性能问题的开发者已经知道的那样。对于实时高帧速率应用,避免卡顿是最重要的。Insomniac Games 开展了一项研究,结果表明稳定且持久的帧速率对游戏的成功至关重要:“稳定的帧速率仍然是专业、精良产品的标志。”Web 开发者请注意。

解决性能问题

解决性能问题就像侦破犯罪一样。您需要仔细检查证据、检查可能的原因,并尝试不同的解决方案。在整个过程中,您必须记录测量结果,以确保您确实解决了问题。此方法与刑侦人员破案的方法没有太大区别。侦探会检查证据、审问嫌疑人并开展实验,希望找到确凿的证据。

V8 CSI: Oz

负责构建 Find Your Way to Oz 的神奇巫师遇到了无法自行解决的性能问题,于是就向 V8 团队求助。Oz 有时会冻结,导致卡顿。Oz 开发者使用 Chrome 开发者工具中的时间轴面板进行了一些初步调查。查看内存用量时,他们遇到了可怕的锯齿图表。垃圾回收器每秒收集 10 MB 的垃圾,并且垃圾回收暂停与卡顿相对应。类似于 Chrome DevTools 中时间轴的以下屏幕截图:

开发者工具时间表

V8 团队的 Jakob 和 Yang 接手了这个支持请求。经过 V8 团队的 Jakob 和 Oz 团队的 Yang 反复沟通,问题最终得到了解决。我已将本次对话浓缩为有助于跟踪此问题的重要事件。

证据

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

我们正在审核哪种类型的应用?

Oz 演示版是一款互动式 3D 应用。因此,它对垃圾回收导致的暂停非常敏感。请注意,以 60fps 的速度运行的交互式应用有 16 毫秒的时间来执行所有 JavaScript 工作,并且必须留出一些时间供 Chrome 处理图形调用并绘制屏幕

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

我们遇到了什么类型的性能问题?

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

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

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

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

虚拟机会自动为开发者管理 JavaScript 中的内存。V8 使用通用垃圾回收系统,其中内存被划分为两个(或更多)生成。新生代包含最近分配的对象。如果某个对象保留的时间足够长,则会移至老代。

收集新生代的时间频率远高于收集旧生代的时间频率。这是有意为之,因为新生代收集的开销要低得多。通常可以认为,频繁的 GC 暂停是由新生代收集导致的。

在 V8 中,新生内存空间被划分为两个大小相等的连续内存块。在任何给定时间,这两个内存块中只有一个处于使用状态,称为“to 空间”。只要目标空间中还有剩余内存,分配新对象的开销就很低。目标空间中的光标会向前移动新对象所需的字节数。此过程会一直持续到 to 空间用尽为止。此时,程序会停止并开始收集数据。

V8 新内存

此时,from 空间和 to 空间会互换。系统会从头到尾扫描之前为目标空间、现在为来源空间的空间,并将仍处于活跃状态的所有对象复制到目标空间或提升到旧版堆。如需了解详情,建议您阅读 Cheney 算法

直观地讲,您应该了解,每当以隐式或显式方式(通过调用 new、[] 或 {})分配对象时,您的应用都会越来越接近垃圾回收和令人恐惧的应用暂停。

此应用是否应产生 10MB/秒 的垃圾?

简而言之,不会。开发者没有任何操作会导致 10MB/秒 的垃圾数据。

嫌疑人

调查的下一阶段是确定可能的嫌疑人,然后缩小嫌疑人范围。

嫌疑人 1

在帧期间调用 new。请注意,分配的每个对象都会使您离垃圾回收暂停时间越来越近。尤其是以高帧速率运行的应用,应力求每帧分配零次。通常,这需要精心设计特定于应用的对象回收系统。V8 侦探与 Oz 团队联系,发现他们并未调用 new。事实上,澳大利亚团队已经非常清楚这一要求,并表示“这会很尴尬”。这项问题已经解决。

嫌疑人 2

在构造函数之外修改对象的“形状”。每当在构造函数之外向对象添加新属性时,就会发生这种情况。这会为对象创建一个新的隐藏类。当优化后的代码看到这个新的隐藏类时,系统会触发 deopt,未优化的代码将会执行,直到该代码被归类为热点并再次进行优化。这种取消优化、重新优化周转会导致卡顿,但与过度创建垃圾没有严格相关。仔细审核代码后,我们确认对象形状是静态的,因此排除了嫌疑对象 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 版本相同的 v8 版本(请查看 about:version),然后构建它

成功构建 v8 后,您可以使用计时器处理程序处理日志:

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

(将 linux 替换为 mac 或 windows,具体取决于您的平台。) (此工具必须从 v8 的顶级源代码目录运行。)

计数器处理器会显示一个文本表格,其中列出了计数次数最多的 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 执行 JavaScript 代码的每个配置文件刻度点,V8.Execute 行上都会绘制一条黑色垂直线。在 V8 执行新一代收集的每个配置文件滴答时,V8.GCScavenger 上都会绘制一条蓝色垂直线。其他 V8 状态也是如此。

其中最重要的一行是“正在执行的代码类型”。每当优化后的代码正在执行时,该指示器将显示为绿色;当未优化的代码正在执行时,该指示器将显示为红色和蓝色的混合色。以下屏幕截图显示了从优化代码到未优化代码再返回优化代码的转换:

正在执行的代码类型

理想情况下,这行会变为纯绿色,但不会立即变为纯绿色。这意味着您的计划已过渡到优化的稳定状态。未优化的代码的运行速度始终比优化后的代码慢。

值得注意的是,如果您已经走到这一步,可以通过重构应用使其能够在 v8 调试 shell d8 中运行,从而更快地完成工作。使用 d8 可通过 tick-processor 和 plot-timer-event 工具缩短迭代时间。使用 d8 的另一个副作用是,更容易查明实际问题,从而减少数据中的噪声量。

查看 Oz 源代码中的计时器事件图表,可以看到从优化代码到未优化代码的转换,并且在执行未优化代码时,触发了许多新一代集合,如以下屏幕截图所示(请注意中间的时间已被移除):

计时器事件图

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

查看 Oz 源代码中的计时器处理器输出后,我们发现顶部函数 (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 循环结构。换句话说,如果函数包含 for-i-in 循环结构,则可能无法进行优化。目前,这是一个特殊情况,未来可能会发生变化,也就是说,V8 有朝一日可能会优化此循环结构。我们不是 V8 专家,对 V8 也不熟悉,那么如何确定 updateSprites 未经过优化的原因呢?

实验 2

使用此标志运行 Chrome:

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

显示优化和取消优化数据的详细日志。在数据中搜索 updateSprites 时,我们发现:

[停用了对 updateSprites 的优化,原因: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 暂停的频率也会降低。您可以使用新代码执行相同的实验,轻松确认这一点。仔细阅读的读者会注意到,双精度数字仍存储为属性。如果性能分析结果表明值得这样做,将 position 更改为双精度数数组或类型化数据数组可以进一步减少要创建的对象数量。

结语

Oz 开发者并未就此止步。借助 V8 侦探分享给他们的工具和技术,他们能够找到其他几个陷入脱优化困境的函数,并将计算代码分解为经过优化的叶函数,从而进一步提升了性能。

赶快出发,开始解决一些性能问题吧!