編寫更快的託管代碼:瞭解開銷情況

編寫更快的託管代碼:瞭解開銷情況
適用於:
Microsoft(r) .NET Framework

摘要:本文介紹託管代碼執行時間的低級操作開銷模型,該模型是通過測量操作時間得到

的,開發人員可以據此做出更好的編碼決策並編寫更快的代碼。

下載 CLR Profiler。(330KB)

目錄
簡介(和誓言)
關於託管代碼的開銷模型
託管代碼的開銷情況
小結
資源

簡介(和誓言)
實現計算的方法有無數種,但這些方法良莠不齊,有些方法遠勝於其他方法:更簡單,更

清晰,更容易維護。有些方法速度很快,有些卻慢得出奇。

不要錯用那些速度慢、內容臃腫的代碼。難道您不討厭這樣的代碼嗎:不能連續運行的代

碼、不時將用戶界面鎖定幾秒種的代碼、頑固佔用 CPU 或嚴重損害磁盤的代碼?

千萬不要用這樣的代碼。相反,請站起來,和我一起宣誓:

“我保證,我不會向用戶提供慢速代碼。速度是我關注的特性。每天我都會注意代碼的性

能。我會經常地、系統地‘測量’代碼的速度和大小。我將學習、構建或購買爲此所需的

工具。這是我的責任。”
(我保證。)你是這樣保證的嗎?非常好。


那麼,怎樣才能在日常工作中編寫出最快、最簡潔的代碼呢?這就要不斷有意識地優先選

擇節儉的方法,而不要選擇浪費、臃腫的方法,並且要深入思考。即使是任意指定的一段

代碼,都會需要許多這樣的小決定。

但是,如果不知道開銷的情況,就無法面對衆多方案作出明智的選擇:如果您不知道開銷

情況,也就無法編寫高效的代碼。

在過去的美好日子裏,事情要容易一些,好的 C 程序員都知道。C 中的每個運算符和操作

,不管是賦值、整數或浮點數學、解除引用,還是函數調用,都在不同程度上一一對應着

單一的原始計算機操作。當然,有時會需要數條計算機指令來將正確的操作數放置在正確

的寄存器中,而有時一條指令就可以完成幾種 C 操作(比較著名的是 *dest++ = *src++

;),但您通常可以編寫(或閱讀取)一行 C 代碼,並知道要花費多少時間。對於代碼和

數據,C 編譯器具有所見即所得的特點 -“您編寫的就是您得到的”。(例外的情況是函

數調用。如果不知道函數的開銷,您將無法知道其花費的時間。)

到了 20 世紀 90 年代,爲了將數據抽象、面向對象編程和代碼複用等技術更好地用於軟

件工程和生產,PC 軟件業將 C 發展爲 C++。

C++ 是 C 的超集,並且是“使用才需付出”,即如果不使用,新功能不會有任何開銷。因

此,C 的專用編程技術,包括其內在的開銷模型,都可以直接應用。如果編寫一段 C 代碼

並用 C++ 重新編譯這段代碼,則執行時間和空間的系統開銷不會有太大變化。

另一方面,C++ 引入了許多新的語言功能,包括構造函數、析構函數、New、Delete、單繼

承、多繼承、虛擬繼承、數據類型轉換、成員函數、虛函數、重載運算符、指向成員的指

針、對象數組、異常處理和相同的複合,這些都會造成許多不易察覺但非常重要的開銷。

例如,每次調用虛函數時都要花費兩次額外的定位,而且還會將隱藏的 vtable 指針字段

添加到每個實例中。或者,考慮將這段看起來比較安全的代碼:

{ complex a, b, c, d; ... a = b + c * d; }
編譯爲大約十三個隱式成員函數調用(但願是內聯的)。

九年前,在我的文章 C++:Under the Hood(英文)中曾探討過這個主題,我寫道:

“瞭解編程語言的實現方式是非常重要的。這些知識可以讓我們消除‘編譯器到底在做些

什麼?’的恐懼和疑慮,讓我們有信心使用新功能,並使我們在調試和學習其他的語言功

能時更具洞察力。這些知識還能使我們認識到各種編碼方案的相對開銷,而這正是我們在

日常工作中編寫出最有效的代碼所必需的。”
現在,我們將以同樣的方式來了解託管代碼。本文將探討託管執行的“低級”時間和空間

開銷,以使我們能夠在日常的編碼工作中權衡利弊,做出明智的判斷。

並遵守我們的承諾。

爲什麼是託管代碼?
對大多數本機代碼的開發人員來說,託管代碼爲運行他們的軟件提供了更好、更有效率的

平臺。它可以消除整類錯誤,如堆損壞和數組索引超出邊界的錯誤,而這些錯誤常常使深

夜的調試工作無功而返。它支持更爲現代的要求,如安全移動代碼(通過代碼訪問安全性

實現)和 XML Web Service,而且與過去的 Win32/COM/ATL/MFC/VB 相比,.NET Framewo

rk 更加清楚明瞭,利用它可以做到事半功倍。

對軟件用戶來說,託管代碼爲他們提供了更豐富、更健壯的應用程序,讓他們通過更優質

的軟件享受更好的生活。

編寫更快的託管代碼的祕訣是什麼?
儘管可以做到事半功倍,但還是不能放棄認真編碼的責任。首先,您必須承認:“我是個

新手。”您是個新手。我也是個新手。在託管代碼領域中,我們都是新手。我們仍然在學

習這方面的訣竅,包括開銷的情況。

面對功能豐富、使用方便的 .NET Framework,我們就像糖果店裏的孩子:“哇,不需要枯

燥的 strncpy,只要把字符串‘+’在一起就可以了!哇,我可以在幾行代碼中加載一兆字

節的 XML!哈哈!”

一切都是那麼容易。真的是很容易。即使是從 XML 信息集中提出幾個元素,也會輕易地投

入幾兆字節的 RAM 來分析 XML 信息集。使用 C 或 C++ 時,這件事是很令人頭疼的,必

須考慮再三,甚至您會想在某些類似 SAX 的 API 上創建一個狀態機。而使用 .NET Fram

ework 時,您可以在一口氣加載整個信息集,甚至可以反覆加載。這樣一來,您的應用程

序可能就不再那麼快了。也許它的工作集達到了許多兆字節。也許您應該重新考慮一下那

些簡單方法的開銷情況。

遺憾的是,在我看來,當前的 .NET Framework 文檔並沒有足夠詳細地介紹 Framework 的

類型和方法的性能含義,甚至沒有具體指明哪些方法會創建新對象。性能建模不是一個很

容易闡述的主題,但是“不知道”會使我們更難做出恰當的決定。

既然在這方面我們都是新手,又不知道任何開銷情況,而且也沒有什麼文檔可以清楚說明

開銷情況,那我們應該做些什麼呢?

測量,對開銷進行測量。祕訣就是“對開銷進行測量”並“保持警惕”。我們都應該養成

測量開銷的習慣。如果我們不怕麻煩去測量開銷,就不會輕易調用比我們“假設”的開銷

高出十倍的新方法。

(順便說一下,要更深入地瞭解 BCL [基類庫] 的性能基礎或 CLR,請查看 Shared Sour

ce CLI [英文],又稱 Rotor。Rotor 代碼與 .NET Framework 和 CLR 屬於同一類別,但

並不是完全相同的代碼。不過即使是這樣,我保證在認真學習 Rotor 之後,您會對 CLR

有更新、更深刻的理解。但一定保證首先要審覈 SSCLI 許可證!)

知識
如果您想成爲倫敦的出租車司機,首先必須學習 The Knowledge(英文)。學生們通過幾

個月的學習,要記住倫敦城裏上千條的小街道,還要瞭解到達各個地點的最佳路線。他們

每天騎着踏板車四處查看,以鞏固在書本上學到的知識。

同樣,如果您想成爲一名高性能託管代碼的開發人員,您必須獲得“託管代碼知識”。您

必須瞭解每項低級操作的開銷,必須瞭解像委託 (Delegate) 和代碼訪問安全等這類功能

的開銷,還必須瞭解正在使用以及正在編寫的類型和方法的開銷。能夠發現哪些方法的開

銷太大,對您的應用程序不會有什麼損害,反倒因此可以避免使用這些方法。

這些知識不在任何書本中,也就是說,您必須騎上自己的踏板車進行探索:準備好 csc、

ildasm、VS.NET 調試器、CLR 分析器、您的分析器、一些性能計時器等,瞭解代碼的時間

和空間開銷。

關於託管代碼的開銷模型
讓我們開門見山地談談託管代碼的開銷模型。利用這種模型,您可以查看葉方法,能馬上

判斷出開銷較大的表達式或語句,而在您編寫新代碼時,就可以做出更明智的選擇。

(有關調用您的方法或 .NET Framework 方法所需的可傳遞的開銷,本文將不做介紹。這

些內容以後會在另一篇文章中介紹。)

之前我曾經說過,大多數的 C 開銷模型仍然適用於 C++ 方案。同樣,許多 C/C++ 開銷模

型也適用於託管代碼。

怎麼會這樣呢?您一定了解 CLR 執行模型。您使用幾種語言中的一種來編寫代碼,並將其

編譯成 CIL(公用中間語言)格式,然後打包成程序集。當您運行主應用程序的程序集時

,它開始執行 CIL。但是不是像舊的字節碼解釋器一樣,速度會非常慢?

實時編譯器
不,它一點也不慢。CLR 使用 JIT(實時)編譯器將 CIL 中的各種方法編譯成本機 x86

代碼,然後運行本機代碼。儘管 JIT 在編譯首次調用的方法時會稍有延遲,但所調用的各

種方法在運行純本機代碼時都不需要解釋性的系統開銷。

與傳統的脫機 C++ 編譯過程不同,JIT 編譯器花費的時間對用戶來說都是“時鐘時間”延

遲,因此 JIT 編譯器不具備佔用大量時間的徹底優化過程。儘管如此,JIT 編譯器所執行

的一系列優化仍給人以深刻印象:

常量重疊
常量和複製的傳播
通用子表達式消除
循環不變量的代碼活動
死存儲 (Dead Store) 和死代碼 (Dead Code) 消除
寄存器分配
內聯方法
循環展開(帶有小循環體的小循環)
結果可以與傳統的本機代碼相媲美,至少是相近。

至於數據,可以混合使用值類型和引用類型。值類型(包括整型、浮點類型、枚舉和結構

)通常存儲在棧中。這些數據類型就像 C/C++ 中的本地和結構一樣又小又快。使用 C/C+

+ 時,應該避免將大的結構作爲方法參數或返回值進行傳送,因爲複製的系統開銷可能會

大的驚人。

引用類型和裝箱後的值類型存儲在堆中。它們通過對象引用來尋址,這些對象引用只是計

算機的指針,就像 C/C++ 中的對象指針一樣。

因此實時編譯的託管代碼可以很快。下面我們將討論一些例外,如果您深入瞭解了本機 C

代碼中某些表達式的開銷,您就不會像在託管代碼中那樣錯誤地爲這些開銷建模。

我還應該提一下 NGEN,這是一種“超前的”工具,可以將 CIL 編譯爲本機代碼程序集。

儘管利用 NGEN 編譯程序集在當前並不會對執行時間造成什麼實質性的影響(好的或壞的

影響),卻會使加載到許多應用程序域和進程中的共享程序集的總工作集減少。(操作系

統可以跨所有客戶端共享一份利用 NGEN 編譯的代碼,而實時編譯的代碼目前通常不會跨

應用程序域或進程共享。請參閱 LoaderOptimizationAttribute.MultiDomain [英文]。)



自動內存管理
託管代碼與本機代碼的最大不同之處在於自動內存管理。您可以分配新的對象,但 CLR 垃

圾回收器 (GC) 會在這些對象無法訪問時自動釋放它們。GC 不時地運行,通常不爲人覺察

,但一般會使應用程序停止一兩毫秒,偶爾也會更長一些。

有一些文章探討了垃圾回收器的性能含義,這裏就不作介紹了。如果您的應用程序遵循這

些文章中的建議,那麼總的內存回收開銷就不會很大,也就是百分之幾的執行時間,與傳

統的 C++ 對象 new 和 delete 大致相當或者更好一些。創建對象以及後來的自動收回對

象的分期開銷非常低,這樣就可以在每秒鐘內創建數千萬個小對象。

但仍不能“免費”分配對象。對象會佔用空間。無限制的對象分配將會導致更加頻繁的內

存回收。

更糟糕的是,不必要地持續引用無用的對象圖 (Object Graph) 會使對象保持活動。有時

,我們會發現有些不大的程序竟然有 100 MB 以上的工作集,可是這些程序的作者卻拒絕

承認自己的錯誤,反而認爲性能不佳是由於託管代碼本身存在一些神祕、無法確認(因此

很難處理)的問題。這真令人遺憾。但是,只需使用 CLR 編譯器花一個小時做一下研究,

更改幾行代碼,就可以將這些程序用到的堆減少十倍或更多。如果您遇上大的工作集問題

,第一步就應該查看真實的情況。

因此,不要創建不必要的對象。由於自動內存管理消除了許多對象分配和釋放方面的複雜

情況、問題和錯誤,並且用起來又快又方便,因此我們會很自然地想要創建越來越多的對

象,最終形成錯綜複雜的對象羣。如果您想編寫真正的快速託管代碼,創建對象時就需要

深思熟慮,確保對象的數量合適。

這也適用於 API 的設計。由於可以設計類型及其方法,因此它們會要求客戶端創建可以隨

便放棄的新對象。不要那樣做。

託管代碼的開銷情況
現在,讓我們來研究一下各種低級託管代碼操作的時間開銷。

表 1 列出了各種低級託管代碼操作的大致開銷,單位是毫微秒。這些數據是在配備了 1.

1 GHz Pentium-III、運行了 Windows XP 和 .NET Framework v1.1 (Everett) 的靜止 P

C 上通過一套簡單的計時循環收集到的。

測試驅動程序調用各種測試方法,指定要執行的多個迭代,自動調整爲迭代 218 到 230

次,並根據需要使每次測試的時間不少於 50 毫秒。一般情況下,這麼長的時間足可以在

一個進行密集對象分配的測試中觀察幾個 0 代內存回收週期。該表顯示了 10 次實驗的平

均結果,對於每個測試主題,都列出了最好(最少時間)的實驗結果。

根據需要,每個測試循環都展開 4 至 60 次,以減少測試循環的系統開銷。我檢查了每次

測試生成的主機代碼,以確保 JIT 編譯器沒有將測試徹底優化,例如,我修改了幾個示例

中的測試,以使中間結果在測試循環期間和測試循環之後都存在。同樣,我還對幾個測試

進行了更改,以使通用子表達式消除不起作用。

表 1:原語時間(平均和最小)(ns)

平均 最小 原語 平均 最小 原語 平均 最小 原語
0.0 0.0 Control 2.6 2.6 new valtype L1 0.8 0.8 isinst up 1
1.0 1.0 Int add 4.6 4.6 new valtype L2 0.8 0.8 isinst down 0
1.0 1.0 Int sub 6.4 6.4 new valtype L3 6.3 6.3 isinst down 1
2.7 2.7 Int mul 8.0 8.0 new valtype L4 10.7 10.6 isinst (up 2) down 1
35.9 35.7 Int div 23.0 22.9 new valtype L5 6.4 6.4 isinst down 2
2.1 2.1 Int shift 22.0 20.3 new reftype L1 6.1 6.1 isinst down 3
2.1 2.1 long add 26.1 23.9 new reftype L2 1.0 1.0 get field
2.1 2.1 long sub 30.2 27.5 new reftype L3 1.2 1.2 get prop
34.2 34.1 long mul 34.1 30.8 new reftype L4 1.2 1.2 set field
50.1 50.0 long div 39.1 34.4 new reftype L5 1.2 1.2 set prop
5.1 5.1 long shift 22.3 20.3 new reftype empty ctor L1 0.9 0.9 get this field


1.3 1.3 float add 26.5 23.9 new reftype empty ctor L2 0.9 0.9 get this prop


1.4 1.4 float sub 38.1 34.7 new reftype empty ctor L3 1.2 1.2 set this field


