内置Go功能


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/bitssync/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变体)或数学/位运算,则编译器将完全插入一个多文件。

Source: https://habr.com/ru/post/zh-CN467673/


All Articles