这篇文章是由JIT部分的。Net 6性能提升。你可以先看看序言:在。Net 6性能改进系列-前言。
本文是翻译,内容很多,主要是简短的示例代码,最好在PC上阅读。
JIT相关的提升JIT代码生成是构建程序的基础,JIT编译器生成优秀代码带来的性能提升很可能是翻倍的,在。Net 6 JIT。JIT将IL(中间语言)转换成汇编代码,AOT(预编译)是Crossgen2和R2R格式(ReadyToRun)的一部分。
它是是否JIT的基础。Net程序性能非常好。让我们从内联和非虚拟化开始。无独有偶,前几天我专门写了一篇关于方法内联的文章,水平有限,文笔也比较简单。在这里,我只是碰巧看到了大人物的文章。
内联意味着调用了原始方法,但现在没有调用该方法。方法内部的代码直接移到被调用方法的位置,降低了调用方法的成本。原始方法内部的代码可以进一步优化。
使用系统。Runtime . CompilerServices使用BenchmarkDotNet。属性;命名空间net6perf。JIT { public class JIT { public void ComputeTest(){ for(int I=0;i 1024I){ int value=Compute();int tmp=valueint tmp2=tmp} } public static int Compute()=ComputeValue(123)* 11;私有静态int ComputeValue(int length)=length * 7;public void ComputeInlineTest(){ for(int I=0;i 1024I){ int value=ComputeInline();int tmp=valueint tmp2=tmp} } public static int ComputeInline()=ComputeValueInline(123)* 11;private static int ComputeValueInline(int length)=length * 7;}} 内联比较。NET框架4.8/。NET 5.0/。NET 6.0,而且发现差别不大。就是因为处决人数少。
与相比。NET框架4.8/。NET 5.0/。Net 6.0,发现差别不大。就是因为执行的次数少,而且版本的。NET 6这里还是预览版7,不是RC1。RC1一直没有在官网发布,但现在是每天晚上建的版本,主要下载失败。
我们来看看生成的汇编代码3360。
;net6perf。JIT.JIT.Compute() sub rsp,28;7B123的十六进制MOVECX,7BCALL NET 6 perf。JIT。JIT。计算值(int 32);0B是11的十六进制数;Imul将eax寄存器的值(7B)乘以0B,imul eax,0B加rsp,28 ret代码22的总字节数;net6perf。JIT . JIT . computevalue(int 32);Imul将ecx寄存器的值乘以7,并将结果放入eax寄存器imul eax,ecx,7 re。
t; Total bytes of code 4读完上边的汇编代码,发现在Compute方法中,将123转为十六进制7B,并将7B加载到eax寄存器上调用ComputeValue,ecx是保存参数的值,将ecx的值乘以7,保存到eax寄存器上将eax寄存器的值乘以11(十六进制0B),将计算的值返回.如果开启内联的话.生成的汇编代码:
; Program.Compute(); 开启内联,会将123*11*7=9471, 9471的十六进制为24FF,这样没有了调用方法的开销,也没有了乘法的开销 mov eax,24FF ret从上边的汇编代码,看到没有乘法计算和方法调用,只是将24FF加载到eax,进行返回.内联是很强大的优化.
看到内联优化的强大,也得看到内联优化并不全是都是正面的,如果内联太多,方法中的代码就会膨胀,这可能会带来严重的问题,在某些基准测试结果看起来比较好,但会带来一些不良的影响.让我们假设Int32.Parse(生成的汇编代码大小1000字节),假设Parse方法总是内联的.每个调用的Parse方法,都将多出1000个字节汇编代码,如果有100个地方调用Parse,那先用的汇编代码大小,就是1000*100,这意味程序集代码需要更多的内存,如果是AOT这需要更多的磁盘空间.还有一些其他的影响,计算机使用快速的和有限的指令缓存存储要运行的代码.
如果要从100个地方调用1000字节大小的代码,那么可能在每个调用的位置都需要重新加载代码到缓存中.这时候内联会让程序运行的更慢.
内联是强大的,但需要谨慎使用.JIT必须要快速的权衡要不要使用内联.在这种情况下:
dotnet/runtime#50675dotnet/runtime#51124dotnet/runtime#52708dotnet/runtime#53670dotnet/runtime#55478通过5个教程,可以理解在JIT的改进,学会内联,如使用常量,让不内联的结构变为内联.下面使用标记内联:
Utf8Formatter.TryFormat在.Net 5和.Net 6性能测试结果对比
首先Utf8Formatter.TryFormat变得更快了,但在.Net 6中Utf8Formatter本身代码几乎没有做任何调整来提高这个基准测试的性能.但测试结果比.Net 5提高35%(我本地测试是41%)左右. 在.Net 5和.Net 6中TryFormat都是调用的TryFormatUInt64,只是在.Net 6的调用TryFormatUInt64的方法上加上了标记内联,还有就是StrandFormat在.Net 5中JIT认为没必要对构造函数进行内联.
内联和去虚拟化(devitalization)是密切相关的.在JIT接受虚方法和接口调用时,是要静态确定调用的最终目标方法并去执行,从而减少了虚拟分发的开销.如果去除虚拟化,就可以进行内联优化.接着看下面的例子:
在.Net 6和.Net Framework4,8对比性能相差2倍多.
在JIT中可以对EqualityComparer<T>.Default.Equals进行去虚拟化处理,对于同级的Comparer<T>.Default.Compare(主要是指.Net Framework 4.8)没有实现去虚拟化.具体看这里 dotnet/runtime#48160 ,下面这个示例是Compare对ValueTuple的元素进行比较.因为生成的汇编代码偏长,这里就不进行汇编代码对比了.
在.Net 6和.Net Framework4,8 Compare对比性能相差5倍多.
在去虚拟化的改进已经超出常用的内联化的方法.看下面这个基准测试:
在.Net 6和.Net Framework4,8 GetLength在去虚拟化后,对比性能相差太多倍
现在把代码进行调整,再看.Net 5和.Net 6测试结果:
因为这个是单次执行的时间,相差不大了,下边看一下.Net 5和.Net 6生成的汇编代码:
.Net 5汇编代码:
; net6perf.JIT.ValueTupleLengthTest2.GetLength() push rsi sub rsp,30 vzeroupper vxorps xmm0,xmm0,xmm0 vmovdqu xmmword ptr
; net6perf.JIT.ValueTupleLengthTest2.GetLength() push rsi sub rsp,30 vzeroupper vxorps xmm0,xmm0,xmm0 vmovupd
当然,在许多情况下,JIT编译器不能确定要调用的具体目标,便不能去去虚拟化和内联.
在.Net 6中我喜欢的特性就是PGO(profile-guided optimization,使用配置文件引导优化),PGO不是新的技术.PGO被实现在各种技术栈(原文是development stacks,在C/C++都有PGO),PGO在.Net体系也存在了多年,只是展现的方式不同,在.Net 6 PGO实现有些不同,我认为是"动态PGO",PGO的思想是开发者先编译程序,然后利用特殊的工具采集程序运行中的数据(采样),根据采集的数据反馈到编译器,重新生成程序,这种称之为"静态PGO",然而有了分层编译,就有了一个新的开始.
在.Net Core 3.0默认开启分层编译,分层编译对于JIT是快速生成代码和高度优化代码的一个折中方式,代码是从0层开始编译的,此时JIT只会对代码进行少量的优化,所以生成代码很快(编译器比较耗时的就是对代码优化),在0层编译好的代码包含一些跟踪信息,用于计算方法调用的次数,一旦满足了条件,JIT便会把这一块代码放入队列中,然后会在1层重新编译.这一次JIT可以进行所有的优化,并从之前的编译中学习.例如:一个可以被访问的只读的int变量可以变成常量,因为它的值在0层编译时可以计算出,在1层编译的时候改为常量.dotnet/runtime#45901改进了队列,使用专用线程,而不是线程池的线程.
在.Net 6 动态PGO默认是关闭,要想使用它,需要在环境变量中设置DOTNET_TieredPGO
#Linux 终端export DOTNET_TieredPGO=1# Windows 命令行set DOTNET_TieredPGO=1# Windows PowerShell$env:DOTNET_TieredPGO="1"添加过环境变量后,JIT 0层编译时就可以收集需要的数据,除此之外,可能还需要设置其他的环境变量,如.Net 核心的类库在安装时已经使用ReadyToRun(R2R,预先编译(AOT的一种形式),减少程序加载时JIT的工作量改进启动时的性能),这代表这些核心类库已经被编译为汇编代码,这些核心类库也会进入分层编译,只是不会进入0层编译,而是直接进入1层,这意味动态PGO没有收集ReadyToRun类库的数据,要想收集这些类库的数据,需要禁用ReadyToRun:
#禁用ReadyToRun 0 开启为1$env:DOTNET_ReadyToRun="0"还需要设置这个环境变量:
#对循环方法进行分层$env:DOTNET_TC_QuickJitForLoops="1"这个变量包含对循环的方法进行分层,否则,具有向后执行的方法会进入1层编译,这意味着会立即优化,像没有分层编译一样,这样做会失去0层编译.你可能听过"完整PGO"需要设置上边这三个环境变量,“完整PGO”包含是动态PGO和静态PGO.注意一下ReadyToRun只是静态PGO.
下面看一下示例:
; net6perf.JIT.EnumerableTest.MoveNext() sub rsp,28 mov rcx,
# 开启分层编译$env:DOTNET_TieredPGO="1"; net6perf.JIT.EnumerableTest.MoveNext() sub rsp,28 mov rcx,
mov rcx,
在.Net 6中开启分层编译优化,性能测试对比
JIT会以多种方式对PGO数据进行优化,如果知道代码行为的数据,可能会更积极地进行内联优化,根据这些数据JIT会知道哪些是有益的,哪些是有无益的.可以执行对大多数接口和虚拟分发的方法进行保护的方式去虚拟化.生成一个或多个去虚拟化且可能内联的快速路径,如果实际类型和预期类型不一致,则回退执行标准分发,JIT会在各种情况下减少代码大小,也有可能会增加代码大小.
许多提交对PGO改进做出了贡献,如下边这些:
dotnet/runtime#44427 通过达到调用的频率,然后内联dotnet/runtime#45133 判断是否启用该接口和虚拟分发后执行调用具体类型的方法.dotnet/runtime#51157 改进对小结构体的支持.dotnet/runtime#51890 将受保护去虚拟化的站点连接在一起,将经常使用的代码分在一起,进行代码优化.dotnet/runtime#52827 当PGO数据可用时,增加了一个特殊的switch,如果有一个主要切换,JIT看到该分支占用了30%的时间,JIT可以预先发出一个专用的if进行检查,而不是让它和其他情况一起切换(注意这适合IL的switch,并不是所有 c#的switch都会在IL作为switch,事实上很多时候不是一一对应,因为c#编译器会switch进行优化,会生成等同if/else.关于内联优化就先到这里,对于高性能C#和.Net代码,其他类型的优化也很重要,例如:边界检查.C#和.Net一个伟大之处就是除非千方百计地绕开现有的安全措施(如在方法上使用unsafe关键字,或者在class上标记unsafe,再或者使用Marshal/MemoryMarshal),否则很难遇到缓冲区溢出等典型安全漏洞,这是因为对数组/字符串,及Span等都会进行边界检查,确保索引在正确的边界内.看示例:
; net6perf.JIT.BoundsCheckingTest.M(Int32<>, Int32) sub rsp,28; 判断数组的长度 cmp r8d,
当然,增加边界检查,会增加一些开销,不过对于大多数代码来说,带来的开销都是忽略不计的,在.Net 核心库还是在尽量避免边界检查,在JIT中可以证明边界不存在的时候,它会避免生成带有边界检查的代码,比较典型的例子就是从0到数组长度的循环,如果你这样写:
public int Sum(int<> arr){ int sum = 0; for (int i = 0; i < arr.Length; i++) { sum += arr; } return sum;}生成的汇编代码:
; net6perf.JIT.BoundsCheckingTest2.Sum(Int32<>) xor eax,eax xor ecx,ecx mov r8d,
在.Net的每一个版本都见证JIT在各种模式变得更聪明,在这些模式,JIT可以消除边界检查,在.Net 6紧跟其后,这几个dotnet/runtime#40180和dotnet/runtime#43568及nathan-moore 这些改进都非常有用,接着看下边的示例:
private char<> _buffer = new char<100>;
看在.Net 5生成的汇编代码:
; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1<Char>) sub rsp,28; 1.0 将span的引用加载eax寄存器 mov rax,
; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1<Char>) mov rax,
; .Net 5汇编代码; net6perf.JIT.StoreTest.Store(System.Span`1<Int64>, System.DateTime) sub rsp,28 mov rax,
另外一个边界检查优化是循环克隆,JIT复制一个循环,创建一个原始的变量(一个循环)和一个移除边界检查的变量(另外一个循环),由运行时根据条件判断使用那个变量中的循环.如:
public static int Sum(int<> array, int length){ int sum = 0; for (int i = 0; i < length; i++) { sum += array; } return sum;}JIT仍然需要对array进行边界检查,因为JIT知道i是否小于length,但不确定length是否小于等于array的长度,因此JIT会克隆一个循环,生成类似下面的代码:
public static int Sum(int<> array, int length){ int sum = 0; //JIT判断执行那个分支中的循环 if (array is not null && length <= array.Length) { for (int i = 0; i < length; i++) { sum += array; //这里不进行边界检查 } } else { for (int i = 0; i < length; i++) { sum += array; //这里依然进行边界检查 } } return sum;}在.Net 6JIT循环克隆增加新分支,新分支中在循环开始时进行一次边界检查,在循环内部消除索引在数组边界检查
; .Net 5.0.9; net6perf.JIT.BoundsCheckingTest3.Sum() sub rsp,28 mov rax,
while (i < 3){ ... i++;}循环反转后:
if (i < 3) //外层增加if{ //while转换为do while do { ... i++; } while (i < 3);}这里的循环反转,是将while转为do-while,是因为do-while可以减少跳转次数,在do-while不满足条件,继续执行下面的执行,而while在自增后,需要跳转上边判断边界检查的指令,不满足条件的话,在跳转到其他分支上.
下边开始说"常量折叠",这是个花哨的术语,其实是在代码编译的时候,由编译器计算值,不在JIT中.看下面的代码:
public static int M() => 10 + 20 * 30 / 40 ^ 50 | 60 & 70;在编译后,通过反编译工具查看生成的dll:
// return 47;IL_0000: ldc.i4.s 47 //由编译器计算出值IL_0002: ret当然在JIT中也可以进行常量折叠,看下面的代码:
public static int J() => 10 + K();public static int K() => 20;生成的IL(函数J)代码:
IL_0000: ldc.i4.s 10 //常量10IL_0002: call int32 net6perf.JIT.ConstTest::K() //调用方法KIL_0007: add //进行相加IL_0008: ret //返回和我们查看一下JIT后汇编代码:
; net6perf.JIT.ConstTest.J(); 将1E放入eax寄存器中,然后返回. 1E为30的十六进制 mov eax,1E ret; Total bytes of code 6内联后,会将常量10和20求和,得到30,常量折叠往往伴随着常量传播,继续看下个示例:
; net6perf.JIT.ContainsSpaceTest.M(); 调用ContainsSpace()方法,JIT进行内联,发现Contains第一个参数的长度为1,第二个参数是字面量; 直接对 s<0> == c对比,将值返回,最后一条指令生成mov eax,1; 这里省掉2次函数调用,并且生成代码很小 mov eax,1 ret; Total bytes of code 6看性能对比:
在.Net 6中JIT通过常量传播优化,生成指令减少了很多
还有就是一个很好的改进 dotnet/runtime#57217,这个改进我在 在C#中使用String的注意事项 提到了,String.Format性能提升也是使用了DefaultInterpolatedStringHandler
还有那些JIT是可以进行内联的,如 dotnet/runtime#49930 这个是为字符串常量时折叠空检查,像Microsoft.Extensions.Logging.Console.ConsoleFormatter是个抽象基类,公开了一个受保护的构造函数,像这样:
protected ConsoleFormatter(string name){ Name = name ?? throw new ArgumentNullException(nameof(name));}这是一个相当典型的构造函数,验证一个参数是不是为null,如果为空则抛出一个异常,不为null则保存起来,现在来看看实现ConsoleFormatter的子类JsonConsoleFormatter:
public JsonConsoleFormatter(IOptionsMonitor<JsonConsoleFormatterOptions> options) : base (ConsoleFormatterNames.Json){ ReloadLoggerOptions(options.CurrentValue); _optionsReloadToken = options.OnChange(ReloadLoggerOptions);}调用基类的构造函数,看看ConsoleFormatterNames.Json:
/// <summary>/// Reserved name for json console formatter/// </summary>public const string Json = "json";相当于:
base("json")但JIT调用基类构造函数,发现有常量字符串,这里会越过为空的安全检查,就等于上方的这一行代码.
因内容较多,JIT部分还是没有展示完,所以将JIT部分进行拆分.
如果您觉得对您有用的话,可以点个赞或者加个关注,欢迎大家一起进行技术交流