在 Golang 中針對 int64 類型優化 abs()

原文:Optimized abs() for int64 in Go,譯文:在 Golang 中針對 int64 類型優化 abs(),歡迎轉載。html

前言

Go 語言沒有內置 abs() 標準函數來計算整數的絕對值,這裏的絕對值是指負數、正數的非負表示。git

我最近爲了解決 Advent of Code 2017 上邊的 Day 20 難題,本身實現了一個 abs() 函數。若是你想學點新東西或試試身手,能夠去一探究竟。github

Go 實際上已經在 math 包中實現了 abs() : math.Abs ,但對個人問題並不適用,由於它的輸入輸出的值類型都是 float64,我須要的是 int64。經過參數轉換是可使用的,不過將 float64 轉爲 int64 會產生一些開銷,且轉換值很大的數會發生截斷,這兩點都會在文章說清楚。golang

帖子 Pure Go math.Abs outperforms assembly version 討論了針對浮點數如何優化 math.Abs,不過這些優化的方法因底層編碼不一樣,不能直接應用在整型上。shell

文章中的源碼和測試用例在 cavaliercoder/go-abs安全

類型轉換 VS 分支控制的方法

對我來講取絕對值最簡單的函數實現是:輸入參數 n 大於等於 0 直接返回 n,小於零則返回 -n(負數取反爲正),這個取絕對值的函數依賴分支控制結構來計算絕對值,就命名爲: abs.WithBranch架構

package abs

func 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 abs

func WithStdLib(n int64) int64 {
    return int64(math.Abs(float64(n)))
}

上邊的代碼中,將 n 先從 int64 轉成 float64,經過 math.Abs 取到絕對值後再轉回 int64,屢次轉換顯然會形成性能開銷。能夠寫一個基準測試來驗證一下:性能

$ go test -bench=.
goos: darwin
goarch: amd64
pkg: github.com/cavaliercoder/abs
BenchmarkWithBranch-8           2000000000               0.30 ns/op
BenchmarkWithStdLib-8           2000000000               0.79 ns/op
PASS
ok      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 的絕對值:

  1. 先計算 x >> 63 ,即 x 右移 63 位(獲取最高位符號位),若是你對熟悉無符號整數的話, 應該知道若是 x 是負數則 y 是 1,否者 y 爲 0
  2. 再計算 (x ⨁ y) - y :x 與 y 異或後減 y,便是 x 的絕對值。

    能夠直接使用高效的彙編實現,代碼以下:

func WithASM(n int64) int64
// abs_amd64.s
TEXT ·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: darwin
goarch: amd64
pkg: github.com/cavaliercoder/abs
BenchmarkWithBranch-8           2000000000               0.29 ns/op
BenchmarkWithStdLib-8           2000000000               0.78 ns/op
BenchmarkWithASM-8              2000000000               1.78 ns/op
PASS
ok      github.com/cavaliercoder/abs    6.059s

這就比較尷尬了,這個簡單的基準測試顯示無分支控制結構高度簡潔的代碼跑起來竟然很慢:1.78 ns/op,怎麼會這樣呢?

編譯選項

咱們須要知道 Go 的編譯器是怎麼優化執行 WithASM 函數的,編譯器接受 -m 參數來打印出優化的內容,在 go buildgo 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 main

import (
  "fmt"
  "github.com/cavaliercoder/abs"
)

func main() {
  n := abs.WithBranch(-1)
  fmt.Println(n)
}

實際上會被編譯成:

package main

import "fmt"

func main() {
  n := -1
  if n < 0 {
      n = -n
  }
  fmt.Println(n)
}

根據編譯器的輸出,能夠看出 WithBranchWithStdLib 在編譯時候被內聯了,可是 WithASM 沒有。對於 WithStdLib,即便底層調用了 math.Abs 但編譯時依舊被內聯。

由於 WithASM 函數無法內聯,每一個調用它的函數會在調用上產生額外的開銷:爲 WithASM 從新分配棧內存、複製參數及指針等等。

若是咱們在其餘函數中不使用內聯會怎麼樣?能夠寫個簡單的示例程序:

package abs

//go:noinline
func WithBranch(n int64) int64 {
    if n < 0 {
        return -n
    }
    return n
}

從新編譯,咱們會看到編譯器優化內容變少了:

$ go tool compile -m abs.go
abs.go:22:23: inlining call to math.Abs

基準測試的結果:

$ go test -bench=.
goos: darwin
goarch: amd64
pkg: github.com/cavaliercoder/abs
BenchmarkWithBranch-8           1000000000               1.87 ns/op
BenchmarkWithStdLib-8           1000000000               1.94 ns/op
BenchmarkWithASM-8              2000000000               1.84 ns/op
PASS
ok      github.com/cavaliercoder/abs    8.122s

能夠看出,如今三個函數的平均執行時間幾乎都在 1.9 ns/op 左右。

你可能會以爲每一個函數的調用開銷在 1.5ns 左右,這個開銷的出現否認了咱們 WithBranch 函數中的速度優點。

我從上邊學到的東西是, WithASM 的性能要優於編譯器實現類型安全、垃圾回收和函數內聯帶來的性能,雖然大多數狀況下這個結論多是錯誤的。固然,這其中是有特例的,好比提高 SIMD 的加密性能、流媒體編碼等。

只使用一個內聯函數

Go 編譯器沒法內聯由彙編實現的函數,可是內聯咱們重寫後的普通函數是很容易的:

package abs

func 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: darwin
goarch: amd64
pkg: github.com/cavaliercoder/abs
BenchmarkWithBranch-8               2000000000               0.29 ns/op
BenchmarkWithStdLib-8               2000000000               0.79 ns/op
BenchmarkWithTwosComplement-8       2000000000               0.29 ns/op
BenchmarkWithASM-8                  2000000000               1.83 ns/op
PASS
ok      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
...

編譯器在編譯 WithASMWithTwosComplement 時,作的事情太像了,編譯器在這時纔有正確配置和跨平臺的優點,可加上 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

相關文章
相關標籤/搜索