自编教材分享:第五章—编译与运行优化(二)



编译器中端
中间代码
LLVM 中间表示的三种格式:在内存中的编译中间语言;硬盘上存储的二进制中间语言,以.bc结尾;以及可读的中间代码格式,以.ll结尾。
LLVM 中间代码结构包括四个部分:
模块(Module)是LLVM IR的顶层容器,对应于编译前端的每个翻译单元。每个模块由目标机器信息、全局符号(全局变量和函数)及元信息组成。
函数(Function)就是编程语言中的函数,包括函数签名和若干个基本块,函数内的第一个基本块叫做入口基本块。
基本块(BasicBlock)是一组顺序执行的指令集合,只有一个入口和一个出口,非头尾指令执行时不会违背顺序跳转到其他指令上去。每个基本块最后一条指令一般是跳转指令(跳转到其它基本块上去),函数内最后一个基本块的最后一条指令是函数返回指令。
指令(Instruction)是LLVM IR中的最小可执行单位。

生成LLVM中间代码的编译命令为:
file.c代码为:
转换后的file.ll代码为:
LLVM中间代码为采用静态单赋值形式的中间表示,使用的指令集为LLVM虚拟指令集。 LLVM虚拟指令集由操作指令和内建指令两部分组成。内建指令是指以llvm.开头的指令,比如预取内建指令llvm.prefetch,在使用时被转换成一条或多条操作指令。常见的操作指令有四则运算指令、逻辑运算指令等,如LLVM中间代码的加法操作:

中间代码优化
编译优化选项
代码优化阶段的任务是对中间代码进行变换或改造,目的是使生成的目标代码更为高效(省时间和省空间)。
-O优化选项用以控制编译器在对程序编译时的优化级别,常用的有-O0、-O1、-O2、-O3、-Ofast。其中-O0选项表示不开启任何优化,该编译过程用的时间消耗最小,此时生成的汇编最接近程序源代码的语句;-O1选项只开启少量优化,主要对代码中的分支、常量以及表达式等进行优化;-O2选项表示开启适度优化,在-O1选项的基础上,增加自动向量化优化,编译过程会有一定的时间和空间消耗;-O3类似-O2,使用更多的优化手段和更长的优化时间;-Ofast启用所有-O3优化以及可能违反语义的其它优化,以追求最大可能的代码执行效率。