2.0 2.0 float mul 34.7 30.7 new reftype empty ctor L4 1.2 1.2 set this prop


27.7 27.6 float div 38.5 34.3 new reftype empty ctor L5 6.4 6.3 get virtual pr

op
1.5 1.5 double add 22.9 20.7 new reftype ctor L1 6.4 6.3 set virtual prop
1.5 1.5 double sub 27.8 25.4 new reftype ctor L2 6.4 6.4 write barrier
2.1 2.0 double mul 32.7 29.9 new reftype ctor L3 1.9 1.9 load int array elem


27.7 27.6 double div 37.7 34.1 new reftype ctor L4 1.9 1.9 store int array ele

m
0.2 0.2 inlined static call 43.2 39.1 new reftype ctor L5 2.5 2.5 load obj arr

ay elem
6.1 6.1 static call 28.6 26.7 new reftype ctor no-inl L1 16.0 16.0 store obj a

rray elem
1.1 1.0 inlined instance call 38.9 36.5 new reftype ctor no-inl L2 29.0 21.6 b

ox int
6.8 6.8 instance call 50.6 47.7 new reftype ctor no-inl L3 3.0 3.0 unbox int


0.2 0.2 inlined this inst call 61.8 58.2 new reftype ctor no-inl L4 41.1 40.9

delegate invoke
6.2 6.2 this instance call 72.6 68.5 new reftype ctor no-inl L5 2.7 2.7 sum ar

ray 1000
5.4 5.4 virtual call 0.4 0.4 cast up 1 2.8 2.8 sum array 10000
5.4 5.4 this virtual call 0.3 0.3 cast down 0 2.9 2.8 sum array 100000
6.6 6.5 interface call 8.9 8.8 cast down 1 5.6 5.6 sum array 1000000
1.1 1.0 inst itf instance call 9.8 9.7 cast (up 2) down 1 3.5 3.5 sum list 100

0
0.2 0.2 this itf instance call 8.9 8.8 cast down 2 6.1 6.1 sum list 10000
5.4 5.4 inst itf virtual call 8.7 8.6 cast down 3 22.0 22.0 sum list 100000


5.4 5.4 this itf virtual call    21.5 21.4 sum list 1000000

免責聲明:請不要照搬這些數據。時間測試會由於無法預料的二次影響而變得不準確。偶

然事件可能會使實時編譯的代碼或某些關鍵數據跨過緩存行,影響其他的緩存或已有數據

。這有點像不確定性原則:1 毫微秒左右的時間和時間差異是可觀察到的範圍限度。

另一項免責聲明:這些數據只與完全適應緩存的小代碼和數據方案有關。如果應用程序中

最常用的部分不適應芯片緩存,您可能會遇到其他的性能問題。本文的結尾將詳細介紹緩

存。

還有一項免責聲明:將組件和應用程序作爲 CIL 的程序集的最大好處之一是,您的程序可

以做到每秒都變快、每年都變快。“每秒都變快”是因爲運行時(理論上)可以在程序運

行時重新調整 JIT 編譯的代碼;“每年都變快”是因爲新發布的運行時總能提供更好、更

先進、更快的算法以將代碼迅速優化。因此,如果 .NET 1.1 中的這幾個計時不是最佳結

果,請相信在以後發佈的產品中它們會得到改善。而且在今後發佈的 .NET Framework 中

,本文中所列代碼的本機代碼序列可能會更改。

不考慮這些免責聲明,這些數據確實讓我們對各種原語的當前性能有了充分的認識。這些

數字很有意義,並且證實了我的判斷,即大多數實時編譯的託管代碼可以像編譯過的本機

代碼一樣,“接近計算機”運行。原始的整型和浮點操作很快,而各種方法調用卻不太快

,但(請相信我)仍可比得上本機 C/C++。同時我們還會發現,有些通常在本機代碼中開

銷不太大的操作(如數據類型轉換、數組和字段存儲、函數指針 [委託])現在的開銷卻變

大了。爲什麼是這樣呢?讓我們來看一下。

算術運算
表 2:算術運算時間 (ns)

平均 最小 原語 平均 最小 原語
1.0 1.0 int add 1.3 1.3 float add
1.0 1.0 int sub 1.4 1.4 float sub
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 float div
2.1 2.1 int shift   
2.1 2.1 long add 1.5 1.5 double add
2.1 2.1 long sub 1.5 1.5 double sub
34.2 34.1 long mul 2.1 2.0 double mul
50.1 50.0 long div 27.7 27.6 double div
5.1 5.1 long shift   

過去,浮點運算幾乎比整數運算慢一個數量級。如表 2 所示,在使用現代的管道化的浮點

單位之後,二者之間的差別變得很小或沒有差別。而且令人驚奇的是,普通的筆記本 PC

現在已經可以在每秒內進行十億次浮點運算(對於適應緩存的問題)。

讓我們看一行從整數和浮點的加法運算測試中得到的實時編譯代碼:

反彙編 1:整數加法運算和浮點加法運算

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h]
00000050 03 54 24 14      add         edx,dword ptr [esp+14h]
00000054 03 54 24 18      add         edx,dword ptr [esp+18h]
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch]
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h]
00000060 03 D5            add         edx,ebp
00000062 03 D6            add         edx,esi
00000064 03 D3            add         edx,ebx
00000066 03 D7            add         edx,edi
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h]
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch]
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h]
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h]
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h]
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch]
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h]
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h]
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h]
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h]

這裏我們可以看到,實時編譯的代碼已接近最佳狀態。在 int add 示例中,編譯器甚至記

錄了五個局部變量。在 float add 示例中,爲了避免通用子表達式消除,我強制使變量

a 到 h 成爲靜態類。

方法調用
本節將探討方法調用的開銷和實現。測試主題是實現接口 I 的類 T,同時測試各種方法。

請參閱列表 1。

列表 1:方法調用的測試方法

interface I { void itf1();  void itf5();  }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, , int p) { }

    static void inl_s1() { }
    static void s1()     { if (falsePred) dummy(1, 2, 3, , 16); }
    void inl_i1()        { }
    void i1()            { if (falsePred) dummy(1, 2, 3, , 16); }
    public virtual void v1() { }
    void itf1()          { }
    virtual void itf5()  { }
}

請參閱表 3。首先可以判斷出,表中的方法可以是內聯的(抽象不需要任何開銷),也可

以不是內聯的(抽象的開銷是整型操作的 5 倍還多)。靜態調用、實例調用、虛擬調用和

接口調用的原始開銷看起來並沒有什麼大的差別。

表 3:方法調用的時間 (ns)

平均 最小 原語 被調用者 平均 最小 原語 被調用者
0.2 0.2 inlined static call inl_s1 5.4 5.4 virtual call v1
6.1 6.1 static call s1 5.4 5.4 this virtual call v1
1.1 1.0 inlined instance call inl_i1 6.6 6.5 interface call itf1
6.8 6.8 instance call i1 1.1 1.0 inst itf instance call itf1
0.2 0.2 inlined this inst call inl_i1 0.2 0.2 this itf instance call itf1
6.2 6.2 this instance call i1 5.4 5.4 inst itf virtual call itf5
    5.4 5.4 this itf virtual call itf5

但是,這些結果是不具代表性的“最好情況”,是連續上百萬次運行計時循環的結果。在

這些測試示例中,虛擬方法和接口方法的調用位置都是單態的(例如,對於每個調用位置

,目標方法不因時間而改變),因此,緩存的虛擬方法和接口方法的調度機制(方法表、

接口映射指針和輸入)再加上非常有預測性的分支預測,使得處理器可以調用這些用其他

方法難以預測並與數據相關的分支來完成這項不切實際但卻富有成效的工作。實際上,任

何調度機制數據的數據緩存不命中或分支預測錯誤(可能是強制性的容量不命中或多態的

調用位置),都可以在多個循環之後使虛擬調用和接口調用的速度減慢。

讓我們進一步看一下這些方法調用的時間。

在第一個 inlined static call 示例中,我們調用了 s1_inl() 等一系列空的靜態方法。

由於編譯器完全內聯了所有調用,因此結果是對一個空循環計時。

