在這個硬件性能日新月異的年代里,我們充分的享受著硬件帶給我們的“免費午餐”,似忽軟件的效率問題已經不再是一種被開發者關心的問題,但是,隨著CPU的主頻提高步伐越來越慢,高速緩存也不是可以無限制增加,新技術的使用逐漸被用來提高硬件的性能,這時候,要充分發揮這些新特性,軟件的支持就顯得很重要了,于是,程序的運行效率再一次映入我們的視野,新時代的軟件優化,我們該何去何從?
看見這篇文章的標題,你可能會說:噢,有必要嗎,現在的電腦速度越來越快,何苦去費力寫高性能的程序呢?如果程序性能實在無法恭維,滿足不了需求,那大不了去換一塊更快的CPU,問題就可以輕松解決了。
或許曾經如此,但種種跡象表明,硬件業為軟件業提供的免費“性能提升”午餐已經快要到頭了,CPU的主頻提高步伐越來越慢,高速緩存也不是可以無限制增加,現在CPU更多地用新指令集、并行執行、亂序執行、多處理內核等技術來提高速度,而這些技術都需要軟件的配合才能發揮作用。如果軟件沒有使用MMX、3DNow、SSE等指令集,那么就無法享受這些指令集帶來的性能提升;如果程序內部并行性不好(沒有使用多線程構架、執行流中條件轉移過多),那么就無法高效利用CPU的并行處理功能(多內核、亂序并行執行)。所以,“如何編寫高性能程序”還是一門需要仔細鉆研的學問。
幸運的是,這些“需要軟件的配合”的工作大多數可以由編譯器來自動完成。所以絕大多數情況下我們不需要也不應該犧牲程序的可讀性去做局部優化。本文的“何時不應該做優化”一節對此進行了詳細討論。
但是,也有很多優化,特別是較大粒度、較高抽象層次的優化,至少在目前,編譯器是無法做到的。比如,把單線程構架改成多線程構架(這點非常重要,否則哪怕運行在四內核的處理器上你的程序也只能占用一個內核,而現在Intel、AMD、IBM都把在處理器上集成多內核當作了主要的提升處理器性能的手段),使用較低時間復雜性的算法,等等。本文的“何時應該做優化”以及“優化的步驟”兩個小節對此做了展開敘述。
涉及到較高層次的優化,以及系統構架對性能的影響,其實有很多規律可行。本文的“設計構架時要注意的一些原則”和“性能模式與反模式”兩小節對此進行了敘述。
在開發中,我們最經常遇到的優化問題是:內存分配(在大多數操作系統中,從堆上分配內存是開銷比較大的操作),線程同步(不同的同步原語性能差距很大),字符串操作(STL中的string真的要比char*慢嗎,Copy-on-Write的string是提高了還是降低了性能),以及這3個問題的組合(在多線程環境下進行涉及到內存分配的字符串操作)。
本文也比較詳盡地探討了這些常見的問題。
何時不應該做優化
用3句話來概括:如果沒有數據證明“性能沒有達到指標”,就不要做優化;如果待優化部分不是在關鍵路徑上,就不要做優化;如果沒有數據證明“優化確實起作用了”,就不要使用優化的代碼。
這3句話中的“數據”指的主要是用profiler模擬各種真實或者極限情況下跑出的數據。背后的理念是,要有的放矢,功夫花在刀刃上(注意80/20法則),用數據說話,不要想當然。
比如,若你參與開發的一個軟件,測試時發現啟動和退出花的時間都比較長,在代碼復審時也確實發現,啟動和退出部分的代碼不夠高效。于是,放棄休假時間,終于把這部分優化好了。嗯,且慢,再看一下需求。噢,這個軟件會被這樣用:啟動之后就會24x7地不停機地運行,直到兩年后被新版本代替。那么就是說,啟動和退出代碼只會被執行一次。你放棄休假時間所做的優化值得嗎?優化之前請先檢查代碼是否在關鍵路徑上。
再比如,你發現代碼中有一些小函數,還有一些循環體,想優化一下,就把小函數都內聯了,把循環體展開了。這時你就必須用數據來證明,你的優化確實起作用了,而沒有“好心幫倒忙”地讓優化起反作用。你可能會驚訝,“怎么可能”?噢,完全有可能的,因為可能原來的這部分代碼比較小,正好夠放在CPU的一級高速緩存中,或者夠放在物理內存中。你優化過后代碼體積增加了,于是緩存中放不下了,或者甚至內存都放不下要用虛存了,于是CPU或者操作系統不得不經常換頁,性能會大幅度降低。所以,不要想當然,要用數據證明你所做的優化確實是優化。
還有很多“好心沒好報”、“優化反被優化誤”的例子。比如很多編譯器都會自動識別對數組操作的for循環這樣的代碼模式,并自動做優化。如果你多事地自己去做了優化,改成了指針操作,編譯器可能就不認識這個代碼模式了,于是就只好保守地產生非優化的代碼。這樣產生的代碼往往反而效率降低了。
再比如現在很多JVM都會識別String操作的模式。若你為了可能的性能提升而手工地把對String的操作改成不那么直觀優雅的StringBuffer操作,那不僅降低了代碼的可讀性,還給JVM的優化機制幫了倒忙。
再比如,Copy-On-Write是一種常用的對字符串操作的優化,可以大幅度減少內存分配和讀寫操作。但是在多線程環境中,為了保持Copy-On-Write的正確性,就不得不額外增加不少線程同步操作。究竟是內存操作的開銷大還是線程同步操作的開銷大呢?最好用數據來說話。事實上,在多線程環境中Copy-On-Write多半是個得不償失的優化。以前不少STL實現中string都用了Copy-On-Write,但現在好多STL實現都摒棄了這種做法。其實我覺得更好的辦法是提供參數來讓用戶選擇。畢竟如果不需要用到多線程,Copy-On-Write還是很好的。
何時應該做優化
如果前一節所說的“不應優化”的前提不成立,那么就只能做優化了。具體的說:如果數據表明,性能確實沒有達到指標,特別是當profiler表明,某處關鍵路徑上的代碼執行占用了大量的時間,那么就是優化的時候了。
首先,要確保你要優化的代碼是正確的,沒有任何已知bug。因為優化后的代碼往往會變得更復雜而難以修改,所以要趁代碼還比較簡單的時候趕緊把bug都修掉吧。
然后,要確認性能指標,可以查specification,或者如果不清楚的話再問問客戶,或者根據其他功能性需求計算得出。用profiler收集目前的性能數據,和性能指標對比,以確定是否需要優化、哪里需要優化。(數據要保留,因為等優化完后還要用這些數據來做對比,以檢查優化是否有效。)常見的profiler有Rational Quantify、Borland Optimizeit等等。很多UNIX下面都自帶了profiler,比如prof、gprof等,對于一般的使用已經夠了。
第三,進行優化。后面“常用的優化方法”一節對此進行了詳細介紹??梢哉罩谐龅某R姷膬灮椒ㄒ粋€個地套用,或者更好的辦法是進行一次團隊頭腦風暴會議,讓大家提出各種可能的優化方案。記得優化時不要刪除原來的實現??梢栽谠次募幸蕴娲瘮祷蛘咦⑨尩姆绞奖A粼瓉淼膶崿F。
第四,使用profiler,驗證優化是否如所想的那樣有效。如果有效,那是最好;如果無效甚至是幫了倒忙,那么就趕緊取消改動,使用原來的版本,然后繼續嘗試其他的優化方案。記得優化要一步一步來,從最省事且最有效的方案到最麻煩且收益最小的方案。一旦達成性能指標就收手,不要戀戰。
最后,記得對優化過的代碼執行單元測試,看看有沒有為了性能犧牲了正確性。要記得在注釋或者文檔中為優化留下記錄。
常用的優化方法
最簡單的優化:請檢查是否使用了編譯器的最新版本,是否把優化編譯開關打開了,是否正確指定了目標處理器(以便使用MMX、SSE、3DNow!等高性能指令集以及讓編譯器自動為處理器所支持的其他高級特性做優化)。如果發布的產品要支持多種處理器,那么如果可能的話,請單獨為每種處理器進行編譯,分別發布,或者使用同一個發布包但讓安裝程序自動檢測處理器型號并安裝對應的二進制版本,或者把會在關鍵路徑上執行的代碼封裝成動態鏈接庫,然后讓程序啟動時自動檢測處理器型號并加載為相應型號優化過的動態鏈接庫版本。
還有,要確保使用了高性能的庫,好的算法。比如,同樣是從堆上分配內存,不同編譯器提供的malloc或者new的實現,性能差異就不小。GCC使用的DL malloc就比較高效,Borland的編譯器提供的實現使用了類似內存池的方式來動態管理內存,效率也很高,但也有些編譯器對此并沒有做什么優化,直接進行系統調用。不僅malloc/new如此,STL的allocator也是如此。SGI STL帶的allocator為小于128字節的內存塊的分配進行了特別優化(用內存池實現),所以小型字符串以及其他會用到allocator此項功能的操作都會性能比較好,但其他STL實現就沒有做這樣的優化。
選擇正確的算法,往往比優化地實現算法更重要。因為不同時間復雜度的算法可能會給性能帶來幾個數量級的差異,而實現上的優化則往往付出很大、所得甚少。如果有時候精度不是那么重要,或者不需要找最佳的結果只需要找近似最佳的結果,那么往往可以用低時間復雜度的近似算法來代替。
另外,查表法也是個常用的技巧。假設,用某個公式可以把彩色圖像轉換成灰度圖象,那么如果轉換處理量很大的話,對每個象素都用該公式計算一邊就不劃算了,完全可以事先對所有顏色都計算好,然后處理時查表即可。對三角函數也是如此。當然,為了減小表的尺寸,在精度上往往需要犧牲一些。
但也不要以為因為是預先計算的不需要考慮計算代價,或者內存比較大虛擬內存更大,就可以把表做得很大。記住操作系統或者操作系統進行內存換頁或者Cache換頁都是要時間的,兩個臨界點分別是Cache的尺寸和物理內存的尺寸。具體是全部計算,還是全部查表,還是部分計算部分查表,表要做得多大,這些都需要嘗試并用實際數據來支持。一個比較復雜的做法是動態地把計算出來的值緩存到稀疏表中并供以后使用時查詢,表的物理尺寸根據當時機器的Cache、內存狀況動態配置。
如果使用Java或者.NET上的編程語言的話,因為垃圾會占用空間,垃圾收集器的執行會占用時間,所以除了優化算法及其實現,還要注意你的代碼對垃圾收集器是否友好。比如有沒有及時把不用的引用置成null,有沒有不必要的finalizer等等。
要避免很大的循環體,因為它們往往會超出Cache的尺寸。盡可能避免復雜的if-else或者switch-case語句,因為現代CPU的亂序執行功能看見這些語句會覺得很無奈。即便你非要用這些語句,最好養成習慣,把最可能的分支放在最前面。還有,如果可能的話,不要在循環體中使用這些條件分支語句。
有一些經典著作,如The Practice of Programming(《程序設計實踐》)、Programming Pearls(《編程珠璣》)、CodeComplete 2e(國內目前只出版了第1版,叫《代碼大全》)也都提到了很多優化技術,但是,很重要的一點是,這些書都很少提到或者沒有展開講“構架設計時注意不要留下性能瓶頸或者缺陷”這個問題。這已超出了優化的范疇,而是要求在設計起始階段時就考慮到性能需求。事實上,在硬件性能極大提高、優化編譯器大行其道的今天,我們寫程序時已基本上很少需要去考慮局部的微觀實現是否優化了,因為有95%的可能編譯器會替你去操心,或者根本性能不優化也可滿足需求。甚至如果程序的內部結構比較清晰的化,算法也是可以很容易地替換的(比如用Strategy模式,或者Policy-Based Design的方式)。但也有的東西不太好在程序寫完后再改,但又可能對性能有極大影響:那就是總體的設計和構架,以及一些影響面很廣的設計決策/取舍。在今天,這些比較宏觀的內容遠比微觀的優化技巧要重要。
讀者可能要問了:“不是說‘不必要的優化是一切罪惡的源泉’、‘沒有數據證明就不要做優化’嗎?在設計起始階段根本還沒有代碼可以執行,怎么獲得數據?你怎么保證這不會是不必要的優化呢?”噢,這個問題很好回答:當設計還沒形成,代碼還沒寫時,這不叫優化,僅僅是設計。優化是一種改變,把現有的緩慢的東西變成快速的東西。而設計時“本來無一物,何來談優化”呢。
更何況,一些比較宏觀的構架上的決策,日后重構起來會非常困難,所以一開始就應該要考慮到。如果一開始需求尚未明確并且你也預計不到日后會有這樣的性能需求,那么沒有考慮到也不能怪你。但若一開始客戶就提出了明確的性能要求,或者你心里很清楚客戶一定會需要這樣的性能,而你設計時卻依然選擇了無法或者難以滿足這樣性能要求的構架,那么這就不太好了。此外,如果兩種設計/構架,并沒有明確的實現復雜性或者優雅程度的差別,而其中一種設計/構架明顯性能擴展性更好,那么也應該選擇后一種。這不叫“premature optimization”,而叫做“避免premature pessimization”(見C++ Coding Standards一書的Item 9)。
另外,還有一些很常見的和性能相關的話題。而且不少人對它們的認識還有一些誤區,比如資源(特別是內存)的獲取和釋放、線程間的同步(也可看作特殊資源——各種線程鎖的獲取和釋放)、字符串(或者其他緩沖區)的處理,以及這些操作的組合。這些話題很值得進一步討論,在今后的文章中,會再和讀者進行更深層次的交流。
原文轉自:http://www.anti-gravitydesign.com