本文作者:icy

[Golang]Go 1.21 中的 CGO 性能(小伙伴们都升级了吗?)

icy 10-17 448 抢沙发
[Golang]Go 1.21 中的 CGO 性能(小伙伴们都升级了吗?)摘要: Cgo调用大约需要40ns,大约相同的时间 encoding/json解析单个数字整数所需的时间。 在我的 20 核机器上 Cgo 调用性能随着核心数量的增加而扩展,最多...

Cgo调用大约需要40ns,大约相同的时间 encoding/json解析单个数字整数所需的时间。 

在我的 20 核机器上 Cgo 调用性能随着核心数量的增加而扩展,最多可达 16 个核心,之后一些已知的争用问题会减慢速度。

虽然本文的很多内容都认为“Cgo 性能实际上很好”,但请不要认为这意味着“Cgo 实际上很好”。 我

有 维护使用 Cgo 和与 lua 的重要绑定的生产应用程序。 表现很棒。 Go 升级是一项常规的工作。 

使用 Cgo 的缺点是失去 Go 交叉编译的优势并且必须管理 c 依赖项。 这些日子 我主要使用 Cgo 来实现兼容性并访问用 C/C++ 编写的库。


Cgo 和性能

Go中Cgo性能了解甚少,网上搜资料 将 2 年前的内容与 7 年前的内容混合在一起。 

蟑螂实验室写了一篇 很棒的文章 它测量了性能并触及了使用 Cgo 的复杂性。 

从那时起,Go 的性能有了很大的提高,但是他们所说的其他一切都是相关的。

我的类似基准测试比 Cockroach 实验室在 2015 年看到的速度快 17 倍。其中一些可能是硬件问题,大部分只是改进 去吧。 

不幸的是,我看到很多 Go 程序员已经内化了“Cgo 很慢”,但实际上并不知道 与什么相比它慢。 

与常规函数调用相比,Cgo 速度较慢。 速度当然不慢 与执行任何类型的 I/O 或解析工作相比。


在这篇文章中 我想基于“ 每个程序员都应该知道的延迟数字 ”的想法来计算 在“缓慢”的 Cgo 的等级制度中,它处于什么位置? 

缓存引用 -> 互斥锁 -> 主内存引用 -> 在网络上发送数据包。 这些数字 是 2012 年的,所以它们实际上只是为了让我们有一个规模感:


延迟比较数字
L1缓存参考0.5纳秒
分支错误预测5纳秒
L2缓存参考7纳秒
互斥锁/解锁25纳秒
主内存参考100纳秒
使用 Zippy 压缩 1K 字节3,000 纳秒 3 微秒
通过 1 Gbps 网络发送 1K 字节10,000 纳秒 10 微秒
从 SSD 中随机读取 4K*150,000 纳秒 150 微秒
从内存中顺序读取 1 MB250,000 纳秒 250 微秒
同一数据中心内的往返500,000 纳秒 500 微秒


我的论点是:Cgo 有开销,但它没有以前那么多的开销,而且它 可能没有您想象的那么多开销。

让我们来谈谈 Cgo 是什么以及它是如何工作的。   铜矿  本质上是 Go 的 ffi。   

当你使用 Cgo 时,你可以从 Go 调用 C 函数,  来回传递信息(遵守某些 规则 )。  

Go编译器自动生成一些  在 Go 和 C 与句柄之间桥接的函数  比如平台调用约定的差异。   

阻塞方式也存在不匹配  处理调用以及如何分配堆栈使得运行 Go 和 C 不切实际/不安全  代码在同一个堆栈上。   

我不会过多地讨论实现,但在较高的层面上“Cgo 意味着线程之间的 IPC”  是一个很好的心智模型。 


基准测试

让我们编写一些基准测试来探索性能。 上进行操作  您可以在github.com/shanemhansen/cgobench 。  

存储库中的代码是自动生成的  来自 本文的源 org 文件,使用 Knuth 的文学编程实现。   

这是写文章最有成效的方式吗?  可能不是,但它很有趣,坦率地说,尝试新的工作流程有助于我的多动症大脑集中注意力。   但我离题了。

首先,我们将放置一个无操作的 go 函数 bench.go并进行并行基准测试。  它没有做任何事情 这是一个很好的起点。

bench.go

func Call() {
	// do less
}

现在,我们将添加一个简单的并行基准测试助手以及空调用基准测试。  我要开始了 使用如此简单的东西,编译器可以内联,然后将其与非内联调用进行比较。 

