PGO: 为你的Go程序提效5%

PGO (基于profile指导的优化) 在Go 1.20 中还属于预览状态, 在Go 1.21中已经生产可用了, 未来 PGO 还有很宏伟的目标,但是现在已经可以很好的帮助我们提高程序的性能呢,根据程序的不同,可能会带来2% ~ 7%的提升,不要小看这个提升,如果你是在大厂做优化的专家,可以这样计算: “我厂大概有 10万Go实例,每个实例平均占用16个核,通过我们的优化,程序性能平均提升5%, 大约节省9万个核,每年为公司节省数亿元的成本”。

最近看到两篇关于PGO的文章:Profile Guided Optimizations in Go 和 Go官方的博客 Profile-guided optimization in Go 1.21。相比较而言, Go官方这篇文章简单明了,而且把·细节也交代的明明白白,所以我就把这篇文章翻译过来,我感觉我自己写也没有官方这篇写的明白,翻译过来就好了。

以下是译文:

2023年初,Go 1.20发布了基于profile指导的优化(PGO)的预览版本,供用户进行测试。在解决了预览版本中已知的限制,并借助社区反馈和贡献进行了进一步锤炼后,Go 1.21中的PGO功能已准备好用于广泛的生产环境!有关完整文档,请参阅用户指南

下面我们将通过一个示例来演示如何使用PGO提高应用程序的性能。在深入示例之前,什么是“基于profile指导的优化”?

当你构建一个Go二进制文件时,Go编译器会执行优化,试图生成性能最佳的二进制文件。例如,常量传播(constant propagation)可以在编译时计算常量表达式的值,避免了运行时的计算开销。逃逸分析(Escape analysis)可以避免为局部作用域的对象分配堆内存,从而避免GC的开销。内联(Inlining)会将简单函数的函数体拷贝到调用者中,这通常可以在调用者中启用进一步的优化(例如额外的常量传播或更好的逃逸分析)。去虚拟化(Devirtualization)会将接口值上的间接调用(如果可以静态确定其类型)转换为对具体方法的直接调用(这通常可以内联该调用)。

Go在每个版本中都在提升优化,但这并非易事。一些优化是可调的,但是编译器不能对每项优化都“turn it up to 11” (英语典故,形容把某事物调到极限状态或者超出常规限度),因为过于激进的优化实际上可能会损害性能或者导致过长的构建时间。其他优化需要编译器对函数中的“常见路径”和“非常见路径”做出判断。编译器必须根据静态启发式方法进行最佳猜测,因为它无法知道运行时哪些分支更常见。

或者编译器可以做到吗?

没有关于代码在生产环境中的使用方式的确定信息,编译器只能对包的源代码进行操作。但是我们确实有一个工具来评估生产行为: profile (剖析,又叫性能分析,后面我们保持英文不翻译)。如果我们向编译器提供一个profile,它可以做出更明智的决定:更积极地优化使用最频繁的函数,或更准确地选择常见情况。

使用应用程序行为的profile进行编译器优化称为基于profile指导的优化 (PGO)(也称为性能分析引导优化、反馈导向优化(FDO))。

示例

好的,让我们构建一个将Markdown转换为HTML的服务:用户将Markdown源上传到/render,它会返回HTML转换结果。我们可以使用gitlab.com/golang-commonmark/markdown来轻松实现这一功能。

搭建

创建一个文件夹并执行下面的命令:

1
2
$ go mod init example.com/markdown
$ go get gitlab.com/golang-commonmark/markdown@bf3e522c626a

创建 main.go 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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))
}

构建并运行这个服务:

1
2
3
$ go build -o markdown.nopgo.exe
$ ./markdown.nopgo.exe
2023/08/23 03:55:51 Serving on port 8080...

好的,我们可以从另一个终端发送一些Markdown过来试试。我们可以使用Go项目中的README.md作为示例文档:

1
2
3
4
5
6
$ 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

既然我们已经有了一个工作的服务,让我们收集一个profile并用PGO重新构建,看看是否可以获得更好的性能。

在main.go中,我们导入了net/http/pprof,它会自动在服务器上添加一个/debug/pprof/profile地址来获取CPU profile。

通常你想要从生产环境中收集profile,这样编译器可以获得生产环境中的代表性行为视图。由于这个示例没有“生产”环境,我创建了一个简单的程序来在收集profile时生成压测负载。获取并启动负载生成器(确保服务器仍在运行!):

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

在负载生成器运行时,从服务器下载一个性能分析:

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

一旦profile下载完成,终止负载生成器和服务器。

使用profile

Go工具链如果在main包目录中找到名为default.pgo的概要文件,就会自动启用PGO。或者, 在go build中使用-pgo标志接受一个文件路径作为PGO要使用的profile路径。

我们建议将profile文件提交到你的仓库中。将profile文件与源代码一起存储可以确保用户只需获取仓库(通过版本控制系统或go get)就可以自动访问profile文件,并且可以保证构建是可重现的。

让我们构建它:

1
2
$ mv cpu.pprof default.pgo
$ go build -o markdown.withpgo.exe

我们可以通过go version检查PGO是否在构建中被启用:

1
2
3
4
$ go version -m markdown.withpgo.exe
./markdown.withpgo.exe: go1.21.0
...
build -pgo=/tmp/pgo121/default.pgo

