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