PGO: 爲你的 Go 程序提效 5-

PGO (基於 profile 指導的優化) 在 Go 1.20 中還屬於預覽狀態, 在 Go 1.21 中已經生產可用了, 未來 PGO 還有很宏偉的目標 [1],但是現在已經可以很好的幫助我們提高程序的性能呢,根據程序的不同,可能會帶來 2% ~ 7% 的提升,不要小看這個提升,如果你是在大廠做優化的專家,可以這樣計算: “我廠大概有 10 萬 Go 實例,每個實例平均佔用 16 個核,通過我們的優化,程序性能平均提升 5%, 大約節省 9 萬個核,每年爲公司節省數億元的成本”。

最近看到兩篇關於 PGO 的文章:Profile Guided Optimizations in Go[2] 和 Go 官方的博客 Profile-guided optimization in Go 1.21[3]。相比較而言, Go 官方這篇文章簡單明瞭,而且把 · 細節也交代的明明白白,所以我就把這篇文章翻譯過來,我感覺我自己寫也沒有官方這篇寫的明白,翻譯過來就好了。

以下是譯文:

2023 年初, Go 1.20 發佈了基於 profile 指導的優化 (PGO) 的預覽版本, 供用戶進行測試。在解決了預覽版本中已知的限制, 並藉助社區反饋和貢獻進行了進一步錘鍊後, Go 1.21 中的 PGO 功能已準備好用於廣泛的生產環境! 有關完整文檔, 請參閱用戶指南 [4]。

下面我們將通過一個示例來演示如何使用 PGO 提高應用程序的性能。在深入示例之前, 什麼是 “基於 profile 指導的優化”?

當你構建一個 Go 二進制文件時, Go 編譯器會執行優化, 試圖生成性能最佳的二進制文件。例如, 常量傳播 (constant propagation) 可以在編譯時計算常量表達式的值, 避免了運行時的計算開銷。逃逸分析 (Escape analysis) 可以避免爲局部作用域的對象分配堆內存, 從而避免 GC 的開銷。內聯 (Inlining) 會將簡單函數的函數體拷貝到調用者中, 這通常可以在調用者中啓用進一步的優化 (例如額外的常量傳播或更好的逃逸分析)。去虛擬化 (Devirtualization)會將接口值上的間接調用 (如果可以靜態確定其類型) 轉換爲對具體方法的直接調用(這通常可以內聯該調用)。

Go 在每個版本中都在提升優化, 但這並非易事。一些優化是可調的, 但是編譯器不能對每項優化都 “turn it up to 11” (英語典故,形容把某事物調到極限狀態或者超出常規限度), 因爲過於激進的優化實際上可能會損害性能或者導致過長的構建時間。其他優化需要編譯器對函數中的“常見路徑” 和“非常見路徑”做出判斷。編譯器必須根據靜態啓發式方法進行最佳猜測, 因爲它無法知道運行時哪些分支更常見。

或者編譯器可以做到嗎?

沒有關於代碼在生產環境中的使用方式的確定信息, 編譯器只能對包的源代碼進行操作。但是我們確實有一個工具來評估生產行爲: profile (剖析,又叫性能分析, 後面我們保持英文不翻譯)。如果我們向編譯器提供一個 profile, 它可以做出更明智的決定: 更積極地優化使用最頻繁的函數, 或更準確地選擇常見情況。

使用應用程序行爲的 profile 進行編譯器優化稱爲基於 profile 指導的優化 (PGO)(也稱爲性能分析引導優化、反饋導向優化 (FDO))。

示例

好的, 讓我們構建一個將 Markdown 轉換爲 HTML 的服務: 用戶將 Markdown 源上傳到 / render, 它會返回 HTML 轉換結果。我們可以使用gitlab.com/golang-commonmark/markdown來輕鬆實現這一功能。

搭建

創建一個文件夾並執行下面的命令:

$ go mod init example.com/markdown
$ go get gitlab.com/golang-commonmark/markdown@bf3e522c626a

創建 main.go 文件:

package main

import (
    "bytes"
    "io"
    "log"
    "net/http"
    _ "net/http/pprof"

    "gitlab.com/golang-commonmark/markdown"
)

