Rust 编译器与运行时揭秘

第17章 增量编译:让重编译只做必要的事

作者 杨艺韬 · 9,703 字 · 发布于 · 更新于

最快的编译是不编译。增量编译的全部智慧,在于精确地找出哪些东西不需要重新编译——拿不准的宁可重编,也绝不复用过时的结果。

本章要点

  • Rust 编译器从 HIR 到 LLVM IR 之间的工作被组织成一个按需查询数据库——这一段的每个计算步骤都被建模为一个 Query(词法、语法分析、宏展开和名字解析仍对整个 crate 一次性完成)
  • 依赖图(Dependency Graph)记录查询之间的依赖关系,是增量编译的核心数据结构
  • 指纹(Fingerprint)对查询输入和输出做 128 位哈希,用于检测"这个结果变了吗"
  • 红绿算法(Red/Green Algorithm)递归检查依赖链,精确标记哪些查询结果仍然有效(绿色)、哪些已经过时(红色)
  • 查询结果通过磁盘缓存持久化,下次编译时绿色节点可直接加载,跳过重新计算
  • Codegen Unit(代码生成单元)和 Work Product 机制让编译器可以跳过未变化的目标文件生成
  • 整个系统的设计灵感来自数据库查询引擎和构建系统;通用增量计算库 Salsa 反过来受它启发,但 rustc 并不使用 Salsa

17.1 为什么需要增量编译

Rust 以编译速度慢闻名:中等规模项目的全量编译常以分钟计,大型项目在 CI 上更久。原因包括泛型单态化、深度类型检查、LLVM 优化和宏展开——这些工作量都随代码规模增长。

在日常开发中,程序员通常只修改几行代码,却不得不等待整个项目重新编译。传统方案(crate 级缓存、目标文件级缓存)粒度太粗——如果你修改了一个 crate 中某个私有函数的实现(不改签名),理论上只需要重新检查该函数、重新生成该函数的 MIR 和机器码,但 crate 级缓存无法做到这一点。

Rust 的增量编译系统走了一条完全不同的路:它将编译器的内部计算分解为成千上万个细粒度的查询(Query),分别追踪每个查询的输入和输出,从而实现以单个定义(如一个函数)为粒度的精确缓存。

17.2 查询系统:编译器作为数据库

传统编译器按阶段组织(词法分析、语法分析、语义分析...),是一条线性流水线。但 rustc 采用了完全不同的架构:按需查询系统(demand-driven query system)。从 HIR 到 LLVM IR 之间的每个计算步骤都被建模为一个"查询"(Query)(词法分析、语法分析、宏展开和名字解析仍对整个 crate 一次性完成:1.96.0 中它们整体放在 resolver_for_lowering_raw 这一个查询的 provider 里,见 compiler/rustc_interface/src/passes.rs:784-798;这个查询不参与红绿判定,每次都重算),有明确的输入(key)和输出(value)。当查询被请求时才计算,结果被缓存。

graph TD
    subgraph "查询系统架构"
        A["type_of(foo)"] --> B["typeck_root(foo)"]
        B --> C["mir_built(foo)"]
        C --> D["optimized_mir(foo)"]
        D --> E["codegen_unit(cgu_0)"]
        A2["type_of(bar)"] --> B2["typeck_root(bar)"]
        B2 --> C2["mir_built(bar)"]
        C2 --> D2["optimized_mir(bar)"]
        D2 --> E
        F["predicates_of(Trait)"] --> B
        F --> B2
        G["generics_of(foo)"] --> B
        H["fn_sig(foo)"] --> B
        style A fill:#2563eb,color:#fff,stroke:none
        style A2 fill:#2563eb,color:#fff,stroke:none
        style D fill:#7c3aed,color:#fff,stroke:none
        style D2 fill:#7c3aed,color:#fff,stroke:none
        style E fill:#10b981,color:#333,stroke:none
    end

实际的 rustc 中有数百种查询(type_of、fn_sig、typeck_root、mir_built、optimized_mir、generics_of、predicates_of、codegen_unit 等;类型检查的查询在 1.96.0 里叫 typeck_root,常见的 tcx.typeck() 只是转调它的包装方法,见 compiler/rustc_middle/src/query/plumbing.rs:211-216)。查询的声明(rustc_queries!)在 compiler/rustc_middle/src/queries.rs,通用管道(QuerySystem、QueryVTable、on_disk_cache)在 compiler/rustc_middle/src/query/,宏展开出的每查询实现与执行引擎在 compiler/rustc_query_impl/。注意:早期版本里独立的 rustc_query_system crate 在 1.96.0 中已不存在,其代码分别并入了 rustc_middle(query/ 与 dep_graph/ 两个模块)和 rustc_query_impl(如 execution.rs)。QuerySystem 是入口:

// compiler/rustc_query_impl/src/lib.rs:45
pub fn query_system<'tcx>(
    local_providers: Providers,
    extern_providers: ExternProviders,
    on_disk_cache: Option<OnDiskCache>,
    incremental: bool,
) -> QuerySystem<'tcx> {
    QuerySystem {
        arenas: Default::default(),
        query_vtables: query_impl::make_query_vtables(incremental),
        side_effects: Default::default(),
        on_disk_cache,
        local_providers,
        extern_providers,
        jobs: AtomicU64::new(1),
    }
}

incremental 参数决定了查询是否在增量模式下运行。每个查询通过 QueryVTable 描述其行为,包含函数指针:invoke_provider_fn(实际计算)、hash_value_fn(稳定哈希)、try_load_from_disk_fn(磁盘加载)、will_cache_on_disk_for_key_fn(是否缓存到磁盘)、handle_cycle_error_fn(循环处理)。

在 compiler/rustc_query_impl/src/execution.rs 中,查询执行有两条路径:

// compiler/rustc_query_impl/src/execution.rs:259(简化)
fn try_execute_query<'tcx, C: QueryCache, const INCR: bool>(
    query: &'tcx QueryVTable<'tcx, C>,
    tcx: TyCtxt<'tcx>,
    span: Span,
    key: C::Key,
    dep_node: Option<DepNode>,
) -> (C::Value, Option<DepNodeIndex>) {
    // ... 省略锁和状态检查 ...

    // 根据是否增量编译,走不同的执行路径
    let (value, dep_node_index) = if INCR {
        execute_job_incr(query, tcx, key, dep_node.unwrap(), id)
    } else {
        execute_job_non_incr(query, tcx, key, id)
    };

    // ... 省略缓存写入 ...
}

