利用 Chrome DevTools 全面分析 JavaScript 性能

原文信息: 查看原文查看原文

Comprehensive guide to JavaScript performance analysis using Chrome DevTools

- Jiayi

让我们看看如何通过 Chrome DevTools 的 Performance(性能)标签来有效地分析并改进 JavaScript 的性能,同时避免常见错误。我们的用例是提高一个现实世界中 canvas 库的渲染 FPS(每秒帧数)。

几周前,我的一位同事和我在 benchmarks.slaylines.io 上查看了 canvas 引擎的比较基准测试。该基准测试列出了几种最受欢迎的解决方案,并允许轻松比较在 canvas 上渲染数千个矩形的性能。

benchmarks.slaylines.io 对 fabric.js 和 16000 个对象的基准测试

benchmarks.slaylines.io 对 fabric.js 和 16000 个对象的基准测试

在工作时,我们都使用 fabric.js,这是一个专注于交互性的 canvas 引擎库,通常以牺牲性能为代价。然而,直到我们看到基准测试,它连续动画化了 16000 个对象,我们才意识到它“牺牲了多少性能”。尽管如此,在一次 Teams 会议的不到 2 小时内,我们就能够将 M2 Pro 上的每秒帧数(FPS)从大约 13fps 提高到接近 50fps。这也是在不到 2 小时内实现了 4 倍或 400% 的性能提升!

大部分更改甚至不是特定于 canvas 的优化,比如我们没有从 CanvasRenderingContext2D 更改为 WebGLRenderingContext。所以,我想为什么不利用这个案例来展示我们如何使用 Chrome DevTools 的 Performance 标签来衡量、调查并改进库的 JavaScript 执行时间呢?虽然 Chrome 对于如何开始 分析运行时性能 有不错的文档,但它只是一个介绍,而且 #分析结果 部分远没有完整地解释如何找到你可以改进的地方,特别是在非平凡案例中。

性能结果的首次概览

在整篇文章中,你可以使用以下 Code Sandbox 尝试性能分析和优化。我准备了一个小型的基准测试克隆:

fabric.js 文件中,你会发现 OptimisedRect 类,它允许覆盖一些 fabric.Object 类的方法,这些方法将对性能产生重大影响。为了衡量性能,我建议在新窗口中打开 Sandbox 预览(点击右上角的图标),以便基准测试在自己的页面上,而不是在 iframe 中。

让我们首先点击 Chrome Devtools 的 Performance 标签上的 Record(记录)按钮,然后首次查看结果。因为我们在这种情况下处理的是连续重新渲染,所以我们只需要记录几秒钟,因为执行将周期性地重复。

性能分析结果

性能分析结果

毫无疑问,性能结果对我们来说是“低效!”的明确指示。

时间线

时间线显示了浏览器在记录期间忙碌的活动。每种颜色代表不同类型的计算,但通常我们寻找的是代表 JavaScript 执行的黄色。一个典型的时间线有一些黄色区域的峰值,这时浏览器最忙于运行 JavaScript 并丢帧:

典型时间线

典型时间线

偶尔丢一些帧是不可避免的,也是可以接受的,如果我们正在构建任何非平凡的应用程序。在时间线上,我们寻找黄色峰值持续至少半秒的范围。它们对应于许多帧丢失的时刻。时间线底部的截图非常有用,可以了解页面上发生了什么。例如,在我们的案例中,我们可以看到矩形在移动:

时间线截图

时间线截图

一个长的黄色矩形意味着 CPU 一直在忙于执行 JavaScript,没有任何空闲时刻。由于我们正在连续动画和重新渲染 canvas 上的矩形,这本身不是问题,但是时间线上的红线和上面的多个红点表明,由于浏览器忙于执行 JavaScript 而不是渲染页面,许多帧被丢弃了。毫不奇怪,基准测试显示 FPS 计量器上的 12-13fps。

