控制流语句与消除无用的 JMP 指令
控制流语句与消除无用的 JMP 指令
1. 简要说明
从机器层面上来看,所有的跳转只分为无条件跳转和有条件跳转,从跳转方式上来分,又分为直接跳转(绝对地址)和间接跳转(相对偏移),所以只需要将 LLVM IR 的跳转 node 成功下降到机器跳转指令,并维护好跳转的范围、跳转的重定位信息即可。
Cpu032I 型机器支持 J 类型的跳转指令,比如无条件跳转 JMP,有条件跳转 JEQ、JNE、JLT、JGT、JLE、JGE,这部分指令是需要通过检查 condition code (SW 寄存器)来决定跳转条件的;Cpu032II 型机器除了支持 J 类型跳转指令之外,还支持 B 类型的跳转指令,比如 BEQ 和 BNE,这两个是通过直接比较操作数值关系来决定跳转条件的。相比较,后者的跳转依赖的资源少,指令效率更高。
SelectionDAG 中的 node,无条件跳转是 ISD::br,有条件跳转是 ISD::brcond,需要在 tablegen 中通过指定指令选择 pattern 来对这些 node 做映射。
另外,J 类型指令依赖的 condition code 是通过比较指令(比如 CMP)的结果来设置的,在之前的内容已经完成了比较指令,LLVM IR 的 setcc node 通常会被翻译为 addiu reg1, zero, const + cmp reg1, reg2 指令。
2. 文件修改
1)Cpu0ISelLowering.cpp
设置本节需要的几个 node 为 custom 的 lowering 类型,即会通过自定义的 lowering 操作来处理它们,这包括 BlockAddress,JumpTable 和 BRCOND。这分别对应 lowerBlockAddress(),lowerJumpTable() 和 lowerBRCOND() 函数,具体实现可参见代码,其中 getAddrLocal() 和 getAddrNonPIC() 是前边内容已经实现的自定义 node 生成函数。BRCOND 是条件跳转节点(包括 condition 的 op 和 condition 为 true 时 跳转的 block 的地址),BlockAddress 字面可知是 BasicBlock 的起始地址类型的节点,JumpTable 是跳转表类型的节点。后两者是叶子节点类型。
另外,设置 SETCC 在 i1 类型时做 Promote。增加了几行代码来说明额外的一些 ISD 的 node 需要做 Expand,有关于 Expand 在之前的内容介绍过,就是采用 LLVM 内部提供的一些展开方式来展开这些不支持的操作。这些操作包括:BR_JT,BR_CC,CTPOP,CTTZ,CTTZ_ZERO_UNDEF,CTLZ_ZERO_UNDEF。其中 BR_JT 操作的其中一个 op 是 JumpTable 类型的节点(保存 JumpTable 中的一个 index)。BR_CC 操作和 SELECT_CC 操作类似,区别是它保存有两个 op,通过比较相对大小来选择不同的分支。
2)Cpu0InstrInfo.td
增加两个和跳转有关的操作数类型:brtarget16 和 brtarget24,前者是 16 位偏移的编码,将用于 BEQ、BNE 一类的指令,这一类指令是属于 Cpu032II 型号中特有;后者是 24 位偏移的编码,将用于 JEQ、JNE 一类的指令。两个操作数均指定了编码函数和解码函数的名称。还定义了 jmptarget 操作数类型,用来作为无条件跳转 JMP 的操作数。
之后便是定义这几条跳转指令,包括它们的匹配 pattern 和编码。
无条件跳转 JMP 的匹配 pattern 直接指明到了 [(br bb::$addr)],很好理解。
然后做一些优化来定义 比较+跳转指令选择 Pattern,也就是将 brcond + seteq/setueq/setne/setune/setlt/setult/setgt/setugt/setle/setule/setge/setuge 系列模式转换为机器指令的比较+跳转指令组合。对于 J 系列的跳转指令,实际上会转换为 Jxx + CMP 模式,而对于 B 系列的跳转指令,则直接转换成指令本身。
比如:
def : Pat<(brcond (i32 (setne RC:$lhs, RC:$rhs)), bb:$dst), (JNEOp (CMPOp RC:$lhs, RC:$rhs), bb:$dst)>;
def : Pat<(brcond (i32 (setne RC:$lhs, RC:$rhs)), bb:$dst), (BNEOp RC:$lhs, RC:$rhs, bb:$dst)>;
需要留意的一个是,无法从 C 语言生成 setueq 和 setune 指令,所以实际上并不会对其做选择(不过考虑到不要过分依赖前端,还是实现了)。
3)Cpu0MCInstLower.cpp
因为跳转的地址既可以是跳转表偏移,也可以是一个 label,所以需要在 MachineOperand 这里对相关的类型做 lowering。在 LowerSymbolOperand() 函数中增加对 MO_MachineBasicBlock、MO_BlockAddress 和 MO_JumpTableIndex 类型的 lowering。
4)Cpu0MCCodeEmitter.h/cpp
实现地址操作数的编码实现函数,包括 getBranch16TargetOpValue(),getBranch24TargetOpValue() 和 getJumpTargetOpValue() 函数,对 JMP 指令同时还是表达式类型的跳转位置的情况,选择正确的 fixups,fixups 类型在 Cpu0FixupKinds.h 文件中定义。
5)Cpu0AsmPrinter.h/cpp
定义一个名为 isLongBranchPseudo() 的函数,用来判断指令是否是长跳转的伪指令。
同时在 EmitInstruction() 函数中增加当属于长跳转伪指令时,不发射该指令。
6)MCTargetDesc/Cpu0FixupKinds.h
添加重定位类型 fixup_Cpu0_PC16 和 fixup_Cpu0_PC24。
7)MCTargetDesc/Cpu0ELFObjectWriter.cpp
添加重定位类型的一些设置,在 getRelocType() 函数中增加内容。
8)MCTargetDesc/Cpu0AsmBackend.cpp
这里有个小的要点需要留意。Cpu0 的架构和其他 RISC 机器一样,采用五级流水线结构,跳转指令会在 decode 阶段实现跳转动作(也就是将 PC 修改为跳转后的位置),但跳转指令在 fetch 阶段时,PC 会自动先移动到下一条指令位置,fetch 阶段在 decode 阶段之前,所以实际上,在 decode 阶段执行前,PC 已经自动 +4 (一个指令长度),所以实际上跳转指令中的偏移,并不是从跳转指令到目标位置的差,而应该是跳转指令的下一条指令到目标位置的差。
比如说:
jne $BB0_2
jmp $BB0_1 # jne 指令 decode 之前,PC 指向这里
$BB0_1:
ld $4, 36($fp)
addiu $4, $4, 1
st $4, 36($fp)
jmp $BB0_2
$BB0_2:
ld $4, 32($fp) # jne 指令 decode 之后,假设 PC 指向这里
jne 指令中的偏移,应该是 jmp 指令到 最后一条 ld 指令之间的距离,也就是 20 (而不是 24)。
为了实现这样的修正,在 adjustFixupValue() 函数中,针对重定位类型 fixup_Cpu0_PC16 和 fixup_Cpu0_PC24,指定其 Value 应该在自身的基础上减 4。
3. 检验成果
编译提供的测试用例 ch7_1_controlflow.c,使用 Cpu032I 生成的汇编如:
...
cmp $sw, $3, $2
jne $sw, $BB0_2
jmp $BB0_1
$BB0_1:
ld $4, 4($sp)
addiu $4, $4, 1
st $4, 4($sp)
jmp $BB0_2
$BB0_2:
ld $2, 4($sp)
...
可见 Cpu032I 处理器使用 sw 寄存器和 J 系列跳转指令完成控制流操作。
使用 Cpu032II 生成的汇编如:
...
bne $2, $zero, $BB0_2
jmp $BB0_1
$BB0_1:
ld $4, 4($sp)
addiu $4, $4, 1
st $4, 4($sp)
jmp $BB0_2
$BB0_2:
ld $2, 4($sp)
Cpu032II 处理器使用 B 系列跳转指令完成控制流操作,指令数更少。
通过 Cpu032I 直接生成二进制代码:
build/bin/llc -march=cpu0 -mcpu=cpu032I -relocation-model=pic -filetype=obj ch7_1_controlflow.ll -o ch7_1_controlflow.o
hexdump ch7_1_controlflow.o
通过 hexdump 可以将二进制代码输出到终端。从其中找到 31 00 00 14 36 00 00 00 这段编码,31 是 jne 指令,36 是 jmp 指令,14 是 偏移的编码,可见这里偏移是 20,说明 Cpu0AsmBackend.cpp 中的设计生效了。
消除无用的 JMP 指令
LLVM 的大多数优化操作都是在中端完成,也就是在 LLVM IR 下完成。除了中端优化以外,其实还有一些依赖于后端特性的优化在后端完成。比如说,Mips 机器中的填充延迟槽优化,就是针对 RISC 下的 pipeline 优化。如果后端是一个带有延迟槽的 pipeline RISC 机器,那么也可以使用 Mips 的这一套优化。
这一小节,实现一个简单的后端优化,叫做消除无用的 JMP 指令。这个算法简单且高效,可以作为一个优化的教程来学习,通过学习,也可以了解如何新增一个优化 pass,以及如何在真实的工程中编写复杂的优化算法。
1. 简要说明
对于如下汇编指令:
jmp $BB_0
$BB_0:
... other instructions
在 jmp 指令的下一条指令,就是 jmp 指令需要跳转的 BasicBlock 块,因为 jmp 指令是无条件跳转,所以这里的控制流必然会做顺序执行,进而可以明确这里的 jmp 指令是多余的,即使删掉这条 jmp 指令,程序流也一样可以执行正确。
所以,目的就是识别这种模式,并删除对应的 jmp 指令。
2. 文件修改
1)CMakeLists.txt
添加新文件 Cpu0DelUselessJMP.cpp。
2)Cpu0.h
声明这个 pass 的工厂函数。
3)Cpu0TargetMachine.cpp
覆盖 addPreEmitPass() 函数,在其中添加 pass。调用这个函数表示 pass 会在代码发射之前被执行。
3. 文件新增
1)Cpu0DelUselessJMP.cpp
这是实现该优化 pass 的具体代码。有几个具体要留意的点:
代码:
#define DEBUG_TYPE "del-jmp"
...
LLVM_DEBUG(dbgs() << "debug info");
这里是为优化 pass 添加一个调试宏,这样可以通过在执行编译命令时,指定该调试宏来打印出想要的调试信息。注意需要以 debug 模式来编译编译器,并且在执行编译命令时,指定参数:
llc -debug-only=del-jmp
或直接打开所有调试信息:
llc -debug
其次,代码:
STATISTIC(NumDelJmp, "Number of useless jmp deleted");
表示定义了一个全局变量 NumDelJmp,可以允许在执行编译命令时,当执行完毕时,打印出这个变量的值。这个变量的作用是统计这个优化 pass 一共消除了多少的无用 jmp 指令,变量的累加是在实现该 pass 的逻辑中手动设计进去的。
在执行编译命令时,指定参数:
llc -stats
就可以打印出所有的统计变量的值。
其次,代码:
static cl::opt<bool> EnableDelJmp(
...
...
);
2)新增代码解析
这部分代码是向 LLVM 注册了一个编译参数,参数名称是这里第一个元素,还指定了参数的默认值,描述信息等。使用参数名为:enable-cpu0-del-useless-jmp,默认是打开的。这就是说,如果指定了这个参数,并且令其值为 false,则会关闭这个优化 pass。
具体的实现代码中,继承了 MachineFunctionPass 类,并在 runOnMachineFunction 中重写了逻辑,这个函数会在每次进入一个新的 Function 时被执行。在内部逻辑中调用了 runOnMachineBasicBlock 函数,同理,这个函数在每进入一个新的 BasicBlock 时被执行。
基本思路是,在每个函数中遍历每一个基本块,直接取其最后一条指令,判断是否为 jmp 指令,如果是,再判断这条指令指向的基本块是否是下一个基本块。如果都满足,则调用 MBB.erase(I) 删除 I 指向的指令(jmp 指令)并且累加 NumDelJmp 变量。
4. 检验成果
执行提供的测试用例:ch7_2_deluselessjmp.cpp
build/bin/llc -march=cpu0 -relocation-model=static -filetype=asm -stats ch7_2_deluselessjmp.ll -o -
查看输出汇编,会发现已经没有 jmp 指令,输出 statistics 信息中 8 del-jmp 告诉删除了 8 条无用的 jmp 指令。可以关闭这个优化再查看汇编(添加 -enable-cpu0-del-useless-jmp=false),两次结果做对比。