通过 Perf-ception 提升性能面板速度 400%

Andrés Olivares
Andrés Olivares
Nancy Li
Nancy Li

无论您正在开发哪类应用,优化其性能并确保其快速加载和顺畅交互对于用户体验和应用成功都至关重要。实现此目的的方法之一是使用性能分析工具来检查应用的活动,以了解应用在一段时间内运行时在后台发生的情况。开发者工具中的 Performance 面板是一款出色的分析工具,可用于分析和优化 Web 应用的性能。如果您的应用在 Chrome 中运行,那么您可以通过该视图详细、直观地了解浏览器在应用执行过程中执行的操作。了解此活动有助于确定模式、瓶颈和性能热点,以便您采取措施来提升性能。

以下示例将引导您使用性能面板。

设置并重新创建分析场景

最近,我们设定了一个目标,以使效果面板的性能更加出色。具体而言,我们希望它能更快地加载大量性能数据。例如,分析长时间运行的进程或复杂进程或捕获高粒度数据时,就属于这种情况。为此,首先需要了解应用的运行情况及其原因,这可通过使用分析工具来实现。

您可能知道,DevTools 本身是一个 Web 应用。因此,可以使用 Performance 面板对其进行分析。如需对此面板本身进行性能分析,您可以打开开发者工具,然后打开与其相连的另一个开发者工具实例。在 Google,此设置称为 DevTools-on-DevTools

设置就绪后,必须重新创建和记录要分析的场景。为避免混淆,原始开发者工具窗口将称为“第一个开发者工具实例”,用于检查第一个实例的窗口将称为“第二个开发者工具实例”。

<ph type="x-smartling-placeholder">
</ph> 屏幕截图:一个开发者工具实例正在检查开发者工具本身中的元素。 <ph type="x-smartling-placeholder">
</ph> DevTools-on-DevTools:使用开发者工具检查开发者工具。

在第二个开发者工具实例上,Performance 面板(从现在起将称为“perf 面板”),它会观察第一个开发者工具实例以重新创建场景,从而加载配置文件。

<ph type="x-smartling-placeholder">

在第二个实例中启动实时记录,在第一个实例中,从磁盘上的文件加载配置文件。系统会加载一个大型文件,以便准确分析处理大型输入时的性能。当两个实例都完成加载后,性能分析数据(通常称为跟踪记录)会显示在加载配置文件的性能面板的第二个开发者工具实例中。

初始状态:发现改进机会

加载完成后,在下一个屏幕截图中观察到第二个性能面板实例上的以下内容。将焦点置于主线程的 activity,它显示在标记为 Main 的轨道下。可以看到,火焰图中有五大组活动。其中包括加载时间最长的任务。这些任务的总时间约为 10 秒。在下面的屏幕截图中,我们使用效果面板来重点查看各个活动组,以便您查看可供查看的内容。

DevTools 中性能面板的屏幕截图,该面板正在检查另一个开发者工具实例的性能面板中的性能轨迹的加载情况。配置文件需要大约 10 秒才能完成加载。本次时间主要分为五组活动。

第一个活动组:不必要的工作

显然,第一组活动是仍在运行但其实并不需要的旧版代码。基本上,标记为 processThreadEvents 的绿色块下的所有内容都浪费了精力。这一路很快就赢了。移除该函数调用大约节省了 1.5 秒的时间。棒极了!

第二个活动组

在第二个活动组中,解决方案不像第一个活动组那样简单。buildProfileCalls 用时约 0.5 秒,该任务不是可以避免的。

屏幕截图:开发者工具中正在检查其他性能面板实例的性能面板。与 buildProfileCalls 函数关联的任务大约需要 0.5 秒。

出于好奇,我们在 Perf 面板中启用了 Memory 选项进行进一步调查,发现 buildProfileCalls activity 也使用了大量内存。在这里,您可以看到蓝色折线图如何在运行 buildProfileCalls 时突然跳动,这表明可能存在内存泄漏。

屏幕截图:开发者工具中内存分析器评估性能面板的内存使用情况。该检查器表明,buildProfileCalls 函数是内存泄漏的原因。

为了跟进这一怀疑,我们使用 Memory 面板(开发者工具中的另一个面板,不同于 Perf 面板中的 Memory 抽屉式导航栏)进行了调查。在“Memory”面板中,“Allocation Sample”已选择性能分析类型,该类型会记录加载 CPU 配置文件的性能面板的堆快照。

内存分析器初始状态的屏幕截图。“分配采样”以红色框突出显示,表示此选项最适合 JavaScript 内存分析。

