PGO in Go 1.21

2023-09-09 15:00:36

原文在這裡

由 Michael Pratt 釋出於 2023年9月5日

在2023年早些時候,Go 1.20釋出了供使用者測試的概要版本的基於效能分析的優化(PGO)。經過解決預覽版已知的限制,並得益於社群反饋和貢獻的進一步改進,Go 1.21中的PGO支援已經準備好供一般生產使用!請查閱效能分析優化使用者指南以獲取完整的檔案。

下面,我們將通過一個範例來演示如何使用PGO來提高應用程式的效能。在我們深入討論之前,什麼是「基於效能分析的優化」(Profile-Guided Optimization,PGO)?

當您構建一個Go二進位制檔案時,Go編譯器會執行優化操作,以儘量生成效能最佳的二進位制檔案。例如,常數傳播可以在編譯時評估常數表示式,避免執行時的評估成本。逃逸分析避免了區域性作用域物件的堆分配,從而避免了垃圾收集的開銷。內聯操作將簡單函數的主體複製到呼叫者中,通常使呼叫者進一步優化(如額外的常數傳播或更好的逃逸分析)。去虛擬化將對介面值的間接呼叫轉換為對具體方法的直接呼叫(這通常允許呼叫的內聯)。

Go會在每個版本中改進優化,但這並不是一項容易的任務。一些優化是可調節的,但編譯器不能僅僅對每個優化都「加大力度」,因為過於激進的優化實際上可能會降低效能或導致構建時間過長。其他優化需要編譯器對函數中的「常見」和「不常見」路徑進行判斷。編譯器必須基於靜態啟發式演演算法進行最佳猜測,因為它無法知道哪些情況在執行時將會常見。

但是,有沒有可能知道呢?

在沒有確切資訊的情況下,瞭解程式碼在生產環境中的使用方式,編譯器只能對包的原始碼進行操作。但我們有一種工具來評估生產行為:效能分析。如果我們向編譯器提供一個效能分析檔案,它就可以做出更明智的決策:更積極地優化最常用的函數,或更準確地選擇常見情況。

使用應用程式行為的效能分析檔案進行編譯器優化被稱為「基於效能分析的優化」(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 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),然後使用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"

這會收集CPU效能分析檔案,持續30秒。

使用效能分析檔案(Profile)

當Go工具鏈在主包目錄中找到名為default.pgo的效能分析檔案時,它將自動啟用PGO。或者,go build命令可以使用-pgo標誌來指定要用於PGO的效能分析檔案的路徑。

我們建議將default.pgo檔案提交到您的程式碼倉庫中。將效能分析檔案儲存在原始碼旁邊可以確保使用者僅需獲取程式碼庫(無論是通過版本控制系統還是go get)即可自動存取效能分析檔案,並且構建仍然是可復現的。

接下來,我們來構建啟用了PGO的應用程式:

$ 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

如果看到輸出中包含-pgo=/path/to/default.pgo,那麼說明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

同樣,在PGO啟用的伺服器執行時,執行多次基準測試迭代:

$ 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的優勢。這是一個令人鼓舞的跡象,表明使用PGO可以幫助提高Go應用程式的效能,並且隨著時間的推移,這一效果可能會變得更加顯著。

下一步

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

有關如何使用PGO、注意事項以及最佳實踐的更多資訊,請參閱效能分析優化使用者指南。如果您對底層發生了什麼感興趣,可以繼續閱讀相關檔案以深入瞭解。

底層原理

為了更好地理解這個應用程式為什麼變得更快,讓我們深入瞭解一下底層原理,看看效能是如何改進的。我們將關注兩種不同的PGO驅動優化。

內聯

要觀察內聯改進,讓我們分別分析使用PGO和不使用PGO的Markdown應用程式。

我們可以使用差異性效能分析(differential profiling)技術來比較它們,該技術涉及收集兩個效能分析檔案(一個使用PGO,一個不使用PGO)然後進行比較。對於差異性效能分析,重要的是兩個效能分析檔案都代表相同數量的工作,而不是相同的時間。因此,我已經調整了伺服器,使其自動收集效能分析檔案,同時調整了負載生成器,使其傳送固定數量的請求,然後退出伺服器。

我對伺服器所做的更改以及收集到的效能分析檔案可以在以下連結找到: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%。這與我們的基準測試結果一致,這是這些效能分析檔案代表性的好跡象。

現在,我們可以開啟一個差異性效能分析檔案,以查詢效能改進的地方:

$ 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時減少了0.46秒的CPU時間。另一方面,gitlab.com/golang-commonmark/markdown.performReplacements在使用PGO時使用了多0.36秒的CPU時間。在差異性效能分析檔案中,通常我們想檢視絕對值(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 Guide 告訴我們 runtime.gcBgMarkWorker 是垃圾回收器的一部分,因此 runtime.scanobject 的節省必定是與垃圾回收相關的節省。那麼 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 指南告訴我們 runtime.mallocgc 是記憶體分配器。

GC 和分配器的成本降低意味著我們總體上分配的記憶體更少。讓我們檢視堆剖析(heap profiles)以獲取更多內容:

$ 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

之前的總分配數量為 4,974,135,這意味著 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/[email protected]/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:]

這些函數和呼叫者的完整原始碼可以在以下位置找到:

  • mdurl.Parse
  • markdown.normalizeLink
  • markdown.normalizeLinkText

那麼在這裡發生了什麼呢?在非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行建立了一個URL作為本地變數(var url URL),然後在第145行返回了對該變數的指標(return &url, nil)。通常情況下,這需要將變數分配到堆上,因為對它的參照在函數返回後仍然存在。然而,一旦mdurl.Parse內聯到markdown.normalizeLink中,編譯器可以觀察到該變數沒有逃逸到normalizeLink之外,這允許編譯器將其分配到堆疊上。markdown.normalizeLinkTextmarkdown.normalizeLink類似。

在這些情況下,我們通過減少堆分配來獲得了效能改進。PGO和編譯器優化的一部分力量在於,對分配的影響根本不是編譯器的PGO實現的一部分。PGO做出的唯一更改是允許內聯這些熱函數呼叫。逃逸分析和堆分配的所有影響都是適用於任何構建的標準優化。改進的逃逸行為是內聯的一個重要結果,但並不是唯一的效果。許多優化可以利用內聯。例如,常數傳播可以在內聯後簡化函數中的程式碼,當其中一些輸入是常數時。

虛擬化 Devirtualization

除了上面範例中看到的內聯(inlining),PGO還可以驅動介面呼叫的條件虛擬化。

在深入瞭解PGO驅動的虛擬化之前,讓我們先回顧一下通常的「虛擬化」是什麼。假設您有類似以下程式碼的內容:

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

在上面的程式碼中,我們呼叫了 io.Reader 介面方法 Read 。由於介面可以有多個實現,編譯器生成了一個間接函數呼叫,這意味著它在執行時從介面值中的型別中查詢要呼叫的正確方法。與直接呼叫相比,間接呼叫具有額外的小的執行時成本,但更重要的是,它排除了一些編譯器優化。例如,編譯器無法對間接呼叫執行逃逸分析,因為它不知道具體的方法實現是什麼。

但在上面的範例中,我們知道具體的方法實現是什麼。它必須是 os.(*File).Read ,因為 *os.File 是唯一可能分配給r的型別。在這種情況下,編譯器將執行虛擬化(devirtualization),其中它將對 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。


孟斯特

宣告:本作品採用署名-非商業性使用-相同方式共用 4.0 國際 (CC BY-NC-SA 4.0)進行許可,使用時請註明出處。
Author: mengbin
blog: mengbin
Github: mengbin92
cnblogs: 戀水無意