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 工具链
博客索引