比较时 Go 与 Cgo 重要的是要认识到 Go 编译器无法内联 Cgo 函数。 

bench_test.go

// helper to cut down on boilerplate
func pbench(b *testing.B, f func()) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			f()
		}
	})

}
// Same as above, but explicitly calling the inlineable Call func.
func BenchmarkEmptyCallInlineable(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Call()
		}
	})
}
func BenchmarkEmptyCall(b *testing.B) {
	pbench(b, Call)
}

对无操作进行基准测试的情况下,检查并确保您的代码没有完全优化总是好的。 

 我倾向于只查看反汇编的输出 BenchmarkEmptyCall果然我看到了令人信服的 call *%rax大会中的指示。  

非动态 调度版本如下所示: call foo+0x3但这个版本正在调用一个地址位于 rax 寄存器中的函数。

让我们编译并检查: 

go test -c
objdump -S cgobench.test  | grep -A15 '^0.*/cgobench.BenchmarkEmptyCall.pbench.func'
0000000000522920 :
	b.RunParallel(func(pb *testing.PB) {
  522920:	49 3b 66 10          	cmp    0x10(%r14),%rsp
  522924:	76 36                	jbe    52295c 
  522926:	55                   	push   %rbp
  522927:	48 89 e5             	mov    %rsp,%rbp
  52292a:	48 83 ec 18          	sub    $0x18,%rsp
  52292e:	48 89 44 24 10       	mov    %rax,0x10(%rsp)
  522933:	48 8b 4a 08          	mov    0x8(%rdx),%rcx
  522937:	48 89 4c 24 08       	mov    %rcx,0x8(%rsp)
		for pb.Next() {
  52293c:	eb 0f                	jmp    52294d 
			f()
  52293e:	48 8b 54 24 08       	mov    0x8(%rsp),%rdx
  522943:	48 8b 02             	mov    (%rdx),%rax
  522946:	ff d0                	call   *%rax

现在我们已经验证了我们的基准测试,我们可以运行它了。  我将使用几个不同的计数值运行基准测试,以便我们可以看到输出如何变化。  

在写这篇文章时 发布后,我尝试了一些其他值,对于大多数基准测试,性能随着核心数线性增加到 16,然后开始下降。 

 在我的 20 核机器上,动态调用的开销约为 1 纳秒,而内联版本的速度要快得多。  作为 预期的。 

go test -cpu=1,2,4,8,16  -bench EmptyCall
goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 12th Gen Intel(R) Core(TM) i7-12700H
BenchmarkEmptyCallInlineable       	1000000000	         0.2784 ns/op
BenchmarkEmptyCallInlineable-2     	1000000000	         0.1383 ns/op
BenchmarkEmptyCallInlineable-4     	1000000000	         0.07377 ns/op
BenchmarkEmptyCallInlineable-8     	1000000000	         0.04089 ns/op
BenchmarkEmptyCallInlineable-16    	1000000000	         0.02481 ns/op
BenchmarkEmptyCall                 	718694536	         1.665 ns/op
BenchmarkEmptyCall-2               	1000000000	         0.8346 ns/op
BenchmarkEmptyCall-4               	1000000000	         0.4443 ns/op
BenchmarkEmptyCall-8               	1000000000	         0.2385 ns/op
BenchmarkEmptyCall-16              	1000000000	         0.1399 ns/op
PASS
ok  	github.com/shanemhansen/cgobench	3.819s

所以现在我可以将上表中的“go 函数调用”成本视为“比 L1 缓存引用贵一点”。 如果我们添加 Cgo 调用会发生什么?

下面是一个简单的 c 函数,用于添加 2 个整数和一个 go 函数来调用它。  

请注意,尽管我们可能 我们期望 gcc 内联 trivial_add,但我们不期望 Go 的编译器这样做。  

我确实玩过一些更简单的 C 函数,但它们并没有真正表现得更好。

bench.go

int trivial_add(int a, int b) {
  return a+b;
}

// wow this is easy
// import "C"
func CgoCall() {
	C.trivial_add(1,2)
}

bench_test.go

func BenchmarkCgoCall(b *testing.B) {
	pbench(b, CgoCall)
}

我们以通常的方式运行基准测试。  单线程Cgo开销约为40ns。  

表现 似乎随着核心数量线性扩展至 16 左右,所以如果我有 Cgo 绑定的工作负载,

我可能不会 懒得把它放在 32 核的机器上,但实际的工作负载通常不仅仅涉及调用 cgo func。  我们可以看到:

  • Cgo 的开销为 40ns。  它位于“互斥锁”和“主内存引用”之间。

  • 40ns/op 是 2500 万次操作/秒。  这对于我参与过的大多数项目来说都非常好。  在 4ns/ops 和 16 个核心下,我们可以获得 2.5 亿次 ops/s。 

go test -cpu=1,2,4,8,16,32  -bench Cgo

goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 12th Gen Intel(R) Core(TM) i7-12700H
BenchmarkCgoCall       	28711474	        38.93 ns/op
BenchmarkCgoCall-2     	60680826	        20.30 ns/op
BenchmarkCgoCall-4     	100000000	        10.46 ns/op
BenchmarkCgoCall-8     	198091461	         6.134 ns/op
BenchmarkCgoCall-16    	248427465	         4.949 ns/op
BenchmarkCgoCall-32    	256506208	         4.328 ns/op
PASS
ok  	github.com/shanemhansen/cgobench	8.609s

现在我想更多地了解为什么性能是这样的。  我们将使用 Go 出色的分析工具来更好地了解更高内核数量下的性能。  

我是 pprof 网络视图的粉丝, 这告诉我们 runtime.(*timeHistorgram).recordruntime.casgstatus需要花费很多时间。  

这  与 伊恩·兰斯·泰勒的观察 相一致。   有趣的是,他并不认为这些操作会受到争议,  所以有提高性能的潜力。

运行测试并收集结果: 

go test -c
./cgobench.test  -test.cpuprofile=c.out -test.cpu=16 -test.bench Cgo
go tool pprof -png cgobench.test c.out > cpu.png

goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 12th Gen Intel(R) Core(TM) i7-12700H
BenchmarkCgoCall-16    	235322289	         4.955 ns/op
PASS


注意底部附近的 2 个大框: 

cpu.png

我也用linux perf。  它能够很好地分析编译语言的跨语言内容以及结合用户空间和内核性能信息。 来自 perf 的相关热指令(之一)的快速快照: 

casgstatus.png

在我们将所有内容放在一起之前,我将添加最后一项数据以帮助我们获得观点。  

这是一个精心设计的 JSON 解码基准,仅解析整数。  它是 书面使用 json.NewDecoder因为只是 json.Unmarshal分配太多。  

您将在下面看到的是,Cgo 调用比使用标准的简单 JSON 解析便宜 20% 单线程和并行测试中的库。

bench_test.go

func BenchmarkJSONCall(b *testing.B) {
	msg := `1`
	b.RunParallel(func(pb *testing.PB) {
		var dst int
		r := strings.NewReader(msg)
		dec := json.NewDecoder(r)
		for pb.Next() {
			r.Seek(0, io.SeekStart)
			if err := dec.Decode(&dst); err != nil {
				panic(err)
			}
		}
	})
}
go test -cpu=1,16 -bench JSON

goos: linux
goarch: amd64
pkg: github.com/shanemhansen/cgobench
cpu: 12th Gen Intel(R) Core(TM) i7-12700H
BenchmarkJSONCall       	21399691	        52.79 ns/op
BenchmarkJSONCall-16    	217874599	         5.471 ns/op
PASS
ok  	github.com/shanemhansen/cgobench	2.942s

结论

那么此时我们至少测量了Cgo的性能开销 就挂钟时间而言(请注意,我们没有查看内存/线程数/电池使用情况/等)。 

我们知道开销约为 2 个互斥操作,并且确实如此 核心数量最多可达 16 个左右。

我们还发现,如果有 16 个核心,我们可以执行大约 4ns/op 或接近 2.5 亿 Cgo ops/s。  

因此,如果我考虑在 2023 年使用 Cgo,我肯定会使用 它位于非常热的循环之外。  

我不会在 2023 年使用 Cgo 的原因有很多(请参阅免责声明),但性能不太可能是其中之一。

我将以这个 Cgo 版本的“每个程序员都应该知道的延迟数字”表结束: 


Go/Cgo 延迟

基准名称1 核16核
内联空函数0.271纳秒0.02489纳秒
空函数1.5秒纳秒0.135纳秒
cgo40 ns4.281 ns
编码/json int 解析52.89纳秒5.518纳秒 


觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏

分享

发表评论

快捷回复:

评论列表 (暂无评论,448人围观)参与讨论

还没有评论,来说两句吧...