Clause
← All documentation

Process heap contract

Process heaps follow the classic ERTS design. This note is the contract; plan 11 phase C (steps 8A–8I) delivered it, and later steps named below extend it. runtime.md summarizes the API.

What phase C replaced

Before phase CProblemReplacement
A list of chunks that never moveCells cannot be compacted or copied; capacity only growsOne contiguous heap block plus fragments, moved by a copying collector (8G, 8H)
Each cell is a node in a per-process std::map indexHeap words alone are not parseable; one host allocation and an O(log n) lookup per cellSelf-describing cells; admission by owned range and header (8C, 8D)
Every bitstring cell has a fixed 64-byte array and a shared_ptr, released through a destructor registryLarge cells for small data; nothing can move a cell or find its dead copiesVariable-size heap binaries and off-heap binary cells on a per-process off-heap list (8B)
Host Term pins the heap with shared_ptr<HeapStorage>; runtime-held values live only in TermsNothing a collector can find or rewriteERTS model: C++ holds raw words only between safe points; runtime-held values are process root words; host callers pass explicit roots to collect() (8E)
One heap buffer per generated root frameNo process stack to scanOne stack of root frames per process (8F)
No overflow areaAllocation either fits the budget or failsHeap fragments while the heap must not move (8G)

Generated code, its ABI and every observable program result stay unchanged.

Word layout

A term is one target word (32 or 64 bits); encodings are in abi.md. Every heap area is a sequence of objects that a walker parses from its first word:

No cell needs alignment stronger than a word. memory/heap_walk parses an area cell by cell and ProcessHeap::verify checks a whole heap (8C). Cells hold only words and bytes, except the off-heap binary's std::shared_ptr (below), so a cell moves by copying its words.

KindWords after the headerTraced words
cons (no header)2 words in totalhead, tail
tuplen element slotsall
map2n slots: keys in exact term order, each followed by its valueall
native_recordaddress of the runtime's RecordDefinition, then n field values in definition ordervalues
fun_closureaddress of the runtime's FunDefinition, then n captured values (funs)values
bignumsign word, then magnitude limbs, least significant firstnone
floating8 bytes: 1 word (64-bit) or 2 words (32-bit)none
reference8 bytes: the reference number (pids and references)none
heap_binarybit length, then data bytes rounded up to words (at most 64 bytes)none
refc_binarybit offset, bit length, std::shared_ptr (2 words), off-heap link: 5 wordsnone
fillern unused wordsnone

The map count is in words (entries = count / 2). Pids are immediates, admitted against the runtime's issued numbers. Kinds not yet admitted (external identities) follow the same rules when they arrive: identities and descriptors are registry IDs in untraced words, never owning C++ pointers.

Off-heap binaries

A binary larger than 64 bytes is an immutable buffer that floats outside every process heap, shared by reference count (BEAM ProcBin and Binary).

Areas

Sizing and budget

Runtime memory limit

Admission

Pointers into a process heap are created only by the compiler and the runtime inside that process, and always name an object start; there are no interior pointers to detect. Admission (8D) is an ownership check for words handed back to a process:

  1. The address is word-aligned inside one of the process's areas, below its top: the heap block is checked first, then fragments sorted by address. Foreign and stale words fail here without any load.
  2. A boxed word names a header of an admitted kind (not filler); a list word names a cons cell (a word that is not a header).

Accessors decode kind, count and payload from the header itself. verify() remains the full check that every slot names an object start, for tests.

Roots and safe points

ProcessContext::visit_roots enumerates every root word for the collector (step 23). At a safe point nothing else holds heap words of the process:

OwnerRoot wordsNotes
Frame term slotsThe first roots slots of every frame on the stack (step 19)Bottom frames have none; resume and handler indices are integers
Raw frame slotsNoneSpilled native values; generated code keeps no heap word there at a safe point (step 24 reload rule)
Registersx[0..live) (ProcessStack::keep_registers)A suspended entry's arguments (step 43); every push and pop clears live
Failure channelError payload (BEAM fvalue), erlang:error/2,3 argument list, stack trace termRebound in place; captured trace frames are descriptor pointers into code
Trap stateThe term words of a trapping builtin's TrapState (step 43A)Released when the builtin finishes or fails
MailboxEvery message in the signal inbox and the message queue (step 45), including 'EXIT' and 'DOWN' messagesUntil a receive takes it; rewritten in place, so the receive cursor (a list position) and timeout deadline stay valid
Explicit rootsThe span a host passes to collect(roots) (8E)Read back after the call
Off-heap listNoneLinks are swept and relinked, not traced

No heap cell holds a pin. Atoms are immediates and the atom table is never collected. Fun cells name code through their untraced FunDefinition, which lives as long as the runtime; loaded modules are never unloaded, so neither funs nor trace descriptors need a pin. Small immediates are not roots.

As in ERTS C code, a host Term is a raw tagged word valid until the next safe point of its heap. It does not pin heap storage; it keeps a weak context lifetime token and the heap's collection count, so use after teardown reports expired_context and use after a later collection reports a stale-term error. A Term is only valid inside its own process; other processes may only read it.

The heap moves only at a safe point, and never while a reservation is open:

Any other request returns unsafe_point and changes nothing, not even the failure channel of a running generated call. Allocation never moves the heap: a request that does not fit creates a fragment.

Collection in generated code

Decision of plan 11 step 24 (2026-10-06), implemented in step 26 (implementation). Generated code collects only at a few safepoints where every live term already sits in a root. Everything else, including every allocating service, is a critical section that never moves the heap.

Triggers

A safepoint collects when the heap asks for it; otherwise it costs one check.

TriggerCondition at the safepointERTS counterpart
Heap fullAny fragment exists: an allocation did not fit the heap block since the last collectionHeap top reaches the heap end
Off-heap binary pressureOff-heap words reach the virtual binary heap limit: 46,422 words at first, after each collection twice the surviving off-heap words, never less than that, but at most the survivors plus half of the budget left free after the heap block (step 27)bin_vheap_sz / binary virtual heap
erlang:garbage_collect/0Always; arrives with the builtin families (steps 36-37) as a forced safepointExplicit full sweep

The new block is sized for the live words plus the stack words in use (ERTS keeps the stack inside the heap block): a deep stack gets a larger heap, so a long recursion collects in proportion to its allocation rather than rescanning the whole stack every few hundred words.

Safepoints

PointWhereLive outside frame term slots
Function entryIn CLAUSE_enter_v1 / CLAUSE_tail_v1 (and so host invocation), before the callee frame is pushedThe callee's arguments x[0..arity), kept as roots (keep_registers)
Loop headA call of CLAUSE_safepoint_v1(context) at the head of every comprehension generator loopNothing

Every Erlang loop is either recursion, which passes a function entry per step, or a comprehension, which passes its loop head, so garbage between two safepoints is bounded by straight-line code and single service results.

Not safepoints (critical sections, which keep allocating into fragments): every other runtime service, including allocation, construction and matching services; CLAUSE_return_v1; exception propagation; and later message delivery (step 45). Services may therefore hold raw heap words in C++ for their whole run, and their input arrays and outputs need no reload.

Waiting and suspended processes

Plan step 51. A process that is not running is never collected: it is waiting in a receive, queued after a yield or trap, or not yet started, and everything it holds is already a root (its frames, the registers of the entry or continuation it will resume at, trap state and its messages). Messages sent to it are copied into fragments of its heap. Every delivery wakes a waiting process, and resuming it repeats the entry of its continuation (the wait builtin, a trap continuation or the function it yielded at), which is a function-entry safepoint: the first thing a resumed process does is collect when its heap asks for it. A process waiting in a selective receive that skips many messages therefore collects as they arrive, as ERTS collects a process when it is next scheduled. executables_mailbox_collection checks hoarding, waiting with a timeout and deep recursion under message load, and that a consumer acknowledging 3,000 messages stays within --max-heap 65536.

