STLPort 哈希表 hash_map/hash_multimap 刪除速度慢

KeyWords: STLPort hash_map hash_multimap erase操作慢

本文使用的STL版本是STLPort5.2.1的最新release版本,配合G++4.5版本使用。我在項目中發現一個性能的瓶頸,最終定位到的原因是使用STLPort的hash_multimap(C++11之後哈希表改爲unordered_map和unordered_multimap)中erase函數消耗時間長導致的。STLPort目前已經基本不怎麼更新了,C++新標準C++11/14/17基本是不支持了。對比了STL的其他版本,G++4.5自帶的版本和G++7.2自帶的版本發現,STLPort的erase操作和G++自帶的STL unordered_multimap::erase 有200倍的執行時間的差距。

測試的方法是使用了20W節點的哈希表,然後在循環中遍歷刪除每一個節點。G++自帶STL庫,開啓了O3優化,在i7 6700K的平臺上面執行時間大約只有0.02S,而STLPort的執行時間有4S之多。

說個題外話,如果使用STLPort5.2.1版本的哈希表的話,最好自己打一個patch。這個版本的哈希表存在一個BUG,在刪除節點時,會減少哈希表的桶的數量,有些情況會rehash,造成迭代器的失效。在循環中刪除哈希表的迭代器節點,有可能得到和預期不一致的結果。BUG的地址:https://sourceforge.net/p/stlport/bugs/148/

STLPort hash_multimap數據結構

STLPort哈希表底層使用的數據結構是slist(C++11中爲forward_list),是一種單向鏈表的結構。所有哈希表的節點都保存在這個鏈式的結構上,哈希表的桶使用vector<_Slist_node_base*> _BucketVector來表示,桶的每一個節點保存了slist類型的基類指針。該指針指向了桶內第一個元素在slist中的位置。所以這個桶是一個邏輯的概念,只是一個類似於遊標或者迭代器的東西,並沒有在原始的數據結構中單獨拿出一條鏈表來保存桶。獲取桶內元素(假設第n個桶)的方式就是使用_BucketVector[n],_BucketVector[n + 1]確定slist中桶內元素的首尾指針,這兩個指針類似於begin(),end()迭代器的概念。然後遍歷該區間就可以找到桶內的所有元素。下圖是一個哈希表的示意圖。

hashtable.png

 

通過Key來查找的過程,首先需要將Key轉換爲桶的索引,知道數據位於哪個桶內。轉換的過程是hash(Key)%bucket_count(),先用哈希函數算出Key的哈希值,然後模上桶的數量得到桶的索引(桶的數量一般是一個素數,在STLPort中也保存了一張素數表來決定桶需要分配的大小)。得到桶的索引後,就執行上面一段講到的內容,通過首尾指針遍歷桶內元素,然後比較桶內元素的Key和查找元素的Key是否相等,返回第一個相等的元素。

hash_map和hash_multimap其實並沒有太多區別,都是對STLPort底層數據結構hashtable的封裝,區別只是插入元素時,使用insert_unique函數還是insert_equal函數而已。

STLPort hash_multimap erase操作

template <class _Val, class _Key, class _HF,
  class _Traits, class _ExK, class _EqK, class _All>
void hashtable<_Val,_Key,_HF,_Traits,_ExK,_EqK,_All>
  ::erase(const_iterator __it) {
  const size_type __n = _M_bkt_num(*__it);//通過迭代器來獲取桶的索引
  _ElemsIte __cur(_M_buckets[__n]);//cur節點指向桶__n的第一個元素

  size_type __erased = 0; //刪除節點的個數
  if (__cur == __it._M_ite) { //如果刪除的節點是桶內第一個元素
size_type __prev_b = __n;
_ElemsIte __prev = _M_before_begin(__prev_b)._M_ite; //獲取__n桶的前一個節點,__prev_b返回第一個和__n桶指向同一節點的索引,這裏具體後面解釋
fill(_M_buckets.begin() + __prev_b, _M_buckets.begin() + __n + 1,
 _M_elems.erase_after(__prev)._M_node);//將該節點刪除,並且將所有指向該節點的桶,更新指向的節點。後面具體解釋
++__erased;//增加刪除節點計數
  }
  else { //刪除的節點不是桶內第一個元素
/* 遍歷__n桶,刪除與入參__it相同的節點,這部分比較好理解,也沒什麼毛病 */
_ElemsIte __prev = __cur++;
_ElemsIte __last(_M_buckets[__n + 1]);
for (; __cur != __last; ++__prev, ++__cur) {
  if (__cur == __it._M_ite) {
_M_elems.erase_after(__prev);
++__erased;
break;
  }
}
  }

  _M_num_elements -= __erased;      //哈希節點計數更新
}

