本文介紹了一種新的高效能雜湊表實現: SwissTable,詳細闡述了 SwissTable 的設計理念、資料結構,並且比較了傳統雜湊表以及當前 Go 的 map 實現。原文:SwissTable: A High-Performance Hash Table Implementation
前言
位元組跳動在 2022 年提出提案,建議 Golang 採用 SwissTable 作為其 map 實現。2023 年,Dolt 發表了一篇題為 SwissMap:更小、更快的 Golang 雜湊表的博文,詳細介紹了他們設計的 swisstable
,引起了廣泛關注。Go 核心團隊正在重新評估 swisstable
的設計,並在執行時新增了相關程式碼。本文將深入探討其原理,將其與 runtime map
進行比較,並瞭解為什麼它可能成為 map
實現的標準。
本文不會全面解釋 hashtable
或 swisstable
的原理,需要讀者對 hashtable
有基本的瞭解。
hashtable
透過使用雜湊函式將 key
對映到某個 "位置",提供了從 key
到相應 value
的對映,從而可以直接檢索所需的值。
傳統雜湊表
hashtable
透過使用雜湊函式將 key
對映到某個 "位置",提供了從鍵到值的對映。然而,在將無限多個鍵對映到有限的記憶體空間時,再完美的雜湊函式都無法避免衝突(兩個不同的鍵將被對映到相同的位置)。為了解決這個問題,傳統雜湊表有幾種衝突解決策略,最常見的是鏈式(chaining
)和線性探測(linear probing
)。
鏈式(chaining
)
鏈式是最常見的方法,如果多個鍵對映到同一位置,這些鍵和值就會儲存在一個連結串列中。在查詢過程中,雜湊函式用於查詢位置,然後遍歷該位置的連結串列以查詢匹配的鍵。其結構與此類似:
圖 1:雜湊表的鏈式實現
鏈式處理簡單易行,需要考慮的邊界條件較少。資料插入和刪除都很快捷,使用頭部插入來新增新條目,調整下一個指標來刪除資料。鏈式處理還能將過長的鏈轉換為搜尋樹,以防效能下降。不過,鏈式處理對快取不友好,如果衝突較多,效能就會受到影響。不同的槽位(slot
)可能在記憶體中分佈很廣,導致資料結構的整體空間定位性差。
線性探測(linear probing
)
線性探測是另一種標準的雜湊表衝突解決方案。與鏈式搜尋不同,當發生雜湊衝突時,從衝突位置開始依次搜尋,直到找到空槽位或迴圈回到衝突位置。此時,它會調整條目大小並重新雜湊。
圖 2:線性探測
查詢的工作原理類似:計算鍵的雜湊位置,然後從該位置開始比較每個鍵,跳過任何已刪除條目,直到出現空槽位,表示鍵不在表中。刪除使用墓碑標記。
圖 3:線性探測查詢
線性探測的時間複雜度與鏈式探測相當。優點是對快取友好,可透過陣列等緊密資料結構實現。然而,它的缺點是:
- 實現複雜,
slot
有三種狀態:佔用、空閒和刪除。 - 衝突的連鎖反應,造成比鏈式更頻繁的大小調整,記憶體使用量可能更大。
- 如果不能將衝突嚴重的區域轉化為搜尋樹,查詢過程降級為
O(n)
的可能性會更大。
由於線性探測在元素刪除和衝突連鎖反應方面存在困難,大部分庫都採用鏈式方案。儘管線性探測存在一些缺點,但其對快取的友好性和記憶體效率在現代計算機上具有顯著的效能優勢,因此被用於 Golang 和 Python 等語言中。
Go map 資料儲存
我們回顧一下 Go Map
是如何儲存資料的:
圖 4:Go Map
快速總結:
- Go
map
使用雜湊函式將鍵對映到多個桶(bucket
),每個桶都有固定數量的鍵值儲存槽(slot
)。 - 每個儲存桶最多可儲存 8 個鍵值對。發生衝突時,衝突的鍵和值會儲存在同一個桶中。
- 使用雜湊函式計算鍵的雜湊值,並找到相應的桶。
- 如果桶已滿(8 個插位都已使用),就會生成一個溢位桶(
overflow bucket
),繼續儲存新的鍵值對。 - 查詢時,先計算鍵的雜湊值,然後確定相應的桶,並檢查桶內每個插槽。如果有溢位桶,也會按順序檢查其鍵。
SwissTable:高效雜湊表實現
SwissTable
是一種基於改進的線性探測方法的 hashtable
實現,核心理念是透過增強雜湊表結構和後設資料儲存來最佳化效能和記憶體使用。SwissTable 採用新的後設資料控制機制,大大減少了不必要的鍵比較,並利用 SIMD 指令提高了吞吐量。
回顧兩種標準雜湊表實現,可以發現它們要麼浪費記憶體、對快取不友好,要麼在衝突後的查詢、插入和刪除操作中效能下降。即使有 "完美雜湊函式",問題依然存在,而次優雜湊函式會大大增加鍵衝突的機率,並降低效能,甚至可能無法在陣列中進行線性搜尋。
業界一直在尋找一種對快取記憶體友好又能防止查詢效能下降的雜湊表演算法。許多人致力於開發更好的雜湊函式,以接近 "完美雜湊函式" 的質量,同時最佳化計算效能;還有人致力於改進雜湊表結構,以平衡快取友好性、效能和記憶體使用。swisstable屬於後者。
SwissTable
的時間複雜度類似於線性探測,而空間複雜度則介於鏈式探測和線性探測之間,參考實現主要基於 dolthub/swiss。
SwissTable 基本結構
雖然名字變了,但 swisstable
仍是 hashtable
,採用了改進的線性探測方法來處理雜湊衝突,底層結構類似於陣列。現在,我們深入瞭解一下 swisstable
的結構:
type Map[K comparable, V any] struct {
ctrl []metadata
groups []group[K, V]
hash maphash.Hasher[K]
resident uint32
dead uint32
limit uint32
}
type metadata [groupSize]int8
type group[K comparable, V any] struct {
keys [groupSize]K
values [groupSize]V
}
在 swisstable
中,ctrl
是後設資料(metadata
)陣列,與 group[K, V]
陣列相對應,每個組(group
)有 8 個槽位(slot
)。
雜湊值中的 57 位稱為 H1,用於確定起始分組,其餘 7 位稱為 H2,作為當前鍵的雜湊值簽名儲存在後設資料中,用於後續搜尋和過濾。
與傳統雜湊表相比,swisstable
的主要優勢在於名為 ctrl
的後設資料。控制資訊包括:
- 槽位是否為空:
0b10000000
- 槽位是否已刪除:
0b11111110
- 槽位中鍵的雜湊簽名 (H2):
0bh2
這些狀態的唯一值允許使用 SIMD 指令,從而最大限度提高效能。
新增資料
在 swisstable
中新增資料的過程包括幾個步驟:
- 計算雜湊值,並將其分為
h1
和h2
。基於h1
確定起始分組。 - 透過
metaMatchH2
檢查當前組後設資料中是否有匹配的h2
。如果找到,則進一步檢查匹配的鍵,如果匹配則更新值。 - 如果沒有找到匹配的鍵,則透過
metaMatchEmpty
檢查當前組中的空槽。如果發現空槽,則插入新的鍵值對,並更新後設資料和resident
計數。 - 如果當前組沒有空槽位,則執行線性探測,檢查下一組。
func (m *Map[K, V]) Put(key K, value V) {
if m.resident >= m.limit {
m.rehash(m.nextSize())
}
hi, lo := splitHash(m.hash.Hash(key))
g := probeStart(hi, len(m.groups))
for { // 內聯查詢迴圈
matches := metaMatchH2(&m.ctrl[g], lo)
for matches != 0 {
s := nextMatch(&matches)
if key == m.groups[g].keys[s] { // 更新
m.groups[g].keys[s] = key
m.groups[g].values[s] = value
return
}
}
matches = metaMatchEmpty(&m.ctrl[g])
if matches != 0 { // 插入
s := nextMatch(&matches)
m.groups[g].keys[s] = key
m.groups[g].values[s] = value
m.ctrl[g][s] = int8(lo)
m.resident++
return
}
g += 1 // 線性探測
if g >= uint32(len(m.groups)) {
g = 0
}
}
}
func metaMatchH2(m *metadata, h h2) bitset {
return hasZeroByte(castUint64(m) ^ (loBits * uint64(h)))
}
func nextMatch(b *bitset) uint32 {
s := uint32(bits.TrailingZeros64(uint64(*b)))
*b &= ^(1 << s)
return s >> 3
}
雖然步驟不多,但卻涉及複雜的位操作。通常,h2
需要依次與所有鍵進行比較,直到找到目標:
- 將
h2
乘以0x01010101010101
得到一個 uint64 值,可同時與 8 個ctrl
值進行比較。 - 與
meta
進行xor
運算。如果後設資料中存在h2
,則相應位將為 0。metaMatchH2
函式可幫助我們理解這一過程。
func TestMetaMatchH2(t *testing.T) {
metaData := make([]metadata, 2)
metaData[0] = [8]int8{0x7f, 0, 0, 0x7f, 0, 0, 0, 0x7f}
m := &metaData[0]
h := 0x7f
metaUint64 := castUint64(m)
h2Pattern := loBits * uint64(h)
xorResult := metaUint64 ^ h2Pattern
fmt.Printf("metaUint64: %b\n", xorResult)
r := hasZeroByte(xorResult)
fmt.Printf("r: %b\n", r)
for r != 0 {
fmt.Println(nextMatch(&r))
}
}
----
輸出
// metaUint64: 00000000 11111110 11111110 11111110 0000000 01111111 01111111 00000000
// r: 10000000 00000000 00000000 00000000 10000000 00000000 00000000 10000000
// 0
// 3
// 7
SwissTable 的優勢
回顧 SwissTable 的實現,可以發現幾個主要優勢:
- 操作從
slot
轉移到ctrl
,ctrl
更小,更容易放入 CPU 快取,儘管多了一個定位slot
的步驟,但還是加快了操作速度。 - 記錄雜湊簽名,減少無意義的金鑰比較(這是線性探測效能下降的主要原因)。
- 對
slot
的ctrl
進行批次操作可顯著提高吞吐量。 - 後設資料和記憶體佈局針對 SIMD 指令進行了最佳化,最大限度提高了效能。
slot
最佳化(如壓縮大資料)可提高快取命中率。
swisstable
解決了空間區域性性問題,並利用現代 CPU 能力進行批次運算,大大提高了效能。
最後,在本地 MacBook M1(不支援 SIMD)上執行的基準測試表明,在大 map 場景下效能有顯著提升。
圖 5:官方 swisstable
基準測試
結論
目前,swisstable
在 Go 中的官方實現仍在討論中,也有一些社群實現,如 concurrent-swiss-map 和 swiss,不過還不夠完美,在小 map 場景中 swisstable
的效能甚至可能不如 runtime_map
。儘管如此,swisstable
在其他語言中展現出的潛力表明它值得期待。
參考資料
- Dolthub: SwissMap
- SwissTable 原理: Abseil SwissTables
- cppcon 原始 SwissTable 提案
- 改進 SwissTable 演算法
- 位操作入門:Stanford Bit Hacks
- 雜湊函式比較測試
An additional bit manipulation article: Fast Modulo Reduction - 另一篇位操作文章:快速模運算
你好,我是俞凡,在Motorola做過研發,現在在Mavenir做技術工作,對通訊、網路、後端架構、雲原生、DevOps、CICD、區塊鏈、AI等技術始終保持著濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。為了方便大家以後能第一時間看到文章,請朋友們關注公眾號"DeepNoMind",並設個星標吧,如果能一鍵三連(轉發、點贊、在看),則能給我帶來更多的支援和動力,激勵我持續寫下去,和大家共同成長進步!
本文由mdnice多平臺釋出