learn-tech/专栏/编译原理实战课/16JavaJIT编译器(四):Graal的后端是如何工作的?.md
2024-10-16 10:05:23 +08:00

273 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
16 Java JIT编译器Graal的后端是如何工作的
你好,我是宫文学。
前面两讲中我介绍了Sea of Nodes类型的HIR以及基于HIR的各种分析处理这可以看做是编译器的中端。
可编译器最终还是要生成机器码的。那么这个过程是怎么实现的呢与硬件架构相关的LIR是什么样子的呢指令选择是怎么做的呢
这一讲我就带你了解Graal编译器的后端功能回答以上这些问题破除你对后端处理过程的神秘感。
首先,我们来直观地了解一下后端处理的流程。
后端的处理流程
在第14讲中我们在运行Java示例程序的时候比如atLeastTen()方法),使用了“-Dgraal.Dump=:5”的选项这个选项会dump出整个编译过程最详细的信息。
对于HIR的处理过程程序会通过网络端口dump到IdealGraphVisualizer里面。而后端的处理过程缺省则会dump到工作目录下的一个“graal_dumps”子目录下。你可以用文本编辑器打开查看里面的信息。
//至少返回10
public int atLeastTen(int a){
if (a < 10)
return 10;
else
return a;
}
不过你还可以再偷懒一下使用一个图形工具c1visualizer来查看
补充c1visualizer原本是用于查看Hopspot的C1编译器也就是客户端编译器的LIR的工具这也就是说Graal的LIR和C1的是一样的另外该工具不能用太高版本的JDK运行我用的是JDK1.8
图1atLeatTen()方法对应的LIR
在窗口的左侧你能看到后端的处理流程
首先是把HIR做最后一次排序HIR Final Schedule这个处理会把HIR节点分配到基本块并且排序
第二是生成LIR在这个过程中要做指令选择
第三寄存器分配工作Graal采用的算法是线性扫描Linear Scan
第四是基于LIR的一些优化工作比如ControlFlowOptimizer等
最后一个步骤是生成目标代码
接下来我们来认识一下这个LIR它是怎样生成的用什么数据结构保存的以及都有什么特点
认识LIR
在对HIR的处理过程中前期High TierMid Tier基本上都是与硬件无关到了后期Low Tier你会看到IR中的一些节点逐步开始带有硬件的特征比如上一讲中计算AMD64地址的节点而LIR就更加反映目标硬件的特征了基本上可以跟机器码一对一地翻译所以从HIR生成LIR的过程就要做指令选择
我把与LIR相关的包和类整理成了类图里面划分成了三个包分别包含了与HIRLIR和CFG有关的类你可以重点看看它们之间的相互关系
图2HIRLIR和CFG的关联关系
在HIR的最后的处理阶段程序会通过一个Schedule过程把HIR节点排序并放到控制流图中为生成LIR和目标代码做准备我之前说过HIR的一大好处就是那些浮动节点可以最大程度地免受控制流的约束但在最后生成的目标代码中我们还是要把每行指令归属到某个具体的基本块的而且基本块中的HIR节点是按照顺序排列的在ScheduleResult中保存着这个顺序blockToNodesMap中顺序保存了每个Block中的节点)。
你要注意这里所说的Schedule跟编译器后端的指令排序不是一回事儿这里是把图变成线性的程序而编译器后端的指令排序也叫做Schedule则是为了实现指令级并行的优化
当然把HIR节点划分到不同的基本块优化程度是不同的比如与循环无关的代码放在循环内部和外部都是可以的但显然放在循环外部更好一些把HIR节点排序的Schedule算法复杂度比较高所以使用了很多启发式的规则刚才提到的把循环无关代码放在循环外面就是一种启发式的规则
图2中的ControlFlowGraph类和Block类构成了控制流图控制流图和最后阶段的HIR是互相引用的这样你就可以知道HIR中的每个节点属于哪个基本块也可以知道每个基本块中包含的HIR节点
做完Schedule以后接着就会生成LIR与声明式的HIR不同LIR是命令式的由一行行指令构成
图1显示的是Foo.atLeatTen方法对应的LIR你会看到一个控制流图CFG里面有三个基本块B0是B1和B2的前序基本块B0中的最后一个语句是分支语句基本块中只有最后一个语句才可以是导致指令跳转的语句)。
LIR中的指令是放到基本块中的LIR对象的LIRInstructions属性中保存了每个基本块中的指令列表
OK那接下来我们来看看LIR的指令都有哪些它们都有什么特点
LIRInstruction的子类主要放在三个包中你可以看看下面的类图
图3LIR中的指令类型
首先在org.graalvm.compiler.lir包中声明了一些与架构无关的指令比如跳转指令标签指令等因为无论什么架构的CPU一定都会有跳转指令也一定有作为跳转目标的标签
然后在org.graalvm.compiler.lir.amd64包中声明了几十个AMD64架构的指令为了降低你的阅读负担这里我只列出了有代表性的几个这些指令是LIR代码中的主体
最后在org.graalvm.compiler.hotspot.amd64包中也声明了几个指令这几个指令是利用HotSpot虚拟机的功能实现的比如要获取某个类的定义的地址只能由虚拟机提供
好了通过这样的一个分析你应该对LIR有更加具体的认识了LIR中的指令大多数是与架构相关的这样才适合运行后端的一些算法比如指令选择寄存器分配等你也可以据此推测其他编译器的LIR差不多也是这个特点
接下来我们就来了解一下Graal编译器是如何生成LIR并且在这个过程中它是如何实现指令选择的
生成LIR及指令选择
我们已经知道了Graal在生成LIR的过程中要进行指令选择
我们先看一下Graal对一个简单的示例程序Foo.add1是如何生成LIR的
public static int add1(int x, int y){
return x + y + 10;
}
这个示例程序在转LIR之前它的HIR是下面这样其中有两个加法节点操作数包括了参数ParameterNode和常数ConstantNode两种类型最后是一个Return节点这个例子足够简单实际上它简单到只是一棵树而不是图
图4add1方法对应的HIR
你可以想一下对于这么简单的一棵树编译器要如何生成指令呢
最简单的方法是做一个语法制导的简单翻译我们可以深度遍历这棵树针对不同的节点分别使用不同的规则来生成指令比如
在遇到参数节点的时候我们要搞清楚它的存放位置因为参数要么是在寄存器中要么是在栈中可以直接用于各种计算
遇到常数节点的时候我们记下这个常数用于在下一条指令中作为立即数使用
在遇到加法节点的时候生成一个add指令左右两棵子树的计算结果分别是其操作数在处理到6号节点的时候可以不用add指令而是生成一个lea指令这样可以直接把结果写入rax寄存器作为返回值这算是一个优化因为可以减少一次从寄存器到寄存器的拷贝工作
遇到Return节点的时候看看其子树的计算结果是否放在rax寄存器中如果不是那么就要生成一个mov指令把返回值放入rax寄存器然后再生成一条返回指令ret)。通常在返回之前编译器还要做一些栈帧的处理工作把栈指针还原
对于这个简单的例子来说按照这个翻译规则来生成代码是完全没有问题的你可以看下Graal生成LIR然后再基于LIR生成的目标代码的示例程序它只有三行足够精简和优化
add esi,edx #将参数1加到参数0上结果保存在esi寄存器
lea eax,[rsi+0xa] #将rsi加10,结果放入eax寄存器
ret #返回
补充-
1.我去掉了一些额外的汇编代码比如用于跟JVM握手让JVM有机会做垃圾收集的代码-
2. lea指令原本是用于计算地址的上面的指令的意思是把rsi寄存器的值作为地址然后再偏移10个字节把新的地址放到eax寄存器-
x86计算机支持间接寻址方式偏移量基址索引值字节数-
其地址是基址 + 索引值*字节数 + 偏移量-
所以你可以利用这个特点计算出a+b*c+d的值但c也就是字节数只能取1248就算让c取1那也能完成a+b+c的计算并且它还可以在另一个操作数里指定把结果写到哪个寄存器而不像add指令只能从一个操作数加到另一个操作数上这些优点使得x86汇编中经常使用lea指令做加法计算
Graal编译器实际上大致也是这么做的
首先它通过Schedule的步骤把HIR的节点排序并放入基本块对于这个简单的程序只有一个基本块
接着编译器会对基本块中的节点做遍历参考NodeLIRBuilder.java中的代码)。针对每个节点转换Lower成LIR
把参数节点转换成了MoveFromRegOp指令在示例程序里其实这是冗余的因为可以直接把存放参数的两个寄存器用于加法计算
把第一个加法节点转换成了CommutativeTwoOp指令
把第二个加法节点转换成了LeaOp指令并且把常数节点变成了LeaOp指令的操作数
Return节点生成了两条指令一条是把加法运算的值放到rax寄存器作为返回值这条我们知道是冗余的所以就要看看后面的优化算法如何消除这个冗余第二条是返回指令
一开始生成的LIR使用的寄存器都是虚拟的寄存器名称用v1v2v3这些来表示等把这些虚拟的寄存器对应到具体的物理寄存器以后就可以消除掉刚才提到的冗余代码了
我们在c1visualizer中检查一下优化过程可以发现这是在LinearScanAssignLocationsPhase做的优化加法指令中对参数1和参数2的引用变成了对物理寄存器的引用从而优化掉了两条指令lea指令中的返回值也直接赋给了rax寄存器这样呢也就省掉了把计算结果mov到rax的指令这样优化后的LIR基本上已经等价于目标代码了
好了通过这样一个分析你应该理解了从HIR生成LIR的过程但是还有个问题这中间似乎也没有做什么指令选择呀唯一的一处就是把加法操作优化成了lea指令而这个也比较简单基于单独的Add节点就能做出这个优化选择那么更复杂的模式匹配是怎么做的呢
不要着急我们接下来就看看Graal是如何实现复杂一点的指令选择的这一次我们用了另一个示例程序Foo.addMemory方法它把一个类成员变量m和参数a相加
public class Foo{
static int m = 3;
public static int addMemory(int a){
return m + a;
}
...
}
这跟add1方法有所不同因为它要使用一个成员变量所以一定要访问内存而add1方法的所有操作都是在寄存器里完成的空中作业”,根本不在内存里落地
我们来看一下这个示例程序对应的HIR其中一个黄色节点Read#Foo.m”,是读取内存的节点也就是读取成员变量m的值而这又需要通过AMD64Address节点来计算m的地址由于m是个静态成员所以它的地址要通过类的地址加上一定的偏移量来计算
图5addMemory()方法对应的HIR
这里有一个小的知识点我在第14讲中也提过对内存操作的节点如图中的ReadNode是要加入控制流中的因为内存里的值会由于别的操作而改变如果你把它变成浮动节点就有可能破坏对内存读写的顺序从而出现错误
回到主题我们来看看怎么为addMemory生成LIR
如果还是像处理add1方法一样那么你就会这么做
计算m变量的地址并放入一个寄存器
基于这个地址取出m的值放入另一个寄存器
把m的值和参数a做加法
不过这样做至少要生成3条指令
在第8讲中我曾经讲过像AMD64这样使用复杂指令集CICS的架构具有强大的地址表达能力并且可以在做算术运算的时候直接使用内存所以上面的三条指令其实能够缩减成一条指令
这就需要编译器把刚才这种基于内存访问做加法的模式识别出来以便生成优化的LIR进而生成优化的目标代码这也是指令选择算法要完成的任务可是如何识别这种模式呢
跟踪Graal的执行你会发现HIR在生成LIR之前有一个对基本块中的节点做模式匹配的操作进而又调用匹配复杂表达式matchComplexExpressions)。在这里编译器会把节点跟一个个匹配规则MatchStatement做匹配注意匹配的时候是逆序做的相当于从树根开始遍历
在匹配加法节点的时候Graal匹配上了一个MatchStatement这个规则的名字叫addMemory”,是专门针对涉及内存操作的加法运算提供的一个匹配规则这个MatchStatement包含了一个匹配模式MatchPattern该模式的要求是
节点类型是AddNode
第一个输入也就是子节点是一个值节点value
第二个输入是一个ReadNode而且必须只有一个使用者singleUser=true
图6匹配规则和匹配模式
这个MatchStatement是在AMD64NodeMatchRules中用注解生成的利用这样的一个匹配规则就能够匹配示例程序中的Add节点
匹配上以后Graal会把AddNode和ReadNode做上特殊标记这样在生成LIR的时候就会按照新的生成规则生成的LIR如下
你可以发现优化后编译器把取参数a的指令省略掉了直接使用了传入参数a的寄存器rsi
最后生成的目标代码如下
movabs rax,0x797b00690 #把Foo类的地址放入rax寄存器
add esi,DWORD PTR [rax+0x68] #偏移0x68后是m的地址做加法
mov eax,esi #设置返回值
ret #返回
到目前为止你已经了解了Graal是如何匹配一个模式并选择优化的指令的了
你可以看看AMD64NodeMatchRules类它的里面定义了不少这种匹配规则通过阅读和理解这些规则你就会对为什么要做指令选择有更加具体的理解了
Graal的指令选择算法算是比较简单的在HotSpot的C2编译器中指令选择采用的是BURSBottom-Up Rewrite System自底向上的重写系统)。这个算法会更加复杂一点消耗的时间更长但优化效果更好一些
这里我补充一个分享我曾经请教过ARM公司的研发人员他们目前认为Graal对针对AArch64的指令选择是比较初级的你可以参考这个幻灯片所以他们也正在帮助Graal做改进
后端的其他功能
出于突出特色功能的目的这一讲我着重讲了LIR的特点和指令选择算法不过在考察编译器的后端的时候我们通常还要注意一些其他功能比如寄存器分配算法指令排序等等我这里就把Graal在这些功能上的实现特点给你简单地介绍一下你如果有兴趣的话可以根据我的提示去做深入了解
寄存器分配Graal采用了线性扫描Linear Scan算法这个算法的特点是速度比较快但优化效果不如图染色算法在HotSpot的C2中采用的是后者
指令排序Graal没有为了实现指令级并行而去做指令排序这里一个主要原因是现在的很多CPU都已经支持乱序out-of-order执行再做重排序的收益不大
窥孔优化Graal在生成LIR的时候会做一点窥孔优化AMD64NodeLIRBuilder类的peephole方法)。不过它的优化功能有限只实现了针对除法和求余数计算的一点优化
从LIR生成目标代码由于LIR已经跟目标代码很接近了所以这个翻译过程已经比较简单没有太难的算法了需要的只是了解和熟悉汇编代码和调用约定
课程小结
这一讲我带你对Graal的后端做了一个直观的认识让你的后端知识有了第一个真实世界中编译器的参考系
第一把LIR从比较抽象的概念中落地你现在可以确切地知道哪些指令是必须跟架构相关的而哪些指令可以跟架构无关
第二把指令选择算法落地虽然Graal的指令选择算法并不复杂但这毕竟提供了一个可以借鉴的思路是你认知的一个阶梯如果你仔细阅读代码你还可以具象地了解到符合哪些模式的表达式是可以从指令选择中受益的这又是一个理论印证实践的点
我把这讲的思维导图也放在了下面供你参考
同时这一讲之后我们对Java编译器的探讨也就告一段落了但是我希望你对它的研究不要停止
我们讨论的两个编译器javac和Graal中的很多知识点你只要稍微深入挖掘一下就可以得出不错的成果了比如我看到有国外的硕士学生研究了一下HotSpot就可以发表不错的论文如果你是在校大学生我相信你也可以通过顺着这门课程提供的信息做一些研究从而得到不错的成果如果是已经工作的同学我们可以在极客时间的社群比如留言区和部落里保持对Java编译技术的讨论也一定会对于你的工作有所助益
一课一思
请你阅读AMD64NodeMatchRules中的匹配规则自己设计另一个例子能够测试出指令选择的效果如果降低一下工作量的话你可以把它里面的某些规则解读一下在留言区发表你的见解
就到这里感谢你的阅读欢迎你把今天的内容分享给更多的朋友我们下一讲再见