前言
本文是 Performance Improvements in .NET 7 Folding(折疊), propagation(傳播), and substitution(替換) 部分的翻譯.下面開始正文:
//原文地址:
// https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/
常量折疊是一種優(yōu)化,編譯器在編譯時計算只涉及常量的表達式的值,而不是在運行時生成代碼來計算值.在.Net中有多個級別的常量折疊,其中一些常量折疊由C#編譯器執(zhí)行,另一些常量折疊由JIT編譯器執(zhí)行.例如給定C#代碼:
[Benchmark]
public int A() => 3 + (4 * 5);
[Benchmark]
public int B() => A() * 2;
C#編譯器將為這些方法生成IL代碼,如下所示:
.method public hidebysig instance int32 A () cil managed
{
.maxstack 8
IL_0000: ldc.i4.s 23 //在編譯時,由編譯器計算值
IL_0002: ret
}
.method public hidebysig instance int32 B () cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance int32 Program::A() //調(diào)用方法A,可以看到?jīng)]有常量折疊和常量傳播
IL_0006: ldc.i4.2
IL_0007: mul
IL_0008: ret
}
您可以看到,C#編譯器已經(jīng)計算出了 3 +(4*5)的值,因為方法A的IL包含了等價的 return 23;但是,方法 B包含等價的 return A()* 2; ,強調(diào)C#編譯器執(zhí)行的常量折疊只是在方法內(nèi)部.下面是JIT生成的結(jié)果:
// Program.A()
mov eax,17 //17是十六進制,為十進制的23
ret
// Total bytes of code 6
// Program.B()
mov eax,2E //2E為十六進制,為十進制的46
ret
// Total bytes of code 6
方法A的匯編代碼是不是特別有趣,它只是返回相同的值23(十六進制0x17).但是方法B更有趣.JIT內(nèi)聯(lián)了從 B到 A的調(diào)用,將 A的內(nèi)容暴露給 B,這樣JIT就有效地將 B的主體看作是等價于 return 23 * 2;此時,JIT可以完成自己的常量折疊,并將B的主體轉(zhuǎn)換為簡單地返回46(十六進制0x2e).常量傳播與常量折疊有著錯綜復雜的聯(lián)系,本質(zhì)上就是可以將常量值(通常是通過常量折疊計算的值)替換為進一步的表達式,此時它們也可以被折疊.
JIT長期以來一直在執(zhí)行常量折疊,但它在.NET7中得到了進一步改進.常量折疊可以改進的方法之一是公開更多要折疊的值,這意味著更多的內(nèi)聯(lián).dotnet/runtime#55745幫助理解內(nèi)聯(lián),像M(constant + constant) (注意這些常量可能是其他方法調(diào)用的結(jié)果)這樣的方法調(diào)用本身就是將常量傳遞給M,而傳遞給方法調(diào)用的常量是對內(nèi)聯(lián)線的提示,它應該考慮更積極地內(nèi)聯(lián),因為將該常量公開給被調(diào)用方的主體可能會顯著減少實現(xiàn)被調(diào)用方所需的代碼量.JIT之前可能已經(jīng)內(nèi)聯(lián)了這樣一個方法,但當涉及內(nèi)聯(lián)時,JIT都是關(guān)于啟發(fā)式和生成足夠的證據(jù)來證明內(nèi)聯(lián)是值得的;這有助于證明這一點.例如,該模式顯示在TimeSpan上的各種FromXx方法中.例如TimeSpan.FromSeconds實現(xiàn)為:
// TicksPerSecond is a constant
public static TimeSpan FromSeconds(double value) => Interval(value, TicksPerSecond);
并且,為了本例的目的,避免參數(shù)驗證, Interval為:
private static TimeSpan Interval(double value, double scale) =>
IntervalFromDoubleTicks(value * scale);
private static TimeSpan IntervalFromDoubleTicks(double ticks) =>
ticks == long.MaxValue ? TimeSpan.MaxValue : new TimeSpan((long)ticks);
如果所有內(nèi)容都內(nèi)聯(lián),則FromSeconds本質(zhì)上是:
public static TimeSpan FromSeconds(double value)
{
double ticks = value * 10_000_000;
return ticks == long.MaxValue ? TimeSpan.MaxValue : new TimeSpan((long)ticks);
}
如果value是一個常量,比如5,那么這里就可以被折疊成常量(在ticks == long.MaxValue分支上消除死代碼)簡單地:
return new TimeSpan(50_000_000);
為此,我將省去.Net 6生成匯編代碼,但在.Net 7中,有這樣一個基準測試:
[Benchmark]
public TimeSpan FromSeconds() => TimeSpan.FromSeconds(5);
我們現(xiàn)在得到的是簡單明了的匯編代碼:
// Program.FromSeconds()
mov eax,2FAF080 //2FAF080為5*1000*1000
ret
// Total bytes of code 6
另一個改進常量折疊的更改包括@SingleAccretion的dotnet/runtime#57726,它在特定的場景中消除了常量折疊,有時在對從方法調(diào)用返回的結(jié)構(gòu)進行逐字段賦值時顯示.作為一個小例子,考慮這個訪問Color.DarkOrange屬性,它會產(chǎn)生new Color(KnownColor.DarkOrange):
[Benchmark]
public Color DarkOrange() => Color.DarkOrange;
在.Net 6中,JIT生成如下代碼:
// Program.DarkOrange()
mov eax,1
mov ecx,39
xor r8d,r8d
mov [rdx],r8
mov [rdx+8],r8
mov [rdx+10],cx
mov [rdx+12],ax
mov rax,rdx
ret
// Total bytes of code 32
有趣的是,有些常量(39是KnownColor.DarkOrange常量值,1是私有StateKnownColorValid常量值)被加載到寄存器(mov eax,1,然后mov ecx,39)中,然后被存儲到返回的Color結(jié)構(gòu)的相關(guān)位置(mov[rdx+12],ax和mov[rdx+10],cx). 在.NET 7中,它現(xiàn)在生成:
// Program.DarkOrange()
xor eax,eax
mov [rdx],rax
mov [rdx+8],rax
mov word ptr [rdx+10],39
mov word ptr [rdx+12],1
mov rax,rdx
ret
// Total bytes of code 25
直接將這些常量值賦值到它們的目標位置(mov word ptr [rdx+12],1和mov word ptr [rdx+10],39).其他變化貢獻常量折疊包括dotnet/runtime#58171從@SingleAccretion和dotnet/runtime#57605從@SingleAccretion .
然而,一個很大的改進類別來自與傳播相關(guān)的優(yōu)化,即前向替換.考慮一下這個不太好的基準測試:
[Benchmark]
public int Compute1() => Value + Value + Value + Value + Value;
[Benchmark]
public int Compute2() => SomethingElse() + Value + Value + Value + Value + Value;
private static int Value => 16;
[MethodImpl(MethodImplOptions.NoInlining)]
private static int SomethingElse() => 42;
如果我們看一下在.Net 6上為Compute1生成的匯編代碼,它看起來就像我們所希望的那樣。我們把Value相加了5次, Value被簡單地內(nèi)聯(lián)并返回一個常量16,所以我們希望為Compute1生成的匯編代碼能夠有效地返回值80(十六進制0x50),這正是所發(fā)生的:
// Program.Compute1()
mov eax,50 //內(nèi)聯(lián)后為80(16進制是0x50)
ret
// Total bytes of code 6
但是Compute2生成匯編代碼有點不同.代碼的結(jié)構(gòu)是這樣的,對SomethingElse的額外調(diào)用最終會略微干擾JIT的分析,而.Net 6最終會得到這樣的匯編代碼:
// Program.Compute2()
sub rsp,28
call Program.SomethingElse()
add eax,10 //10為16進制16的值
add eax,10
add eax,10
add eax,10
add eax,10
add rsp,28
ret
// Total bytes of code 29
而不是單個mov eax,50將值0x50放入返回寄存器,分別為5個單獨的add eax, 10生成最終結(jié)果0x50(80)值.這個相加的過程是不理想.
事實證明,JIT的許多優(yōu)化操作的是作為解析IL的一部分創(chuàng)建的樹數(shù)據(jù)結(jié)構(gòu).在某些情況下,當它們所操作的樹更大,包含更多要分析的內(nèi)容時,優(yōu)化可以做得更好.但是,各種操作可以將這些樹分解為更小的、單獨的樹,例如使用作為內(nèi)聯(lián)一部分創(chuàng)建的臨時變量,這樣做可以抑制這些操作.為了有效地將這些樹組合一起,需要一些東西,那就是正向替換.你可以把正向替換想象成逆向的CSE(公共表達式消除);與嘗試查找重復表達式并通過一次計算值并將其存儲到臨時值中來消除它們不同,正向替換消除了臨時值,并有效地將表達式樹移動到它的使用站點.顯然,如果這樣做會否定CSE并導致重復的工作,您就不希望這樣做,但是對于只定義一次并使用一次的表達式,這種向前傳播是有價值的.
dotnet /runtime#61023添加了一個初始的有限版本的前向替換,然后dotnet /runtime#63720添加了一個更健壯的通用實現(xiàn).隨后,dotnet/runtime#70587對其進行了擴展,使其也涵蓋了一些SIMD向量,然后dotnet/runtime#71161對其進行了進一步改進,以支持替換到更多的位置(在本例中為調(diào)用實參).有了這些,我們的基準測試現(xiàn)在在.Net 7中生成了以下代碼:
// Program.Compute2()
sub rsp,28
call qword ptr [7FFCB8DAF9A8]
add eax,50 //在.Net 6生成匯編代碼,需要5次add相加操作,這里直接用5次相加的值
add rsp,28
ret
// Total bytes of code 18