利用 PGO 提升 .NET 程序性能

引子

.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。注意实现类并没有标注 sealed 因此 JIT 并不知道是否能做去虚拟化(devirtualization),于是生成的代码会老老实实查虚表。

然后我们编写测试代码:

[MethodImpl(MethodImplOptions.NoInlining)]
int Test(IGeneratorFactory factory)
{var generator = factory.CreateGenerator();var result = 0;while (generator.MoveNext()){result += generator.Current;}return result;
}var sw = Stopwatch.StartNew();
var factory = new MyGeneratorFactory();for (var i = 0; i < 10; i++)
{sw.Restart();for (int j = 0; j < 1000000; j++){Test(factory);}sw.Stop();Console.WriteLine($"Iteration {i}: {sw.ElapsedMilliseconds} ms.");
}

你可能会问为什么不用 BenchmarkDotNet,因为这里要测试出 分层编译和 PGO 前后的区别,因此不能进行所谓的“预热”。

进行测试

测试环境:

  • CPU:2vCPU Intel(R) Xeon(R) Platinum 8171M CPU @ 2.60GHz

  • 内存:4G

  • 系统:Ubuntu 20.04.2 LTS

  • 程序运行配置:Release

不使用 PGO

首先采用默认参数运行:

dotnet run -c Release

得到结果:

Iteration 0: 740 ms.
Iteration 1: 648 ms.
Iteration 2: 687 ms.
Iteration 3: 639 ms.
Iteration 4: 643 ms.
Iteration 5: 641 ms.
Iteration 6: 641 ms.
Iteration 7: 639 ms.
Iteration 8: 644 ms.
Iteration 9: 643 ms.

Mean = 656.5ms

你会发现 Iteration 0 用时比其他都要长一点,这符合预期,因为一开始执行的是 tier 0 的低优化代码,然后随着调用次数增加,JIT 重新生成 tier 1 的高优化代码。

然后我们关闭分层编译看看会怎么样:

dotnet run -c Release /p:TieredCompilation=false

得到结果:

Iteration 0: 677 ms.
Iteration 1: 669 ms.
Iteration 2: 677 ms.
Iteration 3: 680 ms.
Iteration 4: 683 ms.
Iteration 5: 689 ms.
Iteration 6: 677 ms.
Iteration 7: 685 ms.
Iteration 8: 676 ms.
Iteration 9: 673 ms.

Mean = 678.6ms

这下就没有区别了,因为一开始生成的就是 tier 1 的高优化代码。

我们看看 JIT dump:

        push    rbppush    r14push    rbxlea     rbp,[rsp+10h]
;   factory.CreateGenerator()mov     rax,[rdi]mov     rax,[rax+40h]call    qword ptr [rax+20h]mov     rbx,rax
;   var result = 0xor     r14d,r14d
;   if (generator.MoveNext())mov     rdi,rbxmov     r11,7F3357AE0008hmov     rax,7F3357AE0008hcall    qword ptr [rax]test    eax,eaxje      short LBL_1LBL_0:
;   result += generator.Current;mov     rdi,rbxmov     r11,7F3357AE0010hmov     rax,7F3357AE0010hcall    qword ptr [rax]add     r14d,eax
;   if (generator.MoveNext())mov     rdi,rbxmov     r11,7F3357AE0008hmov     rax,7F3357AE0008hcall    qword ptr [rax]test    eax,eaxjne     short LBL_0LBL_1:
;   return result;mov     eax,r14dpop     rbxpop     r14pop     rbpret

我用注释标注出了生成的代码中关键地方对应的 C# 写法,还原成 C# 代码大概是这个样子:

var generator = factory.CreateGenerator();
var result = 0;do
{if (generator.MoveNext()){result += generator.Current;}else{return result;}
} while(true);

这里有不少有趣的地方:

  • while 循环被优化成了 do-while 循环,做了一次 loop inversion,以此来节省一次循环

  • generator.CreateGeneratorgenerator.MoveNext 以及 generator.Current 完全没有去虚拟化

  • 因为没有去虚拟化因此也无法做内联优化

