改變一個字元讓Go程式快42%

banq 發表於 2022-11-17
Go

codeowners是一個 Go 程式,它根據GitHubCODEOWNERS檔案中定義的一組規則列印出儲存庫中每個檔案的所有者。

在考慮給定路徑時,最後匹配的規則獲勝:一個簡單但幼稚的匹配演算法透過每條路徑的規則向後迭代,在找到匹配項時停止:

type Ruleset Rule

func (r Ruleset) Match(path string) (*Rule, error) {
  for i := len(r) - 1; i >= 0; i-- {
    rule := r
    match, err := rule.Match(path)
    if match || err != nil {
      return &rule, err
    }
  }
  return nil, nil
}


使用 pprof 和火焰圖查詢瓶頸
上述這個工具在針對中等大型儲存庫執行時有點慢,為了檢視程式將時間花在哪裡,我使用 pprof 記錄了一個 CPU 配置檔案。您可以透過將此程式碼段新增到main函式頂部來獲取生成的 CPU 配置檔案:

pprofFile, pprofErr := os.Create("cpu.pprof")
if pprofErr != nil {
  log.Fatal(pprofErr)
}
pprof.StartCPUProfile(pprofFile)
defer pprof.StopCPUProfile()


我經常使用 pprof,所以我將該程式碼儲存為vscode 片段。我只需鍵入pprof,點選選項卡,就會出現該片段。

Go 帶有一個方便的互動式配置檔案視覺化工具。我透過執行以下命令將配置檔案視覺化為火焰圖,然後導航到頁面頂部選單中的火焰圖檢視。

$ go tool pprof -http=":8000" ./codeowners ./cpu.pprof

正如我所料,大部分時間都花在了那個Match功能上。CODEOWNERS 模式被編譯為正規表示式,Match函式的大部分時間花在了 Go 的正規表示式引擎上。但我也注意到很多時間花在分配和回收記憶體上。

下面火焰圖中的紫色塊與模式匹配gc|malloc,您可以看到它們總體上代表了程式執行時間的重要部分。

改變一個字元讓Go程式快42%

使用逃逸分析跟蹤尋找堆分配
那麼就要看看是否有任何堆分配我們可以擺脫以減少 GC 壓力和花費在malloc.

Go 編譯器使用一種稱為逃逸分析的技術來確定何時需要將一些記憶體駐留在堆上:
假設一個函式初始化一個結構然後返回一個指向它的指標。如果結構是在堆疊上分配的,那麼一旦函式返回並且相應的堆疊幀失效,返回的指標就會失效。
在這種情況下,Go 編譯器會確定指標已經“逃逸”了函式,並將結構移至堆中。

您可以在build構建時,透過傳遞-gcflags=-m來檢視:

$ go build -gcflags=-m *.go 2>&1 | grep codeowners.go
./codeowners.go:82:18: inlining call to os.IsNotExist
./codeowners.go:71:28: inlining call to filepath.Join
./codeowners.go:52:19: inlining call to os.Open
./codeowners.go:131:6: can inline Rule.Match
./codeowners.go:108:27: inlining call to Rule.Match
./codeowners.go:126:6: can inline Rule.RawPattern
./codeowners.go:155:6: can inline Owner.String
./codeowners.go:92:29: ... argument does not escape
./codeowners.go:96:33: string(output) escapes to heap
./codeowners.go:80:17: leaking param: path
./codeowners.go:70:31: string{...} does not escape
./codeowners.go:71:28: ... argument does not escape
./codeowners.go:51:15: leaking param: path
./codeowners.go:105:7: leaking param content: r
./codeowners.go:105:24: leaking param: path
./codeowners.go:107:3: moved to heap: rule
./codeowners.go:126:7: leaking param: r to result ~r0 level=0
./codeowners.go:131:7: leaking param: r
./codeowners.go:131:21: leaking param: path
./codeowners.go:155:7: leaking param: o to result ~r0 level=0
./codeowners.go:159:13: "@" + o.Value escapes to heap



由於我們正在尋找heap堆分配,"moved to heap "是我們應該關注的短語。
回顧上面的Match程式碼,規則結構被儲存在Ruleset片中,我們可以確信它已經在堆中了。由於返回的是一個指向規則的指標,因此不需要額外的分配。然後我看到了:透過分配 rule := 【i】,我們將堆heap中分配的 Rule 從堆片斷中複製到棧stack中,然後再透過返回 &rule,我們建立了一個指向該結構副本的(逃避)指標。
幸運的是,解決這個問題很容易。我們只需要將“&”往上移一點,這樣我們就可以在片斷中獲取一個結構的引用,而不是複製它:

func (r Ruleset) Match(path string) (*Rule, error) {
     for i := len(r) - 1; i >= 0; i-- {
-        rule := r
+        rule := &r
         match, err := rule.Match(path)
         if match || err != nil {
-            return &rule, err
+            return rule, err
         }
     }
     return nil, nil
 }


我確實考慮過另外兩種方法:
  • 將Ruleset從Rule改為*Rule,這將意味著我們不再需要明確地獲取對規則的引用。
    • 返回一個Rule而不是*Rule。這仍然會複製規則,但它應該留在堆疊stack中,而不是移動到堆heap中。



[然而,這兩個方法都會導致一個破壞性的改變,因為這個方法是公共API的一部分。

總之,在做了這個改變之後,我們可以透過從編譯器中得到一個新的跟蹤,並與舊的跟蹤進行比較,來看看它是否達到了預期效果。

$ diff trace-a trace-b
14a15
> ./codeowners.go:105:7: leaking param: r to result ~r0 level=0
16d16
< ./codeowners.go:107:3: moved to heap: rule

成功!分配沒了。現在讓我們看看刪除一個堆分配如何影響效能:

$ hyperfine ./codeowners-a ./codeowners-b
Benchmark 1: ./codeowners-a
  Time (mean ± σ):      4.146 s ±  0.003 s    [User: 5.809 s, System: 0.249 s]
  Range (min … max):    4.139 s …  4.149 s    10 runs

Benchmark 2: ./codeowners-b
  Time (mean ± σ):      2.435 s ±  0.029 s    [User: 2.424 s, System: 0.026 s]
  Range (min … max):    2.413 s …  2.516 s    10 runs

Summary
  ./codeowners-b ran
    1.70 ± 0.02 times faster than ./codeowners-a

由於該分配是針對匹配的每條路徑進行的,因此在這種情況下,刪除它會使速度提高 1.7 倍(意味著它的執行速度提高了 42%)。