爲了測量 static method call 的大致開銷,我們將 s1() 等靜態方法變得很大,使它們

無法內聯到調用者中。

我們甚至不得不使用一個顯式假謂詞變量 falsePred。如果我們寫下

static void s1() { if (false) dummy(1, 2, 3, , 16); }

JIT 編譯器將像以前那樣把死調用 (Dead Call) 消除到 dummy,並內聯整個(不是空的)

方法。順便說一下,這裏有一些調用時間爲 6.1 ns,這要歸結於被調用的靜態方法 s1 中

的(假)謂詞測試和跳轉。(另外,要禁用內聯,一種更好的方法是使用 CompilerServi

ces.MethodImpl(MethodImplOptions.NoInlining) 屬性)。

內聯的實例調用和常規實例調用的計時使用了相同的方法。但是,由於 C# 語言規範規定

,對 Null 對象引用的任何調用都會拋出 NullReferenceException,因此每個調用位置都

必須確保實例不爲空。這可以通過解除實例引用的引用來實現。如果該實例確實是 Null,

則會生成一個故障,並轉變爲此異常。

在反彙編 2 中,我們使用靜態變量 t 作爲實例,因爲當我們使用局部變量

    T t = new T();

時,編譯器會提起簽出循環的 Null 實例。

反彙編 2:使用 Null 實例“檢查”的實例方法調用位置

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h]
00000018 39 09             cmp         dword ptr [ecx],ecx
0000001a E8 C1 DE FF FF    call        FFFFDEE0

inlined this instance call 和 this instance call 相同,只是此實例是 this,而此

處的 Null 檢查已被取消。

反彙編 3:this 實例方法調用位置

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

“虛擬方法調用”的運行情況與傳統的 C++ 實現類似。每個新引入的虛擬方法的地址都存

儲在類型方法表的新插槽中。每個導出類型的方法表都與其基本類型的方法表一致並有所

擴展,並且所有虛擬方法替代都會使用導出類型的虛擬方法地址(在導出的類型方法表的

相應插槽中)來替換基本類型的虛擬方法地址。

在調用位置,與實例調用相比,虛擬方法調用要進行兩次額外的加載,一次是獲取方法表

地址(隨時可以在 *(this+0) 中找到),另外一次是從方法表中獲取適當的虛擬方法地址

並進行調用。請參閱反彙編 4。

反彙編 4:虛擬方法調用位置

               this.v1();
00000012 8B CE            mov         ecx,esi
00000014 8B 01            mov         eax,dword ptr [ecx] ; 獲取方法表地址
00000016 FF 50 38         call        dword ptr [eax+38h] ; 獲取/調用方法地址



最後,討論一下“接口方法調用”(反彙編 5)。在 C++ 中,沒有等效的接口方法調用。

任何給定的類型都可以實現任意數量的接口,並且每個接口在邏輯上都需要自己的方法表

。要對接口方法進行調度,就要查找方法表、方法的接口映射、該映射中接口的入口,然

後通過方法表中接口部分適當的入口進行調用。

反彙編 5:接口方法調用位置

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; 實例地址


00000018 8B 01             mov        eax,dword ptr [ecx]         ; 方法表地址


0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; 接口映射地


0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; 接口方法表

地址
00000020 FF 10             call       dword ptr [eax]             ; 獲取/調用方

法地址

其餘的原語計時,inst itf instance call、this itf instance call、inst itf virtu

al call 和 this itf virtual call,充分印證了這樣一個觀點:不論何時,導出類型的

方法在實現接口方法時,都可以通過實例方法調用位置來保持可調用性。

例如,在 this itf instance call 測試中,通過實例(不是接口)引用來調用接口方法

實現,結果接口方法被成功內聯並且開銷爲 0 ns。甚至當您將接口方法作爲實例方法進行

調用時,接口方法實現都有可能被內聯。

尚未實時編譯的方法調用
對於靜態方法調用和實例方法調用(不是虛擬方法調用和接口方法調用),JIT 編譯器會

根據在目標方法的調用位置被實時編譯時,目標方法是否已經被實時編譯,從而在當前生

成不同的方法調用序列。

如果被調用者(目標方法)還未被實時編譯,編譯器將通過已經用“prejit stub”初始化

的指針來發出調用。對目標方法的第一個調用到達 stub 時,將觸發方法的 JIT 編譯,同

時生成本機代碼,並對指針進行更新以尋址新的本機代碼。

如果被調用者已經過實時編譯,其本機代碼地址已知,則編譯器將直接向其發出調用。



創建新對象
創建新對象包括兩個階段:對象分配和對象初始化。

對於引用類型,對象被分配在可以進行內存回收的堆上。對於值類型,不管是以棧形式駐

留在另一個引用類型或值類型中,還是嵌入到另一個引用類型或值類型中,值類型對象都

與封閉結構有一些固定的差異,即不需要進行任何分配。

對典型的引用類型的小對象來說,堆分配的速度非常快。每次內存回收之後,除了固定的

對象之外,第 0 代堆的活對象都將被壓縮並被提升到第 1 代,因此,內存分配程序可以

使用一個相當大的連續可用內存空間。大多數的對象分配只會引起指針的遞增和邊界檢查

,這要比典型的 C/C++ 釋放列表分配程序(malloc/操作符 new)節省很多開銷。垃圾回

收器甚至會考慮計算機的緩存大小,以設法將第 0 代對象保留在緩存/內存層次結構中快

速有效的位置。

由於首選的託管代碼風格要求大多數分配的對象生存期很短,並且快速回收這些對象,所

以我們還包含了這些新對象的內存回收的分期開銷(在時間開銷中)。

請注意,垃圾回收器不會爲死對象浪費時間。如果一個對象是死的,GC 不會處理它,也不

會回收它,甚至是根本就不考慮它。GC 只關注那些存活的對象。

(例外:可終結的死對象屬於特殊情況。GC 會跟蹤這些對象,並且專門將可終結的死對象

提升到下一代,等待終結。這會花費很大的開銷,而且在最壞的情況下,還會可傳遞地提

升大的死對象圖。因此,若非確實需要,請不要使對象成爲可終結的。如果必須這樣做,

請考慮使用“清理模式”[Dispose Pattern],並在可能時調用 GC.SuppressFinalizer。

)除非 Finalize 方法要求,否則不要保留從可終結對象對其他對象的引用。

當然,生存期短的大對象的分期 GC 開銷要大於生存期短的小對象的開銷。每次對象分配

都使我們更接近下一個內存回收週期;而較大的對象比較小的對象達到得更早。但無論早

晚,“算帳”的時刻終會到來。GC 週期(尤其第 0 代回收)的速度非常快,但不是不需

要開銷的,即使絕大多數新對象是死的也是如此:因爲要查找(標記)活對象,需要先暫

停線程,然後查找棧和其他數據結構,以將根對象引用回收到堆中。

(也許更爲重要的是,只有極少的大對象能夠適應小對象所利用的緩存數量。緩存不命中

的影響很容易超過代碼路徑長度的影響。)

一旦爲對象分配了空間,空間就將保留下來以初始化對象(構造對象)。CLR 可以保證,

所有的對象引用都預先初始化爲 Null,所有的原始標量類型都初始化爲 0、0.0、False

等。(因此沒有必要在用戶定義的構造函數中進行多餘的初始化。當然,不必擔心。但請

注意,當前不必使用 JIT 編譯器優化掉冗餘的存儲。)

除了消除實例字段外,CLR 還初始化(僅引用類型)對象的內部實現字段:方法表指針和

對象標頭詞。而後者要優先於方法表指針。數組也獲得一個 Length 字段,對象數組獲得

Length 和元素類型字段。

然後,CLR 調用對象的構造函數(如果有的話)。每種類型的構造函數,不管是用戶定義

的還是編譯器生成的,都是首先調用其基本類型的構造函數,然後運行用戶定義的初始化

操作(如果有的話)。

從理論上講,這樣做對於深度繼承方案來說可能會花費比較大的開銷。如果 E 擴展 D 擴

展 C 擴展 B 擴展 A(擴展 System.Object),那麼初始化 E 將導致五次方法調用。實際

