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

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

无论您要开发哪种类型的应用,优化其性能并确保其快速加载并提供流畅的互动体验对用户体验和应用的成功至关重要。为此,一种方法是使用性能分析工具检查应用的活动,以了解应用在某个时间段内运行时后台发生了什么。DevTools 中的性能面板是一款出色的性能分析工具,可用于分析和优化 Web 应用的性能。如果您的应用在 Chrome 中运行,您可以通过该工具直观地了解浏览器在应用执行期间所执行的操作。了解此活动有助于您发现可采取行动来提升性能的模式、瓶颈和性能热点。

以下示例将引导您使用效果面板。

设置和重新创建性能分析场景

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

正如您所知,DevTools 本身就是一款 Web 应用。因此,您可以使用性能面板对其进行性能分析。如需对此面板本身进行性能分析,您可以打开 DevTools,然后打开附加到该面板的另一个 DevTools 实例。在 Google 内部,这种设置称为“DevTools-on-DevTools”。

设置就绪后,必须重新创建要分析的场景并进行记录。为避免混淆,我们将原始 DevTools 窗口称为“第一个 DevTools 实例”,将用于检查第一个实例的窗口称为“第二个 DevTools 实例”。

一张屏幕截图,显示了 DevTools 实例在检查 DevTools 本身中的元素。
DevTools-on-DevTools:使用 DevTools 检查 DevTools。

在第二个 DevTools 实例中,性能面板(以下简称“性能面板”)会观察第一个 DevTools 实例,以重现该场景,从而加载配置文件。

第二个 DevTools 实例中,系统会开始实时录制,而在第一个实例中,系统会从磁盘上的文件加载配置文件。加载大型文件是为了准确分析处理大型输入的性能。当两个实例都完成加载后,性能分析数据(通常称为轨迹)会显示在加载配置文件的第二个 DevTools 实例的性能面板中。

初始状态:发现改进机会

加载完成后,我们在下一个屏幕截图中观察到了第二个性能面板实例上的以下内容。重点关注主线程的 activity,该 activity 显示在标记为 Main 的轨道下方。可以看出,火焰图中有五组主要活动。这些任务是指加载时间最长的任务。这些任务的总时间约为 10 秒。在以下屏幕截图中,效果面板用于重点关注这些活动组,以查看可以发现哪些问题。

一张屏幕截图,显示了 DevTools 中的性能面板,其中正在检查另一个 DevTools 实例的性能面板中性能轨迹的加载情况。配置文件大约需要 10 秒钟才能加载完毕。这段时间主要分为五大活动组。

第一个 activity 组:不必要的工作

很明显,第一组 activity 是仍在运行但实际上不需要的旧版代码。基本上,标记为 processThreadEvents 的绿色块下的所有内容都是浪费力气。这个问题很快就解决了。移除该函数调用可节省大约 1.5 秒的时间。棒极了!

第二个活动组

在第二个 activity 组中,解决方案并不像第一个那样简单。buildProfileCalls 大约需要 0.5 秒,而且无法避免执行此任务。

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

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

开发者工具中内存分析器的屏幕截图,用于评估“性能”面板的内存消耗。检查器提示 buildProfileCalls 函数是导致内存泄露的原因。

为了进一步调查这一疑虑,我们使用了“Memory”(内存)面板(DevTools 中的另一个面板,不同于“Performance”面板中的“Memory”抽屉)进行调查。在“内存”面板中,选择了“分配抽样”性能分析类型,该类型会为加载 CPU 性能文件的性能面板记录堆快照。

内存分析器初始状态的屏幕截图。“allocation sampling”(分配抽样)选项用红色框标记,表示此选项最适合进行 JavaScript 内存性能分析。

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

内存分析器的屏幕截图,其中选择了内存密集型基于 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 毫秒。

之前:

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

之后:

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

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

放大此窗口后,您可以看到有两个几乎完全相同的函数调用块。通过查看调用的函数的名称,您可以推断出这些块包含构建树的代码(例如,名称为 refreshTreebuildChildren 的代码)。事实上,相关代码就是用于在面板底部的抽屉中创建树视图的代码。有趣的是,这些树状视图不会在加载后立即显示。相反,用户需要选择树视图(抽屉中的“自下而上”“调用树”和“事件日志”标签页),才能看到树。此外,从屏幕截图中可以看出,系统执行了两次树构建流程。

一张性能面板的屏幕截图,显示了即使不需要也会执行的多个重复性任务。这些任务可以推迟到按需执行,而不是提前执行。