非增量路径直接调用 provider 函数。增量路径(execute_job_incr)是核心:先尝试 try_mark_green 检查所有依赖是否"绿色"(没变化),如果成功就尽量从磁盘加载缓存结果(该查询不落盘或加载失败时,直接重跑 provider);如果失败(某个依赖是"红色"),则重新执行查询并记录新依赖。

// compiler/rustc_query_impl/src/execution.rs:413(简化,省略性能计时)
fn execute_job_incr<'tcx, C: QueryCache>(
    query: &'tcx QueryVTable<'tcx, C>,
    tcx: TyCtxt<'tcx>, key: C::Key,
    dep_node: DepNode, job_id: QueryJobId,
) -> (C::Value, DepNodeIndex) {
    let dep_graph_data = tcx.dep_graph.data()
        .expect("should always be present in incremental mode");
    if !query.eval_always {
        // 尝试"绿色快速路径"
        if let Some(ret) = start_query(job_id, false, || try {
            let (prev_index, dep_node_index) =
                dep_graph_data.try_mark_green(tcx, &dep_node)?;
            let value = load_from_disk_or_invoke_provider_green(
                tcx, dep_graph_data, query, key,
                &dep_node, prev_index, dep_node_index,
            );
            (value, dep_node_index)
        }) {
            return ret;
        }
    }
    // 快速路径失败,完整执行查询
    start_query(job_id, query.depth_limit, || {
        dep_graph_data.with_task(dep_node, tcx, (query, key),
            |tcx, (query, key)| (query.invoke_provider_fn)(tcx, key),
            query.hash_value_fn)
    })
}

17.3 依赖图:追踪一切因果关系

17.3.1 依赖图的数据结构

依赖图是增量编译的骨架。它记录了"查询 A 在计算过程中读取了查询 B 的结果"这样的关系。依赖图的核心定义在 compiler/rustc_middle/src/dep_graph/graph.rs 中:

// compiler/rustc_middle/src/dep_graph/graph.rs:56-64 与 :99-122 两段
// (中间 :66-97 是 DepNodeIndex、MarkFrame、DepNodeColor 等定义)
pub struct DepGraph {
    data: Option<Arc<DepGraphData>>,
    virtual_dep_node_index: Arc<AtomicU32>,
}

pub struct DepGraphData {
    /// 当前编译会话的依赖图
    current: CurrentDepGraph,

    /// 上一次编译会话的依赖图(从磁盘加载)
    previous: Arc<SerializedDepGraph>,

    /// 每个节点的颜色:绿色、红色或未知
    colors: DepNodeColorMap,

    /// 上一次编译会话的 Work Product 信息
    previous_work_products: WorkProductMap,

    /// 增量编译测试用:记录哪些查询结果是从磁盘加载的
    debug_loaded_from_disk: Lock<FxHashSet<DepNode>>,
}

注意两个关键字段:current(当前会话的图)和 previous(上一次会话的图)。增量编译的本质就是比较这两个图。

上一次编译的图以序列化形式存储在 SerializedDepGraph 中:

// compiler/rustc_middle/src/dep_graph/serialized.rs:99
pub struct SerializedDepGraph {
    /// 图中所有 DepNode
    nodes: IndexVec<SerializedDepNodeIndex, DepNode>,

    /// 每个节点的"值指纹"——通常是查询结果的哈希
    value_fingerprints: IndexVec<SerializedDepNodeIndex, Fingerprint>,

    /// 每个节点的边列表(它的依赖)
    edge_list_indices: IndexVec<SerializedDepNodeIndex, EdgeHeader>,

    /// 所有边数据的扁平存储
    edge_list_data: Vec<u8>,

    /// 从 (DepKind, key_fingerprint) 到节点索引的反向映射
    index: Vec<UnhashMap<PackedFingerprint, SerializedDepNodeIndex>>,

    /// 编译会话计数,用于生成唯一的匿名节点 ID
    session_count: u64,
}

设计要点:边列表使用变长编码(1-4字节),所有边数据扁平存储,解码时用固定大小读取加掩码。

每个节点(DepNode)由 DepKind(查询种类)和 PackedFingerprint(key 指纹)组成。关键设计:对查询节点来说,key_fingerprint 是查询 key 的稳定哈希,DefId 这类 key 会先换成 DefPathHash(定义路径的稳定哈希)再参与哈希,不依赖会话特定的 DefId 数值,因此可以直接跨会话序列化/反序列化,类似 git commit hash 跨仓库有效。

17.3.2 依赖追踪的原理

当一个查询在执行过程中读取了另一个查询的结果时,编译器需要记录这个依赖关系。这通过线程局部的 TaskDeps 实现:

// compiler/rustc_middle/src/dep_graph/graph.rs:466(简化)
pub fn read_index(&self, dep_node_index: DepNodeIndex) {
    if let Some(ref data) = self.data {
        read_deps(|task_deps| {
            let mut task_deps = match task_deps {
                TaskDepsRef::Allow(deps) => deps.lock(),
                TaskDepsRef::EvalAlways => return,  // eval_always 查询不记录依赖
                TaskDepsRef::Ignore => return,
                TaskDepsRef::Forbid => panic!("forbidden read"),
            };

            // 检查是否已经记录过这个依赖(去重)
            let new_read = if task_deps.reads.len() <= TaskDeps::LINEAR_SCAN_MAX {
                !task_deps.reads.contains(&dep_node_index)  // 小集合用线性扫描
            } else {
                task_deps.read_set.insert(dep_node_index)    // 大集合用 HashSet
            };

            if new_read {
                task_deps.reads.push(dep_node_index);
                // 当读取数量超过阈值时,切换到 HashSet 加速查重
                if task_deps.reads.len() == TaskDeps::LINEAR_SCAN_MAX + 1 {
                    task_deps.read_set.extend(task_deps.reads.iter().copied());
                }
            }
        })
    }
}

