rsi是什么意思中文翻译,rsi是什么意思啊

  

  这篇文章是由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的改进,学会内联,如使用常量,让不内联的结构变为内联.下面使用标记内联:

  

public class Utf8FormatterTest{ private int _value = 12345; private byte<> _buffer = new byte<100>; public bool Format() => Utf8Formatter.TryFormat(_value, _buffer, out _, new StandardFormat('D', 2));}性能测试.Net 5和.Net 6结果(测试硬件不同,得出的结果也不一样,这里的原文结果相差有点大):

  

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认为没必要对构造函数进行内联.

  

//在.Net6加上了标记内联private static bool TryFormatUInt64(ulong value, Span<byte> destination, out int bytesWritten, StandardFormat format){ if (format.IsDefault) { return TryFormatUInt64Default(value, destination, out bytesWritten); } switch (format.Symbol) { case 'G': case 'g': if (format.HasPrecision) throw new NotSupportedException(SR.Argument_GWithPrecisionNotSupported); // With a precision, 'G' can produce exponential format, even for integers. return TryFormatUInt64D(value, format.Precision, destination, insertNegationSign: false, out bytesWritten); case 'd': case 'D': return TryFormatUInt64D(value, format.Precision, destination, insertNegationSign: false, out bytesWritten); case 'n': case 'N': return TryFormatUInt64N(value, format.Precision, destination, insertNegationSign: false, out bytesWritten); case 'x': return TryFormatUInt64X(value, format.Precision, true /* useLower */, destination, out bytesWritten); case 'X': return TryFormatUInt64X(value, format.Precision, false /* useLower */, destination, out bytesWritten); default: return FormattingHelpers.TryFormatThrowFormatException(out bytesWritten); }}在.Net 6中TryFormatUInt64方法是没有调用的,内联后直接调用TryFormatUInt64D方法,这里减少了方法调用和分支的开销.在TryFormatUInt64D和TryFormatInt64N方法都用了内联标记.

  

内联和去虚拟化(devitalization)是密切相关的.在JIT接受虚方法和接口调用时,是要静态确定调用的最终目标方法并去执行,从而减少了虚拟分发的开销.如果去除虚拟化,就可以进行内联优化.接着看下面的例子:

  

public class EqualityComparerTest{ private int<> _values = Enumerable.Range(0, 100_000).ToArray(); public int Find() => Find(_values, 99_999); private static int Find<T>(T<> array, T item) { for (int i = 0; i < array.Length; i++) if (EqualityComparer<T>.Default.Equals(array, item)) return i; return -1; }}在.Net Core之前,EqualityComparer<T>.Default这个方法是做过去虚拟化的优化. 在.Net6和.Net Framework4.8对比,发现性能是相差2倍.看下图:

  

在.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倍多.

  

在去虚拟化的改进已经超出常用的内联化的方法.看下面这个基准测试:

  

public class ValueTupleLengthTest{ public int GetLength() => ((ITuple)(5, 6, 7)).Length;}上边这个示例使用ValueTuple(值类型元组,有3个元素)和ITuple接口,不过这个不重要,这里只是选择了一个实现接口的值类型,在.Net Core之前的版本使JIT避免装箱(从值类型转换为实现的接口,对实现接口的进行约束后调用),在.Net Core后续的版本加入去虚拟化和内联优化.

  

在.Net 6和.Net Framework4,8 GetLength在去虚拟化后,对比性能相差太多倍

  

现在把代码进行调整,再看.Net 5和.Net 6测试结果:

  