上,情況並沒有這麼糟糕,因爲編譯器會內聯掉對空的基本類型構造函數的調用(使其不

存在)。

參考表 4 的第一列時會發現,我們可以創建和初始化一個結構 D,此結構在大約 8 個整

型加法運算時間中包含四個 int 字段。反彙編 6 是來自三個不同計時循環的生成代碼,

創建了 A、C 和 E 的代碼。(在每個循環中,我們修改了所有新實例,這可以防止 JIT

編譯器優化掉所有內容。)

表 4:值類型和引用類型對象的創建時間 (ns)

平均 最少 原語 平均 最少 原語 平均 最少 原語
2.6 2.6 new valtype L1 22.0 20.3 new reftype L1 22.9 20.7 new rt ctor L1
4.6 4.6 new valtype L2 26.1 23.9 new reftype L2 27.8 25.4 new rt ctor L2
6.4 6.4 new valtype L3 30.2 27.5 new reftype L3 32.7 29.9 new rt ctor L3
8.0 8.0 new valtype L4 34.1 30.8 new reftype L4 37.7 34.1 new rt ctor L4
23.0 22.9 new valtype L5 39.1 34.4 new reftype L5 43.2 39.1 new rt ctor L5
   22.3 20.3 new rt empty ctor L1 28.6 26.7 new rt no-inl L1
   26.5 23.9 new rt empty ctor L2 38.9 36.5 new rt no-inl L2
   38.1 34.7 new rt empty ctor L3 50.6 47.7 new rt no-inl L3
   34.7 30.7 new rt empty ctor L4 61.8 58.2 new rt no-inl L4
   38.5 34.3 new rt empty ctor L5 72.6 68.5 new rt no-inl L5

反彙編 6:值類型對象的構造

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0
00000027 FF 45 FC         inc         dword ptr [ebp-4]

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch]
00000027 33 C0            xor         eax,eax
00000029 AB               stos        dword ptr [edi]
0000002a AB               stos        dword ptr [edi]
0000002b AB               stos        dword ptr [edi]
0000002c FF 45 FC         inc         dword ptr [ebp-4]

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h]
00000029 33 C0            xor         eax,eax
0000002b 8D 48 05         lea         ecx,[eax+5]
0000002e F3 AB            rep stos    dword ptr [edi]
00000030 FF 45 FC         inc         dword ptr [ebp-4]

另外的五個計時(new reftype L1、……、new reftype L5)針對引用類型 A、……、E

的五個繼承級別,沒有用戶定義的構造函數:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

將引用類型的時間與值類型的時間進行比較,我們會發現,對於每個實例,其分配和釋放

的分期開銷在測試計算機上大約爲 20 ns(是整型加法運算時間的 20 倍)。這個速度非

常快,也就是說,一秒鐘可以分配、初始化和回收大約 5 千萬個生存期很短的對象,而且

這種速度可以保持不變。對於像五個字段一樣小的對象,分配和回收的時間僅佔對象創建

時間的一半。請參閱反彙編 7。

反彙編 7:引用類型對象的構造

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h
00000014 E8 9F CC 6C F9   call        F96CCCB8

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h
00000014 E8 A7 CB 6C F9   call        F96CCBC0

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h
00000014 E8 AF CA 6C F9   call        F96CCAC8

最後三組五個計時說明了這種繼承類構造方案的變化情況。

new rt empty ctor L1、……、new rt empty ctor L5:每個類型 A、……、E 都有一個

空的用戶定義的構造函數。這些類型都被內聯掉,而且生成的代碼與上面的代碼相同。


new rt ctor L1、……new rt ctor L5:每個類型 A、……、E 都有一個用戶定義的構造

函數,將其實例變量設置爲 1:
    public class A     { int a; public A() { a = 1; } }
    public class B : A { int b; public B() { b = 1; } }
    public class C : B { int c; public C() { c = 1; } }
    public class D : C { int d; public D() { d = 1; } }
    public class E : D { int e; public E() { e = 1; } }

編譯器將每組嵌套的基類構造函數調用內聯到 new 位置。(反彙編 8)。

反彙編 8:深度內聯的繼承構造函數

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h
00000017 E8 C4 C7 6C F9   call        F96CC7E0
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h
00000017 E8 14 C6 6C F9   call        F96CC630
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h
00000017 E8 84 C3 6C F9   call        F96CC3A0
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1

new rt no-inl L1、……new rt no-inl L5:每個類型 A、……、E 都有一個用戶定義構

造函數,該構造函數被有意編寫爲開銷很大,以至無法內聯。此方案模擬了創建具有深度

繼承層次結構和大型構造函數的複雜對象的開銷。
  public class A     { int a; public A() { a = 1; if (falsePred) dummy(); } }


  public class B : A { int b; public B() { b = 1; if (falsePred) dummy(); } }


  public class C : B { int c; public C() { c = 1; if (falsePred) dummy(); } }


  public class D : C { int d; public D() { d = 1; if (falsePred) dummy(); } }


  public class E : D { int e; public E() { e = 1; if (falsePred) dummy(); } }



表 4 中的最後五個計時顯示了調用嵌套的基本構造函數時所需的額外系統開銷。

中間程序:CLR 分析器(CLR Profiler)演示
現在來簡單演示一下 CLR 分析器。CLR 分析器(舊稱“分配分析器”)使用 CLR 分析 A

PI 在應用程序運行時收集事件數據,特別是調用、返回以及對象分配和內存回收事件。(

CLR 分析器是一種“侵害性”的分析器,即它會嚴重地減慢被分析的應用程序的運行速度

。)收集事件之後,您可以使用 CLR 分析器來檢查應用程序的內存分配和 GC 行爲,包括

分層調用圖和內存分配模式之間的交互。

CLR 分析器之所以值得學習,是因爲對許多“面臨性能挑戰的”託管代碼應用程序來說,

瞭解數據分配配置文件可以使您獲得很關鍵的認知,從而減少工作集並由此而開發出快速

、價廉的組件和應用程序。

CLR 分析器還可以揭示哪些方法分配的存儲比您預期的多,並可以發現您不小心保留的對

無用對象圖的引用,而這些引用原本可能會由 GC 回收。(一種常見的問題設計模式是項

目的軟件緩存或查找表已不再需要,或者對以後的重建是安全的。當緩存使對象圖的生存

期超出其有用壽命時,情況將非常糟糕。因此,務必解除對不再需要的對象的引用。)



圖 1 是在執行計時測試驅動程序時堆的時間線圖。鋸齒狀圖案表示對象 C(洋紅色)、D

(紫色)和 E(藍色)的上千個實例的分配。每過幾毫秒,就會在新對象(第 0 代)堆中

消耗大約 150 KB 的 RAM,而垃圾回收器會短暫運行以回收這部分內存,並將所有活對象

提升到第 1 代。很明顯,即使在這種極具侵害性(緩慢)的分析環境下,在 100 ms(2.

8 秒到 2.9 秒)的時間間隔裏,仍經歷了大約 8 個第 0 代 GC 週期。然後,在 2.977

秒時,垃圾回收器爲另一個 E 實例釋放了空間,並執行第 1 代內存回收,這會回收和壓

縮第 1 代堆,因此鋸齒狀圖案從一個較低的位置開始繼續延伸。



圖 1:CLR 分析器時間線圖

注意,對象越大(E 大於 D,D 大於 C),第 0 代堆充滿的速度就越快,GC 週期就越頻

繁。

類型轉換和實例類型檢查
要使託管代碼安全、可靠、“可驗證”,必須保證類型安全。如果可以將一個對象的類型

轉換爲其他類型,就很容易危及 CLR 的完整性,並因此而使其被不可信的代碼支配。

表 5:類型轉換和 isinst 時間 (ns)

平均 最少 原語 平均 最少 原語
0.4 0.4 cast up 1 0.8 0.8 isinst up 1
0.3 0.3 cast down 0 0.8 0.8 isinst down 0
8.9 8.8 cast down 1 6.3 6.3 isinst down 1
9.8 9.7 cast (up 2) down 1 10.7 10.6 isinst (up 2) down 1
8.9 8.8 cast down 2 6.4 6.4 isinst down 2
8.7 8.6 cast down 3 6.1 6.1 isinst down 3

