写点什么

改变一个字符后,我的 Go 程序快了 42%

  • 2023-02-14
    北京
  • 本文字数:2825 字

    阅读完需:约 9 分钟

改变一个字符后,我的 Go 程序快了 42%

我要先声明通常的基准测试警告:42% 的加速是在我的计算机上运行程序的数据时测量的,所以大家对这个数字请持保留态度。

这个 Go 程序是做什么的?


codeowners 是一个 Go 程序,它根据 GitHub CODEOWNERS 文件中定义的一系列规则,打印出存储库中每个文件的所有者。可能有这样的规则:所有以 .go 结尾的文件都归 @gophers 团队所有,或者 docs/ 目录下的所有文件都归 @docs 团队所有。


当考虑一个给定的路径时,最后匹配的规则获胜。一个简单但天真的匹配算法会向后迭代每条路径的规则,当找到匹配时就会停止。智能算法确实存在,但那是以后的事了。Ruleset.Match 函数如下所示:


type Ruleset []Rule

func (r Ruleset) Match(path string) (*Rule, error) { for i := len(r) - 1; i >= 0; i-- { rule := r[i] match, err := rule.Match(path) if match || err != nil { return &rule, err } } return nil, nil}
复制代码


用 pprof 和 flamegraph 查找“slow bits”


当在一个中等规模的存储库中运行该工具时,它的运行速度有点慢:

$ hyperfine codeownersBenchmark 1: codeowners  Time (mean ± σ):      4.221 s ±  0.089 s    [User: 5.862 s, System: 0.248 s]  Range (min … max):    4.141 s …  4.358 s    10 runs
复制代码


为了了解程序将时间花在哪里,我用 pprof 记录了一个 CPU 配置文件。你可以通过将以下代码片段添加到 main 函数的顶部来获得生成的 CPU 配置文件:

 

pprofFile, pprofErr := os.Create("cpu.pprof")if pprofErr != nil {  log.Fatal(pprofErr)}pprof.StartCPUProfile(pprofFile)defer pprof.StopCPUProfile()
复制代码


题外话:我经常使用 pprof,所以我将这段代码保存为 vscode 代码片段。我只要输入 pprof,点击 tab,就会出现这个代码片段。


Go 附带了一个方便的交互式配置文件可视化工具。通过运行以下命令,然后导航到页面顶部菜单中的火焰图视图,将配置文件可视化为火焰图。


$ go tool pprof -http=":8000" ./codeowners ./cpu.pprof
复制代码


正如我所料,大部分时间都花在了 Match 函数上。CODEOWNERS 模式被编译成正则表达式,Match 函数的大部分时间都花在 Go 的正则表达式引擎中。但是我也注意到,很多时间都花在了分配和回收内存上。下面的火焰图中的紫色块与模式 gc|malloc 相匹配,你可以看到它们在总体上表示程序执行时间的一个有意义的部分。



使用逃逸分析跟踪搜寻堆分配


因此,让我们看看是否有任何分配可以消除,以减少 GC 的压力和在 malloc 花费的时间。


Go 编译器使用一种叫做逃逸分析(escape analysis)的技术来计算出何时需要在堆上驻留一些内存。假设一个函数初始化了一个结构,然后返回一个指向它的指针。如果该结构是在堆栈中分配的,那么一旦函数返回,并且相应的堆栈框架失效,返回的指针就会失效。在这种情况下,Go 编译器将确定该指针已经“逃离”了函数,并将该结构移至堆中。


你可以通过向 go build 传递 -gcflags=-m 来看到这些决定:


$ go build -gcflags=-m *.go 2>&1 | grep codeowners.go./codeowners.go:82:18: inlining call to os.IsNotExist./codeowners.go:71:28: inlining call to filepath.Join./codeowners.go:52:19: inlining call to os.Open./codeowners.go:131:6: can inline Rule.Match./codeowners.go:108:27: inlining call to Rule.Match./codeowners.go:126:6: can inline Rule.RawPattern./codeowners.go:155:6: can inline Owner.String./codeowners.go:92:29: ... argument does not escape./codeowners.go:96:33: string(output) escapes to heap./codeowners.go:80:17: leaking param: path./codeowners.go:70:31: []string{...} does not escape./codeowners.go:71:28: ... argument does not escape./codeowners.go:51:15: leaking param: path./codeowners.go:105:7: leaking param content: r./codeowners.go:105:24: leaking param: path./codeowners.go:107:3: moved to heap: rule./codeowners.go:126:7: leaking param: r to result ~r0 level=0./codeowners.go:131:7: leaking param: r./codeowners.go:131:21: leaking param: path./codeowners.go:155:7: leaking param: o to result ~r0 level=0./codeowners.go:159:13: "@" + o.Value escapes to heap
复制代码


输出有点嘈杂,但你可以忽略其中的大部分。由于我们正在寻找分配,“move to heap”是我们应该关注的短语。回顾上面的 Match 代码,Rule 结构被存储在 Ruleset 片中,我们可以确信它已经在堆中了。由于返回的是一个指向规则的指针,所以不需要额外的分配。


