原文: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安全
对我来讲取绝对值最简单的函数实现是:输入参数 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 的绝对值:
x >> 63
,即 x 右移 63 位(获取最高位符号位),若是你对熟悉无符号整数的话, 应该知道若是 x 是负数则 y 是 1,否者 y 为 0(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 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 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) }
根据编译器的输出,能够看出 WithBranch
和 WithStdLib
在编译时候被内联了,可是 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 ...
编译器在编译 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