写调度器的人和骂调度器的人,往往在同一个晚上失眠。我调到一条循环里的load延迟,性能反而掉了三个百分点,这种经历我太熟了。所以今天这份笔记,我打算换个讲法——不从LLVM源码的类继承关系开始,而是从"为什么指令顺序会要命"讲起,再落到LLVM到底给你留了哪些旋钮、哪些命令能让你看见调度器在想什么。这篇笔记更适合已经能跑通llc、看过一点SelectionDAG,但还没系统梳理过Instruction Scheduling这条线的读者。
1. 指令调度在LLVM里的位置:后端那几个Pass到底谁先谁后
每次有人问我"LLVM后端到底做了什么",我都建议先记住一句话:指令调度不是一个Pass,而是一组在不同阶段、以不同粒度和不同目标反复出现的操作。它贯穿了从目标无关的SelectionDAG到目标相关的MachineFunction,再到汇编输出前的多个环节。
先把这个地图画清楚。LLVM目标后端的编译流水线大致是:
- IR经过SelectionDAG构建,生成初始的目标无关DAG。
- 经历DAGCombine、LegalizeTypes、LegalizeOps等一系列Legalize和Combine步骤。
- 在SelectionDAG阶段做第一次指令选择(ISel),此时已经有一部分指令被选成目标机器指令,但还没有完全落到MachineInstr。
- 在SelectionDAG被转换为MachineInstr之后,进入MachineFunction层的Pass流水线。
- 这里有几个重要Pass会出场:MachineCSE、MachineSinking、MachineLICM,然后就是MachineScheduler(通常叫misched),以及后续的寄存器分配(Register Allocation)。
- 寄存器分配完成之后,还会有一轮PostRA调度(PostMachineScheduler),这是因为寄存器分配改变了指令的依赖关系——尤其是引入了大量的move指令和spill/reload。
- 最后是PEI、分支优化、汇编输出等。
这里要特别强调:SelectionDAG时代的调度(比如VLIW或特定目标自己在TargetLowering里做的调度)和MachineScheduler层面的调度,关注点完全不同。前者更多是配合指令选择和合法化的需求,后者才是真正的性能调度主战场。你在llc里看到的-pre-RA-sched、-misched这些选项,控制的就是这个层次。
从依赖粒度的角度讲,SelectionDAG里一条节点可能对应IR里的一个操作,而MachineScheduler面对的是已经展开的、包含具体寻址模式、立即数、标志寄存器依赖的机器指令序列。越往后,依赖关系越具体,调度器的约束也越强。
这张图对调试也很有用:如果你怀疑某个性能问题出在调度上,先确认它是在PreRA还是PostRA阶段,因为这两个阶段的调试命令完全不同。后面第4部分我会把具体命令列出来。
2. 为什么指令顺序会要命:现代CPU执行模型与依赖的三种类型
要理解LLVM的调度器做了什么,得先回到CPU本身。虽然我们平时写的是顺序代码,但现代高性能CPU大多是超标量、流水线、乱序执行的。乱序执行的核心是:CPU内部有一个指令窗口(比如Intel的ROB和保留站),它能分析指令之间的真实依赖,并让没有依赖的指令并行执行。
问题来了:既然CPU自己能乱序,编译器为什么还要做指令调度?
两个原因。
第一,CPU的乱序窗口有限。ROB和保留站的容量是固定的,几十条到几百条不等。如果编译器给出一段长依赖链,CPU能提前看的范围就那么大,根本没法从远处拉一条无关指令来填充流水线空档。这时候编译器必须先动手,把独立的指令插到延迟指令后面。
第二,有些CPU(尤其嵌入式领域的VLIW、DSP,还有部分ARM Cortex-A系列的特定配置)是静态调度的,它们完全依赖编译器排出好的顺序,硬件不做乱序或者做得非常有限。在这种目标上,编译器排出来的每一条指令顺序,直接决定最终性能。
而要调度,首先得识别依赖。LLVM的调度器里,依赖被分成三类:
- 数据依赖(真依赖,RAW):第二条指令要读第一条指令写的结果。这个是硬约束,无法消除,只能通过插入其他指令来降低stall。
- 反依赖(WAR):第二条指令要覆盖一个寄存器,而第一条指令还没读它。这类依赖可以通过寄存器重命名来消除——现代CPU都有这个机制,所以对乱序核心影响不大,但对静态调度的VLIW很要命。
- 输出依赖(WAW):两条指令写同一个寄存器。同理,现代CPU可以重命名寄存器来消除。
调度器的任务,就是在这些依赖关系的约束下,重新排列指令顺序,使得关键路径(critical path)上的延迟暴露得最少,同时尽量填充各执行单元的空闲槽。
我习惯用一个简单的例子说明。假设有四条指令:
A: load r1, [r0] ; 假设L1命中的延迟4个周期 B: add r2, r1, #1 ; 依赖A的结果 C: mul r3, r4, r5 ; 与A/B无关 D: str r3, [r6] ; 依赖C如果按照A、B、C、D的顺序发射,A的load结果要等4个周期,B在这4个周期里只能干等(当然CPU乱序执行会帮一点忙,但静态调度器就傻了)。一个合格的调度器会把C插到A和B之间:
A: load r1, [r0] C: mul r3, r4, r5 ; 与A无关,先填坑 B: add r2, r1, #1 ; 此时load结果已经回来 D: str r3, [r6]这样在等待load返回的期间,乘法指令已经执行完了。LLVM的list scheduler做的基本就是这个事,差别在于它要处理的依赖图有几百上千个节点,而且必须遵守目标机器的资源约束(发射端口、执行单元数量、流水线阶段等)。
这里还有一个概念必须提:调度宽度和窗口。LLVM的MachineScheduler是基于基本块(Basic Block)级别的,它不会跨分支做全局调度。也就是说,一条if-else的两个分支,调度器是分别处理的。循环体的软件流水(software pipelining)LLVM目前默认也没有大规模启用,更多是依赖LoopVectorize、以及目标后端的特定优化来间接改善循环调度。
还有一个常见的误解:调度一定提升性能?不一定。调度器有自己的代价模型,排不好反而会让代码变大、寄存器压力暴涨、icache miss增加。所以LLVM给了你很多开关来选择调度策略,这也是我要在下一节重点展开的。
3. LLVM的调度器家族:从默认的misched到PostRA到底谁在干活
LLVM的调度器不是一个文件、一个类,而是围绕MachineScheduler和ScheduleDAG建立的一套框架。上面层的调用关系,我建议你按"策略(Policy)、模型(MachineModel)、算法(Algorithm)"三层来理解。
3.1 策略层:-misched与调度器选择
LLVM默认的调度策略由目标后端通过TargetSubtargetInfo指定。大多数目标(X86、AArch64、RISCV)用的是MachineScheduler,其中核心的调度算法是list scheduling,再加上寄存器压力跟踪(RegPressureTracker)。
llc里有一组重要开关,你可以在命令行直接覆盖目标默认值:
-misched=default:目标默认的MachineScheduler。-misched=ilp:激进追求指令级并行(ILP),以牺牲寄存器压力为代价。-misched=regpressure:优先控制寄存器压力,可能牺牲一定并行度。-misched=source:尽量保持源码顺序,不做太多重排。-misched=shuffle:调试用的随机调度,用于对比验证。
这里有个真香经验:改调度策略是成本最低的A/B测试手段。如果一个循环性能诡异,先别急着改代码,直接拿llc -misched=ilp、llc -misched=regpressure各编一版测一下,往往一测就知道瓶颈是寄存器压力还是延迟覆盖。我在AArch64上调过几条视频解码的热循环,ilp和默认策略能差出接近10%的cycle差异,而改源代码可能根本触发不了这个差距。
3.2 模型层:SchedMachineModel到底在描述什么
调度器做判断的依据,不是指令名字,而是目标的机器模型(MachineModel)。它定义在.td文件里,每个指令都关联了一组调度参数。最核心的有:
Latency:指令产生结果到结果可用的周期数。load和除法通常高,ALU指令通常低。NumMicroOps:指令分解成的微操作数量,影响发射端口占用。SchedRW:调度读写资源,描述使用哪些执行单元以及使用多少周期。
比如ARM的SchedMachineModel会区分"简单整数指令"和"NEON乘加指令"的latency。如果你在调一个自定义后端,机器模型写不准确,调度器就是瞎排。
有一件很坑的事情值得提醒:机器模型里的Latency是静态平均值,它不区分L1命中和L2未命中。所以LLVM调度器对load的默认延迟估计往往偏乐观或偏保守,取决于目标定义者的选择。遇到访存密集的循环,想精确建模,你需要看目标是否有类似TSchedModel的扩展或per-operand的latency标注,必要时自己改.td。
3.3 算法层:list scheduling在LLVM里是怎么一步步排的
list scheduling是一个经典的贪心算法,LLVM的基本流程是:
- 构建数据依赖DAG,计算每个节点的height(到叶子节点的最长路径长度)和depth。
- 把所有没有前驱依赖的节点放入就绪队列。
- 每轮从就绪队列里按优先级(priority)选一个节点发射,优先级由目标策略决定(比如height高的优先、延迟长的优先)。
- 发射后更新依赖关系,将新的就绪节点加入队列。
LLVM里实现这个核心循环的是ScheduleDAGMI::schedule和MachineScheduler::schedule,而真正决定每步选谁的是各种SchedMutation和SchedPriority。默认的GenericScheduler会结合height和寄存器压力做权衡,这也是为什么同样是list scheduling,LLVM比教科书版复杂得多。
还有一个关键机制:cluster。LLVM会在调度时对某些指令做聚类,比如连续的load/store尽量放在一起,以便发挥目标的后缀合并或burst访问能力。X86和AArch64上的mode clustering就是这样。这个机制经常导致"不按常理出牌"的调度结果,但它是target专门要求的,不要轻易关。
3.4 PostRA调度:为什么寄存器分配完还要再排一遍
寄存器分配(特别是寄存器分配器引入spill/reload、以及copy coalescing之后)会极大改变指令序列。原本很好的调度顺序,可能因为插入的spill让依赖链拉长。PostRA调度就是干这个的:在寄存器分配之后、PEI之前,对MachineInstr再做一次轻量调度。
PostRA和PreRA最大的区别是:
- PreRA看不到最终物理寄存器,只能处理虚拟寄存器依赖。
- PostRA能看见物理寄存器,因此能精确处理WAR和WAW,但也因此更保守——它必须保证不破坏已经分配好的寄存器使用序列。
X86默认会跑PostRA,AArch64也会。如果你用llc -debug-only=misched看到的日志既有MachineScheduler又有PostGenericScheduler,那就是这两个阶段分别在工作。
3.5 底层调度:SelectionDAG里的"调度前哨"
很多人不知道,SelectionDAG阶段也有一轮调度,但它主要是配合ScheduleDAGSDNodes做的指令选择后的intruction ordering。它不像MachineScheduler那样做精细的性能建模,更多是保证DAG节点按合法顺序输出给EmitSchedule。
对于大多数目标,你真正需要关心的还是MachineScheduler。只有VLIW/DSP这类目标(比如Hexagon)会在SelectionDAG层做非常重的packet化调度,这种调度已经和指令打包(bundling)融为一体,属于另一个话题。
4. 让调度器开口说话:调试指令调度的实用命令和输出解析
调度器看起来是个黑盒,但它实际上留了很多观测窗口。我在实际调优时最常用的命令和工具,基本都在这一节。
先做一个最简单的实验。假设你有test.ll,想看看它被编到AArch64的指令序列,直接跑:
llc -mtriple=aarch64-linux-gnu -o test.s test.ll但这样只能看到最终汇编,看不到调度过程。想看调度器日志,用-debug-only过滤:
llc -mtriple=aarch64-linux-gnu -debug-only=misched -o test.s test.ll这个输出会非常大,里面包含了每个基本块的依赖DAG节点、依赖边、就绪队列变化,以及调度器认为的critical path。我第一次看的时候差点被淹死,习惯之后重点只看三样东西:
SU(0)这种"Schedule Unit"编号,对应一条指令。- 依赖边上的
latency=N,这是调度器做决策的关键数值。 ** schedule **之后的指令序列,这是最终排出来的顺序。
如果想更直观地看DAG,LLVM还支持用Graphviz输出:
llc -mtriple=aarch64-linux-gnu -view-dag-combine1-dags -view-misched-dags -o test.s test.ll这会弹出一堆Graphviz窗口(或者输出dot文件),把依赖DAG画出来。不过说实话,在调试真实性能问题时,Gephi式的大图反而难用,我更多是直接读文本log里的关键行。
还有一个非常有用的调试手法:做调度器消融对比。比如你怀疑某一个循环是调度问题,分别编三版:
llc -mtriple=aarch64-linux-gnu -misched=default -o default.s test.ll llc -mtriple=aarch64-linux-gnu -misched=ilp -o ilp.s test.ll llc -mtriple=aarch64-linux-gnu -misched=source -o source.s test.ll然后看这三版的目标代码差异,以及各自在真机/perf里的cycle数。这个做法比盯着DAG看半天高效得多,因为最终还是要落到硬件行为。我在调一个加密算法的时候就是这么干,最后发现ilp因为插入了太多独立load导致寄存器溢出,性能和默认差不多,反而是source效果最好——因为源码顺序本身就是手写的NEON优化顺序,调度器重排反而破坏了它。
如果还想让调度器输出它认为的每个SU的关键路径和资源使用,可以加:
llc -mtriple=aarch64-linux-gnu -debug-only=misched -misched-print-pressure=true ...(具体子选项随版本不同略有差异,以llc --help-hidden | grep misched为准。)
多说一句:调试调度器最好用opt+llc的组合,并且加-stop-after。比如:
llc -mtriple=aarch64-linux-gnu -stop-after=post-RA-sched -o after.sched.mir test.ll这条命令会输出调度完之后的MIR。看MIR比看最终汇编更能理解调度器的意图,因为此时还能看到虚拟寄存器、debug location等信息。我有段时间排查一个inline asm导致的调度异常,就是从MIR里定位到问题指令的。
5. 指令调度的常见性能陷阱与排查思路
指令调度写起来是一套框架,真正头疼的是线上性能。这一节我整理几个自己踩过、也在社区反复看到的典型问题,每个都给出排查路径。
5.1 调度导致寄存器压力暴涨
最典型的现象是:-misched=ilp之后代码变快了,但到了寄存器分配阶段疯狂spill,最终性能反而下降。这本质是调度器在"提前执行"指令——把很远的独立指令插到当前依赖链之间,当然会延长变量生存期。
排查方法:
- 看寄存器分配的spill统计:
llc -debug-only=regalloc。 - 对比不同策略的spill数量。
- 如果某个函数特别严重,可以考虑用
__attribute__((optnone))先确认是不是调度器的锅。
在LLVM里,还可以用依赖DAG的压力边界(pressure boundary)来干预。目标定义好SchedMachineModel之后,调度器会尽量维持压力在可用寄存器数量内。如果确实因为机器模型错误导致压力失控,修改.td里的资源模型往往是正解。
5.2 load延迟估计不准导致"假性关键路径"
默认的load延迟在很多目标上被定义为某个固定值(比如AArch64可能是4-5周期)。但如果你的数据在L2/L3,真实延迟是30-50周期。调度器以为它排得很合理,实际load早就cache miss了。
面对访存密集循环,我常用的一个做法是用llc编译时把相关循环改成显式prefetch(LLVM有@llvm.prefetch),或者用目标特性做software pipelining,把多次独立的load提前发射。也可以考虑给目标模型加SchedReadAdvance之类的配置,让某些load的延迟能根据可用信息调整。这块属于比较深的目标定制,普通应用开发者不需要碰,但做嵌入式或专用加速器的同学可能会用到。
5.3 inline asm参与调度导致的"神秘延迟"
内联汇编是个黑盒。调度器不知道汇编里用了哪些寄存器、依赖什么、延迟多少,所以它只能极其保守地处理,这会干扰周围的指令调度。
我的经验是:热循环里的inline asm,要么完整写成一段无依赖的大块,要么就别碰调度器。如果你需要精细控制指令顺序,可以在inline asm里用"memory"clobber和explicit register operand来给调度器提供线索。另外,LLVM提供__builtin_constant_p这类编译期分支,有时候能在IR层避开内联汇编对调度的负面影响。
5.4 分支密集代码的调度效果差
调度器是基本块级别的,一个if-else只有两个基本块,块内指令本来就不多,调度收益有限。这种场景下,真正能拉开差距的不是调度,而是:
- 分支预测优化(LLVM的
-branch-prob和块重排)。 - 用
select指令替代分支。 - 循环展开后扩大基本块,给调度器更多操作空间。
这也是为什么我建议新手先看循环展开再加调度调优——调度是放大器,它放大的是基本块内的并行机会,如果基本块太小,调度巧妇难为无米之炊。
5.5 调度日志过大,怎么快速定位到目标基本块
如果函数很大,-debug-only=misched输出几十万行很正常。这时候我习惯先看MIR里的基本块序号,比如bb.2.for.body,然后在日志里搜bb.2,顺便用shell管道过滤:
llc -mtriple=aarch64-linux-gnu -debug-only=misched -o /dev/null test.ll 2>&1 | grep -A 500 "bb.2.for.body"这个方法比用眼睛翻快得多。另外,给热函数加__attribute__((noinline)),能让你更快在日志里定位,免得被内联出来的碎片基本块干扰。
6. 实操笔记:一个AArch64上的调度效果验证实验
多余的理论不多说了,放一个我最近复现的验证步骤,想练手的朋友直接照着做。
先准备一个简单的C程序,文件名叫test.c:
#include <stdint.h> void sum_array(const int32_t *a, const int32_t *b, int32_t *c, int n) { for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; } }生成LLVM IR:
clang -O2 -S -emit-llvm -o test.ll test.c然后用不同调度策略编译出汇编:
llc -mtriple=aarch64-linux-gnu -misched=default -o default.s test.ll llc -mtriple=aarch64-linux-gnu -misched=source -o source.s test.ll llc -mtriple=aarch64-linux-gnu -misched=ilp -o ilp.s test.ll对比default.s和source.s,如果一切正常,你在AArch64的汇编里应该能看到load指令被安排得相对提前了。不过这么简单的循环,三种策略差异可能不大。想看到明显区别,可以改成一个依赖链较长的循环,比如累加器:
int32_t sum_chain(const int32_t *a, int n) { int32_t s = 0; for (int i = 0; i < n; i++) { s += a[i]; // 这里有循环携带依赖 } return s; }这个案例里,循环携带依赖(s的更新)是关键路径,每条s += a[i]的add延迟都会串起来,调度器能做的只是加载提前,能不能真正改善取决于目标CPU的乱序能力和a的cache行为。
如果是在x86上做实验,命令改成-mtriple=x86_64-linux-gnu,效果也类似。
我自己的一个习惯做法是:对比不同调度器的汇编输出,重点看load指令与使用它的ALU指令之间隔了几条其他指令。间隔越大,说明调度器越激进地执行了load提前。如果间隔过小,而且实测profile发现stall,那么可以手工在IR层用简单的数组预取或循环展开来替调度器创造空间。
7. 自定义后端写调度器时最容易犯的三个错
如果你在做自定义后端,或者正在改一个RISC-V、AI加速器的LLVM后端,这一节可能比前面的调试经验更值钱。我见过不少后端把机器模型写完之后,调度器表现乱七八糟,最后定位到的原因不出以下三类。
第一个错:Latency全填1。这会直接废掉调度器的延迟建模能力,它眼里所有指令都是零延迟,自然不做任何提前调度。排查方式是打开-debug-only=misched,看依赖边上的latency是不是都长一个样。
第二个错:不区分读端口和写端口。现代CPU往往有不对称的发射端口,比如有两个整数ALU但只有一个load/store单元。如果机器模型里不写SchedWriteRes的资源占用,调度器会把两条load同时发射给同一个端口,造成资源竞争。这类问题在高频定制后端尤其明显。
第三个错:只写调度模型,不写TSchedModel边界。调度器处理的是一个基本块里的指令序列,如果指令原本是超长块(basic block很长),调度器会盲目把无关指令拖来拖去,造成cache局部性恶化。正确做法是给后端定义合理的NumMicroOps和Latency,同时通过SchedReadAdvance等机制告诉调度器哪些场景该保守。
如果你在改模型,我强烈建议保留一组测试用例,用llc -misched=source和默认策略各编一次,用一组代表实际负载的函数做回归对比。调度器优化最大的风险是过拟合到某一个benchmark,一旦改了机器模型,原来那些"碰巧排得好"的循环很可能翻车。
这让我想起之前调试一个RISC-V DSP扩展的经历:目标默认的SchedMachineModel是从ARM拷贝来改的,latency表完全对不上乘法指令的真实流水级数,结果调度器总是把乘法结果的使用指令排得太早,性能比不开调度的还差。最后一行一行对着硬件手册改.td里的延迟和资源写,跑出来的cycle才恢复正常。这件事之后我学乖了,每次接手一个新后端都会先花一天检查机器模型,再谈调度策略。
8. LLVM版本差异与指令调度选项变动说明
最后提醒一个很实际的问题:LLVM不同版本的调度器选项变动很大。比如早期版本有-pre-RA-sched=list-burr这种选项,后来的版本被-misched取代;又比如某些目标的后端会在TargetPassConfig里自定义了调度Pass的插入位置,导致你在命令行传-misched并不能完全覆盖原有行为。
在调试时,我建议先用llc --help-hidden | grep sched看当前版本支持的调度相关选项,再根据版本查阅对应文档。我的经验是:先用默认策略跑通,再尝试其他策略做对比,切忌一上来就追求某个"最优"配置。调度器是目标相关的,没有万能解。
关于目标后端的调度实现,推荐阅读:
llvm/lib/CodeGen/MachineScheduler.cpp:核心调度框架和策略实现,最值得啃的文件之一。llvm/lib/CodeGen/ScheduleDAGMI.cpp:ScheduleDAG的MachineInstr层实现,处理依赖构建。llvm/include/llvm/CodeGen/TargetSchedule.h:目标机器模型的查询接口。llvm/lib/Target/AArch64/AArch64SchedA53.td等:理解真实目标的调度模型如何组织。
读代码时别一口气全看。我自己的路径是先看MachineScheduler.cpp里GenericScheduler::pickNode的注释,再对比一个目标(比如AArch64)的调度器子类,最后回到ScheduleDAGMI.cpp看依赖边怎么构建。这个顺序能让你在三天内形成一个整体框架,而不是陷进细节里出不来。
实际工作中,你真正需要亲手写的调度器其实是少数,大多数场景是选策略、调机器模型、看日志定位问题。但理解调度器的决策逻辑,对你看汇编、调循环、甚至判断一个性能问题是不是"调度器的锅",都至关重要。