實現鍵值對儲存(五):雜湊表實現

熊鐸發表於2014-12-12

在本文中,我將會研究C++中雜湊表的實際實現以理解其瓶頸。雜湊函式是CPU密集型的並且應該維持而優化。然而,大部分雜湊表的內部機制只關心記憶體效率和I/O訪問,這將是本文主要注意的東西。我將會研究三個不同的雜湊表的C++實現,既有記憶體中的又有硬碟上的,並看看資料是怎麼組織和訪問的。本文將包括:

1.雜湊表
1.1 雜湊表簡介
1.2 雜湊函式
2.實現
2.1 TR1的unordered_map
2.2 SparseHash的dense_hash_map
2.3 Kyoto Cabinet的HashDB
3.結論
4.參考文獻

 

1.雜湊表

1.1 雜湊表簡介

雜湊表可以認為是人類所知最為重要的資料結構 .
— 斯蒂夫 耶奇

雜湊表可以高效的訪問關聯資料。每個條目都有一對對應的鍵名鍵值,並且能僅通過鍵名來快速的取回和賦值。為了達到這個目的,鍵名通過雜湊函式進行雜湊,以將鍵名從原始形式轉換為整數。此整數之後作為索引來得到要訪問的條目的值所在的bucket在bucket陣列中的地址。很多鍵名可以被雜湊為相同的值,這表示這些key在bucket陣列中回出現碰撞。有數種方法解決碰撞,如使用連結串列的分離連結串列separate chaining 亦稱開鏈單獨連結串列)或自平衡二叉樹或線性或者二次探測的開放定址

從現在開始,我預設你知道什麼是雜湊表。如果你認為自己需要溫習一下知識,Wikipedia的“Hash table”詞條[1](及其底部的擴充套件連結一節)和Cormen 等人寫的Introduction to Algorithms一書中Hash table一章[2]都是很好的參考文獻。

1.2 雜湊函式

雜湊函式的選擇相當重要。一個好雜湊函式的基本需求是輸出的雜湊值比較均勻。這樣可以使碰撞的發生最小化,同時使得各個bucket中碰撞的條目比較平均。

可用的雜湊函式有很多,除非你確切的知道資料會變成什麼樣子,最安全的方法是找一個能夠將隨機資料分佈均勻的雜湊函式,如果可能的話符合雪崩效應[3]。有少數人對雜湊函式做過比較[4] [5] [6] [7],而他們的結論是MurmurHash3 [8]和CityHash [9] 是在寫本文的時候最好的雜湊函式。

2.實現

和雜湊函式的比較一樣,只有很少比較各個C++的記憶體雜湊表庫效能的博文。我見到的最出名的是Nick Welch 的“Hash Table Benchmarks” [10],和Jeff Preshing 的“Hash Table Performance Tests” [11]。而其他文章也值得一看[12] [13] [14]。從這些比較中,我發現兩個研究起來比較有意思的部分:GCC的TR1的unordered_map和SparseHash 庫(以前叫Google SparseHash)的dense_hash_map,我將會在下文中介紹他們。另外,我同樣會描述Kyoto Cabinet中HashDB的資料結構。顯然因為unordered_map 和dense_hash_map是記憶體雜湊表,不會像HashDB那樣和我的鍵值對儲存相關。儘管如此,稍微看一下其內部資料結構的組織和其記憶體模式也是很有意思的。

在下述三個雜湊表庫的描述中,我的通用示例是把一組城市名作為鍵名其各自的GPS座標作為鍵值。unordered_map的原始碼可以在GCC程式碼中作為libstdc++-v3的一部分找到。我將會著眼於GCC v4.8.0的libstdc++-v3 release 6.0.18[15],SparseHash v2.0.2中的dense_hash_map[16],和Kyoto Cabinet v1.2.76中的HashDB[17]

Matthew Austern的“A Proposal to Add Hash Tables to the Standard Library (revision 4)”一文[18]和SparseHash的“Implementation notes”頁面[19]也有很有意思的關於雜湊表實現的討論。

2.1 TR1中的unordered_map

TR1的unordered_map提供了一個用連結串列(分離鏈)解決碰撞的雜湊表。Bucket陣列位於堆中,並且基於雜湊表的負載係數自動調整大小。而bucket的連結串列則是用叫做_Hash_node的節點結構體建立。

如果鍵和值都是整型,其可以直接儲存在_M_v結構體中。否則將會儲存指標,同時需要額外的記憶體。Bucket陣列是在堆中一次性分配的,但並不分配節點的空間,節點的空間是通過各自呼叫C++記憶體分配器來分配的。

因為這些節點是各自分配的,分配過程中可能浪費大量的記憶體。這取決於編譯器和作業系統使用的記憶體分配過程。我甚至還沒說每次分配中系統執行的呼叫。SGI雜湊表的原始實現為這些節點做了一些資源預分配工作,但這個方法沒有保留在TR1的 unordered_map實現中。

下文的圖5.1展示了TR1中unordered_map的記憶體和訪問模式。讓我們來看看當我們訪問和鍵名“Johannesburg”相關的GPS座標的時候會發生什麼。這個鍵名被雜湊並對映到了bucket #0。在那我們跳到了此bucket的連結串列的第一個節點(bucket #0左邊的橙色箭頭),我們可以訪問堆中儲存了鍵“Johannesburg”所屬資料的記憶體區域(節點右側的黑色箭頭)。如果鍵名所指向的第一個節點不可用,就必須遍歷其他的節點來訪問。

至於CPU效能,不能指望所有的資料都在處理器的同一個快取行中。實際上,基於bucket陣列的大小,初始bucket和初始節點不會在同一個快取行中,而和節點相關的外部資料同樣不太可能在同一個快取行中。而隨後的節點機器相關資料同樣不會在同一個快取行中並且需要從RAM中取回。如果你不熟悉CPU優化和快取行,維基上的“CPU Cache”文章是一個很好的介紹[20]

圖5.1

2.2 SparseHash的dense_hash_map

SparseHash庫提供了兩個雜湊表實現,sparse_hash_map和dense_hash_map。sparse_hash_map在低成本下提供了出色的記憶體佔用,並使用一個特定的資料結構sparsetable來打到這個目的。在SparseHash的“Implementation notes”頁面19可以找到更多關於sparsetables 和sparse_hash_map的資訊。在此我只討論dense_hash_map。

dense_hash_map用二次內部探測處理碰撞。和unordered_map一樣,bucket陣列也是在堆中一次分配,並基於雜湊表的負載因子調整大小。bucket陣列的元素是std::pair的例項,其中KeyT分別是鍵名和鍵值的模版引數。在64位架構下儲存字串的時候,pair的例項大小是16位元組。

下文的圖5.2是dense_hash_map記憶體和訪問模式的展示。如果我們要尋找“Johannesburg”的座標,我們一開始會進入bucket #0,其中有“Paris”(譯註:圖上實際應為“Dubai”)的資料(bucket #0右側的黑色箭頭)。因此必須探測然後跳轉到bucket (i + 1) = (0 + 1) = 1(bucket #0左側的橙色箭頭),然後就能在bucket #1中找到“Johannesburg”的資料。這看上去和unordered_map中做的事情差不多,但其實完全不同。當然,和unordered_map一樣,鍵名和鍵值都必須儲存在分配於堆中的記憶體,這將導致對鍵名和鍵值的尋找會使快取行無效化。但為碰撞的條目尋找一個bucket相對較快一些。實際上既然每個pair都是16位元組而大多數處理器上的快取行都是64位元組,每次探測就像是在同一個快取行上。這將急劇提高運算速度,與之相反的是unordered_map中的連結串列需要在RAM中跳轉以尋找餘下的節點。

二次內部探測提供的快取行優化使得dense_hash_map成為所有記憶體雜湊效能測試中的贏家(至少是在我目前讀過的這些中)。你應該花點時間來看看Nick Welch的文章“Hash Table Benchmarks” [10]

圖5.2

2.3 Kyoto Cabinet的HashDB

Kyoto Cabinet實現了很多資料結構,其中就有雜湊表。這個雜湊表HashDB雖然有一個選項可以用來把他用作代替std::map的記憶體雜湊表,但其是設計用於在硬碟上持久化的。雜湊表的後設資料和使用者資料一起用檔案系統依次儲存在硬碟上唯一的檔案中。
Kyoto Cabinet使用每個bucket中獨立的二叉樹處理碰撞。Bucket陣列長度固定且不改變大小,無視負載因子的狀態。這是Kyoto Cabinet的雜湊表實現的主要缺陷。實際上,如果資料庫建立的時候定義的bucket陣列的長度低於實際需求,當條目開始碰撞的時候效能會急劇下降。

允許硬碟上的雜湊表實現改變bucket陣列大小是很難的。首先,其需要bucket陣列和條目儲存到兩個不同的檔案中,其大小會各自獨立的增長。第二,因為調整bucket陣列大小需要將鍵名重新雜湊到新bucket陣列的新位置,這需要從硬碟中讀取所有條目的鍵名,這對於相當大的資料庫來說代價太高以至於幾乎不可能。避免這種重新雜湊過程的一種方法是,儲存雜湊後鍵名的時候每個條目預留4或8個位元組(取決於雜湊是長度32還是64 bit)。因為這些麻煩事,固定長度的bucket陣列更簡單,而Kyoto Cabinet中採用了這個方法。

圖5.3顯示出檔案中儲存的一個HashDB的結構。我是從calc_meta()方法的程式碼,和kchashdb.h尾部HashDB類中屬性的註釋中得到的這個內部結構。此檔案以如下幾個部分組織:

  • 頭部有資料庫所有的後設資料
  • 包含資料區域中可用空間的空塊池
  • bucket陣列
  • 記錄(資料區域)

一條記錄包含一個條目(鍵值對),以及此獨立鏈的二叉樹節點。這裡是Record結構體:

圖5.4可以看到記錄在硬碟上的組織。我從kchashdb.h中的write_record()方法中得到組織方法。注意其和Record結構體不同:儲存在硬碟上的目標是最小化硬碟佔用,然而結構體的目標是使記錄在程式設計的時候用起來比較方便。圖5.4的所有變數都有固定長度,除了keyvalue、 和padding,其當然是取決於資料條目中資料的尺寸。變數left 和right是二叉樹節點的一部分,儲存檔案中其他記錄的offset。

圖5.3

圖5.4如果我們要訪問鍵名”Paris”的鍵值,一開始要獲得相關bucket的初始記錄,在本例中是bucket #0.。然後跳轉到此bucket二叉樹的頭節點(bucket #0右側的橙色箭頭),其儲存鍵名為”Johannesburg”.的資料。鍵名為”Paris”的資料需要通過當前節點的右側節點來訪問(”Johannesburg”記錄右側的黑色箭頭)。二叉樹需要一個可比較的型別來對節點分類。這裡用的可比較型別是用fold_hash()方法將雜湊過的鍵名縮減得到的。

把資料條目和節點一起儲存在單一記錄中,乍一看像是設計失誤,但其實是相當聰明的。為了儲存一個條目的資料,總是需要保持三種不同的資料:bucket、碰撞和條目。既然bucket陣列中的bucket必須順序儲存,其需要就這樣儲存並且沒有任何該進的方法。假設我們儲存的不是整型而是不能儲存在bucket中的字元或可變長度位元組陣列,這使其必須訪問此bucket陣列區域之外的其他記憶體。這樣當新增一個新條目的時候,需要即儲存衝突資料結構的資料,又要儲存該條目鍵名和鍵值的資料。

如果衝突和條目資料分開儲存,其需要訪問硬碟兩次,再加上必須的對bucket的訪問。如果要設定新值,其需要總計3次寫入,並且寫入的位置可能相差很遠。這表示是在硬碟上的隨機寫入,這差不多是I/O的最糟糕的情況了。現在既然Kyoto Cabinet的HashDB中節點資料和條目資料儲存在一起,其就可以只用一次寫入寫到硬碟中。當然,仍然必須訪問bucket,但如果bucket陣列足夠小,就可以通過作業系統將其從硬碟中快取到RAM中。如規範中”Effective Implementation of Hash Database”一節[17]宣告的,Kyoto Cabinet可能採用這種方式。

然而在硬碟上用二叉樹儲存條目需要注意的一點是,其會降低讀取速度,至少當碰撞出現的時候會是這樣。實際上,因為節點和條目儲存在一起,處理一個bucket中的碰撞實際上是在一個二叉樹中尋找要找的條目,這可能需要大量的對硬碟的隨機讀取。這可以讓我們理解當條目的數量超過bucket數量時Kyoto Cabinet的效能急劇下降的原因。

最後,因為所有的東西都是存在檔案中,Kyoto Cabinet是自己處理記憶體管理,而非像unordered_map 和dense_hash_map那樣交給作業系統處理。FreeBlock結構體儲存著和檔案中空閒空間的資訊,其基本上是offset和大小,如下:

所有的FreeBlock例項都載入在std::set中,其可以像fetch_free_block()方法中那樣使用std::set的upper_bound()方法來釋放記憶體塊,從而實現“最佳擬合”的記憶體分配策略。當可用空間顯得過分細碎或者FreeBlock池中沒有空間了,檔案將進行碎片整理。此碎片整理過程通過移動各條記錄來減少資料庫檔案的整體大小。

3.結論

本文中,我展示了三個不同雜湊表庫的資料組織和記憶體訪問模式。TR1的unordered_map和SparseHash的dense_hash_map是在記憶體中的,而Kyoto Cabinet的HashDB是在硬碟上的。此三者用不同的訪問處理碰撞,並對效能有不同的英雄。將bucket資料、碰撞資料和條目資料各自分開將影響效能,這是unordered_map中出現的情況。如dense_hash_map及其二次內部探測那樣,將碰撞資料和bucket儲存在一起;或者像HashDB那樣,將碰撞資料和條目資料儲存在一起都可以大幅提高速度。此兩者都可以提高寫入速度,但將碰撞資料和bucket儲存在一起可以使讀取更快。

如果讓我說從這些雜湊表中學到的最重要的東西是什麼的話,我會說在設計雜湊表的資料組織的時候,首選的解決方案是將碰撞資料和bucket資料儲存在一起。因為即便是在硬碟上,bucket陣列和碰撞資料也會相當小,足夠它們儲存在RAM中,隨機讀取的花費將會比在硬碟上低得多。

4.參考文獻

[1] http://en.wikipedia.org/wiki/Hash_table
[2] http://www.amazon.com/Introduction-Algorithms-Thomas-H-Cormen/dp/0262033844/
[3] http://en.wikipedia.org/wiki/Avalanche_effect
[4] http://blog.reverberate.org/2012/01/state-of-hash-functions-2012.html
[5] http://www.strchr.com/hash_functions
[6] http://programmers.stackexchange.com/questions/49550/which-hashing-algorithm-is-best-for-uniqueness-and-speed/145633#145633
[7] http://blog.aggregateknowledge.com/2012/02/02/choosing-a-good-hash-function-part-3/
[8] https://sites.google.com/site/murmurhash/
[9] http://google-opensource.blogspot.fr/2011/04/introducing-cityhash.html
[10] http://incise.org/hash-table-benchmarks.html
[11] http://preshing.com/20110603/hash-table-performance-tests
[12] http://attractivechaos.wordpress.com/2008/08/28/comparison-of-hash-table-libraries/
[13] http://attractivechaos.wordpress.com/2008/10/07/another-look-at-my-old-benchmark/
[14] http://blog.aggregateknowledge.com/2011/11/27/big-memory-part-3-5-google-sparsehash/
[15] http://gcc.gnu.org/
[16] https://code.google.com/p/sparsehash/
[17] http://fallabs.com/kyotocabinet/spex.html
[18] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2003/n1456.html
[1\9] http://sparsehash.googlecode.com/svn/trunk/doc/implementation.html
[20] http://en.wikipedia.org/wiki/CPU_cache

相關文章