FPS 应该至少高于 24,才不被视为卡顿。30fps 是一个好结果,60fps 很棒且流畅,120fps 是惊人的。我们希望至少达到 30fps,这意味着每秒能够渲染 30 次,即 1000 / 30 = 33ms 是我们每帧拥有的总时间。这包括执行我们自己的 JS 来更新 canvas 上的矩形和 浏览器的渲染阶段。所以实际上它少于 33ms。

你可能认为 FPS 只对动画、canvas 或游戏很重要,但实际上任何网页都需要平滑地重新渲染。即使你只是在移动鼠标并悬停在链接或按钮上,你也需要浏览器重新渲染页面并显示带有不同背景的悬停按钮。如果你想知道“如果我不使用 canvas,我怎样才能得到一个 FPS 计量器?”你可以从 Chrome Devtools 通过点击 Devtools 右上角的三个点菜单 > 更多工具 > 渲染来启用 FPS 渲染统计信息

Chrome Devtools FPS 计量器

Chrome Devtools FPS 计量器

你可以注意到,Chrome 的原生 FPS 计量器显示的值比基准测试内置的 FPS 计量器好,24fps 与 13.6fps。我猜用户实现的 FPS 计量器不够准确,因为它更多是衡量 requestAnimationFrame 调用的次数,而不是原始渲染帧。

摘要

摘要告诉我们在选定的时间范围内时间是如何分配的。在我们的例子中,我们选择了 2.62s - 3.65s 之间的范围,所以大约是 1 秒,而那段时间基本上全部花在了运行 JS(黄色脚本)上。浏览器只花了 22ms 的时间来进行实际的绘制。

虽然摘要标签并没有帮助我们指出性能瓶颈,但它是一个不错的通用指标,可以让我们开始。当我们深入分析时,它会显示关于我们在火焰图上选择的内容的真正有用的信息。

火焰图

火焰图是函数调用栈的图形可视化,在一瞥中,我们可以看到谁调用了什么。图中的每个节点对应一个函数,用眼睛沿着图表移动意味着跟随代码路径。我是一个大数据可视化的忠实粉丝,我认为火焰图是通用的最佳可视化工具之一。

在 JavaScript 和 Chrome 的背景下,火焰图显示了从调用者到被调用者的自上而下的调用栈,然后火焰图区域被划分为部分,每个事件循环任务一个图表。在我们的例子中,由于我们正在动画化,每个任务都是一个 requestAnimationFrame 回调,因为这是启动我们任务的“触发器”。在 JS 中,由于我们有事件循环,浏览器在没有触发任务的事件(即鼠标事件、网络事件、超时触发等)的情况下是空闲的。

火焰图任务

火焰图任务

Devtools 再次告诉我们,每个任务花费的时间太长。如果我们要达到 30fps,我们不应该使用超过 33ms,但每个任务花费了 77.63ms,而且还是在 M2 Pro 上!

点击摘要上的“长任务”链接将带我们进入 Chrome 文档,他们建议 将计算分割成异步块。这通常是不可能的,或者它是一个高度特定于应用程序的高级优化。这不是你在不到两小时内可以完成的事情!例如,在我们的例子中,异步更新矩形,同时交错帧渲染,将导致一些矩形的位置没有被更新或没有被渲染。将计算分割成异步块并不容易。更常见的是,我们需要减少同步计算,并将异步块分割作为最后的高级优化。

Chrome v122 的一个不错的补充是,我们甚至可以看到事件任务的发起者,例如谁调用了 setTimeoutrequestAnimationFrame。如果你想知道例如“为什么这代码会运行”,这非常方便。

requestAnimationFrame 发起者

requestAnimationFrame 发起者

你可以通过在火焰图中回溯箭头或打开摘要标签中的函数源代码来确定发起者。我发现回溯箭头更有用,因为函数引用指向了调用 requestAnimationFrame 的确切函数,在我们的例子中只是一个包装器:

import { getFabricWindow } from '../../env';
export function requestAnimFrame(callback: FrameRequestCallback): number {
  return getFabricWindow().requestAnimationFrame(callback);
}
export function cancelAnimFrame(handle: number): void {
  return getFabricWindow().cancelAnimationFrame(handle);
}

这不会告诉我们很多关于谁以及为什么它被调用的信息。如果你回溯火焰图中的箭头,你可以看到调用栈中的父级。在我们的案例中,发起者是一个名为 animate 的函数,一个 fabric 实用程序,它又由一个通用的 函数调用 调用。这是因为我们使用了一个匿名箭头函数,但幸运的是,摘要将提供函数源代码的引用:

animate 源代码

animate 源代码

animate = () => {
  const rects = this.rects;
  for (let i = 0; i < this.count.value; i++) {
    const r = rects[i];
    r.x -= r.speed;
    r.el.left = r.x;
    if (r.x + r.size < 0) {
      r.x = this.width + r.size;
    }
  }
  this.fabricCanvas.requestRenderAll();
  this.meter.tick();
  this.request = requestAnimationFrame(this.animate);
};

函数也被称为 animate,但这次它是负责更新矩形并要求 fabric.js 为每个动画帧重新渲染的代码。该函数中的每行代码在左侧都有一个数字标签,表示整个记录中在该行上花费的总执行时间。

虽然更大的值用更强烈的黄色着色,但这并不表示那段代码有什么问题。首先,请记住,这是整个记录的总时间,所以不是每个函数执行的时间。除非你看到在几秒钟的记录中,某些值大于几百毫秒,否则你根本不需要担心。其次,某些代码行比其他行花费更多的时间是自然的,差异在执行中累积,所以你经常看到有些行有更大的标签时间。在我们的例子中,它可能似乎表明 r.x -= r.speed 有问题,但当然没有问题。

所以根据我的经验,仔细评估与代码行相关联的时间标签。它们经常误导你,你可能会浪费时间走错方向。

你们中最聪明的人可能会想“嘿,也许它告诉你那行代码有问题!也许你应该让那个属性写入更快!”当然,我可能通过使用一些疯狂的二进制操作等低级优化来实现更好的时间,但这不是你在分析性能时应该首先尝试的列表上的事情。

分析火焰图

我们的目标是减少火焰图中每个任务的执行时间。进行性能分析的一个关键方面是首先了解代码试图完成什么。因此,我们首先通过选择一个任务并查看图表提供的代码流信息来做到这一点。

任务火焰图

任务火焰图

我们可以看到每个任务都以“函数调用”开始,我们已经看到这是基准测试的 animate 方法。然后我们有一些方法调用,导致 _renderObjects。你会注意到中间的节点和 _renderObjects 本身与父节点有相同的宽度。火焰图中一个节点的宽度对应于父执行时间的百分比,所以一个占用全部父宽度的节点意味着我们可以忽略父节点并沿着代码路径向前移动。

例如 renderCanvas 是一个长方法,但是因为火焰图显示 _renderObjects 占用了它所有的执行时间,我们不需要调查方法中的其他代码行:

renderCanvas 源代码

renderCanvas 源代码

这就是为什么精通阅读火焰图很重要,它节省了很多时间!随着你习惯使用火焰图,你自然会跳过那些子节点占据了大部分宽度的节点/函数。但是,不要总是立即跳到火焰图的叶节点,因为你可能会错过一些自己的执行时间相当长的函数,即花费相当多时间来做工作的函数,如循环。

继续,我们可以看到 _renderObjects 有几个名为 render 的子节点,构成了 _renderObjects 执行时间的全部。通过查看其源代码可以很容易地解释这一点:

_renderObjects(ctx: CanvasRenderingContext2D, objects: FabricObject[]) {
  for (let i = 0, len = objects.length; i < len; ++i) {
    objects[i] && objects[i].render(ctx);
  }
}

它所做的就是为 canvas 上的每个对象调用 objects[i].render(ctx)

