查看原文
其他

某些情况下,合理使用Go指针将大大提升程序的运行效率

The following article is from Go 101 Author 老貘

1. 避免在循环中造成不必要的数组空指针检查


目前官方标准 Go 编译器实现中存在一些缺陷(v1.18)。其中之一是 一些 nil 数组指针检查没有被移出循环。这里有一个例子来体现这个缺陷。

// unnecessary-checks.gopackage pointers
import "testing"
const N = 1000var a [N]int
//go:noinlinefunc g0(a *[N]int) { for i := range a { a[i] = i // line 12 }}
//go:noinlinefunc 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, CX0x0004 00004 (unnecessary-checks.go:19) JMP 130x0006 00006 (unnecessary-checks.go:20) MOVQ CX, (AX)(CX*8)...Benchmark_g0-4 517.6 ns/opBenchmark_g1-4 398.1 ns/op

从输出结果中,我们可以发现 g1 实现比 g0 实现更高效。即使 g1 的实现多了一行代码(第 18 行)。为什么会这样?输出的汇编指令回答了这个问题。


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


这里有第三种实现,其性能与 g1 的实现一样高效。第三种实现方式使用了一个从数组指针参数派生出来的切片。

//go:noinlinefunc 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:noinlinefunc f0(t *T) { for i := range t.a { t.a[i] = i }}
//go:noinlinefunc f1(t *T) { _ = *t.a for i := range t.a { t.a[i] = i }}

欲将数组空指针检查移出循环,

我们应该把 t.a 字段复制到一个局部变量,然后采用上面介绍的技巧:

//go:noinlinefunc f3(t *T) { a := t.a _ = *a for i := range a { a[i] = i }}

或者简单地从数组指针字段中派生出一个切片:

//go:noinlinefunc f4(t *T) { a := t.a[:] for i := range a { a[i] = i }}

基准测试结果:

Benchmark_f0-4 622.9 ns/opBenchmark_f1-4 637.4 ns/opBenchmark_f2-4 511.3 ns/opBenchmark_f3-4 390.1 ns/opBenchmark_f4-4 387.6 ns/op

基准结果验证了我们上面的结论。


注意,基准结果中提到的 f2 函数声明为

//go:noinlinefunc 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.gopackage 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), SI0x000c 00012 (avoid-indirects_test.go:8) ADDQ (BX)(DX*8), SI0x0010 00016 (avoid-indirects_test.go:8) MOVQ SI, (AX)0x0013 00019 (avoid-indirects_test.go:7) INCQ DX0x0016 00022 (avoid-indirects_test.go:7) CMPQ CX, DX0x0019 00025 (avoid-indirects_test.go:7) JGT 9...0x000b 00011 (avoid-indirects_test.go:14) MOVQ (BX)(DX*8), DI0x000f 00015 (avoid-indirects_test.go:14) INCQ DX0x0012 00018 (avoid-indirects_test.go:15) ADDQ DI, SI0x0015 00021 (avoid-indirects_test.go:14) CMPQ CX, DX0x0018 00024 (avoid-indirects_test.go:14) JGT 11...Benchmark_f-4 3024 ns/opBenchmark_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) ...}


推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存