The Query System — Demand-Driven Compilation and Incremental Reuse
Prerequisites
- ›Article 1: Architecture Overview and How to Navigate 75 Compiler Crates
- ›Understanding of memoization and caching strategies
- ›Familiarity with Rust macros (declarative and proc-macro)
The Query System — Demand-Driven Compilation and Incremental Reuse
If Part 1 showed you the shape of the Rust compiler, this article reveals its operating principle. The query system is the single most important architectural decision in rustc — it replaced a traditional sequential pipeline with a network of demand-driven, memoized computations. Every non-trivial piece of information the compiler computes (the type of an expression, the MIR of a function, whether a trait implementation is valid) is a query.
Understanding the query system is not optional. It's the connective tissue that binds the 75 compiler crates together, and it's the foundation for incremental compilation — the feature that makes cargo check fast on subsequent runs.
Why Demand-Driven? From Sequential Pipeline to Queries
A traditional compiler runs in phases: parse → type-check → optimize → emit code. Each phase consumes the entire output of the previous one. This has two problems. First, it forces the compiler to do all work up front, even for portions of the program that might not need it. Second, changing a single function forces recomputation of everything downstream.
The query architecture inverts this. Instead of pushing data through a pipeline, consumers pull results as needed. When you ask for optimized_mir(def_id), the query system checks whether the result is already cached. If not, it invokes the provider function, which may itself trigger other queries (type checking, borrow checking, etc.), forming a directed acyclic graph of computations.
flowchart TD
A["optimized_mir(fn_a)"] --> B["mir_drops_elaborated(fn_a)"]
B --> C["mir_borrowck(fn_a)"]
B --> D["mir_promoted(fn_a)"]
D --> E["mir_built(fn_a)"]
C --> D
C --> F["typeck(fn_a)"]
F --> G["type_of(struct_s)"]
F --> H["predicates_of(trait_t)"]
style A fill:#f96,stroke:#333
style C fill:#9cf,stroke:#333
style F fill:#9f9,stroke:#333
This gives three benefits: (1) only the queries needed are evaluated, (2) results are memoized across invocations within a session, and (3) a dependency graph records which queries depend on which inputs, enabling incremental recompilation by invalidating only what changed.
Defining Queries: The rustc_queries! Macro
All queries are defined in a single file using the rustc_queries! macro:
compiler/rustc_middle/src/queries.rs#L138
The file's module-level documentation (lines 1–44) is unusually thorough and worth reading in full. It explains the entire expansion pipeline and all available modifiers.
Each query declaration specifies a key type and a return type, plus optional modifiers:
rustc_queries! {
query type_of(key: DefId) -> ty::EarlyBinder<'tcx, Ty<'tcx>> {
desc { "computing type of `{}`", tcx.def_path_str(key) }
cache_on_disk_if { key.is_local() }
}
query optimized_mir(key: DefId) -> &'tcx Body<'tcx> {
desc { "optimizing MIR for `{}`", tcx.def_path_str(key) }
cache_on_disk_if { key.is_local() }
}
}
The key modifiers to understand:
| Modifier | Meaning |
|---|---|
eval_always |
Never reuse cached results — always re-evaluate |
cache_on_disk_if { .. } |
Persist results to disk for incremental compilation |
arena_cache |
Allocate results in the arena instead of cloning |
separate_provide_extern |
External (cross-crate) providers are registered separately |
desc { .. } |
Human-readable description shown in cycle errors and profiling |
The rustc_macros::rustc_queries proc macro expands each declaration into: a method on TyCtxt for invoking the query, a field in the Providers struct for registering the implementation, and all the caching and dependency-tracking plumbing.
Tip: When reading
queries.rs, focus on the key type to understand the granularity. Queries keyed byDefIdwork per-item. Queries keyed by()(the unit type) are crate-global. Queries keyed byLocalDefIdonly apply to items defined in the current crate.
TyCtxt and GlobalCtxt: The Query Execution Engine
As we saw in Part 1, TyCtxt<'tcx> is the central hub of the compiler. Structurally, it's a single-pointer wrapper around GlobalCtxt:
compiler/rustc_middle/src/ty/context.rs#L745-L747
pub struct TyCtxt<'tcx> {
gcx: &'tcx GlobalCtxt<'tcx>,
}
GlobalCtxt is the "god struct" — it holds everything:
compiler/rustc_middle/src/ty/context.rs#L768-L839
classDiagram
class TyCtxt~tcx~ {
gcx: &GlobalCtxt
}
class GlobalCtxt~tcx~ {
arena: &WorkerLocal~Arena~
hir_arena: &WorkerLocal~hir::Arena~
interners: CtxtInterners
sess: &Session
dep_graph: DepGraph
query_system: QuerySystem
types: CommonTypes
lifetimes: CommonLifetimes
consts: CommonConsts
}
TyCtxt --> GlobalCtxt : wraps
GlobalCtxt --> Session : holds
GlobalCtxt --> DepGraph : tracks dependencies
GlobalCtxt --> QuerySystem : memoizes results
When you call tcx.type_of(def_id), the generated method:
- Looks up the result in the query cache (inside
query_system) - If cached, records a dependency edge and returns the cached result
- If not cached, looks up the provider function from
Providers - Calls the provider, records the result, and returns it
The 'tcx lifetime ties everything allocated during compilation to the GlobalCtxt. When the global context is dropped, all arenas are freed in one shot — no individual deallocation needed.
Provider Registration: Wiring 20 Crates to the Query System
The Providers struct is a table of function pointers — one for each query. During compiler startup, ~20 crates register their implementations:
compiler/rustc_interface/src/passes.rs#L880-L919
pub static DEFAULT_QUERY_PROVIDERS: LazyLock<Providers> = LazyLock::new(|| {
let providers = &mut Providers::default();
providers.queries.analysis = analysis;
providers.queries.hir_crate = rustc_ast_lowering::lower_to_hir;
// ...
rustc_hir_analysis::provide(&mut providers.queries);
rustc_borrowck::provide(&mut providers.queries);
rustc_mir_transform::provide(providers);
rustc_monomorphize::provide(providers);
rustc_codegen_ssa::provide(providers);
// ...20+ more crates
*providers
});
flowchart TD
LP["LazyLock: DEFAULT_QUERY_PROVIDERS"] --> P["Providers struct"]
P --> |"analysis"| PASSES["rustc_interface::passes"]
P --> |"hir_crate"| LOWER["rustc_ast_lowering"]
P --> |"typeck"| HTA["rustc_hir_analysis"]
P --> |"mir_borrowck"| BORROW["rustc_borrowck"]
P --> |"optimized_mir"| MIR["rustc_mir_transform"]
P --> |"collect_and_partition_mono_items"| MONO["rustc_monomorphize"]
P --> |"codegen_fn_attrs"| CODEGEN["rustc_codegen_ssa"]
style LP fill:#f96
After all crate providers are registered, the codegen backend gets a chance to provide its own queries (line 966), and then the override_queries callback (from Config) is applied. This is how clippy-driver and rust-analyzer inject their custom behavior — they swap out specific query implementations after the defaults are set up.
This is a brilliant architectural pattern: each crate only knows about rustc_middle types (not about the other crates), and the wiring happens at the integration layer in rustc_interface. It keeps the dependency graph manageable.
The Steal Pattern: Linear Consumption of Query Results
There's a tension in the query system: queries should be pure functions that return immutable values (so caching works correctly), but some intermediate results (particularly MIR bodies) need to be iteratively transformed in place. Cloning a full MIR body for each transformation would be prohibitively expensive.
The solution is Steal<T>:
compiler/rustc_data_structures/src/steal.rs#L4-L27
sequenceDiagram
participant Q1 as mir_built query
participant Steal as Steal~Body~
participant Q2 as mir_promoted query
Q1->>Steal: new(body) → &'tcx Steal<Body>
Note over Steal: value = Some(body)
Q2->>Steal: borrow() → read access
Q2->>Steal: steal() → takes ownership
Note over Steal: value = None
Note over Steal: Any future borrow() panics!
Steal<T> wraps a value in an RwLock<Option<T>>. You can borrow() it as many times as you want (returning a read guard), but once you steal() it, the value is gone. Any subsequent borrow() panics with a clear message: "attempted to read from stolen value".
This enforces a linear consumption discipline: mir_promoted steals the result of mir_built, transforms it, and produces a new Steal-wrapped result. Nobody else can accidentally read the pre-promotion MIR afterward.
The risky_hack_borrow_mut() method (line 48) is an escape hatch for custom drivers — it provides mutable access while the value hasn't been stolen. The name is deliberately alarming because using it breaks incremental compilation assumptions.
Dependency Tracking and Incremental Compilation
Every query invocation is tracked. When query A reads from query B, the system records an edge in the dependency graph. On the next compilation, if the inputs to B haven't changed, B's cached result is still valid — and so is A's, transitively.
The dependency graph is managed through the dep_graph field on GlobalCtxt. The cache_on_disk_if modifier on a query determines whether its result is serialized to disk between compilations. Not all queries are persisted — only those where serialization cost is less than recomputation cost.
flowchart TD
subgraph "Compilation N"
A1["type_of(Foo)"] --> B1["predicates_of(Bar)"]
A1 --> C1["generics_of(Foo)"]
B1 --> D1["source file hash"]
end
subgraph "Compilation N+1"
D2["source file hash"] -->|"changed?"| CHECK{Hash differs?}
CHECK -->|"No"| REUSE["Reuse B, A, C from cache"]
CHECK -->|"Yes"| RECOMPUTE["Recompute B → re-verify A, C"]
end
The general flow for incremental compilation:
- Load the previous session's dependency graph and query results from disk
- For each "green" node (unchanged inputs), try to reuse the cached result
- For each "red" node (changed inputs), recompute and check if the output changed
- If a recomputed node produces the same output, downstream nodes remain green
This "try to mark green" approach means that even when a source file changes, if the change doesn't affect the public API (say, you only changed a function body), downstream queries that only depend on signatures can still be reused.
The analysis Query: Root of the Query Cascade
The analysis query is the root that kicks off all semantic checking. It's invoked as tcx.ensure_ok().analysis(()) from the driver (as we saw in Part 1), and it fans out to trigger dozens of sub-queries:
compiler/rustc_interface/src/passes.rs#L1180-L1257
The analysis function calls run_required_analyses(tcx) first, which orchestrates:
compiler/rustc_interface/src/passes.rs#L1056-L1176
flowchart TD
ANALYSIS["analysis(())"] --> RRA["run_required_analyses()"]
RRA --> CC["check_crate() — type checking"]
RRA --> BORROWCK["par_hir_body_owners → mir_borrowck"]
RRA --> UNSAFE["check_unsafety"]
RRA --> LIVENESS["check_liveness"]
RRA --> DROPS["mir_drops_elaborated_and_const_checked"]
ANALYSIS --> PRIVACY["check_mod_privacy"]
ANALYSIS --> DEATH["check_mod_deathness"]
ANALYSIS --> LINT["rustc_lint::check_crate"]
ANALYSIS --> EXPECT["check_expectations"]
CC --> COHERENCE["coherence checking"]
CC --> TYPECK["typeck(def_id) per item"]
CC --> WF["check_type_wf"]
Notice the use of par_hir_body_owners (line 1138) — this iterates over all items in parallel when parallel compilation is enabled. Each item independently triggers its own cascade of queries (mir_borrowck, check_unsafety, etc.), and the query system's memoization ensures no work is duplicated.
The ensure_ok() method is worth noting — it's a variant of query invocation that doesn't return the result, only ensures the query runs successfully. This is used when we want to trigger side effects (like error reporting) without needing the computed value.
Tip: When debugging a compilation issue, knowing the query DAG helps immensely. Use
RUSTC_LOG=rustc_query_impl=traceto see every query invocation. For a specific query,-Z dump-dep-graphwill write the dependency graph to disk for inspection.
What's Next
Now that you understand the demand-driven architecture, we can trace what these queries actually compute. In Part 3, we'll follow a Rust program through the compiler's four intermediate representations: from the token-oriented AST produced by the parser, through the desugared HIR, the typed THIR, and finally into MIR — the control-flow graph IR where borrow checking and optimizations happen.