ARM 64平台之上的Go工具链实现剖析

640?wx_fmt=png

作者简介

640?wx_fmt=png

肖 玮

2016年至今一直在 arm 开源软件部门担任主任工程师,领导 Golang 针对 arm64 架构的功能实现(enabling)和性能优化工作,同时也是 Golang 汇编器(asm)和编译器(compile)针对 arm64 架构改进的主要贡献者之一。在加入 arm 之前一直供职于 Intel 开发者工具事业部,长期从事针对 X86 架构的动态二进制翻译器(DBT)和编译器产品等相关工作。








1.Toolchain

2.Compile

3.Asm

4.Link

5.Others



   讲到 Arm 大家首先会想到的就是手机的处理器, 不管是安卓还是苹果手机,他们所采用的处理器绝大部分都是由 Arm 设计。那么问题来了,为什么我会出现在一个 Golang 的会议上呢? 因为 Golang 看起来比较后端。原因是 Arm 这几年除了继续聚焦于移动处理器,也开始在服务器市场发力。一讲到服务器、云计算肯定少不了我们的 Golang。因为移动计算的需求,Arm 处理器的功耗一直是很低的,除此之外 Arm 服务器和英特尔服务器有什么区别呢?首先价格便宜。还有一些别的方面的差异,例如最近远程登录过一台合作伙伴的 Arm 服务器,有两百多个核以前一个服务如果要消耗一两百个核的话,需要好几台 X86 服务器来支撑,现在只需要一台 Arm 服务器就够了。虽然 Arm 单核性能没有 X86 好,但是省电,在性能要求不那么高的场合,例如存储服务,Arm 的服务器优势。  


1.Toolchain

1.1 Go toolchain overview

640?wx_fmt=png

   Golang 有好几工具绝大部分户使用的工具链其实是 GC 工具,它自于 plan9,该系统虽然不是主流系统但现在还活着,有自己的一整套工具链,包括编译器、汇编器和链接器。所以 GC 工具链里有很多 plan9 的影子第二个工具链就是 gccgo,它基于 Gcc 编译器工具链,对于一些比较老的架构例如 sparc 有很好的支持,其实 Golang 很多核心开发者以前就是 GCC 的开发者,他们很钟爱以前的编译器,所以肯定会让 GCC 支持 Go 语言,但有一个问题,许可证于 GPL,这对一些开源项目可能会存在问题第三个工具链 llgo 基于 LLVM 的,在 LLVM 上面做了一个 Golang 的前端,但是这个项目现在几乎没有人维护了。去年的时候,谷歌Golang组又起了一个 GoLLVM 的项目,试图走得更远一点Golang 核心开发人员现在对这个项目比较保守,一直说只是做实验,短期可能不会变成官方的工具链。


1.2 Go toolchain example

640?wx_fmt=png

  大家用的最多的可能是 Go build 或者是 Go install,这些工具会调用一些其他的工具,比如 compile 和 asm。asm 就是做一些机器码的生成,最后把各个包(package) 给 link 做一个拼装,生成最终可执行的代码。


1.3 Go toolchain workflow

640?wx_fmt=png

   上图所示,Go 的工具链除了刚才提的三个工具其实还有很多别的工具。比如有 cgo,nm 等等。还有 objdump,就是进行反汇编,从机器码回到可读的汇编语言。由于时间关系今天主要给大家介简单介绍一下 compileasm 和 link这些工具都是和目标机器的类型相关的,如果你的目标机器是 X86 而不是 Arm,他们生成的可执行文件是不一样的


2.  Compile

2.1  Go compiler  overview


我对 Go 编译器的理解分为经典的三个阶段:前端、中端和后端。

640?wx_fmt=png

   

第一个阶段前端用户的 Go 程序会被前端生成抽象语法树 (AST)并 AST上面会做一些初步的工作,例如类型检查

第二个阶段是中Go 编译器形成跟目标无关的中间表示,也就基于 SSA 的中间表示在这个 SSA 上面做一些和目标机器无关的优化会比较方便,例如一些图的优化会比较直观。

第三个阶段是后端,Go 编译器生成目标机器的机器指令


2.1.1  Front end

640?wx_fmt=png

   再回到前端,这是用户最容易感受到的一个阶段,例如如果你写的 Go 程序不规范,语法上有问题,就会被前端的语法规则检查报错如果你表达是两边的类型写得不匹配,前端报错。还有inlie,你的函数比较小,你去 call 它的话不会有 call 指令,前端会做 inline 优化,相当于把它贴过来了。但是 inline 争议比较多,做的不太好可能会有副作用我最近看到谷歌的工程师在讨论这一点,他们会在最近一两个版本对这一块进行改进,允许一些非叶子函数做简单的 inline。


2.1.2 Middle end

