這篇文章發布于我的 github 博客:原文
在真正開始討論之前先定義一下 Scope。
- 本文討論的范圍限于執行速度,內存占用什么的不在評估的范圍之內。
- 本文不討論算法:編譯器帶來的優化基本上屬于底層的優化,難以從質上提升執行速度。程序的快慢主要影響因素是采用的數據結構和算法這些高層次上的東西。我們接下來的討論建立在這些高層次的東西已經被充分考慮的基礎之上。
目錄
- .NET 的 Debug 和 Release build 對執行速度的影響
- 如果你沒有時間
- Debug 和 Release build 的主要差異
- 觀察 JIT 優化的代碼
- JIT 優化對不同場景的影響
- 迭代和內存操作
- 非頻繁的庫調用
- 頻繁的庫調用
- 頻繁的閉包調用
- 結論
如果你沒有時間
那么答案是:
- 對執行速度有影響。影響主要是由 JIT 而非 IL 編譯器引入的。
- 一般來說 Release build 使得本程序集內的代碼執行速度更快,而對第三方庫的調用則幾乎沒有影響。
- 對于本程序集內代碼來說,Release build 對迭代和內存操作加速效果比較明顯。
Debug 和 Release build 的主要差異
Debug 和 Release build 的一個最主要的區別是 Release build 會添加 /optimize 選項。這個選項完成了兩個任務:優化 IL 代碼以及添加元數據。有意思的是第一項對性能提升的影響并不大。這是因為 CLR 應用的性能提升主要是由于 JIT 編譯器而非具體的語言編譯器完成的。語言編譯器所完成的優化是很有限的,例如(不限于下面這些):
- 當一個表達式的邏輯僅僅有一個作用而無其他副作用的時候將只會把產生這些副作用的代碼進行生成;
- 忽略無用的賦值。例如
int foo = 0
。因為我們知道 memory allocator 會將其初始化為 0(注:這是 IL 一級而非語言一級的); - 當靜態類沒有需要初始化的 field,或者 field 初始化為默認值時忽略對靜態構造函數的生成;
- 忽略迭代中的局部未引用的變量(包括在僅僅在閉包的迭代中的未引用的外部變量);
- 復用函數的棧空間(局部變量復用,刪除未使用的局部變量);
- 減少對局部變量(例如
if
和switch
表達式的結果,以及函數調用的返回值)存儲的要求而盡量的使用棧空間; - 優化 branch 跳轉指令;
這些優化都非常的直接,如果查看程序集的 IL 語句,會發現 /optimize 打開或關閉的情況下生成的代碼幾乎是相同的。不會有 IL 內聯優化和循環的展開這種高級優化。因此性能提升不大。
但是,有一條 IL 優化對性能提升做出了相當的貢獻,這里特別介紹一下:
- 刪除了一部分為 breakpoints 定位以及 edit and continue 而插入的 nop 指令,若知詳情如何,請看下面的 Tips。
真正起做用的是 /optimize 選項的第二個任務,更改 DebuggableAttribute
的參數。在添加 /optmize 的情況下該參數為 IgnoreSymbolStoreSequencePoints
,而不會包含 DisableOptimizations
與 EnableEditAndContinue
。這會實際的影響 JIT 編譯器生成代碼的策略。
Tip:
MSDN 對
IgnoreSymbolStoreSequencePoints
解釋為:使用 MSIL 的序列點而非 PDB 的序列點。JIT 編譯器不會將兩個序列點的進行合并優化編譯,因而使用 PDB 文件中提供的序列點就可以保證編譯結果和 PDB 嚴格對應從而提供更好的 Debug 體驗。有的同學就開始激動了,那么我如果使用 Debug Build 但是將 PDB 刪除是否能夠有和 optimize 一樣的性能呢?顯然不是的!由于加載 PDB 引發的這種性能問題在 .NET 2.0 的時候就已經解決了。解決的方式就是在 Debug build 中添加了 nop 指令作為隱式的序列點。從那之后,即使在 Debug build 下,其
DebuggingModes
也會包含IgnoreSymbolStoreSequencePoints
選項了(因為根本沒有必要加載 PDB)。此時應該明白了為何在 Release build 下刪除了一些 nop 指令會使得執行效率得到提升,因為刪除 nop 指令使得 JIT 編譯器可以在相對大的范圍內進行代碼優化。
但是如果入口應用程序是 Debug build 會不會影響 .NET BCL 或者第三方庫的 JIT 編譯結果呢?這是不會的,因為這個屬性是 assembly scope 的,只要你使用的是 optimize 過的第三方庫都會得到優化的 JIT 代碼。
觀察 JIT 優化的代碼
本文不會廣泛展示 JIT 優化的結果,但是如果你希望對比一下 Debug 和 Release build 下的 JIT 編譯結果必須首先更改 Visual Studio 的默認 Debug 設置。
在 Visual Studio 中選擇 Tools -> Options -> Debugging -> General。
- 取消 Enable Just My Code(這是因為優化的代碼不屬于 My Code 的范疇)
- 取消 Suppress JIT optimization on module load (Managed only)(防止在 Visual Studio 啟動項目時阻止 JIT 優化)
至此就可以使用 Visual Studio 的調試器,在斷點命中時通過 disassembly 窗口觀看優化后的匯編代碼了。
JIT 優化對不同場景的影響
即便我們認識到開啟 optimize 有可能使執行速度得到提升,但是在不同的使用場景下,其提升效果是不同的。
迭代和內存操作
場景之一是自己的代碼中包含比較多的算法成分(并不是調用系統或者第三方庫的算法而是自己實現算法)。算法中最典型的即極多的迭代操作和內存讀寫,因而我們選擇插入排序作為測試算法。
// 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
我們必須確認兩種不同的 build 的執行速度提升確實發生在迭代和內存讀寫上。通過 Profiling 我們可以證實這一猜想。其性能提升主要發生在循環體迭代,也就是 for (int outerIndex = 0; outerIndex < length; ++outerIndex)
,數組數據讀寫,以及細小方法調用 collection[innerIndex].CompareTo(minimum)
上。其優化手法主要是盡量使用寄存器而不是內存尋址。
例如,內層循環 for (int innerIndex = outerIndex + 1; innerIndex < length; ++innerIndex)
在 Release build 下被編譯為:
// outerIndex + 1
00007FFCBA5746F2 inc ebx
// stack pointer change
00007FFCBA5746F4 inc ebp
// compare innerIndex to length
00007FFCBA5746F6 cmp ebx,esi
00007FFCBA5746F8 jl 00007FFCBA5746A0
而 Debug 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 則性能提升也會非常明顯。
Iteration on reference type test (selection sort on 20000 ref instance array. The ref type contains 1 int field)
- Debug build: 11.57s
- Release build: 4.00s
類似的操作還例如 DTO 之間的映射,這個操作也屬于迭代式的內存密集形操作。在如下的測試代碼:
var source = Enumerable.Range(0, dataAmount)
.Select(
i => new Dto
{
Name = new NameDto
{
FirstName = "firstname" + i,
Middle = "Q",
LastName = "lastname" + i
},
Age = 20 + i % 10
});
var destination = source.Select(
e => new
{
FirstName = e.Name.FirstName,
MiddleName = e.Name.Middle,
LastName = e.Name.LastName,
Age = e.Age
});
m_count = destination.Count();
每一次 5,000,000 個迭代測試的情況下能夠獲得 15% 以上的執行速度提升。其主要的優化手段仍然是盡量的使用寄存器。
非頻繁的庫調用
該場景下僅對系統或者第三方庫進行非頻繁調用。非頻繁的調用有兩種情況,第一種情況屬于調用的方法仍是有相當復雜程度的算法,這是非頻繁調用的常見情況;第二種是非頻繁調用的方法也非常簡單,但是這對性能影響不大因此我們只關注第一種情況。
在測試之前,不妨預測一下,由于我們系統 BCL 和第三方庫均使用 /optimize 進行 build,因此對于非頻繁的庫調用,我們的代碼優化的空間并不大,性能數據應當非常接近。以下是測試結果。
Infrequent lib calls (quick sort 9,000,000 integers x 5 runs)
- Debug build: 6.37s
- Release build: 6.62s
頻繁的庫調用
頻繁的庫調用往往包含對細小的操作進行的調用。我們著重關注 Parsing 和 ToString 這兩種常見的操作。這是因為在 Web App 中,Serialize - deserialize 是最頻繁而常見的操作。
同樣我們可以預測執行的結果。由于是頻繁操作,因此迭代部分的性能會有一些增強。但是相比于迭代,庫調用的時間要長的多,因此這種性能增益幾乎是不可見的。可以預見其性能數據應當是非常接近的。
測試代碼范例:
double next = 1d + random.NextDouble();
total += double.Parse(next.ToString(CultureInfo.InvariantCulture));
Frequent lib calls (serialize/deserialize
double
x 5,000,000 times)
- Debug build: 6.89s
- Release build: 6.77s
為了保證測試的有效性我們仍然需要確認性能的消耗主要發生在 serialize - deserialize 上。Profile 結果和我們預想是一致的:
for (int i = 0; i < iterationCount; ++i) // 0.5%
{
double next = 1d + random.NextDouble(); // 1.3%
total += double.Parse(
next.ToString(CultureInfo.InvariantCulture)); // 98.2%
}
頻繁的閉包調用
我們關注頻繁的閉包調用,因為 LINQ 以及事件處理已經得到了非常廣泛的應用。其典型形式是使用匿名函數或 lambda 表達式作為回調方法。回調方法往往執行數據的加工(Select)或者篩選(Where)。
由于使用 LINQ 就是庫調用,因此迭代的優化不論 Debug 還是 Release build 都會發生,唯一的優化空間只是匿名委托的內聯以及寄存器的使用,但這樣也不會帶來什么性能提升,因為大多數情況下匿名函數的執行時間要比 call 長的多。可以預見,Debug build 和 Release build 的性能指標是比較接近的。
測試代碼范例:
IEnumerable<double> enumerable = Enumerable
.Range(1, iterationAmount)
.Select(
i =>
{
string str = i.ToString(CultureInfo.InvariantCulture);
int operand = int.Parse(str);
return Math.Pow(operand, factor);
})
.Where(i => i > 0.2);
double total = enumerable.Average();
Frequent closure calls (for 8,000,000 iterations)
- Debug build: 4.51s
- Release build: 4.47s
結論
可見 JIT 編譯器對 BCL 以及 Release build 下的第三方庫調用影響并不大,因為本地代碼本身并不占有很多的比重,典型的情形例如數據庫查詢。但是對于本地代碼占有很高比重,且其中包含大量的迭代和內存操作的情形(光線追蹤,服務端頁面生成(非預編譯的情形),批量 DTO / Entity 映射)的可以起到比較不錯的優化效果。
因此,從執行速度的角度上考慮,推薦在 Package/Deployment 的時候切換至 Release build。
文章列表