在.Net 6中性能改进系列-JIT

本文深入探讨了.NET 6中JIT(Just-In-Time)编译器的性能改进,包括内联优化、去虚拟化、边界检查优化和循环克隆。文章通过代码示例和汇编分析展示了这些改进如何提升程序性能,并介绍了动态PGO(Profile-Guided Optimization)如何利用运行时数据优化代码。此外,还讨论了常量折叠和方法内联在减少代码大小和提高执行效率方面的作用。
摘要由CSDN通过智能技术生成

  起因

  本文是.Net 6性能改进 JIT部分,可以先看看前言: 在.Net 6 性能改进系列-前言

  本文是翻译,内容较多,主要是较短的示例代码,最好是在PC端阅读.

  JIT相关改进

  JIT代码生成是构建程序的基础,JIT编译器生成优秀的代码带来的性能提升是有可能倍增的,在.Net 6 JIT部分有惊人的性能提升.JIT将IL(中间语言)转为汇编代码,AOT(预先编译)作为Crossgen2 和 R2R format (ReadyToRun)的一部分.

  JIT是.Net程序性能是否优秀的基础.让我们从内联和非虚拟化开始,无独有偶,前几天我还特意写了一篇关于方法内联的文章 C# 方法内联 ,水平有限,写得比较浅显,这里正好拜读大佬的文章.

  内联就是原先调用方法,现在不调用方法,直接将方法内部的代码移到调用的方法的位置,减去调用方法的开销.还可以对原先方法内的代码进行后续的优化.

  using SystemtimepilerServices;

  using BenchmarkDotNet.Attributes;

  namespace net6perf.JIT

  {

  [DisassemblyDiagnoser(printSource: true, maxDepth: 2)]

  public class JIT

  {

  [Benchmark]

  public void ComputeTest()

  {

  for (int i=0; i < 1024; i++)

  {

  int value=Compute();

  int tmp=value;

  int tmp2=tmp;

  }

  }

  [MethodImpl(MethodImplOptions.NoInlining)]

  public static int Compute()=> ComputeValue(123) * 11;

  [MethodImpl(MethodImplOptions.NoInlining)]

  private static int ComputeValue(int length)=> length * 7;

  [Benchmark]

  public void ComputeInlineTest()

  {

  for (int i=0; i < 1024; i++)

  {

  int value=ComputeInline();

  int tmp=value;

  int tmp2=tmp;

  }

  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]

  public static int ComputeInline()=> ComputeValueInline(123) * 11;

  [MethodImpl(MethodImplOptions.AggressiveInlining)]

  private static int ComputeValueInline(int length)=> length * 7;

  }

  }

在.Net 6中性能改进系列-JIT

  内联在.Net Framework 4.8/.Net 5.0/.Net 6.0进行对比,发现相差并不大.是因为执行的次数少

  内联在.Net Framework 4.8/.Net 5.0/.Net 6.0进行对比,发现相差并不大.是因为执行的次数少,这里的.Net 6版本还是Preview 7,不是RC1.是RC1还没在官网发布,现在是每夜构建的版本,最主要的下载失败.

  接着我们看一下生成的汇编代码:

  ; net6perf.JIT.JITpute()

  sub rsp,28

  ;7B为123的十六进制

  mov ecx,7B

  call net6perf.JIT.JITputeValue(Int32)

  ;0B为11的十六进制

  ;imul 将eax寄存器的值(7B) 和0B进行乘法运算

  imul eax,0B

  add rsp,28

  ret

  ; Total bytes of code 22

  ; net6perf.JIT.JITputeValue(Int32)

  ;imul 将ecx寄存器的值和7进行乘法运算,并将结果放入eax寄存器上

  imul eax,ecx,7

  ret

  ; Total bytes of code 4

  读完上边的汇编代码,

  发现在Compute方法中,将123转为十六进制7B,并将7B加载到eax寄存器上调用ComputeValue,ecx是保存参数的值,将ecx的值乘以7,保存到eax寄存器上将eax寄存器的值乘以11(十六进制0B),将计算的值返回.

  如果开启内联的话.生成的汇编代码:

  ; Programpute()

  ; 开启内联,会将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的改进,学会内联,如使用常量,让不内联的结构变为内联.下面使用标记内联:

  [DisassemblyDiagnoser(printSource: true, maxDepth: 2)]

  public class Utf8FormatterTest

  {

  private int _value=12345;

  private byte[] _buffer=new byte[100];

  [Benchmark]

  public bool Format()=> Utf8Formatter.TryFormat(_value, _buffer, out _, new StandardFormat('D', 2));

  }

  性能测试.Net 5和.Net 6结果(测试硬件不同,得出的结果也不一样,这里的原文结果相差有点大):

  

