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

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

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

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

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

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

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

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

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

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

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

初始状态:发现改进机会

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

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

第一个 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 毫秒。

之前:

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

之后:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

总结

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

之前:

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

之后:

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

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

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

要点总结

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

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

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

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

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

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

3. 找出不必要的工作

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

4. 恰当使用数据结构

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

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

如果操作的执行成本较高,则有必要存储其结果,以备下次需要时使用。如果操作执行多次,那么即使每次操作的成本并不特别高,也有必要这样做。

6. 推迟非关键工作

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

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

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

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

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