工程权衡:依赖少时线性扫描(缓存友好),多了自动切换到 HashSet。

查询执行时依赖追踪有四种模式:Allow(正常记录)、EvalAlways(不记录,每次重算)、Ignore(不记录不报错)、Forbid(读任何依赖都 panic——用于反序列化,防止在不同编译会话间创建不一致的依赖边)。

17.4 指纹系统:如何检测"变了没有"

增量编译的核心问题是:这个查询的结果和上次编译时一样吗? 答案依赖于 128 位指纹(Fingerprint),使用 StableHasher(基于 SipHash 变体)计算。关键约束:哈希必须稳定——相同输入在不同编译会话中产生相同哈希,不能依赖内存地址或 DefId 数值,只能依赖 DefPathHash 等跨会话稳定的标识符。

17.4.1 节点着色:指纹比较的时刻

当一个查询被重新执行后,编译器会比较新的指纹和上一次编译的指纹,决定这个节点的"颜色":

// compiler/rustc_middle/src/dep_graph/graph.rs:755
fn alloc_and_color_node(
    &self,
    key: DepNode,
    edges: EdgesVec,
    value_fingerprint: Option<Fingerprint>,
) -> DepNodeIndex {
    if let Some(prev_index) = self.previous.node_to_index_opt(&key) {
        let is_green = if let Some(value_fingerprint) = value_fingerprint {
            if value_fingerprint == self.previous.value_fingerprint_for_index(prev_index) {
                // 绿色:结果和上次一样
                true
            } else {
                // 红色:结果变了
                false
            }
        } else {
            // 没有哈希函数(no_hash 查询)——保守地标记为红色
            false
        };

        // ... 写入颜色信息 ...
    }
}

节点有三种颜色:

// compiler/rustc_middle/src/dep_graph/graph.rs:93
pub(super) enum DepNodeColor {
    Green(DepNodeIndex),  // 结果没变,可以复用
    Red,                  // 结果变了,需要重算
    Unknown,              // 还没有确定颜色
}

17.4.2 no_hash 查询的特殊处理

某些查询被标记为 no_hash——它们不计算结果的指纹。1.96.0 的 compiler/rustc_middle/src/queries.rs 里共 9 个这样的查询,大致有两类:

  1. 哈希不划算:erase_and_anonymize_regions_ty 的注释写明不哈希返回值性能略好
  2. 结果是用 Steal 包装、会被后续阶段取走的中间产物(thir_body、mir_promoted、mir_drops_elaborated_and_const_checked 等),或 resolver_for_lowering_raw 这类整块的前端状态

对于 no_hash 查询,一旦需要重新执行,编译器就无法判断结果是否真的变了,只能保守地将其标记为红色(compiler/rustc_middle/src/query/modifiers.rs:79-83)。这意味着依赖它的节点都无法经由它被标绿,只能重新执行。这是一个性能和正确性之间的权衡。

17.4.3 指纹验证

编译器内置了验证机制防止哈希不一致:按约 1/32 的概率抽样验证绿色节点的指纹(is_multiple_of(32)),重新哈希从磁盘加载的结果并与之前的指纹比较。使用 -Zincremental-verify-ich 可以开启全量验证。如果磁盘加载失败需要重新计算,则总是验证结果。

17.5 红绿算法:增量编译的核心

17.5.1 算法概述

红绿算法(Red/Green Algorithm)是增量编译的灵魂。它回答一个核心问题:给定一个查询节点,它在上次编译之后是否仍然有效?

算法的基本思路是:

  1. 如果一个节点的所有依赖都是绿色的(结果没变),那么这个节点也是绿色的——无需重新计算
  2. 如果任何一个依赖是红色的(结果变了),那么这个节点可能需要重新计算
  3. 未知颜色的节点需要递归地检查
flowchart TD
    Start["try_mark_green(node)"] --> Check["在上一次编译中存在?"]
    Check -->|"不存在"| New["返回 None(新节点)"]
    Check -->|"存在"| Color["检查当前颜色"]
    Color -->|"已绿色"| RetGreen["返回 Green"]
    Color -->|"已红色"| RetNone["返回 None"]
    Color -->|"未知"| Loop["遍历所有依赖"]
    Loop --> DepColor["检查依赖的颜色"]
    DepColor -->|"绿色"| Continue["继续检查下一个依赖"]
    DepColor -->|"红色"| Fail["返回 None(此节点是红色)"]
    DepColor -->|"未知"| Recurse["递归 try_mark_green(依赖)"]
    Recurse -->|"成功"| Continue
    Recurse -->|"失败"| Force["try_force_from_dep_node(依赖)"]
    Force -->|"变绿了"| Continue
    Force -->|"变红了"| Fail
    Continue --> AllGreen{"所有依赖都绿色?"}
    AllGreen -->|"是"| Promote["promote_node_and_deps_to_current"]
    Promote --> MarkGreen["标记为绿色,返回索引"]
    style Start fill:#2563eb,color:#fff
    style RetGreen fill:#10b981,color:#333
    style MarkGreen fill:#10b981,color:#333
    style Fail fill:#dc2626,color:#fff
    style New fill:#f59e0b,color:#333
    style RetNone fill:#dc2626,color:#fff

17.5.2 源码解读:try_mark_green

让我们深入 try_mark_green 的实际实现(compiler/rustc_middle/src/dep_graph/graph.rs):

// compiler/rustc_middle/src/dep_graph/graph.rs:873(省略 debug_assert_eq! 与部分注释)
impl DepGraphData {
    pub fn try_mark_green<'tcx>(
        &self,
        tcx: TyCtxt<'tcx>,
        dep_node: &DepNode,
    ) -> Option<(SerializedDepNodeIndex, DepNodeIndex)> {
        debug_assert!(!tcx.is_eval_always(dep_node.kind));

        // 第一步:这个节点在上次编译中存在吗?
        let prev_index = self.previous.node_to_index_opt(dep_node)?;

        // 第二步:检查已知颜色
        match self.colors.get(prev_index) {
            DepNodeColor::Green(dep_node_index) => Some((prev_index, dep_node_index)),
            DepNodeColor::Red => None,
            DepNodeColor::Unknown => {
                // 第三步:颜色未知,需要递归检查依赖
                self.try_mark_previous_green(tcx, prev_index, None)
                    .map(|dep_node_index| (prev_index, dep_node_index))
            }
        }
    }
}

