Go 博客
Go 1.21 中的概要式引导优化
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 后,工作负载的 CPU 使用率通常会提高 2% 到 7%。概要文件包含大量关于应用程序行为的信息,Go 1.21 只是开始通过将这些信息用于有限的优化来挖掘其潜力。未来的版本将继续通过编译器的更多部分利用 PGO 来提高性能。
后续步骤
在此示例中,收集概要文件后,我们使用与原始构建中完全相同的源代码重新构建了服务器。在实际场景中,开发工作总是不断进行的。因此,我们可能会从运行上周代码的生产环境中收集概要文件,并使用它来构建今天的源代码。这是完全可以的!Go 中的 PGO 可以处理源代码的细微变化,不会出现问题。当然,随着时间的推移,源代码会发生越来越大的变化,因此定期更新概要文件仍然很重要。
有关使用 PGO 的更多信息、最佳实践和需要注意的注意事项,请参阅 概要式引导优化用户指南。如果您想知道幕后发生了什么,请继续阅读!
幕后
为了更好地了解是什么让此应用程序变快了,让我们看看幕后发生了什么,以了解性能发生了哪些变化。我们将查看两种不同的 PGO 驱动优化。
内联
为了观察内联改进,让我们分析一下启用了和未启用 PGO 的 Markdown 应用程序。
我将使用一种称为差分概要分析的技术进行比较,在这种技术中,我们收集两个概要文件(一个启用 PGO,一个未启用 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 时间从 ~118s 降至 ~115s,即下降了约 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
多用了 0.36 秒的 CPU 时间。在差分概要文件中,我们通常希望查看绝对值(flat
和 cum
列),因为百分比没有意义。
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.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.normalizeLink
和 markdown.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:]
这些函数和调用者的完整源代码可以在以下位置找到:
那么这里发生了什么呢?在非 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.normalizeLinkText
与 markdown.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
)可以被内联并应用额外的优化,但我们仍然保留了回退路径,因为配置文件不能保证这始终是这种情况。
在我们对降价服务器的分析中,我们没有看到 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 工具链
博客索引