Clause
← All documentation

Execution model: frames and continuations

How generated Erlang functions call, return, suspend and fail. The model was chosen after the prototype comparison in alternatives compared; the generated-code side of its ABI is in frames and transfers.

Decision

Every Erlang process runs on its own flat stack of explicit frames, and generated code moves between functions only by guaranteed tail transfers (LLVM musttail). A non-tail call stores the caller's continuation in the caller's frame and jumps to the callee; a return jumps back to that continuation. The native stack therefore stays one call deep above the scheduler, whatever the Erlang recursion depth, and a process can stop at any transfer and resume later on any thread.

Process state

abi::v1::FrameState starts every context, so generated code reads and updates it inline:

FieldMeaning
stack, top, limitThe stack block (moves when it grows), its first free word, and the words a push may reach without growing or passing the stack limit
frameWord offset of the current frame header
registers, liveThe 256 argument/result registers; the first live words are roots at a transfer
reductionsFunction entries left in the time slice
slow, failedBits that send transfers to the services (slow_collect, slow_profile); nonzero while the failure channel holds a failure

heap_top/heap_limit, runtime and self serve inline allocation, atom loads and self/0 (frames and transfers). A suspended process records the entry it continues at in ProcessStack::resume_.

Frames

A frame is a 4-word header followed by the function's slots (BEAM Y registers): term slots, which are roots, then raw slots for spilled native values.

Header wordMeaning
previousOffset of the caller's header (frames link by offsets, never pointers)
functionThe function's FrameDescriptor
resumeContinuation index the body switches on when control returns here
handlerHandler index; always 0, as failures return through callers

The descriptor <symbol>.frame names the module, function and arity and holds the body code pointer and the slot counts (abi). Header words are not terms; a walker follows previous and reads slot counts from descriptors. A runtime-owned bottom frame sits under the first call of every process and of every host invocation: returning into it stores the result in the first register and ends the native call.

Operations

Every call pushes a frame; leaf functions do not skip it, so every function keeps its stack-trace entry.

Root visibility

At every transfer and every safepoint the roots of a process are: the term slots of all frames on its stack, x[0..live), and the failure channel's payload, argument list and stack term (roots).

Values never survive a transfer in native registers or SSA values, except the parameters of the transfer itself. Each body reloads its frame address from stack + frame after it is entered, and reads live values from slots. Within one continuation, a service that can push a frame or move the heap invalidates every slot pointer and heap pointer held in SSA values; the reload rule for collections is fixed in reload rule.

The stack block grows by doubling and moving while the runtime-wide limit allows it, never shrinks while running, and is separate from the heap block. It has no frame-count bound; stack words count toward the runtime memory limit.

Targets

musttail with one uniform code signature is accepted by every required target at O0 and O2 (prototype below, clang 23.1.2):

TargetWordResult
x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu64tail jumps
i686-pc-windows-msvc, i686-unknown-linux-gnu32tail jumps
aarch64-unknown-linux-gnu, arm64-apple-macosx14.064tail jumps
armv7-unknown-linux-gnueabihf32tail jumps

The backend reports an error when it cannot honour a musttail call, so a successful compile is the guarantee. Fallback for a future target that rejects it: a trampoline, where each code returns the next code pointer to the scheduler loop instead of jumping; frames, roots and continuation indices are unchanged. No required target needs it.

Implementation

Alternatives compared

tests/prototypes/execution_model hand-lowers the same Erlang functions (sum/1 body recursion, loop/2 tail recursion, fail/1 raising at depth N, catcher/1 catching it) three ways and compiles them for every target above at O0 and O2 (python tests/prototypes/execution_model/run.py). On Windows x64 at O2 the chosen model ran 1M-deep body recursion in 17 ms with 5 words per frame and 10M tail calls in constant stack; its trampoline variant passed the same runs. Rejected:

Accepted costs: one indirect jump per return plus a switch dispatch; values live across calls are reloaded from slots; native debugger backtraces show only the current function, while Erlang stack traces come from the frame chain (debugging).