掘金 后端 ( ) • 2024-04-14 09:53

自从我上次更新大家有关.NET在Bing技术栈中的状态以来已经过去了一年多,尤其是位于核心位置的高性能工作流执行引擎。在这段时间里,这个引擎的应用范围只增不减,特别是随着Microsoft Copilot的发布。虽然我们的工作流引擎起源于Bing,但现在可以说它支撑了许多Microsoft应用程序中搜索和数据栈的相当大一部分。

我们从.NET 8的早期预览版开始进行测试。尽管在.NET 8的核心库中有明显的性能递增好处,但促使我们升级的最大因素是对动态PGO(Profile Guided Optimization,配置文件指导的优化)的显著改进。这一功能自.NET 6起就以预览形式存在,在.NET 8中,改进足够显著,以至于默认启用了这一功能。

之前的帖子:

迁移Bing工作流引擎到 .NET5

.NET 5 升级到 .NET 7,再次为必应带来性能提升

动态PGO

鉴于我们的规模,有时会有一些功能开箱即用就能在几乎所有应用中表现良好,但我们仍会给予额外的考虑。

在进程启动时,这个服务器会加载数千个合作伙伴组件,这些组件包含了我们执行工作流程所需的插件。这大约有2GB的代码,其中很多都需要即时编译(JIT)。当第一个用户查询请求击中服务器时,它需要能够在几百毫秒内提供答案,同时避免因即时编译而导致的暂停。

人们自然会想到,像NGEN和Ready2Run这样的技术是否会有所帮助。我们尝试过预编译代码,并取得了不同程度的成功。最终,我们发现最佳的性能与启动时间最小化的平衡是让JIT自行处理,结合在以往运行中检测到的特别关键的方法列表进行预编译。我们在启动时并行加载其他数据时做这件事。我们还在接受真实用户流量之前,通过系统推送一些测试查询。

动态PGO通过根据需要重新编译某些代码来提高运行时代码的质量。理论上,这可以帮助我们改善延迟,但我们需要彻底测试它对启动和第一个用户查询的影响。关于动态PGO的工作原理,你可以在其他地方阅读到,但简而言之:

动态PGO会在新即时编译的代码中加入一些轻量级指令,以记录性能特征并建立一个重新编译候选队列。可能的优化包括:

  • 内联
  • 方法去虚拟化
  • 循环优化
  • 尾递归移除
  • 优化内存中的代码布局以优化处理器缓存
  • ……还有更多

在测试中,我们看到了两个主要结果:

  1. 稳定状态下性能显著提升。
  2. 我们最大的工作负载在第一个用户查询时有一个小的延迟峰值,这表明要么有些方法根本没有编译,要么是因为分析的影响太大。这使得总延迟超出了最高限制。对于我们较小的工作负载来说,这不是问题。 下面的延迟图显示与基线相比,延迟有一个大幅度的峰值:

(请注意,本文档中的图表已删除了特定的内部指标信息,但形状的变化应能让您大致了解相对变化情况。)

在深入调查和来自 .NET 团队的帮助后,我们发现在我们遇到第一个用户查询之前,重新编译(re-jit)队列的大小已经增长到超过300,000个方法!正如我所说,我们的代码量非常大。最终,当进程运行几个小时后,我们通常会有超过两百万个方法。

作为响应,我们实施了一些小的改动:

  1. 增加了额外的预热查询,给更多方法机会进行即时编译(jit)和重新编译(re-jit)。
  2. 在接受用户流量之前稍作暂停,以便让队列有时间排空(我们起初将其静态配置,但有一个事件可以让您监控 JIT 队列大小)。
  3. 一些针对我们超大场景的自定义 JIT 设置:
REM 启用 64 位计数器以稍微减少错误共享
set DOTNET_JitCollect64BitCounts=1

REM 移除启动分层编译的延迟
REM (目标是减少总体上执行带有检测代码的方法所花费的时间)
set DOTNET_TC_CallCountingDelayMs=0

有了这些变化,延迟峰值消失了,现在我们可以享受稳定状态下的性能改进。

性能提升

我们所见到的在多个性能特征上的改进,或许是自从从.NET Framework迁移到.NET 5以来最显著的一次。

我们执行一个查询所消耗的CPU周期数减少了13%。换句话说,我们的效率提高了13%,或者再换一种方式表达:这意味着我们需要购买的机器数量减少了13%,以应对不断增长的需求。下面的图表显示了执行工作流程时总CPU时间的下降。

一个图表显示了查询执行中CPU时间的减少。

受到gen0或gen1垃圾回收影响的查询比例下降了20%(我们几乎从不会有查询受到gen2 GC的影响,因为A. 我们的内存管理策略避免了这一点,B. 我们采取了额外的措施以确保机器在gen2 GC迫在眉睫时不会服务用户——这是极其罕见的)。

这个图表显示了GC对查询影响的相对差异:

图表显示了受垃圾回收影响的查询百分比的下降。许多其它指标也显示出查询执行各个阶段有类似上述图表的改进。一些内部延迟降低了超过25%。查询服务的很大一部分是等待其他后端,因此总体查询服务时间的改进幅度稍微小一些,大约8%——但这仍然非常显著!

总结

总的来说,这次的 .NET 发布对我们来说既稳固又相对容易。我们在延迟上取得了改进,在效率上也有了巨大提升,这将在未来几年为我们节省数百万美元。尽管在我们庞大的代码库和严格的延迟要求下,动态 PGO 需要一些微调,但在运行时性能方面,它确实是一个巨大的胜利。

现在,我需要开始为 .NET 9 做准备了……希望在明年能再次向你们报告!

原文信息

作者:Ben Watson Principal Software Engineer, Bing Platform

链接:https://devblogs.microsoft.com/dotnet/bing-on-dotnet-8-the-impact-of-dynamic-pgo/