本章引入的實際問題為:定義一個簡單的Trace類,將當前函數名輸出到日志文件中。Trace對象會帶來一定的開銷,因此在默認情況下不會開啟Trace功能。問題是:怎麼設計Trace類,使得在不開啟Trace功能時引入的開銷最小。
用宏來開關Trace功能很簡單,在不開啟時開銷完全沒有:
#ifdef TRACE Trace trace("aaa"); #endif
缺點是每次開關都需要重新編譯。
使用狀態變量的話有一定的運行時開銷,但能保證靈活性,是一種比較合理的選擇:
class Trace { public: ... static bool isTraceEnabled; void Debug() { if (isTraceEnabled) { ... } } }
原本的Trace類中內置string成員,這樣在不開啟Trace時也要承擔構造和析構的開銷。可以將其改為string*,並在真正需要開啟時再創建該成員。
如果Trace的開啟時間遠小於總時間,則此方法很有效,否則當動態創建的開銷大於固定的1次構造和析構的開銷時,原方法更好一些。
繼承和合成會導致構造函數和析構函數的開銷超過你的預期。如何在代碼重用性與運行性能間權衡值得關注。
去掉不必要的對象使用,不管是類中的成員變量,還是函數的參數,都會帶來不必要的構造和析構開銷。
有些基類沒有成員變量,也沒有提供接口的作用,這種基類就屬於無意義的基類,在繼承層次中去掉這樣的基類可以減少以下幾項開銷:
如果直接嵌入對象的話,對象A的構造會導致對象B和C的構造,會導致對象B1 B2 C1 C2的構造……會形成一個構造樹,層次較多時這個的開銷會很巨大。
嵌入指針的話屬於延遲創建,在第一次使用時才構造,但會帶來new的開銷。若經常使用則直接嵌入對象會好一些。
這裡寫的是將對象的定義和創建盡量延後,直到所有條件都具備了再進行創建。
主要是指不必要的復制:
string s; s = "a"; // 有一次多余的賦值開銷,還可能有一次將"a"轉換為string臨時對象的構造和析構開銷
以及不在初始化列表中進行的初始化:
class A { public: A(const string &s) { name = s; //有一次多余的賦值開銷 } private: string name; };
利用好虛函數的優點:動態綁定,以及節省代碼。盡量避免虛函數帶來的開銷。
可分為三種:
不繼承的話就是將各子類獨立出來,缺點是在代碼中會充斥大量的switch,非常沒有靈活性,排除。
繼承的缺點如3.2.1所述,成員函數無法內聯,尤其是非常短小使用頻繁的函數,會增加大量開銷。
使用模板來實現隱式接口:
template <typename LockType> void func(LockType &lock) { lock.Lock(); ... lock.Unlock(); }
實現了一個需要有Lock和Unlock的隱式接口。因為模板是在編譯時確定的,因此生成的函數可以內聯,同時還省去了指針間接跳轉的開銷。
缺點是模板導致的編譯錯誤非常難以調試,同時C++不支持這種隱式接口,開發時經常會弄錯模板的接口要求。
任何時候只要路過了對象的創建和清除,就會獲得性能上的收益。編譯器會在可能時去掉一些臨時對象的創建和清除,這種優化被稱作返回值優化(RVO)。
函數結尾直接返回一個匿名對象往往可以進行RVO:
string Func() { string a = "a"; return a; } string FuncRVO() { return string("a"); }
FuncRVO相比於Func更容易進行RVO,編譯器會去掉返回的對象,而將其值直接賦給接收返回值的對象中。
如果類A有“+”操作如下:
const A operator +(const A &x, const A &y) { A z(x); z += y; return z; }
那麼在A中增加一個構造函數:
class A { public: A(const A &x, const A &y); // A = x + y; };
則將“+”改為以下形式可獲得RVO收益:
1 2 3const
A
operator
+(
const
A &x,
const
A &y) {
return
A(x, y);
}
優點是減少了一個臨時對象的構造和析構成本,缺點是要為所有需要進行RVO的操作分別新增一個類似的構造函數,靈活性太差。如果對性能要求特別高,可以考慮這種優化方法。
如何避免產生不必要的臨時對象。
在不同類型間的賦值容易無意中導致臨時對象的創建。可以通過在單參數構造函數前加explicit來避免這種隱式的轉換產生。
如下循環中:
Complex a; for (int i = 0; i < 10; ++i) { a += 1.0; }
其中每次循環都會創建一個值為1.0的Complex對象。可以在循環外創建一個值為1.0的Complex對象,來減少這種開銷:
Complex one(1.0); for (int i = 0; i < 10; ++i) { a += one; }
默認的通用內存管理器的性能在特定場景下會造成一定的性能瓶頸。本章討論的是在單線程環境下,每次分配固定大小和不固定大小的內存時,實現比通用new/delete性能更好的內存池管理器。
Rational *array[1000]; for (int j = 0; j < 500; ++j) { for (int i = 0; i < 1000; ++i) { array[i] = new Rational(i); } for (int i = 0; i < 1000; ++i) { delete array[i]; } }
每次分配Rational大小的內存塊,用一個空閒鏈表維護已分配的空閒內存,在釋放時重新將此內存塊放回到鏈表中:
class Rational { ... static list<char *> freeList; void *operator new(size_t size) { if (freeList.empty()) { return new char[sizeof(Rational)]; } else { void *buf = freeList.back(); freeList.pop_back(); return buf; } } void operator delete(void *ptr, size_t size) { freeList.push_back(ptr); } };
此版本的內存池從不收縮,如果需要釋放內存,則需要新增一個接口。
此版本的內存池與通用內存管理器相比,收益在於:
不只針對Rational,而是擴展為支持任意固定大小的類:
template <typename T> class FixedSizeMemoryPool { public: FixedSizeMemoryPool(): size_(sizeof(T)) {} ~FixedSizeMemoryPool() { for(char *&p: freeList_) { delete[] p; } } void *Alloc() { if (freeList_.empty()) { return new char[size_]; } else { void *buf = freeList_.back(); freeList_.pop_back(); return buf; } } void Free(void *buf) { freeList_.push_back(buf); } private: list<char *> freeList_; const size_t size_; };
Rational則需要改為:
class Rational { public: void *operator new(size_t size) { return pool.Alloc(); } void operator delete(void *ptr, size_t size) { pool.Free(ptr); } private: static FixedSizeMemoryPool<Rational> pool; };
不定大小的內存池的管理方法與上面的版本不同,因為沒有辦法直接從鏈表中返回一個內存塊(大小不同)。這裡我們在需要時分配一個大的固定大小的內存塊,每次分配單個對象的內存時就從這個內存塊上分配,空間不夠時就分配大的內存塊。
隨著通用性的增加,性能也在逐漸下降。因此,在非常需要性能時,犧牲一些靈活性通用性也許會有很好的效果。
在單線程的內存池中加入互斥鎖,來實現多線程環境下可工作的分配器。在初始化分配器時可以傳入鎖的參數。
在上一章MutableSizeMemoryPool中增加一個新的模板參數:typename LockType,允許傳入一個LockType*,並在Alloc和Free時加鎖,其它保持不變。
這一版的分配器的性能並不好,原因是pthread_mutex的性能超出了我們的需要,我們可能只需要一個功能很簡單的鎖。如果能傳入一個更原始版本的互斥鎖的話,會有更好的性能。
目前版本的內存池在高並發環境下性能不好,因為對內存塊的訪問(Alloc和Free)必須要串行化。可以增加多個內存塊的列表,並為每個列表單獨加鎖,這樣可以把多個請求分散到不同的列表中同時進行處理。
內聯可能會提高性能,但也可能會降低性能。如何避免負面影響,同時利用好正面收益,是本章的關注點。
去除了函數調用的開銷。一般的函數調用包括:
另外,還避免了進行跳轉帶來的處理器空轉損失。
使得編譯器可以進行跨函數的變量優化:
int Inc(int x) { return x + 1; } void Func() { int y = Inc(1); }
上面的Inc如果內聯的話,Func就相當於:
void Func() { int x = 1; int y = x + 1; }
編譯器甚至可以進一步優化為:
void Func() { int y = 2; }
通過內聯,編譯器可以重排大量的方法,從中省略掉大量不必要的語句,甚至包括對象的創建和清除。
通過內聯關鍵路徑上的函數,可以在完全不改變程序邏輯的情況下縮短關鍵路徑,從而大幅提高性能。
內聯後,函數代碼會展開在每個調用點,如果函數代碼量和調用點都比較多,則會導致代碼體積膨脹。一方面會導致代碼載入速度變慢,另一方面會導致更頻繁的缺頁發生。
但如果函數長度特別短,比整個調用過程還短,那麼內聯後反倒會減小代碼體積,這種函數是一定要內聯的。
內聯的函數如果修改了代碼,所有用到它的地方必須重新進行編譯,因為該函數的代碼已經在各個調用點展開了。因此大型工程往往在收尾時才進行內聯。
本章主要關注內聯的第2項收益:在內聯函數的代碼展開後,編譯器針對其進行的各項性能優化。
內聯後,調用函數的代碼與調用處代碼混合,這允許編譯器進行很多高級的優化,如同將代碼重新組織了一樣。尤其是針對直接量進行的優化:
int Choice(int x) { switch (x) { case 1: return 5; break; case 2: ... ... case 100: return 301; break; default: return 0; break; } } int x = Choice(100);
在內聯優化後,上面的代碼可能只剩下:
int x = 301;
內聯的主要缺點就是可能會增大代碼體積。尤其是當相對龐大的方法被多層內聯時會出現體積指數級膨脹的問題。
缺點2是每次修改需要全部重新編譯。
缺點3是很難對內聯函數進行調試,因為實際的函數已經沒有了,無法追蹤到函數的入口和出口。
在內聯前應該統計各個函數的編譯後體積和調用次數、調用點等信息,通過這些配置信息來決定對哪些函數進行內聯。
可以將函數的尺寸分為:
對靜態尺寸較小而動態尺寸較大的函數,內聯會有很大的收益。對於只有一個調用點的函數,如循環內的調用,內聯幾乎總是對的。而對於調用點和調用次數都很多的函數,最好重寫以展示出其快速路徑,再進行內聯。
某函數如下:
void FuncX { if (/* error handle code */) { ... // 30 lines } ... // real work (5 lines) }
FuncX有大約40行代碼,表面上看不適於內聯,但如果將它的錯誤處理代碼拆成一個單獨的函數:
void FuncX { if (...) FuncY(); ... // real work (5 lines) } void FuncY { ... // error handle code (30 lines) }
此時FuncX只有7行代碼,很適合內聯了。這也相當於將靜態尺寸大而動態尺寸小的代碼段拆出去,從而讓剩余的靜態尺寸小動態尺寸大的代碼可以進行內聯。
一些可幫助你更好的內聯的技巧。
如果想用一個預編譯選項來控制某些函數何時內聯,何時關閉內聯,可以使用條件內聯的技巧。
將內聯函數的定義放到.inl中,其它函數的定義放到.cpp中,然後在.h中加入:
#ifdef INLINE #include "*.inl" #endif
在.inl中加入:
#ifndef INLINE #define inline // let inline be void #endif inline FuncX(...){}
在.cpp中加入:
#ifndef INLINE #include "*.inl" #endif
可以將某函數在一些調用點處內聯,而在其它調用點處不內聯。具體內容不是很喜歡,略過。
尾遞歸的函數可以改成迭代函數,再尋找內聯方法。
非尾遞歸的函數如果非常在意性能,可以將函數進行一定的展開:
void RecursiveInline() { ... Recursive(); ... } void Recursive() { ... RecursiveInline(); ... }
將前一個函數內聯,這樣會加快運行速度,但也會明顯增加編譯後體積。
也可以手動展開,或是用宏來維護,但很容易出問題。
有些體系結構下(如SPARC),函數調用的開銷會在調用層次較少時非常的低,此時再內聯那些非微小的函數的收益就很不明顯了。因此,任何對非微小函數的內聯都要建立在了解配置信息的基礎上。
要比STL更好的話,往往要犧牲一定的通用性和靈活性,從一些特定的環境因素著手進行優化。
C++使用了引用計數來解決垃圾回收問題,基本思想是把對象清除的責任從客戶端代碼轉移給對象本身。
引用計數可以減少內存使用、避免內存洩漏,但在執行速度方面卻可能會有壞處,尤其是在多線程環境中。
實現A:類內置引用計數。類RefCountBase封裝和引用計數相關的操作,需要實現引用計數的類繼承它:
class RefCountBase { public: Attach() { ++refCount_; } Detach() { if (--refCount_ == 0) { delete this; } } protected: RefCountBase(): refCount_(0) {} RefCountBase(const RefCountBase &rc): refCount_(0) {} RefCountBase &operator=(const RefCountBase &rc) { return *this; } virtual ~RefCountBase() {} size_t refCount_; };
如類A繼承自RefCountBase,為了實現引用計數,還需要一個代理類SmartPtr充當A的智能指針:
template <typename T> class SmartPtr { public: SmartPtr(T *ptr = nullptr): ptr_(ptr) {} SmartPtr(const SmartPtr &sptr): ptr_(sptr.ptr_) { if (ptr_) { ptr_->Attach(); } } SmartPtr &operator=(const SmartPtr &sptr) { if (sptr.ptr_) { sptr.ptr_->Attach(); } if (ptr_) ptr_->Detach(); ptr_ = sptr.ptr_; return *this; } T *operator->() { return ptr_; } T &operator*() ( return *ptr_; ) private: T *ptr_; };
實現B:將計數功能放入SmartPtr中。去掉RefCountBase,而是在SmartPtr中增加一個size_t *count_,對ptr_的Attach操作變為++*count_,而Detach操作則變為--*count_。其它相同。
SmartPtr中需要同時對count_和ptr_進行操作,在並發環境下這就意味著需要在操作前後加鎖,來保證對兩個對象的原子操作。
實現A中需要對原類進行修改,如果不能進行這種修改,則只能使用實現B。實現B中因為需要操作兩個堆上的成員(count_和ptr_),創建和清除性能會比實現A差一些。
引用計數的收益是:
引用計數的壞處:
下列條件會增加引用計數的收益:
應用程序編碼階段會引入很多的性能問題,這類問題通常是小范圍的問題,解決它們不需要看太多的代碼,也不需要改變深層次的設計,但有可能會帶來比較明顯的性能提升。
記住頻繁計算和計算代價高的計算結果。比較典型的是將在循環中需要反復計算的固定結果保存在循環外的一個變量中,並在循環中使用這個變量。
可以將一些在關鍵路徑上需要頻繁用到的計算結果提前進行計算,將結果保存起來,這樣真正使用時只需要簡單的查找就可以了。
如果目標代碼使用的范圍很固定,那麼就不需要在代碼中考慮太多的通用情況,而是可以針對目前已知的一些特定情況進行大膽的假設,從而加快運行速度。
80%的時間消耗在20%的函數調用上,因此盡量降低這20%的函數需要的時間就能大大提高整個系統的性能。
相似的例子出現在if (and1 && and2)以及if (or1 || or2)中,如果兩個條件沒有依賴關系,那麼就將更有可能決定整個關系式值的條件放在前面,即如果and1比and2更容易為false,那就將and1放在前面,而如果or1比or2更容易為false,就將or2產在前面。
除了條件值的可能性外,還可以將每個操作的指令數也考慮進去,則可以令整個條件式指令數最小的條件放在前面。
而如果所有外部參數中有5%是特殊的,其它95%是類似的,那麼我們可以單獨為這5%的特殊參數設計一個路徑,從而加快95%的常見情況的處理速度。
將計算延遲到真正需要的時候,從而避免昂貴的計算結果最後沒被使用。這節沒什麼新東西。
這節沒什麼新東西。
在設計對象布局時考慮到體系結構的影響,主要是系統緩存帶來的影響。如矩陣的行長度如果恰好和緩存行長度相等,那麼在進行矩陣轉置時會出現頻繁的緩存未命中。而在設計經常需要一起訪問的兩個成員時,最好讓它們可以處於同一緩存行中,這也需要讓先被訪問的成員放在前面。
性能是一種交易。沒什麼新東西。
很多性能細節都隱藏在庫和系統調用的背後,因此在設計時要詳細了解這些細節,並在多個可用的工具中選擇功能剛剛好夠用的那個,它的性能往往也要比那些功能更加完善強大的版本好一些。
在release時開啟編譯器優化,可能會有很大的性能提高。
設計上的優化是全局的,依賴於其它組件和代碼。
在軟件開發的早期,如果不了解程序的熱點,那麼就全面使用STL好了。當對程序的運行有一定了解後,可以用一些靈活性去換取性能。
web服務中每次請求都需要寫入日志,並帶有一個時間戳。如果單次請求需要多次寫入,那麼可以將計算出來的時間戳緩存起來供所有這些日志寫使用。
如果對象經常需返回某個操作的值,那麼可以將這個值內嵌在對象中,如各種容器的size等。
如果某段代碼每次都需要判斷請求的類型來決定運行路徑,那麼可以將它拆成兩段代碼分別做單一的操作。這是虛函數很擅長的領域。
沒什麼新東西。
沒什麼新東西。
並行或並發環境下的性能問題。
SMP體系的一個性能瓶頸是多個處理器需要共享與內存間的總線。
解決方案是每個處理器配一個大的緩存,但帶來的主要問題是緩存一致性問題。
以上兩個問題導致了實際的並行性能提升難以達到核心數量提升的倍數。
順序計算是通往可伸縮性道路上的主要障礙。單獨加速某一段帶來的提升不會大於這段所占的總開銷比例。
把單一的任務分解為多個並發子任務可以提高以下指標:
I/O密集型任務更適合並發執行。
例子:某線程服務於某請求,在線程生命期內,線程的許多操作都要作用於該請求之上。一種思路是在每個操作處調用pthread_getspecific,但這會帶來嚴重的鎖開銷。另一種思路就是在線程開始時獲得一次指針,並傳給隨後的所有函數。
上例中,更好的方法是直接將線程相關的東西放到與線程關聯的結構中,這樣可以完全地去掉需要串行化的部分。
在不知道請求數量的時候,可以用固定大小的線程池來進行服務,這樣有著很好的伸縮性。
通常,把多個無關的資源融合到單個鎖的保護之下不是個好主意。例外是滿足以下兩個條件的情況:
鎖的粒度太粗會導致並行性下降,而粒度太細又會導致鎖的開銷增加、以及單個任務的處理時間增長。
SMP系統上,兩個鎖如果處於同一緩存行中,那麼p1對m1的鎖操作會導致整個緩存行在p2上失效,從而導致p2訪問m2時要重新讀內存。避免這個問題的方法是手動在m2和m2間插入一定的空白。
如果用多個線程accept,比如100個,那麼在來連接請求時,100個線程都會醒來,但只有1個線程能獲得請求,其它99個線程轉而繼續睡眠。這種CPU沖擊會導致服務器萎縮並嚴重損害吞吐量。當吞吐量下降時,系統可能會增加更多的線程,從而導致問題更加嚴重。
解決問題的方法是只用一個線程accept再將請求分發給其它線程。
沒什麼新東西。
本章討論的東西只簡單的羅列如下: