优化 WebKit 和 Safari 以适应 Speedometer 3.0

Speedometer 3.0 的推出是让所有人都能更快浏览网页、让 Web 开发者能够创建以前不可能实现的网站和 Web 应用迈出的重要一步。在本文中,我们将探讨 WebKit 团队如何根据 Speedometer 3.0 基准测试,在 WebKit 和 Safari 中进行性能优化。

为了实现这些改进,我们广泛利用了我们的性能测试基础设施。它与我们的持续集成系统集成,并提供安排 A/B 测试的功能。这使得工程师能够快速测试性能优化并发现新的性能退化。

改进工具

适当的工具支持是识别和解决性能瓶颈的关键。我们为 JavaScriptCore 采样分析器输出定义了新的内部 JSON 格式,以便离线转储和处理它们。它包括一个处理和生成 JavaScriptCore 热函数和热字节码分析的脚本。我们还添加了FlameGraph生成工具,用于转储的采样分析器输出,它将性能瓶颈可视化。此外,我们还在 Darwin 平台上添加了对 JITDump 生成的支持,以便在执行期间转储与 JIT 相关的信息。而且我们改进了生成的 JITDump 信息,以便于使用。这些工具的改进使我们能够快速识别 Speedometer 3.0 中的瓶颈。

改进 JavaScriptCore

修订巨晶型内联缓存 (IC)

当一个属性访问点观察到许多不同的对象类型和/或属性名时,巨晶型 IC 可提供更快的属性访问。我们观察到一些框架(如 React)包含巨晶型属性访问。这促使我们持续改进 JavaScriptCore 的巨晶型属性访问优化:扩展 put 巨晶型 IC,in 操作添加巨晶型 IC,并为巨晶型 IC 添加通用改进。

修订调用 IC

调用 IC 通过内联缓存调用目标来提供更快的函数调用。我们重新设计了调用IC两种不同的架构集成到即时 (JIT) 编译器的不同层级中。较低层级使用不生成任何 JIT 代码的调用 IC,而最高层级使用生成 JIT 代码且速度最快的调用 IC。代码生成时间和代码效率之间存在权衡,JavaScriptCore 在两者之间进行平衡以在不同层级实现最佳性能。

优化 JSON

Speedometer 3.0 还为我们的 JSON 实现带来了新的优化机会,因为它们包含比以前更多的非 ASCII 字符。我们使我们的快速 JSON 字符串化器能够处理 Unicode 字符。我们还仔细分析了配置文件数据,使 JSON.parse 以往 更快

调整内联启发式算法

在 JavaScript 中内联函数时存在许多权衡。例如,内联函数可以更积极地增加总字节码大小,并可能导致内存带宽成为新的瓶颈。CPU 中可用的指令缓存量也会影响给定内联策略的有效性。随着我们对 JavaScriptCore 进行更多改进(例如添加新的字节码指令和更改 DFG 的众多优化阶段),这些权衡的计算会随时间而变化。我们抓住新 Speedometer 3.0 基准测试发布的契机,根据在配备最新 JavaScriptCore 的现代 Apple 芯片 Mac 上收集的数据调整了内联启发式算法

使 JIT 代码销毁延迟

由于复杂的条件,当 GC 检测到 CodeBlock 和 JIT 代码已死亡时,JavaScriptCore 会立即销毁它们。由于这些销毁操作代价高昂,因此应在浏览器空闲时延迟处理。我们进行了更改,以便在大多数情况下,它们现在在空闲时间延迟销毁。

机会性清理和垃圾收集

此外,我们注意到在 Speedometer 2.1 和 3.0 的所有子测试中,有大量时间用于执行垃圾收集和增量清理。特别是,如果一个子测试在堆上分配了大量 JavaScript 对象,我们通常会在后续的子测试中花费大量时间来收集这些对象。这产生了几个影响:

  1. 当达到堆大小限制时,由于按需清理和垃圾收集,许多子测试的同步时间间隔增加。
  2. 在同步计时间隔结束后,由于异步垃圾收集或基于计时器的增量清理立即触发,许多子测试的异步时间间隔增加。
  3. 整体方差增加,这取决于基于计时器的增量清理和垃圾收集是否会落在任何给定子测试的同步或异步计时窗口内。

