這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
原文:Optimized abs() for int64 in Go,譯文:在 Golang 中針對 int64 類型最佳化 abs(),歡迎轉載。
前言
Go 語言沒有內建 abs()
標準函數來計算整數的絕對值,這裡的絕對值是指負數、正數的非負表示。
我最近為瞭解決 Advent of Code 2017 上邊的 Day 20 難題,自己實現了一個 abs()
函數。如果你想學點新東西或試試身手,可以去一探究竟。
Go 實際上已經在 math
包中實現了 abs()
: math.Abs ,但對我的問題並不適用,因為它的輸入輸出的實值型別都是 float64
,我需要的是 int64
。通過參數轉換是可以使用的,不過將 float64
轉為 int64
會產生一些開銷,且轉換值很大的數會發生截斷,這兩點都會在文章說清楚。
文章 Pure Go math.Abs outperforms assembly version 討論了針對浮點數如何最佳化 math.Abs
,不過這些最佳化的方法因底層編碼不同,不能直接應用在整型上。
文章中的源碼和測試案例在 cavaliercoder/go-abs
類型轉換 VS 分支控制的方法
對我來說取絕對值最簡單的函數實現是:輸入參數 n 大於等於 0 直接返回 n,小於零則返回 -n(負數取反為正),這個取絕對值的函數依賴分支控制結構來計算絕對值,就命名為: abs.WithBranch
package absfunc WithBranch(n int64) int64 { if n < 0 { return -n } return n}
成功返回 n 的絕對值,這就是 Go v1.9.x math.Abs
對 float64 取絕對值的實現。不過當進行類型轉換(int64 to float64)再取絕對值時,1.9.x 是否做了改進?我們可以驗證一下:
package absfunc WithStdLib(n int64) int64 { return int64(math.Abs(float64(n)))}
上邊的代碼中,將 n 先從 int64
轉成 float64
,通過 math.Abs
取到絕對值後再轉回 int64
,多次轉換顯然會造成效能開銷。可以寫一個基準測試來驗證一下:
$ go test -bench=.goos: darwingoarch: amd64pkg: github.com/cavaliercoder/absBenchmarkWithBranch-8 2000000000 0.30 ns/opBenchmarkWithStdLib-8 2000000000 0.79 ns/opPASSok github.com/cavaliercoder/abs 2.320s
測試結果:0.3 ns/op, WithBranch
要快兩倍多,它還有一個優勢:在將 int64 的大數轉化為 IEEE-754 標準的 float64 不會發生截斷(丟失超出精度的值)
舉個例子:abs.WithBranch(-9223372036854775807)
會正確返回 9223372036854775807。但 WithStdLib(-9223372036854775807)
則在類型轉換區間發生了溢出,返回 -9223372036854775808,在大的正數輸入時, WithStdLib(9223372036854775807)
也會返回不正確的負數結果。
不依賴分支控制的方法取絕對值的方法對有符號整數顯然更快更准,不過還有更好的辦法嗎?
我們都知道不依賴分支控制的方法的代碼破壞了程式的運行順序,即 pipelining processors 無法預知程式的下一步動作。
與不依賴分支控制的方法不同的方案
Hacker’s Delight 第二章介紹了一種無分支控制的方法,通過 Two’s Complement 計算有符號整數的絕對值。
為計算 x 的絕對值:
- 先計算
x >> 63
,即 x 右移 63 位(擷取最高位符號位),如果你對熟悉不帶正負號的整數的話, 應該知道如果 x 是負數則 y 是 1,否者 y 為 0
- 再計算
(x ⨁ y) - y
:x 與 y 異或後減 y,即是 x 的絕對值。可以直接使用高效的彙編實現,代碼如下:
func WithASM(n int64) int64
// abs_amd64.sTEXT ·WithASM(SB),$0 MOVQ n+0(FP), AX // copy input to AX MOVQ AX, CX // y ← x SARQ $63, CX // y ← y >> 63 XORQ CX, AX // x ← x ⨁ y SUBQ CX, AX // x ← x - y MOVQ AX, ret+8(FP) // copy result to return value RET
我們先命名這個函數為 WithASM
,分離命名與實現,函數體使用 Go 的彙編 實現,上邊的代碼只適用於 AMD64 架構的系統,我建議你的檔案名稱加上 _amd64.s
的尾碼。
WithASM
的基準測試結果:
$ go test -bench=.goos: darwingoarch: amd64pkg: github.com/cavaliercoder/absBenchmarkWithBranch-8 2000000000 0.29 ns/opBenchmarkWithStdLib-8 2000000000 0.78 ns/opBenchmarkWithASM-8 2000000000 1.78 ns/opPASSok github.com/cavaliercoder/abs 6.059s
這就比較尷尬了,這個簡單的基準測試顯示無分支控制結構高度簡潔的代碼跑起來居然很慢:1.78 ns/op,怎麼會這樣呢?
編譯選項
我們需要知道 Go 的編譯器是怎麼最佳化執行 WithASM
函數的,編譯器接受 -m
參數來列印出最佳化的內容,在 go build
或 go test
中加上 -gcflags=-m
使用:
運行效果:
$ go tool compile -m abs.go# github.com/cavaliercoder/abs./abs.go:11:6: can inline WithBranch./abs.go:21:6: can inline WithStdLib./abs.go:22:23: inlining call to math.Abs
對於我們這個簡單的函數,Go 的編譯器支援 function inlining,函數內聯是指在調用我們函數的地方直接使用這個函數的函數體來代替。舉個例子:
package mainimport ( "fmt" "github.com/cavaliercoder/abs")func main() { n := abs.WithBranch(-1) fmt.Println(n)}
實際上會被編譯成:
package mainimport "fmt"func main() { n := -1 if n < 0 { n = -n } fmt.Println(n)}
根據編譯器的輸出,可以看出 WithBranch
和 WithStdLib
在編譯時間候被內聯了,但是 WithASM
沒有。對於 WithStdLib
,即使底層調用了 math.Abs
但編譯時間依舊被內聯。
因為 WithASM
函數沒法內聯,每個調用它的函數會在調用上產生額外的開銷:為 WithASM
重新分配棧記憶體、複製參數及指標等等。
如果我們在其他函數中不使用內聯會怎麼樣?可以寫個簡單的樣本程式:
package abs//go:noinlinefunc WithBranch(n int64) int64 { if n < 0 { return -n } return n}
重新編譯,我們會看到編譯器最佳化內容變少了:
$ go tool compile -m abs.goabs.go:22:23: inlining call to math.Abs
基準測試的結果:
$ go test -bench=.goos: darwingoarch: amd64pkg: github.com/cavaliercoder/absBenchmarkWithBranch-8 1000000000 1.87 ns/opBenchmarkWithStdLib-8 1000000000 1.94 ns/opBenchmarkWithASM-8 2000000000 1.84 ns/opPASSok github.com/cavaliercoder/abs 8.122s
可以看出,現在三個函數的平均執行時間幾乎都在 1.9 ns/op 左右。
你可能會覺得每個函數的調用開銷在 1.5ns 左右,這個開銷的出現否定了我們 WithBranch
函數中的速度優勢。
我從上邊學到的東西是, WithASM
的效能要優於編譯器實作類別型安全、記憶體回收和函數內聯帶來的效能,雖然大多數情況下這個結論可能是錯誤的。當然,這其中是有特例的,比如提升 SIMD 的加密效能、流媒體編碼等。
只使用一個內嵌函式
Go 編譯器無法內聯由彙編實現的函數,但是內聯我們重寫後的普通函數是很容易的:
package absfunc WithTwosComplement(n int64) int64 { y := n >> 63 // y ← x >> 63 return (n ^ y) - y // (x ⨁ y) - y}
編譯結果說明我們的方法被內聯了:
$ go tool compile -m abs.go...abs.go:26:6: can inline WithTwosComplement
但是效能怎麼樣呢?結果表明:當我們啟用函數內聯時,效能與 WithBranch
很相近了:
$ go test -bench=.goos: darwingoarch: amd64pkg: github.com/cavaliercoder/absBenchmarkWithBranch-8 2000000000 0.29 ns/opBenchmarkWithStdLib-8 2000000000 0.79 ns/opBenchmarkWithTwosComplement-8 2000000000 0.29 ns/opBenchmarkWithASM-8 2000000000 1.83 ns/opPASSok github.com/cavaliercoder/abs 6.777s
現在函數調用的開銷消失了,WithTwosComplement
的實現要比 WithASM
的實現好得多。來看看編譯器在編譯 WithASM
時做了些什嗎?
使用 -S
參數告訴編譯器列印出彙編過程:
$ go tool compile -S abs.go..."".WithTwosComplement STEXT nosplit size=24 args=0x10 locals=0x0 0x0000 00000 (abs.go:26) TEXT "".WithTwosComplement(SB), NOSPLIT, $0-16 0x0000 00000 (abs.go:26) FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB) 0x0000 00000 (abs.go:26) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 00000 (abs.go:26) MOVQ "".n+8(SP), AX 0x0005 00005 (abs.go:26) MOVQ AX, CX 0x0008 00008 (abs.go:27) SARQ $63, AX 0x000c 00012 (abs.go:28) XORQ AX, CX 0x000f 00015 (abs.go:28) SUBQ AX, CX 0x0012 00018 (abs.go:28) MOVQ CX, "".~r1+16(SP) 0x0017 00023 (abs.go:28) RET...
編譯器在編譯 WithASM
和 WithTwosComplement
時,做的事情太像了,編譯器在這時才有正確配置和跨平台的優勢,可加上 GOARCH=386
選項再次編譯產生相容 32 位系統的程式。
最後關於記憶體配置,上邊所有函數的實現都是比較理想的情況,我運行 go test -bench=. -benchme
,觀察對每個函數的輸出,顯示並沒有發生記憶體配置。
總結
WithTwosComplement
的實現方式在 Go 中提供了較好的可移植性,同時實現了函數內聯、無分支控制的代碼、零記憶體配置與避免類型轉換導致的值截斷。基準測試沒有顯示出無分支控制比有分支控制的優勢,但在理論上,無分支控制的代碼在多種情況下效能會更好。
最後,我對 int64 的 abs 實現如下:
func abs(n int64) int64 { y := n >> 63 return (n ^ y) - y}
via:Optimized abs() for int64 in Go
作者:Ryan Armstrong
譯者:wuYinBest
校對:rxcai