編寫更快的託管代碼

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

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++ 對象 newdelete 大致相當或者更好一些。創建對象以及後來的自動收回對象的分期開銷非常低,這樣就可以在每秒鐘內創建數千萬個小對象。

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

更糟糕的是,不必要地持續引用無用的對象圖 (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.00.0Control2.62.6new valtype L10.80.8isinst up 1
1.01.0Int add4.64.6new valtype L20.80.8isinst down 0
1.01.0Int sub6.46.4new valtype L36.36.3isinst down 1
2.72.7Int mul8.08.0new valtype L410.710.6isinst (up 2) down 1
35.935.7Int div23.022.9new valtype L56.46.4isinst down 2
2.12.1Int shift22.020.3new reftype L16.16.1isinst down 3
2.12.1long add26.123.9new reftype L21.01.0get field
2.12.1long sub30.227.5new reftype L31.21.2get prop
34.234.1long mul34.130.8new reftype L41.21.2set field
50.150.0long div39.134.4new reftype L51.21.2set prop
5.15.1long shift22.320.3new reftype empty ctor L10.90.9get this field
1.31.3float add26.523.9new reftype empty ctor L20.90.9get this prop
1.41.4float sub38.134.7new reftype empty ctor L31.21.2set this field
2.02.0float mul34.730.7new reftype empty ctor L41.21.2set this prop
27.727.6float div38.534.3new reftype empty ctor L56.46.3get virtual prop
1.51.5double add22.920.7new reftype ctor L16.46.3set virtual prop
1.51.5double sub27.825.4new reftype ctor L26.46.4write barrier
2.12.0double mul32.729.9new reftype ctor L31.91.9load int array elem
27.727.6double div37.734.1new reftype ctor L41.91.9store int array elem
0.20.2inlined static call43.239.1new reftype ctor L52.52.5load obj array elem
6.16.1static call28.626.7new reftype ctor no-inl L116.016.0store obj array elem
1.11.0inlined instance call38.936.5new reftype ctor no-inl L229.021.6box int
6.86.8instance call50.647.7new reftype ctor no-inl L33.03.0unbox int
0.20.2inlined this inst call61.858.2new reftype ctor no-inl L441.140.9delegate invoke
6.26.2this instance call72.668.5new reftype ctor no-inl L52.72.7sum array 1000
5.45.4virtual call0.40.4cast up 12.82.8sum array 10000
5.45.4this virtual call0.30.3cast down 02.92.8sum array 100000
6.66.5interface call8.98.8cast down 15.65.6sum array 1000000
1.11.0inst itf instance call9.89.7cast (up 2) down 13.53.5sum list 1000
0.20.2this itf instance call8.98.8cast down 26.16.1sum list 10000
5.45.4inst itf virtual call8.78.6cast down 322.022.0sum list 100000
5.45.4this itf virtual call   21.521.4sum list 1000000

免責聲明:請不要照搬這些數據。時間測試會由於無法預料的二次影響而變得不準確。偶然事件可能會使實時編譯的代碼或某些關鍵數據跨過緩存行,影響其他的緩存或已有數據。這有點像不確定性原則:1 毫微秒左右的時間和時間差異是可觀察到的範圍限度。

另一項免責聲明:這些數據只與完全適應緩存的小代碼和數據方案有關。如果應用程序中最常用的部分不適應芯片緩存,您可能會遇到其他的性能問題。本文的結尾將詳細介紹緩存。

還有一項免責聲明:將組件和應用程序作爲 CIL 的程序集的最大好處之一是,您的程序可以做到每秒都變快、每年都變快。“每秒都變快”是因爲運行時(理論上)可以在程序運行時重新調整 JIT 編譯的代碼;“每年都變快”是因爲新發布的運行時總能提供更好、更先進、更快的算法以將代碼迅速優化。因此,如果 .NET 1.1 中的這幾個計時不是最佳結果,請相信在以後發佈的產品中它們會得到改善。而且在今後發佈的 .NET Framework 中,本文中所列代碼的本機代碼序列可能會更改。

不考慮這些免責聲明,這些數據確實讓我們對各種原語的當前性能有了充分的認識。這些數字很有意義,並且證實了我的判斷,即大多數實時編譯的託管代碼可以像編譯過的本機代碼一樣,“接近計算機”運行。原始的整型和浮點操作很快,而各種方法調用卻不太快,但(請相信我)仍可比得上本機 C/C++。同時我們還會發現,有些通常在本機代碼中開銷不太大的操作(如數據類型轉換、數組和字段存儲、函數指針 [委託])現在的開銷卻變大了。爲什麼是這樣呢?讓我們來看一下。

。。。。

資源

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章