渲染节点

渲染节点

火焰图将向我们展示不同称为 render 的子节点,其中大多数将具有相同的宽度,但有些可能更宽或更窄。render 节点的数量并不对应它们实际被执行的次数(在我们的基准测试中每个矩形执行一次)。函数节点被批处理,火焰图向我们展示了每个批次的执行时间。

因此,我们可以选择任何同级节点并查看美味的 render 函数:

渲染火焰图

渲染火焰图

render 方法负责渲染每个对象,具体来说,在我们的例子中,是每个矩形。与父方法相比,它的子节点明显更加多样化,而且有很多,以至于我无法将所有函数名称放入单个屏幕截图中!让我们看看源代码:

渲染源代码

渲染源代码

如果你看看左侧的时间标签,你会注意到一些非常奇怪的事情,即它似乎表明做一个早期返回需要 100ms!我认为 Chrome 在放置时间标签方面有一个长期存在的 bug。我认为在这种情况下,时间标签偏移了大约一个语句,100ms 对应于 if 块,其中 !this.isOnScreen() 检查可能是昂贵的。如果我们回到火焰图,我们经常看不到任何 isOnScreen 节点作为 render 的子节点,这意味着它实际上并不那么重要。当我们确实在某些 render 节点下找到它时,它只是一个小节点:

isOnScreen 在火焰图上

isOnScreen 在火焰图上

我被解释说,isOnScreen 很少出现在火焰图上的原因是 Chrome 正在进行样本分析,它在固定间隔测量调用栈。因此,小而频繁的调用可能在样本中没有被捕捉到。感谢 romgrk,你可以在他的优秀文章 为了乐趣和利润优化 JavaScript 中阅读更多。

我们可以通过更改 render 方法来排除 isOnScreen 检查,因为我们的所有对象都在屏幕上并且正在动画化。我们保存并再次记录 Performance:

  • 页面 FPS 计量器现在达到 14fps(之前是 12fps)
  • 火焰图中的每个任务现在大约是 68ms(之前是 78ms)

这大约是 ~15% 的改进,这还不错,但远非人们可能从源面板中的巨大时间标签数字中预期的。这就是为什么我不完全相信源面板上的时间标签,掌握阅读火焰图是很重要的。虽然这是一个不错的改进,我们稍后会应用它,但我们被误导认为 isOnScreen 是主要的罪魁祸首,而我们将在后面发现有更有效的目标。特别是对于像 canvas 或游戏渲染这样的复杂代码,集中精力改进错误的代码可能会非常耗时。在我们的例子中,我们可以删除检查,知道我们的特定案例,但通常你会花几个小时思考和实验如何使 isOnScreen 更有效。

其余的时间标签与火焰图信息一致:有一些函数调用占用了一点代码,有些占用了更多,但它们中的任何一个都没有明显比其他的更昂贵。

火焰图模式

回到 render 的火焰图,当我们调查火焰图时,我们通常想寻找这些模式:

  1. 单个子节点明显大于其他节点,表明例如 render 大部分时间在执行该函数节点
  2. 非常频繁重复的节点。虽然每个单独的节点没有占用太多时间,但如果我们运行低效的循环,我们可能会遭受著名的“千刀万剐”

根据这些模式,一些节点引起了我们的注意。

火焰图上的 Minor GC

火焰图上的 Minor GC

偶尔,但足够频繁以至于在火焰图中可以注意到,一个 Minor GC(垃圾回收)节点出现了,并且它明显大于其他节点,因此满足了模式 #1。每个 GC 节点意味着 JavaScript 引擎认为有必要清理内存,这是有代价的。垃圾回收 使运行时执行时间不可预测,因为它的调度不是确定性的。我们不知道它何时运行以及它将花费多少时间,导致更多的执行时间和不一致的帧率。

关于火焰图上 Minor GC 节点的一个重要且令人困惑的注意事项是,它们可能会误导你认为父节点负责不良的内存管理。

长期以来,我也被它愚弄了。在我们的截图中,我们会认为 lineTo 是罪魁祸首,但这怎么可能,因为 lineTo 是一个本地的 CanvasRenderingContext2D 方法!事实是火焰图会告诉你 什么时候 发生了某事,所以如果垃圾回收发生在 lineTo 执行期间,它将作为其子节点出现。然而,被收集的内存是由一个完全不同的函数分配的,如果它不是 CPU 昂贵的,甚至可能不会出现在火焰图中。

不幸的是,检测导致昂贵不可预测垃圾回收的低效内存分配的根本原因不在本文的讨论范围内。我们将在专门的未来文章中更多地讨论内存管理和分析,但幸运的是,通过解决一个 CPU 密集型函数,我们的问题也将得到解决。我们的代码中的一个函数既负责低效的 CPU 执行,也负责内存管理,所以解决一个问题也就解决了另一个问题。

火焰图上的 ctx.save/restore/fill

火焰图上的 ctx.save/restore/fill

saverestorefilllineTo 的节点似乎出现得非常频繁,所以它们满足模式 #2。很明显,使用它们有一些成本,并且调用非常频繁,但很难说是否有任何可能的行动。它们是本地的 CanvasRenderingContext2D,所以我们不能检查它们以查看实现,以获取更多关于我们可以改进什么的信息。所以现在让我们继续前进。

火焰图上的 calcOwnMatrix

火焰图上的 calcOwnMatrix

calcOwnMatrix 是一个在火焰图中频繁出现的节点,它也占据了相当一部分的执行时间,所以它满足我们要找的两个火焰图模式。正如我们将看到的,它确实是一个非常好的候选,但再次很难确切地说它是否有任何问题。一个额外的疑问是,是否有问题与 calcOwnMatrix 还是它的子节点 transformMatrixKeymultiplyTransformMatrices,因为有时它们作为子节点出现,并且和 calcOwnMatrix 本身一样大。

所以火焰图分析给了我们一些进一步调查的线索,但如果我们能得到更多的指导就好了。我们想要的是数字。在进行性能分析时,基于数字做出决策至关重要。 Bottom-Up 面板将帮助我们。

Bottom-Up 分析

Bottom-Up 面板包含在选定的时间范围内或选定的火焰图节点内调用的所有函数。然后我们可以按 Self Time(累积自己的执行时间)或 Total time(累积自己的执行时间 + 内部调用函数的执行时间)进行排序。通过“累积”,我们指的是在选定的时间线范围或火焰图节点内的总和。该面板之所以被称为 Bottom-Up,是因为我们可以展开它,看到每个函数的自下而上的调用栈。这有助于确定为什么调用了一个昂贵的函数:

render Bottom-Up 栈

render Bottom-Up 栈

为了分析 render 函数的 Bottom-Up 面板,我们必须做一些有点违反直觉的事情:选择火焰图中的 renderCanvas 节点。原因是,正如我们所注意到的,火焰图中的 render 函数节点不一致。它们有不同的宽度/持续时间,取决于你选择的特定节点,你会在 Bottom-Up 面板中看到不同的函数和执行时间。

render 不一致的持续时间

render 不一致的持续时间

当选择要分析的火焰图节点时,使用更大的累积时间和更一致的节点会更容易。

你会注意到 renderCanvas 的持续时间要一致得多,Bottom-Up 列表也是如此。

renderCanvas 一致的持续时间

renderCanvas 一致的持续时间

由于 renderrenderCanvas 火焰图的一个子图,前者几乎占据了后者的全部执行时间,我们可以预期在两个视图中列出相同的昂贵函数。的确,让我们首先按 Self Time 降序排列,我们会注意到许多顶级结果是由 render 调用的函数:

按 Self Time 排序的 Bottom-Up 函数

按 Self Time 排序的 Bottom-Up 函数

