SwissTable:高效能雜湊表實現

俞凡發表於2024-12-01
本文介紹了一種新的高效能雜湊表實現: 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 實現的標準。

本文不會全面解釋 hashtableswisstable 的原理,需要讀者對 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 中新增資料的過程包括幾個步驟:

  • 計算雜湊值,並將其分為 h1h2。基於 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 轉移到 ctrlctrl 更小,更容易放入 CPU 快取,儘管多了一個定位 slot 的步驟,但還是加快了操作速度。
  • 記錄雜湊簽名,減少無意義的金鑰比較(這是線性探測效能下降的主要原因)。
  • slotctrl 進行批次操作可顯著提高吞吐量。
  • 後設資料和記憶體佈局針對 SIMD 指令進行了最佳化,最大限度提高了效能。
  • slot 最佳化(如壓縮大資料)可提高快取命中率。

swisstable 解決了空間區域性性問題,並利用現代 CPU 能力進行批次運算,大大提高了效能。

最後,在本地 MacBook M1(不支援 SIMD)上執行的基準測試表明,在大 map 場景下效能有顯著提升。

圖 5:官方 swisstable 基準測試

結論

目前,swisstable 在 Go 中的官方實現仍在討論中,也有一些社群實現,如 concurrent-swiss-mapswiss,不過還不夠完美,在小 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多平臺釋出

相關文章