在.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(预先编译)作为Crossgen2 和 R2R 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
读完上边的汇编代码,
- 发现在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#50675
- dotnet/runtime#51124
- dotnet/runtime#52708
- dotnet/runtime#53670
- 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方法,这里减少了方法调用和分支的开销.在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>(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改进做出了贡献,如下边这些:
- 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<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的单号呢?今天小编给大家分享一个新方法,一起来试试吧。需要哪些工具?安装一个快递批量查询高手多个快递单号怎么快速查询?运行【快递批量查...
- 掌握批量查询快递动态的技巧,一键查找无信息记录的两种方法解析
-
在快节奏的商业环境中,高效的物流查询是确保业务顺畅运行的关键。作为快递查询达人,我深知时间的宝贵,因此,今天我将向大家介绍一款强大的工具——快递批量查询高手软件。这款软件能够帮助你批量查询快递动态,一...
- 从复杂到简单的单号查询,一键清除单号中的符号并批量查快递信息
-
在繁忙的商务与日常生活中,快递查询已成为不可或缺的一环。然而,面对海量的单号,逐一查询不仅耗时费力,还容易出错。现在,有了快递批量查询高手软件,一切变得简单明了。只需一键,即可搞定单号查询,一键处理单...
- 物流单号查询,在哪里查询快递
-
如果在快递单号多的情况,你还在一个个复制粘贴到官网上手动查询,是一件非常麻烦的事情。于是乎今天小编给大家分享一个新的技巧,下面一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号怎么快速查询?...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- wireshark怎么抓包 (75)
- qt sleep (64)
- cs1.6指令代码大全 (55)
- factory-method (60)
- sqlite3_bind_blob (52)
- hibernate update (63)
- c++ base64 (70)
- nc 命令 (52)
- wm_close (51)
- epollin (51)
- sqlca.sqlcode (57)
- lua ipairs (60)
- tv_usec (64)
- 命令行进入文件夹 (53)
- postgresql array (57)
- statfs函数 (57)
- .project文件 (54)
- lua require (56)
- for_each (67)
- c#工厂模式 (57)
- wxsqlite3 (66)
- dmesg -c (58)
- fopen参数 (53)
- tar -zxvf -c (55)
- 速递查询 (52)