我们有一些潜在的候选,如 restoresavecalcOwnMatrixtransformMatrixKeymultiplyTransformMatrices。同样值得注意的是,这些方法中有三种涉及“矩阵”。那是因为 canvas 操作,如渲染,基本上需要计算点和对象的几何变换,类似于可以 translaterotatescale 的 CSS 变换。对于最好奇的人,提到的矩阵函数是 fabric.js 等同于标准 JS DOMMatrix 方法。如果你想阅读一个简短的解释,说明为什么 2D 渲染需要矩阵变换,你可以阅读 Dirty Flag 优化模式 中的“动机”部分。存在一个众所周知的模式来改进矩阵变换的计算证明它是昂贵的。所以如果我们改进矩阵计算,我们也会得到更快的渲染。

然而,在深入研究矩阵变换之前,这听起来很可怕,让我们也按 Total Time 对 Bottom-Up 列表进行排序:

按 Total Time 排序的 Bottom-Up 函数

按 Total Time 排序的 Bottom-Up 函数

当查看 Bottom-Up 面板时,我们希望寻找 Total Time 百分比高但在火焰图中并不高的函数。这意味着它们占用了相当一部分时间,尽管它们不是高级别函数,即它们是昂贵的低级别函数。

例如,renderCanvas 方法有 100% 的 Total Time,但这是显而易见的,因为它是我们在火焰图中选择的函数!第二个结果与 (anonymous) 函数也是误导的。它有 100% 的 Total Time,因为它包括所有匿名函数,包括 requestAnimationFrame 回调,尽管右侧的源引用可能会告诉你相反的情况。

(anonymous) 调用栈

(anonymous) 调用栈

了解分析的代码的含义在这里很重要,以确定哪些函数尽管是低级别的,但具有显著的 Total Time。这里有趣的候选是:transform_rendercalcOwnMatrix_renderPaintInOrderrestoretransformMatrixKeymultiplyTransformMatrices。关于一些函数的一些说明:

  • drawObject 被排除在外,因为它由 render 调用,只是将对象渲染委托给 _render,后者也在列表中,并且具有类似的 Total Time,这意味着实际上是 _render 可能是运行时的原因
    drawObject(ctx: CanvasRenderingContext2D, forClipping?: boolean) {
      this._renderBackground(ctx);
      // ...
      this._render(ctx);
    }
    ```
*   `calcTransformMatrix` 也被类似地排除在外,因为它调用 `calcOwnMatrix`,后者在列表中具有类似的 Total Time,所以 `calcTransformMatrix` 本身没有什么有趣的:

js calcTransformMatrix(skipGroup = false): TMat2D { let matrix = this.calcOwnMatrix(); // … return matrix; } ```

通过 Self Time 和 Total Time 的函数分析似乎都得出我们应该看看与矩阵相关的函数和 save/restore() 的结论。

提升 Canvas 渲染性能

现在我们已经找到了可能的优化点,让我们尝试深入研究。我们将从与矩阵相关的函数开始。我们看到 calcOwnMatrix 占用了相当的自身时间和总时间。

calcOwnMatrix

calcOwnMatrix 源码

calcOwnMatrix 源码

216毫秒的时间标签似乎表明有一行代码代价昂贵,因为它明显比其他标签大得多。它指向 this.ownMatrixCache,但这没道理,因为它只是读取一个属性。更有可能的是真正的行是 transformMatrixKey(),如果你还记得,它也列在昂贵的 Bottom-Up 函数中!这个函数负责获取一个缓存键,以检查是否需要重新计算变换矩阵。查看 transformMatrixKey 源码实现似乎证实了这一点:

transformMatrixKey 源码

transformMatrixKey 源码

再次,代码行误导了我们。通过查看代码,我们可能认为 transformMatrixKey 的昂贵递归调用存在问题,但我们的基准矩形没有 this.group 属性,因为没有分组。错误的行是返回指令,它创建了一个长字符串作为缓存。使用像 JavaScript 这样的高级语言,并且不必处理服务器上的 100k 网络请求,通常意味着我们不必担心像字符串性能这样的低级问题,但如果我们不小心,我们仍需支付昂贵操作的成本。创建和比较大型字符串可以迅速变得昂贵,如这个 大型字符串比较基准 所示。

