如果問題是C#怎麼才能和C++一樣快,那麼真正的問題就是C#到底是慢在哪。內聯是諸多影響C#效能中的一個,如果頻繁調用的大量小函數沒有內聯,那麼對效能的影響是非常大的,因為建棧、刪棧、壓棧和跳轉的時間加起來很可能比實際執行函數體的時間還長。
在實際的應用中,Milo Yip的《C++/C# /F#/Java/JS/Lua/Python/Ruby渲染比試》是非常好的例子,典型的計算密集的應用,裡面有大量向量計算的小函數調用。結果C#的表現令人失望,效能落後VC++版本一倍還多,即使我改成struct out ref的形式(代碼請參見Milo文章)雖然效能略有提高但是差距仍然較大。首先想到是否因為.NET CLR沒有內聯這些小函數導致的這個效能差異呢。實踐出真知,趕快調試看看,不知道如何看JIT產生的ASM的同學可以看Clayman的這篇文章。結果是我猜錯了,.NET的JIT編譯器已經內聯了這些函數。如下面向量按分量乘法的調用處:
Vec.mul(out rad, ref f, ref rad);
0000067e fld qword ptr [ebp-78h]
00000681 fmul qword ptr [ebp+FFFFFF58h]
00000687 fstp qword ptr [ebp+FFFFFF58h]
0000068d fld qword ptr [ebp-70h]
00000690 fmul qword ptr [ebp+FFFFFF60h]
00000696 fstp qword ptr [ebp+FFFFFF60h]
0000069c fld qword ptr [ebp-68h]
0000069f fmul qword ptr [ebp+FFFFFF68h]
000006a5 fstp qword ptr [ebp+FFFFFF68h]
看來並不是因為沒有內聯而造成的效能差異,不禁要深入思考下內聯的問題,一定不是所有的函數都會內聯的,那麼究竟.NET JIT內聯的規則是什麼呢。一定有比擲骰子更高明點的辦法。Google找到了一篇關於.NET CLR的內聯問題好文章,《Inline or not to Inline: That is the question》 博主Vance Morrison號稱是.NET Runtime的架構師,並且主要關注.NET Runtime的效能問題。聽起來很牛哦。以下是他的主要觀點:
內聯並不總是好的,內聯的確會減少總的運行指令數。但是另一方面會增大代碼尺寸,這在代碼量比較大的時候可能會降低指令cache的命中率,如果L1 cache miss了需要從L2讀指令的情況會浪費3-10個刻度,而如果L2也Miss了需要從記憶體讀的話浪費的更多。而且更大的代碼尺寸會降低程式啟動的速度。.NET JIT取消了對於多大函數可以內聯的硬性規則,.NET項目組對應何種情況應該內聯做了大量實驗,JIT在決定是否進行inline是沒有足夠的資訊得知整個程式的運行流程,所以結果不會總是對的,但以下是顯而易見的:
1.如果內聯減小了代碼的大小,那麼一定會內聯。注意我們說的尺寸是指機器碼(Native)的尺寸而不是IL代碼的尺寸。
2.調用越頻繁的函數越可能被內聯從而得到更好的效能,比如在迴圈內的調用比迴圈外的內聯的機會更大。
3.內聯可能帶來更好的最佳化的情況更可能被內聯,比如實值型別參數的函數更可能被內聯,因為內聯實值型別參數的函數通常可以帶來更好的最佳化效果。
JIT採用如下啟發學習法演算法來進行判斷
1.評估非內聯情況下的調用體大小。
2.評估在內聯情況下的調用體大小,這個評估是基於IL的,我們用一個簡單的狀態機器(Markov Model,猜測是隱式馬爾科夫模型),其中使用的評估邏輯基於大量的實測資料。
3.計算一個係數。預設是1.
4.如果代碼在迴圈裡增加係數。(5x)
5.(原文:Increase the multiplier if it looks like struct optimizations will kick in). 沒太明白是結構性的最佳化還是指實值型別中的struct。
6.如果 內聯的大小 <= 不內聯的大小 * 係數 則進行內聯
結論很簡單:
1.內聯對C#來說是透明的JIT會搞定的,要相信組織。
2.小的函數更容易被內聯。因為內聯後不會顯著增大代碼尺寸。
3.在迴圈體內的函數調用更容易被內聯。
4.使用實值型別參數的函數更容易被內聯。
對於上面的觀點我進行了驗證,結果如下:
1.的確實際情況中同一個函數在迴圈內一般會內聯而外面不會。如同樣的向量normal()函數。
public static void mul(out Vec result, ref Vec a, ref Vec b){ result.x = a.x * b.x; result.y = a.y * b.y; result.z = a.z * b.z;}public void normal(){ mul(out this, ref this, 1 / Math.Sqrt(x * x + y * y + z * z));}
A情況沒有內聯:調用在主函數開頭,即整個程式只會運行一次:
rd.normal();
0000007d lea ecx,[ebp-40h]
00000080 call dword ptr ds:[00143978h]
B情況內聯了:調用在radiance函數中,而radiance在主函數的多次迴圈內:
u.normal();
000003e9 fld qword ptr [ebp+FFFFFF28h]
000003ef fmul st,st(0)
000003f1 fld qword ptr [ebp+FFFFFF30h]
000003f7 fmul st,st(0)
000003f9 faddp st(1),st
000003fb fld qword ptr [ebp+FFFFFF38h]
00000401 fmul st,st(0)
00000403 faddp st(1),st
00000405 fsqrt
00000407 fld1
00000409 fdivrp st(1),st
0000040b fld st(0)
0000040d fmul qword ptr [ebp+FFFFFF28h]
00000413 fstp qword ptr [ebp+FFFFFF28h]
00000419 fld st(0)
0000041b fmul qword ptr [ebp+FFFFFF30h]
00000421 fstp qword ptr [ebp+FFFFFF30h]
00000427 fmul qword ptr [ebp+FFFFFF38h]
0000042d fstp qword ptr [ebp+FFFFFF38h]
可見的確在迴圈體內的函數更可能被inline,而且normal函數是比較大的。所以是否內聯得看調用情況,直接調用一個函數看是否內聯是不行的。
2.我測試了.NET 4 CP和.NET 3.5 2.0的情況,發現JIT內聯產生的程式碼是不一樣的。如上面的mul函數的同一處調用為例:
在 .NET 2.0、3.0、3.5下產生的程式碼
Vec.mul(out x, ref r.d, t);
000000db lea ecx,[esp+10h]
000000df lea edx,[ebp+8]
000000e2 cmp byte ptr [edx],al
000000e4 add edx,18h
000000e7 mov eax,edx
000000e9 fld qword ptr [esp]
000000ec fstp qword ptr [esp+000003B0h]
000000f3 fld qword ptr [eax]
000000f5 fmul qword ptr [esp+000003B0h]
000000fc fstp qword ptr [ecx]
000000fe fld qword ptr [eax+8]
00000101 fmul qword ptr [esp+000003B0h]
00000108 fstp qword ptr [ecx+8]
0000010b fld qword ptr [eax+10h]
0000010e fmul qword ptr [esp+000003B0h]
00000115 fstp qword ptr [ecx+10h]
在.NET 4下產生的程式碼
Vec.mul(out x, ref r.d, t);
000000d2 fld qword ptr [ebp-14h]
000000d5 lea eax,[ebp+20h]
000000d8 fld qword ptr [eax]
000000da fmul st,st(1)
000000dc fstp qword ptr [ebp-30h]
000000df lea eax,[ebp+20h]
000000e2 fld qword ptr [eax+8]
000000e5 fmul st,st(1)
000000e7 fstp qword ptr [ebp-28h]
000000ea lea eax,[ebp+20h]
000000ed fld qword ptr [eax+10h]
000000f0 fmulp st(1),st
000000f2 fstp qword ptr [ebp-20h]
clr 4.0和2.0產生的程式碼是不同的,而且.NET 4 JIT產生的內聯代碼效率更高,這也許可以解釋為什麼這個測試程式在3.5和4裡面有較大效能差異,3.5用時86秒,4用時67秒。我仔細查看了測試程式中的調用,在迴圈內被頻繁調用的計算函數都被內聯了,只有在迴圈外只運行一次的沒有被內聯,看來JIT工作的很好,我們可以放心的把inline的工作交給JIT了。
既然不是內聯導致的效能問題那麼造成C#這個測試效能不佳的原因還有什麼呢,是因為C#的兩次編譯無法進行C++那樣的更深入全面的最佳化嗎,還是因為其他原因呢?我們還需要繼續去探索。
To be continued. . .
代碼
Vec.mul(out rad, ref f, ref rad);
0000067e fld qword ptr [ebp-78h]
00000681 fmul qword ptr [ebp+FFFFFF58h]
00000687 fstp qword ptr [ebp+FFFFFF58h]
0000068d fld qword ptr [ebp-70h]
00000690 fmul qword ptr [ebp+FFFFFF60h]
00000696 fstp qword ptr [ebp+FFFFFF60h]
0000069c fld qword ptr [ebp-68h]
0000069f fmul qword ptr [ebp+FFFFFF68h]
000006a5 fstp qword ptr [ebp+FFFFFF68h]
http://www.cnblogs.com/miloyip/archive/2010/07/07/languages_brawl_GI.html