A Frame Stack for the Interpreter

Written under erasure. Generics have since moved to fat pointers and descriptor tables (Generics: how it works). What this says about erased values, data wrappers, erase/reify or descriptor shapes describes the old implementation.

A plan for making interpreter calls cheap: frames that are only bytes, on one stack of their own, and bytecode calls that do not recurse on the Rust stack. These are items 1 and 2 of "Calls" in plan-bytecode.md, which has the measurements this starts from.

Contents

Why

A bytecode call costs about 380 instructions, of which the callee's two ops are a handful. Stepped through: 90 in Frame::enter, 40 taking and giving back the pooled frame, 25 pushing parameters into a vector, 60 in fast_call's own prologue, epilogue and lookups, about 100 in the callee's run_bytecode around its ops. Wordfreq makes 44 million calls; fib is nothing but calls, and is where CPython 3.11, whose calls are a frame pushed on a contiguous stack inside one loop, is still twice as fast.

The same two changes are what the interpreter needed to stop aborting on deep recursion: frames that can be walked, and a recursion depth bounded by something the interpreter checks rather than by the Rust stack. (It no longer aborts; what is left is "Deep recursion aborts the process off Linux" in issues.md.)

The target is a bytecode-to-bytecode call of a few dozen instructions: bump a pointer, write the arguments, clear the tracking bytes, switch the loop's registers; and a return that copies the result and switches them back.

Where things stand

A function frame today is a Box<Frame> from FramePool:

Script frames are different and stay so: built by Frame::new, run only by the IR walker, kept in the FrameStore for later units to read and move out of, liveness always kept.

Things that constrain any redesign, from a survey of the crate:

Part 1: a frame is its bytes

Everything a function frame keeps goes into its bytes, frames come off one stack, and Frame becomes a handle.

The layout

The layout splits in two: a core every backend shares, and an extension that is the interpreter's.

The core is FrameLayout, and holds what every backend uses: values, slots, tracking bytes. The Cranelift and C backends read exactly these offsets, frame_size and frame_align. The parameter region moves out of it -- no compiled backend reads its offsets, parameters being SSA values or C arguments there -- so compiled frames shrink by a pointer per parameter. Nothing in the core may point into the extension, so that the core's offsets are the same in every engine and every build.

The extension is laid out by IrLayout after the core's frame_size, aligned, and the interpreter's frame is the two together. Its regions vary with what only the interpreter cares about -- liveness bytes come and go with debug builds -- without moving anything the compiled backends see.

All four are the extension's; the core is unchanged but for losing the parameter region.

The stack

FrameStack, owned by the interpreter: a list of chunks that never move, each allocated once and kept for reuse. A frame is bumped onto the current chunk, or onto the next one if it does not fit; frames never straddle chunks. A pop puts the top back where the frame's header says it was. Chunks rather than one reserved region of virtual memory, because the interpreter is the engine for targets without a JIT, wasm among them, where there is no reserving address space; and because a chunk list grows without a limit fixed up front.

A frame on the stack is a small header and then its bytes:

The total size is capped, by a setting with a generous default; exceeding it is an error, not an abort. That is the interpreter half of the stack-overflow issue.

FramePool, Box<Frame> and the frame's vectors go. The frame's bytes are reached through raw pointers into chunks the interpreter allocated, not through a borrow of the interpreter, which is what lets a dispatcher be handed slices of the callee frame alongside &mut IrInterpreter.

Frame as a handle

Frame becomes Copy: a base pointer and a layout pointer. Its accessors keep their names and signatures -- value, slot, param, value_deref, mark_* -- and read the frame's bytes. Script frames keep owning their buffer in the FrameStore, which hands out the same handle for them, so the IR walker, the bytecode and everything that reads an external value see one kind of frame.

A call

The caller pushes the callee's frame and writes each argument straight into the callee's parameter region -- pointer, and descriptor (the callee layout's own for an owned parameter, the argument's for a borrowed one, which is what enter does now after the fact) -- and the shape descriptors into the shape region. Then the tracking bytes are cleared, and in debug builds the liveness bytes set. The frame is entered.

The IR walker takes this too; it stays recursive (it is the checked engine, and simple), but its calls get the cheaper frame.

Part 2: bytecode calls without recursion

Today a bytecode call is run_bytecode → fast_call → run_bytecode: a fresh Rust activation of the loop per datalove call, with its prologue, its epilogue, its own copies of the loop's registers, and about 900 bytes of Rust stack.