从宏观层面来看,我们意识到其中一些工作可以在渲染更新之间(即空闲时间)机会性地执行,而不是在子测试进行中触发。为了实现这一点,我们在 WebCore 中引入了一种新机制,向 JavaScriptCore 提供提示,以便在上次渲染更新完成后,直到给定截止时间(由到下一次渲染更新的估计剩余时间确定)之前,机会性地执行预定工作。机会性任务调度器还会考虑即将到来的零延迟计时器或待处理的 requestAnimationFrame 回调:如果观察到其中任何一个,它就不太可能调度机会性工作,以避免干扰即将进行的脚本执行。我们目前执行几种类型的机会性调度任务:

  • 增量清理:在机会性任务调度器之前,JavaScriptCore 中的增量清理是由一个周期性安排的 100 毫秒计时器自动触发的。这有时会在异步计时间隔期间触发增量清理,但也不够积极,无法阻止在脚本执行过程中按需清理。现在 JavaScriptCore 知道何时机会性地调度任务,它可以在渲染更新之间执行大部分增量清理,同时没有即将到来的计时器。清理过程也细化到每个标记块,这使我们能够在即将超出下一次估计渲染更新的截止时间时,提前停止机会性清理。
  • 垃圾收集:通过跟踪前几个周期中执行垃圾收集所花费的时间,我们能够根据自上次周期以来访问或分配的字节数,粗略估计下一次垃圾收集所需的时间。如果执行机会性调度任务的剩余时间长于此估计的垃圾收集持续时间,我们立即执行伊甸区收集完整垃圾收集。此外,我们将基于活动的垃圾收集集成到此新方案中,以便在适当的时机进行调度。

总体而言,这一策略使 Speedometer 3.0 的总性能提升了 6.5%*,显著减少了每个子测试的耗时;使 Speedometer 2.1 的总性能提升了 6.9%*,显著减少了几乎所有子测试的耗时。

* macOS 14.4,MacBook Air (M2, 2022)

针对实际使用场景的各种杂项优化

我们广泛审查了所有 Speedometer 3.0 子测试,并针对实际使用场景进行了许多优化。示例包括但不限于:使用空对象更快地执行 Object.assign,提高对象展开性能等。

改进 DOM 代码

改进 DOM 代码是 Speedometer 的同名之举,我们正是这样做的。例如,我们现在将 NodeType 直接存储在 Node 对象本身中,而不是依赖于虚拟函数调用。我们还使 DOMParser 使用快速解析器改进了对 li 元素的支持,并使DOMParser 不再构造冗余的 DocumentFragment。这些更改共同使 TodoMVC-JavaScript-ES5 提升了约 20%。我们还消除了快速解析器中的 O(n^2) 行为,使 Speedometer 3.0 整体提升了约 0.5%。我们还在 input 元素的构造和克隆过程中,使其惰性构造其用户代理影子树,后者是 Speedometer 3.0 中由于 Web 组件和 Lit 测试而新增的功能。我们去虚拟化了许多函数内联了更多函数,以减少函数调用开销。我们仔细审查了性能分析数据,并消除了热路径中的低效率问题,例如重复解析相同的 URL

改进布局和渲染

我们在布局和渲染代码中进行了一些重要的优化。首先,对 RenderObject 执行的大多数类型检查现在都使用内联枚举类而不是虚函数调用来完成,仅此一项就使 Speedometer 3.0 整体提升了约 0.7%。

改进样式引擎

我们还优化了计算 Web Animations 代码动画属性的方式。以前,在解析 transition: all 时,我们会枚举所有可动画属性。我们优化了这段代码,使其只枚举受影响的属性。这使 Speedometer 3.0 整体提升了约 0.7%。现在,动画元素可以在不完全重新计算其样式的情况下进行解析,除非出于正确性需要。

Speedometer 3.0 的内容,像许多现代网站一样,广泛使用了 CSS 自定义属性。我们实施了显著优化以提高它们的性能。现在,大多数自定义属性引用都通过快速缓存查找来解析,从而避免了耗时的样式解析属性解析。自定义属性现在存储在一个新的分层数据结构中,这也能减少内存使用。

WebKit 样式性能的一个关键组成部分是缓存(称为“匹配声明缓存”),它直接将一组 CSS 声明映射到最终的元素样式,从而避免为样式相同的元素重复昂贵的样式构建步骤。我们显著提高了此缓存的命中率。

我们还改进了作者影子树的样式性能,允许具有相同样式的树更有效地共享样式数据。

改进内联布局

我们还修复了内联布局引擎中的一些性能瓶颈。消除 Editor-TipTap 中的复杂文本路径带来了约 7% 的整体显著改进。要理解这项优化,WebKit 有两种不同的文本布局代码路径:简单文本路径,它使用低级字体 API 访问原始字体数据;以及复杂文本路径,它使用 CoreText 进行复杂的字形整形和连字处理。简单文本路径更快,但不能覆盖所有边缘情况。复杂文本路径覆盖全面,但比简单文本路径慢。

