阅读 1106

Go语言汇编优化-蒙卓

原文链接: mp.weixin.qq.com

目录

  • 基础知识

  • 汇编语法

  • Demo

  • 基本程序

  • debug

讲汇编优化,不得不说一句高德纳的名言——过早的优化就是万恶之源。如果你们没有被逼到绝路,或者要榨干CPU的性能,千万不要尝试以下演讲的内容。



    我给 Go 的 1.11 提交了这几个项目,第一个是 Hashmap 优化,就是你们常用的 map 操作里面最费时的哈希值计算优化。VDSO,虚拟动态对接的 syscall,主要是优化系统时间调用。Md5、Chacha20就不说了。还有一个 Duffcopy,这是给编译器展开优化用的,它在 arm64平台优化得不是很好,所以我也做了优化。除了 Chacha20还没有完成外,其他的都已经在 Go master 上可以用到了。可能有些人会觉得为什么都是 arm64 平台的优化?其实就是 Go 官方团队维护了 X86-64,已经优化得很好,我就不要搀和了,就挑了一个比较新的平台,arm64。



   国内 arm 公司的大牛肖玮带领他的团队也在做 Go 相关的优化,比如 sha256,提升的效率有 16倍。国外的也有,Cloudflare,做CDN的公司,他们有一个密码学大牛弗拉德做了一些优化,也在 Go 的1.11里面合进去了,优化的效率是多少呢?



这是他们的CTO转发的推,CTO问他上周优化了一些什么东西呢?他说他优化了一些Go的库,RSA 性能有20倍,AES-GCM有15倍,P256有18倍。看了这些大牛优化以后有这么好的性能提升,是不是很心动啊?这次演讲就是教大家入门汇编优化,怎么做十几倍的加速。

1. 基础知识

   所以怎么跑得那么快?就要知道干什么。 总结下来有三点,减少读写,并行操作,硬件加速。

1.1 减少读写



  上图是谷歌的 Jeff Dean 分享的《程序员应该知道的延迟》,这个延迟是什么延迟呢?比如数据从CPU L1里面挖出来的速度,在2012年的时候是0.5ns,CPU L2里面是7ns;储存,也就是我们常说的内存里面拉出来是100ns。大家有没有发现每多一层就是10倍的性能下降,所以你要尽量少用内存的操作,多用寄存器。还有,CPU访问内存的时候有一个小窍门,把这个对齐再访问,CPU会执行得更快一些。这些都是基本知识,大家可以百度、Google 一下,不展开。

1.2 并行操作

业内叫做并行操作SIMD,就是单一指令多个数据进行操作。比如一般的加法操作,一次性只能加一个数,但是你要是用上一些向量指令集,就可以一次性操作8个、16个、32个,意味着相同的时间内能操作数据就更多,也就更快,这是很自然的事情。

1.3 硬件加速

算法再好,最多10倍,然而硬件指令是16倍朝上,比如肖伟和弗拉德他们做的优化基本上是借助硬件指令,非常简单粗暴。像马云说的,武功再高,也怕菜刀。

1.4 程序内存分布

  • 构造与其他程序一致

  • TEXT=可执行代码

  • DATA=堆+全局变量

  • frame=函数参数+临时数据

  • stack=Go调度器/信号处理



    Go 的内存分布要大致了解,因为汇编是直接对内存进行的操作,所以你需要对内存的位置,哪个位置存什么东西有所了解。其实 Go 怎么使用内存和其他程序是差不多的。最下面的TEXT是存放可知性代码,DATA 是堆和全局变量。唯一不一样的地方是 Go 没有完全使用系统栈,而是拆成 frame 栈帧,栈帧保证程序存的参数和临时数据。那原来系统的栈拿去干嘛了?Go 的调度器和信号处理都是在系统栈上,不在栈帧上。

2. 汇编语法

汇编语法特点

  • 准抽象汇编语言

  • AT&T风格(左到右)

  • 指令 参数×N 目标(N=0...3)

    虽然看起来汇编语法是好复杂,其实是非常简单粗暴的,没有 C++、Java 等一堆术语。对内存直接操作,就是这么简单。实际上Go的汇编语法和Plan9这个操作系统渊源很深,Plan9 操作系统大家可能没有听说过,其实和Go是同一波人做的。

   Go 的汇编语法其实很简单,它是准抽象的汇编语言,为什么叫准抽象?Go 本来的汇编语言是希望大一统,有什么X86-64,Arm64,大家只要写一种汇编语言就可以。实现起来后发现大部分做不到,最后只能保留差异,统一了风格,再输出机器码,所以叫准抽象的汇编语言。再有就是它的AT&T的风格,从左到右的写法,就是指令级在最左边,中间设几个参数,然后放目标寄存器或者目标,其他的嘛,各个平台就完全不一样了。

