使用调查和取证来解决 JavaScript 性能之谜
为什么性能很重要?
CPU 周期是一场零和游戏。 让系统的一部分使用更少可以让您在另一部分使用更多或整体运行更顺畅。 跑得更快和做得更多通常是相互竞争的目标。 用户需要新功能,同时也希望您的应用程序运行更流畅。 JavaScript 虚拟机的速度越来越快,但这并不是忽视性能问题的理由,您现在可以解决这些问题,正如许多开发人员在处理其 Web 应用程序中的性能问题时所知道的那样。 在实时、高帧率应用中,无卡顿的压力是最重要的。 Insomniac Games 进行的一项 研究 表明,稳定、持续的帧速率对于游戏的成功非常重要:稳定的帧速率仍然是专业、制作精良的产品的标志。 Web 开发人员注意了。
解决性能问题
解决性能问题就像解决犯罪问题。 您需要仔细检查证据,检查可疑原因,并尝试不同的解决方案。 在整个过程中,您必须记录您的测量结果,以确保您确实解决了问题。 这种方法与刑事侦探破案的方法几乎没有什么区别。 侦探检查证据、审讯嫌疑人并进行实验,希望能找到确凿的证据。
V8 CSI:Oz
的神奇奇才 构建 Find Your Way to Oz 向 V8 团队提出了一个他们自己无法解决的性能问题。 偶尔 Oz 会冻结,导致卡顿。 Oz 开发人员使用 中的时间线面板 进行 Chrome DevTools 了一些初步调查。 在查看内存使用情况时,他们遇到了可怕的 锯齿 图。 垃圾收集器每秒收集一次 10MB 的垃圾,垃圾收集暂停与卡顿相对应。 类似于 Chrome Devtools 中时间轴的以下屏幕截图:
V8 侦探 Jakob 和 Yang 接手了此案。 发生的事情是来自 V8 团队和 Oz 团队的 Jakob 和 Yang 之间的长时间来回。 我已将此对话提炼为有助于追查此问题的重要事件。
证据
第一步是收集和研究初始证据。
我们在看什么类型的应用程序?
Oz 演示是一个交互式 3D 应用程序。 正因为如此,它对垃圾回收引起的暂停非常敏感。 运行的交互式应用程序 请记住,以60fps 有 16 毫秒的时间来完成所有 JavaScript 工作,并且必须留出一些时间给 Chrome 来处理图形调用和绘制屏幕。
Oz 对 double 值进行了大量的算术计算,并频繁调用 WebAudio 和 WebGL。
我们看到了什么样的性能问题?
我们看到暂停又名丢帧又名卡顿。 这些暂停与垃圾收集运行相关。
开发人员是否遵循最佳实践?
是的,Oz 开发人员精通 JavaScript VM 性能和优化技术。 值得注意的是,Oz 开发人员使用 CoffeeScript 作为他们的源语言,并通过 CoffeeScript 编译器生成 JavaScript 代码。 这使得一些调查变得更加棘手,因为 Oz 开发人员编写的代码与 V8 使用的代码之间存在脱节。 Chrome DevTools 现在支持 源映射 ,这会让这更容易。
为什么垃圾收集器运行?
JavaScript 中的内存由 VM 自动为开发人员管理。 V8 使用通用的垃圾收集系统,其中内存分为两代(或更多 代 ) 。 年轻一代持有最近分配的对象。 如果一个对象存活的时间足够长,它就会被移到老年代。
年轻一代的收集频率比老一代的收集频率高得多。 这是设计使然,因为年轻一代的收集要便宜得多。 通常可以安全地假设频繁的 GC 暂停是由年轻代收集引起的。
在 V8 中,young 内存空间被分成两个大小相等的连续内存块。 在任何给定时间,这两个内存块中只有一个在使用,称为 to 空间。 虽然 to 空间中还有剩余内存,但分配一个新对象的成本很低。 to 空间中的游标向前移动新对象所需的字节数。 这一直持续到 to 空间用完为止。 此时程序停止并开始收集。
此时交换了 from 空间和 to 空间。 什么是 to 空间,现在是 from 空间,从头到尾扫描,任何仍然存在的对象都被复制到 to 空间或提升到老年代堆。 如果您想了解详细信息,我建议您阅读 切尼算法 。
凭直觉,您应该明白,每次隐式或显式分配对象(通过调用 new、[] 或 {})时,您的应用程序都越来越接近垃圾回收和可怕的应用程序暂停。
此应用程序预计会产生 10MB/秒 的垃圾吗?
简而言之,不。 开发人员没有做任何期望 10MB/秒 垃圾的事情。
嫌疑人
调查的下一阶段是确定潜在的嫌疑人,然后将其减少。
嫌疑人 #1
在帧中调用新的。 请记住,分配的每个对象都会使您更接近 GC 暂停。 以高帧率运行的应用程序尤其应该争取每帧零分配。 通常这需要一个经过深思熟虑的、特定于应用程序的对象回收系统。 V8 侦探与 Oz 团队进行了核实,他们并没有打电话给新人。 事实上,Oz 团队已经很清楚这一要求,并表示“那会很尴尬”。 从列表中划掉这个。
嫌疑犯 #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(检查 about:version),然后 构建它 。
成功构建 v8 后,您可以使用 tick 处理器处理日志:
$ tools/linux-tick-processor /path/to/v8.log
(根据您的平台,用 mac 或 windows 代替 linux。)
(此工具必须从 v8 中的顶级源目录运行。)
tick 处理器显示一个基于文本的 JavaScript 函数表,其中包含最多的 ticks:
[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.Execute 行在 V8 执行 JavaScript 代码的每个配置文件标记处绘制了黑色垂直线。 V8.GCScavenger 在 V8 执行新一代收集的每个配置文件刻度上绘制了一条蓝色垂直线。 对于其余的 V8 状态也是如此。
最重要的行之一是“正在执行的代码类型”。 每当优化代码正在执行时它将是绿色的,而当未优化代码正在执行时它将是红色和蓝色的混合。 以下屏幕截图显示了从优化代码到未优化代码再回到优化代码的过渡:
理想情况下,但不会立即,这条线将是纯绿色。 这意味着您的程序已经过渡到优化的稳定状态。 未优化的代码总是比优化的代码运行得慢。
如果你已经到了这个长度,值得注意的是,你可以通过重构你的应用程序来更快地工作,这样它就可以在 v8 调试 shell 中运行:d8。 通过 tick-processor 和 plot-timer-event 工具,使用 d8 可以加快迭代时间。 使用 d8 的另一个副作用是更容易隔离实际问题,减少数据中存在的噪声量。
查看 Oz 源代码中的计时器事件图,显示了从优化代码到未优化代码的过渡,并且在执行未优化代码时触发了许多新一代集合,类似于以下屏幕截图(注意时间已在中间删除):
If you look closely you can see that the black lines indicating when V8 is executing JavaScript code are missing at precisely the same profile tick times as the new generation collections (blue lines). This demonstrates clearly that while garbage is being collected, the script is paused.
查看 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 侦探与他们分享的工具和技术,他们能够找到其他一些陷入去优化地狱的函数,并将计算代码分解为经过优化的叶函数,从而获得更好的性能。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论