Go1-21 中的 PGO 技術詳解

2023 年初,Go 1.20 發佈了 PGO(Profile-guided optimization) 預覽版供用戶測試。在解決了預覽版中的已知限制,並通過社區反饋和貢獻進行了額外的改進後,Go 1.21 中的 PGO 支持已準備好用於生產!更多更詳細的內容可以參考 PGO 用戶指南(https://go.dev/doc/pgo)。

下面我們將通過一個例子來展示 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 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>
...

分析

現在我們已經有了一個可以運行的服務,讓我們收集一個配置文件並使用 PGO 進行重建,看看是否可以獲得更好的性能。

main.go中,我們導入了 net/http/pprof,它會自動/debug/pprof/profile向服務器添加一個端點以獲取 CPU 配置文件。

通常,你希望從生產環境中收集配置文件,以便編譯器獲得生產中行爲的代表性視圖。由於此示例沒有 “生產” 環境,因此我創建了一個簡單的程序來在收集配置文件時生成負載。獲取並啓動負載生成器(確保服務器仍在運行!):

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

當它運行時,從服務器下載配置文件:

$ curl -o cpu.pprof "http://localhost: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 後工作負載的 CPU 使用率通常會提高 2% 到 7%。配置文件包含大量有關應用程序行爲的信息,Go 1.21 剛剛開始通過使用這些信息進行一組有限的優化來探索表面。隨着編譯器的更多部分利用 PGO,未來的版本將繼續提高性能。

下一步

在此示例中,收集配置文件後,我們使用原始構建中使用的完全相同的源代碼重建了我們的服務器。在現實世界中,總是有持續的改進。因此,我們可以從生產中收集運行上週代碼的配置文件,並使用它來構建今天的源代碼。那完全沒問題!Go 中的 PGO 可以毫無問題地處理源代碼的微小更改。當然,隨着時間的推移,源代碼會越來越漂移,因此偶爾更新配置文件仍然很重要。

有關使用 PGO、最佳實踐和注意事項的更多信息,請參閱配置文件引導優化用戶指南。如果您對幕後發生的事情感到好奇,請繼續閱讀!

新引擎的真面目

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

內聯

爲了觀察內聯改進,讓我們分析這個帶有和不帶有 PGO 的 Markdown 應用程序。

我將使用一種稱爲差異分析的技術來對此進行比較,其中我們收集兩個配置文件(一個帶有 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 使用 PGO 時比不使用 PGO 時使用的 CPU 時間少 0.46 秒。另一方面,gitlab.com/golang-commonmark/markdown.performReplacements 多使用了 0.36 秒的 CPU 時間。在差異分析中,我們通常希望查看絕對值(flat 和 cum 列),因爲百分比沒有意義。

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

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其他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 使用率的下降,而這往往與分配數量而不是大小相關。這裏有相當多的減少,但讓我們關注最大的減少是,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.normalizeLinkText類似於markdown.normalizeLink.

優化文件中顯示的第二大減少是 mdurl.(*URL).String 消除內聯後逃逸的類似情況。

在這些情況下,我們通過減少堆分配來提高性能。一般來說,PGO 和編譯器優化的部分功能在於,對分配的影響根本不是編譯器 PGO 實現的一部分。PGO 所做的唯一更改是允許內聯這些熱函數調用。對逃逸分析和堆分配的所有影響都是適用於任何構建的標準優化。改進的轉義行爲是內聯的一個很大的下游效果,但它不是唯一的效果。許多優化可以利用內聯。例如,當某些輸入是常量時,常量傳播可能能夠在內聯後簡化函數中的代碼。

去虛擬化

除了我們在上面的示例中看到的 inling 之外,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,從而允許其他優化。

(您可能會想 “代碼是無用的,爲什麼有人會那樣寫?” 這是一個很好的觀點,但請注意,像上面這樣的代碼可能是內聯的結果。假設被傳遞到一個帶有參數的函數fio.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。

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