導讀:本文從哈希表傳統設計與解決思路入手,深刻淺出地引出新的設計思路:從儘可能規避哈希衝突,轉向了利⽤合適的哈希衝突機率來優化計算和存儲效率。新的哈希表設計代表 SIMD 指令的並⾏化處理能⼒的有效應⽤能⼤幅度提高哈希表對哈希衝突的容忍能⼒,進⽽提高查詢的速度,而且能幫助哈希表進⾏極致的存儲空間壓縮。
算法
哈希表是⼀種查找性能⾮常優異的數據結構,它在計算機系統中存在着⼴泛的應⽤。儘管哈希表理論上 的查找時間複雜度是 O(1),但不一樣的哈希表在實現上仍然存在巨⼤的性能差別,因⽽⼯程師們對更優秀 哈希數據結構的探索也從未停⽌。數組
1.1 哈希表設計的核⼼服務器
從計算機理論上來講,哈希表就是⼀個能夠經過哈希函數將 Key 映射到 Value 存儲位置的數據結構。那麼哈希表設計的核⼼就是兩點:數據結構
1. 怎樣提高將Key映射到Value存儲位置的效率?app
2. 怎樣下降存儲數據結構的空間開銷?ide
因爲存儲空間開銷也是設計時的⼀個核⼼控制點,在受限於有限的空間狀況下,哈希函數的映射算法就存在着⾮常⾼的機率將不一樣的 Key 映射到同⼀個存儲位置,也就是哈希衝突。⼤部分哈希表設計的區別,就在於它如何處理哈希衝突。函數
當遇到哈希衝突時,有⼏種常⻅的解決⽅案:開放尋址法、拉鍊法、⼆次哈希法。可是下⾯咱們介紹兩種有趣的、不常⻅的解決思路,而且引出⼀個咱們新的實現⽅案——B16 哈希表。性能
傳統哈希表對哈希衝突的處理會增長額外的分⽀跳轉和內存訪問,這會讓流⽔線式的CPU指令處理效率變差。那麼確定就有⼈考慮,怎麼能徹底規避哈希衝突?因此就有了這樣⼀種函數,那就是完美哈希函數(perfect hash function)。測試
完美哈希函數能夠將⼀個 Key 集合⽆衝突地映射到⼀個整數集合中。若是這個⽬標整數集合的⼤⼩與輸⼊集合相同,那麼它能夠被稱爲最⼩完美哈希函數。優化
完美哈希函數的設計每每⾮常精巧。例如CMPH(http://cmph.sourceforge.net/)函數庫提供的 CDZ 完美哈希函數,利⽤了數學上的⽆環隨機 3-部超圖概念。CDZ經過 3 個不一樣的 Hash 函數將每一個 Key 隨機映射到3-部超圖的⼀個超邊,若是該超圖經過⽆環檢測,再將每一個 Key 映射到超圖的⼀個頂點上,而後經過⼀個精⼼設計的與超圖頂點數相同的輔助數組取得 Key 最終對應的存儲下標。
完美哈希函數聽起來很優雅,但事實上也有着實⽤性上的⼀些缺陷:
完美哈希函數每每僅能做⽤在限定集合上,即全部可能的 Key 都屬於⼀個超集,它⽆法處理沒⻅過的 Key;
完美哈希函數的構造有⼀定的複雜度,⽽且存在失敗的機率;
完美哈希函數與密碼學上的哈希函數不一樣,它每每不是⼀個簡單的數學函數,⽽是數據結構+算法組成的⼀個功能函數,它也有存儲空間開銷、訪問開銷和額外的分⽀跳轉開銷;
可是在指定的場景下,例如只讀的場景、集合肯定的場景(例如:漢字集合),完美哈希函數可能會取得⾮常好的表現。
即使不使⽤完美哈希函數,不少哈希表也會刻意控制哈希衝突的機率。最簡單的辦法是經過控制 Load Factor 控制哈希表的空間開銷,使哈希表的桶數組保留⾜夠的空洞以容納新增的 Key。Load Factor 像是控制哈希表效率的⼀個超參數,⼀般來講,Load Factor 越⼩,空間浪費越⼤,哈希表性能也越好。
但近年來⼀些新技術的出現讓咱們看到了解決哈希衝突的另⼀種可能,那就是充分利⽤哈希衝突。
SIMD 是單指令多數據流(Single Instruction Multiple Data)的縮寫。這類指令可使⽤⼀條指令操做多個數據,例如這些年⾮常⽕的 GPU,就是經過超⼤規模的 SIMD 計算引擎實現對神經⽹絡計算的加速。
⽬前的主流 CPU 處理器已經有了豐富的 SIMD 指令集⽀持。例如⼤家可接觸到的 X86 CPU ⼤部分都已經⽀持了 SSE4.2 和 AVX 指令集,ARM CPU 也有 Neon 指令集。但科學計算之外的⼤部分應⽤程序,對 SIMD指令的應⽤還都不太充分。
Facebook 在 Folly 庫中開源的 F14 哈希表有個⾮常精巧的設計,那就是將 Key 映射到塊,而後在塊⾥使⽤ SIMD 指令進⾏⾼效過濾。由於分塊的數量⽐傳統的分桶要更⼩,這至關於⼈爲增長了哈希衝突,而後在塊中⽤ SIMD 指令再解決衝突。
具體的作法是這樣的:
經過哈希函數對 Key 計算出兩個哈希碼:H1 和 H2 , 其中 H1 ⽤來肯定 Key 映射到的塊,H2 只有 8 bits,⽤來在塊內進⾏過濾;
每一個塊⾥最多存放 14 個元素,塊頭有 16 個字節。塊頭的前 14 個字節,存放的是 14 個元素對應的 H2 ,第 15 字節是控制字節,主要記錄該塊⾥有多少個元素是從上⼀個塊溢出的,第 16 字節是越界計數器,主要記錄若是該塊空間⾜夠⼤,應該會被放置多少個元素。
在插⼊時,當 Key 映射到的塊中 14 個位置還有空時,就直接插⼊;當塊已經滿時,就增長越界計數器,嘗試插⼊下⼀個塊中;
在查詢時,經過待查找 Key 計算獲得 H1 和 H2 。經過 H1 對塊數取模肯定其所屬的塊後,⾸先讀取塊頭,經過 SIMD 指令並⾏⽐較 H2 與 14 個元素的 H2s 是否相同。若是有相同的 H2 ,那麼再⽐對 Key 是否相同以肯定最終結果;不然根據塊頭的第 16 字節判斷是否還須要⽐對下⼀個塊。
F14 爲了充分利⽤ SIMD 指令的並⾏度,在塊內使⽤了 H2 這種 8 bits 的哈希值。由於⼀個 128 bits 寬度的 SIMD 指令能夠進⾏最多 16 個 8 bits 整數的並⾏⽐較。雖然 8 bits 哈希值的理論衝突機率 1/256 並不低,但也至關於有 255/256 的可能性省去了逐個 Key 對⽐的開銷,使哈希表可以容忍更⾼的衝突機率。
不考慮分塊內部的設計,F14 本質上是⼀個開放尋址的哈希表。每一個塊頭的第 1五、16 字節被⽤於開放尋址的控制策略,只剩 14 個字節留給哈希碼,也因⽽被命名爲 F14。
那麼咱們考慮能不能從另⼀個⻆度出發,使⽤拉鍊法來組織分塊。因爲省去了控制信息,每一個分塊中能夠放置 16 個元素,咱們把它命名爲 B16。
△B16 哈希表數據結構(3元素示例)
上圖所示就是⽤每一個分塊 3 個元素展現的 B16 哈希表的數據結構。中間綠⾊的是常⻅的 BUCKET 數組,存放的是每一個分桶中 CHUNK 拉鍊的頭指針。右側的每一個 CHUNK 與 F14 相⽐,少了控制字節,多了指向下⼀個 CHUNK 的 next 指針。
B16 也是經過哈希函數對 Key 計算出兩個哈希碼:H1 和 H2 。例如 「Lemon」 的兩個哈希碼是 0x24EB 和0x24,使⽤ H1 的⾼位做爲 H2 ⼀般來講⾜夠了。
在插⼊時,經過 H1 對桶⼤⼩取模計算 Key 所在的分桶,例如 "Lemon" 所在的分桶是 0x24EB mod 3 =1。而後在 1 號分桶的分塊拉鍊中找到第⼀個空位,將 Key 對應的 H2 和元素寫⼊該分塊。當分塊拉鍊不存在,或者已經裝滿時,爲拉鍊建立⼀個新的分塊⽤於裝載插⼊的元素。
在查找時,先經過 H1 找到對應的分桶拉鍊,而後對每一個塊進⾏基於 SIMD 指令的 H2 對⽐。將每一個塊的塊頭 16 字節加載到 128 bits 寄存器中,⾥⾯包含了 16 個 H2' ,把 H2 也重複展開到 128 bits 寄存器中,經過 SIMD 指令進⾏ 16 路同時⽐對。若是都不一樣,那就對⽐下⼀個塊;若是存在相同的 H2 ,就繼續對⽐對應元素的 Key 是否與查找的 Key 相同。直到遍歷完整條拉鍊,或者找到對應的元素。
在刪除時,⾸先查找到對應的元素,而後⽤塊拉鍊最尾部的元素覆蓋掉對應的元素便可。
固然,B16 哈希表每一個塊的元素個數能夠根據 SIMD 指令的寬度進⾏靈活調整,例如使⽤ 256 bits 寬度指令能夠選擇 32 元素的塊⼤⼩。但影響哈希表性能的不只僅是查找算法,內存訪問的速度和連續性也⾮常重要。控制塊⼤⼩在 16 之內在⼤多數狀況下能充分利⽤ x86 CPU 的 cache line,是⼀個較優的選擇。
普通的拉鍊式哈希表,拉鍊的每一個節點都只有⼀個元素。B16 這種分塊式拉鍊法,每一個節點包含 16 個元素,這會形成不少空洞。爲了使空洞儘量的少,咱們就必須增長哈希衝突的機率,也就是儘可能地縮⼩BUCKET 數組的⼤⼩。咱們通過試驗發現,當 Load Factor 在 11-13 之間時,B16 的總體性能表現最好。其實這也至關於把原來存在於 BUCKET 數組中的空洞,轉移到了 CHUNK 拉鍊中,還省去了普通拉鍊式每一個節點的 next 指針開銷。
爲了進⼀步壓榨哈希表的存儲空間,咱們還設計了 B16 的只讀緊湊數據結構,以下圖所示:
△B16Compact 哈希表數據結構(3元素示例)
B16Compact 對哈希表結構作了極致的壓縮。
⾸先它省去了 CHUNK 中的 next 指針,把全部的 CHUNK 合併到了⼀個數組中,而且補上了全部的CHUNK 空洞。例如【圖1】中 BUCKET[1] 的拉鍊中原本有 4 個元素,包含 Banana 和 Lemon,其中頭兩個元素被補到了【圖2】的 CHUNK[0] 中。以此類推,除 CHUNK 數組中最後⼀個 CHUNK 外,全部的CHUNK 均是滿的。
而後它省去了 BUCKET 中指向 CHUNK 拉鍊的指針,只保留了⼀個指向原拉鍊中第⼀個元素所在 CHUNK 的數組下標。例如【圖1】中 BUCKET[1] 的拉鍊第⼀個元素被補到了【圖2】的 BUCKET[0] 中,那麼新的 BUCKET[1] 中僅存儲了 0 這個下標。
最後增長了⼀個 tail BUCKET,記錄 CHUNK 數組中最後⼀個 CHUNK 的下標。
通過這樣的處理之後,原來每一個 BUCKET 拉鍊中的元素在新的數據結構中依然是連續的,每一個 BUCKET依然指向第⼀個包含其元素的 CHUNK 塊,經過下⼀個 BUCKET 中的下標依然能夠知道最後⼀個包含其元素的 CHUNK 塊。不一樣的是,每一個 CHUNK 塊中可能會包含多個 BUCKET 拉鍊的元素。雖然可能要查找的 CHUNK 變多了,但因爲每一個 CHUNK 均可以經過 SIMD 指令進⾏快速篩選,對總體查找性能的影響相對較⼩。
這個只讀哈希表只⽀持查找,查找的過程跟原來差別不⼤。以 Lemon 爲例,⾸先經過 H1=24EB 找到對應的分桶1,得到分桶對應拉鍊的起始 CHUNK 下標爲0,結束 CHUNK 下標爲1。使⽤與 B16 一樣的算法在 CHUNK[0] 中查找,未找到 Lemon,而後繼續查找 CHUNK[1],找到對應的元素。
B6 Compact 的理論額外存儲開銷能夠經過下式算得:
其中,n 是隻讀哈希表的元素個數。
當 n 取100萬,Load Factor 取13時,B16Compact 哈希表的理論額外存儲開銷是9.23 bits/key,即存儲每一個 key 所⽀出的額外開銷只有1個字節多⼀點。這⼏乎能夠媲美⼀些最⼩完美哈希函數了,⽽且不會出現構建失敗的狀況。
B16Compact 數據結構僅包含兩個數組,BUCKET 數組和 CHUNK 數組,也就意味着它的序列化和反序列化能夠作到極簡。因爲 BUCKET 中存儲的是數組下標,⽤戶甚⾄不須要將哈希表整個加載到內存中,使⽤⽂件偏移便可進⾏基於外存的哈希查找,對於巨⼤的哈希表來講能夠有效節省內存。
當使⽤的哈希表的 Key 和 Value 類型均取 uint64_t,Key、Value 對的輸⼊數組由隨機數⽣成器預先⽣成。哈希表均使⽤元素個數進⾏初始化,即插⼊過程當中不須要再 rehash。
插⼊性能:經過 N 個元素的逐⼀插⼊總耗時除以 N 得到,單位是 ns/key;
查詢性能:經過對隨機的 Key 查詢20萬次(全命中) + 對隨機的 Value 查詢20萬次(有可能不命中)得到總耗時除以40萬得到,單位是 ns/key;
存儲空間:經過總分配空間除以哈希表⼤⼩得到,單位是 bytes/key。對於總分配空間來講,F14和B16均有對應的接⼝函數可直接獲取,unordered_map 經過如下公式獲取:
// 拉鍊節點總⼤⼩umap.size() * (sizeof(uint64_t) + sizeof(std::pair<uint64_t, uint64_t>) + sizeof(void*))// bucket 總⼤⼩+ umap.bucket_count() * sizeof (void *)// 控制元素⼤⼩+ 3 * sizeof(void*) + sizeof(size_t);
Folly 庫使⽤ - mavx - O2 編譯,Load Factor 使⽤默認參數;B16 使⽤ - mavx - O2 編譯,Load Factor 設定爲13;unordered_map 使⽤ Ubuntu 系統⾃帶版本,Load Factor 使⽤默認參數。
測試服務器是⼀臺4核 8G 的 CentOS 7u5 虛擬機,CPU 是 Intel(R) Xeon(R) Gold 6148 @ 2.40GHz,程序在Ubuntu 20.04.1 LTS Docker 中編譯執⾏。
△插⼊性能對⽐
上圖中折線所示爲 unordered_map、F14ValueMap 和 B16ValueMap 的插⼊性能對⽐,不一樣的柱⼦顯示了不一樣哈希表的存儲開銷。
能夠看到 B16 哈希表在存儲開銷明顯⼩於 unordered_map 的狀況下,仍然提供了顯著優於unordered_map 的插⼊性能。
因爲 F14 哈希表對 Load Factor 的⾃動尋優策略不一樣,在不一樣哈希表⼤⼩下 F14 的存儲空間開銷存在⼀定波動,但 B16 的存儲開銷總體仍優於 F14。B16 的插⼊性能在 100 萬 Key 如下時優於 F14,可是在 1000萬 Key 時劣於 F14,多是由於當數據量較⼤時 B16 拉鍊式內存訪問的局部性⽐ F14 差⼀些。
△查找性能對⽐
上圖中折線所示爲 unordered_map、F14ValueMap、B16ValueMap 和 B16Compact 的查找性能對⽐,不一樣的柱⼦顯示了不一樣哈希表的存儲開銷。
能夠看到 B1六、B16Compact 哈希表在存儲開銷明顯⼩於 unordered_map 的狀況下,仍然提供了顯著優於 unordered_map 的查找性能。
B16 與 F14 哈希表的查找性能對⽐與插⼊性能相似,在 100 萬 Key 如下時明顯優於 F14,但在 1000 萬時略劣於 F14。
值得注意的是 B16Compact 哈希表的表現。因爲實驗哈希表的 Key 和 Value 類型均取 uint64_t,存儲 Key,Value 對自己就須要消耗 16 字節的空間,⽽ B16Compact 哈希表對不一樣⼤⼩的哈希表以穩定的約 17.31bytes/key 進⾏存儲,這意味着哈希結構⾥爲每一個 Key 僅額外花費了 1.31 個字節。之因此沒有達到 9.23bits/key 的理論開銷,是由於咱們的 BUCKET 數組沒有使⽤ bitpack ⽅式進⾏極致壓縮(這可能會影響性能),⽽是使⽤了 uint32_t。
本⽂中咱們從哈希表設計的核⼼出發,介紹了兩種有趣的、不算「常⻅」的哈希衝突解決⽅法:完美哈希函數和基於 SIMD 指令的 F14 哈希表。
在 F14 的啓發下,咱們設計了 B16 哈希表,使⽤了更容易理解的數據結構,使得增、刪、查的實現邏輯更爲簡單。實驗代表在⼀些場景下 B16 的存儲開銷和性能⽐ F14 還要好。
爲追求極致的存儲空間優化,咱們對 B16 哈希表進⾏緊緻壓縮,設計了⼏乎能夠媲美最⼩完美哈希函數的 B16Compact 哈希表。B16Compact 哈希表的存儲開銷顯著低於 F14 哈希表(介於40%-60%之間),但卻提供了很有競爭⼒的查詢性能。這在內存緊張的場合,例如嵌⼊式設備或者⼿機上,能夠發揮很⼤的做⽤。
新的哈希表設計代表 SIMD 指令的並⾏化處理能⼒的有效應⽤能⼤幅度提高哈希表對哈希衝突的容忍能⼒,進⽽提高查詢的速度,而且能幫助哈希表進⾏極致的存儲空間壓縮。這讓哈希表的設計思路從儘可能規避哈希衝突,轉向了利⽤合適的哈希衝突機率來優化計算和存儲效率。
閱讀原文:趣談哈希表優化:從規避 Hash 衝突到利⽤ Hash 衝突
更多幹貨、內推福利,歡迎關注同名公衆號「百度Geek說」~