表 5 顯示了這些強制性類型檢查的系統開銷。從導出類型轉換到基本類型總是安全的,而

且也是不需要開銷的,而從基本類型轉換到導出類型則必須經過類型檢查。

(已檢查的)類型轉換將對象引用轉換爲目標類型,或者拋出 InvalidCastException。



相反,isinst CIL 指令用於實現 C# as 關鍵字:

  bac = ac as B;

如果 ac 不是 B 或者從 B 導出,結果就是 Null,而不是一個異常。

列表 2 是一個類型轉換的計時循環,反彙編 9 顯示了向下轉換爲導出類型的生成代碼。

爲執行類型轉換,編譯器直接調用 Helper 例程。

列表 2:測試類型轉換計時的循環

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (c)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (c)bd; dce = (D)ce; edf = (E)df;
    }
}

反彙編 9:向下類型轉換

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp
00000030 B9 40 73 3E 00   mov         ecx,3E7340h
00000035 E8 32 A7 4E 72   call        724EA76C

屬性
在託管代碼中,屬性是一對方法,即一個屬性獲取方法和一個屬性設置方法,類似於對象

的字段。get_ 方法獲取屬性,set_ 方法將屬性更新爲新的值。

除此之外,屬性的行爲和開銷與常規的實例方法、虛擬方法的行爲和開銷非常相像。如果

使用一個屬性來獲取或存儲一個實例字段,通常是以內聯方式進行,這與小方法相同。



表 6 顯示了獲取(和添加)並存儲一組整數實例字段和屬性所需的時間。獲取或設置屬性

的開銷實際上與直接訪問基本字段相同,除非將屬性聲明爲虛擬的。如果聲明爲虛擬的,

則開銷基本上就是虛擬方法調用的開銷。這沒什麼可奇怪的。

表 6:字段和屬性時間 (ns)

平均 最少 原語
1.0 1.0 get field
1.2 1.2 get prop
1.2 1.2 set field
1.2 1.2 set prop
6.4 6.3 get virtual prop
6.4 6.3 set virtual prop

寫屏障(Write Barrier)
CLR 垃圾回收器充分利用“代假設”(即“多數新對象的生存期很短”)來最大限度地減

少回收的系統開銷。

堆在邏輯上被劃分爲幾個代。最新的對象存儲在第 0 代,這些對象尚未經過回收。在第

0 代回收期間,GC 確定從 GC 根集可以到達哪些第 0 代對象(如果有的話),這其中包

括計算機寄存器中、棧上、類靜態字段對象引用中的對象引用。能夠以傳遞方式到達的對

象是“存活的”,並被提升(複製)到第 1 代。

由於總的堆大小可能是數百 MB,而第 0 代堆大小可能只有 256 KB,因此限制 GC 對象圖

對第 0 代堆的跟蹤範圍是一項優化,對於實現 CLR 的非常短暫的回收暫停時間極爲重要



但是,可以將一個第 0 代對象的引用存儲在第 1 代或第 2 代對象的對象引用字段中。因

爲我們不在第 0 代回收期間掃描第 1 代或第 2 代對象,所以如果此引用是對給定的第

0 代對象的唯一引用,則該對象可能會被 GC 誤回收。我們不能允許發生這種情況!

相反,對堆中所有對象引用字段進行的所有存儲都會導致“寫屏障”(Write Barrier)。這

是一種高效記錄代碼,可以記錄新代對象引用到舊代對象的字段的存儲情況。此類舊對象

引用字段被添加到後續 GC 的 GC 根集中。

“各對象引用字段存儲”寫屏障的系統開銷與簡單的方法調用的開銷基本相等(表 7)。

這是一項新的開銷,本機 C/C++ 代碼中沒有提供。但由於能夠大幅提高對象分配和 GC 的

速度,並充分利用自動內存管理來提高工作效率,因此這種開銷通常還是很值得的。

表 7:寫屏障時間 (ns)

平均 最少 原語
6.4 6.4 write barrier

在銜接緊密的內層循環中,寫屏障的開銷比較大。可以預見,在未來幾年中,會出現可以

減少寫屏障數目和總分期開銷的先進編譯技術。

您可能會認爲,寫屏障只在存儲引用類型的對象引用字段時纔是必需的。但是,在一個值

類型方法中,存儲對象引用字段(如果有的話)同樣會受到寫屏障的保護。這是必需的,

因爲有時值類型本身可能會嵌入到駐留在堆中的引用類型中。

數組元素訪問
要診斷和排除數組超出邊界的錯誤和堆的損壞,並保護 CLR 本身的完整性,必須在加載和

存儲數組元素時進行邊界檢查,以確保索引在間隔 [0,array.Length-1] 包含或拋出的 I

ndexOutOfRangeException 的之內。

我們的測試測量了加載或存儲 int[] 數組和 A[] 數組的元素所用的時間。(表 8)。



表 8:數組訪問時間 (ns)

平均 最少 原語
1.9 1.9 load int array elem
1.9 1.9 store int array elem
2.5 2.5 load obj array elem
16.0 16.0 store obj array elem

邊界檢查需要將數組索引與隱式的 array.Length 字段進行對比。如反彙編 10 所示,我

們只利用兩條指令來檢查索引是否既不小於 0、也不大於或等於 array.Length。如果索引

在此範圍內,我們將轉到一個拋出異常的行序列。這也適用於對象數組元素的加載,以及

在 int 和其他簡單值類型數組中的存儲。(由於內層循環稍有不同,Load obj array el

em 的速度稍有點緩慢。)

反彙編 10:加載 int 數組元素

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; 比較 i 和 array.

Length
00000027 73 19            jae         00000042
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8]
                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx
00000044 E8 52 78 52 72   call        7252789B

JIT 編譯器通常通過對代碼質量的優化,來消除冗餘的邊界檢查。

回憶一下前面幾節,我們可以認爲“對象數組元素存儲”的開銷會大得多。要將對象引用

存儲到一個對象引用的數組中,運行時必須:

檢查數組索引未超出邊界;
檢查對象是數組元素類型的一個實例;
執行寫屏障(記錄從數組到對象的所有代間對象引用)。
此代碼序列相當長。編譯器並未在各個對象的數組存儲位置發出調用,而是對共享的 Hel

per 函數發出調用,如反彙編 11 所示。此調用(再加上這三項操作)說明了爲何此示例

中會出現附加時間。

反彙編 11:存儲對象數組元素

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx 
00000028 8B CF            mov         ecx,edi
0000002a BA 01 00 00 00   mov         edx,1
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; 存儲對象數組元素 Helper

裝箱 (Boxing) 和拆箱 (Unboxing)
綜合利用 .NET 編譯器和 CLR,可以將值類型(包括原始類型,例如 int [System.Int32

])作爲引用類型使用,即作爲對象引用進行尋址。這樣一來,就可以將值類型作爲對象傳

遞到方法、作爲對象存儲在集合中,等等。

對值類型進行“裝箱”就是創建一個包含其值類型的副本的引用類型對象。從概念上講,

這就相當於創建一個類,而這個類具有與值類型相同的未命名實例字段。

對已裝箱的值類型進行“拆箱”就是將值從對象複製到值類型的新實例中。

如表 9 所示(與表 4 比較),裝箱 int 以及以後對其進行內存回收所需的分期時間與實

例化包含一個 int 字段的小型類所需的時間大致相同。

表 9:裝箱和拆箱 int 的時間 (ns)

平均 最少 原語
29.0 21.6 box int
3.0 3.0 unbox int

要對已裝箱的 int 對象進行拆箱,需要將類型明確轉換爲 int。這將被編譯爲對象的類型

(由其方法表地址表示)與裝箱的 int 方法表地址的比較。如果它們相等,值就被從對象

中複製出去。否則,就會拋出異常。請參閱反彙編 12。

反彙編 12:裝箱和拆箱 int

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h
0000001f E8 E4 A5 6C F9   call        F96CA608
00000024 8B D0            mov         edx,eax
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == ty