递归检查的核心在 try_mark_previous_green 中:

// compiler/rustc_middle/src/dep_graph/graph.rs:901(省略 debug_assert! 与部分注释)
fn try_mark_previous_green<'tcx>(
    &self,
    tcx: TyCtxt<'tcx>,
    prev_dep_node_index: SerializedDepNodeIndex,
    frame: Option<&MarkFrame<'_>>,
) -> Option<DepNodeIndex> {
    let frame = MarkFrame { index: prev_dep_node_index, parent: frame };

    // 遍历该节点的所有依赖
    for parent_dep_node_index in self.previous.edge_targets_from(prev_dep_node_index) {
        match self.colors.get(parent_dep_node_index) {
            DepNodeColor::Green(_) => continue,   // 依赖是绿色,继续
            DepNodeColor::Red => return None,       // 依赖是红色,此节点也标红
            DepNodeColor::Unknown => {}             // 需要进一步检查
        }

        let parent_dep_node = self.previous.index_to_node(parent_dep_node_index);

        // 尝试递归标记依赖为绿色
        if !tcx.is_eval_always(parent_dep_node.kind)
            && self.try_mark_previous_green(tcx, parent_dep_node_index, Some(&frame)).is_some()
        {
            continue;
        }

        // 递归失败,尝试强制执行这个依赖查询
        if !tcx.try_force_from_dep_node(*parent_dep_node, parent_dep_node_index, &frame) {
            return None;
        }

        // 强制执行后再检查颜色
        match self.colors.get(parent_dep_node_index) {
            DepNodeColor::Green(_) => continue,
            DepNodeColor::Red => return None,
            DepNodeColor::Unknown => {
                // 如果有编译错误,无法确定颜色,保守返回 None
                if tcx.dcx().has_errors_or_delayed_bugs().is_none() {
                    panic!("try_mark_previous_green() - forcing failed to set a color");
                }
                return None;
            }
        }
    }

    // 所有依赖都是绿色的!将此节点提升到当前会话的依赖图中
    let dep_node_index = self.promote_node_and_deps_to_current(prev_dep_node_index)?;
    Some(dep_node_index)
}

17.5.3 "强制执行"(Forcing)的含义

当 try_mark_previous_green 递归检查依赖时,可能遇到一个无法直接标记为绿色的依赖。此时它会"强制执行"(force)这个依赖查询,也就是重新运行查询的 provider 函数。执行完毕后,查询的结果会被哈希并与上次比较——如果结果相同,该节点变为绿色;如果不同,变为红色。

这个机制非常关键:即使一个查询的某个输入变了,只要最终结果没变(比如修改了注释但没改代码逻辑),那么依赖它的查询仍然可以保持绿色。

17.5.4 eval_always 查询

某些查询被标记为 eval_always,这意味着它们每次编译都必须重新执行,不参与红绿判定。典型例子包括(1.96.0 中共 35 个):

  • 整个 crate 的 HIR hir_crate(源文件内容是外部输入)
  • 读取环境变量的 env_var_os、读取外部 crate 信息的 crates、used_crate_source 等

eval_always 查询在依赖追踪中也不记录依赖——它们被视为图的"根节点"。

17.5.5 一个完整的例子

假设我们有一个简单的 Rust 项目:

fn helper() -> i32 { 42 }
fn main() { println!("{}", helper()); }

第一次编译会建立如下依赖图(极简化):

type_of(helper) -> typeck_root(helper) -> mir_built(helper) -> optimized_mir(helper)
type_of(main) -> typeck_root(main) -> mir_built(main) -> optimized_mir(main)
typeck_root(main) --depends-on--> fn_sig(helper)

现在我们修改 helper 的实现为 fn helper() -> i32 { 43 }。第二次编译时:

  1. HIR 解析发现 helper 的函数体变了
  2. type_of(helper) 被重新计算,结果是 i32——和上次一样,绿色
  3. fn_sig(helper) 被重新计算,结果是 fn() -> i32——和上次一样,绿色
  4. typeck_root(helper) 被重新计算,类型检查结果可能相同——绿色
  5. mir_built(helper) 被重新计算,MIR 变了(常量从 42 变成 43)——红色
  6. optimized_mir(helper) 的依赖 mir_built(helper) 是红色,需要重新优化——红色
  7. typeck_root(main) 依赖 fn_sig(helper) 和 type_of(main),都是绿色——绿色,无需重新检查
  8. optimized_mir(main) 依赖绿色的 mir_built(main) 和 typeck_root(main)——绿色

最终结果:只有 helper 的 MIR 需要重做,main 的查询结果都可以复用。用 #[rustc_clean(cfg="rev2")](见 §17.9)在 1.96.0 上实测,helper 只有 opt_hir_owner_nodes 与 optimized_mir 变脏,fn_sig、type_of、typeck_root 都保持干净,main 的各项全部干净。但代码生成是按 CGU 复用的:这个单文件例子里 helper 和 main 落在同一个 CGU(-Zprint-mono-items 可见),所以这个 CGU 的目标文件仍要整体重新生成(见 §17.7)。

17.5.6 DepNodeColor 三态

上面算法描述了"红色/绿色"两种状态,但真实 DepNodeColor 枚举(compiler/rustc_middle/src/dep_graph/graph.rs:93)有三态:

#[derive(Debug)]
pub(super) enum DepNodeColor {
    Green(DepNodeIndex),   // 绿色:未变、携带新 session 里的 index
    Red,                   // 红色:变了或依赖变了
    Unknown,               // 未知:还没被 try_mark_green 处理过
}

Unknown 第三态是算法语义的关键——遍历依赖时可能遇到尚未决定颜色的节点、必须区分 "未处理" 和 "确定红"。伪代码 mermaid 里的 "未知" 分支对应这个状态,它推动递归走 try_mark_green。初始化时除了预留的永红节点,所有旧节点都是 Unknown、被动拉动触发(即查询按需决定颜色、而不是一次性遍历整个图)——这是增量编译"懒惰求值"的关键。