2.1 汇编语法例子

  这个函数很复杂,c=a+b,然后返回。第一步怎么做呢?我把这个函数名搬下来,这个英文SB实际上是告诉汇编器是说这个东西是static base,基于静态地址寻址。 刚才讲的TEXT区,这是告诉汇编器说你从这里开始找,不要从别的地方找,汇编器说,行,我直接把地址编进去,就这么简单。 还记得例子里刚才我们看到三个参数,abc,都是 int64,一个 int64多少字节?8个字节哈,所以这个栈帧长24个字节。注意这里有对齐的问题,其他平台不一定是24,不过为了简单理解,我把24放到这里来。

2.2 例子代码讲解

第一步是move指令

就是把一个数据从一个地方挪到另外一个地方,简单就是把ab两个数据放寄存器R1、R2里面,这里面多一个东西,FP,就是 Frame Pointer,刚才讲到栈帧保存参数和临时存储的数据的地方。 这就是就是FP开始寻址,FP指栈帧的最低位。你把a拿出来,从0开始寻,挪到R1里面,把B拿出来,是不是8个字节,然后就把它存到R2里面。

第三步,R3=a+b。

最后把R3里面的数据放回C的参数返回,Return。

 大家到现在就已经学会汇编语言了。

   非常简单,但是大家最好不要这么写,为什么?我用Go写,就一行的事情。你用汇编写内存动来动去,还要算来算去,千万不要用汇编写复杂语言,这很困难。还记得,我们刚才讲的三个汇编优化目标,减少读写、并行操作、硬件加速。

2.3 减少读写

比如 memmove,Go 内建函数 copy,很简单,把一片数据从原地址挪到目标地址,最简单的做法是一个一个搬,从原地址挪8个字节,再搬8个,存进去,再搬8个字节出来,一直循环完为止。这里面会有什么问题?

塞满寄存器

每次搬8个字节就要走一遍,还要用同一个寄存器,CPU就不高兴了——它的性能就下降。

占满流水线

这种现象叫做CPU流水线堵塞,你搬一个用一个,会造成堵塞。怎么解决这个问题?就是疯狂的从源地址能挪多少挪多少,一次性把所有的数据搬到CPU不同寄存器里,再一次性写到目标地址里面去。这样做就可以避免刚才说的流水线的阻塞问题。

处理块数据

  处理块数据对CPU来说是非常容易的事情,它可以把之前操作的数据塞到L1、L2里面去,所以这个寻址速度比主存里面拉出来快很多。

 这个写起来有点复杂,但是最核心的,arm64平台的寄存器很多,32个,减去Go拿来做内部用途的4个,还给你留下28个。所以你可以一次性的搬28乘以8个字节。代码应该这样写。大家还会注意到,这里怎么性能下降了?这就CPU有关系,各个公司实现的CPU不一样,有些公司偷工减料,不巧我碰上了,所以出现这个问题。根据 arm 的说明书说的,访问未对齐地址不会有任何性能惩罚。最后怎么测?找台CPU比较好的来测,最后高通一个哥们儿给我发数据,说这个优化效果很好,都是有提升的。很遗憾,这个patch没有被官方接受,为什么?就是因为开源协议,因为 Go 用BSD,我参考 glib c这个代码,毕竟这个算法不是我想的,天下代码一乱抄,代码都是从别的地方搬过来的,glibc 用的协议是 GPL,Go 的核心开发就说了,这个用GPL,不行。我辩解过 glibc 也是开源,为什么不能用?官方回复就是我们公司BSD和GPL不能互用,所以这个 patch 没有进入 Go 的 master 里面,很遗憾。



2.4 并行操作

给大家举个例子。很简单,一个 uint8的 slice,你把它加起来,放到dst里面,把这个slice的数据全部加起来。这里数据比较复杂,所以我要给大家做一个Demo给大家看。