以前,每当使用 font-featurefont-variant 的非默认值时,我们都会采用复杂文本路径。这是因为从历史上看,简单文本路径不支持这些操作。然而,我们注意到简单文本路径中唯一仍然缺少的功能是 font-variant-caps。通过在简单文本路径中实现 font-variant-caps 支持,我们允许简单文本路径处理基准测试内容。这使得 Editor-TipTap 子测试提升了 4.5 倍,并使 Speedometer 3.0 整体提升了约 7%。

除了改进 WebKit 中文本内容的处理外,我们还与 CoreText 团队合作,避免在布局字形时进行不必要的工作。这使得 Speedometer 3.0 整体提升了约 0.5%,这些性能提升不仅会惠及 WebKit,还会惠及其他使用 CoreText 的框架和应用程序。

改进 SVG 布局

我们还对 SVG 进行了许多优化。Speedometer 3.0 在诸如 React-Stockcharts-SVG 等测试用例中包含相当多的 SVG 内容。我们曾花费大量时间通过创建 GraphicsContext、应用所有样式并在 CoreGraphics 中实际绘制笔触来计算重绘的边界框。在这里,我们采用了 Blink 的优化来近似边界框,使 React-Stockcharts-SVG 子测试提升了约 6%。我们还在 SVG 文本布局代码中消除了 O(n^2) 算法,这使一些 SVG 内容加载速度快了很多

提高 IOSurface 缓存命中率

我们进行的另一项优化是提高 IOSurface 的缓存命中率。IOSurface 是我们用于绘制网页内容的位图图像缓冲区。由于创建此对象相当昂贵,我们根据其尺寸缓存 IOSurface 对象。我们观察到缓存命中率相当低(约 30%),因此我们在 macOS 上将缓存大小从 64MB 增加到 256MB,并将缓存命中率提高了 2.7 倍,达到约 80%,使 Speedometer 3.0 的总分提高了约 0.7%。实际上,这意味着画布操作和其他绘制操作的延迟更低。

减少 GPU 进程的等待时间

以前,我们需要从 Web 进程到 GPU 进程的同步 IPC 调用,以确定 CoreAnimation 释放的现有缓冲区中哪些适合用于下一帧。我们对此进行了优化,让 GPUP 直接选择(或分配)一个合适的缓冲区,并将所有传入的绘图命令导向正确的目的地,而无需任何响应。我们还将任何新分配的IOSurface 句柄通过后台辅助线程进行传递,而不是阻塞 Web 进程的主线程。

改进合成

合成层的更新现在进行批处理,并在渲染更新期间刷新,而不是在每次布局时计算。这显著降低了脚本引起的布局刷新开销。

改进 Safari

除了我们在 WebKit 中进行的优化之外,Safari 也进行了一些优化。

优化自动填充代码

我们关注的一个领域是 Safari 的自动填充代码。Safari 使用 JavaScript 实现其自动填充逻辑,这段执行时间显示在 Speedometer 3.0 配置文件中。我们通过等待页面内容稳定后再执行一些自动填充工作,显著加快了这段代码的速度。这包括在页面加载完成后(如果可能)合并处理新聚焦的字段,并将低优先级工作从加载和呈现页面的关键路径中移出,以应对加载时间较长的页面。这使得 TodoMVC-React-Complex-DOM 提升了约 13%,其他许多测试提升了约 1%,使 Speedometer 3.0 的总分提升了约 0.9%。

配置文件引导优化

除了进行上述代码更改外,我们还调整了我们的配置文件引导优化,以考虑 Speedometer 3.0。这使我们能够将 Speedometer 3.0 的总分提高 1%~1.6%。值得注意的是,我们观察到代码更改和配置文件引导优化之间存在复杂的交互。有时,当我们消除或降低特定代码路径的运行时成本时,我们不会立即观察到 Speedometer 3.0 总分的即时提升,直到配置文件引导优化的每日更新生效。这是因为修改或新添加的代码必须从配置文件引导优化中受益,才能显示出可衡量的差异。在某些情况下,我们甚至观察到性能优化最初会导致性能下降,直到配置文件引导优化更新。

结果

通过所有这些以及数十项其他优化,我们成功将 Safari 17.0 到 Safari 17.4 之间的 Speedometer 3.0 总分提升了约 60%。尽管单项改进通常不到 1%,但随着时间的推移,它们累积起来产生了巨大的影响。由于其中一些优化也使 Speedometer 2.1 受益,Safari 17.4 在 Speedometer 2.1 上的速度也比 Safari 17.0 快约 13%。我们很高兴能为用户带来这些性能提升,使 Web 开发者能够构建比以往任何时候都更具响应性和更快的网站和 Web 应用。