百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

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

liebian365 2024-11-27 17:08 2 浏览 0 评论

起因

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

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

JIT相关改进

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

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

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

using System.Runtime.CompilerServices;
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 Framework 4.8/.Net 5.0/.Net 6.0进行对比,发现相差并不大.是因为执行的次数少,这里的.Net 6版本还是Preview 7,不是RC1.是RC1还没在官网发布,现在是每夜构建的版本,最主要的下载失败.

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

; net6perf.JIT.JIT.Compute()
       sub       rsp,28
;7B为123的十六进制
       mov       ecx,7B
       call      net6perf.JIT.JIT.ComputeValue(Int32)
;0B为11的十六进制
;imul 将eax寄存器的值(7B) 和0B进行乘法运算
       imul      eax,0B
       add       rsp,28
       ret
; Total bytes of code 22
; net6perf.JIT.JIT.ComputeValue(Int32)
;imul 将ecx寄存器的值和7进行乘法运算,并将结果放入eax寄存器上
       imul      eax,ecx,7
       ret
; Total bytes of code 4

读完上边的汇编代码,

  1. 发现在Compute方法中,将123转为十六进制7B,并将7B加载到eax寄存器上
  2. 调用ComputeValue,ecx是保存参数的值,将ecx的值乘以7,保存到eax寄存器上
  3. 将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必须要快速的权衡要不要使用内联.在这种情况下:

  1. dotnet/runtime#50675
  2. dotnet/runtime#51124
  3. dotnet/runtime#52708
  4. dotnet/runtime#53670
  5. dotnet/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结果(测试硬件不同,得出的结果也不一样,这里的原文结果相差有点大):

首先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<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方法,这里减少了方法调用和分支的开销.在TryFormatUInt64DTryFormatInt64N方法都用了内联标记.

内联和去虚拟化(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>(T[] array, T item)
    {
        for (int i = 0; i < array.Length; i++)
            if (EqualityComparer<T>.Default.Equals(array[i], item))
                return i;

        return -1;
    }
}

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

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

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

[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 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 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]].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   [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<int> _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的方法去虚拟化,进行内联,这种最终会使代码变大,但对常见的场景进行了的优化.查看对比结果:

JIT会以多种方式对PGO数据进行优化,如果知道代码行为的数据,可能会更积极地进行内联优化,根据这些数据JIT会知道哪些是有益的,哪些是有无益的.可以执行对大多数接口和虚拟分发的方法进行保护的方式去虚拟化.生成一个或多个去虚拟化且可能内联的快速路径,如果实际类型和预期类型不一致,则回退执行标准分发,JIT会在各种情况下减少代码大小,也有可能会增加代码大小.

许多提交对PGO改进做出了贡献,如下边这些:

  1. dotnet/runtime#44427 通过达到调用的频率,然后内联
  2. dotnet/runtime#45133 判断是否启用该接口和虚拟分发后执行调用具体类型的方法.
  3. dotnet/runtime#51157 改进对小结构体的支持.
  4. dotnet/runtime#51890 将受保护去虚拟化的站点连接在一起,将经常使用的代码分在一起,进行代码优化.
  5. 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<char> destination)
{
    if (destination.Length >= 4)
    {
        destination[0] = 't';
        destination[1] = 'r';
        destination[2] = 'u';
        destination[3] = 'e';
        return true;
    }

    return false;
}

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