这里面有三个函数,我们先看第一个,刚才的函数和刚才一模一样,直接摘过来的,下面是空的,意思是告诉汇编器,这里要开始了。凡事都要测试,我把这些数据摆出来,64个,全部塞进去,dst,把64减去原来的i值,最后每个64。这个代码是空的,什么都没有。这个和刚才差不多,Slice的数据结构有人了解过吗?一共占用三个数字而已。刚才的函数大家还记得吗?两个slice,从第一个slice里面读是从0号位开始读,我从第二个slice里面读是从第二位开始读,大家不明白也可以。你暂时理解为把两个指针塞到R1和R3里面。接下来我把R1和R3里面的数据分别载入到4个向量寄存器里面,也就是一共是8个,载入进来以后,最后做一个向量加的操作,最后把这些数据塞回给R1。最后是返回操作。你要开发Go的master代码,可能需要一些Go最新的编译器。测试的结果没有问题。

   看一下效果,上面的函数,刚才用Go实现的版本,下面这个是用向量加的方式加的,这两个函数只差一个函数名。这个吞吐量原来用Go实现的,原来285MB/S,用向量,3GB/s,效果提升了10倍。其实真正的优化不会有这么高,这是在你的算法和数据结构实现好的情况下,差不多才有这个性能提升。

   可能大家有点印象,几个文件的名字有点奇怪,为什么在后面加了arm64?这是告诉汇编器只能在arm64编译,其他的平台不要动它。

   benchmark很重要,你觉得代码、数据结构很好,但是测出来不行,为啥?这就需要测试和benchmark来找出来。

2.5 GDB Debug

Go 写的代码,比如二进制程序进来,gdb下怎么运行程序?run一下。

你在某一行、某一个地方想打断点,这对汇编程序很重要,用 break。

想试试看接下来怎么运行,Go 做得很好的地方是连汇编都能按一行行执行, 用n ext。

有时候 Go 的优化里面会用到寄存器,那么查看寄存器是 info register。

有时候你要全局变量那些东西怎么查看?用 eXamine 查看全局变量的地址或者寄存器,以寄存器为主来做这个东西。

   最后是硬件加速,时间有限就不展开了,还有硬件加速是非常难的事情,你要对特定的CPU 的指令集非常了解。

   以上是所有内容,谢谢大家!

 

【提问环节】

问: 汇编看不懂,很多对不上。 

蒙卓:右边的图是一个示例,具体到里面讲解到的所谓栈帧的实现是每个平台都不一样,我后面的参考资料里面有,国内 滴滴的开发曹同学 (xargin) 的也研究过 ,发现 X86和 arm64连栈帧的实现都不一样,这个真得看源代码。

提问:看编译器的代码?

蒙卓:不是,看 runtime 的代码,它上面有些文档,但是不全。如果你真的有疑问,可以用GDB跟着跑一次就知道了。

提问:很多参数是一次性加载的,减少了读,是不是以空间换时间的方式?会不会耗费大量的空间? 

蒙卓:对,用寄存器空间换执行时间。寄存器就是拿来塞东西的,我这么好好的用他们,不是很好吗?

 

提问:汇编这块要怎么用让我们学习?因为我本身也看过个官方的源码,如果我把源码拿去debug,行不行,怎么做?

蒙卓:可行,就用GDB,日志看不到,但是看函数行为、打断点都可行。

提问:这个我找资料可以看到,现在拿到Go语言的源码,怎么跑起来?然后我在Go语言里面,比如举个场景,现在Go语言的语法可能想看一下语法怎么运作,甚至怎么编译的,这个要怎么调试才能看到?

蒙卓:Go本身只是一个编译器,编译出来的东西是都是计算机可执行,这涉及三个环节,一个是编译器,一个是连接器,还有最后可执行文件(打包),要看三个部分(的代码)。就是说你知道Go的源代码没有问题,但本身只是编译,只是把Go的语言、语句变成二进制的文件而已,就这么简单。你刚才说的怎么编译的过程?

提问:怎么变的。

蒙卓:要看Go下面编译器的代码。

提问:我要看怎么跑起来,通过IDE的方式看得到它的执行步骤。 

蒙卓:IDE的方式,那就是跟Go其他程序一样,比如Go run什么的。 

提问:我没有跑成功过。我就想分析它的语法术,看它编译原理是什么样。语法这些我知道官方有,但是我想自己改变它的语法,重新实现一套,增加自己内测的语法功能。

其他观众:Go里面有专门AST包。

蒙卓:你要研究的部分,从语法分析直到编译的环节,Go官方自己的文档里面有的,源代码都在 golang.org/pkg/go 里面。

文章分类
后端
文章标签