某些情況下,合理使用Go指針將大大提升程序的運(yùn)行效率
1. 避免在循環(huán)中造成不必要的數(shù)組空指針檢查
目前官方標(biāo)準(zhǔn) Go 編譯器實(shí)現(xiàn)中存在一些缺陷(v1.18)。其中之一是?一些 nil 數(shù)組指針檢查沒有被移出循環(huán)。這里有一個例子來體現(xiàn)這個缺陷。
// unnecessary-checks.gopackage pointersimport "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 18for 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?編譯選項(xiàng)來運(yùn)行此基準(zhǔn)測試,得到的輸出結(jié)果如下(省略了不感興趣的輸出):
$ 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
從輸出結(jié)果中,我們可以發(fā)現(xiàn)?g1?實(shí)現(xiàn)比?g0?實(shí)現(xiàn)更高效。即使?g1?的實(shí)現(xiàn)多了一行代碼(第 18 行)。為什么會這樣?輸出的匯編指令回答了這個問題。
在?g0?實(shí)現(xiàn)中,TESTB?指令生成在在循環(huán)內(nèi),而在?g1?實(shí)現(xiàn)中,TESTB?指令生成在循環(huán)外。?TESTB?指令用于檢查參數(shù)?a?是否是一個空指針。對于這種特定情況,檢查一次就足夠了。多出來的這一行代碼避免了編譯器實(shí)現(xiàn)中的缺陷。
這里有第三種實(shí)現(xiàn),其性能與?g1?的實(shí)現(xiàn)一樣高效。第三種實(shí)現(xiàn)方式使用了一個從數(shù)組指針參數(shù)派生出來的切片。
//go:noinlinefunc g2(x *[N]int) {a := x[:]for i := range a {a[i] = i}}
請注意該缺陷可能在未來的編譯器版本中被修補(bǔ)。
同時請注意,如果這三個函數(shù)實(shí)現(xiàn)可以內(nèi)聯(lián),那么基準(zhǔn)測試結(jié)果將產(chǎn)生很大變化。這就是為什么這里使用?//go:noinline?編譯器指示的原因。(然而,我們應(yīng)該知道的是,在Go 工具鏈 v1.18 之前,//go:noinline?編譯器指示在這里實(shí)際上是不必要的,因?yàn)?包含?for-range?循環(huán)的函數(shù)從 Go 工具鏈 v1.18 以前是不可內(nèi)內(nèi)聯(lián)的)。
2. 數(shù)組指針是一個結(jié)構(gòu)體字段的情況
如果一個數(shù)組指針為一個結(jié)構(gòu)體字段的情況,情況會稍微有點(diǎn)復(fù)雜。下面代碼中的?_ = *t.a?一行無法避開上述編譯器缺陷。例如,在下面的代碼中,f1?函數(shù)和?f0?函數(shù)的性能差異很小。(事實(shí)上,如果在?f1?函數(shù)的循環(huán)內(nèi)產(chǎn)生了一條?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.afor i := range t.a {t.a[i] = i}}
欲將數(shù)組空指針檢查移出循環(huán),
我們應(yīng)該把?t.a?字段復(fù)制到一個局部變量,然后采用上面介紹的技巧:
//go:noinlinefunc f3(t *T) {a := t.a_ = *afor i := range a {a[i] = i}}
或者簡單地從數(shù)組指針字段中派生出一個切片:
//go:noinlinefunc f4(t *T) {a := t.a[:]for i := range a {a[i] = i}}
基準(zhǔn)測試結(jié)果:
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
基準(zhǔn)結(jié)果驗(yàn)證了我們上面的結(jié)論。
注意,基準(zhǔn)結(jié)果中提到的?f2?函數(shù)聲明為
//go:noinlinefunc f2(t *T) {a := t.afor i := range a {a[i] = i}}
f2?實(shí)現(xiàn)沒有?f3?和?f4?實(shí)現(xiàn)快,但它比?f0?和?f1?實(shí)現(xiàn)快。不過,那是?另一個故事。
如果數(shù)組指針字段的元素在循環(huán)中不被修改(而僅被讀取),那么?f1?實(shí)現(xiàn)與?f3?和?f4?實(shí)現(xiàn)性能相當(dāng)。
我的個人觀點(diǎn)是,對于大多數(shù)情況,我們應(yīng)該嘗試使用切片方式(?f4?實(shí)現(xiàn))來獲得最佳性能, 因?yàn)橥ǔ碚f,官方標(biāo)準(zhǔn) Go 編譯器對切片的優(yōu)化要比對數(shù)組的優(yōu)化做得好。
3. 避免在循環(huán)中進(jìn)行不必要的解引用
某些時候,當(dāng)前的官方標(biāo)準(zhǔn) Go 編譯器(v1.18)?沒有聰明到以最優(yōu)化的方式生成匯編指令。我們不得不以另一種方式寫代碼以獲得最佳性能。例如,在下面的代碼中,f?函數(shù)的性能比?g?函數(shù)差得多。
// avoid-indirects_test.gopackage pointersimport "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 = 0for _, v := range s { // line 14n += v // line 15}*sum = n}var s = make([]int, 1024)var r intfunc 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)}}
基準(zhǔn)測試結(jié)果(省略了不感興趣的文字):
$ 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?函數(shù)的循環(huán)中被解引用。解引用操作是一個內(nèi)存操作。對于?g?函數(shù),解引用操作發(fā)生在循環(huán)外, 而為循環(huán)產(chǎn)生的指令只處理寄存器。CPU 指令處理寄存器的速度比處理內(nèi)存要快得多。這就是為什么?g?函數(shù)比?f?函數(shù)的性能好得多原因。
對于這種特定情況,另一種高性能實(shí)現(xiàn)是將指針參數(shù)移出函數(shù)體:
func h(s []int) int {var n = 0for _, v := range s {n += v}return n}func use_h(s []int) {var sum = new(int)*sum = h(s)...}
推薦閱讀