peof(int)"?
00000047 74 0C            je          00000055
00000049 8B D6            mov         edx,esi
0000004b B9 08 07 B9 79   mov         ecx,79B90708h
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; 否,拋出異常


00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax]
0000005a 03 38            add         edi,dword ptr [eax]        ; 是,獲取 in

t 字段

委託 (Delegate)
在 C 中,函數指針是一種逐字存儲函數地址的原始數據類型。

C++ 中添加了成員函數的指針。成員函數的指針 (PMF) 代表一個延遲的成員函數調用。非

虛擬成員函數的地址可以是一個簡單的代碼地址,而虛擬成員函數的地址則必須包含一個

特殊的虛擬成員函數調用,對這樣的 PMF 解除引用就是虛擬的函數調用。

要解除 C++ PMF 的引用,您必須提供一個實例:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

幾年前,在 Visual C++ 編譯器開發組工作的時候,我們常常問自己:表達式 pa->*pmf(

沒有函數調用操作符)究竟是什麼東西?我們把它稱爲“成員函數的綁定指針”,但是“

潛在的成員函數調用”一樣恰當。

返回到託管代碼領域,委託對象與此(潛在的方法調用)類似。委託對象代表要調用的方

法和要調用的實例,或者對於靜態方法來說,就是要調用的靜態方法。

(正如我們的文檔所述:委託聲明定義了一種使用特定簽名來封裝方法的引用類型。委託

實例封裝了靜態或實例方法。委託大致與 C++ 中的函數指針類似,但是,委託是類型安全

的和可靠的。)

C# 中的委託類型是 MulticastDelegate 的導出類型。此類型提供了豐富的語義,包括可

以構建在調用委託時要調用的 (object,method) 對的調用列表。

委託還提供一種進行異步方法調用的功能。定義委託類型並實例化委託類型之後(通過潛

在的方法調用初始化),您可以通過 BeginInvoke 同步(方法調用語法)或異步調用該委

託類型。如果調用了 BeginInvoke,運行時就對調用進行排隊並立即返回到調用者。隨後

在線程池的線程上調用目標方法。

所有這些豐富的語義的開銷都很大。比較表 10 和表 3,可以發現委託調用比方法調用大

約慢八倍。希望以後會有所改進。

表 10:委託調用的時間 (ns)

平均 最少 原語
41.1 40.9 delegate invoke

關於緩存不命中、頁面錯誤和計算機結構
回顧“過去那些美好的日子”,大約是在 1983 年吧,處理器的速度很慢(大約 50 萬條

指令/秒),相對而言,RAM 的速度非常快但是較小(256 KB 的 DRAM 的訪問時間大約爲

300 ns),磁盤很慢而且很大(10 MB 的磁盤的訪問時間大約爲 25 ms)。PC 微處理器

採用標量的 CISC,大多數的浮點運算都在軟件中進行,而且沒有緩存。

在“摩爾定律”提出二十年後,大約在 2003 年,處理器已經相當快了(3 GHz 的處理器

每個週期可以發出最多三項操作),相對而言,RAM 則變得非常慢(512 MB 的 DRAM 的訪

問時間大約爲 100 ns),磁盤已顯得“極其”緩慢而“巨大”(100 GB 的磁盤的訪問時

間大約爲 10 ms)。現在的 PC 微處理器採用無序數據流、超標量、超線程、跟蹤緩存的

RISC(運行解碼的 CISC 指令),而且有多級緩存,例如,某些服務器專用的微處理器有

32 KB 的一級數據緩存(可能是 2 次滯後時間週期)、512 KB 的二級數據緩存和 2 MB

的三級數據緩存(可能是 12 次滯後時間週期),所有這些緩存都在芯片上。

在過去的好日子裏,您可以計算所編寫的代碼的字節數,計算代碼運行所需的週期數。加

載或存儲需要的週期數大約與添加所需的週期數相等。現代處理器在多個功能單元中使用

分支預測、推測和無序(數據流)執行來查找指令級的並行計算,因此可以同時進行多個

計算過程。

現在,最快的 PC 每微秒可以發出多達 9000 項左右的操作,但同是在一微秒內,只能將

大約 10 個緩存行加載或存儲到 DRAM。在計算機結構領域,這被稱爲“撞內存牆”。緩存

隱藏了內存滯後時間,但只隱藏到某個點。如果代碼或數據不適應緩存,和/或顯示出很差

的引用位置,那麼我們那架每微秒 9000 項操作的“超音速噴氣機”就會退化爲每微秒只

有 10 次加載的“三輪車”。

而且,(請不要讓這種情況發生在您身上),如果程序的工作集超出可用的物理 RAM,並

且程序在一開始就出現硬頁面錯誤,那麼,在每個 10,000 微秒的頁面錯誤服務(磁盤訪

問)中,我們就會喪失爲用戶提供多達“9000 萬”項操作的機會。這實在太可怕了,因此

我相信,從今天開始您會認真測量您的工作集 (vadump) 並使用像 CLR 分析器這樣的工具

,來消除不必要的分配和無意的對象圖保持。

但是,所有這一切與瞭解託管代碼原語的開銷有什麼關係呢?關係重大。

回憶一下表 1,即託管代碼原語時間的綜合列表,其中的數據是在 1.1 GHz P-III 上測量

得到的。這些數據表明,每一個時間,甚至是使用五級顯式構造函數調用分配、初始化和

回收一個五字段對象的分期開銷,都比訪問一次 DRAM 要“快”。哪怕僅僅是一次未使用

所有級別的芯片緩存的加載,都比一次託管代碼的操作需要的時間長。

因此,如果您關心代碼的速度,那麼在設計和實現算法、數據結構時就必須考慮和測量緩

存/內存的層次結構。

現在我們看一個簡單的示例:是對一個 int 數組求和快,還是對一個等價的 int 鏈接列

表求和快?哪種情況快、快多少、爲何快?

您可以考慮一會兒。對於像 int 這樣的小項目,每個數組元素所佔用的內存空間是鏈接列

表元素所佔用空間的四分之一。(每個連接列表節點都有兩個單詞的對象系統開銷和兩個

單詞的字段 [下一個鏈接和 int 項目]。)這將危害緩存的利用。因此數組方法會更好一

些。

但是,數組遍歷可能會導致對每個項目進行數組邊界檢查。您前面已經看到,邊界檢查需

要佔用一點時間。也許這有利於鏈接列表?

反彙編 13:對 int 數組求和與對 int 鏈接列表求和

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; 邊界檢查


00000027 73 19            jae         00000042
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; 加載數組元


               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx 
0000002e 3B CE            cmp         ecx,esi
00000030 7C F2            jl          00000024


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8]
0000002d 8B 40 04         mov         eax,dword ptr [eax+4]
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8]
00000033 8B 40 04         mov         eax,dword ptr [eax+4]
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8]
00000039 8B 40 04         mov         eax,dword ptr [eax+4]
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8]
0000003f 8B 40 04         mov         eax,dword ptr [eax+4]
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx 
00000043 85 C9            test        ecx,ecx
00000045 79 E3            jns         0000002A

參閱反彙編 13 後,我開始支持鏈接列表遍歷,並將該遍歷展開四次,甚至刪除了通常的

Null 指針列表結尾檢查。數組循環中的每個項目需要六條指令,而鏈接列表循環中的每

個項目只需要 11/4 = 2.75 條指令。現在,您認爲哪個更快?

測試條件:首先,創建一個包含 100 萬個 int 的數組和一個包含 100 萬個 int(1 M 列

表節點)的簡單傳統鏈接列表。然後,計算將前 1,000 個、10,000 個、100,000 個以及

1,000,000 個項目加起來所需的時間。每個重複循環多次,測量每個示例中最佳的緩存行

爲。

哪個更快?請您想一想,答案是:表 1 中的最後八個條目。

真有意思!隨着引用數據的大小超過連續緩存大小,時間也變得相當慢。數組版本始終要

比鏈接列表版本快,即使執行兩倍指令也是如此,而在執行 100,000 個項目時,數組版本

要快七倍!

爲什麼是這樣?首先,只有很少的鏈接列表項目能夠適應任何給定的緩存級。所有這些對

象標頭和鏈接都是在浪費空間。其次,現代的無序數據流處理器可以迅速提高速度,以同

