指针

原文:go101.org/optimizatio...

避免在循环中对nil数组指针进行不必要的检查

当前官方标准的Go编译器实现`(v1.24.n 版本)`存在一些缺陷。其中之一是,部分针对nil数组指针的检查没有从循环中移除。下面通过一个示例来展示这一缺陷。

css 复制代码
// unnecessary-checks.go
package pointers

import "testing"

const N = 1000
var a [N]int

//go:noinline
func g0(a *[N]int) {
	for i := range a {
		a[i] = i // line 12
	}
}

//go:noinline
func g1(a *[N]int) {
	_ = *a // line 18
	for i := range a {
		a[i] = i // line 20
	}
}

func Benchmark_g0(b *testing.B) {
	for i := 0; i < b.N; i++ { g0(&a) }
}

func Benchmark_g1(b *testing.B) {
	for i := 0; i < b.N; i++ { g1(&a) }
}

让我们使用 -S 编译选项运行基准测试,得到以下输出(省略不相关文本):

scss 复制代码
$ go test -bench=. -gcflags=-S unnecessary-checks.go
...
0x0004 00004 (unnecessary-checks.go:12)	TESTB	AL, (AX)
0x0006 00006 (unnecessary-checks.go:12)	MOVQ	CX, (AX)(CX*8)
...
0x0000 00000 (unnecessary-checks.go:18)	TESTB	AL, (AX)
0x0002 00002 (unnecessary-checks.go:18)	XORL	CX, CX
0x0004 00004 (unnecessary-checks.go:19)	JMP	13
0x0006 00006 (unnecessary-checks.go:20)	MOVQ	CX, (AX)(CX*8)
...
Benchmark_g0-4  494.8 ns/op
Benchmark_g1-4  399.3 ns/op

从输出结果中我们可以发现,即便g1实现多了一行代码(第18行),它的性能也比g0实现更好。为什么呢?输出的汇编指令给出了答案。

在g0实现中,TESTB指令在循环内部生成,而在g1实现中,TESTB指令在循环外部生成。TESTB指令用于检查参数a是否为nil指针。对于这种特定情况,检查一次就足够了。多出来的这一行代码避免了编译器实现中的缺陷。

还有第三种实现,其性能与g1实现相当。第三种实现使用从数组指针参数派生的切片。

css 复制代码
//go:noinline
func g2(x *[N]int) {
	a := x[:]
	for i := range a {
		a[i] = i
	}
}

请注意,该缺陷可能会在未来的编译器版本中得到修复。

还要注意的是,如果这三个实现函数可以内联,基准测试结果会有很大变化。这就是这里使用//go:noinline编译指令的原因。(在 Go 工具链 v1.18 之前,这里实际上并不需要//go:noinline编译指令。因为 Go 工具链 v1.18 之前从不内联包含 for - range 循环的函数。)

数组指针作为结构体字段的情况

对于数组指针作为结构体字段的情况,事情会稍微复杂一些。以下代码中的`_ = *t.a`这一行对于避免编译器缺陷并无用处。例如,在以下代码中,`f1`函数和`f0`函数之间的性能差异很小。(实际上,如果在`f1`函数的循环中生成一条NOP指令,它甚至可能更慢。)

css 复制代码
type T struct {
	a *[N]int
}

//go:noinline
func f0(t *T) {
	for i := range t.a {
		t.a[i] = i
	}
}

//go:noinline
func f1(t *T) {
	_ = *t.a
	for i := range t.a {
		t.a[i] = i
	}
}

为了将nil数组指针检查移出循环,我们应该把t.a字段复制到一个局部变量,然后采用上面介绍的技巧:

css 复制代码
//go:noinline
func f3(t *T) {
	a := t.a
	_ = *a
	for i := range a {
		a[i] = i
	}
}

或者直接从数组指针字段派生一个切片:

css 复制代码
//go:noinline
func f4(t *T) {
	a := t.a[:]
	for i := range a {
		a[i] = i
	}
}

基准测试结果:

bash 复制代码
Benchmark_f0-4  622.9 ns/op
Benchmark_f1-4  637.4 ns/op
Benchmark_f2-4  511.3 ns/op
Benchmark_f3-4  390.1 ns/op
Benchmark_f4-4  387.6 ns/op

结果验证了我们之前的结论。

注意,基准测试结果中提到的f2函数声明如下

css 复制代码
//go:noinline
func f2(t *T) {
	a := t.a
	for i := range a {
		a[i] = i
	}
}

f2的实现不如f3和f4的实现快,但比f0和f1的实现要快。不过,这是另外一回事了。

如果在循环中数组指针字段的元素不被修改(仅读取),那么f1的方式与f3和f4的方式性能相当。