这已经是 tier 1 代码了,也就是目前阶段 RyuJIT(.NET 6 的 JIT 编译器)在不借助任何指示编译器的 Attribute 以及 PGO 所能生成的最大优化等级的代码。

使用 PGO

这一次我们先看看启用动态 PGO 能得到怎样的结果。

为了使用动态 PGO,现阶段需要设置一些环境变量。

export DOTNET_ReadyToRun=0 # 禁用 AOT
export DOTNET_TieredPGO=1 # 开启分层 PGO
export DOTNET_TC_QuickJitForLoops=1 # 为循环启用 Quick Jit

然后运行即可:

dotnet run -c Release

得到如下结果:

Iteration 0: 349 ms.
Iteration 1: 190 ms.
Iteration 2: 188 ms.
Iteration 3: 189 ms.
Iteration 4: 190 ms.
Iteration 5: 190 ms.
Iteration 6: 189 ms.
Iteration 7: 188 ms.
Iteration 8: 191 ms.
Iteration 9: 189 ms.

Mean = 205.3ms

得到了惊人的性能提升,只用了先前的 31% 的时间,相当于性能提升 322%。

然后我们试试静态 PGO + AOT 编译,AOT 负责在编译时预先生成优化后的代码。

为了使用静态 PGO,我们需要安装 dotnet-pgo 工具生成静态 PGO 数据,由于正式版尚未发布,因此需要添加如下 nuget 源:

<configuration><packageSources><add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" /><add key="dotnet-tools" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json" /><add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" /><add key="dotnet6" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet6/nuget/v3/index.json" /><add key="dotnet6-transport" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet6-transport/nuget/v3/index.json" /></packageSources>
</configuration>

安装 dotnet-pgo 工具:

dotnet tool install dotnet-pgo --version 6.0.0-* -g

先运行程序采集 profile:

export DOTNET_EnableEventPipe=1
export DOTNET_EventPipeConfig=Microsoft-Windows-DotNETRuntime:0x1F000080018:5
export DOTNET_EventPipeOutputPath=trace.nettrace # 追踪文件输出路径
export DOTNET_ReadyToRun=0 # 禁用 AOT
export DOTNET_TieredPGO=1 # 启用分层 PGO
export DOTNET_TC_CallCounting=0 # 永远不产生 tier 1 代码
export DOTNET_TC_QuickJitForLoops=1
export DOTNET_JitCollect64BitCounts=1dotnet run -c Release

等待程序运行完成,我们会得到一个 trace.nettrace 文件,里面包含了追踪数据,然后利用 dotnet-pgo 工具产生 PGO 数据。

dotnet-pgo create-mibc -t trace.nettrace -o pgo.mibc

至此我们就得到了一个 pgo.mibc,里面包含了 PGO 数据。

然后我们使用 crossgen2,在 PGO 数据的指导下对代码进行 AOT 编译:

dotnet publish -c Release -r linux-x64 /p:PublishReadyToRun=true /p:PublishReadyToRunComposite=true /p:PublishReadyToRunCrossgen2ExtraArgs=--embed-pgo-data%3b--mibc%3apgo.mibc

你可能会觉得这一系列步骤里面不少参数和环境变量都非常诡异,自然也是因为目前正式版还没有发布,因此名称和参数什么的都还没有规范化。

编译后我们运行编译后代码:

cd bin/Release/net6.0/linux-x64/publish
./PgoExperiment

得到如下结果:

Iteration 0: 278 ms.
Iteration 1: 185 ms.
Iteration 2: 186 ms.
Iteration 3: 187 ms.
Iteration 4: 184 ms.
Iteration 5: 187 ms.
Iteration 6: 185 ms.
Iteration 7: 183 ms.
Iteration 8: 180 ms.
Iteration 9: 186 ms.

Mean = 194.1ms

相比动态 PGO 而言,可以看出第一次用时更小,因为不需要经过 profile 收集后重新 JIT 的过程。

我们看看 PGO 数据指导下产生了怎样的代码:

        push    rbppush    r15push    r14push    r12push    rbxlea     rbp,[rsp+20h]