func render(w http.ResponseWriter, r *http.Request) {
    if r.Method != "POST" {
        http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
        return
    }

    src, err := io.ReadAll(r.Body)
    if err != nil {
        log.Printf("error reading body: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }

    md := markdown.New(
        markdown.XHTMLOutput(true),
        markdown.Typographer(true),
        markdown.Linkify(true),
        markdown.Tables(true),
    )

    var buf bytes.Buffer
    if err := md.Render(&buf, src); err != nil {
        log.Printf("error converting markdown: %v", err)
        http.Error(w, "Malformed markdown", http.StatusBadRequest)
        return
    }

    if _, err := io.Copy(w, &buf); err != nil {
        log.Printf("error writing response: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }
}

func main() {
    http.HandleFunc("/render", render)
    log.Printf("Serving on port 8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

構建並運行這個服務:

$ go build -o markdown.nopgo.exe
$ ./markdown.nopgo.exe
2023/08/23 03:55:51 Serving on port 8080...

好的, 我們可以從另一個終端發送一些 Markdown 過來試試。我們可以使用 Go 項目中的README.md作爲示例文檔:

$ curl -o README.md -L "https://raw.githubusercontent.com/golang/go/c16c2c49e2fa98ae551fc6335215fadd62d33542/README.md"
$ curl --data-binary @README.md http://localhost:8080/render
<h1>The Go Programming Language</h1>
<p>Go is an open source programming language that makes it easy to build simple,
reliable, and efficient software.</p>
...

獲取 profile

既然我們已經有了一個工作的服務, 讓我們收集一個 profile 並用 PGO 重新構建, 看看是否可以獲得更好的性能。

在 main.go 中, 我們導入了net/http/pprof, 它會自動在服務器上添加一個/debug/pprof/profile地址來獲取 CPU profile。

通常你想要從生產環境中收集 profile, 這樣編譯器可以獲得生產環境中的代表性行爲視圖。由於這個示例沒有 “生產” 環境, 我創建了一個簡單的程序來在收集 profile 時生成壓測負載。獲取並啓動負載生成器(確保服務器仍在運行!):

$ go run github.com/prattmic/markdown-pgo/load@latest

在負載生成器運行時, 從服務器下載一個性能分析:

$ curl -o cpu.pprof "http://localhost:8080/debug/pprof/profile?seconds=30"

一旦 profile 下載完成, 終止負載生成器和服務器。

使用 profile

Go 工具鏈如果在 main 包目錄中找到名爲 default.pgo 的概要文件,就會自動啓用 PGO。或者, 在 go build 中使用-pgo標誌接受一個文件路徑作爲 PGO 要使用的 profile 路徑。

我們建議將 profile 文件提交到你的倉庫中。將 profile 文件與源代碼一起存儲可以確保用戶只需獲取倉庫 (通過版本控制系統或 go get) 就可以自動訪問 profile 文件, 並且可以保證構建是可重現的。

讓我們構建它:

$ mv cpu.pprof default.pgo
$ go build -o markdown.withpgo.exe

我們可以通過 go version 檢查 PGO 是否在構建中被啓用:

$ go version -m markdown.withpgo.exe
./markdown.withpgo.exe: go1.21.0
...
        build   -pgo=/tmp/pgo121/default.pgo

評估

我們將使用負載生成器的 Go 基準測試版本來評估 PGO 對性能的影響。

首先, 我們爲沒有使用 PGO 優化的服務器進行基準測試。啓動那個服務器:

$ ./markdown.nopgo.exe

在服務運行時,執行幾次基準測試:

$ go get github.com/prattmic/markdown-pgo@latest
$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > nopgo.txt

一旦完成, 終止原始服務器並啓動帶 PGO 的版本:

$ ./markdown.withpgo.exe

在服務運行時,也執行同樣次數的基準測試:

$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > withpgo.txt

一旦完成,比較兩次的測試結果:

$ go install golang.org/x/perf/cmd/benchstat@latest
$ benchstat nopgo.txt withpgo.txt
goos: linux
goarch: amd64
pkg: github.com/prattmic/markdown-pgo/load
cpu: Intel(R) Xeon(R) W-2135 CPU @ 3.70GHz
        │  nopgo.txt  │            withpgo.txt             │
        │   sec/op    │   sec/op     vs base               │
Load-12   374.5µ ± 1%   360.2µ ± 0%  -3.83% (p=0.000 n=40)

新的版本大約快了 3.8%! 在 Go 1.21 中, 啓用 PGO 後, 工作負載的 CPU 使用率通常可以提高 2% 到 7%。profile 文件其實包含了大量關於應用程序行爲的信息, Go 1.21 只是開始利用這些信息進行有限的幾項優化。隨着編譯器的更多部分利用 PGO, 未來的版本將繼續改進性能。

後續步驟

在這個示例中, 收集 profile 文件後, 我們使用與原始構建完全相同的源代碼重新構建了服務器。在實際場景中, 代碼都在不斷開發。所以我們可能會從運行上週代碼的生產環境收集 profile 文件, 並用它來構建今天的源代碼。這完全沒問題! Go 中的 PGO 可以毫無問題地處理源代碼的細微變更。當然, 隨着時間推移, 源代碼會越來越不相同, 所以定期更新 profile 文件仍然很重要。

關於使用 PGO 的更多信息、最佳實踐和需要注意的警告, 請參閱基於 profile 指導的優化用戶指南 [5]。如果您好奇咋優化的, 請繼續閱讀!

原理剖析

爲了更好地理解是什麼讓這個應用程序變得更快,讓我們深入瞭解一下性能是如何改進的。我們將查看兩種不同的 PGO 驅動的優化。

內聯

爲了觀察內聯優化的改進,讓我們分別分析這個 Markdown 應用程序在使用 PGO 和不使用 PGO 時的情況。

我將使用一種稱爲差異性分析的技術進行比較,其中我們收集兩個不同的性能分析數據(一個使用 PGO,一個不使用 PGO),然後進行比較。對於差異性分析,重要的是兩個 profile 數據代表了相同數量的工作量,而不是相同數量的時間。因此,我已經調整了服務器以自動收集性能分析數據,還調整了負載生成器以發送固定數量的請求,然後退出服務器。

我已經對服務器進行了更改,依然收集到的 profile 數據, 代碼可以在 https://github.com/prattmic/markdown-pgo 找到。負載生成器使用了參數-count=300000 -quit來運行。

作爲一個快速的一致性檢查,讓我們來看一下處理所有 300,000 個請求所需的總 CPU 時間:

$ go tool pprof -top cpu.nopgo.pprof | grep "Total samples"
Duration: 116.92s, Total samples = 118.73s (101.55%)
$ go tool pprof -top cpu.withpgo.pprof | grep "Total samples"
Duration: 113.91s, Total samples = 115.03s (100.99%)

CPU 時間從約 118 秒下降到約 115 秒,下降了約 3%。這與我們的基準測試結果一致,這是這些 profile 數據具有代表性的一個好現象。

現在我們可以打開一個差異性分析數據來尋找節省的部分:

$ go tool pprof -diff_base cpu.nopgo.pprof cpu.withpgo.pprof
File: markdown.profile.withpgo.exe
Type: cpu
Time: Aug 28, 2023 at 10:26pm (EDT)
Duration: 230.82s, Total samples = 118.73s (51.44%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top -cum
Showing nodes accounting for -0.10s, 0.084% of 118.73s total
Dropped 268 nodes (cum <= 0.59s)
Showing top 10 nodes out of 668
      flat  flat%   sum%        cum   cum%
    -0.03s 0.025% 0.025%     -2.56s  2.16%  gitlab.com/golang-commonmark/markdown.ruleLinkify
     0.04s 0.034% 0.0084%     -2.19s  1.84%  net/http.(*conn).serve
     0.02s 0.017% 0.025%     -1.82s  1.53%  gitlab.com/golang-commonmark/markdown.(*Markdown).Render
     0.02s 0.017% 0.042%     -1.80s  1.52%  gitlab.com/golang-commonmark/markdown.(*Markdown).Parse
    -0.03s 0.025% 0.017%     -1.71s  1.44%  runtime.mallocgc
    -0.07s 0.059% 0.042%     -1.62s  1.36%  net/http.(*ServeMux).ServeHTTP
     0.04s 0.034% 0.0084%     -1.58s  1.33%  net/http.serverHandler.ServeHTTP
    -0.01s 0.0084% 0.017%     -1.57s  1.32%  main.render
     0.01s 0.0084% 0.0084%     -1.56s  1.31%  net/http.HandlerFunc.ServeHTTP
    -0.09s 0.076% 0.084%     -1.25s  1.05%  runtime.newobject
(pprof) top
Showing nodes accounting for -1.41s, 1.19% of 118.73s total
Dropped 268 nodes (cum <= 0.59s)
Showing top 10 nodes out of 668
      flat  flat%   sum%        cum   cum%
    -0.46s  0.39%  0.39%     -0.91s  0.77%  runtime.scanobject
    -0.40s  0.34%  0.72%     -0.40s  0.34%  runtime.nextFreeFast (inline)
     0.36s   0.3%  0.42%      0.36s   0.3%  gitlab.com/golang-commonmark/markdown.performReplacements
    -0.35s  0.29%  0.72%     -0.37s  0.31%  runtime.writeHeapBits.flush
     0.32s  0.27%  0.45%      0.67s  0.56%  gitlab.com/golang-commonmark/markdown.ruleReplacements
    -0.31s  0.26%  0.71%     -0.29s  0.24%  runtime.writeHeapBits.write
    -0.30s  0.25%  0.96%     -0.37s  0.31%  runtime.deductAssistCredit
     0.29s  0.24%  0.72%      0.10s 0.084%  gitlab.com/golang-commonmark/markdown.ruleText
    -0.29s  0.24%  0.96%     -0.29s  0.24%  runtime.(*mspan).base (inline)
    -0.27s  0.23%  1.19%     -0.42s  0.35%  bytes.(*Buffer).WriteRune

當指定 pprof -diff_base 時,pprof 中顯示的值是兩個配置文件之間的差異。因此,例如,使用 PGO 的 runtime.scanobject 的 CPU 使用時間比沒有使用的少 0.46 秒。另一方面,gitlab.com/golang-commonmark/markdown.performReplacements 的 CPU 使用時間多了 0.36 秒。在差異性配置文件中,我們通常想要看絕對值(flat 和 cum 列),因爲百分比沒有意義。

top -cum 顯示了累積變化最大的頂級差異。也就是說,一個函數及其所有傳遞調用者的 CPU 差異。這通常會顯示我們程序調用圖中最外層的幀,例如 main 或另一個 goroutine 入口點。在這裏,我們可以看到大部分節省來自處理 HTTP 請求的 ruleLinkify 部分。

top 顯示僅限於函數本身變化的頂級差異。這通常會顯示我們程序調用圖中的內部幀,這裏正在進行大部分實際的工作。在這裏我們可以看到,個別節省主要來自 runtime 函數。

都是些啥,讓我們挑幾個看看它們的調用棧:

pprof) peek scanobject$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.86s 94.51% |   runtime.gcDrain
                                            -0.09s  9.89% |   runtime.gcDrainN
                                             0.04s  4.40% |   runtime.markrootSpans
    -0.46s  0.39%  0.39%     -0.91s  0.77%                | runtime.scanobject
                                            -0.19s 20.88% |   runtime.greyobject
                                            -0.13s 14.29% |   runtime.heapBits.nextFast (inline)
                                            -0.08s  8.79% |   runtime.heapBits.next
                                            -0.08s  8.79% |   runtime.spanOfUnchecked (inline)
                                             0.04s  4.40% |   runtime.heapBitsForAddr
                                            -0.01s  1.10% |   runtime.findObject
----------------------------------------------------------+-------------
(pprof) peek gcDrain$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                               -1s   100% |   runtime.gcBgMarkWorker.func2
     0.15s  0.13%  0.13%        -1s  0.84%                | runtime.gcDrain
                                            -0.86s 86.00% |   runtime.scanobject
                                            -0.18s 18.00% |   runtime.(*gcWork).balance
                                            -0.11s 11.00% |   runtime.(*gcWork).tryGet
                                             0.09s  9.00% |   runtime.pollWork
                                            -0.03s  3.00% |   runtime.(*gcWork).tryGetFast (inline)
                                            -0.03s  3.00% |   runtime.markroot
                                            -0.02s  2.00% |   runtime.wbBufFlush
                                             0.01s  1.00% |   runtime/internal/atomic.(*Bool).Load (inline)
                                            -0.01s  1.00% |   runtime.gcFlushBgCredit
                                            -0.01s  1.00% |   runtime/internal/atomic.(*Int64).Add (inline)
----------------------------------------------------------+-------------

所以 runtime.scanobject 最終來自 runtime.gcBgMarkWorker。Go GC 指南告訴我們,runtime.gcBgMarkWorker 是垃圾收集器的一部分,所以 runtime.scanobject 的節省必定是 GC 的節省。那麼 nextFreeFast 和其他的運行時函數呢?

(pprof) peek nextFreeFast$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.40s   100% |   runtime.mallocgc (inline)
    -0.40s  0.34%  0.34%     -0.40s  0.34%                | runtime.nextFreeFast
----------------------------------------------------------+-------------
(pprof) peek writeHeapBits
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.37s   100% |   runtime.heapBitsSetType
                                                 0     0% |   runtime.(*mspan).initHeapBits
    -0.35s  0.29%  0.29%     -0.37s  0.31%                | runtime.writeHeapBits.flush
                                            -0.02s  5.41% |   runtime.arenaIndex (inline)
----------------------------------------------------------+-------------
                                            -0.29s   100% |   runtime.heapBitsSetType
    -0.31s  0.26%  0.56%     -0.29s  0.24%                | runtime.writeHeapBits.write
                                             0.02s  6.90% |   runtime.arenaIndex (inline)
----------------------------------------------------------+-------------
(pprof) peek heapBitsSetType$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.82s   100% |   runtime.mallocgc
    -0.12s   0.1%   0.1%     -0.82s  0.69%                | runtime.heapBitsSetType
                                            -0.37s 45.12% |   runtime.writeHeapBits.flush
                                            -0.29s 35.37% |   runtime.writeHeapBits.write
                                            -0.03s  3.66% |   runtime.readUintptr (inline)
                                            -0.01s  1.22% |   runtime.writeHeapBitsForAddr (inline)
----------------------------------------------------------+-------------
(pprof) peek deductAssistCredit$
Showing nodes accounting for -3.72s, 3.13% of 118.73s total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                            -0.37s   100% |   runtime.mallocgc
    -0.30s  0.25%  0.25%     -0.37s  0.31%                | runtime.deductAssistCredit
                                            -0.07s 18.92% |   runtime.gcAssistAlloc
----------------------------------------------------------+-------------

看起來 nextFreeFast 和前 10 名中的其他一些最終來自 runtime.mallocgc,GC 指南告訴我們這是內存分配器。

GC 和分配器的成本降低表明我們總體上分配的較少。讓我們看看 heap profile 文件以獲取更深入的瞭解:

$ go tool pprof -sample_index=alloc_objects -diff_base heap.nopgo.pprof heap.withpgo.pprof
File: markdown.profile.withpgo.exe
Type: alloc_objects
Time: Aug 28, 2023 at 10:28pm (EDT)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for -12044903, 8.29% of 145309950 total
Dropped 60 nodes (cum <= 726549)
Showing top 10 nodes out of 58
      flat  flat%   sum%        cum   cum%
  -4974135  3.42%  3.42%   -4974135  3.42%  gitlab.com/golang-commonmark/mdurl.Parse
  -4249044  2.92%  6.35%   -4249044  2.92%  gitlab.com/golang-commonmark/mdurl.(*URL).String
   -901135  0.62%  6.97%    -977596  0.67%  gitlab.com/golang-commonmark/puny.mapLabels
   -653998  0.45%  7.42%    -482491  0.33%  gitlab.com/golang-commonmark/markdown.(*StateInline).PushPending
   -557073  0.38%  7.80%    -557073  0.38%  gitlab.com/golang-commonmark/linkify.Links
   -557073  0.38%  8.18%    -557073  0.38%  strings.genSplit
   -436919   0.3%  8.48%    -232152  0.16%  gitlab.com/golang-commonmark/markdown.(*StateBlock).Lines
   -408617  0.28%  8.77%    -408617  0.28%  net/textproto.readMIMEHeader
    401432  0.28%  8.49%     499610  0.34%  bytes.(*Buffer).grow
    291659   0.2%  8.29%     291659   0.2%  bytes.(*Buffer).String (inline)

-sample_index=alloc_objects 選項爲我們顯示了分配的數量,而不考慮大小。這很有用,因爲我們正在調查 CPU 使用率的降低,這往往更與分配的數量而不是大小相關。這裏有相當多的減少,但讓我們關注最大的減少,也就是 mdurl.Parse

作爲參考,讓我們看一下這個函數在沒有 PGO 的情況下的總分配數量:

$ go tool pprof -sample_index=alloc_objects -top heap.nopgo.pprof | grep mdurl.Parse
   4974135  3.42% 68.60%    4974135  3.42%  gitlab.com/golang-commonmark/mdurl.Parse

在此之前的總數是 4974135,這意味着 mdurl.Parse 已經消除了 100% 的分配!

回到另一個 profile 文件中 (帶 pgo 優化的),讓我們收集更多的上下文信息:

(pprof) peek mdurl.Parse
Showing nodes accounting for -12257184, 8.44% of 145309950 total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context
----------------------------------------------------------+-------------
                                          -2956806 59.44% |   gitlab.com/golang-commonmark/markdown.normalizeLink
                                          -2017329 40.56% |   gitlab.com/golang-commonmark/markdown.normalizeLinkText
  -4974135  3.42%  3.42%   -4974135  3.42%                | gitlab.com/golang-commonmark/mdurl.Parse
----------------------------------------------------------+-------------

調用 mdurl.Parse 的是來自 markdown.normalizeLink 和 markdown.normalizeLinkText。

(pprof) list mdurl.Parse
Total: 145309950
ROUTINE ======================== gitlab.com/golang-commonmark/mdurl.Parse in /usr/local/google/home/mpratt/go/pkg/mod/gitlab.com/golang-commonmark/mdurl@v0.0.0-20191124015652-932350d1cb84/parse
.go
  -4974135   -4974135 (flat, cum)  3.42% of Total
         .          .     60:func Parse(rawurl string) (*URL, error) {
         .          .     61:   n, err := findScheme(rawurl)
         .          .     62:   if err != nil {
         .          .     63:           return nil, err
         .          .     64:   }
         .          .     65:
  -4974135   -4974135     66:   var url URL
         .          .     67:   rest := rawurl
         .          .     68:   hostless := false
         .          .     69:   if n > 0 {
         .          .     70:           url.RawScheme = rest[:n]
         .          .     71:           url.Scheme, rest = strings.ToLower(rest[:n]), rest[n+1:]

這些函數和調用者的完整源代碼可以在以下位置找到:

所以這裏發生了什麼優化?在非 PGO 構建中,mdurl.Parse 被認爲太大,不符合內聯的條件。然而,因爲我們的 PGO profile 文件表明調用這個函數的操作是熱點,所以編譯器確實將它們內聯了。我們可以從 profile 文件中的 “(inline)” 註解看到這一點:

$ go tool pprof -top cpu.nopgo.pprof | grep mdurl.Parse
     0.36s   0.3% 63.76%      2.75s  2.32%  gitlab.com/golang-commonmark/mdurl.Parse
$ go tool pprof -top cpu.withpgo.pprof | grep mdurl.Parse
     0.55s  0.48% 58.12%      2.03s  1.76%  gitlab.com/golang-commonmark/mdurl.Parse (inline)

mdurl.Parse 在第 66 行創建了一個 URL 作爲本地變量(var url URL),然後在第 145 行返回該變量的指針(return &url, nil)。通常這需要將變量分配在堆上,因爲對它的引用在函數返回之後仍然存在。然而,一旦 mdurl.Parse 被內聯到 markdown.normalizeLink 中,編譯器就可以觀察到該變量並沒有逃逸到 normalizeLink 之外,這允許編譯器將其分配在棧上。markdown.normalizeLinkTextmarkdown.normalizeLink 類似。

配置文件中顯示的第二大減小,來自 mdurl.(*URL).String,這是一個在內聯之後消除了逃逸的類似案例。

在這些情況下,我們通過減少堆分配來提高性能。PGO 和編譯器優化的部分威力在於,對分配的影響並不直接是由編譯器的 PGO 實現。PGO 做的唯一改變就是允許將這些熱點函數調用內聯。所有對逃逸分析和堆分配的影響都是在構建時的標準優化。優化的逃逸行爲是內聯的引起的延伸效應,但這並非是唯一的效應。許多優化也可以利用內聯。例如,當一些輸入是常量時,常量傳播可能能夠在內聯之後簡化函數中的代碼。

去虛擬化

除了我們在上面的示例中看到的內聯,PGO 還可以驅動接口調用的有條件的去虛擬化。

在進行 PGO 驅動去虛擬化之前,讓我們先步後並定義一下一般的 “去虛擬化”。假設你的代碼大概長這樣:

f, _ := os.Open("foo.txt")
var r io.Reader = f
r.Read(b)

這裏我們對 io.Reader 接口方法 Read 進行了調用。由於接口可以有多個實現,因此編譯器會生成一個間接函數調用,也就是說,它會在運行時從接口值中的類型查找正確的方法來調用。間接調用與直接調用相比有一點額外的運行時開銷,但更重要的是,它們排除了一些編譯器優化。例如,編譯器無法對間接調用進行逃逸分析,因爲它不知道具體的方法實現。

但是在上面的示例中,我們確實知道具體的方法實現。它一定是 os.(*File).Read,因爲 *os.File 是唯一可能被賦值給 r 的類型。在這種情況下,編譯器會進行去虛擬化,將間接調用 io.Reader.Read 替換爲直接調用 os.(*File).Read,從而允許其他優化。

(你可能在想,“那段代碼沒用,爲什麼會有人這麼寫?” 這是個好問題,但請注意,上面的代碼可能是內聯的結果。假設 f 被傳入一個接受 io.Reader 參數的函數。一旦函數被內聯,現在 io.Reader 就變成了具體的。)

PGO 驅動的去虛擬化將這個概念擴展到了具體類型在靜態上未知,但分析可以顯示,例如,io.Reader.Read 調用大多數時候是針對 os.(*File).Read 的情況。在這種情況下,PGO 可以將 r.Read(b) 替換爲類似的:

if f, ok := r.(*os.File); ok {
    f.Read(b)
} else {
    r.Read(b)
}

也就是說,我們添加了一個針對最可能出現的實體類型的運行時檢查,如果是的話,我們使用具體的調用,否則就回退到標準的間接調用。這裏的優點是,常見的路徑(使用 *os.File)可以被內聯並應用額外的優化,但我們仍然保留了一個回退路徑,因爲配置文件並不能保證這種情況總是出現。

在我們對 markdown 服務器的分析中,我們沒有看到 PGO 驅動的去虛擬化的優化,但我們也看到了優化最大的地方。PGO(以及大多數編譯器優化)通常從許多不同地方的非常小的改進中獲得其利益的累計,所以可能發生的事情比我們看到的還要多。

內聯和去虛擬化是 Go 1.21 中可用的兩種 PGO 驅動優化,但正如我們已經看到的,它們常常觸發額外的優化。此外,Go 的未來版本將繼續通過附加優化來改善 PGO。

未來可期。

參考資料

[1]

目標: https://github.com/golang/go/issues/62463#issuecomment-1709485195

[2]

Profile Guided Optimizations in Go: https://landontclipp.github.io/blog/2023/08/25/profile-guided-optimizations-in-go/#viewing-the-assembly

[3]

Profile-guided optimization in Go 1.21: https://go.dev/blog/pgo

[4]

用戶指南: https://go.dev/doc/pgo

[5]

基於 profile 指導的優化用戶指南: https://go.dev/doc/pgo

[6]

mdurl.Parse: https://gitlab.com/golang-commonmark/mdurl/-/blob/bd573caec3d827ead19e40b1f141a3802d956710/parse.go#L60

[7]

markdown.normalizeLink: https://gitlab.com/golang-commonmark/markdown/-/blob/fd7971701a0cab12e9347109a4c889f5c0a1a479/util.go#L53

[8]

markdown.normalizeLinkText: https://gitlab.com/golang-commonmark/markdown/-/blob/fd7971701a0cab12e9347109a4c889f5c0a1a479/util.go#L68

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