避免在循环中对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)
...
}