;   if (factory.GetType() == typeof(MyGeneratorFactory))mov     rax,offset methodtable(MyGeneratorFactory)cmp     [rdi],raxjne     near ptr LBL_11
;   IGenerator generator = new MyGenerator()mov     rdi,offset methodtable(MyGenerator)call    CORINFO_HELP_NEWSFASTmov     rbx,raxLBL_0:
;   var result = 0xor     r14d,r14djmp     short LBL_4LBL_1:
;   if (generator.GetType() == typeof(MyGenerator))mov     rdi,offset methodtable(MyGenerator)cmp     r15,rdijne     short LBL_6
;   result += generator.Current
LBL_2:mov     r12d,[rbx+0Ch]LBL_3:add     r14d,r12dLBL_4:
;   if (generator.GetType() == typeof(MyGenerator))mov     r15,[rbx]mov     rax,offset methodtable(MyGenerator)cmp     r15,raxjne     short LBL_8
;   if (generator.ReachEnd)mov     rax,rbxcmp     byte ptr [rax+10h],0jne     short LBL_7
;   generator._current++mov     eax,[rbx+8]inc     eaxmov     [rbx+8],eax
;   if (generator._current > 1000)cmp     eax,3E8hjg      short LBL_5mov     [rbx+0Ch],eaxjmp     short LBL_2LBL_5:
;   ReachEnd = truemov     byte ptr [rbx+10h],1jmp     short LBL_10LBL_6:
;   result += generator.Currentmov     rdi,rbxmov     r11,7F5C42A70010hmov     rax,7F5C42A70010hcall    qword ptr [rax]mov     r12d,eaxjmp     short LBL_3LBL_7:xor     r12d,r12djmp     short LBL_9LBL_8:
;   if (generator.MoveNext())mov     rdi,rbxmov     r11,7F5C42A70008hmov     rax,7F5C42A70008hcall    qword ptr [rax]mov     r12d,eaxLBL_9:test    r12d,r12djne     near ptr LBL_1LBL_10:
;   return true/falsemov     eax,r14dpop     rbxpop     r12pop     r14pop     r15pop     rbpretLBL_11:
;   factory.CreateGenerator()mov     rax,[rdi]mov     rax,[rax+40h]call    qword ptr [rax+20h]mov     rbx,raxjmp     near ptr LBL_0

同样,我用注释标注出来了关键地方对应的 C# 代码,这里由于稍微有些麻烦因此就不在这里还原回大概的 C# 逻辑了。

同样,我们发现了不少有趣的地方:

  • 通过类型测试判断 factory 是否是 MyGeneratorFactorygenerator 是否是 MyGenerator

    • 如果是,则跳转到一个代码块,这里面将 IGeneratorFactory.CreateFactoryIGenerator.MoveNext 以及 IGenerator.Current 全部去虚拟化,这也叫做 guarded devirtualization,并且全部进行了内联

    • 否则跳转到一个代码块,这里面的代码等同于不开启 PGO 的 tier 1 代码

    • 这里做了一次 loop cloning

  • while 循环同样被优化成了 do-while,做了一次 loop inversion

相比不开启 PGO 而言,显然优化幅度就大了很多。

用一张图来对比首次运行、总体用时(毫秒)和比例(均为越低越好),从上至下分别是默认、关闭分层编译、动态 PGO、静态 PGO:

总结

有了 PGO 之后,之前的很多性能经验就不再有效。最典型的例如在用 List<T> 或者 Array 的时候 IEnumerable<T>.Where(pred).FirstOrDefault() 比 IEnumerable<T>.FirstOrDefault(pred) 快,这是因为 IEnumerable<T>.Where 在代码层面手动做了针对性的去虚拟化,而 FirstOrDefault<T> 没有。但是在 PGO 的辅助下,即使不需要手动编写针对性去虚拟化的代码也能成功去虚拟化,而且不仅仅局限于 List<T> 和 Array,对所有实现 IEnumerable<T> 的类型都适用。

