Skip to content

Memory & GC

Achronyme manages heap memory through typed arenas with a bitmap mark-and-sweep garbage collector. All heap objects are allocated in type-specific pools and reclaimed when no longer reachable.

The Heap contains 10 typed arenas, one per heap object type:

ArenaTypeAllocation Size
stringsStringcapacity() bytes
listsVec<Value>capacity() × 8 bytes
mapsHashMap<String, Value>capacity × (string + value + 8) bytes
functionsFunctionchunk.len() × 4 + constants.len() × 8 bytes
closuresClosurefunction handle + upvalue handles
upvaluesUpvaluelocation + linked list pointer
iteratorsIteratorObjsource value + state
fieldsFieldElement32 bytes (BN254 Montgomery form)
proofsProofObject3 JSON strings (proof + public + vkey)
bigintsBigInt32–64 bytes

Each Arena<T> uses a slot-based allocator with free list recycling and bitmap mark bits:

Arena<T> {
data: Vec<T>, // grows monotonically
free_indices: Vec<u32>, // reusable slot stack (LIFO)
free_set: HashSet<u32>, // O(1) membership check
mark_bits: Vec<u64>, // 1 bit per slot, packed into 64-bit words
}

Allocation (alloc): reuses a freed slot if available, otherwise appends to data.

Deallocation (mark_free): adds the slot index to the free list. The slot is zeroed to release memory (e.g., String::new(), Vec::new()).

Invariant: free_indices and free_set must always stay in sync.

Each arena maintains its own Vec<u64> bitmap for GC marking. Each u64 word covers 64 slots:

  • set_mark(idx) — Sets bit idx % 64 in word idx / 64. Returns true if the object was previously unmarked.
  • is_marked(idx) — Checks whether the bit is set. Returns false for out-of-range indices.
  • clear_marks() — Resets all bits to zero in O(N/64) time via memset, preserving capacity for reuse.

This replaces the previous HashSet<u32> approach, reducing memory from ~56 bytes per marked object to 1 bit per slot, with cache-friendly sequential access during sweep.

The GC is a stop-the-world mark-and-sweep collector with bitmap marking.

The heap tracks live allocation size in bytes_allocated. When it exceeds next_gc_threshold (starting at 1 MB), the GC is triggered:

if heap.request_gc || stress_mode {
collect_garbage();
}

This check runs at the start of every interpreter cycle (before executing the next instruction). GC is not triggered while the GC lock is held (see GC Lock below).

Breadth-first traversal from roots using a worklist:

  1. Collect roots:

    • Active stack values (per-frame, bounded by max_slots)
    • All global variables
    • All call frame closures
    • All function prototypes (stay alive for closure creation)
  2. Traverse children (using bitmap set_mark to track visited objects):

    • List: all elements are added to worklist
    • Function: all constants are added to worklist
    • Closure: marks function handle + all upvalues; closed upvalues add their captured value to worklist
    • Map: all values are added (keys are Rust-owned, freed when HashMap drops)
    • Iterator: source value is added
    • Field, Proof, BigInt: leaf types, no children
  3. Root open upvalues: After the main trace, all open upvalues are explicitly marked via the linked list traversal. This prevents premature collection of stack variables captured by closures.

If set_mark returns false (already marked), the object is skipped, preventing infinite loops on cycles.

Linear scan over each arena:

  1. For each slot in data:
    • If not marked and not already free: zero the slot and add to free list
    • If marked: leave in place
  2. Call clear_marks() on each arena’s bitmap
  3. Recount bytes_allocated from scratch (eliminates tracking drift from in-place mutations like Vec::push)

After sweeping, the threshold is recalculated with hysteresis:

grow = bytes_allocated × 2
hysteresis = previous_threshold × 1.5
next_gc_threshold = max(grow, hysteresis, 1 MB)

This prevents GC thrashing when the heap size hovers near the old threshold.

Native functions that perform multiple heap allocations can be interrupted by a GC cycle between allocations. If intermediate values aren’t yet rooted (not on the stack or in a global), the GC could free them prematurely.

The GC lock prevents this:

heap.lock_gc() // increment lock depth
// ... multiple allocations are safe here ...
heap.unlock_gc() // decrement lock depth; re-checks threshold at depth 0

The lock is reentrant — it uses a depth counter (gc_lock_depth), so nested lock/unlock pairs work correctly. Only the outermost unlock_gc() re-checks the GC threshold, allowing deferred collection.

A debug_assert in collect_garbage() ensures the GC is never triggered while the lock is held.

The GC lock is currently used in:

  • Stdlib natives that allocate multiple objects (e.g., split, keys, chars)
  • Closure opcode — protects upvalue capture loop (multiple alloc_upvalue calls)
  • GetIter opcode — protects map iteration (key string allocations)

A configurable hard cap prevents runaway programs from consuming all host memory:

Terminal window
ach run program.ach --max-heap 256M

When bytes_allocated exceeds max_heap_bytes, the VM raises a HeapLimitExceeded runtime error with a clear message instead of an OS-level OOM kill.

Accepted formats: "256M", "1G", "512K", or raw byte count. Default: unlimited.

Run with --gc-stats to print GC metrics to stderr after execution:

Terminal window
ach run program.ach --gc-stats

Output:

-- GC Stats --
Collections: 12
Freed (total): 48320 bytes
Peak heap: 131072 bytes
GC time: 0.847 ms
Heap now: 24576 bytes

The gc_stats() native function returns a map with live metrics, accessible from within a program:

let stats = gc_stats()
print(stats["collections"]) // number of GC cycles so far
print(stats["bytes_freed"]) // cumulative bytes reclaimed
print(stats["peak_bytes"]) // maximum heap size reached
print(stats["gc_time_ms"]) // cumulative milliseconds spent in GC
print(stats["bytes_allocated"]) // current live heap size

Run with --stress-gc to force garbage collection on every interpreter cycle:

Terminal window
ach run program.ach --stress-gc

This triggers collect_garbage() before every instruction, exposing:

  • Dangling pointers: objects freed while still referenced
  • Rooting bugs: roots not properly tracked (e.g., open upvalues)
  • Timing-dependent bugs: issues that only appear under specific GC timing

Stress mode is used extensively in the test suite (vm.stress_mode = true) to verify GC correctness.

Closure {
function: u32, // handle to Function
upvalues: Vec<u32>, // handles to Upvalue arena
}
Upvalue {
location: Open(stack_index) | Closed(Value),
next_open: Option<u32>, // linked list for open upvalues
}

Open upvalues point to a stack slot — the variable is still in scope. When the enclosing function returns, open upvalues are closed: the value is copied from the stack into the upvalue (Closed(value)), and the upvalue is removed from the open list.

ProofObject {
proof_json: String, // Groth16 proof data
public_json: String, // public inputs
vkey_json: String, // verification key
}

Created by prove {} blocks. Accessible via proof_json(), proof_public(), proof_vkey().

ComponentFile
Arena & bitmapmemory/src/arena.rs
Heap, trace & sweepmemory/src/heap.rs
Value encodingmemory/src/value.rs
FieldElementmemory/src/field.rs
GC trait & mark_rootsvm/src/machine/gc.rs
VM (GC trigger)vm/src/machine/vm.rs
GC stats nativevm/src/stdlib/core.rs