死代码删除
死代码是指在程序操作过程中不可能被执行到的代码,或是代码可以被执行,但是对计算结果不起任何作用,比如代码段。
示例:
C语言代码:
执行命令:
关闭死代码删除优化:
执行命令:
开启死代码删除优化:
对于上述所示代码,S1和S2两个语句在程序执行时会被执行,但是只有S1的结果有意义,语句S2的执行对结果没有任何影响,语句S2就叫死代码,编译器可自动将其识别出来并删除。
过程间优化
过程间优化是涉及程序中多个过程的程序变换与优化,过程间分析阶段为过程间优化提供足够的信息,用于支持过程间优化阶段的各类程序变换。内联优化是过程间优化中最常用的一种方法,它是指如果在循环中调用了另一个过程,则过程间优化会将该过程内联到函数体内,并且会重新对过程排序以获得更好的内存布局。
示例:
执行命令:
内联优化前中间代码为:
执行命令:
内联优化后中间代码为:
通过对比可以看出,过程间优化中的内联操作将add函数内联到了循环体中,减少了函数调用的入栈出栈开销,并且可以执行一些内联优化之前不会进行的优化,比如在本例中内联优化后的循环可以被向量化,LLVM在优化级别-O3选项下即开启过程间优化。
自动向量优化
自动向量化是指编译器自动的将串行代码转化为向量代码的一种优化变换。向量计算是一种特殊的并行计算方式,相比于标量执行时每次仅操作一个数据,它可以在同一时间对多个数据执行相同的操作,从而获得数据级并行。LLVM目前支持两种自动向量化方法,分别是循环级向量化和基本块级向量化。
循环级向量化
通过扩大循环中的指令以获得多个连续迭代中操作的向量执行,在LLVM中通过选项-fvectorize开启循环向量化,并且当打开-O2选项及高于-O2优化级别的选项即自动开启循环向量化优化。
示例:
执行命令:
循环级向量化的中间代码为:
可以看出,经向量化后生成的中间代码中,对于数组a每次取了四个数据进行运算,如果后端支持向量指令,则在一个循环内对四个数据进行同时操作,实现了数据级并行。
基本块级向量化
基本块级向量化算法的思想来源于指令级并行,通过将基本块内可以同时执行的多个标量操作打包成向量操作来实现并行,与循环级向量并行发掘方法不同,基本块级向量化发掘方法主要是在基本块内寻找同构语句,发掘基本块内指令的并行机会。在LLVM中加入选项-fslp-vectorize以开启基本块级向量化。
示例:
执行命令:
基本块级向量化的中间代码为:
优化后的中间代码代码示意如下:
基本块级向量化算法主要用于发掘基本块内的并行性,它要遍历所有的向量方案得到最优解,所以复杂度高于循环级向量化,具有更强的向量挖掘能力,经转化后的向量代码程序性能会有很大的提升。
循环优化
循环优化是编译器中重要的优化手段之一,本节选取LLVM编译器支持的几种典型的循环优化,包括循环展开、循环分布和循环剥离进行介绍。
循环展开
循环展开是指将循环体代码复制多次的实现,通过增大指令调度的空间来减少循环分支指令的开销、增加数据引用的局部性,从而提高循环执行性能的一种循环变换技术。在LLVM中通过选项-funroll-loops打开循环展开优化。
示例:
执行命令:
中间代码部分即为对sum规约加法操作进行的循环展开,即在一个基本块内,对该操作展开了四次操作。
循环分布
循环分布是指将循环内的一条或多条语句移到单独一个循环中,以满足某些特定的需求。例如,当循环中某条语句存在依赖不可消除的情况,会导致整个循环无法向量化,通过循环分布优化将循环中有依赖的语句和无依赖的语句分开,使得分离后的某个循环中不存在依赖在LLVM中通过选项-mllvm -enable-loop-distribute打开循环分布优化。
示例:
执行命令:
循环分布后的中间代码为:
循环剥离
循环剥离常用于将循环中数据首地址不对齐的引用,以及循环末尾不够装载到一个向量寄存器的数据剥离出来,使剩余数据满足向量化对齐性要求。在LLVM编译器优化分析中,循环展开优化通常与循环剥离配合使用,优化人员可通过选项-mllvm unroll-peel-count写定剥离数值。
示例:
执行命令:
循环剥离后的中间代码为:
剥离前,不符合向量对齐要求

剥离后,符合向量对齐要求

浮点优化
若优化人员想提高浮点数据的运算性能,可以利用LLVM编译器的-ffast-math选项开启较为激进的浮点优化,该选项中包含了多种浮点优化方法,比如浮点数据归约向量化、除法运算优化和忽略浮点数0的正负号等。
示例:
进行浮点优化前:
可以从代码中看出,该程序代码中变量均为浮点数类型,第二次循环中的sum变量由循环的连续迭代所使用,LLVM为了保证计算结果的精度,在未加-ffast-math选项之前不会对该浮点类型数据的归约实施向量化优化。
数据预取优化
LLVM编译器支持自动数据预取和手动添加预取内建指令两种预取方式,其中自动数据预取是编译器对程序分析后在中间代码中自动插入预取指令,当前LLVM编译器仅支持AArch64平台和PowerPC平台的自动数据预取优化。手动添加的预取函数为:

示例:
进行数据预取优化后的中间代码为:
如果平台支持预取指令则可以生成,否则该函数会是一个空操作,不做任何处理。当为高局部性时则表示该数据会在首次被访问不久之后极有可能再次被访问。其中内建函数__builtin_prefetch(arr+i,1,3)表示对数组arr实施预取写操作,并且该数组具有很好的数据局部性。该内建函数会在中间代码和汇编代码中生成预取指令以达到预取优化功能。
相关选项
有关中端的常用选项如表所示,每一种优化功能都通过优化选项控制。
这些优化可以通过使用优化信息选项打印编译优化相关的信息,其中包括优化成功的信息、优化失败的信息、优化分析信息等。

