Go 真實項目的性能案例研究

爭做團隊核心程序員,關注「幽鬼」

大家好,我是程序員幽鬼。

Dolt DB[1] 是世界上第一個可以像 git 存儲庫一樣分支和合並、推送和拉取、分叉和克隆的 SQL 數據庫。

我們從頭開始構建 Dolt 的存儲引擎,以加快這些操作。編寫行存儲引擎和 SQL 執行引擎並不容易。大多數人甚至不嘗試它是有原因的,但在 DoltHub,我們的構建方式不同。

今天,我們將回顧幾個在對 Dolt 進行基準測試以使行訪問與 MySQL 一樣快時遇到的性能問題的案例研究。每個案例研究都是我們遇到並解決的實際性能問題。

讓我們開始吧。

案例研究:接口斷言

在嘗試以更快的方式將行從磁盤中讀出並通過執行引擎獲取時,我們決定製作新的行迭代接口。因爲我們已經有了許多原始接口的實現,所以我們認爲可以擴展它,如下所示:

type RowIter interface {
    Next(ctx *Context) (Row, error)
}

type RowIter2 interface {
    RowIter
    Next2(ctx *Context, frame *RowFrame) error
}

我們的 SQL 引擎的架構涉及一個深度嵌套的執行圖,圖中的每個節點從下一層獲取一行,對其進行一些工作,然後將其傳遞到鏈上。當我們在迭代器中實現該Next2方法時,它看起來像這樣:

type iter struct {
    childIter  sql.RowIter
}

func (t iter) Next2(ctx *sql.Context, frame *sql.RowFrame) error {
    return t.childIter.(sql.RowIter2).Next2(ctx, frame)
}

但是當我們運行這段代碼時,我們在分析器圖表中注意到,由於這種模式,我們付出了非常可觀的性能損失。在我們的 CPU 圖表中,我們注意到很多時間都花在了 runtime.assertI2I

圖片

runtime.assertI2I是 go 運行時調用的方法,用於驗證接口的靜態類型是否可以在運行時轉換爲不同的類型。這涉及到接口表的查找和接口指針轉換,這不是那麼昂貴,但也肯定不是免費的。由於嵌套的執行圖,我們在每行獲取的數據中多次執行此操作,從而導致相當嚴重的性能損失。

解決方案:消除接口類型轉換

爲了消除這種損失,我們只需在必要的地方存儲兩個字段來消除轉換,每個靜態類型一個字段。

type iter struct {
    childIter  sql.RowIter
    childIter2 sql.RowIter2
}
func (t iter) Next(...) {
    return t.childIter.Next(ctx)
}
func (t iter) Next2(...) {
    return t.childIter2.Next2(ctx, frame)
}

這兩個字段指向內存中的同一個對象,但由於它們具有不同的靜態類型,因此它們需要不同的字段以避免轉換損失。

案例研究:切片

接下來,我們將研究分配切片的不同方法及其對性能的影響,尤其是垃圾收集器開銷。我們將分析一種生成隨機元素切片然後將它們相加的方法。我們將在我們的個人資料中一遍又一遍地調用它。

func sumArray() uint64 {
    a := randArray()
    var sum uint64
    for i := range a {
        sum += uint64(a[i].(byte))
    }
    return sum
}

我們將研究實現該randArray() 功能的 4 種不同方法,從最差到最好,並展示不同實現對程序性能的影響。

最差:append 到切片

獲得這個切片的更糟糕的方法是創建一個長度爲零的切片,然後一遍又一遍地調用 append,如下所示:

func randArray() []interface{} {
    var a []interface{}
    for i := 0; i < 1000; i++ {
        a = append(a, byte(rand.Int() % 255))
    }
    return a
}

不管我們是從上面的 nil 切片開始,還是用 make([]interface{}, 0) 製作一個長度爲零的切片,效果都是一樣的。當我們基於 profile 生成火焰圖時,我們可以看到垃圾收集佔用了巨大的開銷,並且 runtime.growslice佔用了整整四分之一的 CPU 週期。

圖片

總的來說,只有不到一半的 CPU 週期直接用於有用的工作。

之所以如此昂貴,是因爲 go slices 有一個底層數組,當調用 append 時,如果底層數組容量不足,運行時必須分配一個更大的數組,複製所有元素,並回收舊數組。

圖片

我們可以做得比這更好。

更好:靜態數組大小

我們可以通過在創建時固定切片的大小來消除 runtime.growslice 開銷,就像這樣。

func randArray() []interface{} {
    a := make([]interface{}, 1000)
    for i := 0; i < len(a); i++ {
        a[i] = byte(rand.Int() % 255)
    }
    return a
}

當我們 profile 這段代碼時,我們可以看到已經完全消除了 runtime.growslice 的開銷,並減輕了垃圾收集器的壓力。

圖片

你可以一眼看出這個實現花費了更多的時間做有用的工作。但是每次創建切片仍然會對性能造成重大影響。我們整整 13% 的運行時間都花在分配切片上,即 runtime.makeslice.

更好的是:原始切片類型

奇怪的是,我們可以通過分配 byte 切片而不是切片interface{}來做得更好。執行此操作的代碼:

func randArray() []byte {
    a := make([]byte, 1000)
    for i := 0; i < len(a); i++ {
        a[i] = byte(rand.Int() % 255)
    }
    return a
}

當我們查看 profile 時,我們可以看到 runtime.makesliceCPU 的影響從超過 13% 下降到大約 3%。你甚至在火焰圖上幾乎看不到它,而且也很容易看到垃圾收集器開銷的相應減少。

圖片

造成這種差異的原因只是interface{}類型的分配成本更高(一對 8 字節指針,而不是單個字節),而且垃圾收集器推理和處理的成本也更高。這個故事的寓意是,在可能的情況下,分配原始切片類型而不是接口類型通常會在性能方面得到回報。

最佳:在循環外分配

但實現此功能的唯一最佳方法是根本不在其中分配任何內存。相反,讓我們在外部範圍內分配一次切片,然後將其傳遞給此函數以進行填充。

func randArray([]interface{}) {
    for i := 0; i < len(a); i++ {
        a[i] = byte(rand.Int() % 255)
    }
}

當我們這樣做時,我們完全消除了所有垃圾收集壓力,並且我們有效地將所有 CPU 週期用於做有用的工作。

圖片

我們不必將切片作爲參數傳入,我們只需要避免每次調用此函數時都分配它。實現此目的的另一種方法是使用全局sync.pool變量在函數調用之間重用切片。

總結

通過調用 rand.Int 所花費的時間作爲我們花費多少 CPU 時間做有用工作的示例,我們得到以下摘要:

底線:如何使用切片會對程序的整體性能產生非常顯着的影響。

案例研究:結構體還是指針作爲接收器

關於在實現接口時是使用結構體還是指向結構體的指針作爲接收器類型,golang 社區中一直存在着非常激烈的爭論。意見不一。

現實情況是,這兩種方法都需要權衡取捨。將結構體複製到堆棧上通常比指針更昂貴,尤其是對於大型結構。但是將對象保留在堆棧上而不是堆上可以避免垃圾收集器的壓力,這在某些情況下可能會更快。從美學 / 設計的角度來看,也存在權衡:有時確實需要強制執行不變性語義,這可以通過結構體接收器獲得。

讓我們來說明你爲大型結構付出的性能損失。我們將使用一個非常大的 36 個字段,並在其上一遍又一遍地調用一個方法。

type bigStruct struct {
    v1, v2, v3, v4, v5, v6, v7, v8, v9 uint64
    f1, f2, f3, f4, f5, f6, f7, f8, f9 float64
    b1, b2, b3, b4, b5, b6, b7, b8, b9 []byte
    s1, s2, s3, s4, s5, s6, s7, s8, s9 string
}

func (b bigStruct) randFloat() float64 {
    x := rand.Float32()
    switch {
    case x < .1:
        return b.f1
        …
}

當我們一次又一次地分析這個方法時,我們可以看到,我們在一個名爲 runtime.duffcopy 的東西上付出了非常大的代價,35% 的 CPU 週期。

圖片

runtime.duffcopy 是什麼?在某些情況下,這是 go runtime 複製大量連續內存塊(通常是結構)的方式。之所以這樣稱呼它,是因爲 duffcopy 有點像 Duff 的設備,它是 Tom Duff 在 80 年代發現的 C 編譯器黑客。他意識到你可以濫用 C 編譯器通過交錯循環和開關的構造來實現循環展開:

register short *to, *from;
register count;
{
    register n = (count + 7) / 8;
    switch (count % 8) {
    case 0: do { *to = *from++;
    case 7:      *to = *from++;
    case 6:      *to = *from++;
    case 5:      *to = *from++;
    case 4:      *to = *from++;
    case 3:      *to = *from++;
    case 2:      *to = *from++;
    case 1:      *to = *from++;
            } while (--n > 0);
    }
}

循環展開可以極大地加快速度,因爲你不必在每次通過循環時檢查循環條件。Go 的 duffcopy 實際上並不是 Duff 的設備,但它是一個循環展開:編譯器發出 N 條指令來複制內存,而不是使用循環來這樣做。

避免支付這種懲罰的方法是簡單地使用指向結構的指針作爲接收者,避免昂貴的內存複製操作。但是在概括這個建議時要小心——你對哪種技術性能更好的直覺可能是不正確的,因爲它實際上取決於許多與你的應用程序不同的因素。歸根結底,你確實需要對替代方案進行 profile 分析,以瞭解哪些方案總體上表現更好,包括垃圾收集的影響。

總結

要使 Dolt 與更成熟的數據庫技術一樣具有高性能,我們還有很多工作要做,尤其是在查詢計劃方面。但是我們已經設法通過這些簡單的優化將引擎的性能提高了 2 倍,並通過完全重寫我們的存儲層再提高了 3 倍 [2]。在簡單的基準測試中,我們現在的延遲大約是 MySQL 的 2.5 倍,但我們還沒有完成。隨着我們接近 1.0 版本,我們很高興能夠繼續提高我們的數據庫速度。

原文鏈接:https://www.dolthub.com/blog/2022-10-14-golang-performance-case-studies/

參考資料

[1]

Dolt DB: https://doltdb.com/

[2]

並通過完全重寫我們的存儲層再提高了 3 倍: https://www.dolthub.com/blog/2022-09-30-new-format-default/


歡迎關注「幽鬼」,像她一樣做團隊的核心。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NVkJswK4SUaXtTPb2-Od9g