指针

原文: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)
	...
}
相关推荐
研究司马懿12 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
梦想很大很大1 天前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰1 天前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘1 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤1 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo