Cuckoo Hashing的应用及性能优化
written by 欽誠,禎祺
Cuckoo Hashing 的引入
在indexlib的底層,需要一個高性能的Key/Value引擎來提供類似python Dict/ Java HashMap/ C++ unordered map(這些數(shù)據(jù)結(jié)構(gòu)的性能不能接受)的支持。最基本的就是支持insert(key, value)和find(key)這兩種操作。原本我們有Dense Hash Table和Chain Hash Table這兩種數(shù)據(jù)結(jié)構(gòu),但是它們各自都有明顯的缺點。
Chain Hash Table是利用拉鏈法解決沖突的哈希表,它的特點是空間利用率比較高,除了順序存下所有(k, v)對之外僅需要一個索引來記錄鏈表頭,但由于鏈表的空間不連續(xù),導(dǎo)致查詢性能一般。
- 在我們實現(xiàn)的版本中,圖中的A,H,P,J,B,M,Q,D,W存在一塊連續(xù)的內(nèi)存區(qū)域中,但它們的順序不一定,我們將其稱作鏈表區(qū)域。
- 一種較簡單的優(yōu)化是在全部插入操作完成之后,重新整理鏈表區(qū)域,把同一條鏈上的結(jié)點歸到連續(xù)的區(qū)域,即鏈表空間中嚴(yán)格按照A, H, P, J, B, M, Q, D, W的順序存儲。這樣,查詢時若要遍歷鏈表,不需要“跳”著訪問,而是訪問連續(xù)的地址。
- 但這個步驟本身花費(fèi)不小(可能的額外空間、時間),并且由于索引區(qū)域和鏈表區(qū)域仍不連續(xù),需要兩次訪存,其性能仍然存疑。
Cuckoo Hash Table是本次引入的數(shù)據(jù)結(jié)構(gòu),它用了兩個哈希函數(shù)來解決沖突。Cuckoo查詢操作的理論復(fù)雜度為最差O(1),優(yōu)于Dense的期望查詢復(fù)雜度O(1)和Chain的O(1+α),而Cuckoo的插入復(fù)雜度為均攤O(1)。我們引入Cuckoo是希望它在實際應(yīng)用中,能夠在較高的空間利用率下,仍然維持不錯的查詢性能。
- Cuckoo利用兩個哈希函數(shù)來實現(xiàn)最差O(1)的查詢復(fù)雜度:
對任意一個Key可求出兩個哈希值,相當(dāng)于映射到兩個桶,如A被映射到1,4,說明它可以被存到1號或4號桶,而實際在1號桶。本圖中,用箭頭連接某個Key實際存入的桶和另一個對應(yīng)的桶,如1--->4。 - 當(dāng)插入一個F時,如果對應(yīng)的兩個桶至少有一個為空,則將其插入到這個位置,否則任選一個桶,不妨設(shè)為A所在的1號桶。我們將其中原有的A = Key’/Value’踢出,將新的F存入。對于剛?cè)〕龅腁,它之所以存儲在1號桶是因為用了兩個哈希函數(shù)之一,那么我們用另外一個哈希函數(shù),知道A對應(yīng)的位置還有4號桶。若4號桶為空,則將A放入,整個過程就結(jié)束了。而事實上,4號桶中還存有B = Key’’/Value’’,那么把它們踢出并重復(fù)上面的操作。整個過程中進(jìn)行踢出、填入操作的,形如“1->4-> … ->空位”這樣的序列我們將其稱為Cuckoo Kick路徑。
- 當(dāng)查詢一個F時,分別檢查它的的哈希值對應(yīng)的兩個位置即可。
我們的Cuckoo哈希表的特性
使用4路組相聯(lián)
不進(jìn)行組相聯(lián)的Cuckoo,只能達(dá)到50%左右的容積率,就會開始出現(xiàn)插不進(jìn)去新的(k, v)對的情況。而如果用組相聯(lián),則可以大幅提高容積率。組相聯(lián)即上圖中的一個位置,變?yōu)楝F(xiàn)在的一組位置,它們對應(yīng)同一個哈希值。我們使用的4路組相聯(lián),可以在性能無明顯下降時達(dá)到超過90%的容積率。如果使用k路的組相聯(lián),那么一次查詢就意味著最多要查2k個桶。因此如果使用更多的路數(shù)進(jìn)行組相聯(lián),雖然可以進(jìn)一步提高容積率,但同時會導(dǎo)致每一次查詢操作時掃描過多的桶,導(dǎo)致讀性能下降,不能接受。我們常見的使用場景是8bit key + 8bit value和 8bit key + 4bit value。對于第一種情況,4路組相聯(lián)可以保證每次查的一個block在同一個cache line中,而即使是第二種,也最多跨兩個cache line。相當(dāng)于利用cache line內(nèi)的訪問,來減少組相聯(lián)對讀性能的影響。
使用BFS尋找Cuckoo Kick路徑
我們遇到?jīng)_突時,先不急著將Key/Value踢出,而是使用廣度優(yōu)先搜索,從A和B分別開始找一條完整的Cuckoo Kick路徑,每一步都將組相聯(lián)的4個桶一起加入BFS隊列,這樣可以保證找到的路徑是最短的一條。找到之后,先把路徑上最后一個Key/Value移到空位,形成新的空位,再把倒數(shù)第二個Key/Value移到空位,以此類推。支持增加哈希函數(shù):
在容積率達(dá)到較高(如90%)以后,如果希望再插入新的(k, v)對,那么插入過程中BFS尋找的Cuckoo Kick路徑將會越來越長(見性能測試部分),插入性能將會快速下降。因此,我們在插入失敗時,選擇添加一個哈希函數(shù)(從2個變?yōu)?個,再不行變?yōu)?個,5個…,這意味著每個key對應(yīng)的block從2個變成更多),讓BFS搜索樹從二叉樹變成多叉樹,使得找到一條Cuckoo Kick路徑更加容易,在損失一些讀性能的前提下,提高了容積率。名詞定義
- 我們的哈希表將key, value對完整地存下來,簡稱為(k, v)對
- 將哈希表中用來存儲(k, v)對的位置稱為桶(bucket),因此表體就是桶的數(shù)組
- 容積率(occupancy) = 已存有(k, v)對的桶數(shù)/總桶數(shù),相當(dāng)于空間利用率/load factor
- insert/插入/寫,表示將一個(k, v)對插入哈希表
- find/查詢/讀/lookup,表示在哈希表中查詢key對應(yīng)的value
- 命中/查詢成功/positive lookup,表示find函數(shù)在哈希表中成功找到了所查的key
- 不命中/查不到/negative lookup,表示find函數(shù)發(fā)現(xiàn)key不在哈希表中
- QPS(query per second或reqs per second) = 平均每秒完成的insert或find操作的次數(shù)
- Cuckoo的組相聯(lián)意味著每4個桶對應(yīng)同一個哈希值,這4個桶稱為一個塊(block)
- 一張Dense哈希表包括:表頭(header)+ 表體(bucket的數(shù)組)
- 一張Cuckoo哈希表包括:表頭(header)+ 表體(block的數(shù)組)+ 輔助數(shù)據(jù)結(jié)構(gòu)(臨時,僅插入時使用,不需要存下來)
參考資料
- Cuckoo Hashing for Undergraduates, Rasmus Pagh
- MemC3: Compact and Concurrent MemCache with Dumber Caching and Smarter Hashing, NSDI’13
- Algorithmic Improvements for Fast Concurrent Cuckoo Hashing, eurosys’14
- https://brilliant.org/wiki/cuckoo-filter/
- https://en.wikipedia.org/wiki/Cuckoo_hashing
- http://www.cs.rmit.edu.au/online/blackboard/chapter/05/documents/contribute/chapter/05/linear-probing.html
Cuckoo Hashing性能測試
容積率
經(jīng)過測試,在桶數(shù)較多的前提下(>10000),使用純隨機(jī)數(shù)據(jù)并且不使用增加哈希函數(shù)的功能,在插入性能明顯下降之前可以達(dá)到92%左右的容積率,不考慮性能的因素,在第一次有key插入失敗之前可以達(dá)到98%的容積率。
使用增加哈希函數(shù)的功能一起測試,一般可以達(dá)到100%的容積率,在1000000個桶的哈希表上進(jìn)行容積率測試,結(jié)果如下表:
| 可達(dá)到的容積率 | 98.02% | 99.92% | >99.995% | >99.995% | 100% |
插入性能
由于Cuckoo Hash Table的特性,在容積率較低時,一次插入掃描的桶數(shù)、代碼邏輯的復(fù)雜程度都高于Dense,導(dǎo)致只能獲得2000000左右的QPS。而隨著容積率的上升,越來越多的插入操作需要通過找Cuckoo Kick路徑來獲得一個空桶,而這個路徑的長度也相應(yīng)增長,其占用的時間、空間資源大幅上升。因此,整體的插入性能應(yīng)該是比不上Dense和Chain的。
向一個有100,000,000個桶的Cuckoo Hash Table中不斷插入key,它花費(fèi)總時間的趨勢如圖所示。在容積率達(dá)到85%之前,插入時間基本是一條直線,說明沒有明顯的性能變化。而在約92%-93%之后,插入性能開始發(fā)生急劇下降。
總體來說,Cuckoo的插入性能是遠(yuǎn)遜與Dense Hash Table的,雖然Dense的性能在高容積率時也會快速下降,但容積率直到90%時插入性能仍遠(yuǎn)高于Cuckoo。
查詢性能
考慮到我們引入Cuckoo 時,就是希望其能在較高的容積率下保持不錯的查詢性能,以在離線場景能節(jié)省空間,因此可以接受稍差一些的插入性能。所以,插入性能暫不在后面進(jìn)一步討論的范圍內(nèi)(即使插入也可以類似地使用Cuckoo性能優(yōu)化這一節(jié)的方法來進(jìn)行優(yōu)化),而將關(guān)注點轉(zhuǎn)移到查詢性能上。
我們預(yù)期的查詢性能是Dense在容積率較低時肯定性能出色,而隨著容積率上升到70%-80%,每次查詢遍歷的桶數(shù)將快速上升,導(dǎo)致查詢時間成比例的上升;Cuckoo預(yù)計的性能將不怎么受到容積率的影響,隨著容積率的上升,查詢性能只會緩慢下降;因此,在一定的容積率(如70%)時,其性能將會超過Dense,并逐漸將其甩開。
但是,實測的查詢性能卻并不理想,雖然前兩個估計基本滿足了,但是Cuckoo性能超過Dense的容積率節(jié)點卻非常靠后,具體數(shù)據(jù)展示在Cuckoo、Dense、Chain性能對比。
其它性能測試
除以上的常規(guī)測試之外,還對Cache Line對齊、存儲在第二個哈希函數(shù)對應(yīng)位置占比等進(jìn)行了測試(哈希表包含100,000,000個桶,里面裝了90,000,000組(k, v)對),結(jié)論主要有以下幾點:
- Cache Line如果不對齊,將導(dǎo)致10%-12%的性能下降。
- 查詢?nèi)棵袝r,只有23%的命中了第二個哈希函數(shù)對應(yīng)的位置。
- 直接改為不查第二個哈希函數(shù)(只遍歷第一個哈希函數(shù)對應(yīng)的block,沒有查到就退出),性能為原本的5倍。
- 8路組相聯(lián)的性能較4路差很多,5路組相聯(lián)較4路也有下降。
Cuckoo、Dense、Chain性能對比
在進(jìn)行對比測試之前,已經(jīng)初步優(yōu)化了Cuckoo的代碼,包括做了一些循環(huán)展開、條件判斷的順序調(diào)整、優(yōu)化代碼邏輯等等。為了保證Cuckoo與Dense、Chain格式盡量在公平的條件下進(jìn)行對比測試,控制實驗條件如下:
- 盡量為表體使用同樣大小的內(nèi)存,因此,Cuckoo和Dense表中包含的桶數(shù)是相同的,均為100,000,000。在建表時,Cuckoo會多用一些額外的輔助結(jié)構(gòu),它們占用了一定內(nèi)存,但這部分最終是不用存下來的。
- 容積率統(tǒng)一設(shè)置為90%,即插入過程在插入90,000,000個(k, v)對后結(jié)束,之后開始測試查詢性能。由于Chain不存在容積率的概念,我們?nèi)园凑毡3謨?nèi)存大小一致的原則,用90,000,000個桶存下所有插入的(k, v)對(存多條鏈),剩下10,000,000個桶的空間作為索引(存鏈表頭)。
- Key和Value各占用8個字節(jié),Cuckoo Hash Table還滿足表體的首字節(jié)地址為64的倍數(shù),以確保Cache Line對齊(即一個Block對應(yīng)一個Cache Line)。
- 采用一組實際的user_id數(shù)據(jù)進(jìn)行測試。
測試結(jié)果如下圖所示,橫軸為哈希表的容積率,縱軸為查詢操作的QPS。可以看出,在絕大部分key不命中的場景,反而是Chain哈希具有較好的性能,這是因為Chain每次查詢訪問的空間連續(xù),且它不需要判斷桶是否為empty,少一次比較操作,效率相對較高。而Cuckoo和Dense對于每個桶的檢查是完全一致的,我們原本認(rèn)為性能的差異應(yīng)當(dāng)對應(yīng)于每次查詢平均遍歷的桶數(shù)/Cache Miss次數(shù)的差異。
在圖示查詢不命中的實測數(shù)據(jù)中,如果我們對Cuckoo和Dense這兩種閉鏈哈希進(jìn)行對比的話,僅在較低的容積率50%時,Dense的查詢性能比Cuckoo好;在容積率為60%及以上,Cuckoo的性能就超過了使用線性探查的Dense;在容積率達(dá)到90%以后,Dense的性能已經(jīng)退化到無法使用的地步。
這一結(jié)果基本符合我們的預(yù)期:Dense Hash Table隨著越來越多的key的插入桶數(shù)組,空桶位越來越少,并且,key不命中的場景是需要遍歷所有連續(xù)存有(k, v)對的桶(它們會跨越多個Cache Line,有多次Cache Miss),直到找到一個空桶,才能確定這個key確實不存在;而Cuckoo在大多數(shù)情況只有一次Cache Miss(掃第一個哈希函數(shù)對應(yīng)的block),偶爾會有兩次Cache Miss(掃第一個和第二個哈希函數(shù)對應(yīng)的兩個block),因此在容積率達(dá)到一定時,性能將會超過Dense。
然而,在全部命中的場景,Cuckoo的表現(xiàn)則不盡如人意,從橫軸(容積率)來看,Dense的性能一路領(lǐng)先,直到達(dá)到90%的容積率時,性能才被Cuckoo反超,可以說離我們的期望有較大的差距。因為按照我們最初的想法(上一段中的預(yù)期),即使是命中的場景,只要容積率達(dá)到60%-70%,Dense也應(yīng)該比Cuckoo掃描更多的桶,從而QPS比Cuckoo低。可是,實測數(shù)據(jù)卻說明Dense的性能直到90%才被超過,這個差異將在Cuckoo性能分析與優(yōu)化一節(jié)進(jìn)行分析。
另一方面,插入完成后進(jìn)行過鏈表區(qū)域整理的Chain哈希的查詢性能則基本處于Cuckoo和Dense兩者之間,考慮到在不命中時其性能領(lǐng)先,在容積率處于70%-90%之間時Chain也不失為一種解決方案。
Cuckoo 性能分析與優(yōu)化
性能差異分析
首先回顧一下我們的預(yù)期:Cuckoo和Dense對于每個桶的檢查是完全一致的,它們之間性能的差異應(yīng)當(dāng)在每次查詢平均遍歷的桶數(shù)/Cache Line數(shù)上。由于Dense就是普通的線性探查哈希,在容積率較高時,一個key實際存儲的位置,相較于這個key通過哈希函數(shù)求出來的桶,往往需要探查較長的距離,也就是和Cuckoo相比(一次查詢最多遍歷8個桶)要遍歷更多的桶。
考慮到實測結(jié)果,特別是key全部命中的情況,不符合我們的預(yù)期。于是我們統(tǒng)計了Cuckoo和Dense每次查詢平均遍歷的桶數(shù),以判斷以下假設(shè)的正確性:在較高的容積率時,Dense會比Cuckoo遍歷更多的桶,這些桶跨越更多的Cache Lines。
| 80% | 5.67461 | 2.90213 | 12.9941 | 3.00045 |
| 90% | 6.74718 | 3.30731 | 50.4867 | 5.49856 |
上表展示的是每次查詢平均掃描的桶數(shù),可以看出,在Key查不到的情況下,Dense每次需要遍歷的桶數(shù)遠(yuǎn)超Cuckoo需要遍歷的桶數(shù),因此性能不如Cuckoo也是合理的。但是,它們之間性能的實際差距遠(yuǎn)沒有遍歷的桶數(shù)的差距那么大。
而在Key全部命中的情況下,由于數(shù)據(jù)比較隨機(jī),Dense的散列效果并不差,在容積率為80%時平均遍歷的桶數(shù)僅略多于Cuckoo,在90%時則超過較多,約為Cuckoo的1.7倍。然而,實測性能告訴我們?nèi)莘e率在80%甚至85%時,Dense的性能還領(lǐng)先于Cuckoo,90%時也只是略差于Cuckoo。綜上所述,我們之前認(rèn)為的“性能和遍歷的桶數(shù)/Cache Miss次數(shù)成反比”這一斷言存在一定的問題,我們需要找到其中隱藏的其他因素的影響。
一種可能的解釋是Dense的空間局部性較好,每次查詢操作都是由給定的key求出哈希值,再從哈希值對應(yīng)的桶開始,向后連續(xù)遍歷一些桶,直到找到這個key或者碰到一個空桶。即使這些桶跨越了較多的Cache Line,CPU還是能較好地進(jìn)行分支預(yù)測、預(yù)取到緩存等操作。Cuckoo雖然做了Cache Line對齊,且每次查詢最多會訪問兩個block,即兩個Cache Line的數(shù)據(jù),但這兩個行之間沒有任何聯(lián)系,進(jìn)行訪存的時候CPU沒有優(yōu)化的余地,且還比Dense多了一次取模運(yùn)算。考慮其它性能測試b)c)提到的,僅23%的操作訪問了第二個哈希函數(shù)對應(yīng)的行(占總操作的23/123),卻導(dǎo)致了超過50%的性能差異,可見Cuckoo訪問“第二行”的開銷是相當(dāng)大的。因此,在80%左右的容積率下,雖然Dense訪問的桶數(shù)略多于Cuckoo,但它憑借優(yōu)良的空間局部性挽回了不少性能;而Cuckoo被少數(shù)“需要訪問第二個哈希函數(shù)的查詢操作”中的第二次取模、第二次訪存所拖累,所以性能不如Dense。
為了驗證這種解釋,我們使用Intel VTune抓取CPU數(shù)據(jù),我們發(fā)現(xiàn)Cuckoo的查詢函數(shù)中,有一條cmp匯編指令的平均CPI(Clockticks per Instructions)非常高,而這句指令前面是哈希函數(shù)(包括取模獲取桶號)以及訪存,并且訪存指令依賴于取模的結(jié)果、cmp指令依賴于訪存的結(jié)果,這一系列操作加起來可能占用數(shù)百個時鐘周期。雖然上述指令的CPI并沒有異常,但可以推測VTune是把這一系列指令的時鐘周期算在了cmp指令上。
通過進(jìn)一步查詢資料以及和intel工程師的合作,基本可以確認(rèn)瓶頸就是在取模、訪存操作上(和Dense的差距也就在于Cuckoo可能用到的第二個哈希函數(shù)對應(yīng)的取模、訪存),而且我們使用的64位取模指令花費(fèi)的時間可以達(dá)到32位取模的數(shù)倍。由于這幾句匯編指令前后還存在數(shù)據(jù)依賴,更導(dǎo)致流水線幾乎停頓,并且在這期間,超標(biāo)量處理器的多個發(fā)射指令的port也只有一個能被使用。
取模與預(yù)取優(yōu)化
最終,我們應(yīng)用了兩個優(yōu)化:
經(jīng)過測試,發(fā)現(xiàn)僅將64位取模語句直接修改為32位取模就可以獲得15%的性能提升。但在特定場景下的總桶數(shù)的確是可能超過4G的,因此考慮下述兩種解決方案:
- a) 將一個大表拆成多個桶數(shù)不超過4G的小表
這就需要將key的值分段進(jìn)行索引,一段索引到表,一段索引到個桶。這樣就存在潛在的問題:一個是不同小表的容積率可能不會均衡;一個是可能需要兩次取模。
- b) 在桶數(shù)較少時,使用32位取模,桶數(shù)較多時仍維持64位取模
這種修改比較容易實現(xiàn),也足以優(yōu)化大多數(shù)場景。可是,這就需要我們加入對桶數(shù)的條件判斷,既可以每次取模時判斷,也可以在建表前判斷,并用函數(shù)指針確定每次調(diào)用的時64位模還是32位模。后者雖然不需要做多次判斷,但是函數(shù)指針的存在導(dǎo)致代碼不能內(nèi)聯(lián),實測的性能是:
32位取模>每次條件判斷使用哪種>函數(shù)指針>64位取模。
在應(yīng)用了1.b)的優(yōu)化后,減少了取模運(yùn)算消耗的時間,可以利用這一點再對訪存操作再進(jìn)行優(yōu)化。我們嘗試加入了prefetch預(yù)取,即在讀取第一個哈希函數(shù)對應(yīng)的block之前,計算第二個哈希函數(shù)對應(yīng)的block號,并利用__builtin_prefetch()語句顯式的預(yù)取第二行的內(nèi)容到Cache。經(jīng)過多輪實驗,最終確定的(在高容積率下)性能最優(yōu)的執(zhí)行順序如下:
本實驗依然在“100,000,000個桶,90,000,000個(k, v)對”的條件下進(jìn)行,可以看出32位取模對性能有約15%的影響,預(yù)取在這一基礎(chǔ)上,又對總性能有約20%的提升。圖中prefetch1是僅預(yù)取第2個block的另一種寫法,prefetch2對應(yīng)上述的執(zhí)行順序。由于低容積率下絕大多數(shù)時候不需要遍歷第二個block,這種方式實際上是略微犧牲了低容積率下的性能的。但是,對于高容積率的情況,不命中時有約74%的會查到第2個block,因此性能有大幅提高。命中時雖然僅有23%的查詢會訪問第二個block,但是基于以下幾點原因,性能同樣得到了提高。
- 優(yōu)化之前,我們已知23%的操作訪問了第2個block,卻占用了總時間的50%。
- 應(yīng)用32位取模后,多余的取模操作造成的負(fù)面影響已經(jīng)被大幅減小。
- 現(xiàn)代CPU都是超標(biāo)量、流水線、多核處理器,以開發(fā)機(jī)的Intel Haswell處理器為例,它的每個核心中都有6個可以同時發(fā)射指令的port(6條超標(biāo)量流水線),Skylake則有8個,每個核心中有一個scheduler決定將指令發(fā)射到哪個端口,優(yōu)化前,VTune數(shù)據(jù)顯示我們對port的利用不佳,大多數(shù)時候都只能用到其中1個port。優(yōu)化后,預(yù)取操作雖然屬于訪存操作,需要花費(fèi)較多的時鐘周期,但它只需要占用6個中的1個支持內(nèi)存load的port(并非6個port都支持內(nèi)存load的操作)。這就意味著,在進(jìn)行預(yù)取的同時,其余的port上仍然可以進(jìn)行計算、比較等指令,比如,預(yù)取第1個block和計算第2個哈希可以在不同port上并行,預(yù)取第2個block和查詢第1個block也應(yīng)當(dāng)可以并行。
總結(jié)
在應(yīng)用了這兩項優(yōu)化之后,重新進(jìn)行Cuckoo和Dense的性能對比,可以明顯地看到Cuckoo性能的提升,我們的優(yōu)化有不錯的效果。現(xiàn)在基本可以認(rèn)為,在80%的容積率時,Cuckoo的整體查詢性能已經(jīng)超過了Dense。(不命中的場景QPS達(dá)到了Dense的兩倍,命中時以約5%的差距稍稍落后于Dense。)
幾點以后需要注意的地方:
總結(jié)
以上是生活随笔為你收集整理的Cuckoo Hashing的应用及性能优化的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Centos7 Docker Jen
- 下一篇: 3.25 for循环