LLVM RISC-V后端指令调度模型实战:从原理到调试优化
如果你跟我一样整天跟 LLVM 后端打交道,肯定遇到过这样的场景:同一段 C 代码,换个调度模型配置,生成的汇编顺序就完全不一样,性能差异甚至能拉到 20% 以上。很多人以为指令调度是编译器里“锦上添花”的环节,真正做 RISC-V 后端的时候才发现,调度模型建得准不准,直接决定了你的 CPU 流水线能不能吃饱、cache miss 能不能藏住、除法延迟有没有被正确避开。
这篇笔记是 LLVM 调度模型系列的第一篇,聚焦 RISC-V 后端。我会先从调度模型到底解决什么问题讲起,再带你过一遍 LLVM 里与调度相关的核心框架,然后落到 RISC-V 后端实际的 TableGen 配置和调试方法。内容适合正在做 LLVM 后端移植、或者想深入理解编译优化栈的开发者阅读,有基础但没系统梳理过调度模型的人,应该能从里面拿到不少可以直接抄作业的东西。
1. 为什么 RISC-V 后端需要认真对待指令调度
1.1 调度不是“锦上添花”,而是性能的底线
先聊一个我自己的体会。很多刚开始接触编译器后端的人,会把指令调度跟指令选择搞混,觉得只要把 LLVM IR 正确地变成 RISC-V 汇编就算完成任务了。但实际跑性能测试就会发现,指令顺序不对,处理器流水线里就是满满的气泡。
RISC-V 是个典型的高性能计算和嵌入式通吃的指令集架构。它没有像 x86 那样的硬件重排缓冲那么复杂,但绝大多数真实实现的 RISC-V 核,无论大小,都至少是 5 级流水线起步,带上分支预测、乱序执行、多发射的核也不在少数。乱序核能靠硬件重排指令掩盖部分调度不当的问题,但到了具体微架构下,发射宽度、保留站数量、访存队列深度都是有上限的。编译器把指令排得太差,硬件那点重排能力很快就会被耗尽。
而对顺序执行的 RISC-V 核来说,指令调度就是天花板上的天花板。一次 load 的延迟是 5 个周期,如果用一条依赖 load 结果的 add 指令紧跟着 load,那这 5 个周期里流水线就只能干等。编译器需要把无关指令插到这个间隙里,这就是最基本也最重要的调度动机。
RISC-V 有另一点很特殊:它是一个模块化指令集,厂商可以自由选择是否实现乘除扩展 M、原子扩展 A、浮点扩展 F/D、向量扩展 V。这些可选扩展的指令延迟差异极大,M 扩展里的除法可能是 10 个周期,也可能是 30 个周期,FMA 可能是 4 个周期。这样分散的微架构实现里,一个能精确描述指令延迟和资源占用情况的调度模型,就比任何“通用优化”都有价值。
1.2 LLVM 调度模型与 RISC-V 的天然契合点
LLVM 的指令调度框架经过多年演变,已经从早期的 Itinerary 模型逐步过渡到基于 MachineModel 的资源驱动模型。这一套机制跟 RISC-V 的指令集风格其实非常合拍。
原因在于 RISC-V 指令编码和语义足够规整。大部分基础整数指令是等长的 32 位,寻址模式非常简单,读写寄存器数量固定,没有 x86 那种隐式标志位依赖、隐式访存等复杂副作用。这给调度器建立依赖图省去了很多麻烦。RISC-V 里需要特别建模的依赖,主要就是 load-use、乘除这类长延迟单元,以及原子操作引起的内存顺序约束。
另外一个契合点是 RISC-V 广泛使用自定义扩展。如果你在为一个自定义 RISC-V 协处理器做代码生成,一个结构清晰的调度模型,能让你很方便地为专用指令加入新的延迟和资源描述,不需要大改调度器核心代码。这一点在对比一些历史包袱较重的后端时特别有优势。
2. 调度模型的基础概念与 LLVM 实现框架
2.1 从数据依赖到依赖图:先搞懂调度对象
调度器要解决的核心问题,可以概括为一句话:在一条指令序列中,确定每条指令在哪个周期发出,既满足指令间的数据依赖约束,又满足处理器资源的占用约束,同时让总周期数尽可能短。
数据依赖关系是调度的第一约束。在 LLVM 里,调度器会构建一张依赖图(Dependence Graph),每个节点是一条指令,每条边表示一个依赖关系。常见的依赖类型有三种:数据依赖(RAW, read-after-write),也就是后面指令要读前面指令写的结果;反依赖(WAR, write-after-read),后面指令写一个前面指令读过的寄存器;输出依赖(WAW, write-after-write),两条指令写同一个寄存器。
RAW 依赖是真正不可跨越的约束,它决定了调度器能做多少事情。WAR 和 WAW 依赖大多来自寄存器复用,理论上是可以通过寄存器重命名消除的,但 LLVM 后端在做基本块调度的时候,寄存器分配通常还没完全结束,所以这些伪依赖依然存在,调度器必须遵守。这也是为什么有些处理器硬件做得好的时候,编译器调度顺序即便不太理想,乱序执行也能兜住。
资源约束是第二层约束。处理器里有有限的 ALU、乘法器、除法器、访存端口、发射端口。即便两条指令没有直接的数据依赖,它们可能抢同一个 ALU,导致执行时间被拉长。调度器里用 ProcResource 来描述这些硬件资源,用 WriteRes 描述每条指令占用资源的方式。
2.2 两种模型流派:Itinerary 与 MachineModel
LLVM 后端描述指令时序,历史上主要有两种方式。早期很多后端用的是 Itinerary,即行程表模型。它把处理器流水线看成一系列阶段,比如取指、译码、执行、访存、写回,每条指令定义一个它经过这些阶段的时间表。Itinerary 的好处是直观,能精确描述 pipeline stage,但坏处也很明显:要为每条指令的每个变体写一大张表,维护起来非常痛苦,而且不太好表达资源竞争和动态流水线。
现代 LLVM 推荐使用的是 MachineModel,也就是基于机器模型的资源驱动调度。它不再按流水线阶段逐条描述指令,而是把重点放在两个维度:指令延迟(Latency)和资源占用(ProcResource 的 Cycles)。这个模型更贴近现代乱序和顺序处理器的行为,也更容易通过子目标特征进行微调。
下面是两种模型的一个简单对比:
| 维度 | Itinerary 行程表模型 | MachineModel 机器模型 |
|---|---|---|
| 建模对象 | 流水线阶段 | 延迟与资源周期 |
| 灵活性 | 差,修改需大动表格 | 好,可按指令类灵活定义 |
| 资源竞争表达 | 间接,通过阶段占用 | 直接,通过 ProcResource |
| 维护成本 | 高 | 低 |
| 适用场景 | 老后端、固定流水线 | 现代后端、乱序/顺序皆可 |
RISC-V 后端现在基本都走 MachineModel 路线。我也强烈建议新写的后端直接用 MachineModel,省下来的维护时间可以用来调性能。
2.3 LLVM 调度器的工作流程与关键开关
LLVM 里的指令调度不是只在某一个阶段做一次,而是在多个位置都会做。理解这几次调度的分工,对后面分析问题是关键。
在 SelectionDAG 阶段,指令选择之后会有一个 DAG 调度,它主要为了降低临时寄存器的压力,为指令选择生成更紧的 ScheduleDAG。这个调度发生在正式 MachineInstr 生成之前。
随后 Machine IR 层会有 MachineScheduler,这是核心的调度器。它在寄存器分配之前对 MachineInstr 做调度,通常以基本块为单位。还有一个 PostRA 调度器,运行在寄存器分配之后。后者的主要任务不再是优化延迟,而是处理前面调度暴露出来的寄存器压力问题,同时做一点流水线气泡优化。RISC-V 后端默认是否启用 PostRA,取决于具体 CPU 子目标开启的选项,默认很多是关闭或轻量开启的。
实际跟调度相关的编译选项和调试开关不少,最常用的几个我列在下面:
-debug-only=misched 是我平时调 RISC-V 后端时用得最多的命令,它能打印出依赖图、每个基本块的调度顺序、最终选择的周期,非常直观地告诉你调度器到底在想什么。后面第四节我会专门展开。
3. RISC-V 后端调度模型的实操配置
3.1 TableGen 描述:Schedule.td 与 SchedMachineModel
RISC-V 后端的调度模型主要在 RISCVSchedule.td 里定义。它遵循一个统一的模式:先定义处理器模型,再定义指令类,最后把指令映射到指令类上。
一个最基本的 SchedMachineModel 长这样:
这里 CompleteModel = 1 的意思是这个模型已经把所有指令都覆盖了,如果在定义指令时漏掉了某条指令的调度信息,TableGen 会直接报错,而不是静默地用默认值。这对防止漏写很有效,新写后端时可以多利用这一点。MicroOpBufferSize 表示指令在乱序核里进入调度队列前的缓冲深度,顺序核一般设 1 就行。
3.2 定义处理器资源与 WriteRes
资源定义是调度模型里最需要花心思的地方。先看一个典型例子,假设我们有一个双发射、支持单周期 ALU、5 周期乘法、非流水除法器的简单 RISC-V 核:
ProcResource<2> 表示有两个同类资源,如果一个周期内要发射三条需要 ALU 的指令,调度器只能安排两条在本周期,第三条排队到下个周期。
接着定义 WriteRes,也就是每条指令或每类指令如何占用这些资源、占用多少周期:
这段描述的含义很直接:普通整数运算占 ALU 一个周期;乘法要同时占用 ALU 和另一个 ALU,整体延迟 5 周期;除法器被独占 20 个周期;load 延迟 4 周期。ResourceCycles 用于描述指令在资源上驻留的周期数,如果不写,默认等于 1。
这里有个细节需要注意:ProcResource 代表的是执行资源,不是发射槽。若后端模型没有显式定义发射宽度相关的资源,调度器可能无法精确模拟多发射宽度。比较新的 LLVM 里可以用 ProcResource 加上额外的 SchedMachineModel 字段描述宽度约束,但总的来说,ProcResource 的粒度需要跟实际微架构对齐。定义太粗,比如整个 CPU 就一个资源,调度器无法区分指令并行;定义太细,每条指令都占用好几个资源,调度器的计算压力也会变大。
3.3 指令类的调度读写匹配
有了资源和 WriteRes,接下来要把它们挂到指令上。LLVM 里通过 SchedReadWrite 把指令绑定到对应的 WriteRes 上。以 RISC-V 加载指令为例:
Sched<[WriteLoad]> 表示这条指令的 write 类型是 WriteLoad。真正到某个具体 CPU 时,需要在对应调度模型里把 WriteLoad 映射到合适的资源上:
InstRW 是逐指令重写调度信息的方法,它比全局 WriteRes 更灵活,因为你可以把若干条指令归为一组,统一设定调度读写。这在处理“多数指令走默认,个别指令特殊”的场景时非常好用。
RISC-V 指令集里有一些多周期且结果依赖明确的指令,典型的比如 M 扩展的乘除法:
乘法和除法放在一起虽然方便,但后来查性能问题的时候,会发现它们共享一个 WriteIMul 实际上掩盖了微架构里乘法和除法可能走完全不同的执行单元。如果 CPU 的乘法器和除法器是分开的,建议拆成两个 WriteRes,否则调度器会误判资源竞争,导致本该并行执行的乘除法被串行化。
3.4 从 RISCVSubtarget 到 enablePostRAScheduler
调度模型定义好了,还需要在 RISCV 子目标里把它选出来。通常在 RISCVSubtarget.h 里会有类似这样的代码:
然后在 RISCVSubtarget.cpp 里,根据 CPU 名或 feature 字符串选择调度模型:
此外,调度器是否启用、PostRA 是否启用,也在 TargetSubtargetInfo 里有迹可循。RISC-V 后端的 enablePostRAScheduler 通常默认不开启,但如果你的核是顺序发射的,且指令序列里存在很多寄存器重命名压力造成的假依赖,可以考虑打开 PostRA 调度。我个人的建议是先把 MachineScheduler 的模型调准确,PostRA 作为锦上添花的环节,能不动就不动,因为它可能干扰之前基于基本块的精心排序。
4. 调试调度模型与性能调优的实用技巧
4.1 用 -debug-only=misched 看调度结果
调度模型写得再漂亮,也得看实际调度器认不认账。在调试阶段,-debug-only=misched 是最直接的手段。它会输出每个基本块的 Scheduling DAG、每个节点的 Cycle 和高度、最终选择的指令顺序。
我举个简单的例子,用下面的 C 代码做测试:
编译命令和部分输出大概是这样的:
在输出里你会看到类似:
调度器按照依赖图决定哪个 SU(Schedule Unit) 先发出。如果模型里 load 的延迟是 4,那么 MUL 最早也要在 load 之后的 cycle 4 才能发,调度器会尽量把无关的 SU(1) 插在两个 load 之间,达到访存并行的效果。
如果看到某条指令的 Cycle 一直往后推,说明两个可能:一是依赖链很长,二是资源冲突导致排队。这时去检查 WriteRes 里的资源占用周期是否合理。我踩过一个坑:把无符号除法 DIVU 的 ResourceCycles 写成了和 DIV 一样的大周期数,结果同一段代码里只要有两个除法,调度器就认为它们必须串行,其实我的目标 CPU 除法器是流水化的,支持每周期发射一个新除法。改成 ResourceCycles=1、Latency=20 之后,两个独立除法就能并行,性能提升了约 30%。
4.2 用 llvm-mca 做调度器效果评测
调试完调度日志后,一个更系统的方法是使用 llvm-mca 做静态性能分析。llvm-mca 会根据调度模型模拟指令在处理器里的执行,输出吞吐量、资源压力、依赖链长度等数据。
先让 llc 生成目标汇编:
输出里最值得关注的是 Resource pressure by cycle 表格。它会显示每个周期哪些资源处于忙碌状态。如果某一行显示 [RISCVALU] 连续很多周期都是满的,说明你这段代码是计算密集型的,调度器已经把 ALU 用满,瓶颈在硬件资源本身。如果你看到明明运算量不大,ALU 却一直忙,那可能是模型里 WriteRes 误把所有指令都分配到了同一个资源。
另一个重要指标是总的迭代周期数。你可以修改调度模型的延迟,重新编译生成汇编,再观察 llvm-mca 估算的周期数降没降。这是个非常快的反馈闭环,不用上板就能初步评估模型改动的影响。
4.3 微基准测试与调度前后的对比
静态分析终归不能替代实际跑分。签发一个微基准测试的时候,我习惯把被测量代码放进一个循环里,编译出指令序列后再对比调度前后的指令重排情况,同时用硬件性能计数器看 cycle 和指令数。
针对 RISC-V,可以用 Linux 下的 perf stat 直接读 cycles 和 instructions。如果是嵌入式环境,不少 RISC-V 核也支持 read cycle CSR。一个比较实用的测试框架是:
这种点积代码,依赖链短,访存占主导,特别适合观察 load-use 延迟建模是否准确。如果调度模型里 store 延迟或 load 延迟设置偏差,循环核心里就会出现额外气泡。我把不同延迟参数下编译出的循环体展开,做过对比表:
| Load 延迟设置 | 循环体周期/迭代 | 说明 |
|---|---|---|
| 4 | 4 cycle | 刚好两个 load 并行,后续乘加接上 |
| 6 | 5 cycle | load 延迟拉长,部分窗口开始等待 |
| 10 | 7 cycle | 依赖链无法被指令窗口覆盖 |
这个测试在真实 CPU 上也测过一遍,趋势与 llvm-mca 模拟基本一致,误差在 10% 以内。所以模型调参前,先拿这类微基准做基线,再动延迟,是最稳妥的流程。
5. 常见问题与避坑指南
5.1 延迟设置不准导致的 load-use 停顿
load-use 延迟是 RISC-V 调度模型里最容易出问题的地方。很多人会想把 load 延迟设成 cache miss 的最坏情况,比如 200 个周期,但这会严重高估访存代价,导致调度器把所有指令都挪来挪去,反而牺牲指令局部性。最好把 load 延迟设置为命中 L1 cache 的情况,比如 3~5 个周期,让调度器在最常见场景下做优化。如果程序访问模式确实以 miss 为主,这是硬件预取和缓存策略的锅,不是调度器单靠延迟能救回来的。
另外,我遇到过一种情况:同一条 load 指令在不同上下文里的使用方式不同,比如结果直接传给一个 ALU 运算,和结果作为地址基址再 load。这两种场景的最优延迟并不一样。LLVM 里除了基础 Latency,还可以通过 ReadAdvance 调整源操作数的可用周期。RISC-V 后端的 base+offset 寻址,load 结果作为后面的地址操作数时,可以设置 ReadAdvance 让它提前几个周期,从而隐藏地址计算依赖。
5.2 原子指令与伪指令的调度建模
RISC-V 的 A 扩展原子指令总是在内存系统层面引入隐式顺序约束,比如 LR/SC 对。这些指令如果只是按普通指令建模,调度器可能会为了优化而把两条原子指令重排,导致生成违反内存原子性语义的代码。
LLVM 里对这种指令一般需要设置 hasUnmodeledSideEffects = 1,或者在调度模型里把它们标记为不可移动。底层做法是在指令定义时加 let hasPostISelHook = 1,并且在 RISCVISelLowering 里把它们提升到调度边界。实际操作中,我更推荐在 SchedMachineModel 层面为原子指令单独定义一种 WriteRes,比如 WriteAtomic,并把资源设置为独占、高延迟,这样调度器虽然能看到它们,但不会轻易把其他访存指令塞到它们之间乱序执行。
伪指令也有类似问题。RISC-V 里有些指令是汇编器伪指令,例如 NOP、LI、LA、CALL,这些指令在 MachineInstr 阶段可能已经被展开或还没有被展开。如果调度模型只覆盖真实指令,没有对伪指令做处理,可能在 pre-RA 阶段就不会有调度信息,漏进默认延迟。建议在 TableGen 里给所有伪指令也绑定一个合适的 SchedRW,即使它们最终会被展开成多条指令,至少不要让默认路径的延迟污染调度结果。
5.3 基本块太短怎么办
RISC-V 代码的一个特点是基本块普遍比较短,尤其是未开启循环展开时。调度器的调度窗口通常局限于基本块内的指令,短基本块意味着调度器几乎没有空间来做跨指令重排。针对这个问题,我通常建议先开启 -unroll-loops 或人工展开热点循环,让基本块变长,再通过调度模型看到效果。
还有一个容易被忽略的选项是 MachineScheduler 的区域边界调整。LLVM 调度器支持把相邻基本块合并成更大的 region 进行调度,只要控制流简单且寄存器压力可接受。可以通过 -misched-merge-region 之类的参数控制合并范围,但要注意合并会带来跨基本块的寄存器压力上升,在寄存器分配时可能产生溢出。RISC-V 寄存器数量相比 x86 多一些,但不要轻视边缘情况,最好用典型的 hot loop 做测试对比。
另外,分支指令的建模也很关键。RISC-V 指令集没有分支延迟槽,但分支本身的代价取决于分支预测器。如果你在为一个没有复杂分支预测的简单顺序核写模型,分支指令的延迟可以设高一点,让调度器尽量在分支前面排布不依赖分支结果的指令,减轻分支代价。我见过有人把分支延迟设成 1,结果调度器疯狂把比较指令和分支指令粘连在一起,流水线清空时损失惨重。
5.4 模型很好,但性能没有提升?先检查指令选择
最后提醒一个容易让人沮丧的情况:模型调了快一周,用 llvm-mca 测试和上板测试性能都有提升,但编译器生成的汇编里就是没有充分体现调度模型的优化效果。这种情况有一个高频原因:指令选择阶段生成的代码形态本身就不理想。
比如 RISC-V 的地址计算,如果指令选择把大量地址运算留在 ALU 里,而不是利用 addi 和 load 的合并寻址,那么调度模型再怎么调,也无法解决访存指令数量过多、依赖链过长的问题。这时候回头检查 RISCVISelLowering 的 addressing mode 选择,比继续调调度参数更有效。
我个人的经验法则是:调度模型负责的是“在指令形态固定后找到最优的发射顺序”,它不能替代指令选择和寄存器分配。如果优化效果不达标,先用 llc -print-after-all 看每个 pass 后的 IR 和指令形态,确认瓶颈到底在哪一层,再决定是去改调度模型,还是去改前面 pass 的优化逻辑。
调度模型的调试往往是个反复迭代的过程。先定好资源的粗粒度,再一条指令一条指令地对齐延迟,中间配合 -debug-only=misched 看调度日志和 llvm-mca 的静态分析,最后上板用性能计数器验证。只要这个循环坚持跑几轮,你的 RISC-V 后端调度质量一定会从“能编出正确的代码”上升到“能编出高效利用硬件的代码”。这个系列后面我打算继续写 RISC-V 调度模型在自定义扩展指令上的建模方法,以及跟寄存器分配交互时的注意事项,到时候再跟大家做更细的分享。