我们发现此图片存在两个问题:

  1. 某个非关键任务妨碍了加载时间的性能。用户并不总是需要其输出。因此,该任务对配置文件加载而言并不重要。
  2. 这些任务的结果未缓存。因此,尽管数据没有变化,系统还是计算了两次树。

我们首先将树计算推迟到用户手动打开树视图时。只有这样,创建这些树的代价才值得。两次运行此代码的总时间约为 3.4 秒,因此延迟执行对加载时间有很大影响。我们仍在研究如何缓存此类任务。

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

仔细研究这个组后,我们发现系统正在反复调用特定的调用链。同一模式在火焰图的不同位置出现了 6 次,并且此时间段的总时长约为 2.4 秒!

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

被多次调用的相关代码是处理要渲染在“迷你地图”(面板顶部的时间轴活动概览)上的数据的部分。我们尚不清楚为何会多次发生,但肯定不会发生 6 次!事实上,如果未加载其他配置文件,代码的输出应保持最新状态。从理论上讲,该代码应该只运行一次。

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

性能面板的屏幕截图,显示生成相同轨迹迷你地图所需的 6 次单独函数调用已减少为 2 次。

请注意,迷你地图渲染执行会发生两次,而不是一次。这是因为系统会为每个配置文件绘制两个迷你地图:一个用于面板顶部的概览,另一个用于从历史记录中选择当前可见的配置文件的下拉菜单(此菜单中的每个项都包含其所选配置文件的概览)。不过,这两个报告的内容完全相同,因此可以将其中一个报告用于另一个报告。

由于这两个迷你地图都是在画布上绘制的图片,因此只需使用 drawImage Canvas 实用程序,然后只运行一次代码即可节省一些时间。经过这项工作,该组的时长从 2.4 秒缩短到了 140 毫秒。

总结

应用所有这些修复程序(以及其他一些小修复程序)后,配置文件加载时间轴的变化如下所示:

之前:

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

之后:

性能面板的屏幕截图,显示了优化后的轨迹加载情况。该过程现在大约需要 2 秒钟。

改进后,加载时间缩短为 2 秒,这意味着只需付出相对较少的努力,就实现了约 80%的改进,因为所做的大部分工作都是快速修复。当然,正确确定最初要做什么至关重要,而性能面板是这方面的理想工具。

另外,请务必强调这些数字仅适用于作为研究对象的配置文件。该配置文件特别大,因此引起了我们的注意。不过,由于每个配置文件的处理流水线都是相同的,因此所实现的显著改进会应用于性能面板中加载的每个配置文件。

要点总结

从这些结果中,我们可以总结出一些关于应用性能优化的经验:

1. 利用性能分析工具找出运行时性能模式

性能分析工具非常有助于了解应用在运行期间发生的情况,尤其是发现有哪些机会可以提升性能。Chrome DevTools 中的“Performance”面板是 Web 应用的绝佳选择,因为它是浏览器中的原生 Web 性能分析工具,并且会积极维护,以便随时提供最新的 Web 平台功能。此外,它的速度现在也快了很多!😉

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

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

请尽可能避免使调用图过于复杂。复杂的调用层次结构很容易引入性能回归问题,并且很难理解代码以何种方式运行,这会导致很难实现改进。

3. 找出不必要的工作

老旧的代码库通常包含不再需要的代码。在我们的示例中,旧版和不必要的代码占据了总加载时间的很大一部分。移除它是唾手可得的机会。

4. 恰当使用数据结构

使用数据结构来优化性能,但在决定使用哪种数据结构时,也要了解每种数据结构带来的成本和权衡。这不仅包括数据结构本身的空间复杂性,还包括适用操作的时间复杂性。

5. 缓存结果,以避免为复杂或重复的操作执行重复工作

如果操作的执行成本较高,则有必要存储其结果,以备下次需要时使用。如果操作要重复执行多次,这样做也很有意义,即使每次操作的开销并不特别大也是如此。

6. 推迟非关键工作

如果不需要立即获取任务的输出,并且任务执行会延长关键路径,请考虑在真正需要其输出时延迟执行该任务,方法是延迟调用该任务。

7. 对大型输入使用高效算法

对于大型输入,采用时间复杂度最优的算法至关重要。我们在本例中没有探讨此类别,但它们的重要性不言而喻。

8. 附加内容:对流水线进行基准测试

为了确保不断演变的代码保持快速运行,建议您监控代码的行为并将其与标准进行比较。这样,您就可以主动发现回归问题并提高整体可靠性,为取得长期成功做好准备。