Rust编译器原理-第17章 增量编译:让重编译只做必要的事

《Rust 编译器原理》完整目录

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

"最快的编译是不编译。增量编译的全部智慧,在于精确地找出哪些东西不需要重新编译------不多一个,不少一个。"

:::tip 本章要点

  • Rust 编译器不是流水线,而是一个按需查询数据库------每个编译操作都被建模为一个 Query
  • 依赖图(Dependency Graph)记录查询之间的依赖关系,是增量编译的核心数据结构
  • 指纹(Fingerprint)对查询输入和输出做 128 位哈希,用于检测"这个结果变了吗"
  • 红绿算法(Red/Green Algorithm)递归检查依赖链,精确标记哪些查询结果仍然有效(绿色)、哪些已经过时(红色)
  • 查询结果通过磁盘缓存持久化,下次编译时绿色节点可直接加载,跳过重新计算
  • Codegen Unit (代码生成单元)和 Work Product 机制让编译器可以跳过未变化的目标文件生成
  • 整个系统的设计灵感来自数据库查询引擎和构建系统,与 Salsa 框架有深厚的血缘关系 :::

17.1 为什么需要增量编译

Rust 以编译速度慢闻名。一个中等规模的 Rust 项目全量编译可能需要 3-5 分钟,大型项目(如 Servo、TiKV)在 CI 环境中可能需要 20-40 分钟。原因包括泛型单态化、深度类型检查、LLVM 优化和宏展开。

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

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

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

传统编译器按阶段组织(词法分析、语法分析、语义分析...),是一条线性流水线。但 rustc 采用了完全不同的架构:按需查询系统(demand-driven query system)。每个计算步骤都被建模为一个"查询"(Query),有明确的输入(key)和输出(value)。当查询被请求时才计算,结果被缓存。

graph TD subgraph "查询系统架构" A["type_of(foo)"] --> B["typeck(foo)"] B --> C["mir_built(foo)"] C --> D["optimized_mir(foo)"] D --> E["codegen_unit(cgu_0)"] A2["type_of(bar)"] --> B2["typeck(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:#3b82f6,color:#fff,stroke:none style A2 fill:#3b82f6,color:#fff,stroke:none style D fill:#8b5cf6,color:#fff,stroke:none style D2 fill:#8b5cf6,color:#fff,stroke:none style E fill:#10b981,color:#fff,stroke:none end

实际的 rustc 中有数百种查询(type_offn_sigtypeckmir_builtoptimized_mirgenerics_ofpredicates_ofcodegen_unit 等)。查询系统的核心实现位于 compiler/rustc_query_impl/ 目录。QuerySystem 是入口:

rust 复制代码
// compiler/rustc_query_impl/src/lib.rs
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),
        cycle_handler_nesting: Lock::new(0),
    }
}

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 中,查询执行有两条路径:

rust 复制代码
// compiler/rustc_query_impl/src/execution.rs(简化)
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 检查所有依赖是否"绿色"(没变化),如果成功就从磁盘加载缓存结果;如果失败(某个依赖是"红色"),则重新执行查询并记录新依赖。

rust 复制代码
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.invoke_provider_fn)(tcx, key), query.hash_value_fn)
    })
}

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

17.3.1 依赖图的数据结构

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

rust 复制代码
// compiler/rustc_middle/src/dep_graph/graph.rs
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 中:

rust 复制代码
// compiler/rustc_middle/src/dep_graph/serialized.rs
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 基于 DefPathHash(定义路径的稳定哈希),不依赖会话特定的 DefId,因此可以直接跨会话序列化/反序列化,类似 git commit hash 跨仓库有效。

17.3.3 依赖追踪的原理

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

rust 复制代码
// compiler/rustc_middle/src/dep_graph/graph.rs(简化)
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.2 节点着色:指纹比较的时刻

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

rust 复制代码
// compiler/rustc_middle/src/dep_graph/graph.rs
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
        };

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

节点有三种颜色:

rust 复制代码
pub(super) enum DepNodeColor {
    Green(DepNodeIndex),  // 结果没变,可以复用
    Red,                  // 结果变了,需要重算
    Unknown,              // 还没有确定颜色
}