以下屏幕截图显示了收集的堆快照。

<ph type="x-smartling-placeholder">
内存分析器的屏幕截图,已选择内存密集型基于 Set 的操作。

通过此堆快照,我们发现 Set消耗了大量内存。通过检查调用点,我们发现将 Set 类型的属性分配给大量创建的对象是不必要的。随着费用的增加,占用了大量内存,以至于应用在处理大量输入时经常会崩溃。

集对于存储唯一项很有用,并且提供使用内容唯一性的操作,例如删除数据集重复数据以及提供更高效的查找。但是,这些功能并不是必需的,因为存储的数据必定与源数据不同。因此,最初并不需要集。为了改进内存分配,属性类型已从 Set 更改为普通数组。应用此更改后,系统会另外截取一个堆快照,并观察到内存分配有所减少。尽管此变更未能显著提升速度,但第二个好处是应用的崩溃频率降低了。

内存分析器的屏幕截图。之前的内存密集型基于 Set 的操作已更改为使用普通数组,从而显著降低了内存开销。

第三个活动组:权衡数据结构权衡

第三部分很独特:在火焰图中,您可以看到它由窄但高的列组成,这些列表示深度函数调用,在本例中表示深度递归。此部分总共持续 1.4 秒左右。通过查看此部分的底部,可以明显看出这些列的宽度是由函数 appendEventAtLevel 的时长决定的,这表明它可能是瓶颈

appendEventAtLevel 函数的实现中,有一点值得注意。对于输入中的每个数据条目(在代码中称为“事件”),系统会向地图添加一个项目,用于跟踪时间轴条目的垂直位置。这会带来问题,因为存储的项数非常大。对于基于键的查询,地图可以快速完成,但这种优势不是免费的。例如,随着地图变大,向其添加数据可能会因重新哈希处理而变得高昂。当连续向地图添加大量项时,此开销会变得很明显。

/**
 * Adds an event to the flame chart data at a defined vertical level.
 */
function appendEventAtLevel (event, level) {
  // ...

  const index = data.length;
  data.push(event);
  this.indexForEventMap.set(event, index);

  // ...
}

我们尝试了另一种方法,该方法不要求我们在地图中为火焰图中的每个条目添加项目。改进非常显著,这证实了瓶颈确实与把所有数据添加到地图所产生的开销有关。活动组的时间从 1.4 秒左右减少到 200 毫秒左右。

之前:

对 extraEventAtLevel 函数进行优化之前的效果面板的屏幕截图。函数运行的总时间为 1,372.51 毫秒。

之后:

对 appendEventAtLevel 函数进行优化后的性能面板屏幕截图。函数的总运行时间为 207.2 毫秒。

第四个活动组:推迟非关键工作并缓存数据以防止重复工作

放大此窗口会发现有两个几乎完全相同的函数调用块。通过观察所调用函数的名称,您可以推断出这些块包含构建树的代码(例如,名称类似于 refreshTreebuildChildren)。事实上,相关代码就是在面板的底部抽屉式导航栏中创建树状视图的代码。有趣的是,这些树状视图不会在加载后立即显示。相反,用户需要选择树状视图(抽屉式导航栏中的“Bottom-up”“Call Tree”和“Event Log”标签页)才能显示树。此外,从屏幕截图可以看出,该树构建过程执行了两次。

性能面板的屏幕截图,显示了即使不需要执行的多项重复性任务。这些任务可以延迟执行,而不是提前执行。

我们在这张图片中发现了两个问题:

  1. 非关键任务影响了加载时间性能。用户并不总是需要这些数据的输出。因此,该任务对配置文件的加载并不重要。
  2. 这些任务的结果未缓存。这就是虽然数据没有变化,但树要计算两次的原因。

我们从将树计算推迟到用户手动打开树状视图时开始计算。只有这样,才有必要为打造这些树木付出代价。运行该程序两次的总时间约为 3.4 秒,因此推迟该程序会显著缩短加载时间。我们仍在研究如何缓存这些类型的任务。

第五个活动组:尽可能避免复杂的调用层次结构

仔细观察这组数据,很明显,某个特定调用链被反复调用。相同的模式在火焰图的不同位置出现了 6 次,该窗口的总时长约为 2.4 秒!

性能面板的屏幕截图,显示了用于生成相同轨迹迷你图的六个单独的函数调用,每个调用堆栈都有深层调用堆栈。

