原文在这里。
由 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
The Go Programming Language
Go is an open source programming language that makes it easy to build simple,
reliable, and efficient software.
...
性能分析
很好,现在我们有一个正常运行的服务了,接下来我们要收集性能分析文件(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的优势。这是一个令人鼓舞的迹象,表明使用P服务器托管网GO可以帮助提高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
当指定pprof -diff_base
时,pprof 中显示的值是两个性能分析文件之间的差异。例如,runtime.scanobject
在使用PGO时比不使用PGO时减少了0.46秒的CPU时间。另一方面,gitlab.com/golang-commonmark/markdown.performReplacements
在使用PGO时使用了多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.1服务器托管网3s 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 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
-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.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/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:]
这些函数和调用者的完整源代码可以在以下位置找到:
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.normalizeLinkText
与markdown.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: 恋水无意
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net