深入解析面向資料的雜湊表效能

Nathan Reed發表於2017-04-17

最近幾年中,面向資料的設計已經受到了很多的關注 —— 一種強調記憶體中資料佈局的程式設計風格,包括如何訪問以及將會引發多少的 cache 缺失。由於在記憶體讀取操作中缺失所佔的數量級要大於命中的數量級,所以缺失的數量通常是優化的關鍵標準。這不僅僅關乎那些對效能有要求的 code-data 結構設計的軟體,由於缺乏對記憶體效益的重視而成為軟體執行緩慢、膨脹的一個很大因素。

高效快取資料結構的中心原則是將事情變得平滑和線性。比如,在大部分情況下,儲存一個序列元素更傾向於使用普通陣列而不是連結串列 —— 每一次通過指標來查詢資料都會為 cache 缺失增加一份風險;而普通陣列則可以預先獲取,並使得記憶體系統以最大的效率執行

如果你知道一點記憶體層級如何運作的知識,下面的內容會是想當然的結果——但是有時候即便它們相當明顯,測試一下任不失為一個好主意。幾年前 Baptiste Wicht 測試過了 std::vector vs std::list vs std::deque,(後者通常使用分塊陣列來實現,比如:一個陣列的陣列)。結果大部分會和你預期的保持一致,但是會存在一些違反直覺的東西。作為例項:在序列連結串列的中間位置做插入或者移除操作被認為會比陣列快,但如果該元素是一個 POD 型別,並且不大於 64 位元組或者在 64 位元組左右(在一個 cache 流水線內),通過對要操作的元素周圍的陣列元素進行移位操作要比從頭遍歷連結串列來的快。這是由於在遍歷連結串列以及通過指標插入/刪除元素的時候可能會導致不少的 cache 缺失,相對而言,陣列移位則很少會發生。(對於更大的元素,非 POD 型別,或者你已經有了指向連結串列元素的指標,此時和預期的一樣,連結串列勝出)

多虧了類似 Baptiste 這樣的資料,我們知道了記憶體佈局如何影響序列容器。但是關聯容器,比如 hash 表會怎麼樣呢?已經有了些權威推薦:Chandler Carruth 推薦的帶區域性探測的開放定址(此時,我們沒必要追蹤指標),以及Mike Acton 推薦的在記憶體中將 value 和 key 隔離(這種情況下,我們可以在每一個 cache 流水線中得到更多的 key), 這可以在我們必須查詢多個 key 時提高區域性效能。這些想法很有意義,但再一次的說明:測試永遠是好習慣,但由於我找不到任何資料,所以只好自己收集了。

測試

我測試了四個不同的 quick-and-dirty 雜湊表實現,另外還包括 std::unordered_map 。這五個雜湊表都使用了同一個雜湊函式 —— Bob Jenkins 的 SpookyHash(64 位雜湊值)。(由於雜湊函式在這裡不是重點,所以我沒有測試不同的雜湊函式;我同樣也沒有檢測我的分析中的總記憶體消耗。)實現會通過簡短的程式碼在測試結果表中標註出來。

  • UM: std::unordered_map 。在 VS2012 和 libstdc++-v3 (libstdc++-v3: gcc 和 clang 都會用到這東西)中,UM 是以連結串列的形式實現,所有的元素都在連結串列中,bucket 陣列中儲存了連結串列的迭代器。VS2012 中,則是一個雙連結串列,每一個 bucket 儲存了起始迭代器和結束迭代器;libstdc++ 中,是一個單連結串列,每一個 bucket 只儲存了一個起始迭代器。這兩種情況裡,連結串列節點是獨立申請和釋放的。最大負載因子是 1 。
  • Ch:分離的、鏈狀 bucket 指向一個元素節點的單連結串列。為了避免分開申請每一個節點,元素節點儲存在普通陣列池中。未使用的節點儲存在一個空閒連結串列中。最大負載因子是 1。
  • OL:開地址線性探測 —— 每一個 bucket 儲存一個 62 bit 的 hash 值,一個 2 bit 的狀態值(包括 empty,filled,removed 三個狀態),key 和 value 。最大負載因子是 2/3。
  • DO1:“data-oriented 1” —— 和 OL 相似,但是雜湊值、狀態值和 key、values 分離在兩個隔離的平滑陣列中。
  • DO2:“data-oriented 2” —— 與 OL 類似,但是雜湊/狀態,keys 和 values 被分離在 3 個相隔離的平滑陣列中。

在我的所有實現中,包括 VS2012 的 UM 實現,預設使用尺寸為 2 的 n 次方。如果超出了最大負載因子,則擴充套件兩倍。在 libstdc++ 中,UM 預設尺寸是一個素數。如果超出了最大負載因子,則擴充套件為下一個素數大小。但是我不認為這些細節對效能很重要。素數是一種對低 bit 位上沒有足夠熵的低劣 hash 函式的挽救手段,但是我們正在用的是一個很好的 hash 函式。

OL,DO1 和 DO2 的實現將共同的被稱為 OA(open addressing)——稍後我們將發現它們在效能特性上非常相似。在每一個實現中,單元數從 100 K 到 1 M,有效負載(比如:總的 key + value 大小)從 8 到 4 k 位元組我為幾個不同的操作記了時間。 keys 和 values 永遠是 POD 型別,keys 永遠是 8 個位元組(除了 8 位元組的有效負載,此時 key 和 value 都是 4 位元組)因為我的目的是為了測試記憶體影響而不是雜湊函式效能,所以我將 key 放在連續的尺寸空間中。每一個測試都會重複 5 遍,然後記錄最小的耗時。

測試的操作在這裡:

  • Fill:將一個隨機的 key 序列插入到表中(key 在序列中是唯一的)。
  • Presized fill:和 Fill 相似,但是在插入之間我們先為所有的 key 保留足夠的記憶體空間,以防止在 fill 過程中 rehash 或者重申請。
  • Lookup:執行 100 k 次隨機 key 查詢,所有的 key 都在 table 中。
  • Failed lookup: 執行 100 k 次隨機 key 查詢,所有的 key 都不在 table 中。
  • Remove:從 table 中移除隨機選擇的半數元素。
  • Destruct:銷燬 table 並釋放記憶體。

你可以在這裡下載我的測試程式碼。這些程式碼只能在 64 機器上編譯(包括Windows和Linux)。在 main() 函式頂部附近有一些開關,你可把它們開啟或者關掉——如果全開,可能會需要一兩個小時才能結束執行。我收集的結果也放在了那個打包檔案裡的 Excel 表中。(注意: Windows 和 Linux 在不同的 CPU 上跑的,所以時間不具備可比較性)程式碼也跑了一些單元測試,用來驗證所有的 hash 表實現都能執行正確。

我還順帶嘗試了附加的兩個實現:Ch 中第一個節點存放在 bucket 中而不是 pool 裡,二次探測的開放定址。 這兩個都不足以好到可以放在最終的資料裡,但是它們的程式碼仍放在了打包檔案裡面。

結果

這裡有成噸的資料!! 這一節我將詳細的討論一下結果,但是如果你對此不感興趣,可以直接跳到下一節的總結。

Windows

這是所有的測試的圖表結果,使用 Visual Studio 2012 編譯,執行於 Windows 8.1 和 Core i7-4710HQ 機器上。

Results for VS 2012, Windows 8.1, Core i7-4710HQ

從左至右是不同的有效負載大小,從上往下是不同的操作(注意:不是所有的Y軸都是相同的比例!)我將為每一個操作總結一下主要趨向。

Fill

在我的 hash 表中,Ch 稍比任何的 OA 變種要好。隨著雜湊表大小和有效負載的加大,差距也隨之變大。我猜測這是由於 Ch 只需要從一個空閒連結串列中拉取一個元素,然後把它放在 bucket 前面,而 OA 不得不搜尋一部分 bucket 來找到一個空位置。所有的 OA 變種的效能表現基本都很相似,當然 DO1 稍微有點優勢。

在小負載的情況,UM 幾乎是所有 hash 表中表現最差的 —— 因為 UM 為每一次的插入申請(記憶體)付出了沉重的代價。但是在 128 位元組的時候,這些 hash 表基本相當,大負載的時候 UM 還贏了點。因為,我所有的實現都需要重新調整元素池的大小,並需要移動大量的元素到新池裡面,這一點我幾乎無能為力;而 UM 一旦為元素申請了記憶體後便不需要移動了。注意大負載中圖表上誇張的跳步!這更確認了重新調整大小帶來的問題。相反,UM 只是線性上升 —— 只需要重新調整 bucket 陣列大小。由於沒有太多隆起的地方,所以相對有效率。

Presized fill

大致和 Fill 相似,但是圖示結果更加的線性光滑,沒有太大的跳步(因為不需要 rehash ),所有的實現差距在這一測試中要縮小了些。大負載時 UM 依然稍快於 Ch,問題還是在於重新調整大小上。Ch 仍是穩步少快於 OA 變種,但是 DO1 比其它的 OA 稍有優勢。

Lookup

所有的實現都相當的集中。除了最小負載時,DO1 和 OL 稍快,其餘情況下 UM 和 DO2 都跑在了前面。(LCTT 譯註: 你確定?)真的,我無法描述 UM 在這一步做的多麼好。儘管需要遍歷連結串列,但是 UM 還是堅守了面向資料的本性。

順帶一提,查詢時間和 hash 表的大小有著很弱的關聯,這真的很有意思。 雜湊表查詢時間期望上是一個常量時間,所以在的漸進檢視中,效能不應該依賴於表的大小。但是那是在忽視了 cache 影響的情況下!作為具體的例子,當我們在具有 10 k 條目的表中做 100 k 次查詢時,速度會便變快,因為在第一次 10 k - 20 k 次查詢後,大部分的表會處在 L3 中。

Failed lookup

相對於成功查詢,這裡就有點更分散一些。DO1 和 DO2 跑在了前面,但 UM 並沒有落下,OL 則是捉襟見肘啊。我猜測,這可能是因為 OL 整體上具有更長的搜尋路徑,尤其是在失敗查詢時;記憶體中,hash 值在 key 和 value 之飄來蕩去的找不著出路,我也很受傷啊。DO1 和 DO2 具有相同的搜尋長度,但是它們將所有的 hash 值打包在記憶體中,這使得問題有所緩解。

Remove

DO2 很顯然是贏家,但 DO1 也未落下。Ch 則落後,UM 則是差的不是一丁半點(主要是因為每次移除都要釋放記憶體);差距隨著負載的增加而拉大。移除操作是唯一不需要接觸資料的操作,只需要 hash 值和 key 的幫助,這也是為什麼 DO1 和 DO2 在移除操作中的表現大相徑庭,而其它測試中卻保持一致。(如果你的值不是 POD 型別的,並需要析構,這種差異應該是會消失的。)

Destruct

Ch 除了最小負載,其它的情況都是最快的(最小負載時,約等於 OA 變種)。所有的 OA 變種基本都是相等的。注意,在我的 hash 表中所做的所有析構操作都是釋放少量的記憶體 buffer 。但是 在Windows中,釋放記憶體的消耗和大小成比例關係。(而且,這是一個很顯著的開支 —— 申請 ~1 GB 的記憶體需要 ~100 ms 的時間去釋放!)

UM 在析構時是最慢的一個(小負載時,慢的程度可以用數量級來衡量),大負載時依舊是稍慢些。對於 UM 來講,釋放每一個元素而不是釋放一組陣列真的是一個硬傷。

Linux

我還在裝有 Linux Mint 17.1 的 Core i5-4570S 機器上使用 gcc 4.8 和 clang 3.5 來執行了測試。gcc 和 clang 的結果很相像,因此我只展示了 gcc 的;完整的結果集合包含在了程式碼下載打包檔案中,連結在上面。

Results for g++ 4.8, Linux Mint 17.1, Core i5-4570S

大部分結果和 Windows 很相似,因此我只高亮了一些有趣的不同點。

Lookup

這裡 DO1 跑在前頭,而在 Windows 中 DO2 更快些。(LCTT 譯註: 這裡原文寫錯了吧?)同樣,UM 和 Ch 落後於其它所有的實現——過多的指標追蹤,然而 OA 只需要在記憶體中線性的移動即可。至於 Windows 和 Linux 結果為何不同,則不是很清楚。UM 同樣比 Ch 慢了不少,特別是大負載時,這很奇怪;我期望的是它們可以基本相同。

Failed lookup

UM 再一次落後於其它實現,甚至比 OL 還要慢。我再一次無法理解為何 UM 比 Ch 慢這麼多,Linux 和 Windows 的結果為何有著如此大的差距。

Destruct

在我的實現中,小負載的時候,析構的消耗太少了,以至於無法測量;在大負載中,線性增加的比例和建立的虛擬記憶體頁數量相關,而不是申請到的數量?同樣,要比 Windows 中的析構快上幾個數量級。但是並不是所有的都和 hash 表有關;我們在這裡可以看出不同系統和執行時記憶體系統的表現。貌似,Linux 釋放大記憶體塊是要比 Windows 快上不少(或者 Linux 很好的隱藏了開支,或許將釋放工作推遲到了程式退出,又或者將工作推給了其它執行緒或者程式)。

UM 由於要釋放每一個元素,所以在所有的負載中都比其它慢上幾個數量級。事實上,我將圖片做了剪裁,因為 UM 太慢了,以至於破壞了 Y 軸的比例。

總結

好,當我們凝視各種情況下的資料和矛盾的結果時,我們可以得出什麼結果呢?我想直接了當的告訴你這些 hash 表變種中有一個打敗了其它所有的 hash 表,但是這顯然不那麼簡單。不過我們仍然可以學到一些東西。

首先,在大多數情況下我們“很容易”做的比 std::unordered_map 還要好。我為這些測試所寫的所有實現(它們並不複雜;我只花了一兩個小時就寫完了)要麼是符合 unordered_map 要麼是在其基礎上做的提高,除了大負載(超過128位元組)中的插入效能, unordered_map 為每一個節點獨立申請儲存佔了優勢。(儘管我沒有測試,我同樣期望 unordered_map 能在非 POD 型別的負載上取得勝利。)具有指導意義的是,如果你非常關心效能,不要假設你的標準庫中的資料結構是高度優化的。它們可能只是在 C++ 標準的一致性上做了優化,但不是效能。:P

其次,如果不管在小負載還是超負載中,若都只用 DO1 (開放定址,線性探測,hashes/states 和 key/vaules分別處在隔離的普通陣列中),那可能不會有啥好表現。這不是最快的插入,但也不壞(還比 unordered_map 快),並且在查詢,移除,析構中也很快。你所知道的 —— “面向資料設計”完成了!

注意,我的為這些雜湊表做的測試程式碼遠未能用於生產環境——它們只支援 POD 型別,沒有拷貝建構函式以及類似的東西,也未檢測重複的 key,等等。我將可能儘快的構建一些實際的 hash 表,用於我的實用庫中。為了覆蓋基礎部分,我想我將有兩個變種:一個基於 DO1,用於小的,移動時不需要太大開支的負載;另一個用於連結並且避免重新申請和移動元素(就像 unordered_map ),用於大負載或者移動起來需要大開支的負載情況。這應該會給我帶來最好的兩個世界。

與此同時,我希望你們會有所啟迪。最後記住,如果 Chandler Carruth 和 Mike Acton 在資料結構上給你提出些建議,你一定要聽。


作者簡介:

我是一名圖形程式設計師,目前在西雅圖做自由職業者。之前我在 NVIDIA 的 DevTech 軟體團隊中工作,並在美少女特工隊工作室中為 PS3 和 PS4 的 Infamous 系列遊戲開發渲染技術。

自 2002 年起,我對圖形非常感興趣,並且已經完成了一系列的工作,包括:霧、大氣霧霾、體積照明、水、視覺效果、粒子系統、皮膚和頭髮陰影、後處理、鏡面模型、線性空間渲染、和 GPU 效能測量和優化。

你可以在我的部落格瞭解更多和我有關的事,除了圖形,我還對理論物理和程式設計感興趣。

你可以在 nathaniel.reed@gmail.com 或者在 Twitter(@Reedbeta)/Google+ 上關注我。我也會經常在 StackExchange 上回答計算機圖形的問題。


via: http://reedbeta.com/blog/data-oriented-hash-table/

作者:Nathan Reed 譯者:sanfusu 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

相關文章