然后我看到了--通过分配 rule := r[i],我们将堆中分配的 Rule 从片中复制到堆栈中,然后通过返回 &rule,我们创建了一个指向结构副本的(逃逸)指针。幸运的是,解决这个问题很容易。我们只需要将 & 号往上移一点,这样我们就引用了片中的结构,而不是复制它:


 func (r Ruleset) Match(path string) (*Rule, error) { 	for i := len(r) - 1; i >= 0; i-- {-		rule := r[i]+		rule := &r[i] 		match, err := rule.Match(path) 		if match || err != nil {-			return &rule, err+			return rule, err 		} 	} 	return nil, nil }
复制代码


我确实考虑过另外两种方法:


  1. 将 Ruleset 从 []Rule 更改为 []*Rule,这意味着我们不再需要显式引用该规则。

  2. 返回 Rule 而不是 *Rule。这仍然会复制 Rule,但它应该保留在堆栈上,而不是移动到堆上。


然而,由于此方法是公共 API 的一部分,这两种方法都会导致一个重大的变化。


无论如何,在做了这个修改之后,我们可以通过从编译器获得一个新的跟踪并将其与旧的跟踪进行比较来看看它是否达到了预期的效果:


$ diff trace-a trace-b14a15> ./codeowners.go:105:7: leaking param: r to result ~r0 level=016d16< ./codeowners.go:107:3: moved to heap: rule
复制代码


成功了!分配消失了。现在让我们看看删除一个堆分配会如何影响性能:


$ hyperfine ./codeowners-a ./codeowners-bBenchmark 1: ./codeowners-a  Time (mean ± σ):      4.146 s ±  0.003 s    [User: 5.809 s, System: 0.249 s]  Range (min … max):    4.139 s …  4.149 s    10 runs

Benchmark 2: ./codeowners-b Time (mean ± σ): 2.435 s ± 0.029 s [User: 2.424 s, System: 0.026 s] Range (min … max): 2.413 s … 2.516 s 10 runs

Summary ./codeowners-b ran 1.70 ± 0.02 times faster than ./codeowners-a
复制代码


由于该分配是针对匹配的每一条路径进行的,因此在这种情况下,删除它会获得 1.7 倍的速度提升(这意味着它的运行速度要快 42%)。对一个字符的变化来说还不错。


作者简介:

Harry,在 GitHub 工作,负责领导团队为开发人员开发安全产品。Dependabot 共同创始人之一。

 

原文链接:

https://hmarr.com/blog/go-allocation-hunting/#circle=on


推荐阅读:

一个架构师在 2023 年需要掌握哪些“必杀技”?

2023-02-14 11:3211416

评论

发布
暂无评论
发现更多内容

云原生负载均衡和网关应用实践

火山引擎开发者社区

云原生 后端

全表遍历并处理数据有点慢?放开!我来!

林一

jpa Stram @QueryHints

CVPR2021竞赛结果出炉,阿里淘系多媒体算法包揽3项国际冠军

阿里巴巴大淘宝技术

CVPR

二分查找常见套路与分析

gevin

算法 二分查找

架构实战营模块七作业

竹林七贤

Linux常用命令-文件操作

正向成长

linux命令

AQS介绍和原理分析(下)

追风少年

并发编程 AQS

Linux之which命令

入门小站

Linux

external-attacher源码分析(1)-main方法与启动参数分析

良凯尔

Kubernetes 源码分析 Ceph CSI Kubernetes Plugin

2022秋春招/提前批面经分享总结(字节、腾讯、阿里)

学无止境的阿奔

腾讯 面试 阿里 字节 校招

为什么AI需要大大大大大模型?

脑极体

【LeetCode每日一题 Day 4】4. 寻找两个正序数组的中位数

编程熊

程序员 算法 LeetCode 面经 笔试

架构实战营模块一作业

Clarke

架构实战营

Python协程 & 异步编程(asyncio) 入门介绍

行者AI

#python

将 FeignClient 的请求记录成 cURL 格式

哈德韦

微服务 前后端分离 log4j curl Spring boot Feign

分层架构:一个经典却得不到优的难题

码猿外

架构 微服务

网络攻防学习笔记 Day63

穿过生命散发芬芳

网络攻防 7月日更

利用 IComparable<T> 以及 IComparer<T> 定义顺序关系

喵叔

7月日更

【Flutter 专题】97 仿网易新闻标签选择器

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 7月日更

大专的我,闭关苦学56天,含泪拿下阿里offer,五轮面试,六个小时灵魂拷问

Java架构师迁哥

在线HTML转JS/JSON工具

入门小站

工具

Ubuntu 如何强制时间同步

HoneyMoose

iOS 15隐私升级及注意事项

阿里巴巴大淘宝技术

ios WWDC21

新能源车的发展趋势

石云升

学习 新能源汽车 7月日更

花2个月备战字节,3轮面试拿下总包60W Offer!

Java架构师迁哥

从特斯拉召回事件,窥探OTA汽车进化真面目

脑极体

网络攻防学习笔记 Day62

穿过生命散发芬芳

网络攻防 7月日更

为什么AI需要大大大大大模型?

白洞计划

一文看懂 Context

Rayjun

Go 语言

08 | 指针系列(二):记住,指针变量也是变量(2)

Nydia

架构实战营模块7作业

Vic

架构实战营

改变一个字符后,我的 Go 程序快了 42%_文化 & 方法_Harry_InfoQ精选文章