作為 Microsoft® .NET Framework 公共語言運行庫團隊的性能架構師,幫助大家充分利用運行時 編寫高性能的應用程序是我的職責所在。這無論是在 .NET 還是在其他語言中都不神秘——您 只需要在設計之初即考慮應用程序的性能問題即可。有很多應用程序在編寫時根本未考慮性能問題。這通 常無關緊要,因為大多數程序的計算量相對較少,而且同和它們交互的人類相比,程序的計算速度要快得 多。遺憾的是,當真的需要程序具有較高性能時,我們卻缺乏相關的知識、技能和工具來很好地實現這一 點。
在這裡我將討論編寫高性能應用程序所需掌握的內容。我將重點介紹為 .NET 編寫的程序,但各種語 言間的概念都是通用的。由於 .NET 對底層機器的抽象程度要比典型的 C++ 編譯器高,並且由於 .NET 提供了許多強大但成本高昂的功能(包括反射、自定義屬性、正則表達式等),所以很容易在無意中將成 本高昂的操作加入到對性能要求苛刻的代碼路徑中。為幫助您避免付出這種開銷,我將介紹如何量化各種 .NET 功能的開銷以使您了解何時應該使用這些功能。
制定計劃
正如我所提及的,大多數 程序在編寫時都沒有充分考慮性能問題,但實際上真的應該為每個項目都制定一個性能計劃。您必須考慮 到各種用戶方案,並清楚地表達出什麼樣的性能算是出色、良好或糟糕。然後,根據數據量、算法復雜程 度以及以前構建類似應用程序的經驗來決定是否能輕松滿足所定義的各種性能目標。對於許多 GUI 應用 程序而言,性能目標比較中庸,無需進行特別設計即可輕松達到至少屬於良好級別的性能。在這種情況下 ,您的性能計劃即告完成。
如果不清楚是否能輕松滿足性能目標,則需要開始制定計劃,列出可 能成為瓶頸的方方面面。典型問題方面包括啟動時間、批量數據操作和圖形動畫等。
配置文件數 據處理示例
舉個例子可以更具體地說明這一點。我目前正在設計用於處理配置文件數據的 .NET 基礎結構。我需要以一種有意義的方式表示出操作系統生成的事件(如頁面錯誤、磁盤 I/O、上下文切換 等)列表。涉及的數據文件通常會比較大;較小的配置文件大約 10MB,而文件大小超過 1GB 的也不稀奇 。
在制定性能計劃時,我發現如果僅計算需要在屏幕上重繪的那部分數據集,數據顯示不會出問 題;換句話說,就是屏幕“懶散”一些問題不大。遺憾的是,要使 GUI 對象(例如樹控件、 列表控件和文本框等)“懶散”一些,需要一些額外的工作。這正是大多數文本編輯器在處理 非常大的文件(比如 100MB)時,其性能讓人難以接受的原因。如果在設計 GUI 時不考慮性能,則結果 幾乎肯定無法令人滿意。
但是,延遲顯示對於那些需要使用文件中所有數據的操作(例如,計算 摘要時)並不會有所幫助。鑒於數據集大小的因素,數據分派和處理方法是必須要用心設計的“熱 ”代碼路徑。程序的其他部分可能對性能沒有嚴格要求,因此不需要特別關注。
這種情況非 常具有代表性。甚至在需要高性能的方案中,應用程序的 95% 都不需要任何性能計劃,但您需要認真考 慮需要性能計劃的最後 5%。而且,根據我的經驗,要確定程序中需要認真考慮其性能的 5% 一般都非常 容易。
及早評量並經常評量
在高性能設計中,接下來的步驟是評量——在編寫 代碼之前,您需要了解性能目標是否能夠實現,如果可以,那它們對設計有哪些限制條件。在本例中,我 需要知道在設計中應該加以考慮的一些基本操作(例如原始文件 I/O 和數據庫訪問等)的開銷。在繼續 操作之前,我需要准備一些數據。這是項目設計中最關鍵的時刻。
讓人沮喪的是,大部分性能都 是在開發過程的最初階段丟失的。當您為程序的核心選定了數據結構後,應用程序的性能框架就已經定型 了。選擇算法時進一步限制了其性能。選擇各種子組件之間的接口約定時又進一步限制了性能。理解早期 設計決策中每個決策的開銷並進行明智的選擇至關重要。
設計是一個迭代過程。最好能夠從最簡潔、最明顯的選擇開始制定設計藍圖(我建議采用熱代碼的原 型)並評估其性能。您還應該考慮如果將性能作為唯一要素,那設計會是什麼樣的,然後再評估該應用程 序性能將達到多高。現在最有趣的工程開始啦!您開始調整設計並考慮在這兩種極端情況下的可選方法, 找出能夠得到最佳結果的設計。
同樣,我在配置文件數據處理程序方面的經驗非常具有指導意義 。與大多數項目一樣,對數據表示的選擇非常重要。數據是否應該存放在內存中?它是否應該以數據流方 式寫入文件?是否應該將它放在數據庫中?標准解決方案是應該將任何大型數據集存儲在數據庫中;但是 ,數據庫適用於變化相對緩慢的數據,對於變化頻繁的大量數據則不適用。我的應用程序需要定期將許多 GB 的數據轉儲到數據庫中。數據庫是否能夠處理它?通過對數據庫操作稍微進行一下評量和分析,很容 易就可以確定數據庫不具備我所需要的性能框架。
在較為詳細地評量了在不引入額外頁面錯誤之 前應用程序可以使用的最大內存大小後,我同樣排除了將數據放在內存中的解決方案。現在剩下的只有用 於基本數據表示的文件數據流解決方案。
但是,仍有許多其他設計決策需要加以制定。配置文件 數據的基本形式是由許多異類事件組成的列表。但這些事件應該是什麼格式的?它們是否是字符串(都非 常一致)?它們是否是 C# 結構或對象?
如果它們是對象,則最直觀的解決方案是按照每個事件 進行分配,這將需要很多次分配。這是否可以接受?當我迭代事件時,調度工作具體是如何進行的?它是 采用回調模型還是迭代模型?調度工作是通過接口、委托還是反射進行的?大約需要制定數十種設計決策 ,而且它們都會對程序的最終性能產生影響,所以我需要對其進行評量以便權衡取捨。
為評量提 供保障支持
很明顯,在設計過程中需要進行許多的評量。具體該如何操作呢?有許多剖析工具可 為您提供幫助,但是其中一種最通用而且最簡單、最常見的技術是微基准。此技術非常簡單:當您想知道 特定操作的開銷時,只需建立一個它的使用示例,然後直接測量該操作所耗時間即可。
.NET Framework 中有一個名為 System.Diagnostics.Stopwatch 的高分辨率計時器,專為此目的而設計。分辨 率的大小隨著硬件的不同而異,但通常都低於 1 微秒,這已經完全夠用。它是隨 .NET Framework 一同 提供的,所以您已經擁有了所需的功能。
雖然 Stopwatch 是一個不錯的開端,但要得到准確的基 准工具還需要做許多工作。較小的操作應該放置在循環中,這可以使間隔時間足夠長,以便能夠進行精確 測量。在進行測量前應該運行一次基准,以確保所有實時 (JIT) 編譯和其他一次性初始化工作都已完成 (當然,如果目標即是對初始化進行測試則另當別論)。因為測量過程會產生干擾,所以應該多運行幾次 基准,並收集統計數據以確定測量的穩定性。它還應該能夠方便地批量運行許多基准(設計變體),並得 到顯示所有結果的報告以便進行比較。
我編寫了一個名為 MeasureIt.exe 的基准工具,它基於 Stopwatch 類構建,可以實現上述這些目標。您可以從《MSDN® 雜志》網站獲得該工具以及本專欄中 討論的所有代碼。解壓縮之後,只需鍵入以下命令即可運行:
MeasureIt
它可以在幾秒鐘之內運行 50 余種標准基准,並以網頁形式顯示出結果。一個數據摘錄示例如圖 1 所 示。在這些結果中,每個測量都執行 10,000 次某個操作(此操作在一個執行 1000 次的循環中被克隆 10 次)。每個測量隨後執行 10 次,並計算出標准統計值(最小值、最大值、中值、平均值、標准偏差 )。
Figure 1 使用 MeasureIt.exe 測量各種操作
測量的操作 中值 平均值 標准偏差 最小值 最大值 示例 MethodCalls:EmptyStaticFunction() [count=1000 scale=10.0] 1.000 1.005 0.084 0.922 1.136 10 MethodCalls:aClass.Interface() [count=1000 scale=10.0] 1.699 1.769 0.090 1.696 1.943 10 ObjectOps:new Class() [count=1000 scale=10.0] 6.248 8.040 3.556 5.087 16.296 10 Arrays:aIntArray[i] = 1 [count=1000 scale=10.0] 0.616 0.638 0.071 0.612 0.850 10 Delegates:aInstanceDelegate() [count=1000 scale=10.0] 1.233 1.244 0.088 1.160 1.398 10 PInvoke:FullTrustCall() [count=1000] 7.452 6.946 0.804 5.878 7.913 10 Locks:Monitor lock [count=1000] 11.487 12.129 0.901 11.322 13.843 10為使時間測量更有意義,這些測量都被規范化,以便使從空的靜態函數中調用(及返回)的中值時間 為一個單位。基准得到的時間經常會差距很大,而這正是所有統計信息都很重要的原因。請注意 FinalizableClass 基准的最小 (71.299) 和最大 (953.864) 時間之間的巨大差異。在接受該基准數據之 前,需要對出現這種差異的原因給出合理的解釋。在那種特殊的情況下,它是由於運行時定期執行較慢的 代碼路徑以批量分配記帳數據結構而導致的。正如我曾說過的,得到這些統計數據對於驗證數據非常有用 。
此表格包含大量有用的性能數據,詳細列出了以 .NET 為目標的代碼所使用的大多數基元操作 的開銷。我將在本專欄下一部分中做詳細介紹,在這裡我只想解釋 MeasureIt 的一個重要功能:它與其 源代碼一同提供。要解壓縮 MeasureIt 的源代碼並啟動 Visual Studio® 進行浏覽(如果 Visual Studio 可供使用),請鍵入:
MeasureIt /edit
有了源代碼就意味著您可以快速准確 地了解基准所測量的內容。它還表示您可以輕松地向套件中添加新基准。
同樣,我在配置文件數 據處理程序方面的經驗非常具有指導意義。在設計時,我可以通過 C# 事件、委托、虛擬方法或接口等執 行特定的常見操作。要制定決策,我需要了解這些選擇的性能並在其中進行權衡取捨。我可以在幾分鐘之 內編寫出微基准來測量每種備選方案的性能。圖 2 顯示的是相關的行,您可以看到在這些備選方案之間 沒有本質的差別。了解這一點後,我可以選擇最適合的選項,並且確定這樣做不會犧牲性能。
Figure 2 測量 .NET 事件、委托、接口和虛擬方法
測量的操作 中值 平均值 標准偏差 最小值 最大值 示例 MethodCalls:aClass.Interface() [count=1000 scale=10.0] 1.651 1.660 0.084 1.579 1.814 10 MethodCalls:aClass.VirtualMethod() [count=1000 scale=10.0] 1.228 1.175 0.077 1.083 1.277 10 Delegates:aInstanceDelegate() [count=1000 scale=10.0] 1.151 1.159 0.085 1.075 1.314 10 Events:Fire Events [count=1000 scale=10.0] 1.228 1.195 0.070 1.088 1.291 10aStructWithInterface.InterfaceMethod();
00000000 ret
這裡顯示的內容表明此基准(對接口方法的 10 個調用)已經內聯消失了。ret 指令實際上是用來定 義整個基准的委托主體的結束點。這樣看來,什麼都不執行自然要比執行方法調用的速度快,因此這就解 釋了出現該異常的原因。
唯一的神秘之處在於為什麼靜態方法不會同樣內聯呢?這是因為對於靜態方法而言,我專門通過 MethodImplOptions.NoInlining 屬性禁用了內聯。我故意“忘記”將它放到接口調用基准中,以證明 JIT 編譯器可以像一些非虛擬調用(在上面的基准中有一處注釋提到了這一點)一樣高效率地進行特定的 接口調用。
結束語
再次強調,很有可能您所測量的並不是您預期要測量的內容,特別是測量用於 JIT 編譯器優化的小操 作時。有時還很可能意外測量到未優化的代碼,或者測量到某方法的 JIT 編譯的開銷,而不是方法本身 的開銷。MeasureIt /usersGuide 命令將會顯示用戶指南,其中討論了創建基准時可能會遇到的許多陷阱 。我強烈建議您在准備編寫自己的基准之前仔細閱讀這些詳細信息。
我想強調的是驗證的概念。如果您無法解釋您的數據,則不應該使用它來制定設計決策。如果得到了 異常的數據,您應收集更多的數據、調試基准,或者與其他更專業的人員協作,直到能夠解釋您的數據為 止。您應對無法解釋的數據持高度懷疑的態度,並且不應在制定任何重要決策時使用它。
本文討論有關編寫高性能應用程序的基礎知識。與軟件的任何其他屬性一樣,良好的性能需要在產品 設計之初就加以考慮。為實現此目的,您需要量化在制定不同設計決策時作為各種取捨依據的測量值。也 就是要進行性能實驗。MeasureIt 能夠方便快捷地生成高質量的微基准,因此成為設計過程中不可或缺的 一部分。MeasureIt 還是一款非常實用的現成工具,因為它自帶了一組涵蓋 .NET Framework 中大部分基 元操作的基准。
您還可以輕松地為 .NET Framework 中您最感興趣的部分添加自己的基准。利用此數據,您可以構建 應用程序開銷模型,並因此可以在編寫應用程序代碼之前對設計備選項的性能做出合理的(大致)猜測。
有關 .NET 中應用程序性能方面的問題,還有很多內容需要介紹。在構建微基准時有許多潛在的陷阱 ,因此在編寫任何基准之前請務必先閱讀 MeasureIt 用戶指南。我還推遲了對作為主要瓶頸的磁盤 I/O 、內存或鎖競爭等情況的討論。我甚至還未討論如何在設計好應用程序後,使用各種剖析工具來驗證並監 控應用程序的性能運行情況。
需要了解的內容還有很多,而大量的信息往往會使開發人員對此類測試望而卻步。但是,由於大部分 性能損失都發生在應用程序的設計階段,所以如果想避免麻煩,最好在最初階段就要考慮到性能問題。我 希望本專欄能夠鼓勵您在設計下一個 .NET 軟件項目時,將性能作為設計中明確考慮的一部分。
請將您想詢問的問題和提出的意見發送至 [email protected].