最近着手去優化項目中一個模塊的性能。該模塊是用C++實現,對大量文本數據進行處理。
一開始時,沒什么思路,因為不知道性能瓶頸在哪里。於是借助perf工具來對程序進行分析,找出程序的性能都消耗在哪里了。
下面對待優化的程序運行一遍,通過perf統計一下程序中哪些函數運行cpu周期占百分百最多。
我們直接看占用比靠前的這一部分,只需要把這些大頭優化好,那么整體的性能就能得到提升。那些本來占用cpu周期很少的函數,再怎么優化都整體的性能也沒有很大的改變。
1 Samples: 629K of event 'cpu-clock', Event count (approx.): 157401000000 2 Children Self Command Shared Object Symbol 3 + 8.87% 8.77% MyTest libc-2.12.so [.] __memcmp_sse4_1 4 + 7.89% 7.79% MyTest libc-2.12.so [.] _int_malloc 5 + 7.26% 7.18% MyTest libc-2.12.so [.] malloc 6 + 6.80% 6.73% MyTest libc-2.12.so [.] _int_free 7 + 4.82% 4.76% MyTest libstdc++.so.6.0.19 [.] std::string::_Rep::_M_dispose 8 + 4.06% 4.01% MyTest MyTest [.] std::_Rb_tree<std::string, std::string, std::_Identity<std::string>, std::less<std::string>, std::allocator<std::string> >::find 9 + 3.92% 3.87% MyTest libstdc++.so.6.0.19 [.] std::string::find 10 + 3.79% 3.74% MyTest MyTest [.] std::_Rb_tree<std::string, std::pair<std::string const, std::string>, std::_Select1st<std::pair<std::string const, std::string> >, std::less<std::string>, std::allocator<std::pair<std::string const, std::string> > >::find 11 + 2.35% 2.30% MyTest libstdc++.so.6.0.19 [.] std::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string 12 + 2.32% 2.30% MyTest libc-2.12.so [.] memcpy 13 + 2.27% 2.24% MyTest libstdc++.so.6.0.19 [.] std::string::assign 14 + 1.89% 1.87% MyTest libstdc++.so.6.0.19 [.] std::string::compare 15 + 1.56% 1.54% MyTest libstdc++.so.6.0.19 [.] std::string::append 16 + 1.43% 1.41% MyTest MyTest [.] std::map<std::string, long long, std::less<std::string>, std::allocator<std::pair<std::string const, long long> > >::operator[] 17 + 1.37% 1.35% MyTest MyTest [.] meta::BaseMetaElement::do 18 + 1.24% 1.22% MyTest libstdc++.so.6.0.19 [.] std::string::_M_mutate 19 + 1.20% 1.19% MyTest libstdc++.so.6.0.19 [.] std::string::_Rep::_M_dispose 20 + 1.19% 1.18% MyTest libc-2.12.so [.] free 21 + 1.17% 1.15% MyTest MyTest [.] cppjieba::Trie::Find 22 + 1.14% 1.13% MyTest MyTest [.] util::string::HalfFullTransformer::isRemainFullChar 23 + 1.10% 1.09% MyTest libstdc++.so.6.0.19 [.] operator new 24 + 1.03% 1.03% MyTest [kernel.kallsyms] [k] retint_careful 25 + 0.93% 0.92% MyTest libstdc++.so.6.0.19 [.] std::string::_Rep::_S_create 26 + 0.92% 0.91% MyTest libstdc++.so.6.0.19 [.] std::string::_Rep::_M_clone 27 + 0.92% 0.91% MyTest libc-2.12.so [.] malloc_consolidate 28 + 0.92% 0.91% MyTest libc-2.12.so [.] __strlen_sse42 29 + 0.85% 0.84% MyTest libstdc++.so.6.0.19 [.] std::string::reserve
乍一看,基本都是c庫和STL庫的函數占用了大部分時間,自己實現的函數寥寥無幾。
消耗時間最多的就是c庫的內存分配和釋放函數,再看看第11行,基本可以確認是因為代碼中過多使用std::string對象,導致了內存頻繁申請和釋放。
代碼中對字符串的處理,都是使用了string類來處理,我們做不到對string的內部的優化,也很難去實現一個比string很好的類,那么只能從string對象的使用上面入手。
1. string優化
1.1 參數的傳遞使用引用
這里不止是string,當函數參數只是作為輸入只讀時,就應該使用常量引用傳參。避免不必要的對象構造和釋放,在傳遞大的對象時,效果相差很大。
1.2 變量延時定義
string變量的定義,盡可能的放在必須要使用的時候再定義。有時候可能一個判斷分支,導致一個預先定義的對象根本就沒有使用,那就這個對象的構造和釋放就是一個額外的消耗,這種情況必須避免。
1.3 string::find()
就是在多次字符串查找時,應該合理記錄上一次查找的位置,作為下一次查找的開始位置的依據。在查找同一個值時,不難理解,如果是在字符串中,每次查找不同的值時,可以根據實際情況去處理。比如從一個地址中,先查找“市”再查找“鎮”,這種情況就不用每次都從開頭開始查找。當字符串很大時,效率提升會比較明顯。具體什么時候使用這種處理方法,就需要自己根據實際情況去考慮了。
1.4 string拼接與善用reserve()/resize()
字符串拼接是一個很常用的操作,平時簡單使用時,也不需要太多注意,怎么簡單怎么來。但是,在處理大字符串時,效率就跟不上來了。
例如,需要對數據庫表的每一行數據拼接成一個字符串,行數據可能很大。在不斷循環表行數進行拼接時,使用string重載的+操作就顯得太慢了。string對象默認初始化的空間比較小,可能每次調用+操作時都需要重新分配空間。這樣當拼接一個大字符串時,就需要分配釋放多次空間,還需要進行內存拷貝,這是非常耗時的。
我們應該在定義string對象時,直接指定分配空間的大小,這個值可以通過預估出來或者通過計算的來。
// 定義時 std::string str(1024, 0); // reserve() str.reserve(1024); // size() str.resize(1024);
如果string對象是新定義的,可以直接調用構造函數來預分配空間大小。如果string對象已經定義了,可以使用reserve()來分配一個指定大小的空間。
當預設好string對象的空間大小后,自己去用memcpy()和string::resize()去實現字符串拼接操作,這樣效率上比用string的+操作要快。
1.5 string的Copy-On-Write機制
當string賦值或者拷貝時都是淺拷貝,兩個string對象的實際存儲字符串的地址是同一個,string中用一個引用計數的變量,來記錄當前有多少個string對象使用同一個字符串存儲空間,類似於共享指針。當string對象需要修改時,這個時候才會重新分配一個空間,並把字符串拷貝到新空間,string對象指向新的空間,在新的地址空間中對字符串進行修改,這就是Copy-On-Write的意思。
1.6 string轉向char*處理
下面代碼中,對string對象的字符串的兩個不同部分分別進行處理。
方案1中,全部用string對象的方法來實現;
方案2中,把string對象轉換為char*,通過對字符串地址直接進行處理,能達到意想不到的效果。
1 std::map<char, char> mapKV; // 對應表 2 3 //////////////////////////////////////////////////////// 4 // 方案1 5 void deal(std::string& value){ 6 size_t size = value.size(); 7 for(size_t i = 0; i < size; ++i){ 8 value[i] = mapKV[value[i]]; 9 } 10 } 11 12 void func(std::string& value){ 13 std::string str = value.sub(6,8); 14 deal(str); 15 value.replace(6,8,str); 16 17 str = value.sub(14,3); 18 deal(str); 19 value.replace(14,3,str); 20 } 21 22 //////////////////////////////////////////////////////// 23 // 方案2 24 void deal2(char* pValue, size_t len){ 25 for(size_t i = 0; i < len; ++i, ++pValue){ 26 *pValue = mapKV[*pValue]; 27 } 28 } 29 30 void func2(std::string& value){ 31 deal2((char*)value.c_str()+6, 8); 32 deal2((char*)value.c_str()+14, 3); 33 }
優化點:
- 減少子string對象的生成
- 減少字符串替換
- 優化string的[]操作使用
2.map優化
看到perf的分析報告,其中第8、10、16行,看到一些紅黑樹查找和map的operator[]使用的cpu周期占用的也挺多。由於數據處理中使用到數據替換對應表都是用std::map來實現,std::map內部是用紅黑樹來實現的,查找效率會比較慢。鑒於對應表中,不需要順序保存,所以用查找效率更高的boost::unordered_map來代替std::map。
map的內部實現是二叉平衡樹(紅黑樹);unordered_map內部是一個hash_table。map是一個有序的容器,提供了穩定的插入刪除查找效率,內存占用也相對較小;而unordered_map是無序容器,可以快速插入刪除,查找效率也比map快,只是內存會占用得多一點。
2.1 unordered_map替換map
在程序中,原來數據對應表都是由map來實現的,這里把數據相關的對應表都改為用unordered_map代替。這樣在數據處理的時候,通過對應表查找相關數據時的效率有很大提升。
2.2 小心map::operator[]()
map的operator[]()函數有個特性,當調用map[key]時,如果key在map中不存在,則會在map中插入一個鍵值對,鍵為key,值則默認構造一個值。map的operator[]()用起來是很方便,但是一但不注意,引入了map中不應該存在的值,很有可能就會帶來一些不必要的麻煩。看下面的代碼:
void Children:set(std::map<std::string, std::string>& ret) { Parent::set(ret); if(!ret["error"].empty()){ return; } }
int main() {
Children ch;
std::map<std::string, std::string> ret;
ch.set(ret);
if(ret.find("error") != ret.end()){
std::cout << "error" << std::endl;
}
}
子類調用了父類的set()函數后,判斷結果是否有錯誤。這里使用了map::operator[](),就會有問題,在外部調用了子類的set()函數后,判斷返回結果集中是否存在"error"的元素,最終結果輸出"error"。
所以在使用map的operator[]()函數時,一定要小心。除非你很明確知道這個key是在map中存在的,否則不要直接調用operator[]()。而應該先通過find()函數查找key是否存在於map中,再去獲取key的值。
2.3 find()與operator[]()之間的使用
map並不是一個順序容器,map的[]操作與數組的[]操作不一樣。map的operator[]()與find()的實現差不多,通過參數key查找到map中的鍵值對並返回不同的值,只不過operator[]()在map查找不到時會插入一個鍵值對。
int getValue(int key){ if(map.find(key) != map.end()){ return map[key]; } return 0; }
上面的代碼,在調用operator[]()前,先用find()函數確保key存在map中,避免了2.2中提到的問題。但是在效率上卻慢了,find()和operator[]()中都有查找算法,這里獲取一個值就查找了兩遍,這是不應該的。應改為一下這種寫法:
int getValue(int key){ std::map<int,int>::iterator it = map.find(key); if(it != map.end()){ return it->second; } return 0; }
2.4 map::end()優化
1 std::map<int, int> mapKV; 2 int sum(std::vector<int> vecKey){ 3 size_t size = vecKey.size(); 4 int sum = 0; 5 for(size_t i = 0; i < size; ++i){ 6 std::map<int, int>::iterator it = mapKV.find(vecKey[i]); 7 if(it != mapKV.end()){ 8 sum += it->second; 9 } 10 } 11 return sum; 12 }
當程序中需要多次從map中查找元素時,每一次都需要查找並判斷元素是否存在。在上面的代碼中第7行判斷元素是否存在時,每一次都需要調用end()函數獲取map的結束迭代器。還有每次查找中,都需要創建一個迭代器來結束查找的值。這些地方都可以優化,優化如下:
1 std::map<int, int> mapKV; 2 int sum(std::vector<int> vecKey){ 3 size_t size = vecKey.size(); 4 int sum = 0; 5 std::map<int, int>::iterator itEnd = mapKV.end(); 6 std::map<int, int>::iterator it = itEnd; 7 for(size_t i = 0; i < size; ++i){ 8 it = mapKV.find(vecKey[i]); 9 if(it != itEnd){ 10 sum += it->second; 11 } 12 } 13 return sum; 14 }
在循環體外,先定義兩個迭代器,並設置為map的結束迭代器,這樣可以避免在循環體內中多次后去map的結束迭代器,提升效率。
3. 算法優化
在優化過程中,會碰到一些小問題,比如vector的順序訪問時,是用迭代器效率高,還是用[]效率高呢。所以在優化前,必須要把這些小點搞清楚。
下面整理一下自己測試出來的結果:
- string
在順序訪問時,迭代器 > [];
在隨機訪問時,it+i > str[i];
當把string轉化為char*訪問時:順序,++it > ++p > p+i > p[i]; 隨機,it+i > p+i > p[i]
- 迭代器
前置++ > 后置++
- vector
順序訪問,[] > ++it > it+i
隨機訪問,[] > it+i
- map\unordered_map
無論順序還是隨機范圍,(find,if) > (find,[])
在循環查找中,用變量保存map::end()的值 > 每次調用end()
3.1 全角字符
在程序中,全角字符與半角字符的對應關系是用map來保存的,后面優化為使用unordered_map保存。
全角字符的編碼范圍在unicode中的FF00-FFEF內,轉換為utf8編碼,則以0xEF開頭。在判斷字符是否為全角字符時,可以優先判斷字符的第一個字節是否符合全角字符的規范,這樣可以省去直接調用unordered_map::find()。
1 //判斷字符是否為全角字符的第一個字節 2 bool likeFullCharFirstByte(unsigned char c) { 3 return !(c ^ 0xEF); 4 }
3.2 utf8字符字節數計算
看下面的實現,依次判斷第一個字節的大小范圍,計算出utf8的字符長度。
1 int getUtfCharLen(unsigned char byte){ 2 if (byte < 0xC0){ 3 return 1; 4 }else if (byte < 0xE0){ 5 return 2; 6 }else if (byte < 0xF0){ 7 return 3; 8 }else if (byte < 0xF8){ 9 return 4; 10 }else if (byte < 0xFC){ 11 return 5; 12 }else{ 13 return 6; 14 } 15 }
但是這種實現,在utf8中文字符串中,效率就有點低。因為中文的utf8字符的長度,基本都是3個字節,這上面的實現中能會多了2字節的字符的判斷邏輯。
重新實現,可以根據實際情況進行調整的方案。
1 int getUtfCharLen(unsigned char byte){ 2 if ((byte & 0x80) == 0x0) { 3 // 1:0xxx xxx 4 return 1; 5 } else if ((byte & 0xF0) == 0xE0) { 6 // 3:1110 xxxx 7 return 3; 8 } else if ((byte & 0xE0) == 0xC0) { 9 // 2:110x xxxx 10 return 2; 11 } else if ((byte & 0xF8) == 0xF0) { 12 // 4:1111 0xxx 13 return 4; 14 } else if ((byte & 0xC0) == 0x80) { 15 // 1:10xx xxxx 16 return 1; 17 } else if ((byte & 0xFC) == 0xF8) { 18 // 5:1111 10xx 19 return 5; 20 } else { 21 return 6; 22 } 23 }
3.3 函數變量靜態化
在函數內,可能為了實現函數功能需要初始化一個固定的數組或者字符串變量,該變量是不會被改變的,即只讀。這個時候可以把變量聲明為靜態變量,使得在該函數體內只初始化一次,以后調用該函數則可以直接使用了。
1 /*身份證號最后一位校驗碼獲得函數*/ 2 char getLastVarify4ID(const std::string& id) { 3 int sum = 0; 4 //int const weight[] = { 7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2 }; 5 static int const weight[] = { 7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2 }; 6 //const std::string lastcode = "10X98765432"; 7 static const char* lastcode = "10X98765432"; 8 9 for (int i = 0; i < 17; i++) { 10 sum += (int) (id[i] - '0') * weight[i]; 11 } 12 int index = sum % 11; 13 return lastcode[index]; 14 }
3.4 空間換時間
在程序中處理數據需要用的對應表,有時候需要生成多個不同的對應表,簡單的方法就是把保存在map的對應表塞到vector中,實現起來簡單方便,使用起來也沒什么問題。
只有當程序需要高頻地使用這些對應表時,效率的問題才會慢慢暴露出來。
下面來針對n版本字符對應表的情況,進行優化:
1 // 在實現n個版本數字字母字符對應表 2 // 簡單的方法,把每張對應表用map存起來,再把不同版本的對應表存到vector中 3 std::vector<std::map<char, char> > vecMaps; 4 // 獲取對應值,index-第幾個版本,c字符的對應值。vecMaps[index][c] 5 char ch = vecMaps[0]['a']; 6 7 //////////////////////////////////////////////////////////////////////////////// 8 // 空間換時間方法 9 // 分配一個n*127的二維char數組,可以保存n張ascii字符的對應表 10 char* pMaps = (char*)malloc(sizeof(char) * n * 127); 11 // 初始化n張對應表 12 for (int i = 0; i < n; ++i) { 13 char* p = pMaps + i * 127; 14 for (int j = 0; j < 127; ++j) { 15 *(p + j) = j; 16 } 17 } 18 // 設置隱私表 19 size_t vecSize = vecMaps.size(); 20 for(size_t i = 0; i < vecSize; ++i){ 21 char* p = pMaps + i * 127; 22 std::map<char, char>::iterator itEnd = vecMaps[i].end(); 23 for (std::map<char, char>::iterator it = vecMaps[i].begin(); it != itEnd; ++it) { 24 *(p + it->first) = it->second; 25 } 26 } 27 // 獲取對應值,index-第幾個版本,c字符的對應值。*(pMaps+(index*127)+c) 28 ch = *(pMaps+(0*127)+'a');
改為用char數組的形式來處理后,需要分配n*127的空間。在空間上其實也未必會比STL實現方式的多多少,因為vector和map對象本身也需要占用一定的空間。但是在效率上,就可以提升很多,而且對應表范圍擴展到ascii所有字符,也很方便。
如果只是針對數字字符的對應關系,可以這個基礎上修改一下,只分配n*10的空間,記錄一個beginChar='0',獲取對應值的方式:
*(pMaps+(index*127)+c-beginChar)
3.5 函數按“完美”數據實現處理邏輯
當需要實現一個功能時,可以先實現功能再談優化。在程序中需要實現一個功能,把字符串中的全角字符去掉,看一下原實現邏輯:
1 //去除全角字符 2 map<size_t, string> delFullChars(string &value) { 3 map<size_t, string> delChars; 4 size_t size(value.size()), i(0), len(0), wordCount(0); 5 string rmChars, curChar; 6 for (i = 0; i < size; ++wordCount) { 7 unsigned char byte = (unsigned) value[i]; 8 len = getUtfCharLen(byte); // 獲取當前字符的字節個數 9 curChar.assign(value, i, len); // 獲取當前字符 10 if (isFullChar(curChar)) { // 判斷字符是否為全角字符 11 delChars[wordCount] = curChar; 12 } else { 13 rmChars += curChar; // 不是全角字符,追加到目標字符串 14 } 15 i += len; 16 } 17 value.assign(rmChars); // 設置最終結果字符串 18 return delChars; 19 }
實際中,在我們程序中的處理的數據,字符串中存在全角字符的情況比較少。所以在優化的時候,就把輸入的字符串設定為“完美數據”(即不需要任何處理的數據),在處理過程中,只做一些必要的判斷即可處理該數據。然后再考慮特殊情況(存在全角字符),把一下操作步驟都盡可能延遲。
優化后:
1 //去除全角字符 2 map<size_t, string> delFullChars(string &value) { 3 map<size_t, string> delChars; 4 ////////////////////////////////////////////////////////////////////////// 5 // 考慮到實際情況中,傳入的字符串很少會包含全角字符。所以在遇到全角字符時,再做一些必要的操作。 6 size_t size(value.size()); 7 size_t wordCount(0); 8 size_t len(0); 9 10 const char* pValue = value.c_str(); 11 std::string* pCurChar = NULL; 12 const char* pCurCharPtr = NULL; 13 14 for (size_t i = 0; i < size; ++wordCount) { 15 if (!(*pValue & 0x80)) { 16 // 單字節字符 17 ++i; 18 ++pValue; 19 } else { 20 len = getUtfCharLen((unsigned char)*pValue); 21 if (len == 3 && i + len <= size && likeFullCharFirstByte(*pValue)) { 22 // 只有當字符的字節數為3時,並且是全角字符開頭的字節,才去判斷是否為全角字符 23 if (pCurChar == NULL) { 24 // 當存在可能是全角字符串時,才分配相關string對象 25 pCurChar = (std::string*)new std::string(4, 0); // 分配4字節長度的string對象 std::string(size_type n,char c) 26 pCurCharPtr = pCurChar->c_str(); 27 } 28 memcpy((void*)pCurCharPtr, pValue, len); 29 if (isFullChar(*pCurChar)) { 30 // 找到一個需要保留的全角字符 31 // delChars[wordCount] = *pCurChar; // 這樣會有問題,由於string的cow機制,delChars內的值string對象都執行同一個地址 32 delChars[wordCount] = pCurCharPtr; // *pCurChar的內存會被直接修改,所以不能把string對象傳給map 33 34 value.erase(i, len); 35 size = value.size(); 36 pValue = value.c_str() + i; 37 continue; // 跳到下一個循環 38 } 39 } 40 i += len; 41 pValue += len; 42 } 43 } 44 45 if (pCurChar != NULL) { 46 delete pCurChar; 47 } 48 49 return delChars; 50 }
優化點:
- 先判斷當前字符是否為單字節字符,用一個與、非位操作即可實現;減少getUtfCharLen()的調用
- 判斷字符個數等於3時,並且當前字節為全角字符開頭的字節
- 延遲實現string對象,並使用memcpy來直接獲取全角字符
優化后的函數,針對字母數字字符串,“完美數據”的情況下效率有很大的提升;針對中文字符串,需要多執行getUtfCharLen()函數。
優化后的函數,在處理極端的字符串時,效率可能反而比較低,原因是出現在原字符串刪除全角字符這一個步驟中(value.erase(i, len);)。當字符串很大時,頻繁去erase()是需要多次內存拷貝,效率上反而沒有字符串拼接方式的高。
4. 總結
上面介紹到的優化點,都是自己在實踐中遇到並着手去優化的地方。雖然不能保證這樣優化后效率就一定提高,就像我上面所說的,具體還是要根據實際情況去考慮如何優化。我這里只是指出一個可以優化的地方,和自己優化的方案。在優化過程中也不可能一步優化到位,就像我在優化“多版本對應表”那里,也是反復優化了3次。
在這里總結一下最近優化的收獲,把一些優化點,做一個記錄。