C++性能優化筆記


  最近着手去優化項目中一個模塊的性能。該模塊是用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次。

  在這里總結一下最近優化的收獲,把一些優化點,做一個記錄。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM