我們要先實現業務功能,還是先優化代碼?
在做軟件設計咨詢工作時,我常常發現:許多高性能軟件產品的研發團隊,在軟件開發階段,僅僅關注并實現業務的特性功能。待功能交付后,才花費大量時間對軟件代碼進行調整優化。
而且,在與這些程序員接觸的過程中,我還觀察到一個有趣的現象:大家普遍認為,在軟件編碼實現階段,過早考慮代碼優化意義不大,應等到功能開發完成,再基于打點 Profiling(數據分析)去優化代碼實現。
其實,這個想法是否可取,也曾困擾過我。然而,在經歷眾多由低級編碼導致的性能問題后,我發現高性能編碼實現極具價值,且能讓我更好地處理編碼實現優化與 Profiling 優化之間的關系。
建立正確的高性能編碼價值觀
首先,提及高性能編碼,想必您肯定聽說過現代計算機科學的鼻祖高德納(Donald Knuth)的那句名言:“We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” 意思是:我們應該忘掉那些效率低下的事情,告誡自己在 97% 的情況下:過早優化是萬惡之源。但是,我們也不應該在關鍵的 3% 上錯過優化機會。——《Computer Programming as an Art (1974)》P671。
不過我認為,或許很多程序員僅僅記住了這句話的前半部分,即 “97% 的情況下,過早優化是萬惡之源”,卻沒有留意到這句話還有后半句:我們不應該放棄掉那關鍵的 3% 的優化機會。
所以,由此造成的后果是:過度推崇不要對代碼進行提前優化,并將此當作編寫低性能軟件代碼的借口。也就是說,當下我們在軟件編碼過程中所遇到的大多數問題,并非由過早優化所致,而是因為在編寫代碼時對執行效率缺乏關注所引發的。
其實,在編寫代碼階段樹立追求高性能實現的意識極為重要,主要有兩方面原因。
第一個原因在于,或許原本只是一個細微的編碼問題,卻有可能引發軟件較大的性能問題。例如,我曾經參與的一個 C++ 高性能軟件開發項目,由于一位研發人員在編碼時不慎遺漏了函數行參的引用符號,致使函數調用開銷增大,軟件版本性能顯著下降。而且這個問題較為隱蔽,我們后續耗費了大量精力,在代碼中增添了眾多定位手段,才得以發現問題。
第二個原因是,一旦錯失高性能編碼的時機,將性能問題遺留到軟件生命周期的后期,很可能因為錯過了當時編寫代碼的具體情境,后續就難以再察覺這個問題。在此,我再為您舉個例子加以說明。
可以看到,在如下所示的代碼片段中,這個類的實例在接收數據之后,會更新各個 Channel 中的數據量大小,而后對外提供了一個方法,用于判斷所有通道中是否存在數據
public class ChannelGroup {
class Channel{
public String channelname;
public int dataSize;
}
Channel[] channels;
public Channels() {
channels = new Channel[10];
}
public receiveData(...) {....} // 收到數據更新Channel中信息,省略
public boolean hasData() { // 判斷所有通道是否有數據。
for (Channel Channel : Channels) {
if (Channel.dataSize > 0){
return true;
}
}
return false;
}
}
那么在看完這段代碼之后,您認為這段代碼實現中的方法 hasData ,能算作高性能實現嗎?倘若僅依據這段代碼實現來加以分析,會覺得它似乎不存在性能問題。畢竟,對于一個僅有 10 個元素的數組而言,運用二分法查找來提高查找速度的必要性并非很大。
好,我們暫且如此認為,接著再做一個假設:在真實的編寫代碼過程中,存在這樣一個潛在的上下文信息,即在絕大多數業務場景下,是第三個通道收到數據。那么針對這種情況,如果再采用從前向后的順序遍歷,必然不是性能最佳的實現方式,而應當先判斷第三個通道中的數據。
所以通過這兩個例子,您應該明白了,如果在編碼實現階段并非從高性能實現的角度出發,而是打算在后續通過打點數據分析來優化解決問題,幾乎是不太可行的。
實際上,依我的思考和實踐經驗而言,在開發一個高性能軟件系統時,在編碼階段考慮高性能的實現方法,與完成業務功能后再進行代碼調優,兩者并不沖突,應當同等重視。因為前期的高性能編碼實現過程,大多由人主觀把控,所以可能會因判斷失誤或者實現過程中的疏忽,引入一些低效率的代碼實現。如此一來,后期通過熱點代碼分析以及代碼調優的過程,是不可省略的。
而且說實話,在我心中,優秀的軟件代碼應當兼具代碼簡潔與性能,倘若對編碼性能嗤之以鼻,我認為這樣的程序員通常也寫不出高質量的代碼。
好了,在理解了應當如何看待高性能編碼之后,接下來的問題便是,如何才能掌握實現高性能編碼的方法,下面我們就具體來瞧瞧。
高性能編碼實現方法
其實在軟件開發的進程中,高性能編碼實現的方法與技術繁多,不同的編程語言之間還會存在一定差異,很難在一節課里介紹周全。
所以,今天我主要從編寫的代碼映射到執行過程的視角,為您介紹四種高性能編碼實現方法,以及相應的實現原則和手段,分別是循環實現、函數方法實現、表達式實現以及控制流程實現。
在實際的軟件編碼過程中,您也能夠依照這樣的角度和思路,嘗試去理解與分析軟件代碼的運行態過程,逐步積累并完善高性能編碼的實現技巧。
好,接下來我們就從循環實現入手,來瞧瞧這種高性能實現的原則和方法。
高性能循環實現
我們都清楚,在編寫代碼時,循環體內的代碼會被多次執行,因而其代碼開銷會被放大,常常會出現在熱點代碼當中。也就是說,怎樣實現高效循環是達成高性能編碼最為關鍵的一步。
那么,編寫高效循環代碼的重要參考原則有哪些呢?我覺得主要有兩個,下面我們具體來了解一下。
第一點,盡量避免對循環起始條件和終止條件的重復計算。為了讓您更輕松地理解這個原則,我先帶您來看一個高效循環的反面例子。在下面這個代碼示例中,所實現的功能是循環遍歷并更新字符串中的值,您會發現,在循環執行的過程中,strlen 被調用了多次,所以性能較低。
void updateStr(char* str)
{
for(int i = 0; i<strlen(str); i++)
{
str[i]= '*';
}
}
那么,針對這種情況,我們就應該在循環開始時,將字符串長度值保存在一個變量中,從而避免重復計算。修改好的代碼如下:
void updateStr(char* str)
{
int length = strlen(str);
for(int i = 0; i< length; i++)
{
str[i]= '*';
}
}
第二點,盡量避免循環體中存在重復的計算邏輯。
我們同樣也來看一個反模式的代碼示例。在下面這段代碼的實現過程中,x*y的值并沒有發生變化,但是在循環體中被執行了很多遍。
void initData(int[] data, int length, int x, int y){
for(int i = 0; i < length; i++)
{
data[i] = x * y + 100;
}
}
因此,從高性能編碼實現的角度出發,我們能夠將 x*y 值的計算過程遷移至循環體之外,以此降低這部分的冗余計算開銷。實際上到這里,您可以記住這么一句話:編寫高效循環代碼的本質,就是盡可能讓循環體中執行的代碼越少越好,剔除掉所有能夠冗余的重復計算。
那么在具體的代碼實現里,需要檢查的循環優化點其實還有眾多。例如,您還需要檢查是否存在重復的函數調用、多余的對象申請和構造、多余的局部變量定義等等。所以,在編寫循環代碼時,您需要留意識別并剝離出此類代碼實現。
高性能函數方法實現
實現高性能的函數方法,存在兩個重要的出發點:盡可能通過內聯來降低運行期函數調用,盡可能減少不必要的運行期多態。接下來,我為您講解一下為何要從這兩個點出發,以及應當如何去做。
第一點,盡可能通過內聯來降低運行期函數調用。所謂 “通過內聯”,指的是將代碼直接插入到代碼調用中進行執行,從而減少運行期函數調用。那為何要減少生成真實的運行期函數呢?這是由于函數調用自身會產生一些額外的性能開銷。在函數調用的過程中,需要先把當前局部變量壓棧,在調用結束后還需要出棧操作,同時還需要更新相關寄存器。所以當函數體內部的邏輯較小時,所產生的額外開銷所占比例會比較高。因此,對于較小的函數方法,我們應盡量采用內聯實現,以此降低不必要的調用開銷。
實際上,不同的編程語言,支撐函數方法內聯的語法和機制存在一定差異。在 Java 語言的開發過程中,我建議您盡量使用 final 來定義方法,因為在這種場景下,Java 的 JIT 有較大概率將這個代碼方法內聯掉。而在 C++ 中,針對一些熱點小函數,您可以使用 Inline 關鍵字來定義方法,如此便能明確告知編譯器盡量將代碼內聯掉。補充:在早期 C 語言的開發過程中,由于沒有內聯語法,程序員常常使用編譯宏來定義方法,以此減少真實方法的調用開銷。然而,最終編譯器或解釋器能否將代碼內聯掉,還存在許多隱性約束條件,所以您在編碼實現時需要多加留意。
第二點,盡量減少不必要的運行期多態。
多態的本質即為函數指針,它需要在運行過程中獲取內存中變量的值,以此判斷代碼執行需要跳轉至哪個位置。而這種在運行期動態決定跳轉地址的情況,極易導致指令集流水線的中斷,使得指令 Cache Miss 的概率增加,進而引發性能下降。
不過在 Java 語言中,由于類方法模式均是抽象的,所以我們能夠將關鍵方法定義為靜態方法,從而避免多態調用;對于 C++ 而言,在定義類方法時,我們可以依據需求決定是否使用抽象方法,以減少不必要的多態;而在 C 語言中,我們能夠通過盡量避免使用不必要的函數指針,來降低運行期多態。
另外,在實現高性能函數方法時,還有一些要點您也需要留意,例如盡量避免遞歸調用、盡量減少不必要的參數傳遞等等。不過這些均屬于高性能編程的常識性問題,所以在此我就不再展開闡述了。
高性能表達式實現
其實,現在的編譯器針對表達式級別的優化支持能力已經很強大了,比如說,如果你在編寫代碼的過程中,使用下面的乘法操作:
int y = x * 128;
那么,對于高性能的編譯器(如新版的 GCC 9.x 等)而言,能夠將這個乘法操作優化為移位操作,進而提升執行性能。然而,我們在編碼的過程中,不能完全依賴這種編譯器的能力,因為一方面編譯器的優化能力存在邊界,另一方面在編寫代碼的過程中,編譯器對表達式的優化也只是順便為之。所以在此,我為您總結了高性能表達式實現中幾個較為重要的點,它們均屬于簡單的實現規則,您也可以在編寫代碼的過程中作為參考并加以注意。
第一點,盡量將常量計算放到一起。
比如你可以看看下面的代碼,這是一個包含了 3 個乘法運算的表達式:
int z = 32 * x * 432 * y;
那么,如果將常量乘法計算放到一起,就很容易在編譯期優化掉,從而就可以避免執行時再計算。
第二點,盡量將表達式簡化,從而減少冗余運算開銷。
我們同樣來看一個例子。在下面的這段代碼示例中,兩個表達式的實現邏輯相同,都是先乘法再加法,不過您會發現,第二個表達式少了一次乘法運算,所以它的執行性能會更為出色:
int z = x * x + y *x ; //兩個乘法操作,一個加法操作
int z = x * (x+y); //一個乘法操作,一個加法操作
第三點,盡量減少除法運算。
目前 CPU 中對除法計算的開銷仍然較大,因此倘若能夠優化為移位操作或者乘法操作,那么都能夠提升執行性能。
高性能控制流程實現首先您需要知曉的是,控制流程代碼在執行的過程中,CPU 執行會通過指令分支預測,提前將接下來的執行指令搬移到 Cache 中,如果預測失敗,就有可能導致指令流水線中斷,從而對執行性能產生影響。
所以,您在編寫控制流程代碼時,就需要思考一下怎樣才能更好地實現,以此來優化代碼的執行性能。
那么具體該怎么做呢?在此,我也為您分享一下我在實踐過程中總結的經驗,即盡量減少不必要的分支判斷。這個原則是最為重要、也是最容易被忽略的。為何這么說呢?我們來看一個具體的例子。在下面這段代碼里,您可以發現 x==2 和 x==3 對應的分支場景是相同的,但是它們還是被放置在了兩個代碼分支當中,所以這樣執行起來不但低效,而且還存在重復代碼:
if ( 2 == x ) { // 場景1
printf("case 1");
}
if ( 3 == x ) { //場景1
printf("case 1");
}
if ( 4 == x ) { //場景2
printf("case 2");
}
在日常的代碼開發里,由于偷懶不想寫一個組合的邏輯表達式,從而增加分支邏輯的現象,實際上是較為常見的。所以說,我們在實際編寫控制流程代碼的時候,務必要注意盡量減少不必要的代碼分支,如此才能有效地提升執行性能。
然而這里您可能還存在一個問題,那就是如果深入挖掘優化代碼中一些重復的分支邏輯,其中包含的門道還比較多。比如說,通過多態來避免代碼中重復的 switch 分支邏輯,利用表驅動來減少 switch 邏輯和小的 for 循環平鋪執行等等。所以在此,我給您一個小建議,在一些特殊場景下(比如 if 條件嵌套非常多的場景),您可以考慮使用 switch 來替換 if ,這樣也有可能改進代碼的執行性能。