如何通过PGO优化.NET程序实现性能提升?
摘要:引子 .NET 6 开始初步引入 PGO。PGO 即 Profile Guided Optimization,通过收集运行时信息来指导 JIT 如何优化代码,相比以前没有 PGO 时可以做更多以前难以完成的优化。 下面我们用 .NET 6
引子
.NET 6 开始初步引入 PGO。PGO 即 Profile Guided Optimization,通过收集运行时信息来指导 JIT 如何优化代码,相比以前没有 PGO 时可以做更多以前难以完成的优化。
下面我们用 .NET 6 的 nightly build 版本 6.0.100-rc.1.21377.6 来试试新的 PGO。
PGO 工具
.NET 6 提供了静态 PGO 和动态 PGO。前者通过工具收集 profile 数据,然后应用到下一次编译当中指导编译器如何进行代码优化;后者则直接在运行时一边收集 profile 数据一边进行优化。
另外由于从 .NET 5 开始引入了 OSR(On Stack Replacement),因此可以在运行时替换正在运行的函数,允许将正在运行的低优化代码迁移到高优化代码,例如替换一个热循环中的代码。
分层编译和 PGO
.NET 从 Core 3.1 开始正式引入了分层编译(Tiered Compilation),程序启动时 JIT 首先快速生成低优化的 tier 0 代码,由于优化代价小,因此 JIT 吞吐量很高,可以改善整体的延时。
然后随着程序运行,对多次调用的方法进行再次 JIT 产生高优化的 tier 1 代码,以提升程序的执行效率。
但是这么做对于程序的性能几乎没有提升,只是改善了延时,降低首次 JIT 的时间,却反而可能由于低优化代码导致性能倒退。因此我个人通常在开发客户端类程序的时候会关闭分层编译,而在开发服务器程序时开启分层编译。
然而 .NET 6 引入 PGO 后,分层编译的机制将变得非常重要。
由于 tier 0 的代码是低优化代码,因此更能够收集到完整的运行时 profile 数据,指导 JIT 做更全面的优化。
为什么这么说?
例如在 tier 1 代码中,某方法 B 被某方法 A 内联(inline),运行期间多次调用方法 A 后收集到了 profile 将只包含 A 的信息,而没有 B 的信息;又例如在 tier 1 代码中,某循环被 JIT 做了 loop cloning,那此时收集到的 profile 则是不准确的。
因此为了发挥 PGO 的最大效果,我们不仅需要开启分层编译,还需要给循环启用 Quick Jit 在一开始生成低优化代码。
进行优化
前面说了这么多,那 .NET 6 的 PGO 到底应该如何使用,又会如何对代码优化产生影响呢?这里举个例子。
测试代码
新建一个 .NET 6 控制台项目 PgoExperiment,考虑有如下代码:
interface IGenerator
{
bool ReachEnd { get; }
int Current { get; }
bool MoveNext();
}
abstract class IGeneratorFactory
{
public abstract IGenerator CreateGenerator();
}
class MyGenerator : IGenerator
{
private int _current;
public bool ReachEnd { get; private set; }
public int Current { get; private set; }
public bool MoveNext()
{
if (ReachEnd)
{
return false;
}
_current++;
if (_current > 1000)
{
ReachEnd = true;
return false;
}
Current = _current;
return true;
}
}
class MyGeneratorFactory : IGeneratorFactory
{
public override IGenerator CreateGenerator()
{
return new MyGenerator();
}
}
我们利用 IGeneratorFactory 产生 IGenerator,同时分别提供对应的一个实现 MyGeneratorFactory 和 MyGenerator。
