《rustc 内幕》第 1 章 全景:rustc 编译流水线与 crate 版图

0 阅读1小时+

《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 → 目标文件

注意两个容易被忽略的事实:

  1. AST 不是一次成型的。宏展开会在 AST 上反复进行,直到没有可展开的宏。因此 ③ 与 ④ 之间不是一次线性变换,而是一个不动点迭代(fixpoint iteration)。
  2. 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)) {

它做了五件事,顺序非常关键:

  1. 丢弃 argv[0](二进制名),并对参数做 @file 展开;
  2. 调用 handle_options,处理 --help、--version、-Zhelp 这类"提前退出"的选项;
  3. 调用 config::build_session_options,把 Matches(getopts 解析结果)构造成 Options;
  4. 调用 make_input、make_output,确定输入文件与输出路径;
  5. 组装 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 { ... }
}

从这段代码可以读出三个重要事实:

  1. 错误是短路式的,但有粒度。has_errors_excluding_lint_errors 说明:普通错误会终止后续分析(避免淹没用户),但 lint 错误不会。这是 rustc 诊断体验的一条硬规则。
  2. 分析阶段大量使用 par_fns 并行。effective_visibilities、check_private_in_public、lint_checking、clashing_extern_declarations 可以并行,因为它们彼此独立,且都通过查询系统读共享数据。
  3. 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 实战:用编译日志验证本章结论

理论需要验证。下面给出几条可操作的观察方法(不需要改源码):

  1. 观察阶段耗时:使用 -Ztime-passes(nightly)可以看到 parse_crate、expansion、type_check_crate、borrow_check、codegen_crate 等阶段的耗时。这些名字直接对应本章的流水线节点。
  2. 观察查询:-Zquery-dep-graph 配合 -Zincremental-info 可以输出查询依赖。
  3. 观察 MIR:-Zunpretty=mir 可以看到 HIR 降级后的 MIR;-Zunpretty=hir-tree 可以看到 HIR 的树形结构。
  4. 观察 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 IRRust 类型细节(泛型、trait)已单态化,只剩具体布局
LLVM IR → 机器码大部分类型信息后端只需要 ABI 与指令

反过来,哪些信息必须保留同样重要:Span 一路保留到 MIR(用于诊断),HirId 保留到类型检查结果,DefId 保留到元数据。这些"贯穿信息"是跨层协作的粘合剂。

1.12 关键 crate 的职责地图

78 个 crate 不可能在概述里逐个讲完,但下面这张表覆盖了流水线上最重要的 30 个,足以支撑后续阅读时的定位。

crate职责关键类型/入口
rustc_data_structures通用数据结构:FxHashMap、IndexVec、Arena、Lock、WorkerLocalsync、fingerprint、graph
rustc_index带类型的整数索引宏 newtype_index!Idx、IdxVec
rustc_macros派生宏与属性宏(Encodable、StableHash、Symbol)——
rustc_spanSpan、SourceFile、SourceMap、卫生性Span、FileName、hygiene
rustc_lexer纯词法器TokenKind、Token
rustc_astAST 定义、token stream、P<T>Crate、Item、Expr
rustc_parse递归下降解析器与错误恢复Parser、TokenCursor
rustc_expand宏展开:声明宏、过程宏、cfgMacroExpander、mbe
rustc_resolve名称解析Resolver、Rib、ModuleData
rustc_ast_loweringAST → HIRLoweringContext
rustc_hirHIR 定义、map、visitorCrate、Body、HirId
rustc_middle编译器内核:TyCtxt、TyKind、mir::Body、查询定义ty、mir、query
rustc_query_system查询系统的通用机制QuerySystem、JobOwner
rustc_query_impl具体查询实现与并行执行QueryCaches、execute_job
rustc_hir_analysisitem 收集、WF、一致性检查collect、check
rustc_hir_typeck表达式/语句类型检查FnCtxt、check_expr
rustc_infer推断变量、region 约束、快照InferCtxt、InferCtxtSnapshot
rustc_trait_selectiontrait 候选选择、归一化、object safetySelectionContext、Obligation
rustc_next_trait_solver新一代 trait solversolve、canonical
rustc_const_eval常量求值、MIR 解释器InterpCx、ConstEvalCtxt
rustc_mir_buildHIR → MIRBuilder、thir
rustc_borrowckNLL 借用检查BorrowckCtxt、BorrowSet
rustc_mir_dataflow数据流框架Analysis、Results
rustc_mir_transformMIR pass 管线MirPass、PassManager
rustc_pattern_analysis穷尽性与 usefulnessAnalysis、Constructor
rustc_monomorphize实例收集与单态化Instance、MonoItem
rustc_codegen_ssa后端抽象层CodegenBackend、MiscCodegenMethods
rustc_codegen_llvmLLVM 后端CodegenCx、Builder
rustc_metadata.rmeta 编解码、crate 加载CrateMetadata、creader
rustc_incrementalDepGraph、缓存持久化DepGraph、WorkProduct
rustc_errors诊断数据结构与渲染DiagCtxt、Diag
rustc_lintlint 框架与内置 lintLintStore、LintPass
rustc_sessionSession、Options、targetbuild_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。五个关联项各有明确职责,逐个解释:

  1. Copy + Eq + Hash + StableHash。Copy 让 key 可以在缓存、依赖图、诊断之间自由复制而不引入生命周期;Hash 用于内存缓存;StableHash 用于增量编译的指纹——两者不能合并,因为默认的 Hash 允许随机种子(如 RandomState),而跨会话的指纹必须稳定。
  2. Cache<V>。默认 DefaultCache<Self, V>(分片哈希表),但每种 key 可以覆盖。这是"参数形状决定数据结构的"入口。
  3. LocalQueryKey。当同一个逻辑查询既要服务本地 crate 又要服务外部 crate时,key 需要能"投影"成纯本地版本,于是本地表可以密集存储。
  4. default_span。查询出错(循环、dep graph 断言失败、-Zquery-dep-graph 报错)时需要给用户一个位置。DefId 本身没有 span,必须通过 tcx 反查 def_span;而 () 没有位置,只能返回 DUMMY_SP。
  5. 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 类型CacheLocalQueryKey典型查询选择理由
()SingleCache<V>无analysis、hir_crate_items、collect_and_partition_mono_items全 crate 只有一个值
CrateNumVecCacheLocalCrateextern_crate、exportable_itemscrate 数量少、编号密集
OwnerIdVecCache无hir_owner_parent_q、opt_hir_owner_nodesowner 编号密集且只增不减
LocalDefIdVecCache无typeck_root、mir_borrowck、mir_built本地 DefIndex 是连续小整数
DefIdDefIdCache本地键可投影optimized_mir、layout_of(用 PseudoCanonicalInput)、generics_of需要同时覆盖本地与外部
ModId / 复合 keyDefaultCacheLocalModIdcheck_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-checking foo" 而不是 "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_cache 57 个;
  • cache_on_disk 58 个;
  • eval_always 37 个;
  • feedable 26 个;
  • separate_provide_extern 117 个;
  • no_hash 10 个;
  • handle_cycle_error 5 个;
  • no_force 2 个;
  • depth_limit 2 个。

这组数字本身就是一张架构图:

  1. separate_provide_extern 占 1/3 以上(117/326)。这说明 rustc 大量查询必须回答"上游 crate 的这个东西是什么",而答案已经在 rmeta 里。增量的收益主要来自这里:外部 crate 不重新计算,只解码。
  2. cache_on_disk 只有 58 个,比 separate_provide_extern 少得多。原因是磁盘缓存只能存"本地键计算出的值"(见 modifiers.rs 的说明),而外部键的值来自 metadata。两者数量差异正说明"跨 crate 查询多、但可落盘的本地查询少"。
  3. eval_always 37 个,包括 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"),所以同一会话内不会算两遍。
  4. feedable 26 个。feed 方法的存在意味着这些查询的值可以由"知道答案的人"直接注入。典型场景是并行前端先解析完某个 owner,再把 hir_owner 喂给查询系统,避免查询系统自己再去 lowering。mir_built 也是 feedable(queries.rs)。
  5. no_hash 10 个。mir_drops_elaborated_and_const_checked、mir_promoted 都在其中(queries.rs)。原因写在 mir_built 的返回类型里:&'tcx Steal<mir::Body<'tcx>>——Steal 的内容会被后续 pass 取走,返回值不是稳定值,哈希它没有意义。这类查询"一旦需要重算,节点必红"(modifiers 文档 modifiers.rs)。
  6. 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 调试方法:如何验证一个查询的真实修饰符

不要在文档里猜。三种可靠手段:

  1. 直接看 queries.rs 中该 query 块内的修饰符列表;desc 之后、} 之前的每一行都是修饰符。
  2. 用 -Zquery-dep-graph 加 rustc_clean 注释做行为测试:如果某查询被标 eval_always,测试里"只改注释"也会让它变红。
  3. 用 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零依赖匿名节点
TraitSelecttrait 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 成绿
   │      → 绿:尝试从磁盘加载值
   │      → 加载失败(格式版本变化):红
   │
   └─ 节点不在旧图中,或指纹不同
          → 红:必须重算

两个关键点:

  1. "绿"不等于"值可用"。绿只说明输入没变;值能否加载还取决于 cache_on_disk 是否开启、缓存文件是否存在、-Z incremental 的版本戳是否匹配。这就是为什么清空 target/debug/incremental 后第一次编译依旧慢,但之后会变快——绿了,也有值了。
  2. 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。三个事实值得注意:

  1. 锁按 key 的哈希分片。不是全局锁、不是每个查询一把锁,而是每个查询一张活跃表、表内再分片。这让"查询 A 的 key1"和"查询 A 的 key2"可以同时被计算。
  2. 缓存检查只在并行前端开启时做。注释解释了原因:并行前端下,别的线程可能已经算完并把值放进缓存,但活跃表里那条记录可能刚好已被清理;若只信活跃表,会重复执行。而在单线程前端下,活跃表与缓存由同一个线程顺序维护,无需这次额外查找——这是一处用编译模式换性能的分支(jobs.frontend.is_some() 等价于 -Zthreads > 1 的前端)。
  3. 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 的收益来自两处:

  1. UCE(unused query error)不再触发。某些查询带 #[rustc_do_not_const_check] 或"必须被使用"的约定,普通 Get 若丢弃返回值会警告;EnsureOk 明确表示"我只要它跑过"。
  2. 返回类型被擦成 ()。对返回大结构的查询(如 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)。它按顺序请求:

  1. run_required_analyses(passes.rs):
    • misc_checking_1 里 par_fns 三路并行:入口点/导出项/limits;
    • check_mod_attrs、check_mod_unstable_api_usage 按模块并行;
    • emit_ast_lowering_delayed_lints;
  2. rustc_hir_analysis::check_crate(tcx)(passes.rs):trait 求解、WF 检查、类型检查的触发者(真正计算由 typeck_root 完成);
  3. MIR_borrow_checking:par_hir_body_owners 对每个非 typeck-child 的 body 调 mir_borrowck(passes.rs);
  4. layout_testing 与 abi_testing(passes.rs);
  5. 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 三个容易搞错的边界

  1. --emit=mir 也需要 codegen。 因为 MIR 是在 codegen 阶段写出的(should_codegen 把 Mir 归为 true)。想只看 MIR 又要避免后端,可用 -Zunpretty=mir,它走的是"unpretty"通道而不是 --emit。
  2. --emit=metadata 的 typeck 仍然跑。 metadata 需要类型信息、trait impl 表、可见性;cargo check 正是"只跑到 metadata"。
  3. -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调试信息的词法作用域树
coroutinecoroutine 附加信息(普通函数为 None)
local_decls局部变量声明:_0 返回值、1..=arg_count 参数、其余临时量
user_type_annotations用户写的类型标注(canonical 形式)
arg_count参数个数,决定哪些 local 由调用方初始化
spread_argrust-call ABI 的展开参数
var_debug_info用户变量调试信息(含捕获)
span整个 body 的 span
required_consts必须成功求值的常量(健全性关键)
mentioned_items可能被单态化的 item(非健全性关键,但必须与优化级别无关)
is_polymorphic是否使用泛型参数;一旦设定不可更改
injection_phaserustc_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 ONLY None during 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 一张对比表收束

维度ASTHIRMIR
节点身份NodeId(全局)HirId(owner 内局部)Local/BasicBlock(函数内)
属性存放节点内 AttrVec独立 AttributeMap无(已降级为语句/常量)
语法糖保留基本去糖完全展开为控制流
控制流隐式(树)隐式(树,带 Loop/Match)显式(CFG)
可变性可改写(宏展开)只读、arena 分配可被 pass 改写(Steal/&mut Body)
关键约束宏展开需要 token增量哈希需要稳定身份借用检查需要显式 place 与 CFG
错误表示解析错误恢复成 ExprKind::Errlowering 失败成 ExprKind::Errtainted_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。三个设计要点:

  1. 真/假两个 arm 都先经过 _ad(后支配点),再到 join。这个中间的"汇聚块"不是优化产物,而是借用检查的需要:变量的活跃区间必须在两条分支上都被"收束",否则 NLL 无法判断某个借用在 join 点是否还活着。

  2. 假边(false edges)是显式的 StatementKind::FalseEdge。当条件本身是 x == 0 这种直接产生 SwitchInt 的表达式时,真实的 CFG 边已经存在;但 MIR 仍插入 FalseEdge { real_target, imaginary_target } 把"概念上的真/假分支"补齐。注释解释说:如果不补,借用检查会认为某些变量在分支里"没有被使用",从而过早结束生命周期,导致莫名其妙的"借用不够长"错误。

  3. 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。四个易被忽略的细节:

  1. 两个 arm 的顺序是"先 Break 后 Continue"(L1965)。这不是随意排列:TryDesugar 的 arm 顺序被下游(MIR lowering、pretty printer)当作协议读取,若反过来,-Zunpretty=hir 的输出了解读者会误判。
  2. 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,否则会跳出整个函数。
  3. 属性被刻意加到返回表达式上:unreachable_code_attr(try_span) 生成 #[allow(unreachable_code)](L1908),因为 Break 分支在类型系统看来是"发散"的,后续 arm 不可达。若不加,用户会在 Ok(v) => ... 里收到"不可达代码"警告——一个完全不该由 ? 引起的警告。
  4. 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 exprdestinationexpr 的值
break 'label expr标签对应的 joinexpr 的值

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 从查询到阶段的三种传播方式

  1. 返回值传播:如上。典型调用点是 analysis 里的 par_hir_body_owners(... tcx.ensure_ok().mir_borrowck(def_id) ...);ensure_ok 对 Result 查询会强制要求 Ok,拿到 Err 就 panic。因此 mir_borrowck 报错后,analysis 不会继续往下跑。

  2. 全局状态传播: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 行为与普通编译行为的差异。

  3. 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 四类退出路径的语义区别

退出方式触发者用户看到的
正常 errordcx.emit_err + ErrorGuaranteed错误与后续编译(可能继续)
raise_fatalErrorGuaranteed::raise_fatal已报错,停止并返回非零
FatalError.raise查询系统(循环、毒化)内部致命错误,仍走 ICE 报告
panic / ICEbug! / 断言失败"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。三个设计点:

  1. 第一个函数在当前线程执行,注释明说"Use that for the longest running function for better scheduling"(L71-L73)。analysis 里 misc_checking_1 的 par_fns 就把最重的 rustc_hir_analysis::check_crate 放在第一位。
  2. 其余函数逆序 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 顺序都会变。
  3. 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 并行的三个不可交换假设

并行前端不是"把串行代码加锁"就能得到的,它依赖三个结构性假设:

  1. owner 之间无写冲突。HIR 是只读 arena,查询只往自己的缓存槽写。若某个 pass 需要全局可变状态(如 Steal),它必须被限制在"单线程阶段"或在 join 点合并。
  2. 诊断顺序由延迟发射保证。并行执行会让 emit_err 的调用顺序随机;DiagCtxt 内部按 Span 与稳定键排序,才让 UI 测试可重复。
  3. 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_injectionpasses.rs注入 extern crate std、prelude
macro_expand_crateL164宏展开(含 cfg 处理前的展开)
expand_crateL216完整展开(含 derive)
check_unused_macrosL228未使用宏检查
maybe_building_test_harnessL252--test 时生成 harness
AST_validationL256AST 合法性校验
maybe_create_a_macro_crateL292proc-macro crate 特化
complete_gated_feature_checkingL416特性门控最终检查
misc_checking_1L1110入口点、导出项、derive registrar
looking_for_entry_pointL1113main/start 查找
check_externally_implementable_itemsL1114#[rustc_*] 外部可实现项检查
looking_for_derive_registrarL1118derive 注册
emit_ast_lowering_delayed_lintsL1142延迟 lint 发射
MIR_borrow_checkingL1154借用检查(按 body 并行)
layout_testingL1194-Zlayout-test 的类型布局断言
abi_testingL1195-Zabi-test 的 ABI 断言
misc_checking_3L1217可见性、私有性、lint、extern 冲突
lint_checkingL1234rustc_lint::check_crate
privacy_checking_modulesL1244模块级隐私检查
check_lint_expectationsL1254#[expect] 是否被满足
ensuring_final_MIR_is_computableL1274最终 MIR 可计算性检查
codegen_crateL1324后端代码生成

这张表的实际价值:看到 -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_borrowck15%–25%与函数数量、NLL 约束数成正比
优化 MIR5%–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 三个常见误判

  1. 把 cargo 的等待时间当成 rustc 时间。cargo build 里有依赖解析、指纹计算、链接;看 -Ztime-passes 的 total 才是 rustc 自身。
  2. 把增量首次编译当成稳态。首次没有磁盘缓存,cache_on_disk 的收益为 0;稳态要看第二次。
  3. 忽视 -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 注释)。它做的事与手工实验等价,但更严格:

  1. 先编译一次,记录所有查询的 dep node;
  2. 修改源码的某个区域,再编译一次;
  3. 断言"哪些查询应该变红、哪些应该保持绿"。

如果你正在改 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 / hostTarget会话构造布局、ABI、codegen
optsconfig::Options会话构造几乎全部阶段
psessParseSess会话构造解析、宏展开
ioCompilerIO会话构造驱动层、诊断
prof / timingsprofiler 句柄会话构造-Zself-profile、--json=timings
code_statsCodeStats统计期累加--print、耗时报告
lint_storeOption<Arc<dyn DynLintStore>>注册 lint 时全部 lint pass
internal_target_featuresFxIndexSet<Symbol>后端 init 之后编译器自身
env_depinfo / file_depinfoLock<FxIndexSet<_>>每次读环境变量/文件增量编译
replaced_intrinsics / fallback_intrinsicsFxHashSet<Symbol>后端 init 之后intrinsic 解析
used_featuresLock<FxHashMap<Symbol, u32>>每次特性被用到unused-features lint
removed_rustc_main_attrAtomicBool测试 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 the DepNodeIndex",类型却不是它,原因在依赖方向: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 有两条不能被破坏的不变量:

  1. 一个 feature 最多对应一个 side-effect 节点。写入路径只有 track_feature(callbacks.rs#L55-L72),它先查表再决定是否新建,天然满足"至多一次"。
  2. 表里存的索引必须已经 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 本章小结

本章建立了四个基础认知:

  1. 七种表示:源码、Token、AST、展开后 AST、带解析 AST、HIR、MIR。后续章节围绕它们展开。
  2. 顶层函数链:run_compiler → interface::run_compiler → create_and_enter_global_ctxt → analysis → start_codegen。每个函数的职责边界清晰。
  3. crate 分层:底层(span/lexer/data_structures)→ 前端(parse/expand/resolve)→ 内核(middle)→ 分析(typeck/trait/infer/borrowck)→ 后端(codegen_ssa/llvm)。
  4. 错误三层:Diag + ErrorGuaranteed + FatalError/ICE。

下一章将从"这套代码是怎么被构建出来的"入手,讲解 x.py、bootstrap 与自举。理解构建系统,才能在自己的机器上复现本系列的所有实验。