mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2574 字
7 分钟
指令选择与调度
2026-01-21

指令选择和指令调度是编译器后端的两个关键阶段——指令选择决定”用什么指令”,指令调度决定”指令按什么顺序执行”。这两个阶段直接影响生成代码的质量和性能。

接下来拆解指令选择与调度——DAG 覆盖如何选择最优指令?LLVM 的 TableGen 如何描述指令集?指令调度如何利用处理器流水线?

一、指令选择的任务#

1.1 从 IR 到机器指令#

flowchart LR IR["LLVM IR<br/>%1 = add i32 %a, 10"] -->|指令选择| SEL["多条候选指令"] SEL --> C1["add rax, 10"] SEL --> C2["lea rax, [rdi+10]"] SEL --> C3["mov rax, rdi<br/>add rax, 10"] C1 -->|选择最优| BEST["add rax, 10 "] style IR fill:#e3f2fd,stroke:#1565c0 style BEST fill:#e8f5e9,stroke:#2e7d32

指令选择的核心任务:

  1. 模式匹配:将 IR 操作匹配到目标指令模式
  2. 代价评估:选择代价最小的指令序列
  3. 特殊指令利用:利用目标特有的指令(如 x86 的 LEA)

1.2 指令选择的挑战#

挑战说明示例
多对多映射一个 IR 操作可能对应多条机器指令addADD/LEA/INC
目标特有指令不同架构有不同特有指令x86 LEA, ARM ADD with shift
指令约束某些指令有固定寄存器要求x86 IDIV 固定使用 RDX
代价模型不同指令延迟和吞吐量不同LEA vs ADD+MOV

二、指令选择方法#

2.1 方法分类#

graph TB ISEL["指令选择方法"] ISEL --> MACRO["宏展开<br/>简单替换"] ISEL --> DAG["DAG 覆盖<br/>模式匹配+代价"] ISEL --> GREEDY["贪心选择<br/>局部最优"] ISEL --> DP["动态规划<br/>全局最优"] style ISEL fill:#e3f2fd,stroke:#1565c0 style DAG fill:#e8f5e9,stroke:#2e7d32

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 matches

2.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
树重写树模式匹配直观不处理 DAGBURG
动态规划最优覆盖全局最优编译慢研究用

三、LLVM TableGen#

3.1 TableGen 的作用#

TableGen 是 LLVM 的指令集描述语言——它用声明式的方式描述目标架构的指令、寄存器、调用约定等,然后自动生成 C++ 代码:

flowchart LR TD["TableGen (.td)<br/>指令集描述"] -->|tblgen| CPP["C++ 代码<br/>指令选择器"] TD -->|tblgen| ASM["汇编器<br/>反汇编器"] TD -->|tblgen| DOC["文档<br/>寄存器描述"] style TD fill:#e3f2fd,stroke:#1565c0 style CPP fill:#e8f5e9,stroke:#2e7d32

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 为什么需要指令调度?#

现代处理器是流水线超标量的——指令的执行顺序影响性能:

flowchart LR subgraph 优化前["优化前:依赖导致停顿"] I1["load r1, [a]"] --> STALL["停顿 4 周期"] --> I2["add r2, r1, 1"] end subgraph 优化后["优化后:填充无关指令"] I3["load r1, [a]"] --> I4["mul r3, r4, r5"] I4 --> I5["add r2, r1, 1"] end style 优化前 fill:#fce4ec,stroke:#c62828 style 优化后 fill:#e8f5e9,stroke:#2e7d32

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 priority

4.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#

flowchart TB IR["LLVM IR"] --> ISEL["指令选择<br/>DAG → MachineInstr"] ISEL --> SCHED1["指令调度(前RA)<br/>减少延迟"] SCHED1 --> RA["寄存器分配<br/>虚拟→物理"] RA --> SCHED2["指令调度(后RA)<br/>处理溢出"] SCHED2 --> EMIT["代码发射<br/>MachineInstr → 汇编"] style IR fill:#e3f2fd,stroke:#1565c0 style ISEL fill:#fff3e0,stroke:#e65100 style RA fill:#fce4ec,stroke:#c62828 style EMIT fill:#e8f5e9,stroke:#2e7d32

6.2 两次调度的必要性#

阶段目标为什么需要
前RA调度减少延迟,降低寄存器压力为寄存器分配创造条件
后RA调度处理溢出指令,微调顺序溢出引入了新的延迟
Note

LLVM 在寄存器分配前后各执行一次指令调度。前RA调度主要关注延迟隐藏,后RA调度主要处理溢出引入的 load/store 延迟。两次调度协同工作,生成高质量的机器码。

七、不同架构的指令选择差异#

7.1 x86 vs ARM vs RISC-V#

特性x86-64AArch64RISC-V
指令长度可变 (1-15 字节)固定 (4 字节)固定 (4/2 字节)
寻址模式丰富中等简单
特殊指令LEA, BMI, AVXNEON, 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.s
cat isel_test.s | grep -E "(add|lea|shl|mov)" | head -20

8.2 实验二:LLVM 指令选择调试#

clang -O2 -emit-llvm -S isel_test.c -o isel_test.ll
llc -debug-only=isel isel_test.ll -o isel_test.s 2>&1 | head -50

8.3 实验三:对比不同架构的指令选择#

