揭秘代碼效率的真相
本文轉(zhuǎn)載自微信公眾號「程序喵大人」,作者程序喵大人。轉(zhuǎn)載本文請聯(lián)系程序喵大人公眾號。
本專題將分析C++各種代碼操作的效率,包括不同類型變量的存儲效率,使用智能指針、循環(huán)、函數(shù)參數(shù)、虛函數(shù)、數(shù)組等的效率,以及如何做針對性優(yōu)化,或選擇更有效的替代方案。
詳細(xì)目錄看下圖:
變量存儲區(qū)域:
在C++中,變量存儲在哪類內(nèi)存,取決于開發(fā)者聲明它們的方式。如果數(shù)據(jù)不連續(xù),分成無數(shù)段分散在內(nèi)存中,會降低數(shù)據(jù)的Cache命中率。因此,理解變量如何存儲非常重要。
??臻g
棧空間,通常用于存儲局部變量、函數(shù)參數(shù)、函數(shù)返回地址、函數(shù)返回前需要恢復(fù)的寄存器等。每次調(diào)用函數(shù)時,系統(tǒng)都會分配一段棧空間,用于存儲這些東西,函數(shù)返回時,這段??臻g會被回收,下次調(diào)用函數(shù)時,程序還可以重用這段棧空間。
一般來說,程序每個線程有固定大小的??臻g,使用多少,回收多少,只是偏移量偏移多少的問題。??臻g特別高效是因為同一段內(nèi)存空間可以被反復(fù)使用,內(nèi)存很容易就加載到Cache中,Cache命中率更高。
我們可以多利用??臻g。所有的變量,最好都在使用它們的函數(shù)中聲明。有些情況下可以在大括號{ }內(nèi)聲明變量,盡可能縮小變量的作用域。
全局或靜態(tài)空間
全局變量,任何函數(shù)都可以訪問,存儲在內(nèi)存的靜態(tài)空間中。static關(guān)鍵字聲明的變量、浮點常量、字符串常量、虛函數(shù)表等,都存儲在靜態(tài)空間中。
靜態(tài)空間的優(yōu)點是,可以在程序啟動前就將其初始化為所需的值。缺點是,即使變量只使用一次,或者只在程序的一小部分中使用,它的內(nèi)存,也會在程序整個運行過程中被占用,會降低Cache的效率。
盡量不要將變量聲明為全局變量,一個變量如果被多個函數(shù)使用,可以考慮將其作為參數(shù),但是參數(shù)傳遞是有開銷的,如果我們想避免這類開銷,難道就要聲明為全局變量了嗎?其實我們也可將變量存儲在類對象中,多個函數(shù)都訪問類對象中的變量成員。
某些情況下,可以考慮static和const共用,例如聲明一個靜態(tài)常量查詢表:
- float SomeFunction(int x) {
- static const float list[] = {1.1, 2.2, 3.4, 4.4, 5.5};
- return list[x];
- }
這種方式的好處是,不需要在每次調(diào)用函數(shù)時對列表進行初始化。static聲明意味著在第一次調(diào)用初始化后,后續(xù)就不再需要初始化,但這樣效率較低,因為需要額外檢查它是第一次調(diào)用,還是已經(jīng)被調(diào)用過。加入const聲明,可以告訴編譯器,不需要對是否是第一次調(diào)用來進行檢查。所以最好加上static和const聲明,以便讓編譯器更好的優(yōu)化。
字符串常量和浮點數(shù)常量,也經(jīng)常保存在靜態(tài)空間中,例如:
- a = b * 3.5;
- c = d + 3.5;
這里,常數(shù)3.5將存儲在靜態(tài)空間中,大多數(shù)編譯器會識別出這兩個常量是相同的,因此只需要存儲一份常量。整個程序中所有相同的常量將被連接在一起,優(yōu)化程序中常量的占用空間。
寄存器存儲
寄存器是CPU中的一小塊內(nèi)存,用作臨時存儲。訪問寄存器中的變量速度非常快,但是寄存器數(shù)量有限,存儲的變量也有限。編譯器優(yōu)化時,會自動選擇函數(shù)中的最常用變量,存到寄存器中。程序中的局部變量就很適合于存儲在寄存器中。
寄存器數(shù)量有限,32位X86系統(tǒng)中,大約有6個整數(shù)寄存器用于通用目的,64位系統(tǒng)中有14個。而浮點變量使用不同的寄存器,32位系統(tǒng)中大約有8個可用的浮點寄存器,64位系統(tǒng)中有16個,當(dāng)在64位系統(tǒng)中啟用更高級的指令集時,可能會有更多可用的浮點寄存器。
volatile
這里需要特別關(guān)注下volatile關(guān)鍵字,該關(guān)鍵字表示其修飾的變量可以被另一個線程更改,防止編譯器做一些過度優(yōu)化。例如:
- volatile int seconds;
- void DelayFiveSeconds() {
- seconds = 0;
- while (seconds < 5) {
- // do nothing while seconds count to 5
- }
- }
在本例中,DelayFiveSeconds()將一直等待,直到另一個線程將seconds增加到5。
如果seconds沒被聲明為volatile,那么編譯器可能會進行過度優(yōu)化,將假定在while循環(huán)中seconds保持為0,循環(huán)內(nèi)的任何內(nèi)容都不能更改該值。循環(huán)將是while(0 < 5){},這將是個死循環(huán)。
關(guān)鍵字volatile的作用是,確保變量永遠(yuǎn)存儲在內(nèi)存中,而不是在寄存器中,并阻止對變量的所有優(yōu)化。
注意volatile不保證原子性,它不會阻止兩個線程同時嘗試寫操作。其他線程增加seconds的同時,試圖將seconds設(shè)置為0,這樣可能會失敗。更安全的做法是,一個線程只讀取seconds,并等待該值更改。
thread-local存儲
C++11中可以使用thread_local關(guān)鍵字來聲明線程本地變量,C++11前也有別的方式聲明,被修飾的變量對于每個線程都有一份拷貝,保證了線程安全。thread-local存儲效率較低,因為它是通過全局指針訪問。我們應(yīng)該盡量避免線程本地存儲,可以更多將變量存儲在線程自己的棧中,即在線程自己的函數(shù)中聲明變量。
堆內(nèi)存
堆內(nèi)存主要通過操作符new和delete動態(tài)分配,或者使用函數(shù)malloc和free。如果以隨機的順序分配和釋放不同大小的內(nèi)存,很容易產(chǎn)生內(nèi)存碎片,分散在堆內(nèi)存的不同地方,而且頻繁分配內(nèi)存,開銷也較大。盡量避免動態(tài)分配內(nèi)存吧,或者用JeMalloc替換一波?或內(nèi)存池?
整數(shù)變量和運算
整型大小
整數(shù)中,不同類型可能會有不同的大小,下圖總結(jié)了不同整型的大小和最大最小值:
在不同的平臺,聲明特定大小整數(shù)的方法不同,我們可以使用標(biāo)準(zhǔn)頭文件stdint.h,聲明特定大小的整型,該方法還可以跨平臺,可移植。
大多數(shù)情況下,整數(shù)運算非???,但是,如果整數(shù)大于可用寄存器大小,效率就會低一些。例如,在32位系統(tǒng)中,使用64位整數(shù)效率低一些,特別是用于乘法或除法時。
如果聲明了int類型,但是沒有指定具體大小,編譯器將始終選擇最有效的整數(shù)大小。較小大小的整數(shù)如char、short int等,效率可能稍微低一些,在很多情況下,編譯器在進行計算時,會將這些類型轉(zhuǎn)換為默認(rèn)大小的整數(shù),然后只使用結(jié)果中的低8位或者低16位。在64位系統(tǒng)中,只要我們不做除法,使用32位整數(shù)和64位整數(shù)的效率其實沒多大差別。
整數(shù)運算時,我們需要考慮中間計算的結(jié)果,看是否會導(dǎo)致溢出。例如表達(dá)式a=b+c+d,即使b、c、d都低于整數(shù)最大值,但是可能b+c就會導(dǎo)致整數(shù)溢出,我們需要時刻注意。
有符號整數(shù)和無符號整數(shù)
多數(shù)情況下,使用有符號整數(shù)和無符號整數(shù),在速度上沒有區(qū)別,但有一些特殊情況:
常量除法,當(dāng)除以常量時,無符號整數(shù)比有符號整數(shù)效率更高,模運算類似。
對于大多數(shù)指令集,使用有符號整數(shù)到浮點數(shù)的轉(zhuǎn)換,要比使用無符號整數(shù)轉(zhuǎn)換更快。
有符號和無符號整數(shù)的溢出行為不同:無符號整數(shù)的溢出產(chǎn)生一個低正結(jié)果,有符號整數(shù)的溢出是官方未定義的。有符號整數(shù),正常的行為是將正溢出轉(zhuǎn)換為負(fù)值,但是編譯器可能做一些優(yōu)化,它會假設(shè)不會發(fā)生溢出。
有符號整數(shù)和無符號整數(shù)之間的轉(zhuǎn)換,沒有任何開銷。這只不過是對同一符號位的不同解釋而已。負(fù)整數(shù)在轉(zhuǎn)換為無符號時,將被解釋為一個非常大的正數(shù)。
- int a, b;
- double c;
- b = (unsigned int)a / 10; // 轉(zhuǎn)換成無符號整數(shù)做除法更快
- c = a * 2.5; // 有符號整數(shù)隱式轉(zhuǎn)換為double型
在上例中,將a轉(zhuǎn)換為unsigned,可以使除法更快。當(dāng)然,只有當(dāng)a絕對不會是負(fù)數(shù)的時候,才可以這么轉(zhuǎn)換。最后一行,在與常數(shù)2.5相乘之前,會隱式地將a轉(zhuǎn)換為double,因為后者是double,這里a作為有符號整數(shù)去轉(zhuǎn)換,效率更高。
注意,在進行比較操作時,例如<操作,不要混用有符號整數(shù)和無符號整數(shù)。有符號整數(shù)和無符號整數(shù)的比較,可能會產(chǎn)生意想不到的結(jié)果。
整數(shù)運算
整數(shù)運算通常非???。簡單的整數(shù)運算,如加法、減法、比較、位運算等,在大多數(shù)微處理器上只需要一個時鐘周期。乘法和除法需要更長的時間。整數(shù)乘法在奔騰4CPU上需要11個時鐘周期,大多數(shù)情況乘法都需要3 - 4個時鐘周期,整數(shù)除法需要40 - 80個時鐘周期,具體取決于CPU。
自增和自減運算
++i和i++速度一樣快,當(dāng)僅用于遞增變量時,使用++i和i++沒有任何區(qū)別,效果完全相同,例如:
for(i=0;i
浮點數(shù)及其運算
現(xiàn)代x86家族的CPU,有兩種不同類型的浮點寄存器,對應(yīng)不同類型的浮點指令,每種類型都有優(yōu)缺點。
x87寄存器
x87寄存器是浮點運算的傳統(tǒng)方法,這些寄存器都有長雙精度,多個寄存器組成了一組寄存器棧,使用寄存器棧的優(yōu)點有:
所有的計算都是用雙精度完成的不同精度之間的轉(zhuǎn)換不需要額外的時間。
對于數(shù)學(xué)函數(shù),如對數(shù)函數(shù)和三角函數(shù),有一些內(nèi)置的指令。
代碼很緊湊,在代碼緩存中占用的空間很小。
寄存器棧也有缺點:
由于寄存器棧的組織方式,編譯器很難創(chuàng)建寄存器變量。
浮點數(shù)比較速度很慢,除非啟用更高的指令集。
當(dāng)使用雙精度時,除法、平方根和數(shù)學(xué)函數(shù),需要更多的時間來計算。
向量寄存器
也叫矢量寄存器,有XMM、YMM或ZMM等寄存器,是進行浮點運算的一種較新的方法,浮點運算以單精度或雙精度完成,中間結(jié)果的計算精度始終與操作數(shù)相同,使用向量寄存器的優(yōu)點有:
- 制作浮點寄存器變量很容易。
- 向量操作可用于對向量寄存器中的多個變量進行并行計算。
它也有缺點:
- 不支持長雙精度。
- 混合不同精度的表達(dá)式計算,需要精度轉(zhuǎn)換指令,這可能非常耗時。
- 數(shù)學(xué)函數(shù)必須使用函數(shù)庫,但這通常也比內(nèi)置的硬件函數(shù)快。
在幾乎所有具有浮點運算能力的系統(tǒng)中,都可以使用x87浮點寄存器,而XMM、YMM和ZMM寄存器分別需要SSE、AVX和AVX512指令集。
現(xiàn)代編譯器,更多情況下會使用向量寄存器,來進行浮點運算。很少有編譯器可以混合兩種不同類型的浮點運算,不能為每次運算選擇最優(yōu)類型。大多數(shù)情況下,當(dāng)沒有使用向量運算時,單精度浮點數(shù)運算和雙精度浮點數(shù)運算速度大體相同,無論精度如何,加法、減法、乘法等運算的速度都是相同的。但如果開啟向量運算,使用XMM等向量寄存器時,單精度除法、平方根和數(shù)學(xué)函數(shù)的計算速度要比雙精度快。
如果真的對精度有高要求,可以使用雙精度浮點數(shù),不需要太擔(dān)心速度。但如果我們可以利用好向量運算,或者有個大的浮點數(shù)數(shù)組,想要充分利用Cache,那可以考慮使用單精度浮點數(shù)。
浮點加法需要3 - 6個時鐘周期,乘法操作需要4 - 8個時鐘周期,除法需要14 - 45個時鐘周期,這取決于CPU。當(dāng)使用傳統(tǒng)的x87浮點寄存器時,浮點數(shù)比較操作,和浮點數(shù)到整數(shù)的轉(zhuǎn)換操作,效率較低。
注意:
同一個表達(dá)式中,不要混合使用單精度和雙精度浮點數(shù)。
盡量避免整型和浮點型的轉(zhuǎn)換。
向量寄存器支持多種模式,可以根據(jù)實際需要設(shè)置不同的模式,例如flush-to-zero模式、denormals-are-zero模式等。
枚舉
枚舉其實就是個偽裝的整數(shù),它的效率與整數(shù)相同。注意,枚舉值名字可能與一些變量或函數(shù)的名字發(fā)生沖突,可以將頭文件中的枚舉設(shè)置成較長且唯一的名字,或者放入命名空間,也可以使用enum class聲明枚舉。
布爾
布爾操作的順序優(yōu)化
布爾操作符&&和||的操作數(shù)按以下方式計算。如果&&的第一個操作數(shù)為false,則根本不計算第二個操作數(shù),因為無論第二個操作數(shù)的值是多少,結(jié)果都是false。同樣,如果||的第一個操作數(shù)為true,則不計算第二個操作數(shù),因為結(jié)果肯定是true。
所以,我們可以將通常為true的操作數(shù)放在&&表達(dá)式的最后,或放在||表達(dá)式的最開始。例如,假設(shè)a在50%的情況下為真,b在10%的情況下為真。當(dāng)a為真時,表達(dá)式a && b需要計算b,這是50%的情況。等價的表達(dá)式b && a只需要在b為真時計算a,這只占10%的時間。
如果一個操作數(shù)比另一個操作數(shù)更可預(yù)測,那么將最可預(yù)測的操作數(shù)放在前面。
如果一個操作數(shù)比另一個操作數(shù)計算得快,那么將計算得最快的操作數(shù)放在前面。
但是,在改變布爾操作數(shù)的順序必須要小心:如果操作數(shù)的計算有副作用,如果第一個操作數(shù)被用來確定第二個操作數(shù)是否有效,則不能交換操作數(shù)。例如:
- unsigned int i;
- const int ARRAYSIZE = 100;
- float list[ARRAYSIZE];
- if (i < ARRAYSIZE && list[i] > 3) {...}
這里我們不能交換順序,因為當(dāng)i>ARRAYSIZE時,list[i]操作是非法的,另一個例子:
- if (handle != INVALID_HANDLE_VALUE && WriteFile(handle, ...)) {...}
同樣,這里我們也不可能交換順序。
布爾值
布爾變量被存儲為8位整數(shù),值為0表示false, 1表示true。此種意義上,布爾變量由多種因素確定,即所有以布爾變量作為輸入的操作符,有可能不只是0和1,而以布爾變量作為輸出的操作符,只能產(chǎn)出0或1的值。這樣可能布爾變量作為輸入的操作效率較低。
舉例來說,對于
- bool a, b, c, d;
- c = a && b;
- d = a || b;
而編譯器可能是這么實現(xiàn)的:
- bool a, b, c, d;
- if (a != 0) {
- if (b != 0) {
- c = 1;
- } else {
- goto cfalse;
- }
- } else {
- cfalse:
- c = 0;
- }
- if (a == 0) {
- if (b == 0) {
- d = 0;
- } else {
- goto dtrue;
- }
- } else {
- dtrue:
- d = 1;
- }
當(dāng)然,這不是最優(yōu)方式。在錯誤預(yù)測的情況下,分支可能還需要很長時間。如果可以確定操作數(shù)除了0和1之外,沒有其他值,那么布爾操作的效率會高得多。編譯器沒有做這樣的假設(shè)的原因是,如果變量沒有初始化,或者來源未知,那么它們可能有其它的值。如果a和b已經(jīng)初始化為有效值,或者它們來自產(chǎn)生布爾輸出的值,則可以優(yōu)化上述代碼。優(yōu)化后的代碼如下所示:
- char a = 0, b = 0, c, d;
- c = a & b;
- d = a | b;
這里,我們可以使用char(或int)代替bool,以便能夠使用位操作符(&和|)而不是布爾操作符(&&和||)。按位操作符,是只占用一個時鐘周期的單個指令。即使a和b的值不是0或1,OR操作符(|)也可以工作。但如果操作數(shù)的值不是0和1,則AND操作符(&)和異或操作符(^)可能會產(chǎn)生不一致的結(jié)果。
注意這里有個坑,我們不能使用~代替!,相反,如果確定輸入是0或1,可以通過與1做異或來得到!的值。
舉例:
- bool a, b;
- b = !a;
可以被優(yōu)化成:
- char a = 0, b;
- b = a ^ 1;
指針和引用
看代碼:
- void FuncA(int *p) {
- *p = *p + 2;
- }
- void FuncB(int &r) {
- r = r + 2;
- }
這兩段分別使用指針和引用的代碼,它們實際上做的是相同的事情,具體我們可以看它們編譯后生成的代碼,其實它們的匯編代碼完全相同,區(qū)別僅僅是編程風(fēng)格的問題。
指針優(yōu)于引用的理由是:
直接看上面的函數(shù)體,很明顯看出p是一個指針,但不清楚r是一個引用,還是一個簡單的變量,使用指針讓閱讀代碼的人更清楚地知道發(fā)生了什么。
指針的指向可以更改,使用更靈活,還可以用指針做算術(shù)運算。
引用優(yōu)于指針的理由是:
引用比指針更安全,因為在大多數(shù)情況下,引用肯定指向一個有效的地址,而且引用的指向不可更改。而對于指針,如果沒有初始化,那指針?biāo)阈g(shù)計算超出了有效地址的范圍,或者指針類型轉(zhuǎn)換為錯誤的類型,那么指針可能是無效的,并導(dǎo)致致命錯誤。
引用對于復(fù)制構(gòu)造函數(shù)和重載運算符很有用。
聲明為常量引用的函數(shù)形參,可接受表達(dá)式作為實參,而指針和非常量引用需要變量。
使用指針或引用訪問變量,可能與直接訪問一樣快。函數(shù)中聲明的所有非靜態(tài)變量,都存儲在棧上,實際上它們也是通過棧指針進行尋址。同樣的,類中聲明的所有非靜態(tài)變量,也是通過隱式的this指針訪問,所以,大多數(shù)變量實際上都是通過指針訪問。
使用指針或引用也有缺點,它需要一個額外的寄存器來保存指針或引用的值,而寄存器是一種稀缺資源,特別是在32位模式下。如果寄存器數(shù)量不足,那么每次使用指針時都必須從內(nèi)存中加載,速度就會變慢。
注意指針這里有個坑:即指針的算數(shù)運算:
- struct A {
- int a;
- int b;
- };
- A *a;
- a = ++a;
- a = ++(char*)a;
++a和++(void*)a,它們的運算結(jié)果是不同的,++a里其實加的值是8,因為A大小是8,++(void*)a里其實加的值是1,因為(char)大小是1。
函數(shù)指針
如果目標(biāo)地址可以預(yù)測,通過函數(shù)指針調(diào)用函數(shù),相比于直接調(diào)用函數(shù),要多花幾個時鐘周期。如果函數(shù)指針的值,與上次執(zhí)行語句時相同,則目標(biāo)地址會被預(yù)測成功,而如果函數(shù)指針的值發(fā)生變化,目標(biāo)地址很可能會被錯誤預(yù)測,預(yù)測失敗會導(dǎo)致有多個時鐘周期的延遲。
智能指針
智能指針是一種行為與指針類似的對象。C++11后基本上有兩種智能指針,unique_ptr和shared_ptr,unique_ptr的特點是有且只有一個指針擁有所分配的對象,只有一個對象指針擁有對象的所有權(quán),而shared_ptr的特點是可以有多個指針共同指向同一個對象,顯然shared_ptr比unique_ptr的開銷更大一些。選擇智能指針時可以更多的選擇unique_ptr,編譯器可以做優(yōu)化,能夠在簡單情況下剝離掉unique_ptr的大部分或者全部開銷,這樣效率就和直接使用new和delete基本相同。一般一個函數(shù)內(nèi)new,需要另一個函數(shù)內(nèi)delete的場景下,可以考慮使用智能指針。但如果在同一個函數(shù)內(nèi)new和delete,且函數(shù)體沒有過多的分支,或許就不需要使用智能指針啦。
有時候程序可能需要很多動態(tài)分配的小對象,每個對象都有自己的智能指針,這種成本是否較高?你有什么解決方案嗎?可以在留言板里留言哦!