评估

我们将使用负载生成器的Go基准测试版本来评估PGO对性能的影响。

首先,我们为没有使用PGO优化的服务器进行基准测试。启动那个服务器:

1
$ ./markdown.nopgo.exe

在服务运行时,执行几次基准测试:

1
2
$ 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的版本:

1
$ ./markdown.withpgo.exe

在服务运行时,也执行同样次数的基准测试:

1
$ go test github.com/prattmic/markdown-pgo/load -bench=. -count=40 -source $(pwd)/README.md > withpgo.txt

一旦完成,比较两次的测试结果:

1
2
3
4
5
6
7
8
9
$ 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%。profile文件其实包含了大量关于应用程序行为的信息,Go 1.21只是开始利用这些信息进行有限的几项优化。随着编译器的更多部分利用PGO,未来的版本将继续改进性能。

后续步骤

在这个示例中,收集profile文件后,我们使用与原始构建完全相同的源代码重新构建了服务器。在实际场景中,代码都在不断开发。所以我们可能会从运行上周代码的生产环境收集profile文件,并用它来构建今天的源代码。这完全没问题!Go中的PGO可以毫无问题地处理源代码的细微变更。当然,随着时间推移,源代码会越来越不相同,所以定期更新profile文件仍然很重要。

关于使用PGO的更多信息、最佳实践和需要注意的警告,请参阅基于profile指导的优化用户指南。如果您好奇咋优化的,请继续阅读!

原理剖析

为了更好地理解是什么让这个应用程序变得更快,让我们深入了解一下性能是如何改进的。我们将查看两种不同的PGO驱动的优化。

内联

为了观察内联优化的改进,让我们分别分析这个Markdown应用程序在使用PGO和不使用PGO时的情况。

我将使用一种称为差异性分析的技术进行比较,其中我们收集两个不同的性能分析数据(一个使用PGO,一个不使用PGO),然后进行比较。对于差异性分析,重要的是两个profile数据代表了相同数量的工作量,而不是相同数量的时间。因此,我已经调整了服务器以自动收集性能分析数据,还调整了负载生成器以发送固定数量的请求,然后退出服务器。

我已经对服务器进行了更改,依然收集到的profile数据,代码可以在https://github.com/prattmic/markdown-pgo找到。负载生成器使用了参数`-count=300000 -quit`来运行。

作为一个快速的一致性检查,让我们来看一下处理所有 300,000 个请求所需的总CPU时间:

1
2
3
4
$ 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%。这与我们的基准测试结果一致,这是这些profile数据具有代表性的一个好现象。

现在我们可以打开一个差异性分析数据来寻找节省的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ 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 中显示的值是两个配置文件之间的差异。因此,例如,使用 PGO 的 runtime.scanobject 的 CPU 使用时间比没有使用的少 0.46 秒。另一方面,gitlab.com/golang-commonmark/markdown.performReplacements 的 CPU 使用时间多了 0.36 秒。在差异性配置文件中,我们通常想要看绝对值(flat 和 cum 列),因为百分比没有意义。

top -cum 显示了累积变化最大的顶级差异。也就是说,一个函数及其所有传递调用者的 CPU 差异。这通常会显示我们程序调用图中最外层的帧,例如 main 或另一个 goroutine 入口点。在这里,我们可以看到大部分节省来自处理 HTTP 请求的 ruleLinkify 部分。

top 显示仅限于函数本身变化的顶级差异。这通常会显示我们程序调用图中的内部帧,这里正在进行大部分实际的工作。在这里我们可以看到,个别节省主要来自 runtime 函数。

都是些啥,让我们挑几个看看它们的调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 和其他的运行时函数呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
(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和分配器的成本降低表明我们总体上分配的较少。让我们看看heap profile文件以获取更深入的了解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ 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 的情况下的总分配数量:

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

回到另一个profile文件中(带pgo优化的),让我们收集更多的上下文信息:

1
2
3
4
5
6
7
8
9
(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.normalizeLink 和 markdown.normalizeLinkText。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(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 profile文件表明调用这个函数的操作是热点,所以编译器确实将它们内联了。我们可以从profile文件中的“(inline)”注解看到这一点:

1
2
3
4
$ 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 类似。

配置文件中显示的第二大减小,来自 mdurl.(*URL).String,这是一个在内联之后消除了逃逸的类似案例。

在这些情况下,我们通过减少堆分配来提高性能。PGO 和编译器优化的部分威力在于,对分配的影响并不直接是由编译器的 PGO 实现。PGO 做的唯一改变就是允许将这些热点函数调用内联。所有对逃逸分析和堆分配的影响都是在构建时的标准优化。优化的逃逸行为是内联的引起的延伸效应,但这并非是唯一的效应。许多优化也可以利用内联。例如,当一些输入是常量时,常量传播可能能够在内联之后简化函数中的代码。

去虚拟化

除了我们在上面的示例中看到的内联,PGO还可以驱动接口调用的有条件的去虚拟化。

在进行PGO驱动去虚拟化之前,让我们先步后并定义一下一般的“去虚拟化”。假设你的代码大概长这样:

1
2
3
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) 替换为类似的:

1
2
3
4
5
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。

未来可期。