在.Net 6中性能改进系列-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认为没必要对构造函数进行内联.

  [MethodImpl(MethodImplOptions.AggressiveInlining)] //在.Net6加上了标记内联

  private static bool TryFormatUInt64(ulong value, Span 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接受虚方法和接口调用时,是要静态确定调用的最终目标方法并去执行,从而减少了虚拟分发的开销.如果去除虚拟化,就可以进行内联优化.接着看下面的例子:

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  public class EqualityComparerTest

  {

  private int[] _values=Enumerable.Range(0, 100_000).ToArray();

  [Benchmark]

  public int Find() => Find(_values, 99_999);

  private static int Find(T[] array, T item)

  {

  for (int i=0; i < array.Length; i++)

  if (EqualityComparer.Default.Equals(array[i], item))

  return i;

  return -1;

  }

  }

  在.Net Core之前,EqualityComparer.Default这个方法是做过去虚拟化的优化. 在.Net6和.Net Framework4.8对比,发现性能是相差2倍.看下图:

  

在.Net 6中性能改进系列-JIT

  在.Net 6和.Net Framework4,8对比性能相差2倍多.

  在JIT中可以对EqualityComparer.Default.Equals进行去虚拟化处理,对于同级的Comparer.Defaultpare(主要是指.Net Framework 4.8)没有实现去虚拟化.具体看这里 dotnet/runtime#48160 ,下面这个示例是Compare对ValueTuple的元素进行比较.因为生成的汇编代码偏长,这里就不进行汇编代码对比了.

  

在.Net 6中性能改进系列-JIT

  在.Net 6和.Net Framework4,8 Compare对比性能相差5倍多.

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

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  public class ValueTupleLengthTest

  {

  [Benchmark]

  public int GetLength()=> ((ITuple)(5, 6, 7)).Length;

  }

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

  

在.Net 6中性能改进系列-JIT

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

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

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  public class ValueTupleLengthTest2

  {

  [Benchmark]

  public int GetLength()

  {

  ITuple t=(5, 6, 7);

  Ignore(t);

  return t.Length;

  }

  [MethodImpl(MethodImplOptions.NoInlining)] //禁止内联

  private static void Ignore(object o) { }

  }

在.Net 6中性能改进系列-JIT

  在.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 [rsp+20],xmm0

  mov dword ptr [rsp+20],5

  mov dword ptr [rsp+24],6

  mov dword ptr [rsp+28],7

  mov rcx,offset MT_System.ValueTuple`3[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]]

  call CORINFO_HELP_NEWSFAST

  mov rsi,rax

  vmovdqu xmm0,xmmword ptr [rsp+20]

  vmovdqu xmmword ptr [rsi+8],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.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]].SystemtimepilerServices.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 [rsp+20],xmm0

  mov dword ptr [rsp+20],5

  mov dword ptr [rsp+24],6

  mov dword ptr [rsp+28],7

  mov rcx,offset MT_System.ValueTuple`3[[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib],[System.Int32, System.Private.CoreLib]]

  call CORINFO_HELP_NEWSFAST

  mov rcx,rax

  lea rsi,[rcx+8]

  vmovupd xmm0,[rsp+20]

  vmovupd [rsi],xmm0

  call net6perf.JIT.ValueTupleLengthTest2.Ignore(System.Object)

  cmp [rsi],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.

  下面看一下示例:

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  public class EnumerableTest

  {

  private IEnumerator _source=Enumerable.Range(0, int.MaxValue).GetEnumerator();

  [Benchmark]

  public void MoveNext()=> _source.MoveNext();

  }

  在.Net 6没开启分层生成的代码:

  ; net6perf.JIT.EnumerableTest.MoveNext()

  sub rsp,28

  mov rcx,[rcx+8]

  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,[rcx+8]

  mov r11,offset MT_System.Linq.Enumerable+RangeIterator

  cmp [rcx],r11

  jne short M00_L03

  mov r11d,[rcx+0C]

  cmp r11d,1

  je short M00_L01

  cmp r11d,2

  jne short M00_L02

  mov r11d,[rcx+10]

  inc r11d

  mov [rcx+10],r11d

  cmp r11d,[rcx+18]

  je short M00_L02

  M00_L00:

  add rsp,28

  ret

  M00_L01:

  mov r11d,[rcx+14]

  mov [rcx+10],r11d

  mov dword ptr [rcx+0C],2

  jmp short M00_L00

  M00_L02:

  mov dword ptr [rcx+0C],0FFFFFFFF

  jmp short M00_L00

  M00_L03:

  ; 用于执行接口分发

  mov r11,7FF7EAB30600

  ; 用于执行调用

  call qword ptr [7FF7EB000600]

  jmp short M00_L00

  ; Total bytes of code 105

  我们看到开启分层编译后,生成的汇编代码长了很多,里面有不少分支判断,用于执行接口分发的调用移到尾部,在PGO常见的优化,就是代码热/冷分开,方法内执行频繁的代码("热代码")被移到方法开始的地方,执行不频繁的代码("冷代码")被移到尾部,这样带来的好处就是可以指令缓存,并最小化引入使用的代码.接下来看:

  mov rcx,[rcx+8]

  mov r11,offset MT_System.Linq.Enumerable+RangeIterator

  cmp [rcx],r11

  jne short M00_L03

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

  

在.Net 6中性能改进系列-JIT

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

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  public class BoundsCheckingTest

  {

  [Benchmark]

  public int M(int[] arr, int index)

  {

  return arr[index];

  }

  }

  看M生成的汇编代码:

  ; net6perf.JIT.BoundsCheckingTest.M(Int32[], Int32)

  sub rsp,28

  ; 判断数组的长度

  cmp r8d,[rdx+8]

  ; 如果超过数组的长度,跳转到 M01_L00

  jae short M01_L00

  movsxd rax,r8d

  mov eax,[rdx+rax*4+10]

  add rsp,28

  ret

  M01_L00:

  call CORINFO_HELP_RNGCHKFAIL

  int 3

  ; Total bytes of code 28

  rdx寄存器存储了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[i];

  }

  return sum;

  }

  生成的汇编代码:

  ; net6perf.JIT.BoundsCheckingTest2.Sum(Int32[])

  xor eax,eax

  xor ecx,ecx

  mov r8d,[rdx+8]

  test r8d,r8d

  jle short M01_L01

  M01_L00:

  movsxd r9,ecx

  add eax,[rdx+r9*4+10]

  inc ecx

  cmp r8d,ecx

  jg short M01_L00

  M01_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];

  [Benchmark]

  public bool TryFormatTrue()=> TryFormatTrue(_buffer);

  private static bool TryFormatTrue(Span destination)

  {

  if (destination.Length >=4)

  {

  destination[0]='t';

  destination[1]='r';

  destination[2]='u';

  destination[3]='e';

  return true;

  }

  return false;

  }

