5 后端优化
GCC后端优化是编译器性能提升的关键环节,它涉及到代码生成、指令调度、寄存器分配等多个方面。以下是GCC后端优化的一些研究现状和最新进展:
编译优化序列选择:研究者使用遗传算法、多目标优化算法等方法为待编译程序选择最优的编译优化序列。例如,Jantz等使用遗传算法为JIT编译器选择最优的编译优化序列,并取得了良好的结果。Ansel等提出了一个开源框架“OpenTuner”,使用多种进化算法为程序选择编译优化序列,实验表明该框架效果良好,能够获得最高2.8倍的加速比。
机器学习在编译优化中的应用:Cavazos等使用代码运行时特征来训练逻辑回归模型,以预测不同编译优化序列对程序的编译效果。在SPEC 2000和miBench基准测试集上,相对于预定义的标准编译优化序列-Ofast,取得了17%的性能提升。
ThinLTO(Thin Link-Time Optimization):ThinLTO是GCC引入的一种链接时优化方案,它结合了整个程序模式和LTRANS模式的优点。在链接阶段,链接器可以使用索引文件来进行全局的优化和代码生成,从而减少编译时间和资源消耗,同时实现一定程度的全局优化。
GCC后端的高级技术:GCC后端的高级技术包括高级寄存器分配技术、高级指令选择策略以及静态程序分析与优化。例如,图着色寄存器分配是一种常见的高级寄存器分配策略,它将寄存器分配问题映射为图着色问题,以减少寄存器的使用。
GCC后端的现代化改进:GCC后端正在逐步引入模块化优化Pass、加强向量化支持、优化编译时性能等现代化改进。例如,GCC for openEuler在通用编译优化能力的基础上,对中后端性能优化技术进行了增强,包括指令优化、向量化增强、预取增强、数据流分析增强等优化。
特定体系结构的优化:GCC后端优化也涉及到对特定体系结构的优化。例如,针对Arm架构的优化,包括乘法计算优化、cmlt指令生成优化等,以实现更高效的代码生成。
编译器后端技术的未来趋势:随着多核处理器和并行计算的需求增长,向量化技术(SIMD)变得越来越重要。这要求编译器后端能够识别可以并行处理的数据操作,并且生成对应的单指令多数据(SIMD)指令。机器学习技术的进步也为编译器优化带来了新的可能性,通过训练机器学习模型,可以实现对程序行为的预测,为编译器提供更智能的决策支持。这些研究和进展表明,GCC后端优化正在不断地发展和改进,以适应新的硬件架构和编程需求。
要为IR形式下的程序生成可执行代码,编译器后端必须解决3个问题。指令选择,指令调度和寄存器分配。后端必须将取操作转换为目标处理器ISA中的操作,这个处理过程称为指令选择(instruction selection)。它必须为这些操作选择一种执行顺序,这一处理过程称为指令调度(instruction scheduling)。它还必须决定,在最终代码中的每个位置上,哪些值应该位于寄存器中,哪些值应该位于内存中,这一处理过程称为寄存器分配(register allocation)。后端的3种主要过程分别是指令选择、指令调度和寄存器分配。所有这3种过程都对所生成代码的质量有直接影响,三者彼此会发生相互作用。
指令选择直接改变了指令调度的处理过程。指令选择既规定了一个操作需要的时间,也规定了它可以在何种处理器功能单元上执行。调度也可能影响指令选择。如果代码生成器可以在两个汇编操作中任选一个来实现某个IR操作,而两个汇编操作使用了不同的资源,那么代码生成器可能需要理解最终的指令调度才能确保作出最好的选择。
指令选择在几个方面与寄存器分配发生交互。如果目标处理器有一个均一的寄存器集合,那么指令选择器可以假定寄存器供应是无限的,并依赖寄存器分配器来插入load/store指令(从而将无限的虚拟寄存器集合匹配到有限的物理寄存器集合)。另一方面,如果目标机的规则限制了寄存器的使用,那么指令选择器必须密切注意特定的物理寄存器。这可能使指令选择复杂化,并预先确定了部分或全部分配决策。在这种情况下,代码生成器可能在指令选择期间利用一个协程来执行局部寄存器分配。
但是流程中还有尽可能使选择、调度与分配保持分离状态,这样可以简化其中每个处理过程的实现和调试。但因为每个处理过程都可能限制其他过程,编译器编写者必须注意避免添加不必要的约束。所以这也是工作的难点。
5.1 指令选择
编译器前端和优化器都运行在代码的IR形式上。IR形式的代码必须针对目标处理器的指令集重写,才能在目标处理器上执行。将IR操作映射到目标机操作的过程称为指令选择(instruction selecion)。
评估一个指令选择算法的性能通常涉及以下几个方面:
(1)时间复杂度:评估算法在最坏情况、平均情况和最佳情况下的运行时间随输入规模的增长而增长的速度。这通常通过理论分析和实际测量来进行,使用大O符号来描述这些复杂度。
(2)空间复杂度:评估算法在执行过程中所需的最大存储空间与输入大小之间的关系,包括理论分析和实际运行时的内存使用情况。
(3)基准测试:通过使用标准测试集或实际数据集来测试算法的执行时间和资源使用情况。这可以比较不同算法在相同条件下的性能表现,以便选择最有效的算法。
(4)实验评估:在实际应用场景中进行实验,测量算法的性能指标,如执行时间、准确性、内存占用等。通过实际数据和真实场景的测试,可以更全面地评估算法的性能。
(5)可扩展性:考虑算法在处理大规模数据和增加计算资源时的性能表现。一个高效的算法应该能够有效地利用增加的计算资源来提高处理能力。
(6)鲁棒性:评估算法在面对不同输入和异常情况时的稳定性和可靠性。
(7)实际运行时间测试:在特定硬件上运行程序,测量其执行时间,来评估指令选择算法的实际运行效率。
(8)性能分析工具:使用性能分析工具来收集指令选择前后的代码执行数据,如CPU周期、内存访问次数等。
(9)反馈优化:在编译的过程中插入检测语句,在可执行文件运行时生成检测文件,以便重新编译的过程中进行优化。
这些评估方法可以单独使用,也可以组合使用,以获得更全面的评估结果。通过这些方法,开发者可以量化地比较不同指令选择算法的性能,并据此进行选择或进一步优化。
为将一个程序从中间表示(如抽象语法树或底层的线性代码)转换为可执行形式,编译器必须每个IR结构映射到目标处理器指令集中一个对应且等价的结构。取决于IR和目标机ISA中插象的相对层次,这一转换可能涉及细化IR程序中被隐藏的细节,也可能涉及将多个操作合并为一个机器指令,编译器所作的特定选择将影响到编译后代码的总体效率。
指令选择是将中间表示对应相应的机器指令。
1条中间表示->1条或多条指令
多条中间表示->1条指令或者多条指令
5.1.1 模板匹配(宏展开)
模板匹配(Template Matching),即对于每一条IR或AST结构,都有一条或多条机器指令与其相对应,编译器直接使用预制好的指令或指令模板替换对应的每一条输入IR即可。这种方式简单粗暴,实现简单,易于理解。但这种操作只支持1:1或1:N的情况,无法处理N:1或N:M的场景,即多条IR或AST对应一条或者多条机器指令的场景,致使指令结果不优。
因为产生的指令非常低效,作为指令选择的后续操作,会产生一些指令合并或用更高效更简洁的指令替换原先指令的优化。
5.1.2 树模式匹配
树覆盖解决了宏展开的不支持N:1或N:M的场景,它主要是将IR或AST的前端语法与后端的机器指令都转换为树结构。这样就把指令选择问题转换为机器指令树覆盖全IR语法树的问题。这种方法通过构建一个有向无环图(DAG),将IR转换为DAG,然后通过模式匹配将DAG节点转换为机器指令节点。这种方法可以处理更复杂的IR到机器指令的映射。
5.1.3 使用窥孔优化进行指令选择(micro)
窥孔优化的核心思想:使用一个滑动窗口(或称为“窥孔”)在指令上面移动,对窗口内相邻指令构成的短序列或者模式进行分析,寻找可以可以改进的特定序列,如果识别出一个指令序列与另一个更高效的指令序列等价(该序列就是特定序列),则用这个等价序列对原序列进行替换,原序列的存在若没有意义(如mov r0, r0),则可以将其删除。
编译器的指令选择是将中间表示转换为特定目标架构的机器指令的过程。这个过程的目标是选择最有效、最优化的机器指令来实现IR中的操作。除了基本的指令选择工作外还需要做一些额外的工作:
- 合法化(Legalization):在指令选择过程中,编译器需要将非法的操作(即目标架构不支持的操作)转换为合法的操作。这可能涉及到操作的分解或合并。
- 结合(Combining):在指令选择过程中,编译器会尝试合并多个操作以减少指令的数量,提高代码的效率。
3 指令调度(Instruction Scheduling):在指令选择之后,编译器会调整指令的顺序以提高执行效率,减少流水线的延迟。 - 寄存器分配(Register Allocation):指令选择后,编译器需要决定哪些变量应该存储在有限的CPU寄存器中,以减少内存访问。
- 自定义指令选择(Custom):在某些情况下,编译器允许用户自定义指令选择的过程,以实现特定的优化目标。
- 优化级别的选择:编译器通常提供不同的优化级别,如-O1, -O2, -O3等,这些级别决定了编译器在指令选择和其他优化上投入的努力程度。
指令选择是编译器后端的关键步骤之一,它直接影响到生成的机器代码的性能和效率。通过上述技术和策略,编译器能够生成针对特定目标架构优化的代码。
在将GIMPLE转换为IR-RTL时,GCC会根据MD文件中的指令模板来生成对应的RTL指令序列。这一过程涉及到对GIMPLE语句的分析和匹配,以及对指令模板的实例化。
在机器相关优化阶段,GCC会使用MD(riscv.md)文件中的define_insn和define_expand模板来指导GIMPLE到RTL的转换。这些模板定义了如何将高级的GIMPLE操作转换为低级的RTL操作。
在生成汇编代码之前,GCC还会使用define_split和define_peephole等模板来进行进一步的优化,这些模板帮助GCC识别和替换那些可以被更高效指令序列替代的指令模式。
在GCC编译器中,机器描述文件(MD文件)是用来指定目标机器的指令集、寄存器分配、内存模型、调用约定等信息的关键文件。这些文件指导GCC如何将源代码转换为目标体系结构的机器码,并进行其他优化和代码生成。GCC通过读取这些机器描述文件来实现可重定向性,即能够为不同的目标机器生成代码。
GCC指定机器描述文件的主要方式包括: - .md 文件:这个文件包含了目标机器的指令模板,使用MD构建(如 define_insn 和 define_expand)来描述。这些模板定义了如何将GIMPLE中间表示转换为RTL,以及最终的汇编代码。例如,一个名为“addsi3”的指令模板可能定义了一个整数相加操作,其中包含了操作数、条件和输出模板。
- .h 文件:这个文件包含了一系列的C宏定义,描述了目标寄存器子集和特征,以及系统软件详细信息,如汇编程序格式、可执行文件格式等。
- .c 文件:这个文件通常是必需的,它实现了特定目标代码的函数,如寄存器类约束的检查和实现。这些函数通常与.h和.md文件中定义的宏和指令模板相关联。
GCC的机器描述文件中的指令模板定义了指令的RTL模板、条件、输出模板和属性。这些模板在编译过程中被用来生成目标机器的汇编代码。例如,一个指令模板可能定义了一个操作数的匹配条件,一个C语言的表达式来确定insn与该指令模板是否匹配,以及不同情况下该指令模板对应的汇编代码的输出格式。
在GCC的构建过程中,当编译程序时,会参考从机器描述中收集到的信息。GCC通过读取机器描述并生成自定义机器描述的后端来实现可重定向性。机器描述受高级语言、GCC体系结构,以及目标、主机和构建系统的属性的影响。
总结来说,GCC通过.md、.h和.c文件来指定和描述目标机器的指令集和其他特性,这些文件共同工作以确保GCC能够为不同的目标机器生成正确的机器码。
5.2 指令调度
一组操作的执行时间严重依赖于其执行顺序。指令调度试图重排一个过程中的各个操作,以改进其运行时间。本质上,指令调度试图在每个周期执行尽可能多的操作。
在许多处理器上,指令序列中各个操作的执行顺序,对指令序列的总体执行时间有着重要的影响不同操作花费的时间不同。在典型的市售微处理器上,整数加法和减法需要的时间少于整数除法,类似地,浮点除法花费的时间长于浮点加法或减法。乘法花费的时间通常在对应类型的加法和除法操作之间。完成一个从内存加载数据的指令所花费的时间,取决于load指令发出时所述数据在内存层次结构中所处的位置。
对程序块或过程中的操作进行排序以有效利用处理器资源的任务称为指令调度(imstuction scheduling)。调度器的输入是由目标机汇编语言操作组成的一个部分有序的列表,其输出是同一列表的一个有序版本。调度器假定其输入代码已经优化过,它并不试图重复优化器的工作。相反,它会将各条指令组装到可用的处理器周期和功能单元发射槽中,使代码尽可能快速地运行。
指令调度的优化目标通常包括:
(1)提高指令流水线的利用率:通过减少流水线的空闲周期来提高处理器的吞吐量。
(2)减少数据冲突:通过合理安排指令顺序,减少数据冲突,如访存冲突和写回冲突。
(3)提高缓存命中率:通过优化指令顺序,使得数据访问模式更加友好,从而提高缓存的命中率。
(4)减少分支预测错误:通过指令调度减少分支指令的不确定性,提高分支预测的准确性。
评估一个指令调度算法的性能通常涉及以下几个方面:
(1)CPU利用率:衡量CPU在执行程序时忙碌的时间占总时间的比例。高CPU利用率通常意味着指令调度算法能够有效地利用处理器资源,减少空闲周期。
(2)系统吞吐量:指单位时间内完成的作业数量。一个优秀的指令调度算法可以提高系统的吞吐量,即在相同时间内处理更多的任务。
(3)周转时间:从作业提交到作业完成的时间。指令调度算法优化可以减少作业的周转时间,提高作业完成的效率。
(4)等待时间:作业在进入执行状态前等待的时间。指令调度算法通过优化指令执行顺序,可以减少因等待资源而产生的延迟。
(5)响应时间:从作业请求到首次产生输出所需的时间。对于交互式应用来说,响应时间是一个重要的性能指标。
(6)实际运行时间测试:通过在特定硬件上运行程序,测量其执行时间,来评估指令调度算法的实际运行效率。这包括对算法进行基准测试(benchmarking)以及与同类型问题上的其他算法进行比较。
(7)性能分析:评估算法在不同输入规模下的性能表现,包括算法的响应时间、吞吐量、资源利用率等指标。
(8)可扩展性分析:评估算法在处理大规模数据时的性能表现,以及算法是否能够适应输入规模的增长。
(9)稳定性分析:评估算法在面临不同类型的数据或异常情况时的鲁棒性,即算法是否能够稳定地输出正确的结果。
(10)与最优解的比较:对于某些问题,已知最优解或近似最优解的性能,可以通过与这些解进行比较来评估算法的效率。
(11)反馈优化:在编译的过程中插入一些检测语句,在可执行文件运行时生成检测文件,以便重新编译的过程中进行优化。
(12)性能数据收集:生成可执行文件并收集性能数据,然后根据性能数据进行优化,最后执行优化后的程序以验证优化效果。
这些评估方法可以单独使用,也可以组合使用,以获得更全面的评估结果。通过这些方法,开发者可以量化地比较不同指令调度算法的性能,并据此进行选择或进一步优化。
在实际的编译器实现中,指令调度是一个复杂的过程,涉及到指令选择(Instruction Selection)、寄存器分配(Register Allocation)、内存访问优化等多个环节。编译器开发者需要综合考虑多种因素,以实现最佳的指令调度效果。
根据指令延迟做调度的,通常会在编译器里建立简化的架构描述模型(architecture description model)来描述CPU的一些特征,例如指令发射宽度、各种资源的个数(多少个加法port、多少个乘法port)、流水线深度、指令的列表、每条指令的使用的资源以及延迟信息等。
指令调度受到多方面的约束,如数据依赖约束、功能部件约束、寄存器约束等,在这些约束下,寻找到最优解,降低指令流水间的stall,就是指令调度的终极目标。指令流水间的stall主要由数据型冒险、结构性冒险控制型冒险导致。
数据型冒险:当前指令的执行依赖与上一条指令执行的结果。数据型冒险共有三种:写中”(RAW)、读后写(WAR)、写后写(WAW)。数据冒险可能产生数据流依赖。
结构型冒险: 多条指令同时访问一个硬件单元的时候,由于缺少相应的资源,导致结构型冒险。
控制型冒险:存在分支跳转,无法预测下一条要执行的指令,导致其产生的控制型冒险。
编译器解决上述冒险的方法就是通过插入NOP指令,增加流水间的stall来化解冒险。但是尽量不插入NOP,插入NOP会降低CPU的利用率。所以这里需要均衡。
指令调度算法之表调度(List Scheduling):
表调度是一种贪心+启发式方法,用以调度基本块中的各个指令操作,是基本块中指令调度的最常见方法。基于基本块的指令调度不需要考虑程序的控制流,主要考虑数据依赖、硬件资源等信息。表调度的基本思想:维护一个用来存储已经准备执行的指令的ready列表和一个正在执行指令的active列表,ready列表的构建主要基于数据依赖约束和硬件资源信息;根据调度算法以周期为单位来执行具体的指令调度,包括从列表中选择及调度指令,更新列表信息。
基本的表调度算法大致分为以下三步:1. 根据指令间依赖,建立依赖关系图。2. 根据当前指令节点到根节点的长度以及指令的latency,计算每个指令的优先级。3. 不断选择一个指令,并调度它。
a. 使用两个队列维护ready的指令和正在执行的active的指令;
b. 在每个周期:选择一个满足条件的ready的指令并调度它,更新ready队列;检查active的指令是否执行完毕更新active列表。
指令调度案例
本案例选自卡内基梅隆大学(Carnegie MellonUniversity)的Compiler Design课程假设当前CPU有两个计算单元(即每个周期可以执行两条指令);加法指令的latency为2cycles,其他指令为 1 cycle。
-
根据数据依赖关系构建出依赖关系图
-
计算指令节点优先级优先级计算公式如下其中,(这个公式没研究)
其中I10为叶节点,优先级为其latency,故结果为1;I4为非叶节点,优先级为当前节点latency (I4为加法指令,latency为2)+子节点的优先级,故结果为3。本例中无反依赖(antidependency)情形。
3. 执行调度
在实际执行调度时,对于同等优先级的指令由于具体调度方案的不同,会出现不同的情况,例如本例中出现的场景,可以通过添加其他度量标准进一步优化优先级计算方案。尽管表调度方法不能保证得到最优调度结果,但它是接近最优解的。本文只是简单介绍了最基本的表调度方法,在实际应用中,存在各种基于该方法的改进方案。指令调度作为NP完全问题目前依旧尚未有一个完美的解决方案,对指令调度算法的探索与优化尚有很大的发展空间。
编译器在进行指令调度时,需要CPU提供一些特性和信息,以确保调度后的指令序列能够在硬件上高效执行。以下是一些关键的CPU特性和信息:
1.指令流水线(lnstruction Pipeline):现代CPU通常采用流水线技术来提高指令的执行效率。编译器需要了解流水线的结构,包括各个阶段的延迟和功能,以便合理安排指令顺序。
2.乱序执行(Out-of-Order Execution):许多现代CPU支持乱序执行,允许在不违反数据依赖性的前提下,动态地重新排列指令的执行顺序。编译器需要考虑这一点,以生成适合乱序执行的指令序列。
3.寄存器重命名(Register Renaming):为了减少寄存器冲突和提高指令流水线的吞吐量,一些CPU采用了寄存器重命名技术。编译器需要了解这一点,以便在生成指令时充分利用寄存器重命名的优势。
4.缓存结构(Cache Hierarchy):CPU的缓存结构对指令调度有很大影响。编译器需要了解缓存的大小、层次和访问延迟,以便优化数据访问模式和提高缓存命中率。
5.分支预测(Branch Prediction):现代CPU通常具备分支预测功能,以减少分支指令的延迟。编译器需要了解分支预测的策略和准确性,以便生成更容易被预测的分支指令。
6.硬件事务内存(Hardware TransactionalMemory,HTM):一些CPU提供了硬件事务内存支持,允许在事务中执行一系列指令,以提高并发性和性能。编译器需要了解这些特性,以便在适当的场景下使用事务内存。
7.SIMD指令集(Single Instruction,Multiple Data):许多CPU提供了SIMD指令集,允许同时对多个数据元素执行相同的操作。编译器需要了解这些指令集以便生成能够充分利用SIMD并行性的指令序列。
8.多线程和多核支持(Multithreading andMulticore Support):现代CPU通常支持多线程和多核处理。编译器需要了解这些特性,以便在指令调度时考虑线程间和核间的交互和同步。
9.指令依赖性(Instruction Dependencies):编译器需要分析指令之间的数据依赖性和控制依赖性,以确保在指令调度时不违反这些依赖关系。编译器需要了解各种指令的执行延迟,以便在指令调度时尽量减少延迟较大的指令对性能的影响。
通过充分利用这些CPU特性和信息,编译器可以生成更高效的指令序列,从而提高程序的执行性能。
GCC中的指令调度主要包括两个处理过程(Pass),即pass_sched和passs_ched2,其中pass_sched在寄存器分配之前进行,而pass_sched2在寄存器分配之后进行。
GCC中使用的指令调度策略有多种,主要通过GCC命令的编译选项进行选择,如下表给出了GCC中使用的指令调度编译选项及其对应的调度算法,同时也给出了这些调度算法实现的主要源代码文件。
GCC中指令调度的主要算法:
指令调度算法 对应的GCC编译选项 GCC处理过程 主要源代码 备注
Swing Moulo Scheduling Algorithm
(模调度算法) -fmodulo-sched pass_sms gcc/modulo-sched.c 在指令调度处理过程之前进行,主要使用该算法对最内层的循环进行处理
Selective Scheduling Algorithm
(选择性调度算法) -fselective-scheduling
-fselctive-scheduling2 pass_sched
pass_sched2 Gcc/sel-sched.c 在Pass_sched和Pass_sched2中均可以使用
Superblock Scheduling Algorithm
(超级块调度算法) -flag_sched2_use_superblocks
-flag_sched2_use_traces Pass_sched2 Gcc/sched-ebb.c 在Pass_sched2中使用
List Scheduling Algorithm
(表调度) -fsecheule-insns
-fsecheule-insns2 Pass_sched
Pass_sched2 Gcc/sched-rgn.c 如果不指定调度方法,在Pass_sched和Pass_sched2中默认使用
5.3 寄存器分配和指派
寄存器分配是编译器在输出汇编代码前必须经历的阶段。寄存器分配算法的好坏,关系着生成代码的性能,大小。为了追求极致性能,很多编译器都在寄存器分配上做了很多工作,不惜引入非常复杂的算法。另一方面寄存器分配算法本身的性能很关键,另外寄存器分配作为将无限多的逻辑单元映射到有限多的物理单元的典型问题,深入了解它也有助于对其他相关问题的理解。
寄存器分配的原则:
(1)当生成某变量的目标代码时,尽量让变量的值或计算结果保留在寄存器中,直到寄存器不够分配时为止。
(2)当到基本块出口时,将变量的值存放在内存中,因为一个基本块可能有多个后继结点或多个前驱结点,同一个变量名在不同前驱结点的基本块内出口前存放的寄存器可能不同,或没有定值,所以应在出口前把寄存器的内容放在内存中,这样从基本块外入口的变量值都在内存中。
(3)对于在一个基本块内后边不再被引用的变量所占用的寄存器应尽早释放,以提高寄存器的利用效率。
评估一个寄存器分配算法的性能通常涉及以下几个关键指标:
(1)运行时间:衡量算法在特定硬件平台上执行的时间。运行时间是衡量算法效率的直接指标,通常以秒为单位进行测量。运行时间越短,说明算法的执行效率越高。
(2)编译时间:衡量编译器完成整个编译过程所需的时间。编译时间包括了寄存器分配算法的运行时间,以及编译过程中其他阶段的时间。编译时间越短,说明编译器的整体效率越高。
(3)溢出次数:在寄存器分配过程中,如果无法为所有变量分配寄存器,就需要将一些变量“溢出”到内存中。溢出次数是衡量寄存器分配算法性能的重要指标,溢出次数越少,说明算法能够更有效地利用有限的寄存器资源。
(4)内存使用:评估算法在运行过程中对内存的占用情况。内存使用量越低,说明算法在内存资源利用上更为高效。
(5)代码质量:评估寄存器分配算法对生成代码质量的影响。这可能包括代码的执行速度、内存访问模式、以及对处理器缓存的利用效率等。
(6)稳定性和可扩展性:评估算法在处理不同规模和复杂度的代码时的稳定性,以及算法是否能够适应不同硬件架构的变化。
(7)实验验证:通过实验验证来测试算法在实际应用中的表现。这通常涉及到在一组标准测试集上运行算法,并记录上述性能指标。
(8)理论分析:对算法的时间复杂度和空间复杂度进行理论分析,以预测算法在最坏情况下的性能。
在实际的评估过程中,可能需要结合多种方法来全面评估寄存器分配算法的性能。可以通过构建一个测试套件,包含不同特性和复杂度的代码样本,然后在这些样本上运行算法并记录性能数据。此外,还可以通过与其他算法的性能比较来评估新算法的优势和局限性。
寄存器分配相关算法:
(1) 图着色算法 图着色(graph coloring)方法是解决寄存器分配问题最常用的方法。
计算机中物理寄存器个数总是有限的,当需要一个寄存器但所有可用寄存器都在被使用时,就要将某个正在使用的寄存器溢出(spill)到内存中从而释放出一个寄存器。图着色算法是溢出代价最小的寄存器分配算法。
该方法的具体步骤为:
S1:给机器指令分配虚拟寄存器,此时假设虚拟寄存器有无限多个;
S2:构建寄存器冲突图,图中的节点是虚拟寄存器,对于两个虚拟寄存器,其中一个在另一个被定值的地方是活跃的则说两者是冲突的即这两个节点之间存在一条边。如:在第二条指令Rd被定值时Ra是活跃的,所以Ra和Rd就是冲突的
1 ADD Ra, Rb, Rc
2 SUB Rd, Rd, Rb
3 ADD Re, Ra, Rf
上述三条指令得到的寄存器冲突图为(感觉图和例子没那么匹配):
寄存器冲突图
S3:利用k种颜色(可用物理寄存器个数)对寄存器冲突图进行图着色。图是否可着色是一个NP完全问题,实践中可采用启发式技术进行快速着色。假设图G中有个节点n,其邻居少于k,把n及和n相连的边删掉得到图N,若图N可着色,只要给节点n分配一个不同于它邻居的颜色即可,进而可推出图G可着色。因此通过不断地从寄存器冲突图中删除边数少于k的节点,若最后得到一个空图则说明可着色,按节点被删除的相反顺序进行着色就可得到原图的着色方案;若最后得到的图中每个节点都至少有k个相邻的节点则说明不可着色。
S4:若可着色则按着色方案将虚拟寄存器替换为对应的物理寄存器。否则需要通过引入保存和加载寄存器的指令将某个节点溢出并重复以上步骤。
(2) 线性扫描算法 线性扫描算法是寄存器分配最快的算法。
线性扫描寄存器分配算法是1999年提出的。相比基于图着色原理的寄存器分配算法,线性扫描算法速度更快、更易于实现,且生成的代码同样高效。
概念介绍:
假设程序中间表示(Intermediate representation)的指令按照一定的顺序排列并编号,且指令中采用无限多个的虚拟寄存器,可用物理寄存器个数为R。live interval是指虚拟寄存器的活跃区间用[i,j]来表示,i和j为指令编号,表示其开始点和结束点。冲突是指两个活跃区间有重叠,此时不能为其分配相同的寄存器。若在某个程序点活跃区间重叠个数n>R,则必须将(n-R)个虚拟寄存器溢出(spill)到内存中。
该算法的具体过程如下:
集合list表示所有虚拟寄存器live interval的集合,以其开始点升序排列。集合active为当前活跃虚拟寄存器live interval的集合,以结束点升序排列。
S1:对集合list中的虚拟寄存器按顺序扫描,每次取出一个虚拟寄存器的interval;
S2:根据新的虚拟寄存器的interval,以其开始点为“当前点”,若集合active中有虚拟寄存器的结束点小于"当前点",则说明其在“当前点”不再活跃,将其从集合active删掉,并释放相应的物理寄存器;
S3:若集合active中虚拟寄存器的个数等于R,则需要溢出一个虚拟寄存器。溢出规则是选择结束点最大的将其溢出。因为集合active中的虚拟寄存器是按结束点升序排列的,所以溢出必然“集合active中的最后一个虚拟寄存器”或“新的虚拟寄存器”。
若集合active中虚拟寄存器的个数小于R,则将这个新的虚拟寄存器加入集合active,并为其分配一个物理寄存器。
S4:返回S1,至到集合list中的虚拟寄存器扫描完。
其伪代码如下图所示:
举例:
图中左边为虚拟寄存器的名字,右边为其对应的interval。假设可用物理寄存器的个数R=2。集合list为{A、B、C、D、E},该算法需扫描5次。当第2次扫描结束,active={A、B};第3次扫描时,active中虚拟寄存器个数等于2,此时必须溢出一个虚拟寄存器,因C的结束点大将其溢出到内存中,此时active={A、B};第4次扫描时,因end(A)<start(D)即active={B},因此有一个物理寄存器可用并将其分配给D,此时active={B、D};同第4次扫描,在第5次扫描结束时active={D、E}。
GCC中关于寄存器分配的代码主要包括(IRA 是 “intergrated Register Allocation” 的缩写,它是一种基于图着色的寄存器分配算法): 集成寄存器分配器(IRA)是一个区域寄存器分配器,对嵌套区域的自上而下遍历执行图着色。区域中的图形着色基于Chaitin-Briggs算法。之所以称之为集成,是因为在着色过程中,寄存器合并、寄存器有效范围分割和选择更好的硬寄存器都是动态完成的。寄存器合并和选择更便宜的硬寄存器是通过在硬寄存器分配过程中进行硬寄存器优先选择来完成的。实时范围分割是区域寄存器分配的副产品(来自源码ira.c中的注释翻译)。
gcc/ira.c:入口函数
gcc/ira-build.c:主要进行分配元的创建
gcc/ira-lives.c:分配元生成范围的计算
gcc/ira-conflicts.c:分配元冲突计算
gcc/ira-costs.c:分配元代价计算
gcc/ira-color.c:使用寄存器着色进行寄存器分配
gcc/ira-emit.c:寄存器分配过程中溢出代码的处理
5.4 后端优化部分代码解析
在RISC-V GCC编译器中,后端优化的程序入口主要涉及到中间表示(IR)转换为目标机器代码的过程。这个过程包括多个阶段,如寄存器分配、指令选择、指令调度等。以下是一些关键点和相关文件,帮助你理解RISC-V GCC编译器后端优化的程序入口:
- RTL(Register Transfer Language)生成
后端优化通常从生成RTL表示开始。GCC使用RTL作为中间表示,具体的优化过程在生成RTL时发生。对于RISC-V,相关的代码通常位于gcc/config/riscv/目录下。 - riscv.md文件
这个文件包含了RISC-V目标的机器描述(Machine Description)。它定义了RISC-V指令的模式(patterns),这些模式用于匹配和生成具体的机器指令。每个模式描述了如何将特定的RTL表达式转换为RISC-V指令。 - riscv.c文件
这个文件包含了RISC-V特定的后端实现,包括寄存器分配、指令调度和其他目标特定的优化。后端优化的入口函数通常会在这个文件中调用。 - 优化入口函数
GCC的后端优化过程涉及多个阶段,每个阶段都有其入口函数。以下是一些关键的入口函数:
rest_of_handle_final:这是GCC后端的最终处理阶段,将RTL转换为汇编,包括指令选择和生成汇编代码,在gcc/final.c中。
ira(Integrated Register Allocator):这是寄存器分配的入口函数。
sched(Instruction Scheduler):这是指令调度的入口函数。 - gen_rtx和emit_insn函数
这些函数用于生成和发出RTL指令。gen_rtx函数用于生成特定类型的RTL表达式,而emit_insn函数则用于将这些RTL表达式转换为实际的机器指令并插入到指令流中。
具体示例
以下是一个简化的示例,展示了如何在RISC-V GCC编译器中进行后端优化:
riscv.md文件中的模式定义:
(define_insn “addsi3”
[(set (match_operand:SI 0 “register_operand” “=r”)
(plus:SI (match_operand:SI 1 “register_operand” “r”)
(match_operand:SI 2 “arith_operand” “rI”)))]
“”
“add\t%0, %1, %2”
[(set_attr “type” “arith”)])
这个模式定义了如何将一个简单的加法操作转换为RISC-V的add指令。
riscv.c文件中的入口函数:
static void
riscv_expand_prologue (void)
{
// 这里是生成函数序言代码的逻辑
}
static void
riscv_expand_epilogue (void)
{
// 这里是生成函数尾声代码的逻辑
}
void
riscv_init_expanders (void)
{
// 初始化指令扩展器
init_expanders ();
}
在这个文件中,你可以找到类似riscv_expand_prologue和riscv_expand_epilogue这样的函数,它们负责生成函数的序言和尾声代码。
5.4.1 后端优化的入口函数
后端优化的程序入口通常在GCC后端的final.c文件中,通过调用目标特定的钩子函数来实现。对于RISC-V,入口函数为以下代码:
void riscv_expand (rtx_insn *insn)
{
// 处理指令选择的逻辑
if (GET_CODE (insn) == INSN)
{
// 调用模式匹配和指令生成逻辑
riscv_emit_insn (insn);
}
}
这个函数会遍历RTL指令,并调用相应的模式匹配和指令生成逻辑。
RISC-V GCC编译器中后端优化的程序入口主要涉及到riscv.md和riscv.c文件中的模式定义和函数实现。通过理解这些文件中的模式和函数,可以更好地理解RISC-V GCC编译器中后端优化的过程。
5.5 RISC-V后端优化方案
要在GCC中针对RISC-V架构进行后端优化,可以通过修改GCC的代码来实现。以下是一些具体的优化方案和步骤:
- RISC-V 特定优化
针对RISC-V架构,可以通过修改GCC的RISC-V后端代码来进行优化。
修改步骤:
(1)定位RISC-V后端代码:GCC的RISC-V后端代码主要在gcc/config/riscv目录下。
(2)增加特定优化:可以增加对RISC-V特定指令的优化,例如压缩指令、特定寄存器使用等。
// 示例:增加RISC-V特定指令优化
if (is_riscv_target() && supports_compressed_instructions()) {
use_compressed_instructions();
}
具体优化示例 - 增加对压缩指令的支持
压缩指令可以减少代码大小,提高指令缓存的命中率。
// 在 gcc/config/riscv/riscv.md 中添加压缩指令模式
(define_insn “c_add”
[(set (match_operand:SI 0 “register_operand” “=r”)
(plus:SI (match_operand:SI 1 “register_operand” “r”)
(match_operand:SI 2 “register_operand” “r”)))]
“TARGET_RVC”
“c.add\t%0, %1, %2”
[(set_attr “type” “arith”)]) - 优化寄存器分配
通过优化寄存器分配,可以减少内存访问,提高程序性能。
// 在 gcc/config/riscv/riscv.c 中修改寄存器分配逻辑
static void RISC-V_allocate_registers (void)
{
// 自定义寄存器分配逻辑
allocate_registers ();
} - 指令调度优化
在GCC RISC-V编译器中,指令调度优化是一个关键的步骤,用于提高程序的执行效率。以下是一些基于搜索结果的优化方案示例:
常量的具体化:使用lui/addiw将立即数加载至寄存器时,对于低12位最高位为1的立即数,需要特殊处理,提前补值0x800。
有效使用x0寄存器:将目标寄存器置0时,应优先使用mv x10, x0或li x10, 0,避免使用xor x10, x10, x10等会产生额外读寄存器开销的指令。
有效利用指令的立即数域:尽量将符合立即数域编码范围的常量编码到立即数域,而不是通过额外的指令加载到寄存器再使用。
善于利用常量池:对于64bit的立即数常量,使用常量池可以减少加载至寄存器所需的字节数。
使用典型的mov指令:使用汇编器MV助记符(可转换为ADDI rd, rs1, 0)将值从一个寄存器复制到另一个寄存器,优先于使用or x10, x11, x0、ori x10, x11, 0、xor x10, x11, x0、xori x10, x11, 0等指令。
使用条件mov代替分支指令:可以将分支指令替换成条件move,从而减少分支预测错误带来的性能损失。
代码段的Padding:在函数结束或开头进行Padding,使其位于Cache行的开头地址,有助于提高跳转操作的效率。
将字符数组对齐到更大的对齐单位:这有助于提高内存访问效率。
这些优化方案可以帮助GCC RISC-V编译器生成更高效的指令序列,从而提高程序的运行速度。开发者可以根据这些指导原则来优化自己的代码,或者利用GCC编译器的优化选项来自动进行这些优化。
通过上述修改,可以显著提升GCC在RISC-V架构上的性能。需要注意的是,每次修改后都需要进行充分的测试,以确保修改不会引入新的问题。