Rust 编译器与运行时揭秘
第15章 MIR 优化:编译器的中间表示与优化管线
编译器的艺术在于将程序员的意图精确地转化为机器指令,同时消除一切不必要的开销。MIR 就是 Rust 编译器完成这一使命的核心战场。
本章要点
- MIR(Mid-level Intermediate Representation) 是 Rust 编译器在 HIR 与 LLVM IR 之间的中间表示,是借用检查、优化、代码生成三大阶段共享的核心数据结构
- MIR 由 BasicBlock、Statement、Terminator、Place、Operand、Rvalue 六大核心类型构成
- MIR 经历 Built → Analysis → Runtime 三个方言阶段,每个阶段有不同的语义约束
- 优化管线包含 30+ 个优化 pass,从 SimplifyCfg 到 Inline、GVN、CopyProp、DestinationPropagation
- 借用检查运行在 Analysis MIR 上,Drop 展开将条件性 Drop 转化为确定性 Drop
- 通过
--emit=mir和-Zunpretty=mir可以查看优化后的 MIR,用-Zdump-mir查看各 pass 前后的 MIR
15.1 为什么需要 MIR:三层 IR 架构的设计哲学
要理解 MIR 的存在意义,我们需要回到一个根本性的问题:为什么 Rust 编译器需要三层中间表示?
15.1.1 从源码到机器码的距离
Rust 源代码与最终的机器指令之间存在巨大的语义鸿沟。源代码中充满了复杂的控制流结构(match、if let、for 循环)、所有权转移、生命周期约束和泛型参数。而机器指令只理解寄存器、内存地址和跳转。如果试图一步完成这个转换,编译器会变得极其复杂且难以维护。
Rust 编译器采用的三层 IR 架构正是为了分而治之:
graph LR
A["Rust 源码"] --> B["HIR<br/>高级中间表示"]
B --> C["MIR<br/>中级中间表示"]
C --> D["LLVM IR<br/>低级中间表示"]
D --> E["机器码"]
style A fill:#6366f1,color:#fff,stroke:none
style B fill:#7c3aed,color:#fff,stroke:none
style C fill:#db2777,color:#fff,stroke:none
style D fill:#f97316,color:#333,stroke:none
style E fill:#10b981,color:#333,stroke:none
HIR(High-level IR) 保留了源码的大部分结构信息,包括完整的类型注解、trait 约束、模式匹配的层次结构。它适合进行类型检查和 trait 求解,但对于数据流分析来说过于复杂。
LLVM IR 是一种接近机器码的低级表示,擅长寄存器分配、指令选择等底层优化,但它不理解 Rust 的所有权语义、生命周期和 Drop 机制。
MIR 恰好填补了这个空白。它足够低级,可以表示为控制流图(CFG)以便进行数据流分析;又足够高级,保留了 Rust 特有的语义信息,如借用、移动和 Drop。
15.1.2 MIR 承载的三大使命
MIR 不仅仅是一个"中转站",它同时承载了三个关键使命:
-
借用检查:MIR 的控制流图结构使得编译器能够精确地分析每个值的生命周期和借用关系。正如我们在前面章节中讨论的 NLL(Non-Lexical Lifetimes),正是在 MIR 上进行的数据流分析赋予了它超越词法作用域的精度。
-
领域级优化:Rust 编译器可以利用自身对所有权、类型系统和语义的理解,在 MIR 层面执行 LLVM 无法完成的优化。例如,MIR 内联在单态化之前、直接在泛型 MIR 上进行,内联的结果被该函数所有单态化实例共享。
-
代码生成的统一入口:无论最终的代码生成后端是 LLVM、Cranelift 还是 GCC,它们都从同一份优化后的 MIR 开始工作。
15.1.3 MIR 的历史背景
MIR 并非 Rust 一开始就有的设计。在早期版本中,Rust 编译器直接从 HIR 生成 LLVM IR。这导致了两个严重的问题:第一,借用检查只能基于词法作用域进行,产生了大量令人困惑的错误信息;第二,许多 Rust 特有的优化机会被浪费了,因为 LLVM 不理解 Rust 的语义。
2016 年引入的 MIR 彻底改变了这一局面。它使得 NLL 成为可能,使得编译器能够进行精确的 Drop 分析,并为后来的一系列优化打下了基础。
15.2 MIR 的核心数据结构
MIR 的设计理念是将函数体表示为一个控制流图(Control Flow Graph, CFG),其中节点是基本块(BasicBlock),边是控制流转移。让我们深入分析每个核心数据结构。
15.2.1 Body:函数的完整表示
一个函数在 MIR 中的完整表示是 Body 结构体,定义在 compiler/rustc_middle/src/mir/mod.rs 中:
// compiler/rustc_middle/src/mir/mod.rs:206(节选 6 个字段,顺序有调整)
pub struct Body<'tcx> {
/// 基本块列表,通过 BasicBlock 索引访问
pub basic_blocks: BasicBlocks<'tcx>,
/// 当前所处的 MIR 阶段
pub phase: MirPhase,
/// 局部变量声明列表
/// 第一个是返回值位置,接着是参数,然后是用户变量和临时变量
pub local_decls: IndexVec<Local, LocalDecl<'tcx>>,
/// 函数参数的数量
pub arg_count: usize,
/// 调试信息
pub var_debug_info: Vec<VarDebugInfo<'tcx>>,
/// 如果是协程,包含额外信息
pub coroutine: Option<Box<CoroutineInfo<'tcx>>>,
// ... 其他字段
}
这个结构体中,几个关键的设计决策值得关注:
局部变量的统一编址:返回值、参数、用户变量和编译器生成的临时变量全部统一存储在 local_decls 中。_0 固定为返回值位置,_1 到 _N(N = arg_count)是函数参数,之后的编号是用户定义的变量和编译器生成的临时变量。
阶段标记:phase 字段标记了当前 MIR 处于哪个编译阶段。这不仅是元数据——不同阶段的 MIR 具有不同的语义约束。
15.2.2 MirPhase:三方言架构
MIR 的最精妙的设计之一是其"方言"(dialect)系统。MirPhase 枚举定义了三个方言,每个方言代表 MIR 在编译过程中的一个阶段:
// compiler/rustc_middle/src/mir/syntax.rs:42 / :104 / :126(注释为本专栏概括)
pub enum MirPhase {
/// MIR 构建阶段,直接从 HIR/THIR 降低而来
Built,
/// 分析阶段 MIR,用于借用检查
Analysis(AnalysisPhase),
/// 运行时阶段 MIR,用于优化和代码生成
Runtime(RuntimePhase),
}
pub enum AnalysisPhase {
Initial = 0,
/// 此阶段后,FalseUnwind、FalseEdge、FakeRead 等被消除
PostCleanup = 1,
}
pub enum RuntimePhase {
/// Drop 展开后的初始状态
Initial = 0,
/// Box 解引用展开后
PostCleanup = 1,
/// 完成所有优化后
Optimized = 2,
}
这三个方言之间存在关键的语义差异:
Built MIR 由 MIR 构建直接产生,只有各类 MIR lint 和常量限定检查(const qualifs)在它上面工作。按源码注释,它与 Analysis MIR 唯一的语义差别在常量提升:Built MIR 里那些会被提升的语句序列在语义上就是常量,Analysis MIR 里所有常量都已显式化。FalseEdge(仅用于借用检查的虚假控制流边)、FakeRead(不执行实际读取但影响 NLL 分析的伪读操作)这类辅助构造在 Built MIR 里就有,一直保留到 Analysis(PostCleanup) 才被禁止。
Analysis MIR 是借用检查的工作对象。在这个阶段,Drop 终结器表示条件性 drop——粗略地说,如果数据流分析确定被 drop 的位置未初始化,这次 drop 就不执行。
Runtime MIR 是 CTFE(编译时函数求值)、优化和代码生成的工作对象。在这个阶段,Drop 是无条件的——到达 Drop 终结器时,如果类型有 drop glue,就一定会执行。
graph TD
subgraph Built["Built MIR(MIR 构建后)"]
B1["包含 FalseEdge、FakeRead"]
B2["常量提升之前"]
end
subgraph Analysis["Analysis MIR(分析阶段)"]
A1["借用检查在此运行"]
A2["Drop 是条件性的"]
A3["PostCleanup 后消除辅助构造"]
end
subgraph Runtime["Runtime MIR(运行时阶段)"]
R1["Drop 是无条件的"]
R2["优化 Pass 在此运行"]
R3["最终生成代码"]
end
Built --> Analysis
Analysis --> Runtime
style Built fill:#6366f1,color:#fff,stroke:none
style Analysis fill:#db2777,color:#fff,stroke:none
style Runtime fill:#10b981,color:#333,stroke:none
15.2.3 BasicBlock 与 BasicBlockData
控制流图的每个节点是一个基本块。基本块是一个线性的语句序列,以一个终结器(terminator)结束:
// compiler/rustc_middle/src/mir/mod.rs:1286(省略 after_last_stmt_debuginfos 字段)
/// 基本块的数据
pub struct BasicBlockData<'tcx> {
/// 本块中的语句列表
pub statements: Vec<Statement<'tcx>>,
/// 本块的终结器——决定控制流如何离开这个块
pub terminator: Option<Terminator<'tcx>>,
/// 是否为 cleanup 块(用于 unwind 路径)
pub is_cleanup: bool,
}
基本块的核心不变量是:控制流只能从基本块的第一条语句进入,只能从终结器离开。这个不变量使得数据流分析可以高效地遍历控制流图。
15.2.4 Statement:基本块内的操作
语句(Statement)是基本块内不改变控制流的操作。每个语句包含源代码信息和具体的操作类型:
// compiler/rustc_middle/src/mir/statement.rs:17(Statement)
// + compiler/rustc_middle/src/mir/syntax.rs:310(StatementKind)
pub struct Statement<'tcx> {
pub source_info: SourceInfo,
pub kind: StatementKind<'tcx>,
/// Some debuginfos appearing before the primary statement.
pub debuginfos: StmtDebugInfos<'tcx>,
}
pub enum StatementKind<'tcx> {
/// 赋值:_1 = rvalue
Assign(Box<(Place<'tcx>, Rvalue<'tcx>)>),
/// 伪读:仅影响借用检查,运行时为 nop
FakeRead(Box<(FakeReadCause, Place<'tcx>)>),
/// 设置枚举的判别式
SetDiscriminant { place: Box<Place<'tcx>>, variant_index: VariantIdx },
/// 标记局部变量的存储生命周期
StorageLive(Local),
StorageDead(Local),
/// Stacked Borrows 模型中的 retag 操作
Retag(RetagKind, Box<Place<'tcx>>),
/// 为 `let _ = PLACE;` 这类通配匹配留下被匹配位置的痕迹:运行时计算该 place,但不读取
PlaceMention(Box<Place<'tcx>>),
/// 类型标注:把用户写下的类型关系交给借用检查器去验
AscribeUserType(Box<(Place<'tcx>, UserTypeProjection)>, ty::Variance),
/// 覆盖率插桩信息
Coverage(CoverageKind),
/// 不需要 unwind 路径、总会返回的内建函数调用
Intrinsic(Box<NonDivergingIntrinsic<'tcx>>),
/// 编译时求值计数器
ConstEvalCounter,
/// 空操作,用于原地删除语句而不影响索引
Nop,
// ...
}
其中,Assign 是最常见也是最重要的语句类型。值得注意的是 Nop 的设计——当优化 pass 需要删除一条语句时,不会真的从 Vec 中移除它(那会破坏所有基于索引的 Location 引用),而是将其替换为 Nop。这个设计体现了 MIR 对增量修改的友好性。
15.2.5 Terminator:控制流的分叉点
终结器决定了基本块结束后控制流的去向。它是 MIR 控制流图中的关键组件:
// compiler/rustc_middle/src/mir/syntax.rs:702(全 15 个变体,这里选列 9 个,
// 末尾 `// ...` 处省略 UnwindTerminate / TailCall / CoroutineDrop /
// FalseEdge / FalseUnwind / InlineAsm)
pub enum TerminatorKind<'tcx> {
/// 无条件跳转
Goto { target: BasicBlock },
/// 基于整数值的多路分支(对应 match/if)
SwitchInt {
discr: Operand<'tcx>,
targets: SwitchTargets,
},
/// 函数返回
Return,
/// 不可达代码(执行到此处是 UB)
Unreachable,
/// 函数调用
Call {
func: Operand<'tcx>,
args: Box<[Spanned<Operand<'tcx>>]>,
destination: Place<'tcx>,
target: Option<BasicBlock>, // 正常返回后跳转到哪里
unwind: UnwindAction, // panic 时的处理
fn_span: Span,
// ...
},
/// Drop 操作
Drop {
place: Place<'tcx>,
target: BasicBlock,
unwind: UnwindAction,
replace: bool,
// ...
},
/// 断言(边界检查、溢出检查等)
Assert {
cond: Operand<'tcx>,
expected: bool,
msg: Box<AssertMessage<'tcx>>,
target: BasicBlock,
unwind: UnwindAction,
},
/// 协程的 yield 点
Yield { value: Operand<'tcx>, resume: BasicBlock, /* ... */ },
/// Unwinding 继续
UnwindResume,
// ...
}
注意 Call 终结器的设计:函数调用可能永不返回(target: None),也可能 panic(unwind 字段处理栈展开)。这种将函数调用建模为终结器而非语句的决策,使得编译器能够精确地追踪控制流在 panic 场景下的行为。
15.2.6 Place、Operand 与 Rvalue:值的三要素
MIR 中对值的处理围绕三个核心概念展开:
Place(位置) 表示"内存中的一个位置",大致对应 Rust 中的左值(lvalue):
// compiler/rustc_middle/src/mir/syntax.rs:1188(Place)/ :1197(ProjectionElem,节选)
pub struct Place<'tcx> {
pub local: Local, // 基础局部变量
pub projection: &'tcx List<PlaceElem<'tcx>>, // 投影链
}
pub enum ProjectionElem<V, T> {
Deref, // 解引用:*x
Field(FieldIdx, T), // 字段访问:x.f
Index(V), // 索引:x[i]
ConstantIndex { /* */ }, // 编译时常量索引
Subslice { /* */ }, // 子切片
Downcast(Option<Symbol>, VariantIdx), // 枚举变体向下转型
// ...
}
一个 Place 由一个局部变量加上零或多个投影组成。例如,_1.0.field 表示对局部变量 _1 先取第 0 个元素(元组索引),再取 field 字段。
Operand(操作数) 表示一个"值"——可以是从位置复制或移动得来的,也可以是一个常量:
// compiler/rustc_middle/src/mir/syntax.rs:1308
pub enum Operand<'tcx> {
/// 从位置复制值(要求类型实现 Copy)
Copy(Place<'tcx>),
/// 从位置移动值(之后该位置变为未初始化)
Move(Place<'tcx>),
/// 编译时常量
Constant(Box<ConstOperand<'tcx>>),
// 另有 RuntimeChecks(RuntimeChecks) 一个变体,此处省略
}
Copy 与 Move 的区分是 Rust 所有权语义在 MIR 层面的直接体现。进入 Runtime MIR 之前,只有 Copy 类型可以使用 Copy 操作数(RuntimePhase::Initial 起才放开)。
Rvalue(右值) 表示一个可以计算出值的表达式:
// compiler/rustc_middle/src/mir/syntax.rs:1368
pub enum Rvalue<'tcx> {
/// 直接使用操作数
Use(Operand<'tcx>),
/// 创建数组:[x; N]
Repeat(Operand<'tcx>, ty::Const<'tcx>),
/// 创建引用
Ref(Region<'tcx>, BorrowKind, Place<'tcx>),
/// 二元运算
BinaryOp(BinOp, Box<(Operand<'tcx>, Operand<'tcx>)>),
/// 一元运算
UnaryOp(UnOp, Operand<'tcx>),
/// 读取判别式
Discriminant(Place<'tcx>),
/// 构造聚合类型(元组、结构体、枚举等)
Aggregate(Box<AggregateKind<'tcx>>, IndexVec<FieldIdx, Operand<'tcx>>),
/// 类型转换
Cast(CastKind, Operand<'tcx>, Ty<'tcx>),
// ...
}
这三者的关系构成了 MIR 中数据处理的基本模型:
graph LR
subgraph "赋值语句 _dest = rvalue"
P["Place<br/>(内存位置)"]
R["Rvalue<br/>(计算表达式)"]
O1["Operand<br/>(值来源)"]
O2["Operand<br/>(值来源)"]
end
O1 -->|"参与计算"| R
O2 -->|"参与计算"| R
R -->|"赋值给"| P
style P fill:#6366f1,color:#fff,stroke:none
style R fill:#db2777,color:#fff,stroke:none
style O1 fill:#10b981,color:#333,stroke:none
style O2 fill:#10b981,color:#333,stroke:none
15.2.7 一个完整的 MIR 示例
让我们通过一个具体的 Rust 函数来看 MIR 的完整结构:
fn example(x: i32) -> i32 {
let y = x + 1;
if y > 10 {
y * 2
} else {
y
}
}
用 rustc 1.96.0 -O -Zunpretty=mir 实测的输出如下(-O 下不做溢出检查;不加 -O 时加法和乘法会变成 AddWithOverflow / MulWithOverflow 加一条 assert):
fn example(_1: i32) -> i32 {
debug x => _1;
let mut _0: i32;
let _2: i32;
let mut _3: bool;
scope 1 {
debug y => _2;
}
bb0: {
_2 = Add(copy _1, const 1_i32);
StorageLive(_3);
_3 = Gt(copy _2, const 10_i32);
switchInt(move _3) -> [0: bb2, otherwise: bb1];
}
bb1: {
_0 = Mul(copy _2, const 2_i32);
goto -> bb3;
}
bb2: {
_0 = copy _2;
goto -> bb3;
}
bb3: {
StorageDead(_3);
return;
}
}
其中 _2 是 y,_3 是比较结果。
这个例子展示了 MIR 如何将 Rust 的 if-else 表达式分解为基本块和 switchInt 终结器。注意 _0 是返回值位置,两个分支都向 _0 赋值后跳转到 bb3 返回。
15.3 MIR 的完整编译管线
理解了 MIR 的数据结构后,让我们来看它是如何在编译器中流转和变换的。Rust 编译器的 MIR 管线定义在 compiler/rustc_mir_transform/src/lib.rs 中,是一个精心设计的多阶段过程。
15.3.1 从 HIR 到 MIR:构建阶段
MIR 的旅程从 mir_built 查询开始。编译器从 THIR(Typed HIR)降低到最初的 MIR,然后立即运行一批初始的 lint 和简化:
// compiler/rustc_mir_transform/src/lib.rs:388(省略 trivial const 直接返回的分支和 dump 调用)
fn mir_built(tcx: TyCtxt<'_>, def: LocalDefId) -> &Steal<Body<'_>> {
let mut body = tcx.build_mir_inner_impl(def);
// ...
pm::run_passes(
tcx,
&mut body,
&[
// MIR 级别的 lint 检查
&Lint(check_inline::CheckForceInline),
&Lint(check_call_recursion::CheckCallRecursion),
&Lint(check_inline_always_target_features::CheckInlineAlwaysTargetFeature),
&Lint(check_packed_ref::CheckPackedRef),
&Lint(check_const_item_mutation::CheckConstItemMutation),
&Lint(function_item_references::FunctionItemReferences),
// 首次控制流简化
&simplify::SimplifyCfg::Initial,
&Lint(sanity_check::SanityCheck),
],
None,
pm::Optimizations::Allowed,
);
tcx.alloc_steal_mir(body)
}
这里有一个重要的细节:Steal 类型。MIR 在不同的编译阶段之间通过"窃取"机制传递,确保每个阶段的 MIR 只被消费一次,避免了昂贵的深拷贝。
15.3.2 常量提升与分析准备
接下来,mir_promoted 查询执行常量提升(Promote Temps),将适合在编译时求值的表达式提取到独立的 MIR body 中:
// compiler/rustc_mir_transform/src/lib.rs:429(节选:省略 const_qualifs 计算、
// steal mir_built 得到 body 等前置步骤)
fn mir_promoted(tcx: TyCtxt<'_>, def: LocalDefId)
-> (&Steal<Body<'_>>, &Steal<IndexVec<Promoted, Body<'_>>>)
{
// ...
let promote_pass = promote_consts::PromoteTemps::default();
pm::run_passes(
tcx,
&mut body,
&[
&promote_pass,
&simplify::SimplifyCfg::PromoteConsts,
&coverage::InstrumentCoverage,
],
Some(MirPhase::Analysis(AnalysisPhase::Initial)),
pm::Optimizations::Allowed,
);
// ...
}
15.3.3 借用检查与分析清理
mir_drops_elaborated_and_const_checked 是一个关键的查询节点。它首先触发借用检查,然后运行一系列降低和清理 pass:
// compiler/rustc_mir_transform/src/lib.rs:523(节选)
fn mir_drops_elaborated_and_const_checked(tcx: TyCtxt<'_>, def: LocalDefId)
-> &Steal<Body<'_>>
{
// ...(协程先确保 mir_coroutine_witnesses 已计算)
// 运行借用检查
let tainted_by_errors = if !tcx.is_synthetic_mir(def) {
tcx.mir_borrowck(tcx.typeck_root_def_id_local(def)).err()
} else {
None
};
// ...
run_analysis_to_runtime_passes(tcx, &mut body);
// ...
}
run_analysis_to_runtime_passes(compiler/rustc_mir_transform/src/lib.rs:579)包含三个子阶段(启用 const_precise_live_drops 检查时,第一、二阶段之间还会插入 RemoveUninitDrops、SimplifyCfg::RemoveFalseEdges 和一个 lint):
分析清理(Analysis Cleanup):
// compiler/rustc_mir_transform/src/lib.rs:614
fn run_analysis_cleanup_passes<'tcx>(tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
let passes: &[&dyn MirPass<'tcx>] = &[
&impossible_predicates::ImpossiblePredicates, // 移除不可能的谓词
&cleanup_post_borrowck::CleanupPostBorrowck, // 清除借用检查用的辅助构造
&remove_noop_landing_pads::RemoveNoopLandingPads,
&simplify::SimplifyCfg::PostAnalysis,
&deref_separator::Derefer, // 分离解引用操作
];
// 完成后进入 Analysis(PostCleanup) 阶段
}
运行时降低(Runtime Lowering)——这是 MIR 从 Analysis 方言转换为 Runtime 方言的关键步骤:
// compiler/rustc_mir_transform/src/lib.rs:633
fn run_runtime_lowering_passes<'tcx>(tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
let passes: &[&dyn MirPass<'tcx>] = &[
&add_call_guards::CriticalCallEdges,
&post_analysis_normalize::PostAnalysisNormalize,
&add_subtyping_projections::Subtyper,
&elaborate_drops::ElaborateDrops, // Drop 展开(关键!)
&Lint(check_call_recursion::CheckDropRecursion),
&abort_unwinding_calls::AbortUnwindingCalls,
&add_moves_for_packed_drops::AddMovesForPackedDrops,
&add_retag::AddRetag,
&erase_deref_temps::EraseDerefTemps,
&elaborate_box_derefs::ElaborateBoxDerefs,
&coroutine::StateTransform, // 协程状态机变换
&Lint(known_panics_lint::KnownPanicsLint),
];
// 完成后进入 Runtime(Initial) 阶段
}
运行时清理(Runtime Cleanup):
// compiler/rustc_mir_transform/src/lib.rs:663
fn run_runtime_cleanup_passes<'tcx>(tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
let passes: &[&dyn MirPass<'tcx>] = &[
&lower_intrinsics::LowerIntrinsics,
&remove_place_mention::RemovePlaceMention,
&simplify::SimplifyCfg::PreOptimizations,
];
// 完成后进入 Runtime(PostCleanup) 阶段
}
15.3.4 优化阶段:核心管线
最后,run_optimization_passes 执行所有的 MIR 优化。这是整个管线中最丰富、也是本章重点讨论的部分:
// compiler/rustc_mir_transform/src/lib.rs:685 起
pub(crate) fn run_optimization_passes<'tcx>(tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
// :686-688 是个小助手 `fn o1<T>(x) -> WithMinOptLevel(1, x)`,给下面的 pass 标
// 「mir-opt-level 至少为 1 才跑」;:690-697 先按 #[optimize(none)] 决定 optimizations 是不是
// Suppressed,再作为最后一个参数传进去;下面的「第 N 阶段」分组注释为本专栏所加,
// 源码里的英文注释从略
pm::run_passes(
tcx,
body,
&[
// === 第一阶段:UB 检查插入 ===
&check_alignment::CheckAlignment,
&check_null::CheckNull,
&check_enums::CheckEnums,
// === 第二阶段:内联前的准备 ===
&lower_slice_len::LowerSliceLenCalls,
&instsimplify::InstSimplify::BeforeInline,
// === 第三阶段:内联 ===
&inline::ForceInline, // 强制内联(#[rustc_force_inline])
&inline::Inline, // 普通内联
// === 第四阶段:内联后清理 ===
&remove_storage_markers::RemoveStorageMarkers,
&remove_zsts::RemoveZsts,
&remove_unneeded_drops::RemoveUnneededDrops,
&unreachable_enum_branching::UnreachableEnumBranching,
&unreachable_prop::UnreachablePropagation,
&o1(simplify::SimplifyCfg::AfterUnreachableEnumBranching),
&multiple_return_terminators::MultipleReturnTerminators,
&instsimplify::InstSimplify::AfterSimplifyCfg,
// === 第五阶段:核心数据流优化 ===
&o1(simplify_branches::SimplifyConstCondition::AfterInstSimplify),
&ref_prop::ReferencePropagation,
&sroa::ScalarReplacementOfAggregates,
&simplify::SimplifyLocals::BeforeConstProp,
&dead_store_elimination::DeadStoreElimination::Initial,
&gvn::GVN,
&simplify::SimplifyLocals::AfterGVN,
// === 第六阶段:高级优化 ===
&ssa_range_prop::SsaRangePropagation,
&match_branches::MatchBranchSimplification,
&dataflow_const_prop::DataflowConstProp,
&single_use_consts::SingleUseConsts,
&o1(simplify_branches::SimplifyConstCondition::AfterConstProp),
&jump_threading::JumpThreading,
&early_otherwise_branch::EarlyOtherwiseBranch,
&simplify_comparison_integral::SimplifyComparisonIntegral,
// === 第七阶段:最终清理与传播 ===
&o1(simplify_branches::SimplifyConstCondition::Final),
&o1(remove_noop_landing_pads::RemoveNoopLandingPads),
&o1(simplify::SimplifyCfg::Final),
&strip_debuginfo::StripDebugInfo,
©_prop::CopyProp,
&dead_store_elimination::DeadStoreElimination::Final,
&dest_prop::DestinationPropagation,
&simplify::SimplifyLocals::Final,
&multiple_return_terminators::MultipleReturnTerminators,
&large_enums::EnumSizeOpt { discrepancy: 128 },
// === 第八阶段:代码生成准备 ===
&add_call_guards::CriticalCallEdges,
&prettify::ReorderBasicBlocks,
&prettify::ReorderLocals,
&dump_mir::Marker("PreCodegen"),
],
Some(MirPhase::Runtime(RuntimePhase::Optimized)),
optimizations,
);
}
这个 pass 列表是经过精心排序的,每个 pass 的位置都有特定的理由。让我们用一个图来概览整个优化管线(图中把上面代码块的八段合并为六段、每段只列代表性 pass,分组同样是本专栏所加):
flowchart TD
START["Runtime MIR<br/>(PostCleanup)"]
subgraph Phase1["阶段 1:安全检查"]
C1["CheckAlignment"]
C2["CheckNull"]
C3["CheckEnums"]
end
subgraph Phase2["阶段 2:内联"]
P1["LowerSliceLenCalls"]
P2["InstSimplify::BeforeInline"]
P3["ForceInline"]
P4["Inline"]
end
subgraph Phase3["阶段 3:内联后清理"]
CL1["RemoveStorageMarkers"]
CL2["RemoveZsts / RemoveUnneededDrops"]
CL3["UnreachableEnumBranching"]
CL4["SimplifyCfg / InstSimplify"]
end
subgraph Phase4["阶段 4:核心优化"]
O1["ReferencePropagation"]
O2["SROA"]
O3["DeadStoreElimination"]
O4["GVN(全局值编号)"]
end
subgraph Phase5["阶段 5:高级优化"]
A1["DataflowConstProp"]
A2["JumpThreading"]
A3["MatchBranchSimplification"]
end
subgraph Phase6["阶段 6:最终传播"]
F1["CopyProp"]
F2["DeadStoreElimination::Final"]
F3["DestinationPropagation"]
F4["SimplifyLocals::Final"]
end
FINISH["Optimized MIR<br/>(→ LLVM IR / Cranelift)"]
START --> Phase1 --> Phase2 --> Phase3 --> Phase4 --> Phase5 --> Phase6 --> FINISH
style START fill:#64748b,color:#fff,stroke:none
style FINISH fill:#10b981,color:#333,stroke:none
style Phase2 fill:#3b82f620,stroke:#3b82f6
style Phase4 fill:#ec489920,stroke:#ec4899
style Phase6 fill:#f9731620,stroke:#f97316
15.4 控制流简化:SimplifyCfg
SimplifyCfg 是运行最频繁的 MIR pass 之一——它在整个管线中出现在 7 个位置,每次标记不同的子阶段(Initial、PromoteConsts、RemoveFalseEdges、PostAnalysis、PreOptimizations、AfterUnreachableEnumBranching、Final;其中 RemoveFalseEdges 只在启用 const_precise_live_drops 检查时才跑),此外枚举还有一个 MakeShim 变体供 shim 构建使用。
15.4.1 SimplifyCfg 的核心操作
从 compiler/rustc_mir_transform/src/simplify.rs 的实现(CfgSimplifier::simplify 与 remove_dead_blocks)可以看到,SimplifyCfg 做这几件事:
-
折叠 goto 链:跳转目标如果是「没有语句、只有一个
goto」的空块,就直接改指向这条链的终点。 -
合并基本块:如果一个基本块 B 只有一个前驱 A,且 A 的终结器是无条件跳转到 B,那么将 B 的语句合并到 A 中。
-
退化分支:所有目标都相同的
switchInt改成goto(Built / Analysis MIR 上不做,以免删掉可能构成 UB 的读取)。 -
删除不可达块:移除从入口块无法到达的死基本块。
此外,CfgSimplifier::simplify 开头还会先清掉所有 nop 语句。
// compiler/rustc_mir_transform/src/simplify.rs:79(省略注释)
pub(super) fn simplify_cfg<'tcx>(tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
if CfgSimplifier::new(tcx, body).simplify() {
body.basic_blocks.invalidate_cfg_cache();
}
remove_dead_blocks(body);
body.basic_blocks.as_mut_preserves_cfg().shrink_to_fit();
}
15.4.2 为什么 SimplifyCfg 如此重要
SimplifyCfg 的重要性体现在两个方面:
第一,它是正确性的保障。源码注释中明确指出:"此 pass 必须在任何分析 pass 之前运行,因为它会移除死块,而其中一些可能是类型不合法的。" 这是因为 typeck 允许在不可达位置返回任意类型——一旦这些块被保留到后续分析阶段,就可能导致 ICE(Internal Compiler Error)。
第二,它为后续优化创造条件。内联后可能产生大量只有单条出边的中间块,不合并这些块会增加后续 pass 的工作量。同样,不可达块传播(UnreachablePropagation)可能使一些块变得不可达,SimplifyCfg 负责清理它们。
15.4.3 实际效果示例
考虑一个对两变体枚举做 match 的函数:
pub enum E { A, B }
pub fn pick(e: E, y: i32) -> i32 {
match e {
E::A => y,
E::B => y,
}
}
用 rustc 1.96.0 -Zmir-opt-level=0 -Zdump-mir=pick 实测,SimplifyCfg-initial 之前的 MIR(省略 debug 与局部声明):
bb0: {
PlaceMention(_1);
_3 = discriminant(_1);
switchInt(move _3) -> [0: bb2, 1: bb4, otherwise: bb1];
}
bb1: {
FakeRead(ForMatchedPlace(None), _1);
unreachable;
}
bb2: {
falseEdge -> [real: bb6, imaginary: bb4];
}
bb3: {
goto -> bb1;
}
bb4: {
_0 = copy _2;
goto -> bb7;
}
bb5: {
goto -> bb1;
}
bb6: {
_0 = copy _2;
goto -> bb7;
}
bb7: {
return;
}
之后:
bb0: {
PlaceMention(_1);
_3 = discriminant(_1);
switchInt(move _3) -> [0: bb2, 1: bb3, otherwise: bb1];
}
bb1: {
FakeRead(ForMatchedPlace(None), _1);
unreachable;
}
bb2: {
falseEdge -> [real: bb4, imaginary: bb3];
}
bb3: {
_0 = copy _2;
goto -> bb5;
}
bb4: {
_0 = copy _2;
goto -> bb5;
}
bb5: {
return;
}
从入口到不了的 bb3、bb5 被删除,剩下的块重新编号。bb1 虽然以 unreachable 结尾,但它仍是 switchInt 的 otherwise 目标,在控制流图上可达,所以不会被删。等到 CleanupPostBorrowck 把 falseEdge 换成 goto、把 FakeRead 换成 nop 之后,SimplifyCfg-post-analysis 再把空的 goto 块折叠掉、清掉 nop,bb1 只剩一条 unreachable;。
15.5 函数内联:Inline
函数内联是 MIR 优化管线中影响最大的单个 pass。它将被调用函数的 MIR 直接嵌入调用点,消除了函数调用的开销,并为后续的数据流优化创造了巨大的分析窗口。
15.5.1 内联的架构设计
Rust 编译器的 MIR 内联系统(compiler/rustc_mir_transform/src/inline.rs)采用了一个优雅的 trait 抽象,统一处理两种内联模式:
// compiler/rustc_mir_transform/src/inline.rs:41 / :73 / :104(trait 只列 4 个方法,
// 省略 new / tcx / history / check_codegen_attributes_extra 等)
pub struct Inline; // 普通内联,基于成本模型
pub struct ForceInline; // 强制内联,用于 #[rustc_force_inline]
trait Inliner<'tcx> {
fn should_inline_for_callee(&self, def_id: DefId) -> bool;
fn check_callee_mir_body(
&self, callsite: &CallSite<'tcx>,
callee_body: &Body<'tcx>,
callee_attrs: &CodegenFnAttrs,
) -> Result<(), &'static str>;
fn on_inline_success(&mut self, /* ... */);
fn on_inline_failure(&self, callsite: &CallSite<'tcx>, reason: &'static str);
}
普通内联(NormalInliner)的启用条件见 Inline::is_enabled:显式给了 -Zinline-mir 就以它为准;否则 mir_opt_level 为 0 或 1 时关闭;等于 2(-O 下的默认值)时还要求 opt-level 为 2/3 且非增量编译;>= 3 时无条件开启。强制内联(ForceInliner)的 is_enabled 恒为 true,且 can_be_overridden 返回 false——不可被 -Zmir-enable-passes 关闭。
15.5.2 成本模型:是否值得内联
内联决策的核心是成本模型,定义在 compiler/rustc_mir_transform/src/cost_checker.rs 中。编译器为 MIR 中的每种构造分配一个"惩罚"值:
// compiler/rustc_mir_transform/src/cost_checker.rs:6-11
const INSTR_COST: usize = 5; // 普通指令(赋值等)
const CALL_PENALTY: usize = 25; // 函数调用
const LANDINGPAD_PENALTY: usize = 50; // landing pad(unwind 处理)
const RESUME_PENALTY: usize = 45; // resume(继续 unwinding)
const LARGE_SWITCH_PENALTY: usize = 20; // 大型 switch
const CONST_SWITCH_BONUS: usize = 10; // 常量 switch(可以优化掉)的奖励
CostChecker 遍历被调用函数的 MIR,累计总惩罚并减去奖励,得到最终成本。它还有一个函数级别的奖励机制——如果被调用函数只有一个以调用类终结器(Call、Drop、Assert 等)结尾的基本块,则额外奖励 CALL_PENALTY 分,因为内联它不会增加整体调用次数。
15.5.3 内联阈值
计算出成本后,与阈值比较:
// compiler/rustc_mir_transform/src/inline.rs:378
let mut threshold = if self.caller_is_inline_forwarder || self.past_depth_limit() {
tcx.sess.opts.unstable_opts.inline_mir_forwarder_threshold.unwrap_or(30)
} else if tcx.cross_crate_inlinable(callsite.callee.def_id()) {
tcx.sess.opts.unstable_opts.inline_mir_hint_threshold.unwrap_or(100)
} else {
tcx.sess.opts.unstable_opts.inline_mir_threshold.unwrap_or(50)
};
// 小函数(3 个块以内)获得 25% 的阈值提升
if callee_body.basic_blocks.len() <= 3 {
threshold += threshold / 4;
}
三个阈值对应三种场景:
| 场景 | 默认阈值 | 说明 |
|---|---|---|
| 普通函数 | 50 | 未标记 #[inline] 的本地函数 |
| 跨 crate 可内联 | 100 | 标记了 #[inline] 或满足跨 crate 内联条件 |
| 转发器/深度限制 | 30 | 调用者本身带 #[inline] 且是转发器,或内联深度已超限 |
15.5.4 内联深度控制
为防止无限递归和指数级代码膨胀,内联器维护了两个深度限制:
// compiler/rustc_mir_transform/src/inline.rs:28-29
const HISTORY_DEPTH_LIMIT: usize = 20; // 调用栈深度限制
const TOP_DOWN_DEPTH_LIMIT: usize = 5; // 顶层多调用点内联限制
history 记录了从当前函数一路内联进来的所有 DefId:被调函数的 DefId 已在其中就不再内联,递归(包括多态递归)因此被截断。top_down_counter 则追踪从顶层开始内联了多少个包含多个调用的函数——这是防止代码膨胀的关键机制。
两个限制任一被超过(past_depth_limit)后,内联并不立即停止,而是收紧:只考虑带 #[inline] 类标注、且只有一个基本块的被调函数,阈值也降到 30。
15.5.5 MIR 内联 vs LLVM 内联
为什么 Rust 要在 MIR 层面做内联,而不是完全交给 LLVM?
-
在泛型 MIR 上一次完成:MIR 内联发生在单态化之前,作用于每个函数只有一份的泛型 MIR。调用点的类型参数已经确定时(
Instance::try_resolve能把 trait 方法解析到具体 impl),就能在这里内联;内联结果随后被该函数的所有单态化实例共享,不必让 LLVM 对每个实例各做一遍。调用点的类型仍依赖泛型参数时,trait 方法要等到单态化才能解析,这里内联不了。 -
跨 crate 内联:Rust 可以将 MIR 序列化到 rlib 中,使得跨 crate 的内联成为可能,而不需要等到 LTO(Link Time Optimization)阶段。
-
优化级联效应:MIR 内联后,后续的常量传播、死存储消除等 pass 可以在更大的分析窗口上工作,产生比 LLVM 单独做更好的优化效果。
-
编译时间:MIR 内联可以在早期就消除小函数调用,有望减少传递给 LLVM 的代码量、从而减轻后端负担(这是推断,本专栏没有做对应的测量)。
15.6 全局值编号:GVN
GVN(Global Value Numbering)是 MIR 优化管线中最强大的数据流优化之一。它的目标是检测 MIR 中的冗余计算,并用已经计算好的结果来替换它们。
15.6.1 GVN 的工作原理
从 compiler/rustc_mir_transform/src/gvn.rs 的文档可以看到,GVN 为每个 SSA 局部变量的赋值计算一个"符号值"(Value),并将其内部化为一个 VnIndex。如果两个不同的赋值产生了相同的 VnIndex,说明它们计算的是同一个值。
其操作语义可以概括为:
_a = some_computation // 获得 VnIndex i
// ... 一些 MIR ...
_b = same_computation // 也获得 VnIndex i
可以被替换为:
_a = some_computation // 获得 VnIndex i
// ... 一些 MIR ...
_b = _a // 直接复用已有的值
15.6.2 引用处理的精妙设计
GVN 处理引用时有一个精妙的设计:它为每个 Ref/RawPtr rvalue 分配一个不同的"来源"(provenance)索引,确保不会错误地合并不应合并的借用:
_x = &_a;
_a = 0;
_y = &_a; // 不能替换为 _y = _x!
但对于不可变引用指向的 freeze 类型,GVN 认为所有通过该引用的解引用都产生相同的值,这使得它能优化类似 *x + *x 的模式。
15.6.3 GVN 的位置
GVN 被安排在 ReferencePropagation 和 SROA(标量替换聚合体)之后运行。一种合理的解释是:SROA 将结构体和元组拆分为独立的标量变量,为 GVN 创造了更多发现冗余值的机会(lib.rs 的注释没有写明排序理由)。GVN 之后紧接着的 SimplifyLocals::AfterGVN 负责清除被 GVN 使其无用的局部变量。
15.7 常量传播:从简单到数据流
Rust 编译器中的常量传播分为两个层次,对应两个不同的 pass。
15.7.1 GVN 中的常量折叠
GVN pass 除了进行值编号外,还会进行常量折叠:登记每个新的值时,它都会尝试把它当作常量求值。例如:
fn const_example() -> i32 {
let x = 3;
let y = 5;
x + y
}
用 rustc 1.96.0 -O -Zdump-mir='const_example & GVN' 实测,GVN 之前的 bb0:
bb0: {
StorageLive(_1);
_1 = const 3_i32;
StorageLive(_2);
_2 = const 5_i32;
StorageLive(_3);
_3 = copy _1;
StorageLive(_4);
_4 = copy _2;
_0 = Add(move _3, move _4);
StorageDead(_4);
StorageDead(_3);
StorageDead(_2);
StorageDead(_1);
return;
}
GVN 之后:
bb0: {
nop;
_1 = const 3_i32;
nop;
_2 = const 5_i32;
StorageLive(_3);
_3 = const 3_i32;
StorageLive(_4);
_4 = const 5_i32;
_0 = const 8_i32;
StorageDead(_4);
StorageDead(_3);
nop;
nop;
return;
}
GVN 知道 _3、_4 的值分别是 3 和 5,把它们的赋值换成常量,并直接算出 3 + 5 = 8。剩下那些已经没人读的赋值由后面的 pass 清理,最终的优化后 MIR 里 bb0 只剩 _0 = const 8_i32; 和 return;。
15.7.2 DataflowConstProp:基于数据流的常量传播
更强大的 DataflowConstProp(compiler/rustc_mir_transform/src/dataflow_const_prop.rs)使用数据流分析框架进行常量传播。GVN 只跟踪 SSA 局部变量(只赋值一次的变量)里存放的值,DataflowConstProp 则能追踪被多次赋值的变量,处理控制流的不同分支汇聚后某些变量的值仍然可以确定的情况。
DataflowConstProp 在 mir_opt_level >= 3 时才启用(-O 的默认值是 2,所以默认不跑),并且对分析的规模有限制:
// compiler/rustc_mir_transform/src/dataflow_const_prop.rs:30-33
// 这些常量是启发式选择的,未经严格优化
const BLOCK_LIMIT: usize = 100; // 基本块数量上限
const PLACE_LIMIT: usize = 100; // 追踪的 Place 数量上限
基本块数超过 BLOCK_LIMIT 时整个 pass 直接跳过;PLACE_LIMIT 限制追踪的 place 数量。在 mir_opt_level >= 4 时,这些限制被解除,但这可能导致编译时间大幅增加。
15.7.3 常量条件分支简化
常量传播的一个重要后续优化是 SimplifyConstCondition,它在管线中出现三次:
AfterInstSimplify:在指令简化之后AfterConstProp:在常量传播之后Final:最终清理
当 switchInt 的判别式被传播为常量时,这个 pass 将多路分支替换为无条件跳转。以 let c = true; if c { x } else { x + 1 } 为例,用 -O -Zdump-mir='branch & SimplifyConstCondition' 实测,GVN 已经把判别式换成常量,SimplifyConstCondition-after-const-prop 前后的 bb0:
// 优化前
bb0: {
nop;
switchInt(const true) -> [0: bb2, otherwise: bb1];
}
// 优化后
bb0: {
nop;
goto -> bb1;
}
这会使 bb2 变为不可达,后续的 SimplifyCfg::Final 将其移除。
15.8 死存储消除:DeadStoreElimination
死存储消除(DSE)删除那些写入值之后再也不会被读取的赋值语句。它在管线中运行两次——Initial 和 Final,其中 Final 紧排在 DestinationPropagation 之前。按模块注释,这个 pass 就是专门为配合目标传播而写的。
15.8.1 基于活跃性分析的消除
DSE 的核心依赖于 MaybeTransitiveLiveLocals 数据流分析。这个分析从每个基本块的出口向入口反向传播,计算在每个程序点哪些局部变量是"活跃的"(即后续会被读取)。
// compiler/rustc_mir_transform/src/dead_store_elimination.rs:33 起——**骨架**:
// 源码在活跃性分析之后还有一整段 :44-76 的 call 参数扫描(把不再活跃的 Copy 实参
// 记进 call_operands_to_move,即 §15.8.2 讲的 Copy→Move 提升),下面只留了死存储
// 判定这一条主线,`/* 可被消除的语句 */` 处对应源码的语句种类匹配。
fn eliminate<'tcx>(tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) -> bool {
let borrowed_locals = borrowed_locals(body);
let debuginfo_locals = debuginfo_locals(body);
let mut live = MaybeTransitiveLiveLocals::new(&borrowed_locals, &debuginfo_locals)
.iterate_to_fixpoint(tcx, body, None)
.into_results_cursor(body);
// 对于每个赋值语句,检查目标是否在该点之后是活跃的
for (statement_index, statement) in bb_data.statements.iter().enumerate().rev() {
if let Some(destination) = /* 可被消除的语句 */ {
live.seek_before_primary_effect(loc);
if !live.get().contains(destination.local) {
// 目标变量不活跃——这个赋值是死存储
patch.push((loc, drop_debuginfo));
}
}
}
}
15.8.2 Copy 到 Move 的提升
DSE 还有一个巧妙的附加优化:当函数调用的参数以 Copy 方式传递,但该参数在调用后不再被使用时,DSE 会将 Copy 提升为 Move。这为后续的代码生成提供了"就地传递"(in-place passing)的机会。
15.8.3 幂等性保证
DSE 文档中强调了一个重要的不变量:此 pass 是幂等的。运行两次 DSE 不会产生额外的变化。更进一步,在两次 DSE 之间插入任意多轮 DestinationPropagation,第二次 DSE 仍然不会再产生变化。模块注释说明,这两条是目标传播对 DSE 提出的额外要求。
15.9 拷贝传播与目标传播
15.9.1 CopyProp:统一拷贝等价类
拷贝传播(compiler/rustc_mir_transform/src/copy_prop.rs)的目标是识别和统一那些彼此拷贝的局部变量。它处理以下模式:
_a = rvalue
_b = move _a // 或 copy _a
_c = move _a // 或 copy _a
_d = move _c // 或 copy _c
CopyProp 使用 SSA(Static Single Assignment)分析来构建"拷贝等价类"——所有通过拷贝链相连的局部变量形成一个等价类,最终全部被替换为该类的代表元素:
// compiler/rustc_mir_transform/src/copy_prop.rs:20
pub(super) struct CopyProp;
impl<'tcx> crate::MirPass<'tcx> for CopyProp {
// :23-25 的 is_enabled 要求 mir_opt_level() >= 1,:56-58 的 is_required 返回 false
fn run_pass(&self, tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
let typing_env = body.typing_env(tcx);
let ssa = SsaLocals::new(tcx, body, typing_env);
// 构建拷贝等价类
let mut any_replacement = false;
let mut unified = DenseBitSet::new_empty(body.local_decls.len());
for (local, &head) in ssa.copy_classes().iter_enumerated() {
if local != head {
// local 和 head 在同一个等价类中
any_replacement = true;
unified.insert(head);
unified.insert(local);
}
}
if !any_replacement {
return; // 一个等价类都没有,直接不干活
}
// 执行替换:所有等价类成员替换为代表元素
Replacer { tcx, copy_classes: ssa.copy_classes(), unified }
.visit_body_preserves_cfg(body);
// 清除不再使用的变量定义
crate::simplify::remove_unused_definitions(body);
}
}
15.9.2 DestinationPropagation:反向传播赋值目标
目标传播(compiler/rustc_mir_transform/src/dest_prop.rs)是 MIR 优化中最精巧的 pass 之一,也是管线中最后运行的数据传播优化。它类似于 C++ 中的 NRVO(Named Return Value Optimization),但不限于返回值。
其核心思想是:对于赋值语句 dest = src,如果 dest 和 src 的生存期不重叠,则可以将所有对 src 的引用替换为 dest,然后删除这条赋值。
健全性检查包括:
src和dest必须是常量位置(不含间接引用或索引投影);目前实现进一步要求两者都是不带任何投影的局部变量- 两者必须具有完全相同的类型(不是子类型,是同一类型)
- 两者的活跃范围必须不相交
- 两者都不能被借用(被取地址的局部变量被排除在外)
目标传播直接解决了 MIR 构建过程中引入的大量冗余拷贝问题。源码注释中指出,LLVM 本身不擅长消除这类冗余(参见 rust-lang/rust#32966),因此在 MIR 层面解决它对最终代码质量有显著影响。
15.10 Drop 展开:从条件到确定
Drop 展开(compiler/rustc_mir_transform/src/elaborate_drops.rs)是 MIR 从 Analysis 方言转换为 Runtime 方言的核心步骤之一。它解决了一个根本性问题:在任意控制流下,如何正确地确定哪些值需要被 drop?
15.10.1 问题的本质
在 MIR 构建阶段,编译器在每个可能发生 drop 的位置插入 Drop 终结器。但此时,这些 drop 是条件性的——一个变量可能在某些控制流路径上已被移走,在另一些路径上仍然有效。
考虑以下代码:
fn example(condition: bool) {
let s = String::from("hello");
if condition {
drop(s); // 路径 A:s 被显式 drop
}
// 路径 B:s 可能还需要在函数结束时被 drop
// 但如果走了路径 A,s 已经被 drop 了
}
15.10.2 Drop 标志机制
Drop 展开通过插入drop 标志(drop flags)来解决这个问题。对于在 drop 点上可能已初始化、也可能未初始化的变量,编译器引入一个布尔值的局部变量来追踪其初始化状态:
// compiler/rustc_mir_transform/src/elaborate_drops.rs:48 起(简化:省略 typing_env 的构造、
// 两个分析上的 exclude_inactive_in_otherwise 等配置调用,以及 ElaborateDropsCtxt::elaborate)
pub(super) struct ElaborateDrops;
impl<'tcx> crate::MirPass<'tcx> for ElaborateDrops {
fn run_pass(&self, tcx: TyCtxt<'tcx>, body: &mut Body<'tcx>) {
let move_data = MoveData::gather_moves(body, tcx,
|ty| ty.needs_drop(tcx, typing_env) // 只追踪需要 drop 的类型
);
let mut inits = MaybeInitializedPlaces::new(tcx, body, &env.move_data)
.iterate_to_fixpoint(tcx, body, Some("elaborate_drops"));
let uninits = MaybeUninitializedPlaces::new(tcx, body, &env.move_data)
.iterate_to_fixpoint(tcx, body, Some("elaborate_drops"));
// 使用初始化/未初始化信息来决定每个 Drop 终结器是否应该执行
}
}
展开后的 MIR 中,原本的条件性 Drop 被替换为:用 switchInt 检查 drop 标志 → 标志为真时跳到一个 drop 终结器(此时已是无条件的)→ 继续执行。对上面的 example 用 -Zdump-mir='example & ElaborateDrops' 实测(节选):_6 就是 s(_2)的 drop 标志,String::from 之前置为 true,drop(s) 那条分支在移走 _2 之前置为 false,函数末尾:
bb10: {
drop(_2) -> [return: bb6, unwind: bb9];
}
bb11: {
switchInt(copy _6) -> [0: bb6, otherwise: bb10];
}
15.10.3 与借用检查的关系
这里有一个关键的时序关系:借用检查运行在 Drop 展开之前。
这意味着借用检查器看到的是条件性的 Drop,它需要考虑所有可能的控制流路径来确定生命周期约束。而 Drop 展开后,这些条件已经被解析为确定性的控制流,适合代码生成。
正如我们在第 3-4 章中讨论的 NLL,借用检查器正是利用 MIR 的控制流图来计算每个借用的精确生命周期。Drop 的位置直接影响了这些生命周期的终点。
15.11 其他重要优化 Pass
15.11.1 ScalarReplacementOfAggregates(SROA)
SROA 将聚合类型(结构体、元组)的局部变量拆分为独立的标量变量。这使得后续的 GVN、CopyProp 等 pass 可以单独操作每个字段。
例如,对 let mut p = (a, b); p.0 += 1; p.0 * p.1 用 -O -Zdump-mir 实测:(i32, i32) 类型的 _3(即 p)被拆成新分配的两个 i32 局部 _8 和 _9,(_3.0: i32) = Add(copy (_3.0: i32), const 1_i32) 变成 _8 = Add(copy _8, const 1_i32),原来构造元组的 _3 = (move _4, move _5) 变成 _8 = move _4; _9 = move _5; 两条赋值。
15.11.2 ReferencePropagation
引用传播消除了不必要的引用创建和解引用对。当一个引用只用于立即解引用时,这个间接层可以被消除。它排在 InstSimplify::AfterSimplifyCfg 和 SimplifyConstCondition::AfterInstSimplify 之后;lib.rs 里的注释说明,InstSimplify 会使 CFG 缓存失效,所以不能夹在都要用支配树的 ReferencePropagation 和 GVN 之间。
15.11.3 JumpThreading
跳转线程化是一种控制流优化,它在判别式已知的情况下"跳过"不必要的分支点。例如:
unsafe extern "C" {
safe fn left() -> i32;
safe fn right() -> i32;
}
pub fn jt(x: u8) -> i32 {
let flag = if x == 0 { left(); true } else { right(); false };
if flag { 10 } else { 20 }
}
用 -O -Zdump-mir='jt & JumpThreading' 实测,JumpThreading 之前(节选):
bb2: {
StorageDead(_4);
_2 = const true;
goto -> bb5;
}
bb4: {
StorageDead(_5);
_2 = const false;
goto -> bb5;
}
bb5: {
StorageDead(_3);
StorageLive(_6);
_6 = copy _2;
switchInt(move _6) -> [0: bb7, otherwise: bb6];
}
之后(节选):
bb2: {
StorageDead(_4);
_2 = const true;
goto -> bb9;
}
bb4: {
StorageDead(_5);
_2 = const false;
goto -> bb10;
}
bb9: {
StorageDead(_3);
StorageLive(_6);
_6 = copy _2;
goto -> bb6;
}
bb10: {
StorageDead(_3);
StorageLive(_6);
_6 = copy _2;
goto -> bb7;
}
JumpThreading 发现从 bb2 进入 bb5 时 _2 一定是 true、从 bb4 进入时一定是 false,于是为两条路径各复制一份 bb5 的语句(bb9、bb10),末尾的 switchInt 换成直达目标的 goto。原来的 bb5 不再有前驱,留给后面的 SimplifyCfg 删除。
15.11.4 UnreachableEnumBranching
当枚举类型被实例化后,编译器可能知道某些变体不可能出现(例如变体里含有 uninhabited 类型,像 Result<T, Infallible> 的 Err)。这个 pass 将不可能的分支标记为 unreachable,使后续的 SimplifyCfg 可以清除它们。
15.11.5 EnumSizeOpt
EnumSizeOpt 是一个有趣的拷贝优化。当枚举的不同变体之间大小差异超过 128 字节(discrepancy: 128)时,移动这种枚举本来要按"最大变体"的字节数做整体拷贝;这个 pass 改为在运行时读出当前活跃的判别式,查一张"判别式索引的变体大小表",只 memcpy 当前变体实际占用的字节。
需要注意的是,在 1.96.0 中这个 pass 默认不运行:is_enabled 要求 sess.opts.unstable_opts.unsound_mir_opts && sess.mir_opt_level() >= 3——源码注释说明它在 wasm 与 ARM 上有尚未厘清的行为差异(rust-lang/rust#154413),因此被保守地当作 unsound 优化关闭。
15.12 MIR 的查看与调试
理解如何查看和调试 MIR 对于深入理解编译器行为至关重要。Rust 编译器提供了丰富的 MIR 输出工具。
15.12.1 基本的 MIR 输出
# 把优化后的 MIR 写入 .mir 文件
cargo rustc -- --emit=mir
# 把优化后的 MIR 直接打印到标准输出
cargo +nightly rustc -- -Zunpretty=mir
# 输出 CFG 的 graphviz .dot 描述(不是 MIR 文本)
cargo +nightly rustc -- -Zunpretty=mir-cfg
--emit=mir 与 -Zunpretty=mir 走的都是 rustc_middle::mir::write_mir_pretty,打印的是 optimized_mir 查询的结果(对 const fn 还会额外打印一份 mir_for_ctfe);要看某个 pass 之前/之后的中间状态,得用下面的 -Zdump-mir。
15.12.2 控制 MIR 优化级别
# 关闭所有 MIR 优化
cargo +nightly rustc -- -Zmir-opt-level=0
# 基础优化(CopyProp 等;不加 -O 时的默认级别)
cargo +nightly rustc -- -Zmir-opt-level=1
# 完整优化(-O 时的默认级别;GVN、SROA、ReferencePropagation、JumpThreading 等;
# 内联在这一级还额外要求 opt-level 为 2/3 且非增量编译)
cargo +nightly rustc -- -Zmir-opt-level=2
# 激进优化(内联无条件开启,并加入 DataflowConstProp)
cargo +nightly rustc -- -Zmir-opt-level=3
15.12.3 Dump 特定 Pass 的 MIR
Rust 编译器可以在每个 pass 前后 dump MIR 到文件:
# dump 所有 pass 的 MIR(生成大量文件)
RUSTFLAGS="-Zdump-mir=all" cargo +nightly build
# 只 dump 特定 pass
RUSTFLAGS="-Zdump-mir=Inline" cargo +nightly build
# 只 dump 特定函数
RUSTFLAGS="-Zdump-mir=my_function" cargo +nightly build
# 组合条件:& 表示同时满足,| 表示任一满足(RUSTFLAGS 按空格切分参数,这里不要加空格)
RUSTFLAGS="-Zdump-mir=my_function&Inline" cargo +nightly build
过滤条件是子串匹配:pass 名或函数路径包含该字符串即算命中(compiler/rustc_middle/src/mir/pretty.rs:84-91),所以 Inline 也会命中 ForceInline。dump 的文件会被写入 mir_dump/ 目录,文件名包含 pass 名称和阶段信息,便于对比优化前后的变化。
15.12.4 阅读 MIR 输出
MIR 输出中需要注意以下约定:
_0:返回值位置_1, _2, ...:前 N 个是参数(N =arg_count),之后是局部变量bb0, bb1, ...:基本块编号const 42_i32:类型标注的常量move _1vscopy _1:移动语义 vs 复制语义StorageLive(_x)/StorageDead(_x):变量存储生命周期标记
15.12.5 实战:观察内联效果
// 你自己 crate 的 lib.rs
#[inline]
fn add_one(x: i32) -> i32 {
x + 1
}
pub fn compute(a: i32) -> i32 {
let b = add_one(a);
let c = add_one(b);
c
}
不加 -O 时 mir-opt-level 是 1,普通内联不会运行。用 -O -Zdump-mir='compute & Inline' 编译(rustc 1.96.0 实测),对比内联前后的 compute 函数 MIR(只列基本块):
内联前:
bb0: {
StorageLive(_2);
StorageLive(_3);
_3 = copy _1;
_2 = add_one(move _3) -> [return: bb1, unwind continue];
}
bb1: {
StorageDead(_3);
StorageLive(_4);
StorageLive(_5);
_5 = copy _2;
_4 = add_one(move _5) -> [return: bb2, unwind continue];
}
bb2: {
StorageDead(_5);
_0 = copy _4;
StorageDead(_4);
StorageDead(_2);
return;
}
内联后:
bb0: {
StorageLive(_2);
StorageLive(_3);
_3 = copy _1;
_2 = Add(copy _3, const 1_i32);
StorageDead(_3);
StorageLive(_4);
StorageLive(_5);
_5 = copy _2;
_4 = Add(copy _5, const 1_i32);
StorageDead(_5);
_0 = copy _4;
StorageDead(_4);
StorageDead(_2);
return;
}
两次函数调用被消除,变成了直接的算术运算,三个基本块也合成了一个。后续 pass 再清掉中间的临时变量,最终优化后的 MIR 里 bb0 只剩 _2 = Add(copy _1, const 1_i32);、_0 = Add(copy _2, const 1_i32); 和 return; 三行。
15.13 设计权衡:MIR 优化 vs LLVM 优化
一个自然的问题是:既然 LLVM 已经拥有成熟的优化管线,为什么 Rust 还要在 MIR 层面做优化?这涉及一系列设计权衡。
15.13.1 MIR 优化的优势
领域知识:MIR 保留了 Rust 的类型系统和所有权语义。编译器知道 Vec<T> 的析构函数做什么,知道 Copy 类型可以安全地复制,知道 &T 指向不含 UnsafeCell 的类型时值不会变(§15.6.2)。LLVM 看到的只是字节、指针和函数调用。
代码量减少:MIR 层面的优化(特别是内联和死代码消除)可以减少传递给 LLVM 的 IR 数量,进而有望缩短 LLVM 的编译时间(推断,本专栏未做测量)。
独立于后端:MIR 优化对所有代码生成后端(LLVM、Cranelift、GCC)都有效。在切换后端时,这些优化的收益不会丢失。
15.13.2 LLVM 优化的优势
成熟度:LLVM 的优化管线经过了数十年的发展和数百万行代码的实战检验,包含了大量精妙的底层优化。
硬件感知:LLVM 了解目标架构的指令集、流水线特性和缓存层次,可以做出 MIR 层面无法做到的低级优化决策(如向量化、指令调度)。
优化间的协同:LLVM 的优化 pass 之间有深度的协同关系,经过精心调优。
15.13.3 两层互补的哲学
实际上,MIR 优化和 LLVM 优化并非竞争关系,而是互补关系:
- MIR 负责高级语义优化:利用 Rust 的类型系统和所有权信息进行内联、常量传播、死存储消除等
- LLVM 负责低级机器优化:向量化、指令选择、寄存器分配、循环优化等
这种分层设计使得每一层都可以专注于自己最擅长的事情,同时避免了在底层重复实现高级语义分析。
15.13.4 编译时间的平衡
一个持续的工程挑战是平衡优化质量和编译时间。MIR 优化管线中多处体现了这种权衡:
DataflowConstProp在mir_opt_level >= 3时才启用,因为其数据流分析的时间复杂度较高- 内联历史超过 20 层或顶层多调用展开超过 5 次后,内联收紧为只考虑单基本块、带
#[inline]类标注的被调函数 - 多个 pass 使用
WithMinOptLevel包装,在低优化级别时被跳过 EnumSizeOpt的差异阈值设为 128 字节,避免为很小的大小差异付出查表和分支的代价(该 pass 在 1.96.0 默认不运行,见 §15.11.5)
15.13.5 实测:MIR 相关 4 个目录共约 80,000 行
把本章贯穿讨论的 MIR 相关 4 个目录实测(find <目录> -name '*.rs' | xargs cat | wc -l,物理行数,含文档注释和空行)——
| crate | 行 | 角色 |
|---|---|---|
compiler/rustc_mir_transform |
34654 | 95 个 .rs 文件(src/ 顶层 78 个)、本章 §15.4-15.11 全部 30+ 优化 pass |
compiler/rustc_mir_build |
22451 | HIR → MIR 构建(§15.3 主角) |
compiler/rustc_middle/src/mir |
15770 | MIR 数据结构定义(§15.2 Body / BasicBlock / Statement / Terminator / Place / Operand / Rvalue 全部) |
compiler/rustc_mir_dataflow |
7241 | 数据流分析框架(liveness / borrowck / 等共享) |
| MIR 基础设施合计 | ~80,116 | — |
rustc_mir_transform/src/ 顶层 78 个文件、最大 9 个——
| 文件 | 行 | 角色 |
|---|---|---|
gvn.rs |
2063 | §15.6 全局值编号最重——SSA 风格的值统一 |
coroutine.rs |
2011 | ch09 §9.13 已测——async fn 状态机变换(不是普通 MIR 优化、但是 MIR pass) |
validate.rs |
1695 | MIR 合法性检查(逐 pass 校验需 -Zvalidate-mir;其中的 validate_types 也被内联器调用) |
liveness.rs |
1504 | check_liveness:为未使用变量 / 赋值类 lint 做活跃性分析 |
elaborate_drop.rs |
1497 | §15.10 Drop 展开的 drop 梯构造(pass 入口 elaborate_drops.rs 另有 518 行) |
inline.rs |
1418 | §15.5 函数内联(含 50/100/30 阈值;成本常量在 cost_checker.rs) |
shim.rs |
1253 | shim 函数生成(drop_in_place / clone 等) |
jump_threading.rs |
1128 | 跳转线程化 |
dataflow_const_prop.rs |
1096 | §15.7 数据流常量传播 |
| 其余 69 个顶层文件 | 16405 | 顶层 78 个文件合计 30070;再加上 src/ 子目录里的 17 个文件才是整 crate 的 34654 |
两条值得记住的物理事实——
- 这 4 个目录共 80,116 行——ch04 §4.12 的
rustc_borrowck34458 行、ch16 §16.15 的三个 codegen crate 共 78,306 行也是整 crate 按同样方法统计的,可以直接比较:MIR 这 4 个目录与三个 codegen crate 体量相当,约为借用检查 crate 的 2.3 倍。§15.1.2 说的借用检查、领域级优化、代码生成统一入口三件事,都建立在这一层之上 gvn.rs2063 +coroutine.rs2011 = 4074 行,约占rustc_mir_transform的 12%——两个最大文件几乎同等大小,但做的是完全不同的事:GVN 是经典编译器优化,coroutine 是 ch09 讲的 async fn 状态机变换;它们都是 MIR pass,功能却互不相干——优化 pass 和语义变换 pass 共用 MIR 这个数据结构,只是运行在不同的阶段
和其他章的实测放在一起看:ch04 借用检查 34458、ch05 内存布局 7924、ch06 单态化 4840、ch08 trait object 1946、ch09 协程 2011、ch10 core 里的 Pin/Waker/Future 4260、ch11 闭包 6964、ch14 §14.10.3 自选三处宏系统源码 19944、ch16 codegen 78306、ch17 增量编译 9022。要说明口径:这些数字是各章按本章主题挑选的 crate、目录或若干文件,用 wc -l 计的物理行数(含文档注释和空行);有的是整 crate,有的只是几个文件,ch10 统计的是标准库 library/core 而不是编译器,ch14 的三处里也有一处是 library/proc_macro;ch09 的 coroutine.rs 2011 行本身就在本节的 rustc_mir_transform 里。所以把它们相加得不到 rustc 的工程总量,也算不出有意义的占比,只能说明本节这 4 个目录属于本专栏测过的各块里最大的一档,与 codegen 相当。
15.14 总结与展望
MIR 是 Rust 编译器架构中最具创新性的设计之一。它不仅是一个中间表示,更是连接类型系统、所有权语义和机器码生成的桥梁。
本章要点回顾
-
MIR 的三层方言(Built → Analysis → Runtime)使得同一数据结构可以服务于不同的编译阶段,每个阶段有清晰的语义约束。
-
核心数据结构(Body、BasicBlock、Statement、Terminator、Place、Operand、Rvalue)形成了一个表达力强且分析友好的控制流图表示。
-
优化管线包含 30+ 个 pass,按照精心设计的顺序执行:先内联以扩大分析窗口,再进行核心数据流优化(GVN、SROA),最后进行传播类优化(CopyProp、DestProp)清理冗余。
-
内联决策基于成本模型,默认阈值 50(普通函数)/ 100(跨 crate
#[inline]函数),考虑了指令成本、调用惩罚和深度限制。 -
Drop 展开将条件性 Drop 转化为确定性 Drop,是 Analysis MIR 到 Runtime MIR 转换的关键步骤。
-
MIR 与 LLVM 的优化是互补的:MIR 利用 Rust 的语义信息进行高级优化,LLVM 负责底层的机器级优化。
未来发展方向
MIR 优化管线仍在积极发展中。一些值得关注的方向包括:
- 更精确的内联策略:考虑调用频率(profile-guided)和调用图全局信息
- 更强的别名分析:利用 Rust 的借用规则为 MIR 优化提供比 LLVM 的 TBAA 更精确的别名信息
- 增量编译与 MIR 优化的协同:在增量编译模式下更好地利用缓存的 MIR 优化结果
- DestinationPropagation 的增强:支持带投影的位置合并(如
_5.foo与_6)
下一章,我们将跨过 MIR 与 LLVM IR 的边界,看 Rust 的类型信息和优化后的 MIR 如何被翻译为 LLVM IR——这是从 Rust 语义到机器语义的最后一步转换。
延伸阅读:MIR 为什么单独存在
Rust 为什么不直接把 HIR 降到 LLVM IR?两层理由。语义层:借用检查、drop 时机、panic 展开是 Rust 专属语义,LLVM IR 的抽象层次过低无法表达——早期 Rust 确实在 AST 上做借用检查,AST 的嵌套结构不适合做控制流分析,切换到 MIR 之后 NLL 才成为可能(本专栏 ch03 §3.2)。性能层:Rust 专属的优化需要在"懂 Rust 语义的层次"做——例如 GVN 利用"&T 指向 Freeze 类型时值不变"合并解引用(§15.6.2),MIR 内联在泛型 MIR 上一次完成、由所有单态化实例共享(§15.5.5);Option<NonNull<T>> 的 niche 布局(ch05 §5.5)同样是在进入 LLVM 之前的布局计算里决定的。到了 LLVM IR,这些语义只剩 noalias 之类属性能表达的一部分。
和其他编译器的对照:Swift 有 SIL(Swift Intermediate Language),Scala/Kotlin 有 bytecode 层面的类似中间表示,GCC 有 GIMPLE/RTL——需要语言专属语义优化的系统大多会引入自己的中间表示。1702 行的 compiler/rustc_middle/src/mir/mod.rs 加上 34654 行的 rustc_mir_transform(均为 wc -l 物理行,见本专栏 §15.13.5)是这一设计决策在源码里的体量。