# x86-64
clang -O2 -S --target=x86_64 isel_test.c -o x86.s
# AArch64
clang -O2 -S --target=aarch64 isel_test.c -o arm.s
# RISC-V
clang -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 层面工作:

flowchart TB IR2["LLVM IR"] --> IRTRANS["IRTranslator<br/>IR → GMIR<br/>通用 MachineIR"] IRTRANS --> LEGAL["Legalizer<br/>类型/操作合法化"] LEGAL --> REGBANK["RegBankSelect<br/>寄存器组选择"] REGBANK --> ISEL3["InstructionSelect<br/>目标指令选择"] ISEL3 --> MIR2["MachineInstr<br/>目标特定"] style IR2 fill:#e3f2fd,stroke:#1565c0 style MIR2 fill:#e8f5e9,stroke:#2e7d32

GlobalISel 的四个阶段各有明确职责:

阶段输入输出核心工作
IRTranslatorLLVM IR通用 MIR (GMIR)1:1 翻译,不优化
LegalizerGMIR合法 GMIR类型/操作合法化
RegBankSelect合法 GMIR带寄存器组 GMIR选择 GPR/FPR/VR
InstructionSelect带寄存器组 GMIR目标 MIR模式匹配选择目标指令

9.3 SelectionDAG vs GlobalISel 对比#

维度SelectionDAGGlobalISel
工作层次DAGMachineInstr
类型合法化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 %3
Note

GlobalISel 并非要一夜之间替代 SelectionDAG——LLVM 的策略是在每个目标架构上逐步启用 GlobalISel。AArch64 已经在生产环境使用 GlobalISel,x86 仍在开发中。你可以通过 -global-isel 标志启用,用 -global-isel-abort=0 在失败时回退到 SelectionDAG。

十、流水线冒险与 ILP 利用#

10.1 流水线冒险类型#

现代处理器的流水线深度可达 15-30 级,指令调度需要处理三类冒险:

flowchart TB HAZARD["流水线冒险"] --> DATA["数据冒险<br/>RAW/WAR/WAW"] HAZARD --> CTRL["控制冒险<br/>分支预测失败"] HAZARD --> STRUCT["结构冒险<br/>资源冲突"] DATA --> RAW["RAW (Read After Write)<br/>最常见<br/>真依赖"] DATA --> WAR["WAR (Write After Read)<br/>反依赖<br/>SSA 消除"] DATA --> WAW["WAW (Write After Write)<br/>输出依赖<br/>SSA 消除"] style HAZARD fill:#e3f2fd,stroke:#1565c0 style RAW fill:#fce4ec,stroke:#c62828
冒险类型原因硬件解决软件解决
RAW真数据依赖转发(Forwarding)指令调度填充延迟槽
WAR反依赖寄存器重命名SSA 构造消除
WAW输出依赖寄存器重命名SSA 构造消除
控制冒险分支方向不确定分支预测基本块布局优化
结构冒险功能单元冲突资源复制调度避免冲突

10.2 ILP(指令级并行)#

ILP 是处理器在不改变程序语义的前提下,同时执行多条指令的能力:

# ILP 利用示例
# 原始代码(串行依赖)
a = b + c # 指令 1
d = 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/SUB14/周期简单整数运算
IMUL31/周期整数乘法
IDIV20-401/20-40周期整数除法,极慢
LOAD (L1)4-52/周期L1 缓存命中
LOAD (L2)12-152/周期L2 缓存命中
LOAD (内存)200+主存访问
FP ADD42/周期浮点加法
FP MUL52/周期浮点乘法
FP DIV10-201/10-20周期浮点除法
Warning

延迟表是调度器最重要的输入——错误的延迟信息会导致调度器做出错误的决策。LLVM 通过 TargetSchedInfo 描述每个目标的延迟表,TableGen 中的 SchedReadWrite 定义指令的调度属性。不同微架构(如 Skylake vs Zen 4)的延迟不同,编译器需要针对具体微架构调度。

十一、本章小结#

上一章中,图着色算法将虚拟寄存器映射到物理寄存器——但溢出处理告诉我们,物理寄存器终究是有限的。当寄存器分配完成后,每个虚拟寄存器都有了归宿,接下来的问题是:这些寄存器上的值,该用什么机器指令来计算?这就是指令选择与调度的任务。

概念要点
指令选择IR 操作映射到目标指令,模式匹配+代价评估
DAG 覆盖LLVM 使用的方法,基于 DAG 的模式匹配
GlobalISelLLVM 新一代指令选择框架,直接在 MIR 层工作
TableGenLLVM 的指令集描述语言,自动生成 C++ 代码
指令调度安排指令执行顺序,减少流水线停顿
列表调度贪心算法,优先调度关键路径最长的就绪指令
软件流水重叠不同迭代的指令,隐藏延迟
两次调度前RA调度减少延迟,后RA调度处理溢出
流水线冒险RAW/WAR/WAW 数据冒险、控制冒险、结构冒险
ILP 利用指令调度、循环展开、软件流水、向量化
架构差异x86 复杂(LEA等),RISC 简洁

用了整章的篇幅拆解指令选择与调度。下一章进入 代码生成与链接,看看机器指令如何被组织为可执行文件。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

指令选择与调度
https://blog.souloss.com/posts/compiler/instruction-selection-and-scheduling/
作者
Souloss
发布于
2026-01-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时