Rejected: allocation as a safepoint (BEAM test_heap). It would need every service input and every SSA term live across any allocation in a root, a reload after each allocating service and a retry protocol in every service, while the two safepoints above already bound the garbage.

Reload rule

No SSA value (a value in a native register) holds a heap word across a safepoint, and no native pointer crosses one at all (already an error in lower_frames).

Failure behavior

Implementation

Step 26 (2026-10-06):

Step 27 (2026-10-06):

Step 27A (2026-10-06):

Prototype

tests/prototypes/safepoint holds one comprehension-style loop in post-lower_frames form (loop.ll): a term Y computed before the loop is stored to a term slot and reloaded after the loop-head safepoint. python tests/prototypes/safepoint/run.py compiles it for x86_64-pc-windows-msvc, aarch64-unknown-linux-gnu (64-bit words), i686-pc-windows-msvc and armv7-unknown-linux-gnueabihf (32-bit words) at O0 and O2 and checks that a load of Y's frame word follows the safepoint call. All eight pass with clang 23.1.2. At O2 (i686) the loop keeps the slot reload and never reuses the register that held Y:

LBB0_2:                     # loop head
    pushl  %edi
    calll  _clause_safepoint_v1
    pushl  20(%esi)         # cursor reloaded from its term slot
    ...
    pushl  24(%esi)         # accumulator
    pushl  28(%esi)         # Y reloaded from its term slot
    calll  _make

Collection

A full-sweep Cheney copy (8H, memory/heap_collect): allocate the new block first (failure is out_of_memory and leaves the heap untouched), then mark host Terms stale, copy the object behind every root word and scan the new block left to right, copying the children of each copy. A moved boxed object's header is replaced by a boxed pointer to its copy; a moved cons cell gets a zero head and a tail pointing to its copy. Forwarding preserves sharing. Roots are rewritten in place, the off-heap list is swept (copies relinked in list order, dead cells destroyed), and the old block and fragments are freed. A heap that was never allocated is not collected. CollectionStats reports words before, live words, the new heap block, the merged fragments, stack slot capacity and off-heap words.

Copying between heaps

ProcessHeap::add(value), equivalently value.copy_to(heap), returns a term of the destination heap (step 28, BEAM size_object and copy_struct):

Measurements

runtime_heap_measurements (full-mode CTest; numbers printed, not gated) builds a 100,000-element list of {Index, Float} tuples through TermFactory, walks it back through checked accessors, and creates 1,000 contexts that each hold one small tuple. Since 8I it also collects with the list as the only root and walks the copy. Side bytes are host allocations beyond heap backing (an object index before 8D, the fragment chain since 8G).

RevisionBuildKernel build / walkHeap used / capacity wordsSide bytesBytes per contextHeap words per context
bb09359 (chunk list, object index)Windows x64 Debug, clang-cl264 / 81 ms700,000 / 704,51224,002,256 (about 80 per cell)66,2178,192
8D (chunk list, owned range)Windows x64 Debug, clang-cl185 / 147 ms700,000 / 704,5123,44066,0578,192
8G (233-word heap, about 3,000 fragments)Windows x64 Debug, clang-cl219 / 174 ms700,000 / 706,223163,8782,377233
8I, before collectionWindows x64 Debug, clang-cl216 / 173 ms700,000 / 706,223163,8782,377233
8I, after one collectionWindows x64 Debug, clang-clcollect 56 ms / walk 72 ms700,000 / 999,6310——

Against the bb09359 baseline: per-cell side metadata is gone (24 MB to none once collected), a context needs 2.4 KB and 233 heap words instead of 66 KB and 8,192 words, building is about 20% faster, and walking is slower until a collection merges the fragments (fragment admission is a binary search over about 3,000 ranges); after one the walk takes 72 ms. A 700,000-word live set collects in about 56 ms into a 999,631-word block, the ERTS size that keeps it below 75%.