Appearance
第17章 增量编译:让重编译只做必要的事
"最快的编译是不编译。增量编译的全部智慧,在于精确地找出哪些东西不需要重新编译——不多一个,不少一个。"
本章要点
- 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)。当查询被请求时才计算,结果被缓存。
实际的 rustc 中有数百种查询(type_of、fn_sig、typeck、mir_built、optimized_mir、generics_of、predicates_of、codegen_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——它们不计算结果的指纹。这通常是因为:
- 结果类型太复杂或太大,哈希成本太高
- 结果包含不适合哈希的信息(如诊断消息)
对于 no_hash 查询,编译器无法判断结果是否真的变了,只能保守地将其标记为红色。这意味着所有依赖 no_hash 查询的节点都会被强制重新计算。这是一个性能和正确性之间的权衡。
17.4.4 指纹验证
编译器内置了验证机制防止哈希不一致:按约 1/32 的概率抽样验证绿色节点的指纹(is_multiple_of(32)),重新哈希从磁盘加载的结果并与之前的指纹比较。使用 -Zincremental-verify-ich 可以开启全量验证。如果磁盘加载失败需要重新计算,则总是验证结果。
17.5 红绿算法:增量编译的核心
17.5.1 算法概述
红绿算法(Red/Green Algorithm)是增量编译的灵魂。它回答一个核心问题:给定一个查询节点,它在上次编译之后是否仍然有效?
算法的基本思路是:
- 如果一个节点的所有依赖都是绿色的(结果没变),那么这个节点也是绿色的——无需重新计算
- 如果任何一个依赖是红色的(结果变了),那么这个节点可能需要重新计算
- 未知颜色的节点需要递归地检查
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()); }第一次编译会建立如下依赖图(极简化):
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 }。第二次编译时:
- HIR 解析发现
helper的函数体变了 type_of(helper)被重新计算,结果是i32——和上次一样,绿色fn_sig(helper)被重新计算,结果是fn() -> i32——和上次一样,绿色typeck(helper)被重新计算,类型检查结果可能相同——绿色mir_built(helper)被重新计算,MIR 变了(常量从 42 变成 43)——红色optimized_mir(helper)的依赖mir_built(helper)是红色,需要重新优化——红色typeck(main)依赖fn_sig(helper)和type_of(main),都是绿色——绿色,无需重新检查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";目录的完整结构如下:
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 加载和保存
加载(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_name 和 saved_files(扩展名到缓存文件名的映射)。CGU 编译后,产出通过硬链接或拷贝存入增量编译缓存目录(copy_cgu_workproduct_to_incr_comp_cache_dir)。下次编译时,如果 CGU 对应的依赖图节点是绿色的,直接从缓存取出 .o 文件,完全跳过 LLVM 代码生成和优化——这通常是最昂贵的步骤。
17.7.3 CGU 拆分与增量编译的关系
CGU 的划分对增量编译的效果有直接影响。如果一个 CGU 包含了太多函数,即使只有一个函数变了,整个 CGU 都需要重新生成。因此,编译器在增量模式下通常会生成更多、更小的 CGU(相当于牺牲了链接时间来换取增量编译的细粒度)。
17.8 副作用的处理
查询理想上是纯函数,但实际上会产生副作用(如编译警告)。如果 typeck(foo) 上次发出了"未使用变量"警告,这次被标绿跳过执行,警告仍需显示。编译器通过 QuerySideEffect 枚举(包含 Diagnostic 和 CheckFeature 两种)将副作用序列化到依赖图中,绿色节点的副作用会被"重放"。
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 核心设计原则
回顾整个增量编译系统,它遵循了几个核心设计原则:
- 正确性优先于性能:宁可多重编译,也不返回过时的结果。
no_hash查询保守标红、缓存版本校验、指纹抽样验证,都体现了这一点 - 自动化依赖追踪:程序员(包括编译器开发者)不需要手动声明依赖关系。查询系统在运行时自动记录"谁读了谁"
- 最终一致性:增量编译的结果必须和全量编译完全一致。如果不一致就是 bug,而不是"可接受的近似"
- 渐进式降级:如果缓存损坏或过期,系统能优雅地退回全量编译,而不是崩溃
17.13.2 从编译器到通用系统的启示
增量编译的设计思想可以迁移到前端构建工具(Webpack/Vite 热更新)、数据库物化视图、响应式框架(React/Solid.js)、CI/CD 缓存等领域,核心模式一致:输入变化检测 -> 依赖图遍历 -> 缓存查找 -> 按需重算。
未来方向:更细粒度缓存(函数级代码生成)、跨 crate 查询结果共享、分布式缓存(类似 sccache)、基于历史修改模式的智能 CGU 划分。
增量编译是 Rust 编译器中最复杂的子系统之一,它将数据库查询引擎、构建系统和编译器技术巧妙地融合在一起。理解它的设计,不仅有助于理解 Rust 编译器本身,更能为设计任何需要增量计算的系统提供宝贵的参考。
在最后一章中,我们将从所有技术细节中抽身,回顾 Rust 编译器设计背后的哲学——以及这些设计思想如何迁移到你自己的系统设计中。