17.5.7 两个特殊 DepNodeIndex 常量

compiler/rustc_middle/src/dep_graph/graph.rs:75-78 定义了两个预留常量:

impl DepNodeIndex {
    const SINGLETON_ZERO_DEPS_ANON_NODE: DepNodeIndex = DepNodeIndex::ZERO;
    pub const FOREVER_RED_NODE: DepNodeIndex = DepNodeIndex::from_u32(1);
}

SINGLETON_ZERO_DEPS_ANON_NODE = 0——一个共享的 singleton "空匿名节点"。anonymous 查询(没有显式 dep node 标识的临时计算)如果没有依赖,全部共用这一个 index——而不是每次创建新节点(compiler/rustc_middle/src/dep_graph/graph.rs:399-406)。再多的无依赖匿名任务也只占一个 slot,压缩了依赖图体积。

FOREVER_RED_NODE = 1——"永远红色"的特殊 DepNode,在 DepGraph::new 里作为第二个节点被无条件分配(DepKind::Red,自身无依赖),并在上一轮图存在时立刻被染成 Red。源码注释原话:"Other nodes can use the always-red node as a fake dependency, to ensure that their dependency list will never be all-green." 它的实际用法有三处:eval_always 查询在给别的查询 feed 结果时会把这个索引压进边表(read_deps 里 TaskDepsRef::EvalAlways => edges.push(DepNodeIndex::FOREVER_RED_NODE));TyCtxt::create_def 这类带副作用的操作用 read_index(FOREVER_RED_NODE) 强制调用方下次必须重算(compiler/rustc_middle/src/ty/context.rs:1394);§17.8 的副作用节点也以它为唯一依赖,保证下次一定被 force、把副作用重放出来(encode_side_effect,compiler/rustc_middle/src/dep_graph/graph.rs:693-708)。是一个内置的 opt-out 标签。

为什么用"保留常量 index"而非 Option<DepNodeIndex> 或 bool forever_red?——因为 DepGraph 的边都用 DepNodeIndex 索引,保留常量让"特殊标记"能和普通依赖共享同一索引类型、不用搞 union 类型或并行 bool 数组。节省内存、简化遍历。

17.5.8 static_assert_size!(Option<DepNodeIndex>, 4) 的极致内存工程

compiler/rustc_middle/src/dep_graph/graph.rs:73 有一条编译期断言:

// We store a large collection of these in `prev_index_to_index` during
// non-full incremental builds, and want to ensure that the element size
// doesn't inadvertently increase.
rustc_data_structures::static_assert_size!(Option<DepNodeIndex>, 4);

语义:Option<DepNodeIndex> 必须是 4 字节、否则编译直接失败。

为什么这么严格?因为 DepNodeIndex 是 newtype_index! 生成的包装类型,底层是一个带值域限制的 u32——compiler/rustc_index_macros/src/newtype.rs:108、:203 把有效值域定在 0..=0xFFFF_FF00,顶端那一段(0xFFFF_FF01 起)就是留给 niche 的空洞(注意 0 本身是合法索引,上一节的 SINGLETON_ZERO_DEPS_ANON_NODE 用的正是它)。于是 Option<DepNodeIndex> 可以被 Rust 塞进同一个 4 字节——None 借用空洞里的某个位模式,不额外占一个判别式字节。这个优化叫 niche optimization(第 5 章讲过)。

但如果哪天有人给 DepNodeIndex 加了第二个字段(比如 debug tag)、或者去掉值域限制让 niche 消失,Option<DepNodeIndex> 就会退化成 8 字节(判别式 + padding + 数据)——每个元素翻倍。大型 crate 的依赖图节点很多,这类按节点存放的数组一旦翻倍,内存开销相当可观。需要说明的是,注释里的 prev_index_to_index 在 1.96.0 中已不是一个字段(全树只在注释里出现):上一轮节点到本轮 DepNodeIndex 的映射已并进 DepNodeColorMap,每个旧节点一个 AtomicU32,绿色直接存本轮索引,红色与未知借用 u32::MAX - 1、u32::MAX 两个值(compiler/rustc_middle/src/dep_graph/graph.rs:1324-1333)——它依赖的仍是同一个事实:合法索引不会用到顶端那段值。

static_assert_size! 是一个宏,展开成 const _: [(); 4] = [(); ::std::mem::size_of::<Option<DepNodeIndex>>()];(compiler/rustc_index/src/lib.rs:41-45),大小不等时数组长度对不上,直接报类型不匹配错误。任何无意中破坏 niche 优化的改动在 CI 上当场暴露——rustc 的增量编译性能红线被显式、硬编码进源码。

这 4 行代码(assert + 3 行注释)体现的是 "内存布局当成 API 契约" 的工程态度——当一个模块承诺"图节点索引只占 4 字节"时、就用 static_assert_size! 把它变成编译器强制的契约、而不是口头约定。

17.6 磁盘缓存:查询结果的持久化

17.6.1 缓存目录结构

增量编译的缓存存储在 target/debug/incremental/ 目录下。目录结构和命名有严格的协议。编译器源码 compiler/rustc_incremental/src/persist/fs.rs 中定义了这些常量:

// compiler/rustc_incremental/src/persist/fs.rs:129-132
const DEP_GRAPH_FILENAME: &str = "dep-graph.bin";
const STAGING_DEP_GRAPH_FILENAME: &str = "dep-graph.part.bin";
const WORK_PRODUCTS_FILENAME: &str = "work-products.bin";
const QUERY_CACHE_FILENAME: &str = "query-cache.bin";

目录的完整结构如下:

target/debug/incremental/
└── my_crate-<disambiguator>/           # crate 级目录
    ├── s-<timestamp>-<random>-<svh>/   # 已完成的会话目录(已发布)
    │   ├── dep-graph.bin               # 序列化的依赖图
    │   ├── query-cache.bin             # 查询结果缓存
    │   ├── work-products.bin           # Work Product 索引
    │   ├── <cgu_name>.o                # 编译生成的目标文件
    │   └── <cgu_name>.dwo              # 调试信息文件(仅拆分调试信息时)
    ├── s-<timestamp>-<random>-working/ # 正在进行的会话目录
    │   └── ...
    └── s-<timestamp>-<random>.lock     # 会话锁文件