640?wx_fmt=png

   第二阶段中端Go 语言关系不大,做的也是跟目标机器无关的事情,最常做的一些简单的优化。上图是中端现在支持的所有 pass。我们拿到一个函数,要一个 pass,一个 pass 去做遍历,把没用的节点和结构删掉,或者把有用的信息进去。其实有一个 pass:opt 现在做得不是很好,不管你编译 Go程序时,优化开关有没有打开,其实都会运行 opt pass现在 Golang 的调试体验不佳,可能跟这个 pass 有点关系,因为有一些编译优化你无法彻底关掉。谈到调试的话我注意到谷歌的工程师其实在不断地改进用户体验,不管是我们这里说的部分还是最终的调试信息,他们一直都在改进

640?wx_fmt=png

   举一个更详细的例子,比如说在 Go 语言写了一个整数除以数,但是最终生成的指令并没有真的做除法,而是把除法变成了加减乘除的简单运算,如上图所示,保证左边和右边的结果一样运行速度来看不管是 X86 平台、Arm 平台或者其他的平台,都是右边更快不管你最终目标机器是什么体系结构中端都会对这个常数除法表达式执行这个优化


2.1.3 Back end

640?wx_fmt=png

   第二阶段结束之后,我们得到语义上完全一致的 SSA 提供给第三阶段:后端对于后端,大家最有感性认识的当属寄存器分配,你的程序具体落地到处理器上执行时,对于不同的处理器,其寄存器和名字完全是不一样

640?wx_fmt=png  还有机器指令的选择。还是以前面的常数除法为例,上图左边是机器无关的表示,右边是 Arm64 目标机器上的最终后端生成的结果,注意红色的那三条指令。虽然中端的输出是一样的,但是不同的目标机器到后端这里得到的结果会完全不一样。


2.2  Generate prog

640?wx_fmt=png

   上面 Prog 这段代码是Golang 工具链代码里面摘出来的,描述了一条具体的机器指令我在这个“机器”上面加了引号,因为它是针对机器的汇编语言,离真正的机器指令还有一定的差距最后总结一下编译器三个阶段,前端处理跟语言相关的信息,中端处理跟机器无关的优化,后端处理跟机器有关的指令选择和寄存器的分配顺便谈一下和 Gcc 编译器一些不同已经达到 2-300 个 pass,而 Golang 编译器只有几十个 pass,所以 Golang 编译器相对于Gcc 编译器还是很,其实这里有很多原因一个原因就是谷歌可能追求编译的速度,你做的事情越复杂,编译的速度越慢。还有二制代码文件的大小,有的时候要用空间换时间,例如我可能为了在最终运行的时候快一点,要进行一些循环展开,就让你的循环几倍的增长。相比较于 Gcc 编译器,Go 编译器到目前为止比较高级的编译优化都没有做。


3.Asm

3.1 Go assembler overview

640?wx_fmt=png

   第二个工具是汇编器,对于 Golang 的汇编语言很多朋友可能用的不是特别多,除非一些性能要求比较高的场合,也许你会写 Golang 的汇编程序。Golang 的汇编语言来源于 plan9 的汇编语言。一条汇编指令对应一个 prog,前面我们讲过编译器可以直接生成 prog,如果手写汇编语言,就会经过比较经典的词法和语法分析最后构成一个 prog 的链。进到这个阶段,汇编器就要干活了首先进行简单的优化,因为 Golang 的汇编语言是个抽象的汇编语言,有一点接近高级语言。除此之外还有很多机器的信息,比如做一些机器相关的优化,还会做预处理,选择最终的指令,还会生成最终的目标文件,就是跟运行相关的语言信息和生成 meta 数据,最终生成我们说的 goobj 的文件


Go arm64 assembly example

640?wx_fmt=png

     这张图描述了一段非常简单的 Arm64 的汇编代码。里面做了一加法有一些 MOVD 指令,为什么会有这条指令呢?我推测当初 plan9 那帮人设计的Golang 汇编语言时,他们想做抽象例如我们做一个 MOVD 指令,真正落地到各个体系结构大家会发现既使是 MOVD 也有很多方式对于 Arm 这种体系结构,MOV 能做两件事,要么进行无符号的扩展,要进行有符号的扩展但是 X86是不,MOVD 就比较麻烦了,要处理三个语义最终由于体系结构的巨大差异,出现很多种类型的 MOVD 汇编语言指令,最后 plan9 不得不承认自己是一个准抽象的汇编语言。对于 Arm,如果你将值MOV回到内存里面去,汇编器会将 MOVD 翻译成 str,反之就是 ldr这些将汇编指令翻译成具体机器指令的事情都是汇编器干的事。

   Golang 对每一个 Goroutine 的栈大小是可变的,一开始栈的大小是 2k随着函数调用越来越,大概消耗到了 1K 多一点的时候,会进行动态的增长怎么做这个动态的增长呢?汇编器会在每个函数开头插入几条指令来检查栈的剩余空间大小也就是查看当前的栈不够用,不够用就会跳到下面去就会做函数调用,调用到指定的地方,那里有 runtime 提供好的函数进行内存管理,管理单纯的堆栈,把里面的指针调好有了足够多的栈空间,的函数就可以任何复杂的操作了这是 Go 汇编器做简单事情的介绍,通过具体的例子让大家看起来比较直观。

   总结上述内容,Golang 的汇编语言翻译成最终的机器码,这些机器码在 Arm的机器上运行就可以按照设定好的规则进行计算或者读写内存。


3.2  Goobj

640?wx_fmt=png





   最后讲关于 Golang Goobj 形式。大家都知道,Gcc 编译 C 文件也会生成 ELF obj 文件,他们的概念是一样的,但是千万不要用分析 ELF obj 文件格式的工具来分析 Goobj 文件。如上图所示,Goobj 和 ELF obj 的文件结构一开始差不多,但在最前面有一些 header,而右边是我们说的程序的表头,开始就不一样了所以特别提醒大家对于 Golang 生成的 obj 文件用自己的工具去分析

   汇编器简单介绍完它会生成一些 goobj 格式的目标文件。


4.Link

4.1  Go link overview

640?wx_fmt=png


   最后一个阶段是链接:link。link 大家平时用得更少并且也是 Golang 工具链里面有很多瑕疵的地方

   首先介绍一下基本概念,我们写了一个 Go 程序,有很多 Go 文件,可能会被编译器编成目标文件,然后打包成 pkg,因为 Golang 是包管理模式,如果 Go程序调用 C 程序,工具链还会调用 cgo,生成额外的 C 文件,并被本地的编译器处理生成的目标文件也会被打包成一个 pkg 文件,最后这些包的文件都会给我们的链接器,生成最终的执行程序

   这个过程当中,我们连接器 link 干了很多事情,你连接的模式是内部连接的时候,也有可能是偷懒什么都不干,就调用了本地的连接器,这个过程和 Gcc的链接过程没有什么太大的区别,都是生成可执行代码文件,link 就是干这个事情的。但是 Golang 的 link 比传统的 link 更加复杂,复杂的原因Go 可以和C 混在一块用,就意味着 Go 的 link 既要处理自己的编译器生成的文件,还要负责你本地安装的 C 语言工具链生成的文件,这种情况下文件格式就有很多了,C 语言工具链生成的 pkg 都要由我们的 Go link 来处理,这个是蛮难的,而且这里面的一些标准一直在变。比如说今天写的 link 明天还是不是符合这个标准都很难说因此 Golang 工具链偷懒了就直接调用了外部的 link 做这个事情。


4.2 Go link workflow

640?wx_fmt=png

  上图是我前段时间总结的关于 Golang link 工作流程一个正常的链接器一般都会做三件事情,首先把各个部分都收集起来,然后是把一些相似的部分挑出来放在一块例如将代码放在 text 段最重要的就是地址重定位例如 A 函数调用 B 函数,但是 A 和 B 是在不同的文件里面,这个时候彼此不知道,A不知道 B 到底在哪里,放到一块以后链接器知道 B 在第几个字节,会把这个B 的地址写到 A 的调用点,这就是地址重定位的过程。

   Go 链接器区别传统链接器在于 Go 语言,里面有很多 Golang 运行的信息例如当我的栈消耗到一定阶段的时候,会被 Go 的运行时走,们先把栈拉大,里面涉及比较复杂的问题,其中一个问题跟垃圾回收有关,我的指针原来指向的位置变了,要先找到指针的位置在哪里,这个过程有跟 Golang 的语言特性息息相关这些东西在传统的编译器里面不需要考虑

   链接器最后生成每天都会用到的可执行文件关于 Golang 链接器就简单介绍到这,如果大家对面的细节有兴趣,欢迎线下讨论。


5.Others

640?wx_fmt=png

   最后一页分享一些我对 Golang 比较热门话题的看法。

1.VGo: Go 开发的程序越来越多,但一些老代码还不能轻易下线,而其基于的第三方库又在不停的升,这个时候版本控制很有必要,所以 VGo += Package Versioning。

2.第二个有意思的事情是去年有人开始开发基于 WebAssembly 后端生成出来的文件不物理的 CPU 上运行而是在浏览器上跑,这意味着 Go 语言可能也可以开发前端应用

3.关于安全点Go 语言是自己做一些圾回收的,函数刚的时候执行流可能会被调度器抢掉。这些都是在函数的入口如果你这个函数一直霸占着不放,调度器是无法抢占你的 CPU。这点 JAVA 做比较好,即使一直在循环操作调度器仍然能抢占你的 CPU,Go 语言也想做这样,这样的话可调度性会更好而且垃圾回收更加密集一点。

最后还有一些跟体系结构相关的优化现在函数调用的时候 Go ABI 不管是什么机器都通过内存传参Arm 的寄存非常多,这样可以考虑优化成寄存器传参,究竟要不要打开这样的优化有许多问题需要考虑,因为一旦打开,首先你调试体验就很差,参数和指针找出来变得麻烦这一问题已经讨论一年多了,但还没有结论。另外就是关于 inline,如果做得比较激进的话,性能会有比较大幅度的提升,但同样也会带来很多寄存器传参类似的问题


2018年的 Gopher Meetup 将在深圳开启巡回第一站,这一次邀请了很多新的讲师给大家一起交流分享Go的使用经验〜

点击阅读原文报名参加

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值