被多次调用的相关代码是处理要渲染到“迷你地图”上的数据的部分(面板顶部的时间轴活动概览)。我们无法确定问题多次发生的原因,但不一定非重复 6 次!事实上,如果未加载其他配置文件,代码的输出应保持最新状态。理论上讲,代码只应运行一次。

经过调查,我们发现,调用相关代码是加载管道中的多个部分直接或间接调用计算迷你地图的函数。这是因为随着时间推移,程序的调用图的复杂性不断演变,并且在不知情的情况下添加了更多对此代码的依赖项。此问题没有快速解决办法。解决方法取决于相关代码库的架构。在本例中,我们必须略微降低调用层次结构的复杂性,并添加一项检查,以防止在输入数据保持不变时执行代码。实施该策略后,我们了解了时间轴:

性能面板的屏幕截图,显示用于生成相同轨迹小地图的 6 个单独的函数调用缩减到仅两次。

请注意,迷你地图渲染执行会执行两次,而不是一次。这是因为系统会为每个配置文件绘制两个小地图:一个用于面板顶部的概览,另一个用于从历史记录中选择当前显示的配置文件的下拉菜单(此菜单中的每个条目都包含所选配置文件的概览)。尽管如此,它们具有完全相同的内容,因此其中一个应该能够重复用于另一个。

由于这些迷你地图都是在画布上绘制的图片,因此只需使用 drawImage 画布实用程序并随后仅运行一次代码即可节省一些额外时间。通过这项措施,该组的时长从 2.4 秒缩短为 140 毫秒。

总结

应用上述所有修复(以及各处的其他一些较小修复)后,配置文件加载时间轴的更改如下所示:

之前:

性能面板的屏幕截图,显示了优化前轨迹加载。该过程大约用了 10 秒钟。

之后:

<ph type="x-smartling-placeholder">
</ph> 性能面板的屏幕截图,其中显示了优化后轨迹加载情况。该过程现在大约需要 2 秒钟。

完成改进后的加载时间为 2 秒,这意味着您不费吹灰之力即可实现约 80%的改进,因为大部分改进都是由快速修复完成的。当然,一开始正确识别要执行的操作至关重要,而性能面板则是实现此目的的理想工具。

同时请务必注意,这些数字针对的是作为研究对象的配置文件。我们之所以对该配置文件很感兴趣,是因为它特别大。尽管如此,由于每个配置文件的处理流水线都是相同的,因此已实现的显著改进适用于性能面板中加载的每个配置文件。

要点总结

关于应用性能优化,您可以从这些结果中汲取一些经验教训:

1. 利用性能剖析工具确定运行时性能模式

性能剖析工具对于了解应用在运行时发生的情况非常有用,尤其是在发现提升性能的机会时。Chrome DevTools 中的 Performance 面板是 Web 应用的理想选择,因为它是浏览器的原生 Web 性能分析工具,并且我们会积极维护该面板,使其与最新的 Web 平台功能保持同步。而且,现在速度明显更快了!😉

使用可用作代表性工作负载的示例,看看您能找到什么!

2. 避免使用复杂的调用层次结构

尽可能避免使调用图过于复杂。使用复杂的调用层次结构时,很容易引入性能下降,并且难以理解代码正常运行的原因,因此很难做出改进。

3. 识别不必要的工作

过时的代码库包含不再需要的代码的情况很常见。在我们的案例中,旧版和不必要的代码占用了总加载时间的很大一部分。移除它是最容易实现的目标。

4. 正确使用数据结构

在决定使用哪种数据结构时,应使用数据结构来优化性能,同时了解每种数据结构的费用和权衡因素。这不仅是数据结构本身的空间复杂性,还涉及适用操作的时间复杂性。

5. 缓存结果以避免针对复杂或重复操作进行重复工作

如果操作的执行成本高昂,那么存储其结果以便下次需要时就很有意义。如果操作执行多次,那么即使每次操作的成本并不特别高,也有必要这样做。

6. 推迟非关键工作

如果任务的输出不是立即需要,并且任务执行会延伸关键路径,请考虑在实际需要输出时延迟调用该任务,从而推迟该任务。

7. 使用高效的算法处理大量输入

对于大量输入,最佳时间复杂度算法至关重要。我们在此示例中并未仔细研究这一类别,但其重要性怎么强调都不为过。

8. 额外好处:对流水线进行基准测试

为确保您不断改进的代码能够保持快速运行,最好监控其行为并将其与标准进行比较。这样,您就可以主动发现回归问题并提高整体可靠性,为取得长期成功奠定基础。