LLVM指令调度不改变程序语义,但会重排汇编指令顺序以提升流水线填充率和延迟隐藏效果;它在MachineInstr阶段主动穿插无依赖指令,结合目标CPU模型(如延迟、吞吐量、资源划分)优化执行序列,并与寄存器分配强耦合,直接影响性能。

LLVM 指令调度不会改变程序语义,但会显著改变汇编指令的物理顺序、寄存器使用节奏和资源分布——这种改变肉眼可见,且直接影响流水线填充率和延迟隐藏效果。
指令重排让无依赖指令“插队”执行
LLVM 调度器在生成 Machinelnstr 阶段,会主动打破源码或 IR 中的线性顺序,把不相关指令穿插进来。比如原始 C 循环中:
a = *p++; b = *q++; c = a + 1; d = b * 2;
对应 IR 可能生成连续的 load → add → load → mul,但调度后汇编常变成:
ldr x0, [x1], #8 ; load a, post-increment p ldr x2, [x3], #8 ; load b, post-increment q add x4, x0, #1 ; c = a + 1 mul x5, x2, x2 ; d = b * 2
关键点:
- 两个
ldr被放在一起,利于地址生成单元(AGU)复用 -
add和mul不等前一个ldr的结果就提前发射,只要它们不依赖该结果 - 如果目标 CPU 的 load 延迟是 4 周期,调度器会确保这 4 周期内至少填入 2–3 条独立计算指令
常见错误现象:你手动写内联汇编时按逻辑顺序排列,却发现性能不如编译器生成的“乱序”版本——本质是你没显式做延迟隐藏,而 LLVM 在寄存器分配后调度阶段(PostGenericScheduler)自动做了。
寄存器压力变化会触发 spill/reload 插入
调度不是孤立发生的,它和寄存器分配强耦合。寄存器分配前调度(GenericScheduler)会刻意拉平虚拟寄存器生命周期,避免尖峰压力;而分配后调度则接受已分配的物理寄存器,并据此调整指令顺序以减少 bank conflict 或 move 指令。
典型表现:
- 原本紧凑的向量计算块,可能被插入若干
mov或fmov指令来绕过寄存器 bank 限制(如 AArch64 的 V0–V7 与 V16–V31 分属不同物理 bank) - 在 Cortex-A76 等核上,若调度器发现连续使用
vadd.f32占满 FPU pipeline,会主动插入访存或整数指令来“腾出”执行单元 - 若某段代码寄存器需求超过物理限制,调度器可能提前触发
str/ldr对,把中间值暂存到栈——这在汇编里就是凭空多出来的内存操作
容易踩的坑:
- 用
-mllvm -unroll-threshold=0关闭循环展开后,调度器失去大量可重排空间,寄存器压力陡增,反而更容易 spill - 在
<strong>attribute</strong>((noinline))函数里观察汇编时,因缺少跨函数优化上下文,调度更保守,寄存器复用率低
不同 target-cpu 会导致同一段 IR 生成完全不同的汇编序列
LLVM 调度器依赖 .td 文件中定义的处理器模型(如 TSV110.td 或 CortexA76.td),这些模型描述了:
- 每个功能单元的吞吐量(如 L/S unit 每周期最多 1 条 load + 1 条 store)
- 指令延迟(
ldr到add使用其结果需 4 cycle) - 资源组划分(ALU、FPU、Crypto unit 是否共享发射端口)
所以:
- 编译时加
-mcpu=tsv110,LLVM 会避开ldp q0, q1, [x0](因 TSV110 对 128-bit load 吞吐差),改用两条ldr q0, [x0]+ldr q1, [x0, #16]并交错调度 - 而用
-mcpu=cortex-a76,它可能直接生成ldp q0, q1, [x0],并紧随其后调度 4 条独立的fmla指令来填满 4-cycle load 延迟窗口
性能影响很实在:在 TSV110 上,错用通用调度模型可能导致向量化循环性能下降 20% 以上,仅因 load 指令成了瓶颈。
调度带来的汇编变化,本质上是编译器在静态可知约束下,对硬件动态行为的一次“预演”。它不保证最优,但比纯线性生成靠谱得多——尤其当你面对的是没有乱序执行能力的 in-order 核(如 Cortex-R52、某些 RISC-V embedded core),此时 LLVM 的调度输出,几乎就等于最终性能。

