17.4.3 no_hash 查询的特殊处理

某些查询被标记为 no_hash------它们不计算结果的指纹。这通常是因为:

  1. 结果类型太复杂或太大,哈希成本太高
  2. 结果包含不适合哈希的信息(如诊断消息)

对于 no_hash 查询,编译器无法判断结果是否真的变了,只能保守地将其标记为红色。这意味着所有依赖 no_hash 查询的节点都会被强制重新计算。这是一个性能和正确性之间的权衡。

17.4.4 指纹验证

编译器内置了验证机制防止哈希不一致:按约 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:#3b82f6,color:#fff style RetGreen fill:#10b981,color:#fff style MarkGreen fill:#10b981,color:#fff style Fail fill:#ef4444,color:#fff style New fill:#f59e0b,color:#fff style RetNone fill:#ef4444,color:#fff

17.5.2 源码解读:try_mark_green

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

rust 复制代码
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 中:

rust 复制代码
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!("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,这意味着它们每次编译都必须重新执行,不参与红绿判定。典型例子包括:

  • 解析源文件的 HIR(因为文件内容是外部输入)
  • 读取环境变量或命令行参数相关的查询

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

17.5.5 一个完整的例子

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

rust 复制代码
fn helper() -> i32 { 42 }
fn main() { println!("{}", helper()); }

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

scss 复制代码
type_of(helper) -> typeck(helper) -> mir_built(helper) -> optimized_mir(helper)
type_of(main) -> typeck(main) -> mir_built(main) -> optimized_mir(main)
typeck(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(helper) 被重新计算,类型检查结果可能相同------绿色
  5. mir_built(helper) 被重新计算,MIR 变了(常量从 42 变成 43)------红色
  6. optimized_mir(helper) 的依赖 mir_built(helper) 是红色,需要重新优化------红色
  7. typeck(main) 依赖 fn_sig(helper)type_of(main),都是绿色------绿色,无需重新检查
  8. optimized_mir(main) 依赖绿色的 mir_built(main)typeck(main)------绿色

最终结果:只有 helper 的 MIR 和代码生成需要重做,main 的全部编译结果都可以复用。

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

17.6.1 缓存目录结构

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

rust 复制代码
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";

目录的完整结构如下:

python 复制代码
target/debug/incremental/
└── my_crate-<disambiguator>/           # crate 级目录
    ├── s-<timestamp>-<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) 成功后重命名为 s-{timestamp}-{svh};(5) 清理旧目录。已完成的会话目录永远不会被修改,保证了并发安全------多个编译器实例可以同时编译同一个 crate。

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

17.6.4 加载和保存

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 "编译结束:保存(并行)" 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):准备会话目录,加载上次的依赖图和 Work Product,垃圾回收旧目录。加载时检查命令行参数哈希------参数变了则整个缓存失效。

保存save_dep_graph)并行执行:(1) 将 staging 依赖图重命名为正式文件;(2) 通过 exec_cache_promotions 将绿色但未使用的查询结果提升到内存,然后序列化查询缓存。

17.6.5 文件格式

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

rust 复制代码
// compiler/rustc_incremental/src/persist/file_format.rs
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);
    stream.emit_raw_bytes(&[rustc_version_len]);
    stream.emit_raw_bytes(rustc_version.as_bytes());
}

编译器版本精确到 git commit hash------只要编译器版本变了,缓存就完全失效。这是非常保守的策略,但避免了格式不兼容导致的 bug。

17.7 Codegen Unit 与 Work Product

17.7.1 什么是 Codegen Unit

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

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

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

增量编译的粒度在代码生成阶段就是 CGU------如果一个 CGU 中没有任何函数的 optimized_mir 发生变化,整个 CGU 的目标文件可以直接复用。

17.7.2 Work Product 机制

Work Product 是 CGU 产出在缓存中的表示,包含 cgu_namesaved_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): 绿
optimized_mir(bar): 绿"] D1["optimized_mir(baz): 红
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:#fff,stroke:none style R1 fill:#ef4444,color:#fff,stroke:none