17.6.2 会话目录的生命周期

协议是写时复制(copy-on-write)风格:(1) 创建 s-{timestamp}-{random}-working 目录;(2) 把最新已完成会话目录的内容硬链接(不支持时拷贝)到新目录;(3) 编译过程中读写工作目录;(4) 成功后把末尾的 working 换成 crate 的 SVH,即 s-{timestamp}-{random}-{svh};(5) 清理旧目录。fs.rs 模块文档写的是 s-{timestamp}-{SVH},但 finalize_session_directory 的实现保留了随机数段(compiler/rustc_incremental/src/persist/fs.rs:345-352),实际生成的目录名形如 s-hml5f2bz72-0q7b70v-1x4m2ksdx3yjtp8opmrh4nvwy。已完成的会话目录永远不会被修改,保证了并发安全——多个编译器实例可以同时编译同一个 crate。

并发安全依赖 .lock 文件和独占锁。垃圾回收在每次编译开始时(setup_dep_graph 里)自动运行:删除孤立目录,对超过 10 秒的 -working 目录尝试获取独占锁(成功则说明所有者进程已死亡),保留最新已完成目录。

17.6.3 加载和保存

flowchart LR
    subgraph "编译开始:加载"
        A["prepare_session_directory"] --> B["load_dep_graph"]
        B --> C{"加载成功?"}
        C -->|"成功"| D["DepGraph::new(prev_graph)"]
        C -->|"参数变化/过期"| E["DepGraph::new(empty)"]
        D --> F["开始流式写入 dep-graph.part.bin"]
        E --> F
    end
    subgraph "编译结束:保存(par_join)"
        G["rename dep-graph.part.bin → dep-graph.bin"]
        H["exec_cache_promotions"]
        H --> I["serialize query-cache.bin"]
    end
    F -.-> G
    F -.-> H

加载(setup_dep_graph,compiler/rustc_incremental/src/persist/load.rs:180):准备会话目录,加载上次的依赖图和 Work Product,垃圾回收旧目录。加载时检查命令行参数哈希(dep_tracking_hash,只计入标为 TRACKED 的选项)——这类参数变了则整个缓存失效。

保存(save_dep_graph,compiler/rustc_incremental/src/persist/save.rs:26)用 par_join 安排两件事:(1) 将 staging 依赖图重命名为正式文件;(2) 通过 exec_cache_promotions 把本轮标绿、但还没从上次缓存里读出来的查询结果载入内存,然后序列化查询缓存。par_join 只有在 -Z threads 大于 1 时才真正并行,默认单线程下两件事按顺序执行(compiler/rustc_data_structures/src/sync/parallel.rs:106-124)。

17.6.4 文件格式

缓存文件以 RSIC(Rust Incremental Compilation)魔术字节开头,后跟格式版本号和编译器版本字符串:

// compiler/rustc_incremental/src/persist/file_format.rs:26
const FILE_MAGIC: &[u8] = b"RSIC";
const HEADER_FORMAT_VERSION: u16 = 0;

pub(crate) fn write_file_header(stream: &mut FileEncoder, sess: &Session) {
    stream.emit_raw_bytes(FILE_MAGIC);
    stream.emit_raw_bytes(&u16::to_le_bytes(HEADER_FORMAT_VERSION));

    let rustc_version = rustc_version(sess);
    let rustc_version_len =
        u8::try_from(rustc_version.len()).expect("version string should not exceed 255 bytes");
    stream.emit_raw_bytes(&[rustc_version_len]);
    stream.emit_raw_bytes(rustc_version.as_bytes());
}

写入的是完整版本字符串,本机 1.96.0 生成的 dep-graph.bin 开头就是 RSIC、两字节 0、长度字节,接着是 1.96.0 (ac68faa20 2026-05-25),其中带 commit 短哈希——只要编译器版本变了,缓存就完全失效。这是非常保守的策略,但避免了格式不兼容导致的 bug。

17.7 Codegen Unit 与 Work Product

17.7.1 什么是 Codegen Unit

编译器在代码生成阶段会将一个 crate 的所有函数分成若干组,每组称为一个 Codegen Unit(CGU)。每个 CGU 独立生成目标文件(.o),最后链接在一起。

CGU 的划分策略由编译器自动决定,通常基于:

  • 模块结构
  • 泛型实例化的位置
  • 代码大小均衡

增量编译的粒度在代码生成阶段就是 CGU——判定方法是对该 CGU 的代码生成依赖节点调用 try_mark_green,能标绿就说明影响这个 LLVM 模块的输入(其中各函数的 optimized_mir 等)都没变,整个 CGU 的目标文件可以直接复用(determine_cgu_reuse,compiler/rustc_codegen_ssa/src/base.rs:1095-1135)。

17.7.2 Work Product 机制

Work Product 是 CGU 产出在缓存中的表示,包含 cgu_name 和 saved_files(扩展名到缓存文件名的映射)。CGU 编译后,产出通过硬链接或拷贝存入增量编译缓存目录(copy_cgu_workproduct_to_incr_comp_cache_dir)。下次编译时,如果 CGU 对应的依赖图节点是绿色的,直接从缓存取出 .o 文件,完全跳过 LLVM 代码生成和优化——这通常是最昂贵的步骤。

17.7.3 CGU 拆分与增量编译的关系

graph LR
    subgraph "Crate 的函数"
        F1["fn foo()"]
        F2["fn bar()"]
        F3["fn baz()"]
        F4["fn qux()"]
    end
    subgraph "Codegen Unit 划分"
        CGU0["CGU-0: foo, bar"]
        CGU1["CGU-1: baz, qux"]
    end
    subgraph "增量编译判定"
        D0["optimized_mir(foo): 绿<br>optimized_mir(bar): 绿"]
        D1["optimized_mir(baz): 红<br>optimized_mir(qux): 绿"]
    end
    subgraph "结果"
        R0["CGU-0.o: 从缓存加载"]
        R1["CGU-1.o: 重新生成"]
    end
    F1 --> CGU0
    F2 --> CGU0
    F3 --> CGU1
    F4 --> CGU1
    CGU0 --> D0
    CGU1 --> D1
    D0 -->|"全绿"| R0
    D1 -->|"有红"| R1
    style R0 fill:#10b981,color:#333,stroke:none
    style R1 fill:#dc2626,color:#fff,stroke:none

