C#編譯期執行了哪些優化,對代碼做了哪些改變?

比如內聯,代碼順序等等。


題主給這個問題打上了「即時編譯(JIT)」的標籤,但是問題是:

c#編譯期執行了哪些優化,對代碼做了哪些改變?

比如內聯,代碼順序等等。

得確定題主問的是從C#源碼編譯到MSIL階段的編譯,還是從MSIL編譯到機器碼階段的編譯。

一般的C#寫的桌面/伺服器端應用,在微軟實現上,會經歷兩段編譯:

  • 源碼到MSIL:Roslyn(或者以前老的csc)
  • MSIL到機器碼:CLR / CoreCLR的RyuJIT做JIT編譯(或者以前老的JIT32 / JIT64)

(這兩者在Mono里都有對應的實現)

這兩個階段都會有若干優化。不過當然對性能提升比較重要的優化主要是在MSIL到機器碼階段做的。

對Roslyn感興趣的話,請參考 Roslyn Overview 文檔,並在Roslyn源碼里搜索 OptimizationLevel.Release 出現的地方。這些地方比較分散,都是些零散的優化;或者反過來說,OptimizationLevel.Debug 是為了更方便生成易於調試的代碼用的,而Release才是「正常」的版本。

對RyuJIT感興趣的話,請參考 RyuJIT Overview 文檔。這個文檔已經描述了大部分題主會關心的優化的概況了。

當然C#不只有Roslyn + CLR / CoreCLR這麼一種實現。

還有一種有趣的不同的做法,例如 .NET Native / CoreRT 的AOT編譯。

它會做的與JIT方案顯著不同的一種優化叫做Dependency Reduce,或者叫做tree-shaking。這是什麼意思請參考.NET Native的介紹:The .NET Native Tool-Chain


沒有研究過這個問題,但是從多年分析IL代碼的經驗看,還是有些優化,比如靜態表達式求值,將字元串放入常量表,冗餘變數清理,不可到達代碼路徑清理。

比如如下程序

static void Main(string[] args)
{
int i = 1;
int j = 2;
int k = i + j;
int l = 3;
for (; false; ) ;
if (false)
{
Console.WriteLine();
}
Console.WriteLine("Hello world");
}

使用release配置編譯以後用ilspy反編譯,結果是

private static void Main(string[] args)
{
Console.WriteLine("Hello world");
}

生成的IL是

.method private hidebysig static
void Main (
string[] args
) cil managed
{
// Method begins at RVA 0x2050
// Code size 15 (0xf)
.maxstack 1
.entrypoint
.locals init (
[0] int32 i,
[1] int32 j
)

IL_0000: ldc.i4.1
IL_0001: stloc.0
IL_0002: ldc.i4.2
IL_0003: stloc.1
IL_0004: ldstr "Hello world"
IL_0009: call void [mscorlib]System.Console::WriteLine(string)
IL_000e: ret
}

顯然其它的都優化掉了。


優化別的答主講的都差不多了,我來提點別的(沒什麼意義的):高級語言功能的展開

IL本身的控制流結構類似CPU指令集,只有條件轉移和無條件轉移,因此分支、循環等結構都要被展開;另外,很多高級語法功能本身也是語法糖,從is、foreach、using這種簡單pattern開始,到yield、async這種涉及到控制流變換的,都由C#編譯器完成。


理解題主的意思應該是涵蓋了 C# -&> MSIL 的過程,也涵蓋了 MSIL -&> Native machine code 的過程。以下的內容大部分是從我的 blog 搬運過來的,權當回答。

對於前者 C# -&> MSIL

編譯器對代碼的優化是有限的。例如 我們通過添加 /optimize (What does the optimize switch do?) 參數打開代碼優化開關。這個開關完成了兩個任務。第一個任務就是優化 IL 代碼。而這項對性能提升的影響並不大。這是因為 CLR 應用的性能提升主要是由於 JIT 編譯器而非具體的語言編譯器完成的。語言編譯器所完成的優化是很有限的,例如(不限於下面這些):

* 當一個表達式的邏輯僅僅有一個作用而無其他副作用的時候將只會把產生這些副作用的代碼進行生成;

* 忽略無用的賦值。例如 int foo = 0。因為我們知道 memory allocator 會將其初始化為 0(註:這是 IL 一級而非語言一級的);

* 當靜態類沒有需要初始化的 field,或者 field 初始化為默認值時忽略對靜態構造函數的生成;

* 忽略迭代中的局部未引用的變數(包括在僅僅在閉包的迭代中的未引用的外部變數);

* 復用函數的棧空間(局部變數復用,刪除未使用的局部變數);

* 減少對局部變數(例如 if 和 switch 表達式的結果,以及函數調用的返回值)存儲的要求而盡量的使用棧空間;

* 優化 branch 跳轉指令;

這些優化都非常的直接,如果查看程序集的 IL 語句,會發現 /optimize 打開或關閉的情況下生成的代碼幾乎是相同的。不會有 IL 內聯優化和循環的展開這種高級優化。因此性能提升不大。

但是,有一條 IL 優化對性能提升做出了相當的貢獻,這裡特別介紹一下:

* 刪除了一部分為 breakpoints 定位以及 edit and continue 而插入的 nop 指令(nop 指令除了支持 edit and continue 之外還起到了在 PDB 文件中進行定位的作用。但是 JIT 編譯器不會跨越這種 nop 指令進行代碼優化,刪除了這些指令可以保證 JIT 編譯器可以在更大的範圍內優化代碼)。

