Go 部落格

Go 1.21 的剖析引導最佳化

Michael Pratt
2023 年 9 月 5 日

2023 年初,Go 1.20 發布了剖析引導最佳化 (PGO) 的預覽版,供使用者測試。在解決了預覽版中已知的限制,並感謝社群提供意見回饋和貢獻,進一步修正後,Go 1.21 中的 PGO 支援已準備好供一般生產環境使用!請參閱 剖析引導最佳化使用者指南,取得完整的文件資料。

以下我們將逐步示範如何使用 PGO 來提升應用程式的效能。在我們開始之前,「剖析引導最佳化」究竟是什麼?

當您建立 Go 二進制檔案時,Go 編譯器會執行最佳化作業,以嘗試產生效能最佳的二進制檔案。例如,常數傳遞會在編譯時期評估常數運算式,避免執行時期評估成本。逸出分析會避免對局部範圍物件進行堆疊配置,進而避免 GC 負擔。內聯會將簡單函式的本體複製到呼叫者中,這通常能啟用呼叫者中的進一步最佳化(例如額外的常數傳遞或更好的逸出分析)。解除虛擬化會將對型別可以在靜態下確定的介面值所進行的間接呼叫轉換為對具體方法的直接呼叫(這通常可以內聯呼叫)。

Go 會隨版本發布改善最佳化,但這並非易事。有些最佳化可以調整,但編譯器不能在每個最佳化中「將其調到 11」,因為過度激進的最佳化實際上會損害效能或導致過長的建置時間。其他最佳化則需要編譯器判斷函式中的「常見」與「不常見」路徑為何。編譯器必須基於靜態經驗法則做出最佳推測,因為它無法知道哪些案例在執行時期會是常見的。

不然它是怎麼知道的呢?

由於沒有如何使用程式碼的確認資訊,編譯器只能作用於套件的原始碼上。但是我們有工具來評估生產行為:剖析。如果我們提供範例給編譯器,它就可以做出更明智的決定:更積極地最佳化最常使用的函式,或更準確地選取常見案例。

將應用程式行為的範例用於編譯器最佳化技術稱為概略引導最佳化 (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 https://127.0.0.1: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://127.0.0.1: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 基準 版本 來評估 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 而獲得 2% 至 7% 的 CPU 使用率提升。輪廓包含大量關於應用程式行為的資訊,而 Go 1.21 僅透過使用這項資訊進行有限的最佳化,就發揮了令人驚豔的潛力。隨著編譯器中有更多部分能利用 PGO,後續版本會持續提升效能。

後續步驟

在這個範例中,收集完剖析資料後,我們使用原始建置所使用的確切相同原始碼,重建了伺服器。在實際情境中,開發會持續進行。因此,我們可能會從執行上週程式碼的製作環境收集剖析資料,並用它來建置今日的原始碼。這完全沒問題!Go 中的 PGO 可以無縫地處理原始碼的細微變更。當然,一段時間後,原始碼的差異會愈來愈大,因此定期更新剖析資料仍然很重要。

如欲深入瞭解使用 PGO、最佳實務和注意事項,請參閱 剖析引導最佳化使用者指南。如果您好奇在程式碼底下發生了什麼事,請繼續閱讀!

程式碼底下

為了更深入地了解這個應用程式變快的因素,讓我們探究一下程式碼底下,看看效能如何改變。我們將探討兩個由 PGO 推動的最佳化。

內聯處理

為了觀察內聯處理的效能提升,讓我們使用 PGO 和未使用 PGO 分析一下這個標記應用程式。

我將使用稱為差異剖析的技巧來進行比較,我們收集兩個剖析資料 (一個使用 PGO,一個沒有使用),並加以比較。對於差異剖析,兩份剖析資料代表相同的工作量 (而非相同時間) 非常重要,因此我已經調整伺服器以自動收集剖析資料,並讓負載產生器在傳送固定數量的要求後退出伺服器。

我對伺服器所做的變更,以及所收集的剖析資料,都可以在這裡找到 https://github.com/prattmic/markdown-pgo。負載產生器使用 -count=300000 -quit 執行。

為了快速確認一致性,讓我們看一下處理所有 30 萬次要求所需的總 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 使用 PGO 時的 CPU 時間減少 0.46 秒,沒有 PGO 時則無減少。而 gitlab.com/golang-commonmark/markdown.performReplacements 使用 PGO 時的 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 和排名前 10 的其他一些部分最終來自 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 使用率下降,而 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 檔案指出,對此功能的呼叫很熱門,因此編譯器確實將它們內聯在其中。我們可以在檔案中的「(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).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。

致謝

為 Go 新增剖析導向最佳化是一項團隊合作,我特別要指出 Uber 的 Raj Barik 和 Jin Lin,以及 Google 的 Cherry Mui 和 Austin Clements 的貢獻。這種跨社群的協作是讓 Go 變得更棒的關鍵部分。

下一篇文章:針對不斷成長的 Go 生態系調整 gopls 規模
上一篇文章:完美可重製的已驗證 Go 工具鏈
部落格索引