简介
近年来,Web 应用的速度大幅提升。现在,许多应用的运行速度都足够快,以至于我听到一些开发者大声质疑:“Web 的速度还不够快吗?”对于某些应用来说,这可能已经足够快了,但对于开发高性能应用的开发者来说,我们知道这还不够快。尽管 JavaScript 虚拟机技术取得了惊人的进步,但最近的一项研究表明,Google 应用有 50% 到 70% 的时间是在 V8 中度过的。应用的时间有限,减少一个系统的周期意味着另一个系统可以执行更多操作。请记住,以 60fps 运行的应用每帧只有 16 毫秒的时间,否则就会出现卡顿。继续阅读,了解如何优化 JavaScript 和分析 JavaScript 应用,这是一个来自 V8 团队性能侦探的实战故事,他们追踪了《奥兹国历险记》中一个不明显的性能问题。
2013 年 Google I/O 大会
我在 2013 年 Google I/O 大会上展示了这些材料。请观看以下视频:
为什么性能很重要?
CPU 周期是一种零和博弈。如果系统中的某个部分使用的资源较少,您就可以在其他部分使用更多资源,或者让系统整体运行得更顺畅。运行更快和执行更多操作通常是相互冲突的目标。用户既需要新功能,又希望应用运行更流畅。JavaScript 虚拟机越来越快,但这并不是忽略您现在可以解决的性能问题的理由,因为许多开发者在处理其 Web 应用中的性能问题时已经知道这一点。在实时、高帧速率应用中,避免卡顿是重中之重。Insomniac Games 开展的一项研究表明,稳定、持续的帧速率对游戏的成功至关重要:“稳定的帧速率仍然是专业、精心制作的产品的标志。”Web 开发者请注意。
解决性能问题
解决性能问题就像破案一样。您需要仔细检查证据,核实疑似原因,并尝试不同的解决方案。在此过程中,您必须记录测量结果,以便确保您确实解决了问题。这种方法与刑事侦探破案的方式几乎没有区别。侦探们仔细检查证据、审问嫌疑人并进行实验,希望能找到确凿的证据。
V8 CSI:Oz
打造《奥兹国历险记》的优秀团队遇到了一个无法自行解决的性能问题,于是向 V8 团队寻求帮助。Oz 偶尔会卡住,导致卡顿。Oz 开发者使用 Chrome 开发者工具中的时间轴面板进行了一些初步调查。查看内存用量时,他们看到了可怕的锯齿图。垃圾回收器每秒回收 10MB 的垃圾,垃圾回收暂停与卡顿相对应。类似于以下来自 Chrome 开发者工具中时间轴的屏幕截图:
V8 侦探 Jakob 和 Yang 接手了此案。接下来,V8 团队的 Jakob 和 Yang 与 Oz 团队进行了长时间的反复沟通。我已将此对话提炼为有助于追踪此问题的重要事件。
证据
第一步是收集并研究初步证据。
我们正在查看哪种类型的应用?
Oz 演示是一个交互式 3D 应用。因此,它对垃圾回收引起的暂停非常敏感。请记住,以 60fps 运行的互动式应用有 16 毫秒的时间来完成所有 JavaScript 工作,并且必须留出一些时间让 Chrome 处理图形调用和绘制屏幕。
Oz 会对双精度值执行大量算术计算,并频繁调用 WebAudio 和 WebGL。
我们遇到了哪种性能问题?
我们看到了暂停,也就是丢帧,也就是卡顿。这些暂停与垃圾回收运行相关。
开发者是否遵循了最佳实践?
是的,Oz 开发者精通 JavaScript 虚拟机性能和优化技术。值得注意的是,Oz 开发者使用 CoffeeScript 作为源语言,并通过 CoffeeScript 编译器生成 JavaScript 代码。这导致部分调查工作变得更加棘手,因为 Oz 开发者编写的代码与 V8 使用的代码之间存在脱节。Chrome 开发者工具现在支持源代码映射,这会让此过程变得更简单。
垃圾回收器为何运行?
JavaScript 中的内存由虚拟机自动为开发者管理。V8 使用一个通用的垃圾回收系统,其中内存分为两个(或更多)代。新生代包含最近分配的对象。如果一个对象存活的时间足够长,它就会被移到老年代。
年轻代的收集频率远高于老年代。这是有意为之,因为年轻代回收的开销要低得多。通常可以安全地假设频繁的 GC 暂停是由新生代回收引起的。
在 V8 中,年轻内存空间分为两个大小相等的连续内存块。在任何给定时间,这两个内存块中只有一个处于使用状态,称为“目标空间”。当 to 空间中还有剩余内存时,分配新对象的开销很低。目标空间中的游标会向前移动新对象所需的字节数。此过程会一直持续到 to 空间耗尽为止。此时,程序会停止,并开始收集。
此时,源空间和目标空间会交换。系统会从头到尾扫描原来的 to 空间(现在是 from 空间),并将所有仍然存活的对象复制到 to 空间或提升到老年代堆。如果您想了解详情,建议您阅读 Cheney 算法。
您应该直观地理解,每次隐式或显式(通过调用 new、[] 或 {})分配对象时,您的应用都会越来越接近垃圾回收和可怕的应用暂停。
此应用是否会产生 10MB/秒的垃圾数据?
简而言之,不会。开发者不会做任何事情来产生 10MB/秒的垃圾。
嫌疑人
调查的下一阶段是确定潜在嫌疑人,然后缩小范围。
嫌疑人 1
在帧期间调用 new。请记住,每分配一个对象,都会使您更接近 GC 暂停。尤其是以高帧速率运行的应用,应力求实现每帧零分配。通常,这需要一个经过精心设计、针对特定应用的对象回收系统。V8 侦探向 Oz 团队核实,发现他们并没有调用 new。事实上,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
(根据您的平台,将 linux 替换为 mac 或 windows。) (此工具必须从 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 轴表示时间(毫秒)。左侧包含每行的标签:
在 V8 执行 JavaScript 代码的每个性能分析周期,V8.Execute 行上都会绘制一条黑色竖线。V8.GCScavenger 在 V8 执行新生代回收的每个性能分析 tick 处都绘制了一条蓝色竖线。对于其余 V8 状态,情况类似。
其中最重要的一行是“正在执行的代码类型”。每当执行经过优化的代码时,它都会显示为绿色;每当执行未经优化的代码时,它都会显示为红色和蓝色混合。以下屏幕截图显示了从优化代码到未优化代码,然后又回到优化代码的过渡:
理想情况下,此线会变为绿色实线,但绝不会立即变为绿色实线。这意味着您的计划已进入优化的稳定状态。未优化的代码运行速度始终比优化后的代码慢。
如果您已经做到这一步,那么值得注意的是,您可以通过重构应用来提高工作效率,使其能够在 V8 调试 shell (d8) 中运行。使用 d8,您可以通过 tick-processor 和 plot-timer-event 工具缩短迭代时间。使用 d8 的另一个副作用是,它能更轻松地隔离实际问题,从而减少数据中存在的干扰。
查看 Oz 源代码中的计时器事件图,可以看到从优化代码到未优化代码的转换,并且在执行未优化代码时,触发了许多新生代回收,类似于以下屏幕截图(请注意,中间的时间已被移除):
如果仔细观察,您会发现,在与新生代回收(蓝线)完全相同的性能分析时间点,表示 V8 正在执行 JavaScript 代码的黑线消失了。这清楚地表明,在进行垃圾回收时,脚本会暂停。
查看 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 非常了解,他们立即意识到 for-i-in 循环结构有时不会被 V8 优化。换句话说,如果函数包含 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 暂停的频率。您应该能够通过使用新代码执行相同的实验来轻松确认这一点。细心的读者会注意到,双精度数字仍存储为属性。如果性能分析表明值得这样做,那么将位置更改为双精度数组或类型化数据数组将进一步减少创建的对象数量。
结语
Oz 开发者并未止步于此。借助 V8 侦探分享的工具和技术,他们又找到了几个陷入去优化地狱的函数,并将计算代码重构为经过优化的叶函数,从而进一步提升了性能。
赶快行动起来,开始解决一些性能问题吧!