某些情况下,合理使用Go指针将大大提升程序的运行效率
1. 避免在循环中造成不必要的数组空指针检查
目前官方标准 Go 编译器实现中存在一些缺陷(v1.18)。其中之一是 一些 nil 数组指针检查没有被移出循环。这里有一个例子来体现这个缺陷。
// 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
编译选项来运行此基准测试,得到的输出结果如下(省略了不感兴趣的输出):
$ 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 517.6 ns/op
Benchmark_g1-4 398.1 ns/op
从输出结果中,我们可以发现 g1
实现比 g0
实现更高效。即使 g1
的实现多了一行代码(第 18 行)。为什么会这样?输出的汇编指令回答了这个问题。
在 g0
实现中,TESTB
指令生成在在循环内,而在 g1
实现中,TESTB
指令生成在循环外。 TESTB
指令用于检查参数 a
是否是一个空指针。对于这种特定情况,检查一次就足够了。多出来的这一行代码避免了编译器实现中的缺陷。
这里有第三种实现,其性能与 g1
的实现一样高效。第三种实现方式使用了一个从数组指针参数派生出来的切片。
//go:noinline
func g2(x *[N]int) {
a := x[:]
for i := range a {
a[i] = i
}
}
请注意该缺陷可能在未来的编译器版本中被修补。
同时请注意,如果这三个函数实现可以内联,那么基准测试结果将产生很大变化。这就是为什么这里使用 //go:noinline
编译器指示的原因。(然而,我们应该知道的是,在Go 工具链 v1.18 之前,//go:noinline
编译器指示在这里实际上是不必要的,因为 包含 for-range
循环的函数从 Go 工具链 v1.18 以前是不可内内联的)。
2. 数组指针是一个结构体字段的情况
如果一个数组指针为一个结构体字段的情况,情况会稍微有点复杂。下面代码中的 _ = *t.a
一行无法避开上述编译器缺陷。例如,在下面的代码中,f1
函数和 f0
函数的性能差异很小。(事实上,如果在 f1
函数的循环内产生了一条 NOP
指令,那它可能更慢。)
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
}
}
欲将数组空指针检查移出循环,
我们应该把 t.a
字段复制到一个局部变量,然后采用上面介绍的技巧:
//go:noinline
func f3(t *T) {
a := t.a
_ = *a
for i := range a {
a[i] = i
}
}
或者简单地从数组指针字段中派生出一个切片:
//go:noinline
func f4(t *T) {
a := t.a[:]
for i := range a {
a[i] = i
}
}
基准测试结果:
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
函数声明为
//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 编译器对切片的优化要比对数组的优化做得好。
3. 避免在循环中进行不必要的解引用
某些时候,当前的官方标准 Go 编译器(v1.18) 没有聪明到以最优化的方式生成汇编指令。我们不得不以另一种方式写代码以获得最佳性能。例如,在下面的代码中,f
函数的性能比 g
函数差得多。
// avoid-indirects_test.go
package pointers
import "testing"
func f(sum *int, s []int) {
for _, v := range s { // line 7
*sum += v // line 8
}
}
func g(sum *int, s []int) {
var n = 0
for _, v := range s { // line 14
n += v // line 15
}
*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)
}
}
基准测试结果(省略了不感兴趣的文字):
$ go test -bench=. -gcflags=-S avoid-indirects_test.go
...
0x0009 00009 (avoid-indirects_test.go:8) MOVQ (AX), SI
0x000c 00012 (avoid-indirects_test.go:8) ADDQ (BX)(DX*8), SI
0x0010 00016 (avoid-indirects_test.go:8) MOVQ SI, (AX)
0x0013 00019 (avoid-indirects_test.go:7) INCQ DX
0x0016 00022 (avoid-indirects_test.go:7) CMPQ CX, DX
0x0019 00025 (avoid-indirects_test.go:7) JGT 9
...
0x000b 00011 (avoid-indirects_test.go:14) MOVQ (BX)(DX*8), DI
0x000f 00015 (avoid-indirects_test.go:14) INCQ DX
0x0012 00018 (avoid-indirects_test.go:15) ADDQ DI, SI
0x0015 00021 (avoid-indirects_test.go:14) CMPQ CX, DX
0x0018 00024 (avoid-indirects_test.go:14) JGT 11
...
Benchmark_f-4 3024 ns/op
Benchmark_g-4 566.6 ns/op
输出的汇编指令显示指针 sum
在 f
函数的循环中被解引用。解引用操作是一个内存操作。对于 g
函数,解引用操作发生在循环外, 而为循环产生的指令只处理寄存器。CPU 指令处理寄存器的速度比处理内存要快得多。这就是为什么 g
函数比 f
函数的性能好得多原因。
对于这种特定情况,另一种高性能实现是将指针参数移出函数体:
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)
...
}
推荐阅读