CGU 的划分对增量编译的效果有直接影响。如果一个 CGU 包含了太多函数,即使只有一个函数变了,整个 CGU 都需要重新生成。因此,编译器在增量模式下默认把 CGU 数上限从 16 提到 256,生成更多、更小的 CGU,源码注释说这是为了减少小改动造成的"附带损伤"(compiler/rustc_session/src/session.rs:804-816)。

17.8 副作用的处理

查询理想上是纯函数,但实际上会产生副作用(如编译警告)。如果 typeck_root(foo) 上次发出了"未使用变量"警告,这次被标绿跳过执行,警告仍需显示。编译器通过 QuerySideEffect 枚举(包含 Diagnostic 和 CheckFeature 两种,compiler/rustc_middle/src/dep_graph/graph.rs:42-54)为每个副作用在依赖图里建一个节点、作为该查询的依赖,副作用内容本身随查询缓存存盘;查询被标绿时,这些副作用会被"重放"。

17.9 -Zincremental 标志与相关选项

Cargo 中增量编译默认在 dev profile 开启、release 关闭。环境变量 CARGO_INCREMENTAL=1/0 可强制控制。直接使用 rustc 时通过 -C incremental=<dir> 指定缓存目录。

rustc 提供了一系列 -Z 标志用于调试:

标志 作用
-Zincremental-info 打印增量复用(或未复用)的概要信息
-Zincremental-verify-ich 对每个绿色节点都验证指纹一致性
-Zquery-dep-graph 启用依赖图相关的测试注解(如 #[rustc_clean])
-Zassert-incr-state=loaded 断言增量编译状态被成功加载
-Zassert-incr-state=not-loaded 断言增量编译状态未被加载(首次编译)
-Zdump-dep-graph 把依赖图导出到 $RUST_DEP_GRAPH,必须同时加 -Zquery-dep-graph,否则直接报错(compiler/rustc_session/src/config.rs:2676-2678)。注意选项帮助文本写的默认值 /tmp/dep_graph.gv 和实现对不上:compiler/rustc_incremental/src/assert_dep_graph.rs:207 是 unwrap_or_else(|_| "dep_graph".to_string()),随后在 :224 与 :233 拼出 <前缀>.txt 和 <前缀>.dot 两个文件——落在当前工作目录、后缀是 .dot 而非 .gv

编译器开发者还可以使用 #[rustc_clean(cfg = "rev2", except = "optimized_mir")] 注解编写增量编译正确性测试,确保修改代码后恰好正确的查询集合被标红。

17.10 局限性与边界情况

增量编译在以下场景效果有限:修改被广泛使用的类型/trait 定义、修改宏定义、命令行参数变化、编译器版本升级、外部 crate 更新。首次编译要额外付出指纹计算和依赖图序列化的成本,缓存目录本身也会占用可观的磁盘空间(依赖图 + 查询缓存 + 每个 CGU 的 .o)。no_hash 查询一旦重算就标红,可能导致级联失效。NFS/FAT 等文件系统上硬链接和文件锁可能不可靠。

17.11 收益规律与演进

17.11.1 收益的分布规律

增量编译的收益高度依赖改动的"传播半径"。按红绿算法的机制可以直接推出下面的排序(具体倍数因项目而异,官方持续跟踪的数据见 https://perf.rust-lang.org/):

修改类型 复用程度 说明
什么都不改(touch) 最高 几乎只做指纹检查与依赖图遍历
修改函数体(不改签名) 高 fn_sig/type_of 保持绿色,只有该函数的 MIR 与所属 CGU 变红
添加新函数 高 新增节点需要计算,已有节点大多保持绿色
修改 trait 实现 中 可能触发较广泛的重检
修改类型定义 低 所有使用该类型的查询都要重算
命令行参数变化 / 换编译器版本 无 缓存整体失效,退回全量编译

17.11.2 发展历程

RELEASES.md 里可核对的几个节点:1.24.0(2018-02-15)Cargo 开始对 dev 构建默认开启增量编译;1.44.0(2020-06-04)增量模式下 CGU 默认数改为 256;1.52.1(2021-05-10)与 1.59.0(2022-02-24)都曾因已知 bug 临时把增量编译"软禁用",只在设置 RUSTC_FORCE_INCREMENTAL=1 时启用,修复后恢复默认开启。

内存控制手段:流式序列化(依赖边写盘后即丢弃,不在内存里保留整张当前图——只有 -Zquery-dep-graph 才额外维护一份 RetainedDepGraph,见 compiler/rustc_middle/src/dep_graph/retained.rs)、紧凑编码(变长整数、最小字节宽度边列表)、延迟加载(mmap 直接读取磁盘,按需解码)。

17.12 与其他编译器查询系统的关系

17.12.1 Salsa 框架

Salsa 框架(由 Rust 编译器团队成员 Niko Matsakis 创建)与 rustc 的查询系统是同一类增量计算框架,方向上是 rustc 在先:rustc-dev-guide 的 src/queries/salsa.md 直接写着 "although Salsa is inspired by (among other things) rustc's query system, it is not used directly in rustc"——Salsa 把这套模型抽象成了通用库,但 rustc 自身并不使用它。

Salsa 思想相同但更抽象:查询用 derive 宏定义、依赖用运行时数据库追踪、不内置磁盘缓存。rust-analyzer 基于 Salsa 构建,需要在每次按键后增量更新分析结果。

其他类似系统:TypeScript 编译器(文件级签名)、Bazel/Buck(动作级缓存)、Swift 编译器(声明级依赖追踪)、Haskell GHC(模块级指纹)。rustc 的特点是粒度细(以单个定义的查询为单位),而且不需要手动声明依赖,完全在运行时自动追踪。

17.12.2 实测:本章三处代码加 fingerprint.rs 共 9022 行

把本章贯穿讨论的三处代码实测(compiler/rustc_middle/src/dep_graph/ 3400 + compiler/rustc_incremental 2828 + compiler/rustc_query_impl 2552 + compiler/rustc_data_structures/src/fingerprint.rs 242)。口径是 find … -name '*.rs' | xargs cat | wc -l 的物理行数(含文档注释、空行和测试);查询声明 compiler/rustc_middle/src/queries.rs(2779 行)、通用管道 compiler/rustc_middle/src/query/(3075 行)和 StableHasher 所在的 stable_hasher.rs 都没有计入,所以 9022 行只是本章选定范围的规模,不是增量编译相关代码的总量——

路径 行 角色
compiler/rustc_middle/src/dep_graph/graph.rs 1479 本章 §17.5 主角——try_mark_green 红绿算法 + DepNode 状态转移
compiler/rustc_middle/src/dep_graph/serialized.rs 973 §17.6 磁盘缓存的 binary 序列化
compiler/rustc_incremental/src/persist/fs.rs 909 §17.6.1 缓存目录结构 + §17.6.2 会话目录生命周期
compiler/rustc_query_impl/src/execution.rs 655 §17.2 两条执行路径——try_execute_query / execute_job_incr
compiler/rustc_query_impl/src/job.rs 476 查询作业与循环检测
compiler/rustc_incremental/src/persist/clean.rs 438 测试用:检查 #[rustc_clean] 注解(§17.9)
compiler/rustc_incremental/src/assert_dep_graph.rs 417 测试用:验证依赖图正确性
compiler/rustc_middle/src/dep_graph/dep_node.rs 397 DepNode / DepKind / WorkProductId 类型定义
compiler/rustc_query_impl/src/handle_cycle_error.rs 348 查询循环的错误处理
compiler/rustc_query_impl/src/query_impl.rs 276 §17.2 查询宏的实现
compiler/rustc_data_structures/src/fingerprint.rs 242 §17.4 指纹系统主角——一个独立工具类型
compiler/rustc_middle/src/dep_graph/dep_node_key.rs 230 DepNode 的 key 指纹类型
compiler/rustc_query_impl/src/plumbing.rs 197 查询底层管道
其余(mod / edges / debug / retained / errors / load / save / file_format / work_product / profiling 等) 1985 —
合计本章主题 9022 —

两条值得记住的事实——

  1. graph.rs 1479 行,约占上表 9022 行的 16%——DepGraph/DepGraphData、三态 DepNodeColor、两个预留 DepNodeIndex 常量、read_index 依赖追踪、alloc_and_color_node 与 try_mark_green/try_mark_previous_green、副作用节点、DepNodeColorMap 全在这一个文件里;而 §17.4.3 的抽样验证在 compiler/rustc_query_impl/src/execution.rs,并不在 graph.rs
  2. fingerprint.rs 仅 242 行——它只是 Fingerprint 这个 128 位哈希类型本身加几个组合运算;真正算哈希的 StableHasher 在 compiler/rustc_data_structures/src/stable_hasher.rs,底层来自外部 crate rustc-stable-hash,各类型的 HashStable 实现则散在整个编译器里。类型本身极小、却被 graph.rs、serialized.rs 和几乎每个查询反复使用——和 ch10 §10.11.3 测得的 Future trait 仅 135 行 + Pin/Waker 配套数千行同款 "核心抽象薄、生态承担重" 模式

和其他章的实测放在一起看:本节的 9022 行里,dep_graph/、rustc_incremental、rustc_query_impl 三处是按整个目录或整个 crate 统计的,可以和同样按整 crate 统计的数字比较——ch04 §4.12 的 rustc_borrowck 34458 行、ch16 §16.15 的三个 codegen crate 共 78306 行、ch15 §15.13.5 的 MIR 4 个目录约 80,000 行;这三处合计 8780 行,约为借用检查 crate 的四分之一。其余章节的数字(ch05 内存布局 7924、ch06 单态化 4840、ch09 协程 2011、ch11 闭包 6964 等)是各章按主题挑选的若干文件,ch10 的 4260 行统计的是标准库 library/core 而不是编译器,口径各不相同,不能相加,也算不出有意义的占比。

17.13 设计哲学与总结

17.13.1 核心设计原则

回顾整个增量编译系统,它遵循了几个核心设计原则:

  1. 正确性优先于性能:宁可多重编译,也不返回过时的结果。no_hash 查询保守标红、缓存版本校验、指纹抽样验证,都体现了这一点
  2. 自动化依赖追踪:程序员(包括编译器开发者)不需要手动声明依赖关系。查询系统在运行时自动记录"谁读了谁"
  3. 最终一致性:增量编译的结果必须和全量编译完全一致。如果不一致就是 bug,而不是"可接受的近似"
  4. 渐进式降级:如果缓存损坏或过期,系统能优雅地退回全量编译,而不是崩溃

17.13.2 从编译器到通用系统的启示

增量编译的设计思想可以迁移到前端构建工具(Webpack/Vite 热更新)、数据库物化视图、响应式框架(React/Solid.js)、CI/CD 缓存等领域,核心模式一致:输入变化检测 -> 依赖图遍历 -> 缓存查找 -> 按需重算。

未来方向:更细粒度缓存(函数级代码生成)、跨 crate 查询结果共享、分布式缓存(类似 sccache)、基于历史修改模式的智能 CGU 划分。

增量编译将数据库查询引擎、构建系统和编译器技术巧妙地融合在一起。理解它的设计,不仅有助于理解 Rust 编译器本身,更能为设计任何需要增量计算的系统提供宝贵的参考。

物理事实:本章选定的三处增量编译代码加 fingerprint.rs 共 9022 行(wc -l 物理行;dep_graph/graph.rs 1479 红绿算法 + serialized 973 + persist/fs 909 + query_impl/execution 655 + dep_node 397 + fingerprint.rs 仅 242 行的 Fingerprint 类型);fingerprint 极小但被 graph.rs、serialized.rs 反复使用——和 ch10 Future trait 135 行同款'核心抽象薄、生态承担重'模式;这个数字只能与同样按整 crate 统计的 borrowck 34458、codegen 78306、MIR 约 80,000 行比较,不与其他章自选文件的行数相加。