上面是STLPort的erase迭代器的過程,有兩個分支,刪除節點如果不是桶的首節點的話比較容易,直接找到要刪除的節點刪掉就好了。複雜的是要刪除的節點是桶的首節點,需要找到該節點的前一個節點,並且要更新桶的首指針的指向,具體內容已經在註釋中寫出了。經過分析和加入調試信息打印,耗時的根源在查找待刪除節點前一個節點這個函數上,_M_before_begin。

讓我們一起看一下這個函數的代碼。

template <class _Val, class _Key, class _HF,
  class _Traits, class _ExK, class _EqK, class _All>
__iterator__
hashtable<_Val,_Key,_HF,_Traits,_ExK,_EqK,_All>
  ::_M_before_begin(size_type &__n) const {
  return _S_before_begin(_M_elems, _M_buckets, __n); //實際的執行函數是_S_before_begin
}  

template <class _Val, class _Key, class _HF,
  class _Traits, class _ExK, class _EqK, class _All>
__iterator__
hashtable<_Val,_Key,_HF,_Traits,_ExK,_EqK,_All>
  ::_S_before_begin(const _ElemsCont& __elems, const _BucketVector& __buckets,
size_type &__n) {
  _ElemsCont &__mutable_elems = __CONST_CAST(_ElemsCont&, __elems);//哈希的slist鏈表去除const標記
  typename _BucketVector::const_iterator __bpos(__buckets.begin() + __n);//指向__n桶的迭代器,這個迭代器是vector存儲的桶的迭代器

  _ElemsIte __pos(*__bpos);//__n桶首元素指向的slist節點的迭代器,該迭代器是指向哈希表slist鏈表的
  if (__pos == __mutable_elems.begin()) {//該函數就是如果刪除的元素是整個slist的首節點,就把slist的首節點之前的虛擬節點返回。因爲slist是單鏈表,只有erase_after操作,如果要刪除鏈表節點,必須找到前面的元素。
__n = 0;
return __mutable_elems.before_begin();
  }

  typename _BucketVector::const_iterator __bcur(__bpos);//__bcur指向__n桶的迭代器。
  _BucketType *__pos_node = __pos._M_node; //__pos_node是指向slist中__n桶首元素的指針。該類型和桶內存儲的指針是同一類型。
  for (--__bcur; __pos_node == *__bcur; --__bcur);//向前遍歷桶,找到第一個和桶指向不同元素的節點。爲什麼這樣做,下面講。這裏是耗時的根源

  __n = __bcur - __buckets.begin() + 1;//更新入參__n的位置,返回與__n桶指向同一slist節點的第一個桶的索引。
  _ElemsIte __cur(*__bcur);//後面的過程就是從找到的前一個桶的指針開始往回遍歷,找到待刪除節點的前一個節點。
  _ElemsIte __prev = __cur++;
  for (; __cur != __pos; ++__prev, ++__cur);
  return __prev;
}  

上面這部分代碼主要是找到待刪除節點的前一個節點,但是爲什麼這麼複雜,而且在我的項目中執行這麼慢呢?最後在for (--__bcur; __pos_node == *__bcur; --__bcur);這一行代碼中加了一個計數發現,在哈希節點20W時,循環刪除迭代器,在刪除到後面的節點時,該循環每次刪除要執行5W次以上。最後考慮了一下發現這樣做的原因是,因爲有可能臨近的很多桶中沒有任何元素,所以這些臨近的桶都指向了一個節點。如下圖所示,是一種多個桶中沒有元素指向同一節點的狀態。

查找before.png

 

上圖中,1-39999桶都沒有元素,如果本次刪除第40000桶的節點時,for (--__bcur; __pos_node == *__bcur; --__bcur);該循環__bcur節點要循環40000次左右查找,才能找到桶0的位置,也就是第一個與40000桶指向不同元素的桶。所以這種情況下再使用erase操作,性能會極具下降。

爲什麼要找到桶1並且返回呢?因爲如果將__pos所指向的slist節點刪除掉了,1--40000桶的指針全部都失效了,所以要找到第一個和40000桶相同指向的桶,然後把1號索引也返回。
fill(_M_buckets.begin() + __prev_b, _M_buckets.begin() + __n + 1,_M_elems.erase_after(__prev)._M_node);

這句代碼就是把1號到40000號桶的指向全部更新爲__pos的下一個節點。

總結

STLPort的哈希表設計決定了如果哈希表過大時,而且存在很多空桶的情況下,刪除效率會下降明顯。因爲每個桶並不是單獨一條鏈表,而且通過一個索引共享一條鏈表,如果空桶過多時,會有很多桶指向同一節點,將這個節點刪除時,需要將所有指向該節點的桶全部更新。由於歷史以及和其他組件結合等原因,在我的項目中,更換STL庫或者更改STL代碼幾乎不太可能了。明白了效率低的具體原因之後,我們將可以規避掉這個做法,不要在循環中頻繁遍歷刪除節點,導致空桶過多。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章