Go 真實項目的性能案例研究
大家好,我是程序員幽鬼。
Dolt DB[1] 是世界上第一個可以像 git 存儲庫一樣分支和合并、推送和拉取、分叉和克隆的 SQL 數據庫。
我們從頭開始構建 Dolt 的存儲引擎,以加快這些操作。編寫行存儲引擎和 SQL 執行引擎并不容易。大多數人甚至不嘗試它是有原因的,但在 DoltHub,我們的構建方式不同。
今天,我們將回顧幾個在對 Dolt 進行基準測試以使行訪問與 MySQL 一樣快時遇到的性能問題的案例研究。每個案例研究都是我們遇到并解決的實際性能問題。
讓我們開始吧。
案例研究:接口斷言
在嘗試以更快的方式將行從磁盤中讀出并通過執行引擎獲取時,我們決定制作新的行迭代接口。因為我們已經有了許多原始接口的實現,所以我們認為可以擴展它,如下所示:
我們的 SQL 引擎的架構涉及一個深度嵌套的執行圖,圖中的每個節點從下一層獲取一行,對其進行一些工作,然后將其傳遞到鏈上。當我們在迭代器中實現該Next2方法時,它看起來像這樣:
但是當我們運行這段代碼時,我們在分析器圖表中注意到,由于這種模式,我們付出了非常可觀的性能損失。在我們的 CPU 圖表中,我們注意到很多時間都花在了 runtime.assertI2I:
runtime.assertI2I是 go 運行時調用的方法,用于驗證接口的靜態類型是否可以在運行時轉換為不同的類型。這涉及到接口表的查找和接口指針轉換,這不是那么昂貴,但也肯定不是免費的。由于嵌套的執行圖,我們在每行獲取的數據中多次執行此操作,從而導致相當嚴重的性能損失。
解決方案:消除接口類型轉換
為了消除這種損失,我們只需在必要的地方存儲兩個字段來消除轉換,每個靜態類型一個字段。
這兩個字段指向內存中的同一個對象,但由于它們具有不同的靜態類型,因此它們需要不同的字段以避免轉換損失。
案例研究:切片
接下來,我們將研究分配切片的不同方法及其對性能的影響,尤其是垃圾收集器開銷。我們將分析一種生成隨機元素切片然后將它們相加的方法。我們將在我們的個人資料中一遍又一遍地調用它。
我們將研究實現該randArray() 功能的 4 種不同方法,從最差到最好,并展示不同實現對程序性能的影響。
最差:append 到切片
獲得這個切片的更糟糕的方法是創建一個長度為零的切片,然后一遍又一遍地調用 append,如下所示:
不管我們是從上面的 nil 切片開始,還是用 make([]interface{}, 0) 制作一個長度為零的切片,效果都是一樣的。當我們基于 profile 生成火焰圖時,我們可以看到垃圾收集占用了巨大的開銷,并且 runtime.growslice占用了整整四分之一的 CPU 周期。
總的來說,只有不到一半的 CPU 周期直接用于有用的工作。
之所以如此昂貴,是因為 go slices 有一個底層數組,當調用 append 時,如果底層數組容量不足,運行時必須分配一個更大的數組,復制所有元素,并回收舊數組。
我們可以做得比這更好。
更好:靜態數組大小
我們可以通過在創建時固定切片的大小來消除 runtime.growslice 開銷,就像這樣。
當我們 profile 這段代碼時,我們可以看到已經完全消除了 runtime.growslice 的開銷,并減輕了垃圾收集器的壓力。
你可以一眼看出這個實現花費了更多的時間做有用的工作。但是每次創建切片仍然會對性能造成重大影響。我們整整 13% 的運行時間都花在分配切片上,即 runtime.makeslice.
更好的是:原始切片類型
奇怪的是,我們可以通過分配 byte 切片而不是切片interface{}來做得更好。執行此操作的代碼:
當我們查看 profile 時,我們可以看到 runtime.makesliceCPU 的影響從超過 13% 下降到大約 3%。你甚至在火焰圖上幾乎看不到它,而且也很容易看到垃圾收集器開銷的相應減少。
造成這種差異的原因只是interface{}類型的分配成本更高(一對 8 字節指針,而不是單個字節),而且垃圾收集器推理和處理的成本也更高。這個故事的寓意是,在可能的情況下,分配原始切片類型而不是接口類型通常會在性能方面得到回報。
最佳:在循環外分配
但實現此功能的唯一最佳方法是根本不在其中分配任何內存。相反,讓我們在外部范圍內分配一次切片,然后將其傳遞給此函數以進行填充。
當我們這樣做時,我們完全消除了所有垃圾收集壓力,并且我們有效地將所有 CPU 周期用于做有用的工作。
我們不必將切片作為參數傳入,我們只需要避免每次調用此函數時都分配它。實現此目的的另一種方法是使用全局sync.pool變量在函數調用之間重用切片。
總結
通過調用 rand.Int 所花費的時間作為我們花費多少 CPU 時間做有用工作的示例,我們得到以下摘要:
- appending: 20%
- interface{} 類型靜態切片: 38%
- 靜態 byte 切片 : 62%
- 切片作為參數: 70%
底線:如何使用切片會對程序的整體性能產生非常顯著的影響。
關于在實現接口時是使用結構體還是指向結構體的指針作為接收器類型,golang 社區中一直存在著非常激烈的爭論。意見不一。
現實情況是,這兩種方法都需要權衡取舍。將結構體復制到堆棧上通常比指針更昂貴,尤其是對于大型結構。但是將對象保留在堆棧上而不是堆上可以避免垃圾收集器的壓力,這在某些情況下可能會更快。從美學/設計的角度來看,也存在權衡:有時確實需要強制執行不變性語義,這可以通過結構體接收器獲得。
讓我們來說明你為大型結構付出的性能損失。我們將使用一個非常大的 36 個字段,并在其上一遍又一遍地調用一個方法。
當我們一次又一次地分析這個方法時,我們可以看到,我們在一個名為 runtime.duffcopy 的東西上付出了非常大的代價,35% 的 CPU 周期。
runtime.duffcopy 是什么?在某些情況下,這是 go runtime 復制大量連續內存塊(通常是結構)的方式。之所以這樣稱呼它,是因為 duffcopy 有點像 Duff 的設備,它是 Tom Duff 在 80 年代發現的 C 編譯器黑客。他意識到你可以濫用 C 編譯器通過交錯循環和開關的構造來實現循環展開:
循環展開可以極大地加快速度,因為你不必在每次通過循環時檢查循環條件。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/