在.Net 6中性能改进系列-JIT

  根据索引修改Span的值

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

  ; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1)

  sub rsp,28

  ; 1.0 将span的引用加载eax寄存器

  mov rax,[rcx]

  ; 1.1 将span的长度加载edx寄存器

  mov edx,[rcx+8]

  ; 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 [rax],74

  ; 这里和2.0一样,就不进行注释

  cmp edx,1

  jbe short M01_L01

  mov word ptr [rax+2],72

  cmp edx,2

  jbe short M01_L01

  mov word ptr [rax+4],75

  cmp edx,3

  jbe short M01_L01

  mov word ptr [rax+6],65

  mov eax,1

  add rsp,28

  ret

  M01_L00:

  xor eax,eax

  add rsp,28

  ret

  M01_L01:

  ; 这里调用异常助手(在超出边界检查调用)

  call CORINFO_HELP_RNGCHKFAIL

  int 3

  ; Total bytes of code 81

  在.Net 5中尽管我们知道这些都在边界内,通过索引给span中的元素赋值时,每次都有一次边界检查,不过这些在.Net 6得到改进:

  ; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1)

  mov rax,[rcx]

  mov edx,[rcx+8]

  cmp edx,4

  jl short M01_L00

  ; 将74移到rax寄存器

  mov word ptr [rax],74

  mov word ptr [rax+2],72

  mov word ptr [rax+4],75

  mov word ptr [rax+6],65

  ; 将1移到eax 表示为真

  mov eax,1

  ; 返回

  ret

  M01_L00:

  xor eax,eax

  ret

  ; Total bytes of code 43

  这些变化还允许撤销一些核心库中的hack(dotnet/runtime#49271,移除边界检查).另一个改进dotnet/runtime#49271来自SingleAccretion,在原先JIT中,内联方法调用可能导致后续的边界检查,这个提交修改该问题,效果非常明显.

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  public class StoreTest

  {

  private readonly long[] _buffer=new long[10];

  private readonly DateTime _now=DateTime.UtcNow;

  [Benchmark]

  public void Store()

  {

  Store(_buffer, _now);

  }

  [MethodImpl(MethodImplOptions.NoInlining)]

  private static void Store(Span span, DateTime value)

  {

  if (!span.IsEmpty)

  {

  span[0]=value.Ticks;

  }

  }

  }

  看一下.Net 5 和 .Net 6汇编代码对比:

  ; .Net 5汇编代码

  ; net6perf.JITTest(System.Span`1, System.DateTime)

  sub rsp,28

  mov rax,[rcx]

  mov ecx,[rcx+8]

  test ecx,ecx

  jbe short M01_L00

  cmp ecx,0

  jbe short M01_L01

  mov rcx,0FFFFFFFFFFFF

  and rdx,rcx

  mov [rax],rdx

  M01_L00:

  add rsp,28

  ret

  M01_L01:

  call CORINFO_HELP_RNGCHKFAIL

  int 3

  ; Total bytes of code 46

  ; .Net 6汇编代码

  ; net6perf.JITTest(System.Span`1, System.DateTime)

  mov rax,[rcx]

  mov ecx,[rcx+8]

  test ecx,ecx

  jbe short M01_L00

  mov rcx,0FFFFFFFFFFFF

  and rdx,rcx

  mov [rax],rdx

  M01_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[i];

  }

  return sum;

  }

  JIT仍然需要对array[i]进行边界检查,因为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[i]; //这里不进行边界检查

  }

  }

  else

  {

  for (int i=0; i < length; i++)

  {

  sum +=array[i]; //这里依然进行边界检查

  }

  }

  return sum;

  }