; net6perf.JIT.BoundsCheckingTest2.TryFormatTrue(System.Span`1<Char>)
       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<Char>)
       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<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,[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.JIT.StoreTest.Store(System.Span`1<Int64>, 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 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

看性能对比:

还有就是一个很好的改进 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部分进行拆分.

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

相关推荐

快递查询教程,批量查询物流,一键管理快递

作为商家,每天需要查询许许多多的快递单号,面对不同的快递公司,有没有简单一点的物流查询方法呢?小编的回答当然是有的,下面随小编一起来试试这个新技巧。需要哪些工具?安装一个快递批量查询高手快递单号怎么快...

一键自动查询所有快递的物流信息 支持圆通、韵达等多家快递

对于各位商家来说拥有一个好的快递软件,能够有效的提高自己的工作效率,在管理快递单号的时候都需要对单号进行表格整理,那怎么样能够快速的查询所有单号信息,并自动生成表格呢?1、其实方法很简单,我们不需要一...

快递查询单号查询,怎么查物流到哪了

输入单号怎么查快递到哪里去了呢?今天小编给大家分享一个新的技巧,它支持多家快递,一次能查询多个单号物流,还可对查询到的物流进行分析、筛选以及导出,下面一起来试试。需要哪些工具?安装一个快递批量查询高手...

3分钟查询物流,教你一键批量查询全部物流信息

很多朋友在问,如何在短时间内把单号的物流信息查询出来,查询完成后筛选已签收件、筛选未签收件,今天小编就分享一款物流查询神器,感兴趣的朋友接着往下看。第一步,运行【快递批量查询高手】在主界面中点击【添...

快递单号查询,一次性查询全部物流信息

现在各种快递的查询方式,各有各的好,各有各的劣,总的来说,还是有比较方便的。今天小编就给大家分享一个新的技巧,支持多家快递,一次能查询多个单号的物流,还能对查询到的物流进行分析、筛选以及导出,下面一起...

快递查询工具,批量查询多个快递快递单号的物流状态、签收时间

最近有朋友在问,怎么快速查询单号的物流信息呢?除了官网,还有没有更简单的方法呢?小编的回答当然是有的,下面一起来看看。需要哪些工具?安装一个快递批量查询高手多个京东的快递单号怎么快速查询?进入快递批量...

快递查询软件,自动识别查询快递单号查询方法

当你拥有多个快递单号的时候,该如何快速查询物流信息?比如单号没有快递公司时,又该如何自动识别再去查询呢?不知道如何操作的宝贝们,下面随小编一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号若干...

教你怎样查询快递查询单号并保存物流信息

商家发货,快递揽收后,一般会直接手动复制到官网上一个个查询物流,那么久而久之,就会觉得查询变得特别繁琐,今天小编给大家分享一个新的技巧,下面一起来试试。教程之前,我们来预览一下用快递批量查询高手...

简单几步骤查询所有快递物流信息

在高峰期订单量大的时候,可能需要一双手当十双手去查询快递物流,但是由于逐一去查询,效率极低,追踪困难。那么今天小编给大家分享一个新的技巧,一次能查询多个快递单号的物流,下面一起来学习一下,希望能给大家...

物流单号查询,如何查询快递信息,按最后更新时间搜索需要的单号

最近有很多朋友在问,如何通过快递单号查询物流信息,并按最后更新时间搜索出需要的单号呢?下面随小编一起来试试吧。需要哪些工具?安装一个快递批量查询高手快递单号若干怎么快速查询?运行【快递批量查询高手】...

连续保存新单号功能解析,导入单号查询并自动识别批量查快递信息

快递查询已经成为我们日常生活中不可或缺的一部分。然而,面对海量的快递单号,如何高效、准确地查询每一个快递的物流信息,成为了许多人头疼的问题。幸运的是,随着科技的进步,一款名为“快递批量查询高手”的软件...

快递查询教程,快递单号查询,筛选更新量为1的单号

最近有很多朋友在问,怎么快速查询快递单号的物流,并筛选出更新量为1的单号呢?今天小编给大家分享一个新方法,一起来试试吧。需要哪些工具?安装一个快递批量查询高手多个快递单号怎么快速查询?运行【快递批量查...

掌握批量查询快递动态的技巧,一键查找无信息记录的两种方法解析

在快节奏的商业环境中,高效的物流查询是确保业务顺畅运行的关键。作为快递查询达人,我深知时间的宝贵,因此,今天我将向大家介绍一款强大的工具——快递批量查询高手软件。这款软件能够帮助你批量查询快递动态,一...

从复杂到简单的单号查询,一键清除单号中的符号并批量查快递信息

在繁忙的商务与日常生活中,快递查询已成为不可或缺的一环。然而,面对海量的单号,逐一查询不仅耗时费力,还容易出错。现在,有了快递批量查询高手软件,一切变得简单明了。只需一键,即可搞定单号查询,一键处理单...

物流单号查询,在哪里查询快递

如果在快递单号多的情况,你还在一个个复制粘贴到官网上手动查询,是一件非常麻烦的事情。于是乎今天小编给大家分享一个新的技巧,下面一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号怎么快速查询?...

取消回复欢迎 发表评论: