從匯編入手,探究泛型的性能問題
2009-05-30 05:21 Jeffrey Zhao 閱讀(25843) 評論(81) 收藏 舉報經過了《泛型真的會降低性能嗎?》一文中的性能測試,已經從實際入手,從測試數據上證明了泛型不會降低程序效率。只是還是有幾位朋友談到,“普遍認為”泛型的代碼性能會略差一些,也有朋友正在進一步尋找泛型性能略差的證據。老趙認為這種探究問題的方式非常值得提倡。不過,老趙忽然想到,如果從能從匯編入手,證明非泛型和泛型的代碼之間沒有性能差距——好吧,或者說,存在性能差距,那么事情不就到此為止了嗎?任何理論說明,都抵不過觀察計算機是如何處理這個問題來的“直接”。因此,老趙最終決定通過這種極端的方式來一探究竟,把這個問題徹底解決。
需要一提的是,老趙并不希望這篇文章會引起一些不必要的爭論,因此一些話就先說在前面。老趙并不喜歡用這種方式來解決問題。事實上,如果可以通過數據比較,理論分析,或者高級代碼來說明問題,我連IL都不愿意接觸,更別說深入匯編。如果是平時的工作,就算使用WinDbg也最多是查看查看內存中有哪些數據,系統到底出了哪些問題。如果您要老趙表態的話,我會說:我強烈反對接觸匯編。我們有太多太多的東西需要學習,如果您并沒有明確您的目標,老趙建議您就放過IL和匯編這種東西吧。我們知道這些是什么就行了,不必對它們有什么“深入”的了解。
下面就要開始真正的探索之旅了。這不是一個順利的旅程,其中有些步驟是連蒙帶猜,最后加以驗證才得到的結果。原本老趙打算按照自己的思路一步一步進行下去,但是發現這樣太過冗余,反而會讓大家的思路難以集中。因此老趙最后決定重新設計一個流程,和大家一起步步為營,朝著目標前進。此外,為了方便某些朋友按照這文章親手進行操作,老趙也制作了一個dump文件,如果您是安裝了.NET 3.5 SP1的32位x86系統,可以直接下載進行試驗。試驗過程中出現的地址也會和文章中完全一致。
廢話就說到這里,我們開始吧。
測試代碼
測試代碼便是我們的目標。和上一篇文章一樣,我們準備了一份最簡單的代碼進行測試,這樣可以盡可能擺脫其他因素的影響,得到最正確的結果:
namespace TestConsole { public class MyArrayList { public MyArrayList(int length) { this.m_items = new object[length]; } private object[] m_items; public object this[int index] { [MethodImpl(MethodImplOptions.NoInlining)] get { return this.m_items[index]; } [MethodImpl(MethodImplOptions.NoInlining)] set { this.m_items[index] = value; } } } public class MyList<T> { public MyList(int length) { this.m_items = new T[length]; } private T[] m_items; public T this[int index] { [MethodImpl(MethodImplOptions.NoInlining)] get { return this.m_items[index]; } [MethodImpl(MethodImplOptions.NoInlining)] set { this.m_items[index] = value; } } } class Program { static void Main(string[] args) { MyArrayList arrayList = new MyArrayList(1); arrayList[0] = arrayList[0] ?? new object(); MyList<object> list = new MyList<object>(1); list[0] = list[0] ?? new object(); Console.WriteLine("Here comes the testing code."); var a = arrayList[0]; var b = list[0]; Console.ReadLine(); } } }
我們在這里構建了兩個“容器”,一個是MyArrayList,另一個是MyList<T>,前者直接使用Object類型,而后者則是一個泛型類。我們對兩個類的索引屬性的get和set方法都加上了NoInlining標記,這樣便可以避免這種簡單的方法被JIT內聯。而在Main方法中,前幾行代碼的作用都是構造兩個類的對象,并確保索引的get和set方法都已經得到JIT。在打印出“Here comes the testing code.”之后,我們便對兩個類的實例進行“下標訪問”,并使控制臺暫停。
當Release編譯并運行之后,控制臺會打印出“Here comes the testing code.”字樣并停止。這時候我們便可以使用WinDbg來Attach to Process進行調試。老趙也是在這個時候制作了一個dump文件,您也可以Open Crash Dump命令打開這個文件。更多操作您可以參考互聯網上的各篇文章,亦或是老趙之前寫過的一篇《使用WinDbg獲得托管方法的匯編代碼》。
分析MyArrayList對象結構
假設您現在已經打開了WinDbg,并Attach to Process(或Open Crash Dump),而且加載了正確的sos.dll(可參考老趙之前給出的文章)。那么第一件事情,我們就要來分析一個MyArrayList對象的結構。
首先,我們還是在項目中查找MyArrayList類型的MT(Method Table,方法表)地址:
0:000> !name2ee *!TestConsole.MyArrayList Module: 5bf71000 (mscorlib.dll) -------------------------------------- Module: 00362354 (sortkey.nlp) -------------------------------------- Module: 00362010 (sorttbls.nlp) -------------------------------------- Module: 00362698 (prcp.nlp) -------------------------------------- Module: 003629dc (mscorlib.resources.dll) -------------------------------------- Module: 00342ff8 (TestConsole.exe) Token: 0x02000002 MethodTable: 00343440 EEClass: 0034141c Name: TestConsole.MyArrayList
我們得到了MyArrayList類型的MT地址之后,便可以在系統中尋找MyArrayList對象了:
0:000> !dumpheap -mt 00343440
Address MT Size
0205be3c 00343440 12
total 1 objects
Statistics:
MT Count TotalSize Class Name
00343440 1 12 TestConsole.MyArrayList
Total 1 objects
不出所料,當前程序中只有一個MyArrayList對象。我們繼續追蹤它的地址:
0:000> !do 0205be3c
Name: TestConsole.MyArrayList
MethodTable: 00343440
EEClass: 0034141c
Size: 12(0xc) bytes
(E:\Users\Jeffrey Zhao\...\bin\Release\TestConsole.exe)
Fields:
MT Field Offset Type VT Attr Value Name
5c1b41d0 4000001 4 System.Object[] 0 instance 0205be48 m_items
OK,到這里為止,我們得到一個結論。如果我們獲得了一個MyArrayList對象的地址,那么偏移4個字節,便可以得到m_items字段,也就是存放元素的Object數組的地址。這點很關鍵,否則可能對于理解后面的匯編代碼形成障礙。
如果您使用同樣的方法來觀察MyList<object>類型的話,您會發現其結果也完全相同:從對象地址開始偏移4個字節便是m_items字段,類型為Object數組。
分析數組對象的結構
接著我們來觀察一下,一個數組對象在內存中的存放方式是什么樣的。首先,我們打印出托管堆上的各種類型:
0:000> !dumpheap -stat
total 6922 objects
Statistics:
MT Count TotalSize Class Name
5c1e3ed4 1 12 System.Text.DecoderExceptionFallback
5c1e3e90 1 12 System.Text.EncoderExceptionFallback
5c1e1ea4 1 12 System.RuntimeTypeHandle
5c1dfb28 1 12 System.__Filters
5c1dfad8 1 12 System.Reflection.Missing
5c1df9e0 1 12 System.RuntimeType+TypeCacheQueue
...
5c1e3150 48 8640 System.Collections.Hashtable+bucket[]
5c1e2d28 347 9716 System.Collections.ArrayList+ArrayListEnumeratorSimple
5c1b5ca4 46 11024 System.Reflection.CustomAttributeNamedParameter[]
5c1cc590 404 11312 System.Security.SecurityElement
5c1e2a30 578 13872 System.Collections.ArrayList
5c1b50e4 335 14740 System.Int16[]
5c1b41d0 1735 87172 System.Object[]
5c1e0a00 718 167212 System.String
5c1e3470 70 174272 System.Byte[]
Total 6922 objects
既然我們的代碼中使用了Object數組,那么我們就把目標放在托管堆上的Object數組中。從上面的信息中我們已經獲得了Object數組的MT地址,于是我們繼續列舉出托管堆上的此類對象:
0:000> !dumpheap -mt 5c1b41d0
Address MT Size
01fd141c 5c1b41d0 80
01fd1c84 5c1b41d0 16
01fd1cc0 5c1b41d0 32
...
0205baa4 5c1b41d0 20
0205bc4c 5c1b41d0 20
0205bc60 5c1b41d0 32
0205bdc4 5c1b41d0 16
0205be48 5c1b41d0 20
0205be74 5c1b41d0 20
0205c058 5c1b41d0 36
02fd1010 5c1b41d0 4096
02fd2020 5c1b41d0 528
02fd2240 5c1b41d0 4096
total 1735 objects
Statistics:
MT Count TotalSize Class Name
5c1b41d0 1735 87172 System.Object[]
Total 1735 objects
我們隨意抽取一個Object數組對象,查看它的內容:
0:000> !do 02fd2020 Name: System.Object[] MethodTable: 5c1b41d0 EEClass: 5bf9da54 Size: 528(0x210) bytes Array: Rank 1, Number of elements 128, Type CLASS Element Type: System.Object Fields: None
WinDbg清楚明白地告訴我們,這個數組是1維的,共有128個元素。那么這個數組的長度信息是如何保存下來的呢(這個信息肯定是對象自帶的,這個很容易理解吧)?我們直接查看這個數組對象地址上的數據吧:
0:000> dd 02fd2020 02fd2020 5c1b41d0 00000080 5c1e061c 01fd1198 02fd2030 0205bdf0 00000000 00000000 00000000 02fd2040 00000000 00000000 00000000 00000000 02fd2050 00000000 00000000 00000000 00000000 02fd2060 00000000 00000000 00000000 00000000 02fd2070 00000000 00000000 00000000 00000000 02fd2080 00000000 00000000 00000000 00000000 02fd2090 00000000 00000000 00000000 00000000
十六進制數00000080不就是十進制的128嗎?沒錯,老趙對多個數組對象進行分析之后,發現數組對象存放的結構是從對象的地址開始:
- 偏移0字節:存放了這個數組對象的MT地址,例如上面的5c1b41d0便是Object[]類型的MT地址。
- 偏移4字節:存放了數組長度。
- 偏移8字節:存放了數組元素類型的MT地址,例如上面的5c1e061c便是Object類型的MT地址,您可以使用!dumpmt -md 5c1e061c指令進行觀察。
- 偏移12字節:從這里開始,便存放了數組的每個元素了。也就是說,如果這是一個引用類型的數組,那么偏移12字節則存放了第1個(下標為0)元素的地址,偏移16字節則存放第2個元素的地址,以此類推。
實際上,這些是老趙在自己的試驗過程中,從接下去會講解的匯編代碼出發猜測出來的結果,經過驗證發現恰好符合。為了避免您走這些彎路,老趙就先將這一結果告訴大家了。
分析Main函數的匯編代碼
接下去便要觀察Main函數的匯編代碼了。獲取匯編代碼的方法很簡單,如果您對此還不太了解,老趙的文章《使用WinDbg獲得托管方法的匯編代碼》會給您一定幫助。Main函數的匯編代碼如下:
0:000> !u 01d40070 Normal JIT generated code TestConsole.Program.Main(System.String[]) Begin 01d40070, size e2 >>> 01d40070 push ebp 01d40071 mov ebp,esp 01d40073 push edi 01d40074 push esi 01d40075 push ebx ... 01d4011d mov ecx,eax // 打印字樣“Here comes the testing code.” 01d4011f mov edx,dword ptr ds:[2FD2030h] ("Here comes the testing code.") 01d40125 mov eax,dword ptr [ecx] 01d40127 call dword ptr [eax+0D8h] // 將MyArrayList對象的地址保存在ecx寄存器中 01d4012d mov ecx,esi // 將edx寄存器清零,作為訪問下面get_Item方法的參數 01d4012f xor edx,edx // 獲取地址0x343424中的數據(它是get_Item方法的訪問入口),并調用 01d40131 call dword ptr ds:[343424h] (...MyArrayList.get_Item(Int32), ...) // 將MyList<object>對象的地址保存在ecx寄存器中 01d40137 mov ecx,edi // 將edx寄存器清零,作為訪問下面get_Item方法的參數 01d40139 xor edx,edx // 獲取地址0x343594中的數據(它是get_Item方法的訪問入口),并調用 01d4013b call dword ptr ds:[343594h] (...MyList`1[...].get_Item(Int32), ...) // 調用Console.ReadLine方法,請注意靜態方法不需要把對象地址放到ecx寄存器中 01d40141 call mscorlib_ni+0x6d1af4 (5c641af4) (System.Console.get_In(), ...) 01d40146 mov ecx,eax 01d40148 mov eax,dword ptr [ecx] 01d4014a call dword ptr [eax+64h] 01d4014d pop ebx 01d4014e pop esi 01d4014f pop edi 01d40150 pop ebp 01d40151 ret
老趙為上面這段匯編代碼添加了注釋,我們主要從打印出“Here comes the testing code.”字樣的代碼開始進行分析。值得注意的是,在調用MyArrayList或MyList<object>的get_Item方法之前,都會把這個對象的地址放置到ecx寄存器中,然后把edx寄存器清零作為get_Item方法的參數。這樣做的好處是加快訪問對象及參數的速度,如果每次都需要從線程棧上讀取這些(就像我們學習匯編時的那些經典案例),其性能肯定比不上讀取寄存器。顯然,調用Console.ReadLine靜態方法是不需要對象地址的,因此無須對ecx寄存器有所操作。
分析get_Item方法的匯編代碼
從Main函數的匯編代碼中我們可以獲得get_Item方法的入口。那么我們現在就來分析MyArrayList類型的get_Item方法,請注意,此時ecx寄存器保存的是MyArrayList對象的地址,edx保存了get_Item方法的參數:
0:000> dd 343424h 00343424 01d40168 71060003 20000006 01d40190 00343434 fffffff8 00000004 00000001 00080000 00343444 0000000c 00040011 00000004 5c1e061c 00343454 00342ff8 00343478 0034141c 00000000 00343464 00000000 5c136aa0 5c136ac0 5c136b30 00343474 5c1a7410 00000080 00000000 003434c0 00343484 10000002 90000000 003434c0 00000000 00343494 0034c05c 00020520 00000004 00000004 0:000> !u 01d40168 Normal JIT generated code TestConsole.MyArrayList.get_Item(Int32) Begin 01d40168, size 17 >>> 01d40168 55 push ebp 01d40169 8bec mov ebp,esp // 把MyArrayList對象的m_items字段地址(對象地址偏移4字節)保存至eax寄存器中 01d4016b 8b4104 mov eax,dword ptr [ecx+4] // 比較傳入的參數(edx寄存器)與數組長度(eax寄存器為數組地址,再偏移4字節)的大小 01d4016e 3b5004 cmp edx,dword ptr [eax+4] // 如果參數超過數組長度,則跳轉至錯誤處理代碼 01d40171 7306 jae 01d40179 // 把需要的元素地址放置到eax寄存器中 // 從數組地址開始偏移12字節為第一個元素的地址,再偏移“下標 * 4”自然就是我們所需要的元素 01d40173 8b44900c mov eax,dword ptr [eax+edx*4+0Ch] 01d40177 5d pop ebp // 返回 01d40178 c3 ret // 如果參數大于數組長度,就會跳轉到此 01d40179 e806c2a15c call mscorwks!JIT_RngChkFail (5e75c384) 01d4017e cc int 3
如果要理解上面的代碼,可能需要您再去回味文章上半段的分析。尤其是幾個偏移量:
- MyArrayList對象偏移4字節則為m_items字段地址
- 數組地址偏移4字節則為其長度
- 數組地址偏移12字節為其第一個元素的地址
然后,再結合ecx(MyArrayList對象地址),edx(參數)以及eax(保存了方法返回值)幾個寄存器的作用,相信理解上面這段代碼也并非難事。
MyArrayList的代碼分析完了,那么MyList<object>的匯編代碼又是如何?
0:000> dd 343594h 00343594 01d401b8 01d401e0 00010001 003435a4 003435a4 5c1e0670 00000000 00000000 00000080 003435b4 00000000 fffffff8 00000004 00000001 003435c4 00080010 0000000c 00040011 00000004 003435d4 5c1e061c 00342ff8 00343610 0034355a 003435e4 00343600 00000000 5c136aa0 5c136ac0 003435f4 5c136b30 5c1a7410 00010001 00343604 00343604 5c1e061c 00000000 00000000 00000080 0:000> !u 01d401b8 Normal JIT generated code TestConsole.MyList`1[[System.__Canon, mscorlib]].get_Item(Int32) Begin 01d401b8, size 17 >>> 01d401b8 55 push ebp 01d401b9 8bec mov ebp,esp 01d401bb 8b4104 mov eax,dword ptr [ecx+4] 01d401be 3b5004 cmp edx,dword ptr [eax+4] 01d401c1 7306 jae 01d401c9 01d401c3 8b44900c mov eax,dword ptr [eax+edx*4+0Ch] 01d401c7 5d pop ebp 01d401c8 c3 ret 01d401c9 e8b6c1a15c call mscorwks!JIT_RngChkFail (5e75c384) 01d401ce cc int 3
是否發現,兩者的代碼除了幾個地址之外可以說完全一樣?
總結
還需要多說什么嗎?我們通過比較匯編代碼,已經證明了MyArrayList和MyList<Object>在執行時所經過的指令幾乎完全相同。到了這個地步,您是否還認為泛型會影響程序性能?
最后繼續強調一句:老趙并不喜歡IL,更不喜歡匯編。除非萬不得已,老趙是不會往這方面去思考問題的。我們有太多東西可學,如果不是目標明確,老趙建議您還是不要投身于IL或匯編這類東西為好。
最后附上dump文件。
浙公網安備 33010602011771號