借助 PGO 我们可以预见大幅度的执行效率提升。例如在 TE-benchmark 非官方测试的 plaintext mvc 中,对比第一次请求时间(毫秒,从运行程序开始计算,越低越好)、RPS(越高越好)和比例(越高越好)结果如下:

另外,PGO 在 .NET 6 中尚处于初步阶段,后续版本(.NET 7+)中将会带来更多基于 PGO 的优化。

至于其他的 JIT 优化方面,.NET 6 同样做了大量的改进,例如更多的 morphing pass、jump threading、loop inversion、loop alignment、loop cloning 等等,并且优化了 LSRA 和 register heuristic,以及解决了不少导致 struct 出现 stack spilling 的情况,以使其一直保持在寄存器中。但是尽管如此,RyuJIT 在优化方面仍有很长的路要走,例如 loop unrolling、forward subsitituion 以及包含关系条件的 jump threading 之类的优化 .NET 6 目前并不具备,这些优化将会在 .NET 7 或者之后到来。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/300130.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

不作死就不会死,盘点那些死于自己发明的发明家

全世界只有3.14 %的人关注了青少年数学之旅还有他们死于自己的发明发明呼吸器&#xff1a;缺氧死亡 1772年&#xff0c;法国人希厄尔弗莱米奈特发明了可用于潜水的循环式再呼吸器&#xff0c;让呼出的气体实现循环。这是世界上第一个自持呼吸装置。不幸的是&#xff0c;弗莱米奈…

如何在Domino中使用文本文件注册用户

具体的步骤如下&#xff1a; 1。先用以下的格式创建一个文本文件&#xff08;每个用户一行&#xff09;: ZhangSan;;;;passw0rd;e:\id\603server;zhangsan.id;603server/r6domain;;zhangsan.nsf;;;;;;;;;;;; LiSi;;;;passw0rd;e:\id\603server;lisi.id;603server/r6domain;;lis…

盘点那些世间顶级直男hhhhhh | 今日最佳

全世界只有3.14 % 的人关注了青少年数学之旅【1】【2】【3】【4】【5】【6】【7】【8】【9】

android的单选按钮xml语法,android 控件 单项选择(RadioGroup,RadioButton)

1、继承关系和子类&#xff1a;2、定义&#xff1a;RadioButton表示单个圆形单选框&#xff0c;而RadioGroup是可以容纳多个RadioButton的容器3、XML重要属性&#xff1a;4、重要方法&#xff1a;5、实战&#xff1a;布局文件android:layout_width"wrap_content"andr…

[006] 了解 Roslyn 编译器

维基百科对编译器的解释是&#xff1a;编译器是一种程序&#xff0c;它将某种编程语言编写的源代码(原始语言)转换成另一种编程语言(目标语言)。编译是从源代码(通常为高阶语言)到能直接被计算机或虚拟机执行的目标代码(通常为低阶语言或机器语言)的翻译过程。在 .NET 平台中&a…

这个让人看跪了的设计!实力证明,数学才是世界的最终boss!

全世界只有3.14 %的人关注了青少年数学之旅最近&#xff0c;有不少购买了年度数学艺术礼盒《数学之旅闪耀人类的54个数学家》的小伙伴&#xff0c;已经按捺不住内心的激动&#xff1a;但超模君秉承着“慢工出细活”的态度&#xff0c;多次亲自到工厂对扑克牌的细节进行把关&…

[007] 详解 .NET 程序集

上一篇我们介绍了 Roslyn 编译器&#xff0c;我们知道&#xff0c;我们编写的 C#/VB 代码经过 Roslyn 编译器编译后会生成程序集文件。按照之前讲的 .NET 执行模型的顺序&#xff0c;这一篇我具体讲讲程序集。1什么是程序集我们编写的 C# 代码经过编译会生成 .dll 或 .exe 文件…

21岁就破解困扰人们300年难题的天才,却一生坎坷,怀才不遇,至死还得不到认可...