CGU 的划分对增量编译的效果有直接影响。如果一个 CGU 包含了太多函数,即使只有一个函数变了,整个 CGU 都需要重新生成。因此,编译器在增量模式下通常会生成更多、更小的 CGU(相当于牺牲了链接时间来换取增量编译的细粒度)。

17.8 副作用的处理

查询理想上是纯函数,但实际上会产生副作用(如编译警告)。如果 typeck(foo) 上次发出了"未使用变量"警告,这次被标绿跳过执行,警告仍需显示。编译器通过 QuerySideEffect 枚举(包含 DiagnosticCheckFeature 两种)将副作用序列化到依赖图中,绿色节点的副作用会被"重放"。

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 将依赖图导出为可视化格式

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

17.10 局限性与边界情况

增量编译在以下场景效果有限:修改被广泛使用的类型/trait 定义、修改宏定义、命令行参数变化、编译器版本升级、外部 crate 更新。首次编译会因指纹计算和依赖图序列化额外开销 5-15%。缓存可能占 200MB-数GB 磁盘空间。no_hash 查询总是标红,可能导致级联失效。NFS/FAT 等文件系统上硬链接和文件锁可能不可靠。

17.11 性能影响:实测数据

17.11.1 典型加速比

根据 Rust 编译器性能追踪网站(perf.rust-lang.org)和 Rust 博客的数据,增量编译在不同场景下的加速效果大致如下:

修改类型 典型加速比 说明
修改函数体(不改签名) 5-20x 最佳场景,只需重编该函数及直接依赖
修改类型定义 2-5x 所有使用该类型的代码需要重检
添加新函数 3-10x 新函数需要编译,但不影响已有代码
修改 trait 实现 2-5x 可能触发较广泛的重检
添加新依赖 1-2x 新 crate 需要全量编译,但本 crate 可增量
什么都不改(touch) 10-50x 验证性编译,几乎只做指纹检查

17.11.2 发展历程

2016 年首次引入(RFC 1298),2018 年默认开启,2021 年 1.52 因稳定性暂时禁用后在 1.52.1 恢复,2023-2025 年持续优化(并行前端、RetainedDepGraph 减少内存占用)。

内存控制手段:流式序列化(不需在内存中完整保留依赖图)、紧凑编码(变长整数、最小字节宽度边列表)、延迟加载(mmap 直接读取磁盘,按需解码)。

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

17.12.1 Salsa 框架

Rust 编译器的查询系统启发了 Salsa 框架(由 Rust 编译器团队成员 Niko Matsakis 创建),而 Salsa 后来又反过来影响了 rustc 查询系统的演进。

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

其他类似系统:TypeScript 编译器(文件级签名)、Bazel/Buck(动作级缓存)、Swift 编译器(声明级依赖追踪)、Haskell GHC(模块级指纹)。Rust 在粒度和自动化程度上最先进------不需要手动声明依赖,完全运行时自动追踪。

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

在最后一章中,我们将从所有技术细节中抽身,回顾 Rust 编译器设计背后的哲学------以及这些设计思想如何迁移到你自己的系统设计中。

相关推荐
杨艺韬2 小时前
Rust编译器原理-第12章 unsafe:安全抽象的逃生舱
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第10章 Pin、Waker 与 Future:异步运行时的三大支柱
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第1章 编译管线全景:从源码到机器码的完整旅程
rust·编译器
杨艺韬2 小时前
Rust编译器原理-第2章 所有权系统:编译期内存管理的核心机制
rust·编译器
杨艺韬2 小时前
Rust编译器原理-前言
rust·编译器
米丘8 小时前
Rust 初了解
rust
古城小栈8 小时前
rustup 命令工具,掌控 Rust 开发环境
开发语言·后端·rust
咸甜适中11 小时前
rust语言待办事项小实例完整代码(axum+sqlx+sqlite+自定义错误)
rust·sqlite·axum·sqlx
Rust研习社11 小时前
深入 Rust 引用计数智能指针:Rc 与 Arc 从入门到实战
开发语言·后端·rust