指令选择和指令调度是编译器后端的两个关键阶段——指令选择决定”用什么指令”,指令调度决定”指令按什么顺序执行”。这两个阶段直接影响生成代码的质量和性能。
接下来拆解指令选择与调度——DAG 覆盖如何选择最优指令?LLVM 的 TableGen 如何描述指令集?指令调度如何利用处理器流水线?
一、指令选择的任务
1.1 从 IR 到机器指令
指令选择的核心任务:
- 模式匹配:将 IR 操作匹配到目标指令模式
- 代价评估:选择代价最小的指令序列
- 特殊指令利用:利用目标特有的指令(如 x86 的 LEA)
1.2 指令选择的挑战
| 挑战 | 说明 | 示例 |
|---|---|---|
| 多对多映射 | 一个 IR 操作可能对应多条机器指令 | add → ADD/LEA/INC |
| 目标特有指令 | 不同架构有不同特有指令 | x86 LEA, ARM ADD with shift |
| 指令约束 | 某些指令有固定寄存器要求 | x86 IDIV 固定使用 RDX |
| 代价模型 | 不同指令延迟和吞吐量不同 | LEA vs ADD+MOV |
二、指令选择方法
2.1 方法分类
2.2 DAG 覆盖
DAG 覆盖是 LLVM 使用的指令选择方法——它将基本块的 IR 表示为 DAG,然后用模式匹配找到最优的指令覆盖:
class DAGISel: """基于 DAG 的指令选择"""
def select(self, dag): """对 DAG 进行指令选择""" result = []
for node in dag.topological_order(): # 尝试匹配模式 matched = self._match_patterns(node)
if matched: # 选择代价最小的匹配 best = min(matched, key=lambda m: m.cost) result.append(best.instruction) else: # 无匹配,使用默认选择 result.append(self._default_select(node))
return result
def _match_patterns(self, node): """尝试所有模式匹配""" matches = [] for pattern in self.patterns: if pattern.match(node): matches.append(pattern) return matches2.3 模式匹配示例
// LLVM TableGen 中的指令模式// 匹配:add %reg, imm → ADDri 指令def : Pat<(add GPR:$src, imm:$imm), (ADDri GPR:$src, imm:$imm)>;
// 匹配:add %reg, %reg → LEA 指令(x86 特有优化)def : Pat<(add GPR:$a, GPR:$b), (LEA32r GPR:$a, 1, GPR:$b, 0)>;
// 匹配:mul %reg, 2^k → shift 指令def : Pat<(mul GPR:$src, PowerOf2Imm:$imm), (SHLri GPR:$src, (Log2 Imm:$imm))>;2.4 指令选择方法对比
| 方法 | 原理 | 优点 | 缺点 | 使用者 |
|---|---|---|---|---|
| 宏展开 | 1:1 替换 | 简单 | 不优化 | 早期编译器 |
| DAG 覆盖 | 模式匹配+代价 | 质量好 | 复杂 | LLVM |
| 树重写 | 树模式匹配 | 直观 | 不处理 DAG | BURG |
| 动态规划 | 最优覆盖 | 全局最优 | 编译慢 | 研究用 |
三、LLVM TableGen
3.1 TableGen 的作用
TableGen 是 LLVM 的指令集描述语言——它用声明式的方式描述目标架构的指令、寄存器、调用约定等,然后自动生成 C++ 代码:
3.2 TableGen 示例
// x86 寄存器定义def RAX : X86Reg<"rax", 0>;def RBX : X86Reg<"rbx", 3>;def RCX : X86Reg<"rcx", 1>;def RDX : X86Reg<"rdx", 2>;
// 寄存器类def GR64 : RegisterClass<"X86", [i64], 64, (add RAX, RCX, RDX, RBX, RSI, RDI, R8, R9, R10, R11, R12, R13, R14, R15)>;
// 指令定义def ADD64rr : I<0x01, MRMDestReg, (outs GR64:$dst), (ins GR64:$src1, GR64:$src2), "add{q}\t{$src2, $dst|$dst, $src2}", [(set GR64:$dst, (add GR64:$src1, GR64:$src2))]>;
// 指令模式(用于指令选择)def : Pat<(add GR64:$src1, i64immSExt32:$imm), (ADD64ri32 GR64:$src1, i64immSExt32:$imm)>;3.3 TableGen 的核心概念
| 概念 | 说明 | 示例 |
|---|---|---|
| Def | 定义一个记录 | def RAX : X86Reg<"rax", 0>; |
| Class | 定义模板 | class X86Reg<string n, int i> { ... } |
| Let | 修改字段 | let isReMaterializable = 1 in { ... } |
| Multiclass | 定义多个相关记录 | multiclass ArithBinOp<string opc> { ... } |
| Pattern | 指令选择模式 | def : Pat<(add ...), (ADDrr ...)>; |
四、指令调度
4.1 为什么需要指令调度?
现代处理器是流水线和超标量的——指令的执行顺序影响性能:
4.2 指令调度的目标
| 目标 | 说明 | 优先级 |
|---|---|---|
| 最小化执行时间 | 减少流水线停顿 | 最高 |
| 最小化代码体积 | 减少指令缓存压力 | 中 |
| 满足约束 | 寄存器约束、指令约束 | 必须 |
4.3 调度算法
class ListScheduler: """列表调度算法"""
def __init__(self, latency_table): self.latency = latency_table
def schedule(self, dag): """对指令 DAG 进行调度""" ready = [] # 就绪指令(所有前驱已完成) scheduled = [] # 已调度指令 remaining = set(dag.nodes)
# 计算每个节点的优先级(关键路径长度) priority = self._compute_priority(dag)
# 初始化就绪列表 for node in dag.nodes: if not dag.predecessors(node): ready.append(node)
cycle = 0 while ready or remaining: # 按优先级排序就绪列表 ready.sort(key=lambda n: priority[n], reverse=True)
# 选择最高优先级的指令 if ready: node = ready.pop(0) scheduled.append((cycle, node)) remaining.remove(node)
# 更新后继的就绪状态 for succ in dag.successors(node): if all(pred in [n for _, n in scheduled] for pred in dag.predecessors(succ)): ready.append(succ)
cycle += 1
return scheduled
def _compute_priority(self, dag): """计算每个节点的关键路径长度""" priority = {} for node in reversed(list(dag.topological_order())): if not dag.successors(node): priority[node] = self.latency.get(node.op, 1) else: priority[node] = max( priority[s] + self.latency.get(node.op, 1) for s in dag.successors(node) ) return priority4.4 调度策略对比
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 列表调度 | 贪心选择关键路径最长的就绪指令 | 简单高效 | 非全局最优 |
| 关键路径调度 | 优先调度关键路径上的指令 | 减少总延迟 | 可能增加代码体积 |
| 寄存器感知调度 | 考虑寄存器压力 | 减少溢出 | 可能增加延迟 |
| 软件流水 | 重叠不同迭代的指令 | 循环优化好 | 实现复杂 |
五、软件流水线
5.1 基本原理
软件流水线将循环的不同迭代的指令重叠执行,类似于硬件流水线:
// 原始循环for (int i = 0; i < n; i++) { a[i] = b[i] * c[i]; // load b, load c, mul, store a}
// 软件流水后// 预热阶段load b[0]; load c[0];// 内核阶段(每次迭代完成一个完整的操作)for (int i = 0; i < n-1; i++) { mul b[i]*c[i]; // 当前迭代的乘法 store a[i]; // 前一次迭代的存储 load b[i+1]; // 下一次迭代的加载 load c[i+1]; // 下一次迭代的加载}// 排空阶段mul b[n-1]*c[n-1]; store a[n-1];5.2 软件流水 vs 循环展开
| 特性 | 循环展开 | 软件流水 |
|---|---|---|
| 原理 | 复制循环体 | 重叠不同迭代 |
| 寄存器压力 | 高 | 更高 |
| 代码体积 | 大 | 中 |
| 延迟隐藏 | 有限 | 更好 |
| 实现复杂度 | 低 | 高 |
| 适用场景 | 通用 | 深流水线处理器 |
六、指令选择与调度的协同
6.1 后端 Pipeline
6.2 两次调度的必要性
| 阶段 | 目标 | 为什么需要 |
|---|---|---|
| 前RA调度 | 减少延迟,降低寄存器压力 | 为寄存器分配创造条件 |
| 后RA调度 | 处理溢出指令,微调顺序 | 溢出引入了新的延迟 |
LLVM 在寄存器分配前后各执行一次指令调度。前RA调度主要关注延迟隐藏,后RA调度主要处理溢出引入的 load/store 延迟。两次调度协同工作,生成高质量的机器码。
七、不同架构的指令选择差异
7.1 x86 vs ARM vs RISC-V
| 特性 | x86-64 | AArch64 | RISC-V |
|---|---|---|---|
| 指令长度 | 可变 (1-15 字节) | 固定 (4 字节) | 固定 (4/2 字节) |
| 寻址模式 | 丰富 | 中等 | 简单 |
| 特殊指令 | LEA, BMI, AVX | NEON, SVE | 向量扩展 |
| 条件执行 | 条件码 | 条件选择 (CSEL) | 无(需分支) |
| 指令数量 | ~1500 | ~1000 | ~100 (基础) |
7.2 x86 LEA 指令的妙用
// LEA (Load Effective Address) 本来用于地址计算// 但编译器经常用它做算术运算——它不设置标志位,可以与算术指令并行
// a + b → LEA rax, [rdi + rsi]// a + b + 1 → LEA rax, [rdi + rsi + 1]// a * 2 + b → LEA rax, [rdi + rsi*2]// a * 4 + b + 10 → LEA rax, [rdi + rsi*4 + 10]八、动手实践
8.1 实验一:观察指令选择
cat > isel_test.c << 'EOF'int add_imm(int x) { return x + 10; }int add_reg(int x, int y) { return x + y; }int mul_shift(int x) { return x * 8; }int complex_addr(int* p, int i) { return p[i + 1]; }EOF
clang -O2 -S isel_test.c -o isel_test.scat isel_test.s | grep -E "(add|lea|shl|mov)" | head -208.2 实验二:LLVM 指令选择调试
clang -O2 -emit-llvm -S isel_test.c -o isel_test.llllc -debug-only=isel isel_test.ll -o isel_test.s 2>&1 | head -508.3 实验三:对比不同架构的指令选择
# x86-64clang -O2 -S --target=x86_64 isel_test.c -o x86.s
# AArch64clang -O2 -S --target=aarch64 isel_test.c -o arm.s
# RISC-Vclang -O2 -S --target=riscv64 isel_test.c -o riscv.s
# 对比diff x86.s arm.s九、SelectionDAG vs GlobalISel
9.1 SelectionDAG 的局限
LLVM 长期以来使用 SelectionDAG 作为指令选择框架,但它有几个根本性的问题:
| 问题 | 说明 | 影响 |
|---|---|---|
| 类型合法化 | SelectionDAG 要求所有类型在指令选择前合法化 | 增加不必要的变换步骤 |
| DAG 构造开销 | 每个基本块构建一个 DAG | 内存和时间开销大 |
| 调试困难 | DAG → MachineInstr 的映射不直观 | 难以追踪指令来源 |
| 扩展性差 | 添加新目标需要大量 TableGen 规则 | 后端开发门槛高 |
9.2 GlobalISel 的设计
GlobalISel 是 LLVM 的新一代指令选择框架,直接在 MachineInstr 层面工作:
GlobalISel 的四个阶段各有明确职责:
| 阶段 | 输入 | 输出 | 核心工作 |
|---|---|---|---|
| IRTranslator | LLVM IR | 通用 MIR (GMIR) | 1:1 翻译,不优化 |
| Legalizer | GMIR | 合法 GMIR | 类型/操作合法化 |
| RegBankSelect | 合法 GMIR | 带寄存器组 GMIR | 选择 GPR/FPR/VR |
| InstructionSelect | 带寄存器组 GMIR | 目标 MIR | 模式匹配选择目标指令 |
9.3 SelectionDAG vs GlobalISel 对比
| 维度 | SelectionDAG | GlobalISel |
|---|---|---|
| 工作层次 | DAG | MachineInstr |
| 类型合法化 | DAG 层 | 独立 Legalizer Pass |
| 寄存器组选择 | 隐含在指令选择中 | 独立 RegBankSelect Pass |
| 编译速度 | 慢(DAG 构造开销) | 快(直接操作 MIR) |
| 代码质量 | 高(成熟优化) | 中等(仍在改进) |
| 调试体验 | 差 | 好(每步可独立观察) |
| 成熟度 | 生产级 | AArch64 生产级,其他架构开发中 |
// GlobalISel 的 GMIR 示例// LLVM IR: %result = add i32 %a, %b//// GMIR (IRTranslator 输出):// %1:_(s32) = COPY $w0 ; 参数 a// %2:_(s32) = COPY $w1 ; 参数 b// %3:_(s32) = G_ADD %1, %2 ; 通用加法// $w0 = COPY %3 ; 返回值//// 目标 MIR (InstructionSelect 输出):// %1:gpr32 = COPY $w0// %2:gpr32 = COPY $w1// %3:gpr32 = ADDWrr %1, %2// $w0 = COPY %3GlobalISel 并非要一夜之间替代 SelectionDAG——LLVM 的策略是在每个目标架构上逐步启用 GlobalISel。AArch64 已经在生产环境使用 GlobalISel,x86 仍在开发中。你可以通过 -global-isel 标志启用,用 -global-isel-abort=0 在失败时回退到 SelectionDAG。
十、流水线冒险与 ILP 利用
10.1 流水线冒险类型
现代处理器的流水线深度可达 15-30 级,指令调度需要处理三类冒险:
| 冒险类型 | 原因 | 硬件解决 | 软件解决 |
|---|---|---|---|
| RAW | 真数据依赖 | 转发(Forwarding) | 指令调度填充延迟槽 |
| WAR | 反依赖 | 寄存器重命名 | SSA 构造消除 |
| WAW | 输出依赖 | 寄存器重命名 | SSA 构造消除 |
| 控制冒险 | 分支方向不确定 | 分支预测 | 基本块布局优化 |
| 结构冒险 | 功能单元冲突 | 资源复制 | 调度避免冲突 |
10.2 ILP(指令级并行)
ILP 是处理器在不改变程序语义的前提下,同时执行多条指令的能力:
# ILP 利用示例# 原始代码(串行依赖)a = b + c # 指令 1d = a + e # 指令 2(依赖指令 1 的结果)f = g + h # 指令 3(与指令 1/2 无关)
# 调度后(利用 ILP)a = b + c # 周期 0 发射f = g + h # 周期 0 发射(与指令 1 无关,可并行)d = a + e # 周期 1 发射(等指令 1 结果)| ILP 技术 | 原理 | 编译器角色 |
|---|---|---|
| 指令调度 | 重排指令消除停顿 | 列表调度、软件流水 |
| 循环展开 | 增大指令窗口 | 展开因子选择 |
| 软件流水 | 重叠迭代执行 | 模调度算法 |
| VLIW | 编译器显式并行 | 指令打包 |
| SIMD | 数据级并行 | 向量化 |
10.3 处理器延迟表
指令调度依赖精确的延迟表——不同指令的执行延迟差异很大:
| 指令(x86-64) | 延迟(周期) | 吞吐量 | 说明 |
|---|---|---|---|
| ADD/SUB | 1 | 4/周期 | 简单整数运算 |
| IMUL | 3 | 1/周期 | 整数乘法 |
| IDIV | 20-40 | 1/20-40周期 | 整数除法,极慢 |
| LOAD (L1) | 4-5 | 2/周期 | L1 缓存命中 |
| LOAD (L2) | 12-15 | 2/周期 | L2 缓存命中 |
| LOAD (内存) | 200+ | — | 主存访问 |
| FP ADD | 4 | 2/周期 | 浮点加法 |
| FP MUL | 5 | 2/周期 | 浮点乘法 |
| FP DIV | 10-20 | 1/10-20周期 | 浮点除法 |
延迟表是调度器最重要的输入——错误的延迟信息会导致调度器做出错误的决策。LLVM 通过 TargetSchedInfo 描述每个目标的延迟表,TableGen 中的 SchedReadWrite 定义指令的调度属性。不同微架构(如 Skylake vs Zen 4)的延迟不同,编译器需要针对具体微架构调度。
十一、本章小结
在上一章中,图着色算法将虚拟寄存器映射到物理寄存器——但溢出处理告诉我们,物理寄存器终究是有限的。当寄存器分配完成后,每个虚拟寄存器都有了归宿,接下来的问题是:这些寄存器上的值,该用什么机器指令来计算?这就是指令选择与调度的任务。
| 概念 | 要点 |
|---|---|
| 指令选择 | IR 操作映射到目标指令,模式匹配+代价评估 |
| DAG 覆盖 | LLVM 使用的方法,基于 DAG 的模式匹配 |
| GlobalISel | LLVM 新一代指令选择框架,直接在 MIR 层工作 |
| TableGen | LLVM 的指令集描述语言,自动生成 C++ 代码 |
| 指令调度 | 安排指令执行顺序,减少流水线停顿 |
| 列表调度 | 贪心算法,优先调度关键路径最长的就绪指令 |
| 软件流水 | 重叠不同迭代的指令,隐藏延迟 |
| 两次调度 | 前RA调度减少延迟,后RA调度处理溢出 |
| 流水线冒险 | RAW/WAR/WAW 数据冒险、控制冒险、结构冒险 |
| ILP 利用 | 指令调度、循环展开、软件流水、向量化 |
| 架构差异 | x86 复杂(LEA等),RISC 简洁 |
用了整章的篇幅拆解指令选择与调度。下一章进入 代码生成与链接,看看机器指令如何被组织为可执行文件。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






