深入理解LLVM代码生成-概述
代码生成是编译器后端的统称,在编译器的实现中占据着非常重要的地位,也是非常复杂的模块。以LLVM 15为例,整个项目代码行数超过1000万^1,其中clang相关代码超过400万行,LLVM中端优化以及后端代码生成相关代码也已经有近400万行代码。其中代码生成代码量约为200万行,包含了架构无关的代码和架构相关的代码。其中架构无关代码约为50万行,架构相关约为150万行。架构无关的代码主要包了含指令选择、指令调度、寄存器分配和机器码生成;架构相关代码是LLVM所有支持的后端代码总和,目前LLVM已经支持超过20种后端,有些后端的实现非常复杂,如X86后端代码量超过了20万行,而有些又非常简单,如BPF后端代码量只有1万行左右。
虽然代码生成模块非常复杂,但LLVM通过模块化的设计,将不同的功能设计为不同的Pass然后再进行组合,以完成代码生成。其代码生成整体逻辑图如霞所示。
每个模块的主要工作简单总结如下:
指令选择:为LLVM IR选择合适的机器指令。LLVM实现了3种指令选择算法,分别是FastISel,SelectionDAGISel,GlobalISel。目前LLVM后端默认使用的是基于DAG的SelectionDAGISel指令选择算法,在指令选择阶段引入了图IR,对LLVM IR使用图匹配的方法生成MIR。值得注意的是MIR不完全是目标无关的IR(MIR结构与后端无关,但是大部分MIR的操作码已经和具体后端相关,只有少数MIR和后端无关,例如一些伪指令)。
指令调度-1:根据MIR中数据依赖对指令进行调度。目前LLVM实现了几种指令调度策略,如基于表调度(List Scheduling)、循环调度等;此时的优化也称为Pre-RA的调度,目的在于减少寄存器分配过程中的压力;在指令选择阶段也有指令调度(图中没有体现)。
编译优化-1:基于SSA形式的MIR进行编译优化,例如进行死代码删除、公共表达式消除等优化。
寄存器分配:将MIR中使用的逻辑寄存器映射到物理寄存器,目前LLVM中提供了4种寄存器分配算法,分别是basic、fast、greedy和pbqp,可以通过命令参数regalloc指定。
Prologue/Epilogue代码生成:为函数生成前言和后序代码,例如处理栈帧布局等。
编译优化-2:经过寄存器分配后的MIR为非SSA形式,可以再次执行一些编译优化,例如复制传播等,需要注意的是,此时执行的编译优化思想和SSA形式的优化相同,但是由于MIR不再具有SSA特点,所以导致优化算法实现也有所不同。
指令调度-2:再次执行指令调度,此时的优化也称为Post-RA的调度,目的在于提高执行效率。
其它优化:执行机器码发射前的优化,例如执行基本块重排等优化;在此处还允许后端实现自己特殊的优化。
机器码发射:寄存器分配完成后就可以进入到机器码发射阶段(真正生成目标机器代码),为了更好的生成目标代码,LLVM引入了MCInst IR(MC),可以更加优雅地处理JIT代码、汇编和反汇编。
如上面提到,BPF后端代码较少,整体比较简单,所以本书主要以BPF后端为例来介绍代码生成,BPF后端的代码生成过程大概涉及45个Pass(不同版本略有不同),上面提到的每个模块都有涉及。而其他一些复杂的后端,例如X86或者Aarch64其代码生成过程涉及的Pass通常超过100个(BPF后端的Pass都包含在这100多个Pass中),和BPF后端相比这些额外的Pass多数是后端优化相关。
当然,要在书中将这45个Pass都详细地介绍也是不可能的事情,故而我们主要对最重要的一些Pass——指令选择、指令调度、寄存器分配、代码输出——进行介绍。除此以外,本书还介绍了一些较为实用的优化Pass,例如If-Conversion(对于GPGPU这样特殊架构效果很好)、公共代码提取(对于代码小型化有效果)、代码布局(对于代码运行性能有效果)等。
实际上,在编译器领域中,除了开发更多、更强大功能的Pass外,对于Pass还有两个方向的研究,一方面是Pass之间的执行顺序对于性能的影响,另一方面是代码生成过程中Pass的重要性(例如JIT中关闭一些不重要的Pass可能获得更好的性能)。
对于Pass顺序的研究一直以来都是编译优化中的较为重要的一个方向,原因是编译优化的Pass之间可能存在相互影响,导致不同Pass执行顺序下产生的机器码质量不同。该问题根本的原因是编译优化是NP难题,无法找到最优解。但是可以根据场景需求,对于Pass顺序进行调整生成高质量机器码,这方面有不少论文,读者可以自行查询。
对于Pass重要性的研究,一些学者通过代码生成过程中不同Pass的耗时进行量化分析(Pass的耗时从一个方面反映了重要度),例如在一些基准测试中Top 20的Pass耗时如下表所示:
| 后端优化Pass | 运行时间据均值和标准差 | 最大值 | 最小值 |
|---|---|---|---|
| DAG to DAG Instruction Selection(指令选择) | 49.52%±7.14% | 81.44% | 7.31% |
| Assembly Printer(汇编输出) | 8.93%±2.67% | 15.80% | 0.44% |
| Greedy Register Allocator(Greddy寄存器分配) | 8.59%±2.78% | 70.13% | 0.38% |
| Live Variable Analysis(活跃变量分析) | 4.19%±1.58% | 19.75% | 0.32% |
| Live Interval Analysis(变量活跃区间分析) | 2.85%±1.26% | 21.74% | 0.20% |
| Prologue/Epilogue Insertion(函数栈帧生成) | 1.90%±0.66% | 3.97% | 0.05% |
| Virtual Register Map(寄存器分配后映射) | 1.79%±0.83% | 4.14% | 0.005% |
| Simple Register Coalescing(寄存器合并) | 1.64%±0.81% | 57.65% | 0.28% |
| Optimize for Code Generation(窥孔优化) | 1.61%±0.69% | 12.28% | 0.02% |
| Module Verifier(IR验证) | 1.22%±0.37% | 6.02% | 0.10% |
| Dominator Tree Construction(支配树构建) | 1.22%±0.45% | 2.64% | 0.004% |
| Machine Function Analysis(管理分析Pass,已经被Pass Manager替代) | 1.13%±0.43% | 2.42% | 0.05% |
| Machine CSE(公共表达式消除) | 1.08%±0.24% | 4.14% | 0.10% |
| Machine Dominator Tree Construction(支配树构建) | 1.06%±0.41% | 2.20% | 0.004% |
| Control Flow Optimizer(Branch Folding,分支折叠) | 1.03%±0.36% | 23.54% | 0.002% |
| Calculate Spill Weights(活跃变量区间权重计算) | 0.96%±0.56% | 2.34% | 0.01% |
| Two-Address Instruction Pass(二地址指令变换) | 0.93%±0.26% | 6.69% | 0.13% |
| Machine Instruction LICM(循环不变量外提) | 0.85%±0.35% | 2.32% | 0.003% |
| Loop Strength Reduction(循环变量强度削弱) | 0.77%±2.93% | 81.99% | 0% |
| Remove Dead Machine Instructions(死指令删除) | 0.64%±0.16% | 1.25% | 0.03% |
该结果是基于LLVM 3.0进行编译,测试套为SPEC CPU2006,执行后端前经过了充分的中端优化,这一研究目的是为了预测在JIT 等场景中后端编译优化的时间(由于JIT 是运行时进行的编译优化,需要在编译质量和编译时间寻找平衡,通过预测编译优化所需的时间可以确定JIT 应该使用的编译优化)。通过该表可以看到不同的编译优化耗时占比情况,通常来说编译耗时较长,功能越重要。例如指令选择、寄存器分配、汇编码生成是编译后端必需的功能,其耗时占比接近70%,这也和本书重点介绍内容一致。
除了重点Pass外,本书中对于表中其他Pass基本都有涉猎(个别除外,例如Machine Function Analysis已经被Pass Manager替代),不过详细程度不同,有些仅仅简单介绍了原理(比如支配树),关于这些Pass更多内容读者可以自行阅读源码或者参考相关资料。通过该表读者可以对代码生成的功能和重要度有一个简单的印象,更多信息可以参考论文[^2].
[^2]: “A LLVM JIT Compilation Cost Analysis”,R-Auler、E-Borin,2013。https://www.ic.unicamp.br/~reltech/2013/13-13.pdf。