You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

286 lines
25 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 40 | 成果检验:方舟编译器的优势在哪里?
你好,我是宫文学。到这里,咱们的课程就已经进入尾声了。在这门课程里,通过查看真实的编译器,你应该已经积累了不少对编译器的直观认识。前面我们研究的各种编译器,都是国外的产品或项目。而这一讲呢,我们则要看看一个有中国血统的编译器:**方舟编译器**。
通过阅读方舟编译器已经公开的代码和文档,在解析它的过程中,你可以检验一下自己的所学,谈谈你对它的认识。比如,跟你了解的其他编译器相比,它有什么特点?先进性如何?你是否有兴趣利用方舟编译器做点实际项目?等等。
不过到目前为止由于方舟编译器开源的部分仍然比较有限所以这一讲我们只根据已经掌握的信息做一些分析。其中涉及两个大的话题一是对方舟编译器的定位和设计思路的分析二是对方舟编译器所使用的Maple IR的介绍。
首先我借助Android对应用开发支持的缺陷来谈一下为什么方舟编译器是必要的。
## Android的不足
为什么要研发一款自己的编译器?**对于一个大的技术生态而言,语言的编译和运行体系非常重要。它处在上层应用和下层硬件之间,直接决定了应用软件能否充分地发挥出硬件的性能。**对于移动应用生态而言,我国拥有体量最大的移动用户和领先的移动应用,也有着最大的手机制造量。可是,对于让上层应用和底层硬件得以发挥最大能力的编译器和运行时,我们却缺少话语权。
实际上我认为Android对应用开发的支持并不够好。我猜测掌控Android生态的谷歌公司对于移动应用开发和手机制造都没有关系到切身利益因此创新的动力不足。
我之所以说Android对应用开发的支持不够好这其实跟苹果的系统稍加对比就很清楚了。同样的应用在苹果手机上会运行得更流畅且消耗的内存也更低。所以Android手机只好增加更多的CPU内核和更多的内存。
你可能会问,谷歌不是也有自己的应用吗?对应用的支持也关系到谷歌自己的利益呀。那我这里其实要补充一下,我说的应用开发,指的是用**Java和Kotlin开发的应用**这也是大部分Android平台上的应用开发者所采用的语言。而像谷歌这样拥有强大技术力量的互联网巨头们通常对于性能要求比较高的代码是用C开发的。比如微信的关键逻辑就是用C编写的像手机游戏这种对性能要求比较高的应用底层的游戏引擎也是基于C/C++实现的。
这些开发者们不采用Java的原因是因为Java在Android平台上的编译和运行方式有待提高。Android为了提升应用的运行速度一直在尝试升级其应用运行机制。从最早的仅仅解释执行字节码到引入JIT编译机制到当前版本的ARTAndroid Runtime支持AOT、JIT和基于画像的编译机制。尽管如此Android对应用的支持仍然存在明显的短板。
**第一个短板,是垃圾收集机制。**我们知道Java基于标记-拷贝算法的垃圾收集机制有两个缺陷。一是要占据更多的内存,二是在垃圾收集的时候会有停顿,导致应用不流畅。在系统资源紧张的时候,更是会强制做内存收集,引起整个系统的卡顿。
实际上Java的内存管理机制使得它一直不太适合编写客户端应用。就算在台式机上用Java编写的客户端应用同样会占用很大的内存并且时不时会有卡顿。你如果使用过Eclipse和IDEA应该就会有这样的体会。
**第二个短板,是不同语言的融合问题。**Android系统中大量的底层功能都是C/C++实现而Java应用只是去调用它们。比如图形界面的绘制和刷新是由一个叫做Skia的库来实现的这个库是用C/C++编写的各种窗口控件都是在Skia的基础上封装出来的。所以用户在界面上的操作背后就有大量的JNI调用。
问题是Java通过JNI调用C语言的库的时候实现成本是很高的因为两种不同语言的数据类型、调用约定完全不同又牵涉到跨语言的异常传播和内存管理所以Java不得不通过虚拟机进行昂贵的处理效率十分低下。
据调查95%的顶级移动应用都是用Java和C、C++等混合开发的。所以,让不同语言开发的功能能够更好地互相调用,是一个具有普遍意义的问题。
**第三个短板就是Android的运行时一直还是受Java虚拟机思路的影响一直摆脱不了虚拟机。**虚拟机本身要占据内存资源和CPU资源。在做即时编译的时候也要消耗额外的资源。
那么如何解决这些问题呢?我们来看看方舟编译器的解决方案。
## 方舟编译器的解决方案
方舟编译器的目标并不仅仅是为了替代Android上的应用开发和运行环境。但我们可以通过方舟是如何解决Android应用开发的问题来深入了解一下方舟编译器。
我们先来看看,方舟编译器是怎么解决**垃圾收集的问题**的。
不过在讨论方舟的方案之前我们不妨先参考一下苹果的方案做个对照。苹果采用的开发语言无论是Objective-C还是后来的Swift都是采用引用计数技术。引用计数可以实时回收内存垃圾所以没有卡顿。并且它也不用像标记-拷贝算法那样,需要保留额外的内存。而方舟编译器,采用的是跟苹果一样的思路,同样采用了引用计数技术。
当然,这里肯定会有孰优孰劣的争论。我们之前也讲过,采用[引用计数法](https://time.geekbang.org/column/article/277707),每次在变量引用对象的时候都要增加引用计数,而在退出变量的作用域或者变量不再指向该对象时,又要减少引用计数,这会导致一些额外的性能开销。当对象在多个线程之间共享的时候,增减引用计数的操作还要加锁,从而进一步导致了性能的降低。
不过,针对引用计数对性能的损耗,我们可以在编译器中通过多种优化算法得到改善,尽量减少不必要的增减计数的操作,也减少不必要的锁操作。另外,有些语言在设计上也会做一些限制,比如引入弱引用机制,从而降低垃圾收集的负担。
无论如何,在全面考察了引用计数方法的优缺点以后,你仍然会发现它其实更适合开发客户端应用。
关于第二个问题,也就是**不同语言的融合问题**。华为采取的方法是让Java语言的程序和基于C、C++等语言的程序按照同一套框架做编译。无论前端是什么语言,都统一编译成机器码,同时不同语言的程序互相调用的时候,也没有额外的开销。
下图是方舟编译器的文档中所使用的架构图。你能看到它的设计目标是支持多种语言都统一转换成方舟IR然后进行统一的优化处理再生成机器码的可执行文件。
![](https://static001.geekbang.org/resource/image/3a/f4/3a33ec779e2e8b86ee375eaa197d56f4.jpg)
[方舟编译器架构示意图](https://www.openarkcompiler.cn/document/frameworkDesgin)
这个技术方案其实非常大胆。**它不仅解决了不同语言之间的互相调用问题也彻底抛弃了植根于JVM的虚拟机思路。**方舟编译器新的思路是不要虚拟机,最大程度地以机器码的方式运行,再加上一个非常小的运行时。
我说这个技术方案大胆是因为方舟编译器彻底抛弃了Java原有的运行方案包括内存布局、调用约定、对象结构、分层编译机制等。我们在第二个模块讲过[Graal](https://time.geekbang.org/column/article/255730)在仍然基于JVM运行的情况下JIT只是尽力做改良它随时都有一个退路就是退到用字节码解释器去执行。就算采用AOT以后运行时可以变得小一些但Java运行机制的大框架仍然是不变的。
我也介绍过GraalVM支持对多种语言做统一编译其中也包含了对C语言的支持并且也支持语言之间的互相调用。但即便如此它仍是改良主义它不会抛弃Java原来的技术积累。
**而方舟编译器不是在做改良,而是在做革命。**它对Java的编译更像是对C/C++等语言的编译抛弃了JVM的那一套思路。
这个方案不仅大胆而且难度更高。因为这样就不再像分层编译那样有退路方舟编译器需要把所有的Java语义都静态编译成机器码。而对于那些比较动态的语义比如运行时的动态绑定、Reflection机制等是挑战比较大的。
**那方舟编译器目前的成果如何呢?**根据华为官方的介绍方舟编译器可以使安卓系统的操作流畅度提升24%响应速度提升44%第三方应用操作流畅度提升高达60%。这就是方舟编译器的厉害之处,这也证明方舟编译器的大胆革新之路是走对了的。
我们目前只讨论了方舟编译器对Android平台的改进。其实方舟编译器的目标操作系统不仅仅是Android平台它本质上可移植所有的操作系统也包括华为自己的鸿蒙操作系统。对于硬件平台也一样它可以支持从手机到物联网设备的各种硬件架构。
所以,你能看出,方舟编译器真的是志存高远。**它不是为了解决某一个小问题,而是致力于打造一套新的应用开发生态。**
好了,通过上面的介绍,你应该对方舟编译器的定位有了一个了解。接下来的问题是,方舟编译器的内部到底是怎样的呢?
## 方舟编译器的开源项目
要深入了解方舟编译器还是必须要从它的源代码入手。从去年9月份开源以来方舟编译器吸引了很多人的目光。不过方舟编译器是逐步开源的由于开放出来的源代码必须在知识产权等方面能够经得起严格的审查因此到现在为止我们能看到的开源版本号还只是0.2版,开放出来的功能并不多。
我参照方舟的环境配置文档在Ubuntu 16.04上做好了环境配置。
注意:请尽量完全按照文档的要求来配置环境,避免出现不必要的错误。不要嫌某些软件的版本不够新。
接着,你可以继续根据[开发者指南](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/files?ref=master&tab=content&filePath=doc%2FDeveloper_Guide.md&isFile=true)来编译方舟编译器本身。方舟编译器本身的代码是用C++写的需要用LLVM加Clang编译这说明它到目前还没有实现自举。然后你可以编译一下示例程序。比如用下面的四个命令可以编译出HelloWorld样例。
```
source build/envsetup.sh; make; cd samples/helloworld/; make
```
这个“hellowold”目录原来只有一个HelloWorld.java源代码经过编译后形成了下面的文件
![](https://static001.geekbang.org/resource/image/8a/8b/8aa0b196e426663d88b6672db7025d8b.jpg)
如果你跟踪查看编译过程,你会发现中间有几步的操作:
第一步执行java2jar这一步是调用Java的编译器把Java文件先编译成class文件然后打包成jar文件。
补充java2jar实际上是一个简单的脚本文件你可以查看里面的内容。
第二步执行jbc2mpl也就是把Java字节码转换成Maple IR。Maple IR是方舟编译器的IR我下面会展开介绍。编译后生成的Maple IR保存到了HelloWorld.mpl中。
第三步通过maple命令执行mpl2mpl和mplme这两项对Maple IR做分析和优化的工作。这其中很重要的一个步骤就是把Java方法的动态绑定用vtable做了实现并生成了一个新的Maple IR文件HelloWorld.VtableImpl.mpl。
最后一步调用mplcg命令将Maple IR转换成汇编代码保存到一个以.s结尾的文件里面。
注意我们目前还没有办法编译成直接可以执行的文件。当前开源的版本既没有编译器前端部分的代码也没有后端部分的代码甚至基于Maple IR的一些常见的优化比如内联、公共子表达式消除、常量传播等等都是没有的。目前开源的版本主要展现了Maple IR以及对Maple IR做的一些变换比如转换成SSA格式以便进行后续的分析处理。
到这里你可能会有一点失望因为当前开放出来的东西确实有点少。但是不要紧方舟编译器既然选择首先开放Maple IR的设计这说明Maple IR在整个方舟编译器的体系中是很重要的。
事实也确实如此。方舟编译器的首席科学家Fred Chow周志德先生曾发表过一篇论文[The increasing significance of intermediate representations in compilers](https://link.zhihu.com/?target=https%3A//queue.acm.org/detail.cfm%3Fid%3D2544374)。他指出IR的设计会影响优化的效果IR的调整会导致编译器实现的重大调整。他还提出如果不同体系的IR可以实现转换的话就可以加深编译器之间的合作。
基于这些思想方舟编译器特别重视IR的设计因为**方舟编译器的设计目标是将多种语言翻译成统一的IR然后共享优化算法和后端**。这就要求Maple IR必须要能够兼容各种不同语言的差异性才行。
那接下来我们就具体看看Maple IR的特点。
## 志存高远的Maple IR
方舟开放的资料中有一个doc目录[Maple IR的设计文档](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/file?ref=master&path=doc%2FMapleIRDesign.md)就在其中。这篇文档写得很细致从中你能够学习到IR设计的很多思想值得仔细阅读。
文档的开头一段指出由于源代码中的任何信息在后续的分析和优化过程中都可能有用所以Maple IR的目标是尽可能完整地呈现源代码中的信息。
这里我想提醒你注意不要忽略这一句话。它作为文档的第一段可不是随意而为。实际上像LLVM的作者Chris Lattner就认为LLVM的IR损失了一些源代码的信息而很多语言的编译器都会在转换到LLVM IR之前先做一个自己的IR做一些体现自己语言特色的分析工作。为了方便满足这些需求他后来又启动了一个新项目MLIR。你可以通过[这篇论文](https://arxiv.org/abs/2002.11054)了解Lattner的观点。
Maple IR则在一开头就注意到了这种需求它提供了对高、中、低不同层次的IR的表达能力。我们在分析别的编译器的时候比如Graal的编译器也曾经讲过它的IR也是分层次的但其实Graal对特定语言的高层次IRHIR的表达能力是不够强的。
HIR特别像高级语言特定于具体语言的分析和优化都可以在HIR上进行。它的特点是提供了很多语言结构比如if结构、循环结构等因为抽象层次高所以IR比较简洁。
与Graal和V8一样Maple IR也用了一种数据结构来表达从高到低不同抽象层次的操作。不过不同于Graal和V8采用了图的结构Maple IR采用的是树结构。在HIR这个层次这个树结构跟原始语言的结构很相似。这听上去跟AST差不多。
随着编译过程的深化抽象的操作被Lower成更低级的操作代码也就变得更多同时树结构也变得越来越扁平最后变成了指令的列表。
那么既然Maple IR是用同一个数据结构来表达不同抽象层次的语义它是以什么来划分不同的抽象层次呢答案是通过下面两个要素
* **被允许使用的操作码**:抽象层次越高,操作码的种类就越多,有一些是某些语言特有的操作。而在最低的层次,只允许那些与机器码几乎一一对应的操作码。
* **代码结构**:在较高的抽象层次上,树的层级也比较多;在最低的抽象层次上,会变成扁平的指令列表。
再进一步Maple IR把信息划分成了两类。一类是声明性的信息用于定义程序的结构比如函数、变量、类型等这些信息其实也就是符号表。另一类是用于执行的代码它们表现为三类节点叶子节点常量或存储单元、表达式节点、语句节点。
我用一个简单的示例程序Foo.java带你看看它所生成的Maple IR是什么样子的。
```
public class Foo{
public int atLeastTen(int x){
if (x < 10)
return 10;
else
return x;
}
public int exp(int x, int y){
return x*3+y+1;
}
}
```
示例程序编译后,会生成.mpl文件。这个文件是用文本格式来表示Maple IR你甚至可以用这种格式来写程序然后编译成可执行文件。当这个格式被读入方舟编译器后就会变成内存格式。另外Maple IR也可以表示成二进制格式。到这里你是不是会有似曾相识的感觉会联想到LLVM的IR对了LLVM也可以[用三种方式来表示IR](https://time.geekbang.org/column/article/264643)(文本格式、内存格式、二进制格式)。
打开.mpl文件你首先会在文件顶部看到一些符号表信息包括类、方法等符号。
```
javaclass $LFoo_3B <$LFoo_3B> public
func &LFoo_3B_7C_3Cinit_3E_7C_28_29V public constructor (var %_this <* <$LFoo_3B>>) void
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32
var $__cinf_Ljava_2Flang_2FString_3B extern <$__class_meta__>
func &MCC_GetOrInsertLiteral () <* <$Ljava_2Flang_2FString_3B>>
```
接下来就是每个方法具体的定义了。比如exp方法对应的IR如下
```
func &LFoo_3B_7Cexp_7C_28II_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32, var %Reg4_I i32) i32 {
funcid 48155 #函数id
var %Reg2_R43694 <* <$LFoo_3B>>
var %Reg0_I i32 #伪寄存器
var %Reg1_I i32
dassign %Reg2_R43694 0 (dread ref %_this)
#INSTIDX : 0||0000: iload_1
#INSTIDX : 1||0001: iconst_3
dassign %Reg0_I 0 (constval i32 3)
#INSTIDX : 2||0002: imul
dassign %Reg0_I 0 (mul i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
#INSTIDX : 3||0003: iload_2
#INSTIDX : 4||0004: iadd
dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg4_I))
#INSTIDX : 5||0005: iconst_1
dassign %Reg1_I 0 (constval i32 1)
#INSTIDX : 6||0006: iadd
dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg1_I))
#INSTIDX : 7||0007: ireturn
return (dread i32 %Reg0_I)
}
```
这里我给你稍加解释一下示例代码中的IR。
**以func关键字开头定义一个函数。**函数名称里体现了原来Java类的名称和方法名称。public和virtual关键字也是继承自原来的Java方法在Java中这类public的方法都是virtual的需要动态绑定。
接下来要注意的是**用var声明以%开头的三个伪寄存器**。伪寄存器相当于本地变量,它的数量是无限的,在后端做寄存器分配的时候才对应成物理寄存器。
在这后面的是**6个dassign语句**。这是6个赋值语句其中的d是直接寻址的意思。有的dassgin操作符后面跟着的是常数constval有的跟着的是加法add或乘法mul表达式。而加法和乘法表达式里面又可能进一步用到其他的表达式。这里就体现出了Maple IR的特点即它是树状结构的。
那么,总结起来,示例函数体现了**Maple IR最基本的结构特点**:程序被分成一个个的函数。函数里呢,是顺序的一条条语句,而每条语句都是一个树状结构,树的节点可以是叶子节点、表达式,或者其他的语句。如果把函数内的每条语句作为函数的子节点,那么整个函数就是一个树状的数据结构。
另外,在示例程序中,还有一些**以#开头的注释**。这些注释代表了原来class文件中的字节码。目前方舟编译器里有一个字节码的前端能够把字节码翻译成Maple IR。这个注释就体现了字节码和Maple IR的对应关系。
不过上面的示例函数并没有体现出流程控制类的语句。我们再来看一下atLeastTen()方法对应的IR。atLeastTen()方法中有一个if语句它能否被翻译成Maple IR的if语句呢
```
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32 {
funcid 48154
var %Reg2_R43694 <* <$LFoo_3B>>
var %Reg0_I i32
dassign %Reg2_R43694 0 (dread ref %_this)
#INSTIDX : 0||0000: iload_1
#INSTIDX : 1||0001: bipush
dassign %Reg0_I 0 (constval i32 10)
#INSTIDX : 3||0003: if_icmpge
brtrue @label0 (ge i32 i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
#INSTIDX : 6||0006: bipush
dassign %Reg0_I 0 (constval i32 10)
#INSTIDX : 8||0008: ireturn
return (dread i32 %Reg0_I)
@label0 #INSTIDX : 9||0009: iload_1
#INSTIDX : 10||000a: ireturn
return (dread i32 %Reg3_I)
}
```
在Maple IR中提供了if语句其语法跟C语言或Java语言的语法差不多
```
if (<cond-expr>) {
<then-part> }
else {
<else-part>}
```
像if这样的控制流语句还有doloop、dowhile和while它们都被叫做**层次化的控制流语句**。
不过在阅读了atLeastTen()对应的IR以后你可能要失望了。因为这里面并没有提供if语句而是通过一个brture语句做了跳转。**brtrue被叫做平面化的控制流语句**它在满足某个条件的时候会跳转到另一个语句去执行。类似的控制流语句还有brfalse、goto、return、switch等。
补充:由于这个.mpl文件是从字节码直接翻译过来的但字节码里已经没有HIR级别的if结构了而是使用了比较低级的if\_icmpge指令所以方舟编译器也就把它翻译成了同样等级的brtrue指令。
好了通过这样的示例你就直观地了解了Maple IR的特点。那么问题来了当前的开源项目都基于Maple IR做了哪些处理呀
你可以打开源代码中的[src/maple\_driver/defs/phases.def](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/file?ref=master&path=src%252Fmaple_driver%252Fdefs%252Fphases.def)文件。这里面定义了一些对Maple IR的处理过程。比如
* **classhierarchy**:对类的层次结构进行分析;
* **vtableanalysis**:为实现动态绑定而做的分析;
* **reflectionanalysis**对使用Reflection的代码做分析以便把它们静态化。
* **ssa**把IR变成SSA格式
* ……
总的来说当前对Maple IR的这些处理有相当一部分是针对Java语言的特点来做一些分析和处理以便把Java完全编译成机器码。更多的分析和优化算法还没有开源我们继续期待吧。
## 课程小结
这一讲我主要跟你探讨了方舟编译器的定位、设计思路以及方舟编译器中最重要的数据结构Maple IR。
对于方舟编译器的定位和设计思路我认为它体现了一种大无畏的创新精神。与之相比脱离不了JVM模子的Android运行时倒有点裹足不前使得Android在使用体验上多年来一直有点落后。
但在大胆创新的背后也必须要有相应的实力支撑才行。据我得到的资料华为的方舟编译器依托的是早年在美国设立的实验室所积累下来的团队这个团队从2009年开始就依托编译技术做了很多研发为内部的芯片设计也提供了一种语言。最重要的是在这个过程中华为积累了几百人来做编译和虚拟机的团队。前面提到的首席科学家周志德先生就是全球著名的编译技术专家曾参与了Open64项目的研发。这些优秀的专家和人才是华为和国内其他团队未来可以在编译技术上有所作为的基础。那么我也非常希望学习本课程的部分同学以后也能参与其中呢。
对于Maple IR中分层设计的思想我们在Graal、V8等编译器中都见到过。Maple IR的一个很大的优点就是对HIR有更好地支持从而尽量不丢失源代码中的信息更好地用于分析和优化。
对于方舟编译器根据已开源的资料和代码我们目前只做了一些初步的了解。不过只分享这么多的话我觉得还不够满意你也会觉得很不过瘾。并且你可能还心存了很多疑问。比如说Graal和V8都选择了图的数据结构而Mapple IR选择了树。那么在运行分析和优化算法上会有什么不同呢我希望后续随着方舟编译器有更多部分的开源我会继续跟你分享
这节课的思维导图我也放在了这里,供你参考:
![](https://static001.geekbang.org/resource/image/0c/1c/0cf1bbc49f148e767f89c5e66d52e11c.jpg)
## 一课一思
你认为用一种IR来表示所有类型的语言的话都会有哪些挑战你能否通过对Maple IR的阅读找到Maple IR是如何应对这种挑战的欢迎在留言区分享你的观点。
如果你身边也有对华为的方舟编译器十分感兴趣的朋友,非常欢迎把这节课的内容分享给他,我们一起交流探讨。感谢你的阅读,我们期末答疑再见!