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

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

无论开发哪种类型的应用,优化其性能并确保其快速加载并提供流畅的互动对于用户体验和应用的成功都至关重要。一种方法是使用分析工具来检查应用的活动,以查看应用在特定时间段内运行时发生的情况。开发者工具中的“性能”面板是一个强大的性能分析工具,用于分析和优化 Web 应用的性能。如果您的应用在 Chrome 中运行,您可以通过该图表详细直观地了解浏览器在执行应用时执行的操作。了解此活动有助于您确定模式、瓶颈和性能热点,然后采取相应措施来提高性能。

以下示例演示了如何使用性能面板。

设置并重新创建分析场景

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

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

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

检查开发者工具本身中元素的开发者工具实例的屏幕截图。
DevTools-on-DevTools:使用开发者工具检查开发者工具。

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

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

初始状态:发现改进机会

加载完成后,在下一个屏幕截图中观察到第二个性能面板实例上的以下内容。将焦点置于主线程的活动,该活动显示在标记为 Main 的轨道下。可以看到,火焰图中有五大类活动。其中包括加载用时最多的任务。这些任务的总时间约为 10 秒。在下面的屏幕截图中,使用效果面板聚焦于其中每个活动组,看看能找到什么。

开发者工具中的性能面板的屏幕截图,其中检查另一个开发者工具实例的性能面板中的性能跟踪记录的加载情况。加载该配置文件大约需要 10 秒钟。活动时间主要分为五个主要活动组。

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

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

第二个活动组

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

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

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

开发者工具中用于评估性能面板的内存消耗情况的内存分析器的屏幕截图。检查器建议 buildProfileCalls 函数导致内存泄漏。

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

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

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

内存分析器的屏幕截图,其中选择了基于内存密集型集合的操作。

从此堆快照中观察到 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 毫秒。

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

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

效果面板的屏幕截图,其中显示了多项重复性的任务,即使不需要这些任务也会执行。这些任务可以推迟按需执行,而不是提前执行。

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

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

我们先将树状计算延迟到用户手动打开树状视图时进行。只有这样,才值得付出代价来打造这些树。运行两次的总时间约为 3.4 秒,因此推迟运行会对加载时间产生重大影响。我们仍在研究如何缓存这些类型的任务。

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

仔细观察该群组后,可以发现某个特定调用链被反复调用。同一模式在火焰图中的不同位置出现了 6 次,此窗口的总时长约为 2.4 秒!

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

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

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

性能面板的屏幕截图,其中显示了用于生成相同轨迹迷你地图的六个单独的函数调用(缩短了两次)。

请注意,迷你地图渲染执行会执行两次,而不是一次。这是因为系统为每个个人资料绘制了两张小地图:一个用于显示在面板顶部的概览,另一个用于下拉菜单,用于从历史记录中选择当前显示的个人资料(此菜单中的每项内容都包含其所选个人资料的概览)。尽管如此,这两种广告的内容完全相同,因此其中一个可以重复用于另一个广告。

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

总结

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

更改前:

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

更改后:

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

改进后的加载时间为 2 秒,这意味着只需相对较低的工作量即可实现约 80%的改进,因为大部分工作都是快速修复。当然,正确识别最初要完成的行动是关键所在,性能面板是实现此目的的合适工具。

另外,请务必注意,这些数字特定于用作研究对象的配置文件。我们对此个人资料很感兴趣,因为它特别大。尽管如此,由于每个配置文件的处理流水线都是相同的,因此所获得的显著改进适用于在性能面板中加载的每个配置文件。

掌握要点

从应用性能优化的角度来看,您可以参考下面这些结果:

1. 使用性能分析工具识别运行时性能模式

分析工具对于了解应用运行时的情况非常有用,尤其是发现提升性能的机会。对于 Web 应用而言,Chrome 开发者工具中的“性能”面板是绝佳的选择,因为它是浏览器中的原生 Web 分析工具,并且会主动维护,以便与最新的网络平台功能保持同步。此外,它的运行速度也明显变快了!😉

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

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

尽可能避免调用图表过于复杂。如果调用层次结构比较复杂,很容易引入性能下降问题,并且很难理解代码为何会以这种方式运行,这使得实现改进变得困难。

3. 找出不必要的工作

老化代码库包含不再需要的代码很常见。在我们的例子中,旧版和不必要的代码占用了很大一部分总加载时间。移除它才是容易实现的效果。

4. 适当使用数据结构

您可以使用数据结构来优化性能,但在决定要使用哪类数据结构时,也应该了解每种数据结构的费用和权衡取舍。这不仅关乎数据结构本身的空间复杂性,也包括适用操作的时间复杂性。

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

如果某项操作的执行成本高昂,则应该存储其结果以供下次需要。如果操作执行多次,即使每次的成本并不特别高,这也很有意义。

6. 推迟非关键工作

如果不需要立即获得任务的输出,并且任务的执行会扩展关键路径,请考虑在确实需要输出时通过延迟调用来推迟该任务。

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

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

8. 额外好处:对流水线进行基准化分析

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