public class ValueTupleLengthTest2{ public int GetLength() { ITuple t = (5, 6, 7); Ignore(t); return t.Length; } //禁止内联 private static void Ignore(object o) { }}在.Net 5和.Net 6 JIt在去虚拟化,是有变化的

  

因为这个是单次执行的时间,相差不大了,下边看一下.Net 5和.Net 6生成的汇编代码:

  

.Net 5汇编代码:

  

; net6perf.JIT.ValueTupleLengthTest2.GetLength() push rsi sub rsp,30 vzeroupper vxorps xmm0,xmm0,xmm0 vmovdqu xmmword ptr ,xmm0 mov dword ptr ,5 mov dword ptr ,6 mov dword ptr ,7 mov rcx,offset MT_System.ValueTuple`3<,,> call CORINFO_HELP_NEWSFAST mov rsi,rax vmovdqu xmm0,xmmword ptr vmovdqu xmmword ptr ,xmm0 mov rcx,rsi; 这里会把t进行装箱,然后调用Ignore call net6perf.JIT.ValueTupleLengthTest2.Ignore(System.Object) mov rcx,rsi add rsp,30 pop rsi ; 会调用get_Length方法,返回元组的长度 jmp near ptr System.ValueTuple`3<,,>.System.Runtime.CompilerServices.ITuple.get_Length(); Total bytes of code 92.Net 6 生成的汇编代码:

  

; net6perf.JIT.ValueTupleLengthTest2.GetLength() push rsi sub rsp,30 vzeroupper vxorps xmm0,xmm0,xmm0 vmovupd ,xmm0 mov dword ptr ,5 mov dword ptr ,6 mov dword ptr ,7 mov rcx,offset MT_System.ValueTuple`3<,,> call CORINFO_HELP_NEWSFAST mov rcx,rax lea rsi, vmovupd xmm0, vmovupd ,xmm0 call net6perf.JIT.ValueTupleLengthTest2.Ignore(System.Object) cmp ,esi; JIT计算元组的长度3,放入到eax,返回 mov eax,3 add rsp,30 pop rsi ret; Total bytes of code 92还有一些其他的调整在改进去虚拟化,例如,dotnet/runtime#53567 改进生成AOT可执行程序中的去虚拟化.dotnet/runtime#45526 是泛型支持去虚拟化,这样就可以获取具体类型的信息进行内联优化.

  

当然,在许多情况下,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.

  

下面看一下示例:

  

public class EnumerableTest{ private IEnumerator<int> _source = Enumerable.Range(0, int.MaxValue).GetEnumerator(); public void MoveNext() => _source.MoveNext();}在.Net 6没开启分层生成的代码:

  

; net6perf.JIT.EnumerableTest.MoveNext() sub rsp,28 mov rcx, mov r11,7FF7E7840600 call qword ptr <7FF7E7D10600> nop add rsp,28 ret; Total bytes of code在将分层编译开启后:

  

# 开启分层编译$env:DOTNET_TieredPGO="1"; net6perf.JIT.EnumerableTest.MoveNext() sub rsp,28 mov rcx, mov r11,offset MT_System.Linq.Enumerable+RangeIterator cmp ,r11 jne short M00_L03 mov r11d, cmp r11d,1 je short M00_L01 cmp r11d,2 jne short M00_L02 mov r11d, inc r11d mov ,r11d cmp r11d, je short M00_L02M00_L00: add rsp,28 retM00_L01: mov r11d, mov ,r11d mov dword ptr ,2 jmp short M00_L00M00_L02: mov dword ptr ,0FFFFFFFF jmp short M00_L00M00_L03:; 用于执行接口分发 mov r11,7FF7EAB30600; 用于执行调用 call qword ptr <7FF7EB000600> jmp short M00_L00; Total bytes of code 105我们看到开启分层编译后,生成的汇编代码长了很多,里面有不少分支判断,用于执行接口分发的调用移到尾部,在PGO常见的优化,就是代码热/冷分开,方法内执行频繁的代码("热代码")被移到方法开始的地方,执行不频繁的代码("冷代码")被移到尾部,这样带来的好处就是可以指令缓存,并最小化引入使用的代码.接下来看:

  

mov rcx,mov r11,offset MT_System.Linq.Enumerable+RangeIteratorcmp ,r11jne short M00_L03当JIT检测这个方法的0层代码时,包括检测接口分发及跟踪每次调用_source的具体类型,JIT发现每次调用都在一个Enumerable+RangeIterator类型上,这是实现Emumerable的私有类,因此,在1层编译时,查看_source的类型是否Enumerable+RangeIterator类型,如果不是,就执行到M00_L03尾部代码,如果是的话,就调用Enumerable+RangeIterator的MoveNext方法,并对MoveNext的方法去虚拟化,进行内联,这种最终会使代码变大,但对常见的场景进行了的优化.查看对比结果:

  

在.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等都会进行边界检查,确保索引在正确的边界内.看示例:

  

public class BoundsCheckingTest{ public int M(int<> arr, int index) { return arr; }}看M生成的汇编代码:

  

; net6perf.JIT.BoundsCheckingTest.M(Int32<>, Int32) sub rsp,28; 判断数组的长度 cmp r8d,; 如果超过数组的长度,跳转到 M01_L00 jae short M01_L00 movsxd rax,r8d mov eax, add rsp,28 retM01_L00: call CORINFO_HELP_RNGCHKFAIL int 3; Total bytes of code 28rdx寄存器存储了arr的地址,并且arr的长度被存储在rdx+8的地址中,所以rdx+8是arr的长度,通过cmp指令将rdx(要查找的索引)和arr的长度进行比较,如果索引大于或等于数组的长度,则跳转结尾,执行一个异常(异常帮助方法),这就是边界检查.

  

当然,增加边界检查,会增加一些开销,不过对于大多数代码来说,带来的开销都是忽略不计的,在.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, test r8d,r8d jle short M01_L01M01_L00: movsxd r9,ecx add eax, inc ecx cmp r8d,ecx jg short M01_L00M01_L01: ret; Total bytes of code 29在Sum方法生成的汇编代码中,我们没有看到调用异常助手的方法,也没有执行int 3(软中断),JIT编译器看到这里的代码不会超出数组的边界,所以也就没有增加边界检查.

  

在.Net的每一个版本都见证JIT在各种模式变得更聪明,在这些模式,JIT可以消除边界检查,在.Net 6紧跟其后,这几个dotnet/runtime#40180和dotnet/runtime#43568及nathan-moore 这些改进都非常有用,接着看下边的示例:

  

private char<> _buffer = new char<100>;public bool TryFormatTrue() => TryFormatTrue(_buffer);private static bool TryFormatTrue(Span<char> destination){ if (destination.Length >= 4) { destination<0> = 't'; destination<1> = 'r'; destination<2> = 'u'; destination<3> = 'e'; return true; } return false;}根据索引修改Span的值

  

看在.Net 5生成的汇编代码:

  

; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1<Char>) sub rsp,28; 1.0 将span的引用加载eax寄存器 mov rax,; 1.1 将span的长度加载edx寄存器 mov edx,; 1.2 判断将span的长度和4进行对比 cmp edx,4; 1.3 小于<4,跳转到M01_L00,返回false jl short M01_L00; 2.0 span长度和0比较 cmp edx,0; 2.1 如小于0,跳转到M01_L01,调用异常助手 jbe short M01_L01; 2.1.1 将74放入rax寄存器上 span<0> ='t' t的acsii码为116 十六进制为74 mov word ptr ,74; 这里和2.0一样,就不进行注释 cmp edx,1 jbe short M01_L01 mov word ptr ,72 cmp edx,2 jbe short M01_L01 mov word ptr ,75 cmp edx,3 jbe short M01_L01 mov word ptr ,65 mov eax,1 add rsp,28 retM01_L00: xor eax,eax add rsp,28 retM01_L01:; 这里调用异常助手(在超出边界检查调用) call CORINFO_HELP_RNGCHKFAIL int 3; Total bytes of code 81在.Net 5中尽管我们知道这些都在边界内,通过索引给span中的元素赋值时,每次都有一次边界检查,不过这些在.Net 6得到改进:

  

; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1<Char>) mov rax, mov edx, cmp edx,4 jl short M01_L00; 将74移到rax寄存器 mov word ptr ,74 mov word ptr ,72 mov word ptr ,75 mov word ptr ,65; 将1移到eax 表示为真 mov eax,1; 返回 retM01_L00: xor eax,eax ret; Total bytes of code 43这些变化还允许撤销一些核心库中的hack(dotnet/runtime#49271,移除边界检查).另一个改进dotnet/runtime#49271来自SingleAccretion,在原先JIT中,内联方法调用可能导致后续的边界检查,这个提交修改该问题,效果非常明显.

  

public class StoreTest{ private readonly long<> _buffer = new long<10>; private readonly DateTime _now = DateTime.UtcNow; public void Store() { Store(_buffer, _now); } private static void Store(Span<long> span, DateTime value) { if (!span.IsEmpty) { span<0> = value.Ticks; } }}看一下.Net 5 和 .Net 6汇编代码对比:

  

; .Net 5汇编代码; net6perf.JIT.StoreTest.Store(System.Span`1<Int64>, System.DateTime) sub rsp,28 mov rax, mov ecx, test ecx,ecx jbe short M01_L00 cmp ecx,0 jbe short M01_L01 mov rcx,0FFFFFFFFFFFF and rdx,rcx mov ,rdxM01_L00: add rsp,28 retM01_L01: call CORINFO_HELP_RNGCHKFAIL int 3; Total bytes of code 46; .Net 6汇编代码; net6perf.JIT.StoreTest.Store(System.Span`1<Int64>, System.DateTime) mov rax, mov ecx, test ecx,ecx jbe short M01_L00 mov rcx,0FFFFFFFFFFFF and rdx,rcx mov ,rdxM01_L00: ret; Total bytes of code 27对比代码后,发现在.Net 6是移除边界检查了,在.Net 6采用test指令而不是使用cmp指令对比,也是小的改进.

  

另外一个边界检查优化是循环克隆,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, xor edx,edx xor ecx,ecx mov r8d,M00_L00:; 通过cmp指令进行边界检查 cmp ecx,r8d; 超出边界,调用M00_L01,调用异常助手 jae short M00_L01 movsxd r9,ecx movzx r9d,byte ptr add edx,r9d inc ecx cmp ecx,0F423F jl short M00_L00 add rsp,28 retM00_L01: call CORINFO_HELP_RNGCHKFAIL int 3; Total bytes of code 54;.Net 6.0.0; net6perf.JIT.BoundsCheckingTest3.Sum() sub rsp,28 mov rax, xor edx,edx xor ecx,ecx test rax,rax; 上边通过test进行rax寄存器逻辑与运算,满足的话,就调转到M00_L001; 不满足的话,就继续往下走,经过nop后进入M00_L00分支,该分支中 for循环内没有边界检查 je short M00_L01 cmp dword ptr ,0F423F jl short M00_L01 nop word ptr M00_L00:; for循环中不需要进行边界检查 movsxd r8,ecx movzx r8d,byte ptr add edx,r8d inc ecx cmp ecx,0F423F jl short M00_L00 jmp short M00_L02M00_L01:; for循环中每次都要进行边界检查 cmp ecx, jae short M00_L03 movsxd r8,ecx movzx r8d,byte ptr add r8d,edx mov edx,r8d inc ecx cmp ecx,0F423F jl short M00_L01M00_L02: add rsp,28 retM00_L03: call CORINFO_HELP_RNGCHKFAIL int 3; Total bytes of code 97JIT循环克隆的改进,还有 dotnet/runtime#55612(改进非基本类型数组)和dotnet/runtime#55299(改进多维数组).谈到循环优化,就得讲讲循环反转,循环反转是编译器的一种标准转换,意在消除循环中的一些分支.如这样的循环:

  

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,常量折叠往往伴随着常量传播,继续看下个示例:

  

public class ContainsSpaceTest{ public bool ContainsSpace(string s) => Contains(s, ' '); private static bool Contains(string s, char c) { if (s.Length == 1) { return s<0> == c; } for (int i = 0; i < s.Length; i++) { if (s == c) return true; } return false; } public bool M() => ContainsSpace(" ");}先看一下M生成的代码:

  

; 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部分进行拆分.

  

如果您觉得对您有用的话,可以点个赞或者加个关注,欢迎大家一起进行技术交流

相关文章