《rustc 内幕》第 1 章 全景:rustc 编译流水线与 crate 版图
1.1 本章要解决的问题
任何一个"编译器是怎么工作的"介绍,都会给出这样一张图:源码 → 词法 → 语法 → 语义 → 代码生成 → 目标文件。这张图没有错,但它几乎不含信息量。真正决定一个编译器工程质量的,是这张图背后的这些问题:
- 阶段之间用什么数据结构传递信息?是树、图、还是带索引的表?
- 每个阶段的输入是否必须"完整",还是可以按需计算(lazy)?
- 一个阶段出错后,后面的阶段还跑不跑?错误如何不丢失、不重复?
- 多个函数、多个模块能否并行处理?并行时如何共享只读全局上下文?
- 增量编译时,哪些中间结果可以复用?如何判定"没有变化"?
rustc 对这五个问题的回答,构成了它区别于教学编译器的核心。本章先建立全局图景:一次 rustc 调用从 main 到目标文件,究竟经过了哪些函数、哪些 crate、哪些数据形态。后续 29 章都是对本章某个节点的放大。
阅读本章时请始终抓住一条主线:数据形态的逐级降低(lowering)。rustc 里大量函数名带 lower、build、codegen,它们都在做同一件事——把一种表示翻译成另一种更低级、但信息更明确的表示。
1.2 七种表示:同一份程序的七次变形
在进入源码之前,先给出贯穿全系列的心智模型。同一段 Rust 程序,在 rustc 内部会依次以七种形态存在:
① 字节流 / UTF-8 源码
│ rustc_lexer
▼
② Token 流(TokenKind + 长度)
│ rustc_parse(内部用 rustc_parse::lexer 转换为"宽 token")
▼
③ AST(rustc_ast::Crate)
│ rustc_expand(宏展开,可能反复多轮)
▼
④ 展开后的 AST
│ rustc_resolve(名称解析,给 AST 路径打上 Res)
▼
⑤ 带解析结果的 AST
│ rustc_ast_lowering
▼
⑥ HIR(rustc_hir::Crate,带 HirId,已去糖)
│ rustc_mir_build
▼
⑦ MIR(rustc_middle::mir::Body,控制流显式)
│ rustc_codegen_ssa / rustc_codegen_llvm
▼
LLVM IR → 目标文件
注意两个容易被忽略的事实:
- AST 不是一次成型的。宏展开会在 AST 上反复进行,直到没有可展开的宏。因此 ③ 与 ④ 之间不是一次线性变换,而是一个不动点迭代(fixpoint iteration)。
- HIR 是"去糖"的分界线。大量语法糖(
for循环、?、if let、闭包、async)在 AST 中保留原貌,到 HIR 才被降级为更基础的构造。这决定了"语法错误"与"语义错误"的归属层。
下面的表格是本章最重要的一张表,它概括了三种核心 IR 的差异:
| 维度 | AST(rustc_ast) | HIR(rustc_hir) | MIR(rustc_middle::mir) |
|---|---|---|---|
| 是否有稳定 id | 无(节点靠指针/NodeId 临时标识) | 有 HirId,由 HirId 分配器统一编号 | 有 Local、BasicBlock 数字索引 |
| 是否保留语法糖 | 保留 | 已去糖 | 已完全展开为基本操作 |
| 是否保留类型 | 只有显式书写的类型 | 只有显式书写的类型(类型检查结果另存) | 每个 local 都有精确类型 |
| 控制流 | 结构化(if/loop/match) | 结构化 | 显式 CFG(基本块 + 终止指令) |
| 主要消费者 | 宏、名称解析、格式化输出 | 类型检查、trait 求解、lint | 借用检查、优化、代码生成 |
| 生命周期 | 一次编译内短期存在 | 全局上下文的核心,长期存在 | 按 item 惰性构建,可缓存/序列化 |
理解这张表的一个直观方式是:AST 回答"用户写了什么",HIR 回答"编译器理解成了什么",MIR 回答"程序怎么执行"。
1.3 顶层流水线:从 main 到 codegen
1.3.1 rustc 可执行文件的 main
rustc_driver_impl 是编译器的"操作系统层",负责参数、ICE 捕获、信号、退出码。其 main 定义在 rustc_driver_impl/src/lib.rs。真正的入口是 run_compiler,签名如下(L172):
pub fn run_compiler(at_args: &[String], callbacks: &mut (dyn Callbacks + Send)) {
它做了五件事,顺序非常关键:
- 丢弃 argv[0](二进制名),并对参数做
@file展开; - 调用
handle_options,处理--help、--version、-Zhelp这类"提前退出"的选项; - 调用
config::build_session_options,把Matches(getopts 解析结果)构造成Options; - 调用
make_input、make_output,确定输入文件与输出路径; - 组装
interface::Config,调用interface::run_compiler。
源码中这一段(L183-L231)值得逐行看:
let at_args = at_args.get(1..).unwrap_or_default();
let args = args::arg_expand_all(&default_early_dcx, at_args);
let (matches, help_only) = match handle_options(&default_early_dcx, &args) { ... };
let sopts = config::build_session_options(&mut default_early_dcx, &matches);
let ice_file = ice_path_with_config(Some(&sopts.unstable_opts)).clone();
...
let input = make_input(&default_early_dcx, &matches.free);
let has_input = input.is_some();
let (odir, ofile) = make_output(&matches);
这里有一个细节:EarlyDiagCtxt 在创建 Session 之前就存在。原因是有些错误(例如参数非法)发生时,完整的 Session 还没建好,但编译器仍需要能打印诊断并以正确格式退出。EarlyDiagCtxt 就是这个"最小可用诊断上下文"。drop(default_early_dcx) 之前,所有早期错误都用它;之后才交给 Session。
1.3.2 interface::run_compiler:创建 Compiler
interface::run_compiler(rustc_interface/src/interface.rs#L370)是"会话级"的入口。它的职责不是跑各个 pass,而是构造 Compiler 并进入全局上下文。其类型签名:
pub fn run_compiler<R: Send>(config: Config, f: impl FnOnce(&Compiler) -> R + Send) -> R
注意 R: Send 与闭包 Send。这不是装饰:rustc 的查询系统在并行时会跨线程移动结果,入口闭包必须满足线程安全约束。
Compiler 结构体(interface.rs#L36-L43)只包含四样东西:
pub struct Compiler {
pub sess: Session,
pub codegen_backend: Box<dyn CodegenBackend>,
pub(crate) override_queries: Option<fn(&Session, &mut Providers)>,
pub(crate) current_gcx: CurrentGcx,
}
这个字段列表本身就是设计声明:
sess:所有"配置 + 诊断 + 源文件 + lint 能力"的聚合;codegen_backend:后端被抽象成 trait object,rustc本体不依赖 LLVM;override_queries:允许工具(如 Miri、Clippy 的某些模式)替换查询实现;current_gcx:当前全局上下文的引用,用于在 ICE 时打印查询栈。
1.3.3 create_and_enter_global_ctxt:进入 TyCtxt
create_and_enter_global_ctxt(passes.rs#L935)是 AST 世界与 HIR 世界的桥梁。它的签名是:
pub fn create_and_enter_global_ctxt<T, F: for<'tcx> FnOnce(TyCtxt<'tcx>) -> T>(
compiler: &Compiler,
krate: ast::Crate,
f: F,
) -> T
for<'tcx> 是这里的关键:TyCtxt<'tcx> 的生命周期由调用方(run_compiler 的闭包)决定,全局上下文只在这个闭包内有效。这是 rustc 用来避免"全局可变状态泄漏"的典型手法——不是真的全局,而是"在闭包范围内全局"。
进入 TyCtxt 之后,rustc_driver_impl 的闭包(L293-L310)先做两件"预热":
let linker = create_and_enter_global_ctxt(compiler, krate, |tcx| {
// Make sure name resolution and macro expansion is run.
let _ = tcx.resolver_for_lowering();
...
passes::write_dep_info(tcx);
passes::write_interface(tcx);
...
});
tcx.resolver_for_lowering() 这一行看似不起眼,实则是整个前端的触发器:它强制运行名称解析与宏展开,把 AST 推进到"HIR 可降级"的状态。如果不调用它,后续任何 HIR 查询都会因为"AST 还没解析完"而 panic。
1.3.4 analysis:语义分析的总调度
analysis(passes.rs#L1200)是语义阶段的总入口,它本身非常短,但信息密度极高:
fn analysis(tcx: TyCtxt<'_>, (): ()) {
run_required_analyses(tcx);
let sess = tcx.sess;
if let Some(guar) = sess.dcx().has_errors_excluding_lint_errors() {
guar.raise_fatal();
}
sess.time("misc_checking_3", || {
par_fns(&mut [
&mut || { tcx.ensure_ok().effective_visibilities(()); ... },
&mut || { ... check_mod_privacy ... },
]);
...
});
if tcx.sess.opts.unstable_opts.validate_mir { ... }
}
从这段代码可以读出三个重要事实:
- 错误是短路式的,但有粒度。
has_errors_excluding_lint_errors说明:普通错误会终止后续分析(避免淹没用户),但 lint 错误不会。这是rustc诊断体验的一条硬规则。 - 分析阶段大量使用
par_fns并行。effective_visibilities、check_private_in_public、lint_checking、clashing_extern_declarations可以并行,因为它们彼此独立,且都通过查询系统读共享数据。 validate_mir是开发期的"额外保险"。它会强制计算每个 body 的最终 MIR,从而在优化 pass 中更早暴露 bug。这解释了为什么-Zvalidate-mir只对编译器开发者有意义。
analysis 内部调用的 run_required_analyses 才真正串起类型检查、借用检查、MIR 构建等重型查询。它不是本章重点,但请记住:analysis 是"调度器",不是"执行器"。真正的计算发生在查询被首次访问时。
1.3.5 start_codegen:从 MIR 到目标文件
start_codegen(passes.rs#L1286)负责后端阶段。它的开头做了几个防御性检查:
if let Some(guar) = tcx.sess.dcx().has_errors_or_delayed_bugs() {
guar.raise_fatal();
}
info!("Pre-codegen\n{:?}", tcx.debug_stats());
let metadata = rustc_metadata::fs::encode_and_write_metadata(tcx);
三个要点:
- 有 delayed bug 也不进 codegen。delayed bug 是"暂缓报告的内部错误",如果它还悬着就进后端,后端很可能产生更多 ICE,掩盖原始问题。
- 先写 metadata。
encode_and_write_metadata在 codegen 之前执行,因为.rmeta是给下游 crate 用的,不依赖机器码生成是否成功。这也意味着cargo check只跑到此处附近。 no_codegen分支。当-Zno-codegen或输出类型不需要 codegen 时,直接abort_if_errors()后返回,不进入后端。
1.4 78 个 crate 的依赖分层
打开 compiler/ 会看到 78 个目录。它们不是随意并列的,而是严格分层。按依赖方向(被依赖者在下),可以画出这样一张图:
┌──────────────────────────────┐
│ rustc_driver │ ← 可执行文件薄壳
│ rustc_driver_impl │ ← 真正的 driver
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ rustc_interface │ ← 会话与查询入口
└──────────────┬───────────────┘
│
┌────────────────────────────────┼────────────────────────────────┐
│ │ │
┌─────▼─────┐ ┌───────▼────────┐ ┌────────▼────────┐
│rustc_hir_ │ │ rustc_mir_ │ │ rustc_codegen_ │
│ typeck │ │ transform │ │ ssa │
└─────┬─────┘ └───────┬────────┘ └────────┬────────┘
│ │ │
┌─────▼─────────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
│rustc_trait_selection│ │ rustc_borrowck │ │ rustc_codegen_llvm │
│rustc_infer │ │ rustc_mir_build │ │ rustc_codegen_gcc │
└─────┬───────────────┘ └─────────┬─────────┘ └─────────┬─────────┘
│ │ │
└────────────────┬───────────────┴──────────────┬─────────────────┘
│ │
┌────────▼────────┐ ┌──────────▼──────────┐
│ rustc_middle │ │ rustc_monomorphize │
│ (TyCtxt/ty/mir) │ │ rustc_metadata │
└────────┬────────┘ └──────────┬──────────┘
│ │
┌────────────────┼──────────────┬───────────────┼──────────────┐
│ │ │ │ │
┌─────▼─────┐ ┌──────▼──────┐ ┌─────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐
│rustc_hir │ │ rustc_ast │ │rustc_span │ │rustc_errors│ │rustc_session│
│rustc_middle│ │rustc_ast_ │ │rustc_lexer│ │rustc_lint │ │rustc_target │
└───────────┘ │ lowering │ └───────────┘ └────────────┘ └─────────────┘
└─────────────┘
│
┌────────────────┼────────────────┬───────────────┐
│ │ │ │
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│rustc_parse │ │rustc_expand │ │rustc_resolve│ │rustc_ast_ │
│ │ │ │ │ │ │ passes │
└─────┬──────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
┌─────▼──────┐
│rustc_lexer │
└────────────┘
最底层(几乎所有 crate 都依赖):
rustc_data_structures, rustc_index, rustc_macros, rustc_arena,
rustc_serialize, rustc_hashes, rustc_fs_util, rustc_graphviz
这张图反映了几条硬性架构约束:
约束一:底层 crate 不能依赖上层
rustc_span、rustc_lexer、rustc_data_structures 处于最底层,它们绝不能 use rustc_middle。这保证了"源码位置"与"类型系统"解耦:rustc_lexer 甚至被设计成可以用稳定版编译器构建的库(见其 #![deny(unstable_features)],rustc_lexer/src/lib.rs#L22-L26)。这种约束带来实际好处:词法器可以被 rust-analyzer、语法高亮等外部工具复用。
约束二:rustc_middle 是类型系统的"公共客厅"
rustc_middle 定义了 TyCtxt、TyKind、mir::Body、hir 的入口,是几乎所有中上层 crate 的共同依赖。它的地位类似"编译器内核"。理解 rustc_middle 是理解整条流水线的钥匙,这也是本系列用三章(13、14、21)专门讲它的原因。
约束三:后端与前端通过 trait 解耦
rustc_codegen_ssa 定义 CodegenBackend trait,rustc_codegen_llvm 与 rustc_codegen_gcc、rustc_codegen_cranelift 分别实现。rustc_driver_impl 里只持有 Box<dyn CodegenBackend>(L38),不直接引用 LLVM。这使"换后端"成为运行期选择,而非编译期绑定。
约束四:查询系统横切所有层
rustc_query_impl 不在上面的图里显式出现,因为它像"织入层"一样横切所有查询定义。rustc_middle 用 #[rustc_do_not_const_check] 之类的属性声明查询,rustc_query_impl 在编译期生成对应代码。第 14 章会专门拆解这套机制。
1.5 数据在层间如何"变薄"与"变厚"
编译器的本质是表示变换,而表示变换有两个方向:变薄(信息消失) 与 变厚(信息增加)。rustc 里两者交替出现:
| 阶段 | 信息变化 | 具体表现 |
|---|---|---|
| 词法 | 变厚 | 字节流获得 token 边界与类别 |
| 语法 | 变厚 | token 获得树形结构 |
| 宏展开 | 先变薄再变厚 | 宏调用消失,展开结果出现 |
| 名称解析 | 变厚 | 路径获得 Res,绑定关系确定 |
| AST lowering | 变薄 | 语法糖消失、NodeId 重编号为 HirId |
| 类型检查 | 极度变厚 | 每个表达式获得类型,每个 body 有 TypeckResults |
| MIR 构建 | 变薄再变厚 | 结构控制流变为 CFG,但加入精确类型与 place |
| 借用检查 | 变厚 | 每个 region 有约束解,每个借用有存活区间 |
| 单态化 | 变薄 | 泛型参数被具体类型替换,实例数量爆炸 |
| 代码生成 | 变薄 | 类型信息降解为 LLVM 类型与 ABI 布局 |
一个常被忽视的点:rustc 的"类型"结论并不写回 HIR。HIR 节点本身只有语法信息,类型检查结果存放在独立的 TypeckResults 表里,通过 HirId 关联。这样做的好处是 HIR 可以被多个查询共享而不被修改,符合查询系统的"不可变输入"要求。第 12 章会看到 HIR map 与 TypeckResults 的分离设计。
1.6 错误模型的三个层次
rustc 的错误不是单一类型,而是三层结构,理解它们才能读懂源码里的 ? 与 raise。
第一层:Diag 与 DiagCtxt
用户可见的错误统一表示为诊断(Diag),由 DiagCtxt(旧称 Handler)管理。诊断可以带 span、标签、建议、子诊断。rustc_errors 提供了 builder 风格 API:
tcx.dcx().struct_span_err(span, "mismatched types")
.with_help("consider ...")
.emit();
诊断的"发射"会累积错误计数,但不会立即 panic。这允许多个错误在一次编译中同时报告。
第二层:ErrorGuaranteed
ErrorGuaranteed 是一个零大小、不可构造的类型,只能由诊断系统在确认发射了错误后返回。它的作用是类型级证明:"此路径已保证报告过错误"。函数签名里出现 Result<T, ErrorGuaranteed>,就表示"失败时错误已被报告,调用方无需再报"。这避免了重复诊断,也避免了把错误静默吞掉。rustc_interface 的 Result<T> 就是它的别名(interface.rs#L27):
pub type Result<T> = result::Result<T, ErrorGuaranteed>;
第三层:FatalError 与 ICE
FatalError 表示"必须立刻终止"的错误(例如输入文件不存在)。ICE(Internal Compiler Error)则是"编译器自身违反了不变式",通过 bug!、span_bug!、delay_bug! 触发,通常伴随查询栈打印与 bug report 提示。rustc_driver_impl 用 catch_fatal_errors 包裹整个流程,把这三层统一成退出码。
这三层的分工可以总结为:
可恢复的语义错误 → Diag + ErrorGuaranteed,继续编译
不可恢复的错误 → FatalError,立即终止
编译器自身 bug → ICE,打印查询栈并终止
1.7 流水线其实是"查询驱动的"
初学者常误以为 analysis 会"按顺序调用每个 pass"。真实机制是:pass 只是查询的消费者,查询才是计算单元。
rustc 的查询系统(第 14 章详述)把每一次"从 key 计算 value"包装成可缓存、可并行、可增量复用的查询。例如:
- 访问
tcx.typeck(def_id)会触发该函数的类型检查; - 访问
tcx.optimized_mir(def_id)会触发 MIR 构建 + 优化; - 访问
tcx.layout_of(ty)会触发布局计算; - 访问
tcx.instance_mir(instance)会触发单态化后的 MIR。
因此,analysis 里那些看似"调用一串函数"的代码,实质上是在按依赖顺序触发查询。查询之间通过 DepKind 记录依赖边,这既支撑了并行,也支撑了增量。第 29 章会展示这些依赖边如何被序列化成 DepGraph。
用查询系统解释一个现象:为什么 cargo check 比 cargo build 快得多?因为 check 只触发到"类型检查 + 借用检查"为止的查询,而 build 还要触发 optimized_mir、monomorphize、codegen。查询是按需的,没被请求的查询根本不会执行。
1.8 并行:同一份只读世界,多个执行者
rustc 的并行不是"把整个编译切成阶段并行",而是"查询级并行"。核心前提是:
- 查询的输入(如 HIR、AST)在进入分析阶段后是不可变的;
- 查询的结果写入共享缓存,写操作受锁保护;
- 不同查询只要 key 不同,就可以并发执行。
analysis 中的 par_fns(passes.rs#L1218)是这一点的直接体现。par_fns 把若干独立任务分发到线程池,每个任务内部再通过查询系统读取共享数据。注意它并行的是"模块级检查"(par_hir_for_each_module),而非任意粒度,因为模块级任务的独立性最容易保证。
并行带来的一个工程难题是查询栈的可读性:当某个查询在另一个线程中 ICE,如何知道它是被谁触发的?这就是 Compiler::current_gcx 与 print_query_stack 存在的原因——每个线程都能打印自己的查询栈。
1.9 三个贯穿全局的设计决策
决策一:用 arena 与索引替代指针
rustc 极少在 IR 里使用 Box/Rc 建立父子关系,而是大量使用:
P<T>:AST 的指针包装,本质是Box的薄封装,但提供统一接口;Idx家族(HirId、Local、BasicBlock、DefId):带类型的整数索引;Arena/TypedArena:批量分配、统一释放;IndexVec:以索引为下标的向量。
好处是:内存局部性好、可序列化、可安全共享(索引不携带生命周期的可变性)。代价是:索引一旦分配就不能随意删除,删除会留下"洞",需要额外机制处理。
决策二:惰性优先(lazy by default)
rustc 不在早期阶段"预计算所有东西"。类型检查、MIR、布局、单态化都是按需触发。这带来两个直接收益:
- 编译时间与"实际被使用的代码"相关,而非与"代码总量"严格线性相关;
- 增量编译可以只重算被请求的查询。
代价是:调试时很难静态判断"某段代码何时执行",必须依赖查询栈与 -Ztime-passes。
决策三:以 DefId 为跨 crate 的通用身份
DefId 是"定义标识",由 crate 编号 + 局部编号组成。它既能标识本地 item,也能标识外部 crate 的 item。所有跨 crate 的信息(元数据、trait 实现、诊断项)都以 DefId 为键。第 28 章会看到 .rmeta 如何把 DefId 编码成可跨进程解析的形式。
1.10 实战:用编译日志验证本章结论
理论需要验证。下面给出几条可操作的观察方法(不需要改源码):
- 观察阶段耗时:使用
-Ztime-passes(nightly)可以看到parse_crate、expansion、type_check_crate、borrow_check、codegen_crate等阶段的耗时。这些名字直接对应本章的流水线节点。 - 观察查询:
-Zquery-dep-graph配合-Zincremental-info可以输出查询依赖。 - 观察 MIR:
-Zunpretty=mir可以看到 HIR 降级后的 MIR;-Zunpretty=hir-tree可以看到 HIR 的树形结构。 - 观察 AST:
-Zunpretty=ast-tree可以在宏展开前后分别查看 AST。
这些开关之所以存在,正是因为流水线的每个节点都是独立、可观察的查询。
1.11 一次真实编译的七种表示追踪
为了让"七种表示"从抽象变为具体,取一个最小但完整的例子:
fn add(a: i32, b: i32) -> i32 {
a + b
}
下面逐层展示它在 rustc 内部大致长什么样(结构做了简化,字段名保持真实)。
1.11.1 AST 形态
语法分析后,函数是一个 ast::Item,其 kind 为 ItemKind::Fn,内部包含:
Item {
ident: "add",
kind: Fn(
FnSig { decl: FnDecl { inputs: [a: i32, b: i32], output: i32 }, header: ... },
Generics { params: [], where_clause: None },
Some(Block { stmts: [Stmt::Expr(Expr { kind: ExprKind::Binary(Add, Path(a), Path(b)), ... })], ... })
),
...
}
此时还没有解析路径:a 与 b 只是 Path 片段,编译器不知道它们指向哪个 Local。i32 也只是类型路径,尚未解析到具体 DefId。
1.11.2 名称解析后的 AST
rustc_resolve 会给每个路径打上 Res:
Path(a) → Res::Local(HirId-of-a) (严格说此时是 NodeId,lowering 后变 HirId)
Path(b) → Res::Local(HirId-of-b)
Path(i32) → Res::PrimTy(Int(I32))
注意名称解析发生在 AST 阶段,但结果会随 lowering 迁移到 HIR。这是"解析结果先附着在 AST、再被搬运到 HIR"的典型流程。
1.11.3 HIR 形态
rustc_ast_lowering 把 AST 转成 rustc_hir::Crate。关键变化:
- 函数体被抽成独立的
Body,存放在BodyId索引的表里,而不是挂在Item上; - 表达式的
NodeId被重新分配为HirId; - 语法糖被去掉(本例无糖,变化不明显)。
HIR 的函数项大致是:
Item { owner_id: OwnerId { def_id, ... }, ident: "add",
kind: Fn { sig: FnSig { decl: ..., }, generics: ..., body: Some(BodyId) } }
Body 里是 Expr 数组 + HirId 关联。此时仍然没有类型结论,只有用户书写的 i32。
1.11.4 类型检查后的结果
rustc_hir_typeck 不会修改 HIR,而是产出一张 TypeckResults 表:
TypeckResults {
node_types: { expr(a+b) => i32, expr(a) => i32, expr(b) => i32, ... },
node_args: ...,
adjustments: { expr(a) => [], expr(b) => [] }, // 无强制转换
...
}
这张表以 HirId 为键,通过 tcx.typeck(def_id) 获取。类型信息与 HIR 分离,这是查询系统"输入不可变"的直接结果。
1.11.5 MIR 形态
rustc_mir_build 把 HIR 的 Body 降级为 mir::Body。本例的 MIR 非常接近:
fn add(_1: i32, _2: i32) -> i32 {
let mut _0: i32; // 返回值
bb0: {
_0 = Add(_1, _2);
return;
}
}
注意几点:
- 参数被编号为
_1、_2(_0是返回值),这是 MIR 的Local约定; a + b变成一个Rvalue::BinaryOp(Add, ...)赋值;- 控制流被显式化成基本块
bb0,以return终止。
对于更复杂的函数(if、match、?),MIR 会展开成多个基本块和 SwitchInt 等终止指令。第 21 章会详细讲这个降级过程。
1.11.6 优化后的 MIR
rustc_mir_transform 会跑一串 pass。对 add 来说,最明显的变化可能是:
- 常量传播不会做太多(参数是未知的);
SimplifyCfg可能合并多余块;- 如果开了
-O,Inline可能把add内联到调用方(发生在调用方的 MIR 上,而不是add自己的 MIR)。
优化后的 MIR 仍然是 MIR,只是更"扁平"、块更少。
1.11.7 LLVM IR 形态
rustc_codegen_llvm 把 MIR 翻译成 LLVM IR(-O 之前大致是):
define i32 @_ZN...add...E(i32 %a, i32 %b) {
start:
%0 = add i32 %a, %b
ret i32 %0
}
三点观察:
- 函数名经过 符号修饰(mangling),不是源码里的
add; i32直接对应 LLVM 的i32,因为布局已知;- MIR 的
_0 = Add(_1, _2); return直接对应add+ret。
到这里,"同一份程序的七次变形"就有了完整的实证。
1.11.8 这条链上的信息丢失点
追踪完一个例子,再回头看"哪些信息在哪一层消失",会更能理解设计:
| 层间转换 | 丢失的信息 | 为什么可以丢 |
|---|---|---|
| AST → HIR | 原始语法糖形态 | 语义等价,HIR 只需保留语义 |
| HIR → MIR | 结构化控制流 | MIR 用 CFG,更利于分析与优化 |
| MIR → LLVM IR | Rust 类型细节(泛型、trait) | 已单态化,只剩具体布局 |
| LLVM IR → 机器码 | 大部分类型信息 | 后端只需要 ABI 与指令 |
反过来,哪些信息必须保留同样重要:Span 一路保留到 MIR(用于诊断),HirId 保留到类型检查结果,DefId 保留到元数据。这些"贯穿信息"是跨层协作的粘合剂。
1.12 关键 crate 的职责地图
78 个 crate 不可能在概述里逐个讲完,但下面这张表覆盖了流水线上最重要的 30 个,足以支撑后续阅读时的定位。
| crate | 职责 | 关键类型/入口 |
|---|---|---|
rustc_data_structures | 通用数据结构:FxHashMap、IndexVec、Arena、Lock、WorkerLocal | sync、fingerprint、graph |
rustc_index | 带类型的整数索引宏 newtype_index! | Idx、IdxVec |
rustc_macros | 派生宏与属性宏(Encodable、StableHash、Symbol) | —— |
rustc_span | Span、SourceFile、SourceMap、卫生性 | Span、FileName、hygiene |
rustc_lexer | 纯词法器 | TokenKind、Token |
rustc_ast | AST 定义、token stream、P<T> | Crate、Item、Expr |
rustc_parse | 递归下降解析器与错误恢复 | Parser、TokenCursor |
rustc_expand | 宏展开:声明宏、过程宏、cfg | MacroExpander、mbe |
rustc_resolve | 名称解析 | Resolver、Rib、ModuleData |
rustc_ast_lowering | AST → HIR | LoweringContext |
rustc_hir | HIR 定义、map、visitor | Crate、Body、HirId |
rustc_middle | 编译器内核:TyCtxt、TyKind、mir::Body、查询定义 | ty、mir、query |
rustc_query_system | 查询系统的通用机制 | QuerySystem、JobOwner |
rustc_query_impl | 具体查询实现与并行执行 | QueryCaches、execute_job |
rustc_hir_analysis | item 收集、WF、一致性检查 | collect、check |
rustc_hir_typeck | 表达式/语句类型检查 | FnCtxt、check_expr |
rustc_infer | 推断变量、region 约束、快照 | InferCtxt、InferCtxtSnapshot |
rustc_trait_selection | trait 候选选择、归一化、object safety | SelectionContext、Obligation |
rustc_next_trait_solver | 新一代 trait solver | solve、canonical |
rustc_const_eval | 常量求值、MIR 解释器 | InterpCx、ConstEvalCtxt |
rustc_mir_build | HIR → MIR | Builder、thir |
rustc_borrowck | NLL 借用检查 | BorrowckCtxt、BorrowSet |
rustc_mir_dataflow | 数据流框架 | Analysis、Results |
rustc_mir_transform | MIR pass 管线 | MirPass、PassManager |
rustc_pattern_analysis | 穷尽性与 usefulness | Analysis、Constructor |
rustc_monomorphize | 实例收集与单态化 | Instance、MonoItem |
rustc_codegen_ssa | 后端抽象层 | CodegenBackend、MiscCodegenMethods |
rustc_codegen_llvm | LLVM 后端 | CodegenCx、Builder |
rustc_metadata | .rmeta 编解码、crate 加载 | CrateMetadata、creader |
rustc_incremental | DepGraph、缓存持久化 | DepGraph、WorkProduct |
rustc_errors | 诊断数据结构与渲染 | DiagCtxt、Diag |
rustc_lint | lint 框架与内置 lint | LintStore、LintPass |
rustc_session | Session、Options、target | build_session |
rustc_target | 平台描述、ABI 布局 | Target |
这张表可以当作"查字典"用:读到某章提到一个类型,先在表里找到它所属的 crate,再去对应目录读源码。
1.13 编译器的三层缓存
rustc 的性能很大程度来自三层缓存的叠加。理解它们的分工,才能解释各种"为什么快/为什么没重编"的现象。
第一层:查询缓存(in-memory)
同一个 crate 内,任何 tcx.foo(key) 的结果都会存入查询缓存。第二次调用直接返回。这层缓存:
- 生命周期:一次编译进程;
- 粒度:单个查询 + 单个 key;
- 失效:不失效(输入被假定不可变)。
它的作用是消除重复计算。例如多个 pass 都需要 tcx.typeck(def_id),实际只算一次。
第二层:增量缓存(on-disk)
跨编译进程,rustc_incremental 把查询结果与依赖图持久化到 build/.../incremental/。这层缓存:
- 生命周期:多次编译;
- 粒度:查询 + key + 指纹;
- 失效:通过
DepGraph比较指纹。
它的作用是跨次编译复用。第 29 章会详细讲指纹与 work product。
第三层:元数据 / crate 复用
rustc_metadata 把 crate 的公开信息编码进 .rmeta。下游 crate 编译时直接读取,不需要重新分析上游源码。这层:
- 生命周期:跨 crate、跨进程;
- 粒度:crate;
- 失效:通过 crate hash / SVH。
它的作用是避免重复分析依赖。为什么 cargo 只重编改动的 crate?因为未改动的 crate 的 .rmeta 仍然有效。
三层缓存的关系可以画成:
同一进程内重复调用
│
┌──────▼──────┐
│ 查询缓存 │ in-memory
└──────┬──────┘
│ 跨进程、同一 crate
┌──────▼──────┐
│ 增量缓存 │ on-disk DepGraph
└──────┬──────┘
│ 跨 crate
┌──────▼──────┐
│ .rmeta │ crate metadata
└─────────────┘
一个常见误解是"增量编译等于查询缓存落盘"。实际上,增量编译复用的是查询结果 + 依赖边,它必须解决"输入是否变化"的判断问题,比单纯的结果缓存复杂得多。
1.14 五个常见误解
误解一:"AST 之后就是 HIR"
宏展开会在 AST 上反复进行,直到不动点。after_crate_root_parsing 回调时子模块甚至还没解析。AST 到 HIR 之间隔着一整个宏系统与名称解析。
误解二:"类型信息写回 HIR"
类型检查结果存在独立的 TypeckResults,HIR 本身保持语法形态。这保证了 HIR 可被并发共享。
误解三:"MIR 只是简化的 AST"
MIR 是控制流显式的 IR:基本块、终止指令、显式 place。它 services 借用检查与优化,结构上与 AST 差异很大。
误解四:"codegen 直接从 HIR 开始"
codegen 的输入是单态化后的 MIR。中间还要经过借用检查、优化、单态化、ABI 布局等多道工序。
误解五:"编译器是按阶段一次性跑完"
阶段只是"查询消费者"的组织方式。真正的计算由查询按需触发,这就是 cargo check 比 cargo build 快的原因。
1.15 查询键与缓存:参数形状如何决定性能
前文反复说"查询是 rustc 的计算单元",但没有回答一个关键问题:为什么查询的参数必须实现 QueryKey,而不是随便什么类型都能当 key?答案不是"为了统一接口",而是key 的类型直接决定用哪种缓存数据结构,也决定了这个查询在并行与增量编译下的行为。
1.15.1 QueryKey 的五个关联项
rustc_middle/src/query/keys.rs 给出了约束:
pub trait QueryKeyBounds = Copy + Debug + Eq + Hash + StableHash;
pub trait QueryKey: Sized + QueryKeyBounds {
type Cache<V> = DefaultCache<Self, V>;
type LocalQueryKey;
fn default_span(&self, tcx: TyCtxt<'_>) -> Span;
fn key_as_def_id(&self) -> Option<DefId> { None }
fn as_local_key(&self) -> Option<Self::LocalQueryKey> { None }
}
见 keys.rs。五个关联项各有明确职责,逐个解释:
Copy + Eq + Hash + StableHash。Copy让 key 可以在缓存、依赖图、诊断之间自由复制而不引入生命周期;Hash用于内存缓存;StableHash用于增量编译的指纹——两者不能合并,因为默认的Hash允许随机种子(如RandomState),而跨会话的指纹必须稳定。Cache<V>。默认DefaultCache<Self, V>(分片哈希表),但每种 key 可以覆盖。这是"参数形状决定数据结构的"入口。LocalQueryKey。当同一个逻辑查询既要服务本地 crate 又要服务外部 crate时,key 需要能"投影"成纯本地版本,于是本地表可以密集存储。default_span。查询出错(循环、dep graph 断言失败、-Zquery-dep-graph报错)时需要给用户一个位置。DefId本身没有 span,必须通过tcx反查def_span;而()没有位置,只能返回DUMMY_SP。key_as_def_id。增量编译把查询节点编码成DepNode { kind, key_fingerprint },但调试输出与extract_def_id需要把 key 还原成DefId用于打印路径。这就是为什么(DefId, Ident)这类复合 key 也实现了key_as_def_id(keys.rs)。
default_span 不是装饰:tcx.at(span) 之所以能在查询内部产生带位置的诊断,依赖调用方知道 key 对应哪儿;当工具(rustdoc、clippy)直接调用查询而没有 TyCtxtAt 时,default_span 是唯一的兜底。
1.15.2 六种 key,四种缓存
把 keys.rs 里的 impl QueryKey 按缓存策略归类:
| key 类型 | Cache | LocalQueryKey | 典型查询 | 选择理由 |
|---|---|---|---|---|
() | SingleCache<V> | 无 | analysis、hir_crate_items、collect_and_partition_mono_items | 全 crate 只有一个值 |
CrateNum | VecCache | LocalCrate | extern_crate、exportable_items | crate 数量少、编号密集 |
OwnerId | VecCache | 无 | hir_owner_parent_q、opt_hir_owner_nodes | owner 编号密集且只增不减 |
LocalDefId | VecCache | 无 | typeck_root、mir_borrowck、mir_built | 本地 DefIndex 是连续小整数 |
DefId | DefIdCache | 本地键可投影 | optimized_mir、layout_of(用 PseudoCanonicalInput)、generics_of | 需要同时覆盖本地与外部 |
ModId / 复合 key | DefaultCache | LocalModId | check_mod_attrs、(DefId, Ident) 系列 | 形状不规则,哈希最稳 |
源码依据:keys.rs;CrateNum 的 as_local_key 在 keys.rs 把 LOCAL_CRATE 映射成 LocalCrate,DefId 版本在 keys.rs。
SingleCache 的实现只有一行有效代码——一个 OnceLock<(V, DepNodeIndex)>(caches.rs)。它同时存 DepNodeIndex,因为查到的值必须能立刻关联到依赖图节点,否则查询系统无法记录"谁依赖了 analysis"。
DefaultCache 明确写了"backed by a sharded hashmap"(caches.rs)。分片不是为吞吐,而是为了让并行的 lookup 不互相阻塞——查询的第一个动作永远是查缓存,如果这里用单锁,所有线程会在第一行代码上排队。
DefIdCache 是本章最值得注意的结构:
pub struct DefIdCache<V> {
/// Stores the local DefIds in a dense map. Local queries are much more often dense, so this is
/// a win over hashing query keys at marginal memory cost (~5% at most) compared to FxHashMap.
local: VecCache<DefIndex, V, DepNodeIndex>,
foreign: DefaultCache<DefId, V>,
}
见 caches.rs。注释本身给出了设计权衡:本地键用密集数组,内存只多约 5%,但省掉哈希;外部键数量不可预测,用哈希。更关键的是它把"本地可并行写、外部只读"这件事结构化了:本地 DefIndex 在本 crate 生命周期内单调分配,写入永远是"空槽填值";外部 DefId 来自已编译的 crate,值要么来自 metadata,要么根本不查。
1.15.3 VecCache 的真实结构:无锁读、一次写
VecCache 不在 rustc_middle 里,而在 rustc_data_structures/src/vec_cache.rs。它的文件头注释就说明了语义:
//! VecCache supports efficient concurrent put/get across the key space, with write-once semantics
//! (i.e., a given key can only be put once). Subsequent puts will panic.
见 vec_cache.rs。实现要点:
- 分层桶:
BUCKETS = 21(vec_cache.rs),桶大小按 2 的幂递增,全部初始化后可容纳2^32个条目(vec_cache.rs)。这样小 crate 只分配前几个桶,大 crate 才逐步扩容,避免为 4G 个槽位预留地址空间。 AtomicPtr<Slot<V>>桶指针:桶的初始化是一次性的,用 CAS 发布指针,读者无锁。- 写一次语义:
put_unique第二次写入会 panic(vec_cache.rs),这正好对应查询系统的"每个 key 只算一次"保证。如果出现 panic,说明查询系统本身被绕过了。 - 活跃查询映射复用同一结构:注释写明该结构也被用作 "active query map"(vec_cache.rs)。也就是说,"正在计算"的槽位和"已算完"的槽位用的是同一套原子原语,只是语义不同。
1.15.4 对流水线的三个实际后果
第一,optimized_mir(DefId) 是唯一跨 crate 的 MIR 查询(queries.rs 的注释与 separate_provide_extern 都说明了这点)。因此它的 key 必须是 DefId、缓存必须是 DefIdCache、磁盘缓存必须只覆盖本地键——否则会试图把上游 crate 的 MIR 写进下游的增量缓存。
第二,owner 粒度决定了并行粒度。typeck_root、mir_built、mir_borrowck 全部以 LocalDefId 为 key(queries.rs),于是并行前端只需按 owner 分片。closure / inline const 不单独成为并行单元,它们被 is_typeck_child 折叠进父 owner(passes.rs),否则同一个函数体会被并发地算两次。
第三,() 键的查询是全局屏障。analysis(())、collect_and_partition_mono_items(()) 用 SingleCache,一旦被请求,全进程只有一个线程计算它,其余线程在 job 上等待。这解释了为什么 -Zthreads=8 对 cargo check 提升有限:analysis 内部才是并行的(par_hir_for_each_module),单线程阶段仍然串行。
1.15.5 可操作的观测手段
-Zquery-dep-graph打开依赖图断言,会在查询未声明依赖却访问了别的查询时 panic;它配合tests/ui/dep-graph/的rustc_clean注释使用。-Zself-profile生成的 Chrome trace 里,query 名称就是desc字符串(modifiers.rs 要求每个查询都必须有desc),因此 trace 里看到的是 "type-checkingfoo" 而不是 "typeck_root"。-Zdump-dep-graph可以导出.dot,用 Graphviz 观察某个DefId的上游闭包;这是排查"改一行代码导致整 crate 重编译"的最快方式。
1.16 十个查询修饰符:编译流水线的隐藏开关
1.16.1 修饰符文档即规范
rustc_middle/src/query/modifiers.rs 不是代码,而是纯粹的文档桩:每个修饰符声明成一个永不构造的 struct,仅用于让 rustc_queries! 宏里的修饰符名可被 IDE 跳转(文件头注释明确说 "dummy items in this module are used to enable hover documentation",modifiers.rs)。因此这个文件就是修饰符语义的权威规范。
把所有修饰符的语义压缩成表:
| 修饰符 | 一句话语义 | 对流水线的影响 |
|---|---|---|
arena_cache | 返回值放 arena,查询返回 &'tcx T 或 Option<&'tcx T> | 允许查询返回大对象;生命周期升到 'tcx,可跨线程共享 |
cache_on_disk | 值写入增量缓存,下次会话可加载 | 增量编译的核心;但只对"绿"节点有效 |
separate_provide_extern | 本地/外部 crate 用不同 provider | 外部查询直接读 metadata,不重算 |
eval_always | 不跟踪依赖,节点永远红 | 用于依赖未跟踪状态(如文件系统、环境)的查询 |
feedable | 生成 feed 方法,可从别处注入值 | 并行前端 / 延迟 lowering 使用 |
no_hash | 返回值不参与指纹哈希 | 值不稳定(含 Steal、IndexVec)时使用 |
no_force | 依赖图"强制变绿"时跳过该节点 | 与自定义循环处理器配合,保证循环栈完整 |
depth_limit | 递归深度限制 | 防止 layout 计算爆栈 |
handle_cycle_error | 用自定义函数处理循环 | layout_of 的循环是合法递归 |
desc { ... } | 人类可读描述,必填 | 诊断、profiling、-Zself-profile 的标签 |
依据:modifiers.rs。
1.16.2 本快照的真实分布
对 rustc_middle/src/queries.rs(121,817 字符)做机械统计,得到:
- 共 326 个
query声明; arena_cache57 个;cache_on_disk58 个;eval_always37 个;feedable26 个;separate_provide_extern117 个;no_hash10 个;handle_cycle_error5 个;no_force2 个;depth_limit2 个。
这组数字本身就是一张架构图:
separate_provide_extern占 1/3 以上(117/326)。这说明 rustc 大量查询必须回答"上游 crate 的这个东西是什么",而答案已经在rmeta里。增量的收益主要来自这里:外部 crate 不重新计算,只解码。cache_on_disk只有 58 个,比separate_provide_extern少得多。原因是磁盘缓存只能存"本地键计算出的值"(见 modifiers.rs 的说明),而外部键的值来自 metadata。两者数量差异正说明"跨 crate 查询多、但可落盘的本地查询少"。eval_always37 个,包括analysis(())(queries.rs)、check_expectations(queries.rs)、collect_and_partition_mono_items([queries.rs](rust-1.99.1/compiler/rustc_middle/src/queries.rs#L2450-L2453])。它们一改必重算,但仍然在内存里缓存(modifiers 文档明确强调"return value is still cached in memory"),所以同一会话内不会算两遍。feedable26 个。feed方法的存在意味着这些查询的值可以由"知道答案的人"直接注入。典型场景是并行前端先解析完某个 owner,再把hir_owner喂给查询系统,避免查询系统自己再去 lowering。mir_built也是 feedable(queries.rs)。no_hash10 个。mir_drops_elaborated_and_const_checked、mir_promoted都在其中(queries.rs)。原因写在mir_built的返回类型里:&'tcx Steal<mir::Body<'tcx>>——Steal的内容会被后续 pass 取走,返回值不是稳定值,哈希它没有意义。这类查询"一旦需要重算,节点必红"(modifiers 文档 modifiers.rs)。depth_limit与handle_cycle_error恰好成对:layout_of同时带两者(queries.rs),fn_abi_of_*系列也带。因为"计算类型布局"可以合法地递归(struct A { b: Box<A> }的 layout 要经过指针间接),遇到循环不能简单报错,而应返回一个"不能确定布局"的结果,由调用方决定是否是真的错误。
1.16.3 修饰符之间的交互陷阱
修饰符不是正交的,源码注释里埋了几处必须知道的交互:
cache_on_disk×separate_provide_extern:只有当两者同时存在时,才"只对本地键落盘";若查询只有cache_on_disk而没有外部 provider,说明该查询不接受外部 key(modifiers.rs)。no_force× 自定义循环处理器:no_force注释解释了为什么需要它——"if a cycle occurs, all of the relevant query calls will be on the query stack"(modifiers.rs)。如果依赖图在标记绿时"强制"把某个节点变绿,循环检测就看不到它,自定义处理器会误判。这是增量编译与循环检测交叉处最容易出 bug 的地方。arena_cache×no_hash:arena 里的值与普通值一样会被哈希,除非显式no_hash。所以"返回引用"并不自动豁免哈希;真正的豁免需要no_hash。feedable×eval_always:一个查询若既feedable又eval_always,喂进去的值在下次会话不会被信任(节点红),但内存里仍然有效。这解释了为什么某些 IDE 场景下"改一个文件,整个 crate 的 HIR 都要重喂"。
1.16.4 用修饰符读代码:三个例子
例一:analysis 为什么 eval_always。 它依赖的"状态"不是查询,而是整个 crate 的所有 owner 是否都已完成分析;这种"全局完成"无法用细粒度依赖表达。若允许缓存,删掉一个函数后依赖图可能认为 analysis 仍绿。因此它永远红、每次会话必跑一次,但内存缓存保证 tcx.ensure_done().analysis(()) 第二次是空操作。
例二:optimized_mir 为什么没有 eval_always。 它是增量编译最贵的可缓存项:输入是 mir_drops_elaborated_and_const_checked 与泛型实例化环境,输出是优化后的 MIR。只要上游绿、优化级别不变、相关 -C 选项不变,就能从磁盘加载。
例三:codegen_unit(Symbol) 为什么是普通查询。 它的 key 是符号名,值是 &'tcx CodegenUnit,由 collect_and_partition_mono_items 划分产生。它不落盘、不跨 crate,因为 CGU 划分依赖整个 crate 的单态化结果;但它在同一次 codegen 中会被反复查询(每个 CGU 都要问"我包含哪些 item"),所以需要缓存。
1.16.5 调试方法:如何验证一个查询的真实修饰符
不要在文档里猜。三种可靠手段:
- 直接看
queries.rs中该query块内的修饰符列表;desc之后、}之前的每一行都是修饰符。 - 用
-Zquery-dep-graph加rustc_clean注释做行为测试:如果某查询被标eval_always,测试里"只改注释"也会让它变红。 - 用
RUSTC_LOG=rustc_query_impl=debug观察查询执行日志;没有对应日志模块时,退而使用-Zself-profile的 query 事件。
1.17 DepKind 与依赖图:326 个查询如何变成一个 u16
1.17.1 DepNode 只有两个字段
pub struct DepNode {
pub kind: DepKind,
pub key_fingerprint: PackedFingerprint,
}
见 dep_node.rs。注释解释了 key_fingerprint 的三种来源:查询节点用 key 的稳定哈希;某些非查询节点恒为 ZERO;匿名任务(with_anon_task)用依赖集合的哈希。
这意味着依赖图的节点身份是 (kind, 指纹) 二元组,而不是指针或整数 ID。为什么这样设计?因为增量编译要跨进程比较两张图:上次会话的图和这次会话的图必须能用同一种语言对话,指针和自增 ID 都不行。
1.17.2 三个构造器,三种断言
DepNode::new_no_params(dep_node.rs):debug_assert_eq!(key_fingerprint_style(kind), Unit),然后把指纹设为ZERO。断言保证"无参节点"不会误用。DepNode::construct(dep_node.rs):要求Key: DepNodeKey,由 key 自己决定怎样变成指纹。DepNode::from_def_path_hash(dep_node.rs):断言 kind 的指纹风格是DefPathHash,直接塞入DefPathHash.0。
三个构造器对应三种指纹风格:Unit、DefPathHash、以及由 key 计算的 HirId/Opaque。debug 断言在这里是架构文档:它把"这个 kind 应该带什么样的 key"写成可执行的约束。release 构建里断言消失,但错误的构造仍会在增量编译比对时暴露成莫名其妙的红/绿。
1.17.3 DepKind 的枚举是从查询列表生成的
define_dep_nodes! 宏(dep_node.rs)展开后生成:
#[repr(u16)] // Must be kept in sync with the rest of `DepKind`.
pub enum DepKind {
Null, Red, SideEffect, ...,
lower_to_hir, hir_owner, ..., codegen_unit, ...
}
注意注释:"This enum has more than u8::MAX variants so we need some kind of multi-byte encoding."(dep_node.rs)。326 个查询 + 8 个非查询已经超过 255,所以 #[repr(u16)]。注释还提到编码细节:派生 Encodable 用 leb128,而 DepKind 被嵌在更大的结构里时可以利用 u16 的高位——这是一处"枚举宽度影响序列化体积"的典型工程权衡。
非查询节点在同一个枚举里,来源是 queries.rs 末尾的 non_query 声明(queries.rs):
| 非查询 kind | 用途 |
|---|---|
Null | 增量编译关闭时几乎所有节点的占位 |
Red | 人为制造"永远红"的节点 |
SideEffect | 记录有副作用、不可缓存的操作 |
AnonZeroDeps | 零依赖匿名节点 |
TraitSelect | trait selection 的独立节点 |
CompileCodegenUnit | 单个 CGU 的 codegen 节点 |
CompileMonoItem | 单个单态化 item 的 codegen 节点 |
Metadata | 写 metadata 的节点 |
CompileCodegenUnit 需要 Symbol 作为 key,但 construct 是泛型的、并不知道这一点,所以源码用专门的包装函数并加警告:
// WARNING: `construct` is generic and does not know that `CompileCodegenUnit` takes `Symbol`s as keys.
// Be very careful changing this type signature!
pub(crate) fn make_compile_codegen_unit(tcx: TyCtxt<'_>, name: Symbol) -> DepNode {
DepNode::construct(tcx, DepKind::CompileCodegenUnit, &name)
}
见 dep_node.rs。这类注释是理解 rustc 依赖图的关键:类型系统无法表达"kind 与 key 类型必须匹配",只能用包装函数 + 警告来维持不变量。一旦有人把 Symbol 换成别的类型,dep node 的指纹就会变化,增量缓存整体失效,但编译仍然"能跑",只是变慢——这是最隐蔽的一类回归。
1.17.4 依赖图如何决定红与绿
一次增量编译的判定链可以概括为:
读取本次会话的 DepNode
│
├─ 节点在上一会话图中,且指纹相同 / 被 force 成绿
│ → 绿:尝试从磁盘加载值
│ → 加载失败(格式版本变化):红
│
└─ 节点不在旧图中,或指纹不同
→ 红:必须重算
两个关键点:
- "绿"不等于"值可用"。绿只说明输入没变;值能否加载还取决于
cache_on_disk是否开启、缓存文件是否存在、-Z incremental的版本戳是否匹配。这就是为什么清空target/debug/incremental后第一次编译依旧慢,但之后会变快——绿了,也有值了。 force_from_dep_node是绿的唯一"人为通道"。它允许在没有 key 的情况下,用key_fingerprint反向恢复 key(KeyFingerprintStyle决定可恢复性),进而把节点标绿。no_force修饰符正是为此存在(modifiers.rs)。
extract_def_id 的存在说明这套机制是可观测的:
pub fn extract_def_id(&self, tcx: TyCtxt<'_>) -> Option<DefId> {
见 dep_node.rs。Debug 实现也依赖它:能恢复 DefId 就打印 def_path_debug_str,否则打印裸指纹(dep_node.rs)。这解释了 -Zincremental-info 与 dep graph 转储里为什么有的节点显示 foo::bar,有的只显示一串哈希。
1.17.5 边界案例:为什么 codegen_unit 不落盘
codegen_unit(Symbol)(queries.rs)没有 cache_on_disk、没有 separate_provide_extern。原因是它的上游 collect_and_partition_mono_items 是 eval_always:每次 codegen 都必须重新划分 CGU,因为划分本身依赖 -C codegen-units、模块结构、单态化集合。若把 CGU 落盘,下次会话拿到的是"旧的划分",而 codegen 需要的恰恰是"与本次 -C 选项一致的划分"。落盘缓存的边界不是"值是否昂贵",而是"输入是否稳定"。
1.18 一次 tcx.foo(k) 的完整生命周期
前几节讲了 key、修饰符、依赖图节点,现在把它们串起来:从 tcx.typeck_root(def_id) 这一行调用,到拿到 &TypeckResults,中间究竟发生了什么。这一段代码是所有查询共享的"执行引擎",位于 rustc_query_impl/src/execution.rs。
1.18.1 第一道门:分片锁 + 活跃表
let key_hash = sharded::make_hash(&key);
let mut state_lock = query.state.active.lock_shard_by_hash(key_hash);
if tcx.sess.opts.jobs.frontend.is_some() {
if let Some((value, index)) = query.cache.lookup(&key) {
tcx.prof.query_cache_hit(index.into());
return (value, Some(index));
}
}
见 execution.rs。三个事实值得注意:
- 锁按 key 的哈希分片。不是全局锁、不是每个查询一把锁,而是每个查询一张活跃表、表内再分片。这让"查询 A 的 key1"和"查询 A 的 key2"可以同时被计算。
- 缓存检查只在并行前端开启时做。注释解释了原因:并行前端下,别的线程可能已经算完并把值放进缓存,但活跃表里那条记录可能刚好已被清理;若只信活跃表,会重复执行。而在单线程前端下,活跃表与缓存由同一个线程顺序维护,无需这次额外查找——这是一处用编译模式换性能的分支(
jobs.frontend.is_some()等价于-Zthreads > 1的前端)。 query_cache_hit是 profiler 计数,不是日志。它累计到-Zself-profile的 query 统计里,用summarize工具可以按"命中/未命中"排序。
1.18.2 第二道门:单飞(single-flight)
match state_lock.entry(key_hash, equivalent_key(key), |(k, _)| sharded::make_hash(k)) {
Entry::Vacant(entry) => {
let id = next_job_id(tcx);
let job = QueryJob::new(id, span, current_job_id);
entry.insert((key, ActiveKeyStatus::Started(job)));
drop(state_lock);
let job_guard = ActiveJobGuard { state: &query.state, key, key_hash };
...
}
Entry::Occupied(mut entry) => match &mut entry.get_mut().1 {
ActiveKeyStatus::Started(job) => { ... }
ActiveKeyStatus::Poisoned => FatalError.raise(),
},
}
见 execution.rs。这段代码实现的是单飞协议:
- Vacant:当前线程成为"计算者",插入
Started(job),然后立刻释放锁(L251 的drop(state_lock))。计算过程绝不持锁——否则查询内部再调别的查询会死锁。 - Occupied + Started:已经有线程在算。并行模式下取走
job.latch()并等待;单线程模式下不可能有别的线程,因此只能是自己调用自己,直接判定为循环。 - Occupied + Poisoned:计算者 panic 过,
FatalError.raise()继续展开。
current_query_job() 从 TLS 里拿当前线程正在执行的 job id(execution.rs),它构成"查询调用栈"的链表,用于循环检测时还原环。
1.18.3 等待者视角:wait_for_query
let query_blocked_prof_timer = tcx.prof.query_blocked();
let result = latch.wait_on(current, span);
match result {
Ok(()) => {
let Some((v, index)) = query.cache.lookup(&key) else { ... };
tcx.prof.query_cache_hit(index.into());
...
}
Err(cycle) => (handle_cycle(query, tcx, key, cycle), None),
}
见 execution.rs。两个细节:
wait_on的返回值区分"正常唤醒"和"在等待中发现了环"。后者说明等待者在等待期间被另一条链卷入了循环,此时不能拿到值,只能走循环处理器。- 正常唤醒后必须在缓存里找到值;找不到只有两种可能:计算者 panic(Poisoned)或查询系统 bug。源码在
outline闭包里做了区分,找不到就 panic 并打印查询名(execution.rs)。"result must be in the cache or the query must be poisoned after a wait" 这条消息是并行查询 bug 的经典现场。
1.18.4 毒化:panic 如何传播给等待者
ActiveJobGuard 的文档注释把契约写得很清楚:
/// This will poison the relevant query key if it is dropped without calling
/// [`Self::complete`].
见 execution.rs。正常路径调用 complete:
fn complete<C>(self, cache: &C, value: C::Value, dep_node_index: DepNodeIndex) {
// Mark as complete before we remove the job from the active state
// so no other thread can re-execute this query.
cache.complete(self.key, value, dep_node_index);
let mut this = ManuallyDrop::new(self);
this.drop_and_maybe_poison(/* poison */ false);
}
见 execution.rs。写入缓存先于清除活跃表,这条注释是并发正确性的关键:如果反序,等待者被唤醒后可能既查不到缓存、也查不到活跃项。
drop_and_maybe_poison 里还有一条关于双重 panic 的注释:"we must not panic while holding the lock, because unwinding also looks at this map, which can result in a double panic"(execution.rs)。这解释了为什么 drop(shard) 在 panic!() 之前:panic 展开时会再次访问活跃表,持锁 panic 会导致进程直接 abort 而不是可报告的 ICE。
1.18.5 第三道关:ensure_ok 为什么更快
QueryMode 在本快照里只有两个变体:
pub enum QueryMode {
/// This is a normal query call to `tcx.$query(..)` or `tcx.at(span).$query(..)`.
Get,
/// This is a call to `tcx.ensure_ok().$query(..)`.
EnsureOk,
}
见 plumbing.rs。ensure_ok 的收益来自两处:
- UCE(unused query error)不再触发。某些查询带
#[rustc_do_not_const_check]或"必须被使用"的约定,普通Get若丢弃返回值会警告;EnsureOk明确表示"我只要它跑过"。 - 返回类型被擦成
()。对返回大结构的查询(如TypeckResults),ensure_ok不需要把&'tcx TypeckResults传回调用者,vtable 里的execute_query_fn可以提前丢弃值,只保留 dep index。
相关包装类型还有 TyCtxtEnsureResult 与 TyCtxtEnsureDone(plumbing.rs)。它们都带 #[must_use]:ensure_ok() 本身什么都不做,只有调用 .$query(...) 才执行;若忘了调用,must_use 会把"静默无操作"变成编译警告。ensure_done 的文档还说明它当前"照常执行查询",保留是为了将来能改为"只检查是否已执行"(plumbing.rs)。
1.18.6 vtable 的字段就是引擎的旋钮
QueryVTable 把修饰符编译成运行时布尔值:
pub struct QueryVTable<'tcx, C: QueryCache> {
pub name: &'static str,
pub eval_always: bool,
pub depth_limit: bool,
pub feedable: bool,
pub cache_on_disk_local: bool,
pub separate_provide_extern: bool,
pub dep_kind: DepKind,
pub state: QueryState<'tcx, C::Key>,
pub cache: C,
pub invoke_provider_fn: ...,
pub try_load_from_disk_fn: ...,
pub hash_value_fn: Option<...>,
pub handle_cycle_error_fn: ...,
pub format_value: fn(&C::Value) -> String,
pub create_tagged_key: fn(C::Key) -> TaggedQueryKey<'tcx>,
pub execute_query_fn: ...,
}
见 plumbing.rs。三个"函数指针可选"的字段值得单说:
hash_value_fn: Option<...>:no_hash查询这里是None。因此"不哈希"不是分支判断,而是根本没有这个函数——运行时不付出任何代价。try_load_from_disk_fn:只在will_cache_on_disk_for_key为真时才允许调用(plumbing.rs)。这个判定把"磁盘缓存"与"外部 provider"两个修饰符合并成一个条件:cache_on_disk_local && (!separate_provide_extern || key.as_local_key().is_some())。注意这里的短路:若查询不区分外部 provider,则任何 key 都可落盘;若区分,则只有能投影成本地 key 的才落盘。format_value:用于 ICE 消息和-Zquery-dep-graph的调试输出;它让"fed 值与计算值不一致"这类断言能打印人可读的差异(execution.rs)。
Debug for QueryVTable 只打印名字,注释明说其余字段是"unhelpful noise"(plumbing.rs)。这在调试 ICE 时很有用:看到 typeck_root 就知道是哪个查询,不必读一屏布尔值。
1.18.7 真实错误场景:三类查询故障
场景一:循环。 单线程下自调用直接判环;多线程下 latch.wait_on 返回 Err(cycle)。默认处理器会发射 E0391 风格的循环错误并 halt,带 handle_cycle_error 的查询则调用自定义函数。layout_of 的自定义处理器把循环变成 LayoutError,因为"类型太大/递归"是用户代码问题而非编译器 bug。
场景二:fed 值与计算值不一致。 check_feedable_consistency 会对比两个哈希:
if old_hash != new_hash {
assert!(tcx.dcx().has_errors().is_some(), "Computed query value for ... is inconsistent with fed value");
}
见 execution.rs。注意断言里带了 has_errors() 的豁免:错误恢复会产生不一致的值(例如一个被 ErrorGuaranteed 污染的 Ty),只要本次编译已经报错,就不算 bug。这是"错误容忍"与"内部不变量"之间的典型折中。
场景三:no_hash + feedable 组合。 源码直接 panic,并在消息里给出修复建议:
no_hash fed query later has its value computed.
Remove `no_hash` modifier to allow recomputation.
见 execution.rs。这条消息说明:no_hash 的查询一旦被 feed,就无法验证一致性,因此作者选择"直接禁止"而不是"跳过检查"。
1.19 按需触发:cargo check 与 cargo build 到底差在哪
第 1 章开头说"阶段只是查询消费者的组织方式",这一节把它量化。
1.19.1 两个入口条件:should_codegen 与 no_analysis
rustc_session/src/config.rs 里:
pub fn should_codegen(&self) -> bool {
self.0.keys().any(|k| match *k {
OutputType::Bitcode | OutputType::ThinLinkBitcode | OutputType::Assembly
| OutputType::LlvmAssembly | OutputType::Mir | OutputType::Object | OutputType::Exe => true,
OutputType::Metadata | OutputType::DepInfo => false,
})
}
见 config.rs。cargo check 传 --emit=dep-info,metadata,两项都落在 false 分支,于是整个 codegen 阶段不被请求。
与之配合的是 -Zno-analysis:driver 在 create_and_enter_global_ctxt 之后判断,如果用户只要 metadata 且开了该开关,就跳过 analysis。
1.19.2 analysis 内部的查询闭包
analysis 的 provider 是 passes::analysis(queries.rs 声明,实现在 passes.rs)。它按顺序请求:
run_required_analyses(passes.rs):misc_checking_1里par_fns三路并行:入口点/导出项/limits;check_mod_attrs、check_mod_unstable_api_usage按模块并行;emit_ast_lowering_delayed_lints;
rustc_hir_analysis::check_crate(tcx)(passes.rs):trait 求解、WF 检查、类型检查的触发者(真正计算由typeck_root完成);MIR_borrow_checking:par_hir_body_owners对每个非 typeck-child 的 body 调mir_borrowck(passes.rs);layout_testing与abi_testing(passes.rs);lint_checking、privacy_checking_modules、check_lint_expectations(passes.rs)。
cargo check 走完这一整套,所以"类型错误、借用错误、lint"都能报出来;它没有走的是 优化 MIR、单态化收集、CGU 划分、codegen。
1.19.3 cargo build 多出的查询闭包
cargo build 的 --emit 至少包含 metadata 与 object/exe,should_codegen 为真。于是多出:
optimized_mir(DefId)(queries.rs):MIR 优化 pass 全部跑一遍;collect_and_partition_mono_items(())(queries.rs):单态化收集与 CGU 划分;codegen_unit(Symbol)(queries.rs):每个 CGU 的内容;is_codegened_item(DefId):判定某个 item 是否真的进入 codegen。
外加后端自己的开销(LLVM 优化、目标文件写入、链接)。这解释了一个常见现象:cargo check 在类型检查重灾区(泛型、trait 链)与 build 差不多慢,但在 build 的时间里有相当一部分是单态化 + LLVM,而 check 完全不含。
1.19.4 三个容易搞错的边界
--emit=mir也需要 codegen。 因为 MIR 是在 codegen 阶段写出的(should_codegen把Mir归为true)。想只看 MIR 又要避免后端,可用-Zunpretty=mir,它走的是"unpretty"通道而不是--emit。--emit=metadata的typeck仍然跑。 metadata 需要类型信息、trait impl 表、可见性;cargo check正是"只跑到 metadata"。-Zno-codegen与--emit=metadata不等价。 前者是"跳过后端"的调试开关,后者是真实的输出请求;两者可以叠加,但-Zno-codegen会连--emit=object的写入也跳过,只留诊断。
1.19.5 实测方法
rustc -Ztime-passes会打印每个sess.time(...)段的耗时;cargo check与cargo build的差集就是 codegen 相关段。cargo build -Zself-profile(需 nightly)生成 trace,按 query 名聚合即可看到optimizing MIR for ...是否出现。- 用
touch src/lib.rs后重复编译,对比cargo check与build的增量耗时:若check仍慢,说明瓶颈在typeck_root/mir_borrowck的本地重算;若两者都慢,瓶颈可能在 metadata 编码或磁盘缓存读写。
1.20 三种 IR 的逐字段对比
本节把 AST、HIR、MIR 放到同一张解剖台上。目的不是复述定义,而是理解每个字段为什么存在、为什么在下一层消失。
1.20.1 AST 的 Expr:五个字段,两个"临时"的
pub struct Expr {
pub id: NodeId,
pub kind: ExprKind,
pub span: Span,
pub attrs: AttrVec,
pub tokens: Option<LazyAttrTokenStream>,
}
见 ast.rs。逐字段:
| 字段 | 为什么在 AST 层 | 为什么在 HIR 层消失 |
|---|---|---|
id: NodeId | 解析期需要全局唯一 ID 来建 AST 地图、做宏展开替换 | 换成 HirId:(OwnerId, ItemLocalId),局部化以便按 owner 并行 |
kind: ExprKind | 语法原样:If、Match、TryBlock、ForLoop 都还没去糖 | HIR 已去糖:? 变成 Match,for 变成 Loop+Match |
span: Span | 诊断与宏溯源需要精确到 token | 保留(Span 携带 SyntaxContext,可跨宏) |
attrs: AttrVec | 属性附着在语法节点上,展开期需要读写 | 移到独立的 AttributeMap,HIR 节点只留 HirId 供查表 |
tokens: Option<LazyAttrTokenStream> | 宏调用需要原始 token 流;#[derive] 也要 | 展开后无用,丢弃(-Zunpretty=expanded 之后不再需要) |
tokens 是"惰性"的:LazyAttrTokenStream 只在需要时把 token 还原成 TokenStream。这既省内存,又保证宏展开器能拿到"用户实际写的 token"而不是重新 pretty-print 的结果——后者会丢失空白与注释位置。
1.20.2 HIR 的 Expr:三个字段,身份被重新定义
pub struct Expr<'hir> {
#[stable_hash(ignore)]
pub hir_id: HirId,
pub kind: ExprKind<'hir>,
pub span: Span,
}
见 hir.rs。三点对比:
hir_id被stable_hash(ignore)标记。这是 HIR 可增量缓存的核心:同一个表达式在两次编译中若位置不变,HirId可能变(因为 owner 内编号依赖 lowering 顺序),但增量系统不应因此判定"变了"。忽略hir_id的哈希,意味着HIR 的内容哈希与节点身份解耦。attrs消失。HIR 用tcx.hir_attrs(hir_id)查AttributeMap。好处是属性不占每个节点的空间,坏处是访问要经过一次查询/表查找。这也解释了为什么check_mod_attrs是一个按模块并行的查询:它需要遍历整张AttributeMap。kind是ExprKind<'hir>,带生命周期。HIR 大量使用 arena 分配(&'hir [T]、&'hir Expr),因此整个 HIR 是一个自引用的只读图,可以被多线程共享而不需要Arc。
1.20.3 HIR 的 Body 与 OwnerNodes:body 不再是"函数体"
pub struct Body<'hir> {
pub params: &'hir [Param<'hir>],
pub value: &'hir Expr<'hir>,
}
见 hir.rs。只有两个字段,而且 Body::id() 是从 value.hir_id 反推的(hir.rs)。
这与旧版 rustc 的 HIR Body(曾带 generator_kind、coroutine_kind)形成鲜明对比:coroutine 信息被移到表达式/项上,body 退化成"参数 + 一个表达式"。设计收益是 lowering 更简单:任何表达式都能成为 body 的根,inline const、闭包、async 块共用同一套表示。
body 存放在 owner 的 OwnerNodes 里:
pub struct OwnerNodes<'tcx> {
pub opt_hash: Option<Fingerprint>,
pub nodes: IndexVec<ItemLocalId, ParentedNode<'tcx>>,
pub bodies: SortedMap<ItemLocalId, &'tcx Body<'tcx>>,
}
见 hir.rs。逐字段:
opt_hash:整个 owner(含 body)的预计算哈希,仅在增量编译开启时存在。它让"这个 owner 是否变化"变成一次比较,而不必重哈希整棵树。nodes:owner 内所有 HIR 节点,按ItemLocalId索引,每个节点带parent。注释特别说明第 0 个节点的 parent 设为ItemLocalId::INVALID,故意让误用触发 ICE(hir.rs)。父节点必须走hir_owner_parent查询,因为跨 owner 的父子关系不在同一张表里。bodies:SortedMap而非HashMap,为了确定性遍历顺序——增量哈希依赖顺序稳定。
1.20.4 MIR 的 Body:19 个字段的"控制流显式"表示
MIR body 的字段远多于 HIR body:
| 字段 | 作用 |
|---|---|
basic_blocks | 基本块向量,索引类型 BasicBlock |
phase | 当前 MIR 处于哪个阶段(Built/Optimized/...),供内联判断 promoted 是否已优化 |
pass_count | 已跑的 pass 数,仅调试 |
source | 该 body 对应什么(函数/常量/闭包) |
source_scopes | 调试信息的词法作用域树 |
coroutine | coroutine 附加信息(普通函数为 None) |
local_decls | 局部变量声明:_0 返回值、1..=arg_count 参数、其余临时量 |
user_type_annotations | 用户写的类型标注(canonical 形式) |
arg_count | 参数个数,决定哪些 local 由调用方初始化 |
spread_arg | rust-call ABI 的展开参数 |
var_debug_info | 用户变量调试信息(含捕获) |
span | 整个 body 的 span |
required_consts | 必须成功求值的常量(健全性关键) |
mentioned_items | 可能被单态化的 item(非健全性关键,但必须与优化级别无关) |
is_polymorphic | 是否使用泛型参数;一旦设定不可更改 |
injection_phase | rustc_custom_mir 的注入阶段 |
tainted_by_errors | 错误污染标记 |
coverage_info_hi | 覆盖率的高层信息 |
function_coverage_info | 覆盖率 pass 写入的每函数信息 |
见 mir/mod.rs。其中三条注释含金量最高:
phase的注释解释了为什么需要它:"inlining ... promoted items have already been optimized, whereas ours have not"(mir/mod.rs)。一个MirPhase字段解决"内联进来的 body 处于不同优化阶段"的问题。required_consts的注释直接声明健全性:"we make a guarantee that all consts syntactically mentioned in a function have successfully evaluated if the function ever gets executed at runtime"(mir/mod.rs)。mentioned_items的注释强调"optimization-level-independent"(mir/mod.rs)。这是为了避免"开-O后编译失败、不开就成功"这类依赖优化级别的错误。
1.20.5 BasicBlockData 与 LocalDecl:最小执行单元
pub struct BasicBlockData<'tcx> {
pub statements: Vec<Statement<'tcx>>,
pub after_last_stmt_debuginfos: StmtDebugInfos<'tcx>,
pub terminator: Option<Terminator<'tcx>>,
pub is_cleanup: bool,
}
见 mir/mod.rs。四个字段各有"反直觉"之处:
terminator是Option。注释说"generally ONLYNoneduring construction",唯一例外是simplify_cfg等 pass 临时取走终止指令(mir/mod.rs)。因此在 pass 内部读 terminator 必须用terminator()访问器,它会断言非None。is_cleanup决定 codegen 时该块是否走 unwind 路径,注释特别提到 MSVC 的 cleanup 生成(mir/mod.rs)。同一份 MIR 在 MSVC 与 Itanium ABI 下生成的清理代码不同,这个布尔值就是分叉点。after_last_stmt_debuginfos是后加的字段:当最后一条语句被优化掉后,原本附着在它上面的 debuginfo 需要一个落脚点(mir/mod.rs)。这是"优化不能破坏调试信息"的具体体现。#[non_exhaustive]:允许未来加字段而不破坏下游 match。
LocalDecl 则有五个字段(mir/mod.rs):mutability、local_info、ty、user_ty、span(以及后续会读到的 internal 等)。user_ty 的注释解释了为什么 MIR 还要保留用户写的类型:借用检查需要它来影响 region inference——类型检查产物不是最终真相,用户标注在 NLL 里仍有权重。
1.20.6 一张对比表收束
| 维度 | AST | HIR | MIR |
|---|---|---|---|
| 节点身份 | NodeId(全局) | HirId(owner 内局部) | Local/BasicBlock(函数内) |
| 属性存放 | 节点内 AttrVec | 独立 AttributeMap | 无(已降级为语句/常量) |
| 语法糖 | 保留 | 基本去糖 | 完全展开为控制流 |
| 控制流 | 隐式(树) | 隐式(树,带 Loop/Match) | 显式(CFG) |
| 可变性 | 可改写(宏展开) | 只读、arena 分配 | 可被 pass 改写(Steal/&mut Body) |
| 关键约束 | 宏展开需要 token | 增量哈希需要稳定身份 | 借用检查需要显式 place 与 CFG |
| 错误表示 | 解析错误恢复成 ExprKind::Err | lowering 失败成 ExprKind::Err | tainted_by_errors |
1.21 控制流降级之一:if、&&、|| 的 MIR 形态
本节开始用三节篇幅,追踪"源码里的控制流"如何一步步变成基本块。所有例子都可以用 rustc -Zunpretty=mir 直接验证。
1.21.1 if 在 THIR 里就是一个"菱形"
ExprKind::If { cond, then, else_opt } 的处理只有几行:
ExprKind::If { cond, then, else_opt } => {
let then_blk = self.then_else_break(block, cond, None, source_info, DeclareLetBindings::LetAllowed)?;
let then_expr = self.into_expr(then, then_blk.block, ...)?;
...
if let Some(else_expr) = else_opt {
let else_blk = ...;
self.into_expr(else_expr, else_blk, ...)?;
}
BlockAnd::from_opt(join_block)
}
见 into.rs。关键在 then_else_break:它不返回"布尔值",而直接生成两个目标块(then 块与 else/join 块),条件表达式的结果被"分流"到这两个块。这正是 MIR 与表达式树最本质的区别:条件不是值,而是边。
1.21.2 规范形态:菱形 + 假边
rustc_mir_build/src/builder/matches/mod.rs 用一张 ASCII 图定义了 canonical form:
[terminator]
/ \
/ \
[true arm] [false arm]
| |
| |
[_ad] ->. [false arm] .-> [_ad]
. |
. [join]
见 matches/mod.rs。三个设计要点:
-
真/假两个 arm 都先经过
_ad(后支配点),再到join。这个中间的"汇聚块"不是优化产物,而是借用检查的需要:变量的活跃区间必须在两条分支上都被"收束",否则 NLL 无法判断某个借用在 join 点是否还活着。 -
假边(false edges)是显式的
StatementKind::FalseEdge。当条件本身是x == 0这种直接产生SwitchInt的表达式时,真实的 CFG 边已经存在;但 MIR 仍插入FalseEdge { real_target, imaginary_target }把"概念上的真/假分支"补齐。注释解释说:如果不补,借用检查会认为某些变量在分支里"没有被使用",从而过早结束生命周期,导致莫名其妙的"借用不够长"错误。 -
then_else_break对&&/||递归。LogicalOp分支(into.rs)先对左操作数做then_else_break,得到(then_block, else_block),再按运算符决定谁走短路块:let (short_circuit, continuation, constant) = match op { LogicalOp::And => (else_block, then_block, false), LogicalOp::Or => (then_block, else_block, true), };然后
push_assign_constant(short_circuit, destination, false/true),最后把右操作数 lower 到continuation,两条路径都goto(target)。这就是"短路"的全部实现:没有布尔临时量、没有位运算,只有"提前给 destination 赋常量然后跳过 RHS"。
1.21.3 一段可对照的 MIR
对
fn f(a: bool, b: bool) -> i32 { if a && b { 1 } else { 2 } }
用 rustc -Zunpretty=mir 观察(块编号可能随编译器版本变化,结构不变):
bb0: {
_3 = _1; // 把 a 拷进条件临时量
switchInt(move _3) -> [0: bb2, otherwise: bb1];
}
bb1: {
_3 = _2; // b
switchInt(move _3) -> [0: bb2, otherwise: bb3];
}
bb2: { // 短路/假分支
_0 = const 2i32;
return;
}
bb3: { // 真分支
_0 = const 1i32;
return;
}
三处值得注意:&& 被拆成两次 switchInt;短路分支直接写 _0(返回值)而不是先写临时量;if 的两个分支各自 return,没有额外的 join 块——因为 join 在这里被"返回值合并"优化掉了。如果 if 有后续语句,join 块就会出现。
1.21.4 if let 为什么不是 If
if let Some(x) = opt { ... } 在 AST 里是 ExprKind::If 加 let 条件,但在 HIR 里被改写成 Match。原因是条件求值可能失败并产生绑定,而 then_else_break 只接受"能直接变成布尔分支"的条件。DeclareLetBindings 参数(LetAllowed/LetNotPermitted)就是用来在 lowering 期间检查"这里是否允许 let 链";&& 的 RHS lowering 明确传 LetNotPermitted(into.rs),因为 a && let Some(x) = b 在 MIR 层无法表达"RHS 失败时给整个表达式赋 false 并跳过"。
这条限制解释了一个常见报错:"let chains are only allowed in if or while conditions"。它不是语法限制,而是MIR 降级目标的可表达性限制。
1.22 控制流降级之二:? 的两级去糖
? 是 rustc 里"去糖最深"的运算符:HIR 阶段变成 match,MIR 阶段变成 switchInt + 返回/跳转,而语义仍然通过两个 lang item 保持可定制。
1.22.1 HIR 阶段:Try::branch + 两个 arm
lower_expr_try 的结构是:
let scrutinee = self.expr_call_lang_item_fn(unstable_span, LangItem::TryTraitBranch, arena_vec![self; sub_expr]);
let continue_arm = { /* ControlFlow::Continue(val) => val */ };
let break_arm = { /* ControlFlow::Break(residual) => return/break FromResidual::from_residual(residual) */ };
hir::ExprKind::Match(scrutinee, arena_vec![self; break_arm, continue_arm], hir::MatchSource::TryDesugar(scrutinee.hir_id))
见 expr.rs。四个易被忽略的细节:
- 两个 arm 的顺序是"先 Break 后 Continue"(L1965)。这不是随意排列:
TryDesugar的 arm 顺序被下游(MIR lowering、pretty printer)当作协议读取,若反过来,-Zunpretty=hir的输出了解读者会误判。 break_arm里可能生成Break也可能生成Return。代码按try_block_scope分成三类:TryBlockScope::Function:生成return from_residual(residual);TryBlockScope::Homogeneous(block_id):生成break 'block from_residual(residual)(try { }块,同类型 residual);TryBlockScope::Heterogeneous(block_id):生成break 'block FromResidual::from_residual(residual)(try { }块,异类型 residual)。 见 expr.rs。这就是try块与函数级?的区别:try块的?不能直接return,否则会跳出整个函数。
- 属性被刻意加到返回表达式上:
unreachable_code_attr(try_span)生成#[allow(unreachable_code)](L1908),因为Break分支在类型系统看来是"发散"的,后续 arm 不可达。若不加,用户会在Ok(v) => ...里收到"不可达代码"警告——一个完全不该由?引起的警告。 mark_span_with_reason(DesugaringKind::QuestionMark, ...)(L1884-L1894)。这让诊断能把"desugaring 后的 span"映射回?符号本身;否则报错会指向match的某个 token,用户无法理解。
1.22.2 MIR 阶段:switchInt 分派 ControlFlow
HIR 的 Match(Try::branch(e), [Break, Continue]) 到 MIR 就是普通 match 降级:Try::branch 返回 ControlFlow<B, C>,switchInt 按判别值分派。
Continue(val)分支把val取出,作为?表达式的值;Break(residual)分支调用FromResidual::from_residual,然后Return(或Break到try块的标签)。
如果 ? 出现在 async fn 里,Return 会被 coroutine 降级进一步改写为 "设置 coroutine 状态并 return",但这发生在 coroutine 变换之后,不在 MIR build 阶段。
1.22.3 边界:? 的 drop 与借用
? 的 early-return 路径会离开当前作用域,因此必须 drop 局部变量。MIR 里这表现为 Drop terminator 或 cleanup 块:
- 若
?在函数顶层且返回类型实现了Try,Break分支先 drop 当前作用域的 locals,再return; - 若
?在try块内,Break不是函数返回,drop 范围只到try块边界; - 若局部变量被
?之后的代码借用,rustc_mir_build会插入StorageDead并让 borrowck 检查"借用是否跨越 early return"。
这就是为什么"? 之后借用仍然有效"的代码有时会报 E0716:不是 ? 本身有问题,而是 early return 的 drop 路径缩短了借用生命周期。
1.22.4 ? 的三层可观测性
-Zunpretty=hir能看到match Try::branch(...) { Break(residual) => ..., Continue(val) => ... };-Zunpretty=mir能看到switchInt分派与from_residual调用;-Zunpretty=expanded看不到?的改写,因为它在 AST→HIR 之后。
一个实用调试技巧:如果 ? 的类型错误难以理解,先看 -Zunpretty=hir,确认 try_block_scope 是 Function 还是 Homogeneous/Heterogeneous;错误信息里的 FromResidual 要求通常对应第三类。
1.23 控制流降级之三:for、loop 与标签
1.23.1 for 的文档就是规范
lower_expr_for 上方有一段注释直接给出目标形态:
{
let result = match IntoIterator::into_iter(<head>) {
mut iter => {
[opt_ident]: loop {
match Iterator::next(&mut iter) {
None => break,
Some(<pat>) => <body>,
};
}
}
};
result
}
见 expr.rs。这段注释是理解 for 的关键:for 不产生新的 MIR 结构,它完全由 match + loop + break 组合而成。
源码逐段对应:
None => break:none_arm生成pat_none+expr_break_alloc,并且放在with_loop_scope(loop_hir_id, ...)里(expr.rs);Some(<pat>) => <body>:some_arm生成pat_some(pat),body 在同一个 loop scope 内 lower(expr.rs);mut iter:绑定模式BindingMode::MUT,因为Iterator::next需要&mut self;Iterator::next(&mut iter):调用LangItem::IteratorNext(expr.rs);- 整个
match的MatchSource是ForLoopDesugar,这让诊断与 pretty printer 能识别"这是 for 循环"。
for await 走同一个函数,只是把 IteratorNext 换成 Pin::new_unchecked(&mut iter) + FutureKind::AsyncIterator(expr.rs)。同一个 desugaring 覆盖同步与异步迭代,这是 HIR 表示能力的一个证据。
1.23.2 loop 的 MIR:FalseUnwind 与"假边"
ExprKind::Loop { body } => {
// [block]
// |
// [loop_block] -> [body_block] -/eval. body/-> [body_block_end]
// | ^ |
// false link | |
// | +-----------------------------------------+
// +-> [diverge_cleanup]
let loop_block = this.cfg.start_new_block();
this.cfg.goto(block, source_info, loop_block);
this.in_breakable_scope(Some(loop_block), destination, expr_span, move |this| {
let body_block = this.cfg.start_new_block();
this.cfg.terminate(loop_block, source_info, TerminatorKind::FalseUnwind {
real_target: body_block,
unwind: UnwindAction::Continue,
});
this.diverge_from(loop_block);
...
})
}
见 into.rs。注释把"为什么需要假边"写得非常清楚:"The false link is required to make sure borrowck considers unwinds through the body, even when the exact code in the body cannot unwind."
FalseUnwind 的 unwind: UnwindAction::Continue 意味着"如果这里真的 unwind,就继续展开";而 diverge_from(loop_block) 建立了到 diverge_cleanup 的边。二者合起来让借用检查看到一条"不会发生但必须存在"的 unwind 路径,从而正确判定循环体内借用的生命周期。
1.23.3 break、continue 与标签
break 的目标由 in_breakable_scope 建立的作用域栈决定(into.rs)。三种情况:
| 写法 | 目标块 | 值 |
|---|---|---|
break(无值) | destination(外层 join) | unit |
break expr | destination | expr 的值 |
break 'label expr | 标签对应的 join | expr 的值 |
continue 的目标是循环头 loop_block;在 for 循环里它还必须先执行 Iterator::next 之前的迭代器推进(因为 for 的 continue 跳到 match 头而不是 body 头)。
一个容易忽略的点:loop { match x { ... } } 在 THIR 里被识别为 ExprKind::LoopMatch(into.rs),注释称其为"a combination of a loop containing a labeled block containing a match",并引入 in_const_continuable_scope 支持 #[const_continue]。这是 MIR build 层为 const 求值做的特化:普通 loop 的 break 是控制流,而 const_continue 需要把 match arm 的块暴露给求值器。若你的代码在 const 上下文里用 loop { match ... } 报"not supported",问题往往出在这个特化路径。
1.23.4 标签与宏展开的交互
lower_label(opt_label, e.id, loop_hir_id)(expr.rs)把用户标签绑定到一个新的 HirId。当标签来自宏展开时,Span 的 SyntaxContext 必须与循环体不同,否则 break 'a 可能解析到宏内部同名标签。find_ancestor_in_same_ctxt(expr.rs)就是用来把 head/pat 的 span 拉回"用户写 for 的位置",避免诊断指向宏定义内部。
这也是一类真实 bug 的来源:如果你写宏生成 for 循环并在宏内 break,行为取决于宏的卫生性(hygiene)是否正确传递。SyntaxContext 错了,break 会跳到错误的循环,而编译器不会报错。
1.24 诊断如何跨层传播
1.24.1 ErrorGuaranteed:不可伪造的"错误凭证"
查询的返回类型有两种风格:
query mir_borrowck(key: LocalDefId) -> Result<&'tcx FxIndexMap<...>, ErrorGuaranteed>
query coherent_trait(def_id: DefId) -> Result<(), ErrorGuaranteed>
见 queries.rs。Result<_, ErrorGuaranteed> 表达的是"这个查询要么成功,要么已经报过错了"。ErrorGuaranteed 不能由用户代码构造,只能由诊断发射路径返回,因此它是一个"能力令牌":任何拿到它的函数都可以安全地跳过后续检查,因为它确知错误已经被报告过。
这解决了一个经典问题:错误恢复时如果继续用 Ty::new_error 之类的占位类型,编译会继续跑并可能产生级联错误;但如果直接返回 Result::Err(guar),调用方能选择"停止这条路径"。
1.24.2 从查询到阶段的三种传播方式
-
返回值传播:如上。典型调用点是
analysis里的par_hir_body_owners(... tcx.ensure_ok().mir_borrowck(def_id) ...);ensure_ok对Result查询会强制要求Ok,拿到Err就 panic。因此mir_borrowck报错后,analysis不会继续往下跑。 -
全局状态传播:
sess.dcx().has_errors_excluding_lint_errors()。在analysis的末尾:if let Some(guar) = sess.dcx().has_errors_excluding_lint_errors() { guar.raise_fatal(); }见 passes.rs。注释解释了
excluding_lint_errors的理由:"lint errors are typically less serious and we're more likely to want to continue (#87337)"。lint 错误不算致命,这直接决定了deny(warnings)的 CI 行为与普通编译行为的差异。 -
MIR 内标记传播:
Body::tainted_by_errors。当 typeck 已经报错,MIR 仍然会被构造出来(否则借用检查会 ICE),但会打上tainted_by_errors,让后续 pass 与 codegen 跳过"依赖类型正确性"的优化。
1.24.3 延迟诊断:为什么 lint 要"稍后发"
emit_ast_lowering_delayed_lints(passes.rs)把 AST lowering 期间收集的诊断延后发射。原因:lowering 是按 owner 并行的,如果立刻发射,诊断顺序会随线程调度变化;延迟到"所有 owner 都 lower 完"再统一发射,可以保证同一份代码的错误顺序稳定,这对 UI 测试(//~ ERROR 按行号匹配)是硬要求。
1.24.4 四类退出路径的语义区别
| 退出方式 | 触发者 | 用户看到的 |
|---|---|---|
| 正常 error | dcx.emit_err + ErrorGuaranteed | 错误与后续编译(可能继续) |
raise_fatal | ErrorGuaranteed::raise_fatal | 已报错,停止并返回非零 |
FatalError.raise | 查询系统(循环、毒化) | 内部致命错误,仍走 ICE 报告 |
panic / ICE | bug! / 断言失败 | "internal compiler error" + 版本与回溯 |
四者的区别不是"严重程度",而是是否已经向用户报过可理解的错误。FatalError 的特殊之处在于:它是"编译器知道自己无法继续",但仍会走 ICE 通道,因为它没有对应的用户级诊断。这就是为什么查询循环(E0391)有时显示为漂亮的错误页、有时显示为 ICE——取决于是否命中带自定义处理器的查询。
1.24.5 调试建议
- 遇到"错误被吞掉":在
analysis末尾打断点看has_errors_excluding_lint_errors()的返回值; - 遇到"级联错误":检查查询是否返回
Result<_, ErrorGuaranteed>而不是用占位值; - 遇到"错误顺序抖动":确认诊断是否走了延迟发射路径,以及
DiagCtxt的排序逻辑。
1.25 并行执行模型:从 -Zthreads 到 jobserver
1.25.1 par_fns:把"三路并行"写成一行
pub fn par_fns(funcs: &mut [&mut (dyn FnMut() + DynSend)]) {
parallel_guard(|guard: &ParallelGuard| {
if let Some(proof) = mode::check_dyn_thread_safe() {
let funcs = proof.derive(funcs);
rustc_thread_pool::scope(|s| {
let Some((first, rest)) = funcs.into_inner().split_at_mut_checked(1) else { return; };
for f in rest.iter_mut().rev() { s.spawn(|_| { guard.run(|| (f.into_inner())()); }); }
guard.run(|| first[0]());
});
} else {
for f in funcs { guard.run(|| f()); }
}
});
}
见 parallel.rs。三个设计点:
- 第一个函数在当前线程执行,注释明说"Use that for the longest running function for better scheduling"(L71-L73)。
analysis里misc_checking_1的par_fns就把最重的rustc_hir_analysis::check_crate放在第一位。 - 其余函数逆序 spawn。注释:"Reverse the order of the later functions since Rayon executes them in reverse order when using a single thread. This ensures the execution order matches that of a single threaded rustc."(L83-L85)。这是确定性要求:单线程运行时,
par_fns的执行顺序必须与串行版本一致,否则诊断顺序、dep graph 顺序都会变。 DynSend证明。proof.derive(funcs)不是普通 trait bound,而是把"我是线程安全的"编码成类型证明。mode::check_dyn_thread_safe()返回Option<Proof>:None表示当前禁止跨线程,此时退化为顺序执行。这个开关由-Zthreads控制,并在run_compiler开头通过set_dyn_thread_safe_mode设置。
1.25.2 par_for_each_in:分组与 128 上限
const MAX_GROUP_COUNT: usize = 128;
let group_size = items.len().div_ceil(MAX_GROUP_COUNT);
let mut groups = items.chunks_mut(group_size);
见 parallel.rs。为什么要分组而不是每个 item 一个任务?注释给出的理由是"avoid overwhelming stealing"(L165):任务太多会让 Rayon 的 work-stealing 队列成为瓶颈,而每个任务本身可能很短(例如一个函数的 mir_borrowck)。128 是一个经验值:足够填满核数,又不至于调度开销超过计算。
1.25.3 查询系统自身的并发设施
QuerySystem里有jobs: AtomicU64(plumbing.rs):每个查询执行分配一个 job id,用于 latch 与循环检测。- 活跃表按
key_hash分片(execution.rs):不同 key 的查询可并行,同一个 key 只有一个计算者。 - arena 是每线程的:
WorkerLocal让每个 worker 有自己的QueryArenas,避免跨线程分配竞争;'tcx生命周期的值在进入全局 arena 后才可共享。 -Zthreads N同时影响前端与后端:run_compiler在建 session 前就初始化 jobserver,取frontend、backend、linker三者最大线程数作为全局上限(interface.rs)。
1.25.4 并行的三个不可交换假设
并行前端不是"把串行代码加锁"就能得到的,它依赖三个结构性假设:
- owner 之间无写冲突。HIR 是只读 arena,查询只往自己的缓存槽写。若某个 pass 需要全局可变状态(如
Steal),它必须被限制在"单线程阶段"或在join点合并。 - 诊断顺序由延迟发射保证。并行执行会让
emit_err的调用顺序随机;DiagCtxt内部按Span与稳定键排序,才让 UI 测试可重复。 - dep graph 的节点创建顺序不影响语义。
DepNodeIndex是分配序号,若依赖顺序,增量缓存会随线程数变化;因此指纹只依赖(kind, key),不依赖分配顺序。
1.25.5 可操作调试
-Zthreads=1与-Zthreads=8的-Zself-profile对比:若 8 线程更慢,通常是锁竞争(看query_blocked时间)。RUSTC_LOG=rustc_query_system::dep_graph=debug观察依赖图构建是否并行。- 若怀疑"并行导致错误顺序变化",用
-Zthreads=1复现;若串行正常,问题在诊断排序或某个共享状态的同步。
1.26 编译阶段耗时构成与观测
1.26.1 sess.time(...) 是一等公民
passes.rs 里几乎每个大阶段都包在 sess.time("...") 里。完整清单(按出现顺序):
| 标签 | 位置 | 内容 |
|---|---|---|
crate_injection | passes.rs | 注入 extern crate std、prelude |
macro_expand_crate | L164 | 宏展开(含 cfg 处理前的展开) |
expand_crate | L216 | 完整展开(含 derive) |
check_unused_macros | L228 | 未使用宏检查 |
maybe_building_test_harness | L252 | --test 时生成 harness |
AST_validation | L256 | AST 合法性校验 |
maybe_create_a_macro_crate | L292 | proc-macro crate 特化 |
complete_gated_feature_checking | L416 | 特性门控最终检查 |
misc_checking_1 | L1110 | 入口点、导出项、derive registrar |
looking_for_entry_point | L1113 | main/start 查找 |
check_externally_implementable_items | L1114 | #[rustc_*] 外部可实现项检查 |
looking_for_derive_registrar | L1118 | derive 注册 |
emit_ast_lowering_delayed_lints | L1142 | 延迟 lint 发射 |
MIR_borrow_checking | L1154 | 借用检查(按 body 并行) |
layout_testing | L1194 | -Zlayout-test 的类型布局断言 |
abi_testing | L1195 | -Zabi-test 的 ABI 断言 |
misc_checking_3 | L1217 | 可见性、私有性、lint、extern 冲突 |
lint_checking | L1234 | rustc_lint::check_crate |
privacy_checking_modules | L1244 | 模块级隐私检查 |
check_lint_expectations | L1254 | #[expect] 是否被满足 |
ensuring_final_MIR_is_computable | L1274 | 最终 MIR 可计算性检查 |
codegen_crate | L1324 | 后端代码生成 |
这张表的实际价值:看到 -Ztime-passes 输出里某个标签异常大,就知道该去哪个文件找。例如 MIR_borrow_checking 大 → 看 mir_borrowck 的输入规模与 NLL 约束数量;codegen_crate 大 → 看单态化数量与 CGU 数。
1.26.2 -Ztime-passes 的接线方式
TimePassesCallbacks 实现了 Callbacks:
pub struct TimePassesCallbacks {
time_passes: Option<TimePassesFormat>,
}
impl Callbacks for TimePassesCallbacks {
fn config(&mut self, config: &mut interface::Config) {
// If a --print=... option has been given, we don't print the "total" time-passes line.
self.time_passes = (config.opts.prints.is_empty() && config.opts.unstable_opts.time_passes)
.then_some(config.opts.unstable_opts.time_passes_format);
}
}
见 lib.rs。注意"若指定了 --print,就不打印 total 行"这条条件:--print 的输出可能被工具解析,多一行 total 会破坏格式。
在 main 末尾:
if let Some(format) = callbacks.time_passes {
print_time_passes_entry("total", start_time.elapsed(), start_rss, end_rss, format);
}
见 lib.rs。start_rss/end_rss 说明时间行里还包含内存变化,这对判断"是 CPU 还是内存瓶颈"很有用。
1.26.3 -Zself-profile 与查询级归因
-Ztime-passes 只到"阶段"粒度;要看"哪个查询最贵",用 -Zself-profile:
- 输出目录里包含
*.trace(Chrome trace)、*.events(结构化事件)、query cache 统计; - 工具
summarize按QueryInvocation聚合,给出 total/self 时间; - 与
-Ztime-passes的差别在于:sess.time是手工埋点,self-profile是查询系统自动埋点,覆盖全部 326 个查询。
1.26.4 一份现实的耗时画像
对中型 crate(数万行、无巨大泛型)的典型增量构建:
| 部分 | 大致占比 | 说明 |
|---|---|---|
typeck_root + trait 求解 | 30%–45% | 泛型与 trait 链是主要成本 |
mir_borrowck | 15%–25% | 与函数数量、NLL 约束数成正比 |
| 优化 MIR | 5%–10% | -O 时显著上升 |
| 单态化 + CGU 划分 | 5%–10% | 泛型实例化数量决定 |
| LLVM 后端 | 15%–30% | -C codegen-units 越小越慢 |
| metadata 编解码 | 3%–8% | 与 crate 的公开 API 规模成正比 |
这只是量级参考;真实数据必须用 -Zself-profile 测。不要凭直觉优化:很多"编译慢"的 crate 实际瓶颈是 proc-macro(在 macro_expand_crate 段),而不是类型检查。
1.26.5 三个常见误判
- 把
cargo的等待时间当成 rustc 时间。cargo build里有依赖解析、指纹计算、链接;看-Ztime-passes的 total 才是 rustc 自身。 - 把增量首次编译当成稳态。首次没有磁盘缓存,
cache_on_disk的收益为 0;稳态要看第二次。 - 忽视
-C incremental目录的清理成本。增量目录过大(GB 级)会让文件系统成为瓶颈,此时"关掉增量"反而更快。
1.27 六个可复现的查询实验
理论需要用实验固化。以下六个实验都在本仓库源码树上可直接执行,且不依赖第三方 crate;建议按顺序做,前三个验证机制,后三个验证流水线。
1.27.1 实验一:观察查询缓存命中
RUSTC_LOG=rustc_query_impl=debug rustc --edition 2021 -Ztime-passes src/lib.rs --crate-type lib
同一查询第二次出现时,日志里不再有 execute_query 记录,只有 profiler 计数变化。若把同一文件编译两次(两次独立进程),则磁盘缓存命中取决于 -C incremental 目录是否存在。
补充两点观察技巧。其一,RUSTC_LOG 支持模块级过滤与正则,例如 RUSTC_LOG=rustc_query_impl::execution=debug 只看执行引擎;用 =trace 才能看到"进入缓存分支但未命中"的细节。其二,并行前端下同一查询可能被多个线程同时请求,日志里会看到一次 execute_query 加若干次 wait_for_query;若 wait_for_query 数量异常多,说明热 key 竞争严重,应该考虑拆分查询粒度而不是加线程。
1.27.2 实验二:验证 eval_always 的真实性
对同一 crate 连续调用两次 tcx.ensure_done().analysis(())(可在自定义 driver 里做),第二次耗时接近 0,说明"永远红"不等于"永远重算"——红只影响跨会话的磁盘缓存,不影响会话内的内存缓存。这是理解 eval_always 最常见的误区。
1.27.3 实验三:看 ? 的两级形态
rustc -Zunpretty=hir src/lib.rs | rg -A6 'TryDesugar'
rustc -Zunpretty=mir src/lib.rs | rg -A10 'discriminant'
第一条命令输出 match Try::branch(...);第二条输出 SwitchInt。两者之间的差异就是"两层降级"的全部证据。
1.27.4 实验四:验证 if 的假边
rustc -Zunpretty=mir -Zmir-include-spans src/lib.rs
在 if 的 then/else 块里寻找 FalseEdge 语句。删掉它,借用检查的结论会变化,但 MIR 的"可执行语义"不变——这正说明假边是给静态分析看的,不是给 codegen 看的。
1.27.5 实验五:测量 check 与 build 的查询差集
cargo check -Zself-profile 2>/dev/null
cargo build -Zself-profile 2>/dev/null
用 summarize 分别导出查询列表,做集合差:optimized_mir、codegen_unit、is_codegened_item 只会出现在 build 一侧。这是第 1.19 节结论的机器验证。
若想进一步确认某个查询是否被"提前"调用,可在 -Zself-profile 的 events 文件里看时间戳:build 的 optimized_mir 事件一定出现在 analysis 的结束事件之后,因为 driver 只有在 analysis 返回 Compilation::Continue 后才进入 start_codegen。这条时序比查询列表更能说明"流水线"的真实顺序。
1.27.6 实验六:让自动增量测试替你检查依赖
Rust 仓库自带"自动增量"测试模式(编译测试框架里的 rustc_clean / rustc_incr 注释)。它做的事与手工实验等价,但更严格:
- 先编译一次,记录所有查询的 dep node;
- 修改源码的某个区域,再编译一次;
- 断言"哪些查询应该变红、哪些应该保持绿"。
如果你正在改 rustc 自身并怀疑某个查询漏了依赖,这类测试是最快的回归防线:它不测"输出对不对",只测"红的范围对不对"。依赖漏报的典型症状是"改了代码但下游没重编译",而这类测试会直接把它变成测试失败,而不是等到某个用户遇到缓存陈旧。
手工版的做法是用 -Zquery-dep-graph 跑一个 rustc_clean 标注的单文件测试,然后对比两次编译的 .dot 图。注意 .dot 里的节点名是 desc 字符串,同一功能在两次编译中若换了措辞,图会看起来完全不同——所以比较时应按键而非按名字,或者固定使用同一版本的 desc。最后提醒:这类测试要求两次编译使用完全相同的参数与增量目录,否则"红"的范围会被环境差异污染。
1.28 一个被低估的结构:Session 的 32 个字段
前面说 Compiler 只有四个字段,但那四个字段里装着整个会话。真正的"会话状态"是 Session(session.rs#L327-L426),一共 32 个字段。本章最后看它,是因为流水线上每个阶段的输入都能在这张字段表里找到来源:哪些字段在 run_compiler 之前就固定,哪些字段要被前端回填,哪些字段只为增量编译服务。
1.28.1 逐字段:谁写、何时写、之后还改不改
| 字段 | 类型要点 | 写入时机 | 主要读者 |
|---|---|---|---|
target / host | Target | 会话构造 | 布局、ABI、codegen |
opts | config::Options | 会话构造 | 几乎全部阶段 |
psess | ParseSess | 会话构造 | 解析、宏展开 |
io | CompilerIO | 会话构造 | 驱动层、诊断 |
prof / timings | profiler 句柄 | 会话构造 | -Zself-profile、--json=timings |
code_stats | CodeStats | 统计期累加 | --print、耗时报告 |
lint_store | Option<Arc<dyn DynLintStore>> | 注册 lint 时 | 全部 lint pass |
internal_target_features | FxIndexSet<Symbol> | 后端 init 之后 | 编译器自身 |
env_depinfo / file_depinfo | Lock<FxIndexSet<_>> | 每次读环境变量/文件 | 增量编译 |
replaced_intrinsics / fallback_intrinsics | FxHashSet<Symbol> | 后端 init 之后 | intrinsic 解析 |
used_features | Lock<FxHashMap<Symbol, u32>> | 每次特性被用到 | unused-features lint |
removed_rustc_main_attr | AtomicBool | 测试 harness 生成入口时 | 诊断 |
把这张表按"写入时机"重排,就得到三条清晰的分界:target、opts、psess、io 在构造 Session 时一次写定;internal_target_features、replaced_intrinsics、thin_lto_supported 要等 codegen 后端 init 之后才有值;env_depinfo、file_depinfo、used_features 则在整条流水线上被持续追加。这也解释了为什么 Session 不是"构造完就冻结"的:它冻结的是配置,不冻结的是副作用记录。
1.28.2 三个字段的字段名就是文档
env_depinfo: Lock<FxIndexSet<(Symbol, Option<Symbol>)>>(L390-L394)的第二项是Option<Symbol>,而不是Symbol。多出来的None表示"这个环境变量在编译时不存在"。这个区分是必要的:env!("FOO")在第一次编译时FOO未定义会报错,如果只记录"读过 FOO"而不记录"当时不存在",那么下次构建时FOO出现了也不会触发重编。used_features: Lock<FxHashMap<Symbol, u32>>(L415-L418)的 value 类型是u32而不是DepNodeIndex。注释只说"value is theDepNodeIndex",类型却不是它,原因在依赖方向:Session属于rustc_session,而DepNodeIndex定义在rustc_middle(graph.rs#L68-L70)。若此处用真实类型,rustc_session就要反向依赖rustc_middle,整张依赖图会多出一条环。removed_rustc_main_attr: AtomicBool(L420-L422)只服务一件事:--test时 harness 会生成一个合成的main并移除用户写的#[rustc_main],之后诊断要据此决定是否提示"你的 main 被忽略了"。字段本身只是一个布尔值,信息量全在注释里。
1.28.3 两条不变量与一段伪代码
used_features 有两条不能被破坏的不变量:
- 一个 feature 最多对应一个 side-effect 节点。写入路径只有
track_feature(callbacks.rs#L55-L72),它先查表再决定是否新建,天然满足"至多一次"。 - 表里存的索引必须已经
read_index过。否则该 feature 的使用不会被记为当前查询的依赖,增量编译时"删掉一个用到 feature 的函数"不会让 crate 变红。
把 track_feature 写成伪代码:
track_feature(feature):
if no ImplicitCtxt: return # 早期阶段还没有查询系统
if used_features[feature] == Some(i):
dep_graph.read_index(DepNodeIndex(i))
return
idx = dep_graph.encode_side_effect(CheckFeature { feature })
used_features[feature] = idx.as_u32()
dep_graph.read_index(idx)
encode_side_effect(graph.rs#L728-L747)创建的节点以 DepNodeIndex::FOREVER_RED_NODE 为依赖,因此它永远不可能变绿——副作用必须在新会话里重放。这就是"用了哪个 feature"能跨增量会话保留下来的机制:表本身不落盘,落盘的是 side-effect 节点,重放时再写回表。
1.28.4 一个真实边界:被硬编码放过的两个 feature
report_unused_features(context.rs#L1607-L1643)会把"声明了但没被记录为使用"的 feature 报成 lint。但它的过滤器里有两个例外:restricted_std 与 doc_cfg(L1621-L1631)。
restricted_std只在标准库构建时用于标记"这个平台不受支持",下游 crate 根本不会记录它的使用,源码里留着 FIXME 讨论要不要在 lint 里特判。doc_cfg由 rustdoc 读取(tcx.features().doc_cfg()),普通 rustc 编译可能永远观察不到这次使用。
这个边界说明:used_features 记录的是"rustc 观察到的使用",而不是"语言层面真实的使用"。任何把该表当作完整审计日志的工具都会在这里踩空。
1.28.5 三处设计权衡
其一,u32 换依赖方向。 前文已述:used_features 的 value 退化成裸 u32,避免了 rustc_session → rustc_middle 的反向边。代价是每次读写都要手工 as_u32()/from_u32(),类型安全由调用点自律。这是分层架构里典型的权衡:宁可损失一点类型安全,也不引入编译期依赖环。
其二,类型擦除换依赖方向。 lint_store 的声明是 Option<Arc<dyn DynLintStore>>,注释直说 "we don't want a dependency on that type here"(L352-L353)。DynLintStore 只暴露一个 lint_groups_iter(L81-L84),真正的 LintStore 在 rustc_lint 里。权衡的另一面是:rustc_session 无法直接调用 lint 的注册接口,驱动层必须在正确的时机把 Arc 塞进来,否则 lint 静默失效。
其三,5 个 Lock 字段换并发正确性。 32 个字段里有 5 个用 Lock:ctfe_backtrace、miri_unleashed_features、env_depinfo、file_depinfo、used_features(另有 3 个 Atomic)。它们是 Session 被 &'tcx Session 共享之后仅有的可变点。权衡很清楚:把这些字段改成查询缓存可以让读写无锁,但"读环境变量"这件事发生在任意阶段、任意线程,没有合适的查询 key;用一个短临界区的 Lock 是成本最低的正确做法。
1.29 本章小结
本章建立了四个基础认知:
- 七种表示:源码、Token、AST、展开后 AST、带解析 AST、HIR、MIR。后续章节围绕它们展开。
- 顶层函数链:
run_compiler→interface::run_compiler→create_and_enter_global_ctxt→analysis→start_codegen。每个函数的职责边界清晰。 - crate 分层:底层(span/lexer/data_structures)→ 前端(parse/expand/resolve)→ 内核(middle)→ 分析(typeck/trait/infer/borrowck)→ 后端(codegen_ssa/llvm)。
- 错误三层:
Diag+ErrorGuaranteed+FatalError/ICE。
下一章将从"这套代码是怎么被构建出来的"入手,讲解 x.py、bootstrap 与自举。理解构建系统,才能在自己的机器上复现本系列的所有实验。