Instead one activation of run_bytecode runs every frame from the one it was entered with to the deepest. The loop's state is a handful of registers: the ops, pc, base, the body, its context. A call to a bytecode body:

  1. pushes the callee's frame, with a record (as built, an Activation in a vector beside the loop) holding the caller's registers -- ops, pc after the call, base, body, context, code reference -- and where the result goes;
  2. writes the arguments into it, as in part 1;
  3. sets the registers to the callee's and continues the loop at its entry.

A return copies the result to where the header says, pops the frame, restores the caller's registers and continues; returning from the frame the activation was entered with returns from run_bytecode.

What still nests on the Rust stack, by design:

So part 2 bounds the Rust stack for code that is bytecode end to end, and leaves mixed chains to a depth guard at the crossings (run_frame, call_in_context_with_shapes, the JIT's dispatch trampoline): a counter, or a check of the stack pointer against a limit, that fails as an error.

Details:

Part 3: unwinding

Not needed for the speed, but this is what turns "an error" into "a safe teardown", and parts 1 and 2 are what make it possible: with frames that can be walked, each saying where it is, an error can destroy what every frame between it and the handler owns.

What a frame owns at a given point is fixed by where it is, for everything untracked -- that is what "precise" means in the ownership analysis -- and by the tracking bytes for the rest. So for each call site, the bindings live and owned across it can be computed once, from the IR's own definitions and drops (a forward analysis per body; the analysis that placed the drops knows it already, and could hand it over). An unwind is then: for each frame from the deepest up, look up its call site's list, destroy those, destroy the tracked bindings whose bytes say live, pop. That fixes the leak on error that exists today, and is what stack overflow, a native's error and, later, any trap need.

Order of work

  1. Value #[repr(C)]; the parameter region out of FrameLayout; IrLayout's extension with 16-byte parameters, the shape region, reference descriptor words and liveness bytes; Frame reading all of them from its bytes, still in a Box from the pool. Every engine's tests pass unchanged. This is where the interpreter changes over to resolve_ref_descriptors.
  2. FrameStack; Frame as a handle; the pool and the vectors go; the size cap. Callers write arguments into the callee's frame. Measure.
  3. The bytecode loop without recursion. Measure. (Steps 1-3 done; see below.)
  4. A depth guard at the crossings; spawn --jit and the REPL worker with large stacks. With 2, deep recursion fails as an error.
  5. Unwinding (part 3).

Each step is useful alone. Steps 1-3 are the speed; 2, 4 and 5 are the stack overflow issue.

What was built

Steps 1-3. Instruction counts (perf stat), the bytecode, before and after:

before parts 1 and 2
fib(32) 5.21G 4.62G -11%
a call to a two-argument function, each ~440 ~425
sum 1.09G 1.10G
primes 6.35G 6.51G +2.5%
wordfreq 29.5G 29.1G -1.5%

Bytecode recursion goes a million levels deep, where it aborted at about nine thousand; running out of frame stack, at about ten million, is InterpError::StackOverflow.

Part 1 alone was slightly slower (fib +3%): the frame header and the offset lookups cost a little more than the pool's boxes. It was what part 2 needed, not a gain of its own.

Part 2, first attempts, were much slower, and how is worth knowing:

What a planned call still costs, about 425 instructions: pushing the frame (the header, the chunk check, the layout's reference count), building and pushing the activation, checking the plan, and on return popping both. The activation could shrink (a context that did not change need not be saved, nor an empty scratch vector), and could live in the frame's header rather than a vector. Primes is 2.5% slower, from layout of the loop rather than any one op.

A leaner call

Measured against CPython 3.14 on fib(38), about 126 million calls, the bytecode ran 640 instructions and 233 cycles a call to CPython's 474 and 91: not many more instructions, but two and a half times the cycles. The processor's own accounting (perf stat -M on Zen 4) said why: CPython's cycles were 85% retiring, ours 48%, with 36% waiting on memory -- not cache misses, which were negligible, but loads waiting on stores. Most of it was in the call: enter_planned a quarter of the time, leave an eighth, FrameStack::push a tenth.

What took it to 4.4s from 6.6s (1.5x), with primes, wordfreq and sum 7-13% faster too:

At 19 billion cycles, about 150 a call, against CPython's 91. Left: the ops themselves, most of the rest -- three constants materialized per call (fib's blocks are not in loops, so nothing hoists them), a branch after every unwrap, a single dispatch point that mispredicts three times as often as CPython's.

Open questions