Jan Gray
Microsoft CLR Performance Team
2003 年 6 月
適用於:
Microsoft® .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 Framework 更加清楚明瞭,利用它可以做到事半功倍。
對軟件用戶來說,託管代碼爲他們提供了更豐富、更健壯的應用程序,讓他們通過更優質的軟件享受更好的生活。
編寫更快的託管代碼的祕訣是什麼?
儘管可以做到事半功倍,但還是不能放棄認真編碼的責任。首先,您必須承認:“我是個新手。”您是個新手。我也是個新手。在託管代碼領域中,我們都是新手。我們仍然在學習這方面的訣竅,包括開銷的情況。
面對功能豐富、使用方便的 .NET Framework,我們就像糖果店裏的孩子:“哇,不需要枯燥的 strncpy
,只要把字符串‘+’在一起就可以了!哇,我可以在幾行代碼中加載一兆字節的 XML!哈哈!”
一切都是那麼容易。真的是很容易。即使是從 XML 信息集中提出幾個元素,也會輕易地投入幾兆字節的 RAM 來分析 XML 信息集。使用 C 或 C++ 時,這件事是很令人頭疼的,必須考慮再三,甚至您會想在某些類似 SAX 的 API 上創建一個狀態機。而使用 .NET Framework 時,您可以在一口氣加載整個信息集,甚至可以反覆加載。這樣一來,您的應用程序可能就不再那麼快了。也許它的工作集達到了許多兆字節。也許您應該重新考慮一下那些簡單方法的開銷情況。
遺憾的是,在我看來,當前的 .NET Framework 文檔並沒有足夠詳細地介紹 Framework 的類型和方法的性能含義,甚至沒有具體指明哪些方法會創建新對象。性能建模不是一個很容易闡述的主題,但是“不知道”會使我們更難做出恰當的決定。
既然在這方面我們都是新手,又不知道任何開銷情況,而且也沒有什麼文檔可以清楚說明開銷情況,那我們應該做些什麼呢?
測量,對開銷進行測量。祕訣就是“對開銷進行測量”並“保持警惕”。我們都應該養成測量開銷的習慣。如果我們不怕麻煩去測量開銷,就不會輕易調用比我們“假設”的開銷高出十倍的新方法。
(順便說一下,要更深入地瞭解 BCL [基類庫] 的性能基礎或 CLR,請查看 Shared Source 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) 的靜止 PC 上通過一套簡單的計時循環收集到的。
測試驅動程序調用各種測試方法,指定要執行的多個迭代,自動調整爲迭代 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 prop |
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 elem |
0.2 | 0.2 | inlined static call | 43.2 | 39.1 | new reftype ctor L5 | 2.5 | 2.5 | load obj array elem |
6.1 | 6.1 | static call | 28.6 | 26.7 | new reftype ctor no-inl L1 | 16.0 | 16.0 | store obj array elem |
1.1 | 1.0 | inlined instance call | 38.9 | 36.5 | new reftype ctor no-inl L2 | 29.0 | 21.6 | box 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 array 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 1000 |
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++。同時我們還會發現,有些通常在本機代碼中開銷不太大的操作(如數據類型轉換、數組和字段存儲、函數指針 [委託])現在的開銷卻變大了。爲什麼是這樣呢?讓我們來看一下。
。。。。
資源
- 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(英文),MSDN。
- Emmanuel Schanzer,Performance Considerations for Run-Time Technologies in the .NET Framework(英文),MSDN。
- vadump(平臺 SDK 工具)(英文),MSDN。
- .NET 演示,[Managed] Code Optimization(英文),2002 年 9 月 10 日,MSDN。