就我个人而言,在大多数情况下,我认为我们应该尝试使用切片方式(f4的方式)来获得最佳性能,因为一般来说,官方标准的Go编译器对切片的优化要好于对数组的优化。

避免在循环中进行不必要的指针解引用。

有时,当前官方标准的Go编译器(v1.24.n)不够智能,无法以最优化的方式生成汇编指令。我们必须换一种方式编写代码以获得最佳性能。例如,在以下代码中,`f`函数的性能比`g`函数差很多。

go 复制代码
// avoid-indirects_test.go
package pointers

import "testing"

//go:noinline
func f(sum *int, s []int) {
	for _, v := range s { // line 8
		*sum += v // line 9
	}
}

//go:noinline
func g(sum *int, s []int) {
	var n = *sum
	for _, v := range s { // line 16
		n += v // line 17
	}
	*sum = n
}

var s = make([]int, 1024)
var r int

func Benchmark_f(b *testing.B) {
	for i := 0; i < b.N; i++ {
		f(&r, s)
	}
}

func Benchmark_g(b *testing.B) {
	for i := 0; i < b.N; i++ {
		g(&r, s)
	}
}

T基准测试结果 (不相关文本已省略):

scss 复制代码
$ go test -bench=. -gcflags=-S avoid-indirects_test.go
...
0x0009 00009 (avoid-indirects_test.go:9)	MOVQ	(AX), SI
0x000c 00012 (avoid-indirects_test.go:9)	ADDQ	(BX)(DX*8), SI
0x0010 00016 (avoid-indirects_test.go:9)	MOVQ	SI, (AX)
0x0013 00019 (avoid-indirects_test.go:8)	INCQ	DX
0x0016 00022 (avoid-indirects_test.go:8)	CMPQ	CX, DX
0x0019 00025 (avoid-indirects_test.go:8)	JGT	9
...
0x000b 00011 (avoid-indirects_test.go:16)	MOVQ	(BX)(DX*8), DI
0x000f 00015 (avoid-indirects_test.go:16)	INCQ	DX
0x0012 00018 (avoid-indirects_test.go:17)	ADDQ	DI, SI
0x0015 00021 (avoid-indirects_test.go:16)	CMPQ	CX, DX
0x0018 00024 (avoid-indirects_test.go:16)	JGT	11
...
Benchmark_f-4  3024 ns/op
Benchmark_g-4   566.6 ns/op

输出的汇编指令显示,在 `f` 函数中,指针 `sum` 在循环内被解引用。解引用操作是一种内存操作。而对于 `g` 函数,解引用操作发生在循环外,为循环生成的指令仅处理寄存器。让CPU指令处理寄存器比处理内存要快得多,这就是 `g` 函数性能比 `f` 函数好得多的原因。

这并非编译器的缺陷。实际上,`f` 函数和 `g` 函数并不等价(尽管在实际的大多数用例中,它们的结果是相同的)。例如,如果像下面代码所示那样调用它们,那么它们会返回不同的结果(感谢Reddit上的skeeto指出这一点)。

scss 复制代码
{
	var s = []int{1, 1, 1}
	var sum = &s[2]
	f(sum, s)
	println(*sum) // 6
}
{
	var s = []int{1, 1, 1}
	var sum = &s[2]
	g(sum, s)
	println(*sum) // 4
}

针对这种特定情况,另一种性能较好的实现方式是将指针参数移出函数体(同样,它与f函数或g函数并不完全等效):

go 复制代码
//go:noinline
func h(s []int) int {
	var n = 0
	for _, v := range s {
		n += v
	}
	return n
}

func use_h(s []int) {
	var sum = new(int)
	*sum += h(s)
	...
}
相关推荐
吴佳浩20 小时前
Go史上最大“打脸”现场来了:泛型方法终于实现了
后端·go
明月_清风1 天前
深入 Go 并发编程:从 Goroutine 到 Channel 的系统性避坑指南
后端·go
用户34232323763172 天前
开源!Go+Wails+Vue3 手搓一个 PLC 实时监控桌面工具
go
止语Lab2 天前
为什么你的 Go TCP server P99 延迟这么高
go
Andy Dennis2 天前
nsq学习记录
消息队列·go·nsq
韦胖漫谈IT2 天前
选语言不是站队,是选适合问题的工具
java·python·ai·rust·go·技术落地
喵个咪3 天前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
夜悊3 天前
Go网络编程的学习代码示例:客户端/服务端(C/S)模型
go
审判长烧鸡3 天前
【AI问答】GO代码循环返值
go
捧 花3 天前
Eino框架记忆功能实现指南
go·agent·eino