Go 部落格

Go 1.21 中的 Profile-Guided Optimization

Michael Pratt
2023 年 9 月 5 日

2023 年初,Go 1.20 釋出了 Profile-Guided Optimization (PGO) 的預覽版供使用者測試。在解決了預覽版中的已知限制,並在社群反饋和貢獻的進一步完善下,Go 1.21 中的 PGO 支援已準備好投入生產使用!有關完整的文件,請參閱 Profile-Guided Optimization 使用者指南

下面我們將透過一個使用 PGO 提高應用程式效能的示例。在此之前,我們先來了解一下“Profile-Guided Optimization”到底是什麼?

當您構建 Go 二進位制檔案時,Go 編譯器會執行最佳化,以生成效能最佳的二進位制檔案。例如,常量傳播可以在編譯時計算常量表達式,避免執行時計算成本。逃逸分析可以避免為區域性作用域的物件分配堆記憶體,減少 GC 開銷。內聯會將簡單函式的體複製到呼叫者中,通常可以實現呼叫者中的進一步最佳化(例如額外的常量傳播或更好的逃逸分析)。對介面值上的間接呼叫(其型別可以在靜態確定時)進行虛擬化,將其轉換為對具體方法的直接呼叫(這通常可以實現呼叫的內聯)。

Go 會在每個版本中不斷改進最佳化,但這並非易事。有些最佳化是可調的,但編譯器不能對所有最佳化都“全速開啟”,因為過於激進的最佳化實際上可能會損害效能或導致過長的構建時間。其他最佳化需要編譯器在函式的“常用路徑”和“不常用路徑”之間做出判斷。編譯器必須基於靜態啟發式做出最佳猜測,因為它無法在執行時知道哪些情況會是常用的。

或者,它真的不知道嗎?

在沒有關於程式碼在生產環境中如何使用的確切資訊的情況下,編譯器只能操作軟體包的原始碼。但是,我們有一個評估生產行為的工具:效能分析(profiling)。如果我們向編譯器提供效能分析資料,它就可以做出更明智的決策:更積極地最佳化最常用的函式,或更準確地選擇常用情況。

使用應用程式行為的效能分析資料來進行編譯器最佳化,被稱為Profile-Guided Optimization (PGO)(也稱為 Feedback-Directed Optimization (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 https://: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>
...

效能分析

現在我們已經有了一個可用的服務,讓我們收集一個性能分析資料,然後使用 PGO 重新構建,看看效能是否會提高。

main.go 中,我們匯入了 net/http/pprof,它會自動向伺服器新增一個 /debug/pprof/profile 端點,用於獲取 CPU 效能分析資料。

通常,您希望從生產環境中收集效能分析資料,以便編譯器能夠獲得生產環境中行為的代表性檢視。由於本示例沒有“生產”環境,我建立了一個 簡單的程式,在收集效能分析資料時生成負載。獲取並啟動負載生成器(確保伺服器仍在執行!)。

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

在它執行時,從伺服器下載效能分析資料。

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

完成後,停止負載生成器和伺服器。

使用效能分析資料

當 Go 工具鏈在主包目錄中找到名為 default.pgo 的效能分析檔案時,它會自動啟用 PGO。或者,go build-pgo 標誌接受一個性能分析檔案的路徑來用於 PGO。

我們建議將 default.pgo 檔案提交到您的儲存庫。將效能分析資料與原始碼一起儲存,可以確保使用者透過獲取儲存庫(透過版本控制系統或 go get)就能自動訪問效能分析資料,並且構建保持可重現。

讓我們來構建。

$ 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 benchmark 負載生成器的版本來評估 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%。效能分析資料包含有關應用程式行為的豐富資訊,而 Go 1.21 僅透過將這些資訊用於有限的最佳化集來初步利用這些資料。未來的版本將隨著編譯器更多部分利用 PGO 而繼續提高效能。

下一步

在此示例中,收集效能分析資料後,我們使用與原始構建完全相同的原始碼重新構建了伺服器。在實際場景中,總會有持續的開發。因此,我們可能會從生產環境中收集一個上週程式碼的效能分析資料,並用它來構建今天的原始碼。這完全沒問題!Go 中的 PGO 可以處理原始碼的微小更改而不會出現問題。當然,隨著時間的推移,原始碼會越來越不同,因此偶爾更新效能分析資料仍然很重要。

有關使用 PGO、最佳實踐和需要注意的注意事項的更多資訊,請參閱 Profile-Guided Optimization 使用者指南。如果您想了解幕後原理,請繼續閱讀!

幕後原理

為了更好地理解此應用程式為何更快,讓我們深入瞭解一下效能是如何變化的。我們將重點關注兩個由 PGO 驅動的最佳化。

內聯

為了觀察內聯的改進,讓我們分析一下帶 PGO 和不帶 PGO 的 Markdown 應用程式。

我將使用一種稱為差異分析(differential profiling)的技術來比較,即收集兩個效能分析資料(一個帶 PGO,一個不帶),然後進行比較。對於差異分析,重要的是兩個效能分析資料都代表相同的**工作量**,而不是相同的時間長度。因此,我調整了伺服器以自動收集效能分析資料,並調整負載生成器以傳送固定數量的請求然後退出伺服器。

我對伺服器進行的更改以及收集的效能分析資料可以在 https://github.com/prattmic/markdown-pgo 找到。負載生成器使用 -count=300000 -quit 執行。

為了快速進行一致性檢查,讓我們看看處理所有 300k 請求所需的總 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%。這與我們的基準測試結果一致,這表明這些效能分析資料具有代表性,是個好跡象。

現在我們可以開啟差異分析來查詢節省的資源。

$ 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 中顯示的值是兩個效能分析資料之間的**差異**。因此,例如,runtime.scanobject 使用的 CPU 時間比不使用 PGO 時少 0.46 秒。另一方面,gitlab.com/golang-commonmark/markdown.performReplacements 使用的 CPU 時間多 0.36 秒。在差異分析中,我們通常需要檢視絕對值(flatcum 列),因為百分比沒有意義。

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.gcBgMarkWorkerGo GC 指南告訴我們 runtime.gcBgMarkWorker 是垃圾回收器的一部分,所以 runtime.scanobject 的節省一定是 GC 的節省。那 nextFreeFast 和其他 runtime 函式呢?

(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 和其他一些排名前十的函式最終都來自 runtime.mallocgc,GC 指南告訴我們這是記憶體分配器。

GC 和分配器的成本降低意味著我們總體上的分配減少了。讓我們看看堆分配的效能分析資料以獲得一些見解。

$ 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% 的分配!

回到差異分析,讓我們收集更多上下文。

(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.normalizeLinkmarkdown.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 效能分析資料表明對該函式的呼叫很熱(hot),編譯器對其進行了內聯。我們可以從效能分析資料中的“(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 行(var url 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 是唯一可能賦給 r 的型別,因此它必須是 os.(*File).Read。在這種情況下,編譯器將執行*虛擬化消除*,用對 os.(*File).Read 的直接呼叫替換對 io.Reader.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。

致謝

將 Profile-Guided Optimization 新增到 Go 是一個團隊的努力,我特別要感謝 Uber 的 Raj Barik 和 Jin Lin,以及 Google 的 Cherry Mui 和 Austin Clements 的貢獻。這種跨社群的協作是使 Go 變得偉大的關鍵部分。

下一篇文章: 為不斷增長的 Go 生態系統擴充套件 gopls
上一篇文章: 完全可重現、經過驗證的 Go 工具鏈
部落格索引