这不是难题本来就是无解何谓数学&#xff1f;数学家Eduardo曾这样回答“数学是永恒&#xff0c;是真理&#xff0c;是一切的答案。”回首往昔数学始终伴随我们左右纵横交错的几何、繁琐复杂的运算难以求解的方程、无从下手的猜想......尽管在数学道路上有多么的坎坷、崎岖、变化…

android 百度地图 在线建议查询,百度地图SDK-----百度地图在线建议查询,结合AutoCompleteTextView实现搜索下拉列表。...

实现效果图 如下这是百度地图 POISearch的效果&#xff0c;这是自己写的效果首先实现这个功能主要用到了两个部分第一个部分 AutoCompleteTextView具体使用参考 http://blog.csdn.net/iamkila/article/details/7230160第二个部分 百度地图的在线搜索建议功能。http://developer…

共享内存 Actor并发模型到底哪个快?

HI&#xff0c;前几天被.NET圈纪检委懒得勤快问到共享内存和Actor并发模型哪个速度更快。前文传送门&#xff1a;《三分钟掌握共享内存 & Actor并发模型》说实在&#xff0c;我内心10w头羊驼跑过.....先说结论1.首先两者对于并发的风格模型不一样。共享内存利用多核CPU的优…

web service

一、Web Service简介 1.1、Web Service基本概念 Web Service也叫XML Web Service WebService是一种可以接收从Internet或者Intranet上的其它系统中传递过来的请求&#xff0c;轻量级的独立的通讯技术。是:通过SOAP在Web上提供的软件服务&#xff0c;使用WSDL文件进行说明&#…

来自爸妈的敷衍问候!| 今日最佳

全世界只有3.14 % 的人关注了青少年数学之旅

Android系统如何实现UI的自适应

2019独角兽企业重金招聘Python工程师标准>>> 做Android应用的人都知道&#xff0c;要一个apk适用多个不同的手机屏幕是很容易的&#xff0c;就是在项目的res文件夹下面有多套相关的资源文件。程序运行的 时候&#xff0c;Android系统会根据当前设备的信息去加载不同…

自定义EventSource(二)PollingCounter

在自定义EventSource时&#xff0c;可以使用四种EventCounter&#xff1a;EventCounter&#xff1a;统计指标收集器&#xff0c;比如平均值&#xff0c;最大值&#xff0c;最小值PollingCounter&#xff1a;自定义统计指标收集器&#xff0c;通过自定义统计方法的方式实现对指标…

这9个人气超高的公众号,你还没关注吗?

有些人&#xff0c;生活离不开朋友圈朋友圈是他们展示自我、观察世界的一扇窗户而有些人&#xff0c;从来也不点开朋友圈他们更愿意利用地铁上的零散化时间看点有意义、有知识的公众号推送完成对自我知识库的更新今天为大家推荐以下优质订阅号Kindle杂志公社ID&#xff1a;Mag1…

客户要求ASP.NET Core API返回特定格式,怎么办?

当ASP.NET Core API提供给到外部系统使用时&#xff0c;在某些情况下&#xff0c;可能需要根据这些外部系统的要求来格式化数据。比如&#xff0c;客户就要求API的返回值属性名必须是PascalCase&#xff08;如UserName&#xff09;&#xff0c;但是这些API需要同时提供给内部系…

史上最神奇的公式,竟然藏着这么多秘密!

全世界只有3.14 % 的人关注了青少年数学之旅前两天&#xff0c;我们的【欧拉公式—数学史上最强公式】数学经典文化衫首发众筹&#xff01;没想到短短几天时间文化衫就售罄了&#xff0c;其火爆程度远远超出了超模君的预期&#xff0c;甚至连不少“白嫖党”遇上这款文化衫后都情…

HiccDS共享音乐列表

Donet 第七组共享音乐列表200.7.7.18

html插入图片和文字,HTML第三课文字图片插入

HTML学习班第三课文字与图片的插入朋友们这一课我们一起来学习一下“文字与图片”的插入&#xff1a;一&#xff1a;首先我们先看一下插入文字的语法&#xff1a;例1&#xff1a;“朋友们好”这几个字的代码&#xff1a;朋友们好朋友们好说明&#xff1a;朋友们不难看出“朋友们…