考虑到我们正在动画化,因此矩形的位置不断变化,使用缓存只是不必要的开销,所以我们可以尝试完全去除矩阵缓存。在大多数情况下,您可能会尝试考虑一个更有效的缓存键,但这超出了本文的范围。

所以让我们创建我们自己的 OptimisedRect 并覆盖 calcOwnMatrix 以完全避免使用缓存。如果我们重新加载页面并再次记录性能,我们会注意到有显著的改进。在我的 M2 Pro 上:

  • 页面 FPS 计量器现在达到 16fps(之前是 13fps)

  • 火焰图中的每个任务现在大约是 65ms(之前是 77ms)

  • calcOwnMatrix 源码不再显示带有 216ms 的时间标签

    calcOwnMatrix 无缓存源码

    calcOwnMatrix 无缓存源码

总体上这是 20% 的改进。

阅读文章的知识渊博的人会意识到,创建大型字符串缓存键之所以昂贵,部分原因是垃圾收集。因为我们的矩形是动画化的,缓存键不断被重新创建,旧的键被垃圾收集。垃圾收集运行需要时间,特别是当涉及大量内存时。如果你再次记录性能并仔细查看火焰图,你会注意到大多数的 Minor GC 节点已经消失,任务的持续时间更加一致。

CanvasRenderingContext2D save/restore

根据 Bottom-Up 分析,我们的下一个候选是 ctx.save/restore()。这是一个高度特定于 Canvas 的优化,但似乎使用保存和恢复 canvas 绘图状态 有不可忽视的成本。这个成本似乎取决于 GPU 和 2D 上下文状态。使用 Bottom-Up 面板我们可以看到谁调用了 save/restore

保存/恢复堆栈

保存/恢复堆栈

让我们检查 _renderFill_renderStroke 的源码:

renderFill/renderStroke 源码

renderFill/renderStroke 源码

没有什么特别值得注意的,但 ctx.save/restore 对于填充和描边矩形确实是不必要的。我们没有使用任何像 strokeUniform 这样的功能(即使父组缩放,也能渲染统一的矩形边框),而且 fabric.js 已经为每个矩形的 render 调用调用了 ctx.save/restore()(即推入一个新的绘图状态)。所以在渲染描边或填充之前也这样做是浪费的。

我们再次保存并记录性能:

  • 页面 FPS 计量器现在达到 20fps(之前是 16fps)
  • 火焰图中的每个任务现在大约是 50ms(之前是 65ms)

这是另外约 25% 的改进,我们至少接近 24fps!

beginPathfillRect GPU 时间

根据 Bottom-Up 列表,我们的下一个目标是 _render,它使用 canvas 2D 操作渲染矩形:

矩形渲染源码

矩形渲染源码

使用 2D 路径命令组合渲染一个简单的矩形似乎是不必要的,当你可以直接做 strokeRectfillRect 并获得相同的结果时。让我们试试:

_render(ctx) {
  ctx.strokeRect(-this.width / 2, -this.height / 2, this.width, this.height);
  ctx.fillStyle = 'white';
  ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height);
}

注意,我们已经替换了 2D 路径命令和 _renderFill_renderStroke 函数,直接调用 strokeRectfillRect

我们再次保存并记录性能:

  • 页面 FPS 计量器现在达到 28fps(之前是 20fps)
  • 火焰图中的每个任务现在大约是 32ms(之前是 50ms)

这是另外约 40% 的改进,我们已经达到了 24fps 的目标!

