One Interpreter, Two Machines: How rustc's Const Eval Powers Miri Too
rustc's const evaluator is written against a Machine trait with eight associated types and 57 hooks; Miri and CTFE are just two instantiations of the same interpreter.
TL;DR: rustc’s constant evaluator is not “the const evaluator” with hooks for Miri bolted on. It is a generic interpreter written against a
Machinetrait with eight associated types and 57 methods. CTFE and Miri are two instantiations. That is why Miri gets UBSan-grade checking for free, and why the core interpreter has no idea which one it is serving.
The trait
// compiler/rustc_const_eval/src/interpret/machine.rs:113-163
/// Methods of this trait signifies a point where CTFE evaluation would fail
/// and some use case dependent behaviour can instead be applied.
pub trait Machine<'tcx>: Sized {
/// Additional memory kinds a machine wishes to distinguish from the builtin ones
type MemoryKind: Debug + std::fmt::Display + MayLeak + Eq + 'static;
/// Pointers are "tagged" with provenance information; typically the `AllocId` they belong to.
type Provenance: Provenance + Eq + Hash + 'static;
/// When getting the AllocId of a pointer, some extra data is also obtained from the provenance
/// that is passed to memory access hooks so they can do things with it.
type ProvenanceExtra: Copy + 'static;
/// Machines can define extra (non-instance) things that represent values of function pointers.
/// For example, Miri uses this to return a function pointer from `dlsym`
/// that can later be called to execute the right thing.
type ExtraFnVal: Debug + Copy;
/// Extra data stored in every call frame.
type FrameExtra;
/// Extra data stored in every allocation.
type AllocExtra: Debug + Clone + 'tcx;
/// Type for the bytes of the allocation.
type Bytes: AllocBytes + 'static;
/// Memory's allocation map
type MemoryMap: AllocMap<
AllocId,
(
MemoryKind<Self::MemoryKind>,
Allocation<Self::Provenance, Self::AllocExtra, Self::Bytes>,
),
> + Default
+ Clone;Eight associated types covering: how memory kinds are distinguished, how provenance is represented, what extra data a function pointer can carry, what goes in a call frame, what goes in an allocation, how bytes are stored, and how the allocation map works.
The design decision that matters: the trait is pure, with associated
types rather than &mut dyn objects. Every hook is a static call after
monomorphization, so the interpreter’s inner loop — which runs millions of
steps during a const evaluation — has no virtual dispatch.
The shared compile-time instantiation
// compiler/rustc_const_eval/src/interpret/machine.rs:692-712
/// A lot of the flexibility above is just needed for `Miri`, but all "compile-time" machines
/// (CTFE and ConstProp) use the same instance. Here, we share that code.
pub macro compile_time_machine(<$tcx: lifetime>) {
type Provenance = CtfeProvenance;
type ProvenanceExtra = bool; // the "immutable" flag
type ExtraFnVal = !;
type MemoryKind = $crate::const_eval::MemoryKind;
type MemoryMap =
rustc_data_structures::fx::FxIndexMap<AllocId, (MemoryKind<Self::MemoryKind>, Allocation)>;
const GLOBAL_KIND: Option<Self::MemoryKind> = None; // no copying of globals from `tcx` to machine memory
type AllocExtra = ();
type FrameExtra = ();
type Bytes = Box<[u8]>;Three details worth stopping on:
type ExtraFnVal = !— the empty type. Miri can return a function pointer fromdlsym; CTFE cannot. By making the type uninhabited, the CTFE instantiation ofcall_extra_fnbecomesmatch fn_val {}— the capability is statically impossible, at zero runtime cost.GLOBAL_KIND = None— globals are never copied out oftcxinto the interpreter’s memory. Astaticthat is only read costs nothing; a naive design would clone the whole allocation on every access.type Bytes = Box<[u8]>— an exactly-sized, contiguous byte store. Miri uses aVec<u8>plus separate provenance tracking; CTFE does not need per-byte provenance for ordinary data.
The lazy operand model
// compiler/rustc_const_eval/src/interpret/operand.rs:25-41
/// An `Immediate` represents a single immediate self-contained Rust value.
///
/// For optimization of a few very common cases, there is also a representation for a pair of
/// primitive values (`ScalarPair`). It allows Miri to avoid making allocations for checked binary
/// operations and wide pointers. This idea was taken from rustc's codegen.
/// In particular, thanks to `ScalarPair`, arithmetic operations and casts can be entirely
/// defined on `Immediate`, and do not have to work with a `Place`.
#[derive(Copy, Clone, Debug)]
pub enum Immediate<Prov: Provenance = CtfeProvenance> {
/// A single scalar value (must have *initialized* `Scalar` ABI).
Scalar(Scalar<Prov>),
/// A pair of two scalar value (must have `ScalarPair` ABI where both fields are
/// `Scalar::Initialized`).
ScalarPair(Scalar<Prov>, Scalar<Prov>),
/// A value of fully uninitialized memory. Can have arbitrary size and layout, but must be sized.
Uninit,
}and:
// compiler/rustc_const_eval/src/interpret/operand.rs:476-489
/// An `Operand` is the result of computing a `mir::Operand`. It can be immediate,
/// or still in memory. The latter is an optimization, to delay reading that chunk of
/// memory and to avoid having to store arbitrary-sized data here.
#[derive(Copy, Clone, Debug)]
pub(super) enum Operand<Prov: Provenance = CtfeProvenance> {
Immediate(Immediate<Prov>),
Indirect(MemPlace<Prov>),
}Operand::Indirect(MemPlace) means the value is still in memory. So an
operand that is never read — the destination of a Drop, an argument that
is overwritten before use — costs zero memory traffic.
And ScalarPair means a wide pointer (&[T], &dyn Trait) or a two-field
tuple lives in registers, with no allocation at all.
Projecting out of a ScalarPair is register shuffling
// compiler/rustc_const_eval/src/interpret/operand.rs:409-431
// This makes several assumptions about what layouts we will encounter; we match what
// codegen does as good as we can (see `extract_field` in `rustc_codegen_ssa/src/mir/operand.rs`).
let inner_val: Immediate<_> = match (**self, self.layout.backend_repr) {
// If the entire value is uninit, then so is the field (can happen in ConstProp).
(Immediate::Uninit, _) => Immediate::Uninit,
// If the field is uninhabited, we can forget the data (can happen in ConstProp).
// `enum S { A(!), B, C }` is an example of an enum with Scalar layout that
// has an uninhabited variant, which means this case is possible.
_ if layout.is_uninhabited() => Immediate::Uninit,
// the field contains no information, can be left uninit
// (Scalar/ScalarPair can contain even aligned ZST, not just 1-ZST)
_ if layout.is_zst() => Immediate::Uninit,
...
// extract fields from types with `ScalarPair` ABI
(
Immediate::ScalarPair(a_val, b_val),
BackendRepr::ScalarPair { a: _, b: _, b_offset },
) => Immediate::from(if offset.bytes() == 0 {
a_val
} else {
assert_eq!(offset, b_offset);
b_val
}),Field 0 or field 1 out of a ScalarPair is a branch on the offset, not a
memory load. ZST and uninhabited fields collapse to Uninit without ever
touching bytes.
A micro-optimisation kept on purpose
// compiler/rustc_const_eval/src/interpret/operand.rs:803-807
let mut op = self.local_to_op(mir_place.local, layout)?;
// Using `try_fold` turned out to be bad for performance, hence the loop.
for elem in mir_place.projection.iter() {
op = self.project(&op, elem)?
}The comment preserves a decision that a future refactoring would undoubtedly “clean up”: the iterator-combinator version measured slower than the plain loop.
Where it lives
compiler/rustc_const_eval/src/interpret/machine.rs— theMachinetrait andcompile_time_machine!.compiler/rustc_const_eval/src/const_eval/machine.rs— the CTFE instantiation.compiler/rustc_const_eval/src/interpret/operand.rs—Immediate,Operand,ScalarPair.compiler/rustc_miri/src/const_eval/machine.rs— the Miri instantiation.
Takeaway
Genericity is usually traded against concreteness: the more hooks you put behind a trait, the slower the hot path. rustc avoided that trade by making the trait pure (associated types, not trait objects), so the interpreter’s inner loop stays monomorphised and inlinable. The result is that Miri’s provenance tracking and CTFE’s cheapness coexist in one implementation — and Miri gets its strict aliasing model for free, because the machinery was always there.