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 Machine trait 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

rust
// 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

rust
// 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 from dlsym; CTFE cannot. By making the type uninhabited, the CTFE instantiation of call_extra_fn becomes match fn_val {} — the capability is statically impossible, at zero runtime cost.

  • GLOBAL_KIND = None — globals are never copied out of tcx into the interpreter’s memory. A static that 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 a Vec<u8> plus separate provenance tracking; CTFE does not need per-byte provenance for ordinary data.

The lazy operand model

rust
// 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:

rust
// 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

rust
// 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

rust
// 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 — the Machine trait and compile_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.

RustInternalsCompilerConst evalMiriPerformance