在.Net 6中性能改进系列-JIT

  在.Net 6JIT循环克隆增加新分支,新分支中在循环开始时进行一次边界检查,在循环内部消除索引在数组边界检查

  ; .Net 5.0.9

  ; net6perf.JIT.BoundsCheckingTest3.Sum()

  sub rsp,28

  mov rax,[rcx+8]

  xor edx,edx

  xor ecx,ecx

  mov r8d,[rax+8]

  M00_L00:

  ; 通过cmp指令进行边界检查

  cmp ecx,r8d

  ; 超出边界,调用M00_L01,调用异常助手

  jae short M00_L01

  movsxd r9,ecx

  movzx r9d,byte ptr [rax+r9+10]

  add edx,r9d

  inc ecx

  cmp ecx,0F423F

  jl short M00_L00

  add rsp,28

  ret

  M00_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,[rcx+8]

  xor edx,edx

  xor ecx,ecx

  test rax,rax

  ; 上边通过test进行rax寄存器逻辑与运算,满足的话,就调转到M00_L001

  ; 不满足的话,就继续往下走,经过nop后进入M00_L00分支,该分支中 for循环内没有边界检查

  je short M00_L01

  cmp dword ptr [rax+8],0F423F

  jl short M00_L01

  nop word ptr [rax+rax]

  M00_L00:

  ; for循环中不需要进行边界检查

  movsxd r8,ecx

  movzx r8d,byte ptr [rax+r8+10]

  add edx,r8d

  inc ecx

  cmp ecx,0F423F

  jl short M00_L00

  jmp short M00_L02

  M00_L01:

  ; for循环中每次都要进行边界检查

  cmp ecx,[rax+8]

  jae short M00_L03

  movsxd r8,ecx

  movzx r8d,byte ptr [rax+r8+10]

  add r8d,edx

  mov edx,r8d

  inc ecx

  cmp ecx,0F423F

  jl short M00_L01

  M00_L02:

  add rsp,28

  ret

  M00_L03:

  call CORINFO_HELP_RNGCHKFAIL

  int 3

  ; Total bytes of code 97

  JIT循环克隆的改进,还有 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 //常量10

  IL_0002: call int32 net6perf.JIT.ConstTest::K() //调用方法K

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

  [DisassemblyDiagnoser(printSource: true, maxDepth: 3)]

  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[i]==c)

  return true;

  }

  return false;

  }

  [Benchmark]

  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

  在.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 options)

  : base (ConsoleFormatterNames.Json)

  {

  ReloadLoggerOptions(options.CurrentValue);

  _optionsReloadToken=options.OnChange(ReloadLoggerOptions);

  }

  调用基类的构造函数,看看

  ConsoleFormatterNames.Json:

  ///

  /// Reserved name for json console formatter

  ///

  public const string Json="json";

  相当于:

  base("json")

  但JIT调用基类构造函数,发现有常量字符串,这里会越过为空的安全检查,就等于上方的这一行代码.

  因内容较多,JIT部分还是没有展示完,所以将JIT部分进行拆分.

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值