對於後者 MSIL -&> Native code

JIT 編譯器的 optimization 相關信息可以從很多文章中找到,例如:

JIT Optimizations

雖然文章久了些但是內容還是很充實的。另外推薦去在 MSDN blog 上搜 JIT 關鍵字:

jit | .NET Blog

場景之一是自己的代碼中包含比較多的演算法成分(並不是調用系統或者第三方庫的演算法而是自己實現演算法)。演算法中最典型的即極多的迭代操作和內存讀寫,因而我們選擇插入排序作為測試演算法。

// sample code
int length = collection.Count;
for (int outerIndex = 0; outerIndex &< length; ++outerIndex) { int minimumIndex = outerIndex; T minimum = collection[outerIndex]; for (int innerIndex = outerIndex + 1; innerIndex &< length; ++innerIndex) { if (collection[innerIndex].CompareTo(minimum) &>= 0)
{
continue;
}

minimumIndex = innerIndex;
minimum = collection[innerIndex];
}

Utility.Swap(collection, outerIndex, minimumIndex);
}

測試結果如下:

Iteration on value type test (selection sort on 20000 32-bit int array)

* Debug build: 4.56s

* Release build: 1.81s

我們必須確認 turn on optimize 後的執行速度提升確實發生在迭代和內存讀寫上。通過 Profiling 我們可以證實這一猜想。其性能提升主要發生在循環體迭代,也就是 for (int outerIndex = 0; outerIndex &< length; ++outerIndex),數組數據讀寫,以及細小方法調用 collection[innerIndex].CompareTo(minimum) 上。其優化手法主要是盡量使用寄存器而不是內存定址。

例如,內層循環 for (int innerIndex = outerIndex + 1; innerIndex &< length; ++innerIndex) 在 optimized build 下被編譯為:

// outerIndex + 1
00007FFCBA5746F2 inc ebx
// stack pointer change
00007FFCBA5746F4 inc ebp
// compare innerIndex to length
00007FFCBA5746F6 cmp ebx,esi
00007FFCBA5746F8 jl 00007FFCBA5746A0

而非 optimize build 是這樣的

// read outerIndex to eax, increase eax then stores the value back
00007FFCBA594BA5 mov eax,dword ptr [rbp+7Ch]
00007FFCBA594BA8 inc eax
00007FFCBA594BAA mov dword ptr [rbp+7Ch],eax
// set ecx to 0
00007FFCBA594BAD xor ecx,ecx
// load length to eax
00007FFCBA594BAF mov eax,dword ptr [rbp+8Ch]
// compare with increased outerIndex and to set the flag, move the flag value to eax and test if the value is true or not
00007FFCBA594BB5 cmp dword ptr [rbp+7Ch],eax
00007FFCBA594BB8 setl cl
00007FFCBA594BBB mov dword ptr [rbp+64h],ecx
00007FFCBA594BBE movzx eax,byte ptr [rbp+64h]
00007FFCBA594BC2 mov byte ptr [rbp+77h],al
00007FFCBA594BC5 movzx eax,byte ptr [rbp+77h]
00007FFCBA594BC9 test eax,eax
00007FFCBA594BCB jne 00007FFCBA594B04

JIT 還將 int.CompareTo 的調用進行了內聯。在本例中,其貢獻達到了 50% 左右,但是這個提升只在所有操作都基本是細小操作的時候才會顯現。

從上述分析中不難看出,/optimize 對迭代中的內存操作的優化非常有效,因此如果我們迭代的並非 value type 而是需要多次進行定址(因為要不斷的使用其 field 值)的 reference type 則性能提升也會非常明顯。類似的操作還例如 DTO 之間的映射,這個操作也屬於迭代式的內存密集形操作。

另一種場景是第三方的庫調用,但是不論是頻繁還是非頻繁調用,代碼優化的空間並不大。因為這主要取決於第三方庫的優化程度。

另一種場景是頻繁的閉包調用。我們關注頻繁的閉包調用,因為 LINQ 以及事件處理已經得到了非常廣泛的應用。其典型形式是使用匿名函數或 lambda 表達式作為回調方法。回調方法往往執行數據的加工(Select)或者篩選(Where)。

由於使用 LINQ 就是庫調用,因此迭代的優化不論非 optimize 還是 optimize 都會發生,唯一的優化空間只是匿名委託的內聯以及寄存器的使用,但這樣也不會帶來什麼性能提升,因為大多數情況下匿名函數的執行時間要比 call 長的多。因此優不優化性能指標是比較接近的。

因此如果討論對代碼的影響。JIT 編譯器對 BCL 以及 Release build 下的第三方庫調用影響並不大,因為本地代碼本身並不佔有很多的比重,典型的情形例如資料庫查詢。但是對於本地代碼佔有很高比重,且其中包含大量的迭代和內存操作的情形(光線追蹤,服務端頁面生成(非預編譯的情形),批量 DTO / Entity 映射)的可以起到比較不錯的優化效果。


推薦閱讀:

一個類C的編譯器大概有多少行?
如何開發編譯器?
constexpr對編譯時間影響大嗎?
深入研究編譯器、程序設計語言理論須要學習哪些數理邏輯學的內容?

TAG:即時編譯JIT | C# | 編譯器 |