后端Pass简介——ExecutionDomainFix
ExecutionDomainFix
ExecutionDomainFix 是 LLVM 后端在生成目标机器代码时,用来 检测并修复“执行域”(execution domain)不一致 问题的一个 MachineFunctionPass。
背景
- 现代多域架构(如 x86 的整数域、SSE/AVX 浮点域、x87 浮点域等)对同一寄存器在不同执行单元间切换有较高的性能开销。
- 编译器在指令调度、寄存器分配后生成的机器指令序列,可能因为控制流合并、指令重排等原因,让同一个寄存器在多个域间来回“漂移”,导致额外的“域切换”指令或隐性开销。
- ExecutionDomainFix 作为一个 MachineFunctionPass,专门用来在 MachineInstr 级别 检测并修复这种跨域不一致问题,最大化利用硬件执行单元、避免不必要的域切换惩罚。
概念
- 执行域(Execution Domain):LLVM 中,一个指令可以在多个域(Domain)中执行。每个域对应目标机器的某种执行单元(如整数 ALU、浮点单元、向量单元等)。
- DomainValue:封装一个寄存器当前可能的域集合(
AvailableDomains
)和该寄存器所绑定、待更新的指令列表。 - LiveRegs:每个基本块里,对应机器寄存器索引的活跃
DomainValue*
数组,跟踪从前驱传递进来的域状态。 - 核心思想:
1. 跟踪:在遍历每条 MachineInstr 时,根据指令的“硬域”或“软域掩码”查询最优域信息。
2. 合并:在基本块入口,把多个前驱传来的域集合取交集(merge
);
3. 强制/折叠:当某个域不兼容时,调用collapse
把指令列表一次性设置到单一域,再force
新定义;
4. 更新:通过visitHardInstr
/visitSoftInstr
分别处理硬域(强制)和软域(尝试兼容合并)指令。举例说明
假设有一段 x86 机器指令,使用同一个物理寄存器 rax
:
; 前驱块1 在整数域写入 rax
mov rax, rbx ; integer domain
jmp merge
pred2:
; 前驱块2 在 SSE 浮点域写入 xmm0 -> rax
movq rax, xmm0 ; vector domain
jmp merge
merge:
; 汇合点:rax 被两个前驱不同域写过
add rax, rcx ; integer add
- 问题:rax 在汇合点前可能处于整数域或向量域,两者不一致。
所以就需要该 Pass 出场修复!
- enterBasicBlock 时,对应 LiveRegs 会 merge 两个前驱的 DomainValue,取交集(如果无交集,则先 force 或者 collapse);
- 在处理
add rax, rcx
时,通过 visitHardInstr 强制 rax 落在整数域; - 如果之前在 SSE 域写过,就在此处插入域切换(collapse),并调用 TII->setExecutionDomain 更新机器指令为正确的整数执行编码。
再来打一个比方:
- 执行域就像“不同规格的车道”
想象一条道路有两种不同宽度的车道:窄的只能小轿车走,宽的只能大卡车走。
- 如果一辆车突然要从小轿车车道切换到大卡车车道,就得停下来换轮胎、改底盘,非常费时。
- 同理,处理器里的寄存器“执行域”也是不同硬件单元,例如整数单元、浮点单元、向量单元。
- 当你在同一个寄存器里,先做整数运算(小轿车道),又马上做向量运算(大卡车道),CPU 就得插入“域切换”指令,相当于“换轮胎”,会浪费时钟周期。
- 为什么要“坚持在一个域”
如果我们提前把所有需要在这个寄存器上做的运算,都在同一个域里完成——
比如都在“整数域”里,用相同的硬件单元连续执行——
那就不需要中途来回切换,执行会更连贯、更快。
个人理解:在超标量里面,要指派指令给哪个单元运算时,尽量让相同寄存器的前往同一个运算单元,否则就要切换域。即同一个寄存器的值不要一会去整型单元运算,一会去浮点那运算。
因为:当一条指令用 A 域(比如整数 ALU)执行,下一条又要用 B 域(比如浮点单元)执行时,处理器必须在两条指令之间重新配置流水线
在 superscalar CPU 上,编译器在代码生成阶段需要给“同一寄存器的后续运算”分配执行域(Integer/FP/Vector 等),如果它们跨域,就会产生显著的管线清空或隐性切换惩罚;ExecutionDomainFix 的工作就是尽量让这些运算都在同一个域里完成,从而减少切换开销。
执行单元 (Carriage)
那么我们再来讲一下硬件执行单元。
- 整数单元(Integer ALU)
只做整数加减、逻辑运算。无法执行浮点或向量指令。 - 浮点单元(FP ALU)
专门处理标量浮点运算(如 fadd, fmul),不支持整数算术。 - 向量单元(SIMD/Vector)
做并行向量浮点或整数运算(如 SSE/AVX),也不能跑纯标量整数指令。 - Load-Store 单元(Memory Port)
专门负责读写内存,不能当算术单元用。
切换成本:当寄存器里的值从一种单元切换到另一种单元使用时,CPU 通常需要
- 刷写/重置流水线状态
- 等待数据从一个执行域“搬运”(域转换指令)
这些都会插入“气泡”,降低 IPC。
同一个寄存器里保存的那个“值”,被先后送到不同的执行单元去运算,就会产生“域切换”惩罚。
在 LLVM 的 MachineInstr 层面,每条指令都带有两种域掩码:
// TargetInstrInfo 定义的 API(伪码)
std::pair<uint16_t /*HardDomain*/, uint16_t /*SoftDomainMask*/>
getExecutionDomain(const MachineInstr &MI);
- HardDomain:该指令“必须”在这些域之一执行,其他域不合法。
- SoftDomainMask:指令“可选”在这些域中执行,LLVM 可以灵活调度到任何一个。
所以 LLVM 就会取掩码的交集,尝试使用 Soft 方式兼容,如果交集为空,就必须折叠。
对于 HardDomain 指令,LLVM 直接 kill 掉旧域并 force 到此指令的合法域,确保它走正确的单元。
再举一个例子:
; 1. 在整数域做一次加法
add_int rax, rbx ; HardDomain = {Integer}
; 2. 假设有条伪向量加法,SoftDomainMask = {Integer, Vector}
vadd_simd rax, rbx ; 可以在 Integer 也可以在 Vector
; 3. 再做一次浮点乘法(只在 FP 域合法)
fmul_fp rax, rcx ; HardDomain = {Floating-Point}
- 步骤 1:rax 的 AvailableDomains 从 ⌀ → {Integer}
- 步骤 2:SoftInstr,交集 {Integer} ∩ {Integer,Vector} = {Integer} → 继续用整型单元
- 步骤 3:交集 {Integer} ∩ {Floating-Point} = ⌀
- 交集为空,必须 collapse:
- 把之前存的 add_int/vadd_simd 都真正编码为整型域(无变化)。
- 然后 force 新的 fmul_fp 到 FP 域,在这里插入一次域切换惩罚(比如 pipeline flush)。
- 交集为空,必须 collapse:
源码解读
该 Pass 依赖 ReachingDefAnalysis
。
下面结合 ExecutionDomainFix.cpp 中的核心函数,梳理一下它在一个 MachineFunction 上的 大体算法流程。
bool ExecutionDomainFix::runOnMachineFunction(MachineFunction &mf) {
// 初始化 & 跳过不需要的函数
if (skipFunction(mf.getFunction())) return false;
// ……准备 TII、TRI、RDA、AliasMap、MBBOutRegsInfos……
// 遍历所有基本块(Loop-ordered)
for (auto &TraversedMBB : TraversalOrder)
processBasicBlock(TraversedMBB);
// 清理残留 DomainValue,返回
return false;
}
- 初始化
- 检查是否要跳过(skipFunction)。
- 获取 TargetInstrInfo/RegisterInfo/ReachingDefAnalysis。
- 根据寄存器类(RC)构建 AliasMap,为每个物理寄存器生成索引列表。
- 准备 MBBOutRegsInfos 数组,用来存储每个块退出时的 LiveRegs 状态。
- 遍历基本块
LLVM 使用 LoopTraversal 算法,先处理“无需全部前驱”的块,再处理汇合点,保证合并信息完整。
void ExecutionDomainFix::processBasicBlock(
const LoopTraversal::TraversedMBBInfo &T) {
enterBasicBlock(T);
for (MachineInstr &MI : *T.MBB) {
if (MI.isDebugInstr()) continue;
bool killOld = T.PrimaryPass ? visitInstr(&MI) : false;
processDefs(&MI, killOld);
}
leaveBasicBlock(T);
}
- 进入基本块 (enterBasicBlock)
- 如果是入口块,LiveRegs 全空。
- 否则,从所有前驱块的 MBBOutRegsInfos[predID] 中取出 LiveRegsDVInfo,对每条寄存器的 DomainValue 做 合并(merge) 或 强制/折叠(force/collapse),得到当前块入口的 LiveRegs。
- 逐指令处理
对块内每条非调试指令调用两步:- visitInstr(MI)
auto DomP = TII->getExecutionDomain(*MI);
if (DomP.first) // 有硬域 (HardDomain)
visitHardInstr(MI, DomP.first);
else if (DomP.second) // 有软域 (SoftDomainMask)
visitSoftInstr(MI, DomP.second);
- 硬域指令 (visitHardInstr):
直接对所有定义/使用的寄存器做 kill + force(domain),保证这一条指令在合法的单一域中执行。
- 软域指令 (visitSoftInstr):
- 读取指令的 mask(可选域集合),与当前 LiveRegs[rx]->AvailableDomains 求交集。
- 如果交集非空,延迟把指令加入对应的 DomainValue;否则 折叠(collapse)旧的 DomainValue 到某一域,再 alloc/force 新域。
- 把指令记录到 DomainValue:: Instrs 中,待后面必要时统一调用 TII->setExecutionDomain。
- processDefs(MI, killOld)
如果 visitInstr 返回 true(表示折叠了旧域),则对所有定义的寄存器执行 kill(rx),释放旧的 DomainValue。
- 离开基本块 (leaveBasicBlock)
- 将当前块末尾的 LiveRegs 拷贝到 MBBOutRegsInfos[MBB->getNumber()],以便后续合并。
- 释放这一时刻所有 DomainValue 的引用。
void ExecutionDomainFix::merge(DomainValue *A, DomainValue *B) { … }
void ExecutionDomainFix::collapse(DomainValue *dv, unsigned domain) { … }
void ExecutionDomainFix::force(int rx, unsigned domain) { … }
-
关键辅助操作
- merge(A, B):合并两个开放域的 DomainValue,取交集、拼接指令列表,并将 B 链入 A。
- collapse(dv, domain):把 dv 中累积的所有指令一次性标记为该 domain,并清空它的“开放”状态。
- force(rx, domain):当一个寄存器当前 DomainValue 与新域不兼容时,先 collapse,再分配新的单一域给它。
流程小结
- 初始化:构建寄存器—域映射;
- 遍历基本块:按前驱完整性顺序;
- 合并前驱域:enterBasicBlock;
- 指令域调度:对每条指令 visitInstr(硬域/软域分别处理),processDefs;
- 记录&清理:leaveBasicBlock 存储活跃域信息;
- 辅助操作:merge/collapse/force 维护 DomainValue 的正确性。
通过这套 “合并 → 延后记录 → 必要时折叠 → 强制” 的机制,ExecutionDomainFix 能在 Machine-Code 级别,最大限度地保持寄存器在同一执行域中运算,减少昂贵的域切换。
评论