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 出场修复!
  1. enterBasicBlock 时,对应 LiveRegs 会 merge 两个前驱的 DomainValue,取交集(如果无交集,则先 force 或者 collapse)
  2. 在处理 add rax, rcx 时,通过 visitHardInstr 强制 rax 落在整数域;
  3. 如果之前在 SSE 域写过,就在此处插入域切换(collapse),并调用 TII->setExecutionDomain 更新机器指令为正确的整数执行编码。
    再来打一个比方:
  • 执行域就像“不同规格的车道”
想象一条道路有两种不同宽度的车道:窄的只能小轿车走,宽的只能大卡车走。
- 如果一辆车突然要从小轿车车道切换到大卡车车道,就得停下来换轮胎、改底盘,非常费时。
- 同理,处理器里的寄存器“执行域”也是不同硬件单元,例如整数单元、浮点单元、向量单元。
- 当你在同一个寄存器里,先做整数运算(小轿车道),又马上做向量运算(大卡车道),CPU 就得插入“域切换”指令,相当于“换轮胎”,会浪费时钟周期。
  • 为什么要“坚持在一个域”
    如果我们提前把所有需要在这个寄存器上做的运算,都在同一个域里完成——
    比如都在“整数域”里,用相同的硬件单元连续执行——
    那就不需要中途来回切换,执行会更连贯、更快。

个人理解:在超标量里面,要指派指令给哪个单元运算时,尽量让相同寄存器的前往同一个运算单元,否则就要切换域。即同一个寄存器的值不要一会去整型单元运算,一会去浮点那运算
因为:当一条指令用 A 域(比如整数 ALU)执行,下一条又要用 B 域(比如浮点单元)执行时,处理器必须在两条指令之间重新配置流水线
在 superscalar CPU 上,编译器在代码生成阶段需要给“同一寄存器的后续运算”分配执行域(Integer/FP/Vector 等),如果它们跨域,就会产生显著的管线清空或隐性切换惩罚;ExecutionDomainFix 的工作就是尽量让这些运算都在同一个域里完成,从而减少切换开销。

执行单元 (Carriage)

那么我们再来讲一下硬件执行单元。

  1. 整数单元(Integer ALU)
    只做整数加减、逻辑运算。无法执行浮点或向量指令。
  2. 浮点单元(FP ALU)
    专门处理标量浮点运算(如 fadd, fmul),不支持整数算术。
  3. 向量单元(SIMD/Vector)
    做并行向量浮点或整数运算(如 SSE/AVX),也不能跑纯标量整数指令。
  4. 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)。
源码解读

该 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;
}
  1. 初始化
    • 检查是否要跳过(skipFunction)。
    • 获取 TargetInstrInfo/RegisterInfo/ReachingDefAnalysis。
    • 根据寄存器类(RC)构建 AliasMap,为每个物理寄存器生成索引列表。
    • 准备 MBBOutRegsInfos 数组,用来存储每个块退出时的 LiveRegs 状态。
  2. 遍历基本块
    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);
}
  1. 进入基本块 (enterBasicBlock)
    • 如果是入口块,LiveRegs 全空。
    • 否则,从所有前驱块的 MBBOutRegsInfos[predID] 中取出 LiveRegsDVInfo,对每条寄存器的 DomainValue 做 合并(merge)强制/折叠(force/collapse),得到当前块入口的 LiveRegs。
  2. 逐指令处理
    对块内每条非调试指令调用两步:
    • visitInstr(MI)
auto DomP = TII->getExecutionDomain(*MI);
if (DomP.first)       // 有硬域 (HardDomain)
  visitHardInstr(MI, DomP.first);
else if (DomP.second) // 有软域 (SoftDomainMask)
  visitSoftInstr(MI, DomP.second);
  1. 硬域指令 (visitHardInstr):
    直接对所有定义/使用的寄存器做 kill + force(domain),保证这一条指令在合法的单一域中执行。
  • 软域指令 (visitSoftInstr):
  1. 读取指令的 mask(可选域集合),与当前 LiveRegs[rx]->AvailableDomains 求交集。
  2. 如果交集非空,延迟把指令加入对应的 DomainValue;否则 折叠(collapse)旧的 DomainValue 到某一域,再 alloc/force 新域。
  3. 把指令记录到 DomainValue:: Instrs 中,待后面必要时统一调用 TII->setExecutionDomain。
  • processDefs(MI, killOld)
    如果 visitInstr 返回 true(表示折叠了旧域),则对所有定义的寄存器执行 kill(rx),释放旧的 DomainValue。
  1. 离开基本块 (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) { … }
  1. 关键辅助操作

    • merge(A, B):合并两个开放域的 DomainValue,取交集、拼接指令列表,并将 B 链入 A。
    • collapse(dv, domain):把 dv 中累积的所有指令一次性标记为该 domain,并清空它的“开放”状态。
    • force(rx, domain):当一个寄存器当前 DomainValue 与新域不兼容时,先 collapse,再分配新的单一域给它。
流程小结
  1. 初始化:构建寄存器—域映射;
  2. 遍历基本块:按前驱完整性顺序;
  3. 合并前驱域:enterBasicBlock;
  4. 指令域调度:对每条指令 visitInstr(硬域/软域分别处理),processDefs;
  5. 记录&清理:leaveBasicBlock 存储活跃域信息;
  6. 辅助操作:merge/collapse/force 维护 DomainValue 的正确性。
    通过这套 “合并 → 延后记录 → 必要时折叠 → 强制” 的机制,ExecutionDomainFix 能在 Machine-Code 级别,最大限度地保持寄存器在同一执行域中运算,减少昂贵的域切换。