这可能会让人惊讶,因为源码时间标签并没有显示显著的数字。然而,如果我们比较 GPU 时间,我们会发现从 39ms 显著下降到 23ms。很难确切地说为什么 GPU 执行 fillRect 指令比 beginPath 指令更快。我会大胆猜测,可能是因为一系列直接的 fillRectstrokeRect 指令对 GPU 来说更简单,可以并行执行。如果你有更好的见解,请留言。

GPU 时间之前

GPU 时间之前

GPU 时间之后

GPU 时间之后

矩阵变换

我们更新后的按总时间排序的 Bottom-Up 列表现在主要包括矩阵函数,特别是 transformcalcOwnMatrixmultiplyTransformMatrixArraymultiplyTransformMatrices。到目前为止,我们一直避免深入研究这些令人生畏的数学函数,但似乎现在是解决它们的时候了!

带有矩阵变换函数的 Bottom-Up

带有矩阵变换函数的 Bottom-Up

multiplyTransformMatrixArraymultiplyTransformMatrices 是原始的矩阵计算函数。

const multiplyTransformMatrixArray = (
  matrices: (TMat2D | undefined | null | false)[],
  is2x2?: boolean
) =>
  matrices.reduceRight(
    (product: TMat2D, curr) =>
      curr ? multiplyTransformMatrices(curr, product, is2x2) : product,
    iMatrix
  );
const multiplyTransformMatrices = (
  a: TMat2D,
  b: TMat2D,
  is2x2?: boolean
): TMat2D =>
  [
    a[0] * b[0] + a[2] * b[1],
    a[1] * b[0] + a[3] * b[1],
    a[0] * b[2] + a[2] * b[3],
    a[1] * b[2] + a[3] * b[3],
    is2x2 ? 0 : a[0] * b[4] + a[2] * b[5] + a[4],
    is2x2 ? 0 : a[1] * b[4] + a[3] * b[5] + a[5],
  ] as TMat2D;

除非有人知道如何释放恶魔般的位操作,否则很难进一步改进它们。即使如此,我怀疑也不可能有显著的变化。

如果我们看 transform 函数,我们注意到它内部调用了 calcOwnMatrix

transform(ctx: CanvasRenderingContext2D) {
  // ...
  const m = this.calcTransformMatrix(true);
  ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
}

尽管我们已经优化了 calcOwnMatrix 以避免使用缓存键,但它仍然占据了总时间的相当一部分。然而,我们意识到我们的矩形在动画中总是在平移,所以没有必要在 calcOwnMatrix 中计算完整的通用矩阵,考虑到缩放、旋转等。

calcOwnMatrix() {
  const center = this.getRelativeCenterPoint();
  const matrix = fabric.util.createTranslateMatrix(center.x, center.y);
  return matrix;
}

我们再次保存并记录性能:

  • 页面 FPS 计量器现在达到 36fps(之前是 28fps)
  • 火焰图中的每个任务现在大约是 22ms(之前是 50ms)

这再次是约 30% 的改进,现在基准测试运行顺畅!你的电脑可能也不会转起风扇了。我们做到了!

如果你想说“仅计算平移矩阵只是提高了基准动画的性能,而不是 fabric.js 本身”,我会说你提出了一个很好的观点。但我们仍然可以减少一般未旋转或未缩放的 canvas 对象的矩阵变换总数。

最终看看性能记录

这是我们旅程开始时的性能记录:

性能记录之前

性能记录之前

这是我们根据分析指导进行优化后的性能记录:

性能记录之后

性能记录之后

在时间线上,我们现在可以看到整体上黄色区域较少,这意味着我们的 JavaScript 执行减少了。在时间线的顶部,我们曾经有一条全宽的红线,表示连续丢弃的帧,而现在帧只是偶尔被丢弃。

火焰图中的每个任务现在需要大约 20ms,而不是 77ms,所以我们也不再有关于“长任务”的红色警告。最终的基准 FPS 计量器显示为 36fps,而 Chrome Devtools FPS 计量器甚至显示为 54fps。

FPS 计量器最终

FPS 计量器最终

分享于 2024-05-27

访问量 76

预览图片