Go允许您编写汇编程序。 但是该语言的作者编写了这样一个标准库,因此不必这样做。 有多种方法可以同时编写可移植和快速的代码。 怎么了 欢迎光临。
开始在汇编器中编写函数非常简单。 例如,声明(正向声明)
Add
函数,该函数添加2 int64:
func Add(a int64, b int64) int64
这是正常功能,但功能体缺失。 尝试编译软件包时,编译器会发誓。
% go build examples/asm ./decl.go:4:6: missing function body
添加扩展名为.s的文件,并在汇编器中实现该功能。
TEXT ·Add(SB),$0-24 MOVQ a+0(FP), AX ADDQ b+8(FP), AX MOVQ AX, ret+16(FP) RET
现在,您可以编译,测试并将“
Add
用作常规功能。 语言开发人员自己在
运行时,math,bytealg,syscall,reflect,crypto包中广泛使用了此方法。 这使您可以使用
语言中未表示的硬件处理器优化和
命令 。
但是有一个问题-无法优化和内置(内联)asm上的函数。 没有这个,开销可能会令人望而却步。
var Result int64 func BenchmarkAddNative(b *testing.B) { var r int64 for i := 0; i < bN; i++ { r = int64(i) + int64(i) } Result = r } func BenchmarkAddAsm(b *testing.B) { var r int64 for i := 0; i < bN; i++ { r = Add(int64(i), int64(i)) } Result = r }
BenchmarkAddNative-8 1000000000 0.300 ns/op BenchmarkAddAsm-8 606165915 1.930 ns/op
对于内联汇编程序,有一些建议,例如gcc中的
asm(...)
指令。 他们都没有被接受。 代替此,添加
内在函数。
Go内置函数以纯Go语言编写。 但是编译器知道可以用更优化的方法代替它们。 在Go 1.13中,嵌入式函数包含在
math/bits
和
sync/atomic
。
这些软件包中的功能具有精美的签名。 实际上,它们重复处理器命令的签名。 如果目标体系结构支持,这使编译器可以用汇编器指令透明地替换函数调用。
下面,我想谈谈go编译器使用内置函数创建更高效代码的两种不同方式。
人口数
数字的二进制表示形式中的此单位数目是重要的密码原语。 由于这是一项重要的操作,因此大多数现代cpu都提供了硬件实现。
math/bits
包在
OnesCount*
函数中提供了此操作。 它们被识别并被
POPCNT
处理器
POPCNT
取代。
为了了解如何提高效率,让我们比较3种实现。 首先是
Kernigan算法 。
func kernighan(x uint64) (count int) { for x > 0 { count++ x &= x - 1 } return count }
算法的循环数与设置的位数一致。 更多位-更长的执行时间,这可能导致第三方渠道上的信息泄漏。
第二种算法来自
Hacker's Delight 。
func hackersdelight(x uint64) uint8 { const m1 = 0b0101010101010101010101010101010101010101010101010101010101010101 const m2 = 0b0011001100110011001100110011001100110011001100110011001100110011 const m4 = 0b0000111100001111000011110000111100001111000011110000111100001111 const h1 = 0b0000000100000001000000010000000100000001000000010000000100000001 x -= (x >> 1) & m1 x = (x & m2) + ((x >> 2) & m2) x = (x + (x >> 4)) & m4 return uint8((x * h1) >> 56) }
分而治之的策略使该版本可以从长整数开始为O(log 2)工作,并且从位数开始在恒定时间内工作,这对于密码学很重要。 让我们将性能与
math/bits.OnesCount64
进行比较。
func BenchmarkKernighan(b *testing.B) { var r int for i := 0; i < bN; i++ { r = kernighan(uint64(i)) } runtime.KeepAlive(r) } func BenchmarkPopcnt(b *testing.B) { var r int for i := 0; i < bN; i++ { r = hackersdelight(uint64(i)) } runtime.KeepAlive(r) } func BenchmarkMathBitsOnesCount64(b *testing.B) { var r int for i := 0; i < bN; i++ { r = bits.OnesCount64(uint64(i)) } runtime.KeepAlive(r) }
老实说,我们将相同的参数传递给函数:从0到bN的序列这对于Kernigan方法更是如此,因为其执行时间随输入参数的位数而增加。
➚ BenchmarkKernighan-4 100000000 12.9 ns/op BenchmarkPopcnt-4 485724267 2.63 ns/op BenchmarkMathBitsOnesCount64-4 1000000000 0.673 ns/op
math/bits.OnesCount64
赢得速度4次。 但是它确实使用硬件实现,还是编译器只是更好地优化了Hackers Delight的算法? 现在是时候进入汇编程序了。
go test -c
有一个用于拆卸go工具objdump的简单实用程序,但是我(与原始文章的作者不同),我将使用IDA。
这里有很多事情。 最重要的是:正如我们希望的那样,x86
POPCNT
指令已内置在测试本身的代码中。 这使标记标记比其他标记更快。
这个分支很有趣。
cmp cs:runtime_x86HasPOPCNT, 0 jz lable
是的,这是汇编程序上的polyphile。 并非所有处理器都支持
POPCNT
。 程序启动时,在您的
main
之前,
runtime.cpuinit
函数检查是否有必要的指令并将其保存在
runtime.x86HasPOPCNT
。 程序每次检查是否可以使用
POPCNT
或使用polyfile。 由于
runtime.x86HasPOPCNT
的值在初始化后不会更改,因此处理器分支的预测相对准确。
原子计数器
内部函数是常规的Go代码;它们可以在指令流中内联。 例如,我们将使用原子包功能的奇怪签名中的方法对计数器进行抽象。
package main import ( "sync/atomic" ) type counter uint64 func (c *counter) get() uint64 { return atomic.LoadUint64((*uint64)(c)) } func (c *counter) inc() uint64 { return atomic.AddUint64((*uint64)(c), 1) } func (c *counter) reset() uint64 { return atomic.SwapUint64((*uint64)(c), 0) } func F() uint64 { var c counter c.inc() c.get() return c.reset() } func main() { F() }
有人会认为这样的OOP会增加开销。 但是Go不是Java,除非您明确使用接口,否则该语言不会在运行时使用绑定。 上面的代码将分解为高效的处理器指令流。 主要是什么样子?
为了。
c.inc
变成
lock xadd [rax], 1
-x86的原子加法。
c.get
成为通常的
mov
指令,在x86中已经是原子的。
c.reset
成为空寄存器和内存之间
xchg
的原子交换。
结论
嵌入式函数是一种简洁的解决方案,无需扩展语言规范即可提供对低级操作的访问。 如果该体系结构没有特定的同步/原子原语(如某些ARM变体)或数学/位运算,则编译器将完全插入一个多文件。