指针

原文: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)
	...
}
相关推荐
梁梁梁梁较瘦4 小时前
内存申请
go
半枫荷4 小时前
七、Go语法基础(数组和切片)
go
梁梁梁梁较瘦1 天前
Go工具链
go
半枫荷1 天前
六、Go语法基础(条件控制和循环控制)
go
半枫荷2 天前
五、Go语法基础(输入和输出)
go
小王在努力看博客2 天前
CMS配合闲时同步队列,这……
go
Anthony_49263 天前
逻辑清晰地梳理Golang Context
后端·go
Dobby_054 天前
【Go】C++ 转 Go 第(二)天:变量、常量、函数与init函数
vscode·golang·go
光头闪亮亮4 天前
Golang使用gofpdf库和barcode库创建PDF原材料二维码标签【GBK中文或UTF8】及预览和打印
go