時處理數組中的多個項目。相比之下,對於鏈接列表,如果當前列表節點不在緩存中,處

理器就無法開始獲取指向當前節點之後的節點的鏈接。

在 100,000 個項目的示例中,處理器要花費全部時間的(平均)大約 (22-3.5)/22 = 84

%,來等待從 DRAM 讀取某個列表節點的緩存行。這聽起來很糟糕,但實際情況可能會比這

更糟糕。由於鏈接列表項目較小,因此其中的許多項目可以適應緩存行。由於我們按分配

的順序遍歷列表,而且由於內存回收器即使在將死對象壓在堆之外時也保持分配順序,因

此很可能在獲取緩存行上的一個節點之後,接下來的多個節點可能也已在緩存中。如果節

點更大,或者如果列表節點以隨機地址順序排列,則訪問的每個節點可能剛好是完全緩存

不命中。向每個列表節點添加 16 個字節會使每個項目的遍歷時間增加一倍,達到 43 ns

;添加 32 字節,達到 67 ns;添加 64 字節會再增加一倍,使每個項目的時間達到 146

ns,這很可能是測試計算機上的平均 DRAM 滯後時間。

那麼應該從中吸取什麼教訓呢?是不是避免使用 100,000 個節點的鏈接列表?不是的。教

訓是,在考慮託管代碼的低級效率時,緩存影響是比本機代碼更爲關鍵的因素。如果您編

寫的託管代碼對性能的要求很高,尤其是管理大型數據結構的代碼,請牢記緩存影響,認

真考慮您的數據結構訪問模式,努力減少數據佔用的空間並實現良好的引用位置 (Locali

ty of Reference)。

順便說一句,存在這樣的趨勢:隨着時間推移,內存牆、DRAM 訪問時間與 CPU 操作時間

的比率將繼續惡化。

下面是一些“重視緩存設計”的經驗法則:

試驗並測量您的方案,因爲很難預測二次影響,而且經驗之談不值得推廣。
有些數據結構,以數組爲例,會利用“隱式相鄰”來表示數據之間的關係。其他的數據結

構,以鏈接列表爲例,則使用“顯式指針(引用)”表示這種關係。通常情況下,隱式相

鄰會更好一些,因爲與指針相比,“隱式”更節約空間,而且相鄰可以提供穩定的引用位

置,並允許處理器在處理下一個指針之前開始更多工作。
有些使用模式支持混合結構,如小數組的列表、數組的數組或 B 樹。
或許,現在應該再次利用在磁盤訪問只耗費 50,000 條 CPU 指令時設計的對磁盤訪問影響

很大的計劃算法,因爲 DRAM 訪問會用到數千條 CPU 操作。
由於 CLR 的垃圾回收器使用“標記和壓縮”的工作機制,會保留對象的相對順序,因此在

同一時間(並在同一線程上)分配的對象傾向於保留在相同的空間中。您或許可以利用此

現象在常用的緩存行上仔細布置不同類別的數據。
您可能希望將數據分成不同的部分:要頻繁遍歷的數據必須適應緩存,不常用的數據則可

以被“緩存掉”。
DIY 時間實驗
在本文的計時測量實驗中,我使用了 Win32 高分辨率性能計數器 QueryPerformanceCoun

ter(和 QueryPerformanceFrequency)。

通過 P/Invoke,可以容易地調用這些性能計數器:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

在計時循環之前和之後分別調用 QueryPerformanceCounter、減去計數、乘以 1.0e9、除

以頻率、除以迭代次數,得出的就是每次迭代的大約時間(以 ns 計)。

由於空間和時間限制,我們沒有涉及鎖定、異常處理或代碼訪問安全系統。讀者可以在自

己的練習中考慮這些因素。

另外,我使用了 VS.NET 2003 中的“反彙編”(Disassembly) 窗口來得到文中的反彙編。

但是,這其中也包含一個小技巧。如果在 VS.NET 調試程序中運行應用程序,即使是在“

發佈”(Release) 模式中構建的優化可執行程序,在“調試模式”下運行時,其中的優化

如內聯等也將被禁用。我找到的查看 JIT 編譯器發出的優化本機代碼的唯一方法,是在調

試程序“外部”啓動測試應用程序,然後再使用 Debug.Processes.Attach 將其附加到調

試程序。

一個空間開銷模型?
篇幅有限,本文將不對空間問題做詳細論述,只簡單介紹一下。

基本考慮(有些是 C# [默認的 TypeAttributes.SequentialLayout] 和 x86 專用的):



值類型的大小通常是其字段的總大小,其中可以包含 4 字節或更小的字段。
可以使用 [StructLayout(LayoutKind.Explicit)] 和 [FieldOffset(n)] 屬性實現聯合。


引用類型的大小是 8 字節加上其字段的總大小,即取整後再加上 4 字節,並且可以包含

4 字節或更小的字段。
在 C# 中,enum 聲明可以指定一個任意整數基本類型(char 除外),因此可以定義 8 位

、16 位、32 位和 64 位的 enum。
就像在 C/C++ 中一樣,您可以通過適當調整整型字段的大小來從大對象中勻出部分空間。


您可以使用 CLR 分析器來檢查一個已分配的引用類型的大小。
大對象(幾十 KB 或更大)在獨立的大對象堆中託管,以避免開銷很大的複製。
回收可終結對象時要佔用一個附加的 GC 代,請儘量少用這些對象,並考慮使用“處置模

式”。
宏觀考慮:

每個 AppDomain 目前都會產生相當大的空間開銷。許多運行時和 Framework 結構不在 A

ppDomain 之間共享。
在一個進程內,一般不在 AppDomain 間共享實時編譯的代碼。如果運行時是專門集成的,

可能會忽略此行爲。請參閱關於 CorBindToRuntimeEx 和 STARTUP_LOADER_OPTIMIZATION

_MULTI_DOMAIN 標記的文檔。
無論何時,進程之間都不會共享實時編譯的代碼。如果您的組件要加載到多個進程中,請

考慮使用 NGEN 進行預編譯以共享本機代碼。
反射 (Reflection)
有這樣一種說法:“如果您要知道‘反射’的開銷是多少,您可能根本負擔不起。”如果

您深入閱讀了本文,您就知道了解開銷情況以及測量這些開銷有多麼重要。

反射很有用而且功能強大,但與實時編譯的本機代碼相比,它既不顯得快,也不夠精煉。

我已經提醒過您了。請親自測量。

小結
現在,您(或多或少地)從最根本上了解了託管代碼的開銷情況。您也獲得了一些基本知

識,幫助您在權衡實現方案時做出更明智的決策,編寫更快的託管代碼。

我們已經瞭解到實時編譯的託管代碼可以像本機代碼一樣放心使用。您的挑戰是,明智地

編碼,在 Framework 的衆多豐富、易用的功能之間做出明智的選擇。

性能在某些環境下無關緊要,而且另一些環境下卻是產品的最重要特性。過早的優化是一

切問題的根源。但是,不重視效率也會導致同樣的結果。您是專業人士,是藝術家,是能

工巧將。那麼,您一定要知道事物的開銷。如果您不知道或即使您認爲自己知道,也要經

常進行測量。

至於 CLR 工作組,我們將繼續努力提供一個“比本機代碼工作效率更高”且“比本機代碼

更快”的平臺。希望情況會越來越好。請繼續關注我們的工作。

記住您的諾言。

資源
David Stutz et al,《Shared Source CLI Essentials》。O'Reilly and Assoc.,2003

。ISBN 059600351X。
Jan Gray,C++:Under the Hood(英文)。
Gregor Noriskin,編寫高性能的託管應用程序:入門,MSDN。
Rico Mariani,Garbage Collector Basics and Performance Hints(英文),MSDN。


Emmanuel Schanzer,Performance Tips and Tricks in .NET Applications(英文),M

SDN。
Emmanuel Schanzer,Performance Considerations for Run-Time Technologies in the

.NET Framework(英文),MSDN。
vadump(平臺 SDK 工具)(英文),MSDN。
.NET 演示,[Managed] Code Optimization(英文),2002 年 9 月 10 日,MSDN。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章