天天看點

編寫更快的托管代碼:了解開銷情況

編寫更快的托管代碼:了解開銷情況

Posted on 2006-02-08 16:40 A.Z 閱讀(1603) 評論(6)  編輯 收藏 網摘 所屬分類: .Net Framework 

Jan Gray

Microsoft CLR Performance Team

2003 年 6 月

适用于:

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.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。