Silicon's v0.1 memory model is explicit arenas on top of a bump allocator. This page covers when to reach for the tools, the rules they impose, and the post-0.1 roadmap.
TL;DR for one-shot CLI programs — you don't need to do anything. The default heap is fine; the OS reclaims it when your program exits. Skip to The default only if you care about the internals.
TL;DR for long-running programs (servers, REPLs, watchers, anything in a loop) — wrap per-iteration work in
@with_arena({ ... }). If the iteration produces a heap value the parent scope keeps (e.g. aStringresponse), make the last expression@move_to_parent_arena(value). See@with_arenaand@move_to_parent_arena.
Every heap-using stdlib type (String, Vec, sum types with payloads,
arrays) goes through a single bump allocator declared in
src/codegen/std.wat and src/codegen/prelude-ir.ts. The heap starts at
offset 1024 and grows monotonically. There is no per-object free list.
For programs that allocate, do their work, and exit, this is the right answer:
- Zero per-allocation overhead. No headers, no bookkeeping.
- Predictable. The bump pointer is observable via
heap_get(). - Simple to reason about. Every allocation strictly comes after the previous one.
For programs that loop, this is a one-way ratchet:
\\ server_loop () -> Int
@fn server_loop := {
@mut i := 0;
@loop(i < 1000000, {
response := str_concat('reply: ', handle_request(i));
send(response);
i = i + 1;
});
0
};
Every str_concat call adds bytes to the heap that are never freed —
1,000,000 iterations later the program traps via the heap-exhaustion path
(see --max-heap). The arena scope is how you
break the ratchet.
@with_arena({ body })
- Saves the current heap pointer at entry.
- Runs
body. Allocations inside bump the heap as usual. - On exit, resets the heap pointer to the saved value. Every byte
allocated inside
bodyis freed at once. - The block evaluates to whatever
body's tail expression produces. Value types (Int,Float,Bool, payload-free enums) flow through unchanged.
Rewriting the server loop with an arena:
\\ server_loop () -> Int
@fn server_loop := {
@mut i := 0;
@loop(i < 1000000, {
@with_arena({
response := str_concat('reply: ', handle_request(i));
send(response);
});
i = i + 1;
});
0
};
Now the per-iteration response String is freed at the end of every loop
body. Memory usage is bounded by the largest single iteration, not by the
sum across iterations.
Arenas nest. They form a LIFO stack — an inner arena's exit restores the heap pointer to the inner entry; the outer arena's exit further restores to the outer entry.
@with_arena({ # heap = H0
a := 'outer'; # heap = H1
@with_arena({
b := 'inner'; # heap = H2
}); # heap restored to H1
c := 'more'; # heap = H1' > H1
}); # heap restored to H0
The arena reset frees everything. If the block needs to return a heap
value (String, Array[T] with T a value type, …) you'd hit a compile
error: the inner pointer would dangle. The fix is to promote the
value to the parent arena before the reset:
\\ greet (String) -> String
@fn greet name := @with_arena({
hello := str_concat('hello, ', name);
with_punct := str_concat(hello, '!');
# ... work that allocates scratch strings ...
@move_to_parent_arena(with_punct)
});
What happens at the call site:
str_concatbuilds uphello, thenwith_punct, plus arbitrary scratch strings that share the inner arena.@move_to_parent_arena(with_punct)looks atwith_punct's type (String), computes its contiguous byte size (4 + length-header bytes), and emits a runtime call to$arena_promote:memcpythose bytes from inside-arena to the saved-pointer boundary;- bump the heap to
saved + size, so the promoted bytes are kept and everything else is freed; - return the new (post-copy) pointer.
- The block evaluates to that new pointer — a
Stringthat survives the arena reset.
Two restrictions, both enforced at compile time:
1. Tail position only. @move_to_parent_arena must be the arena
body's last expression — not buried mid-block. The compiler enforces
this and surfaces:
@move_to_parent_arena may only appear in the tail position of a @with_arena({ … }) block (ADR 0008, Phase 9c; v1.1 will lift this restriction via pointer-fixup).
If you need promotion mid-block, restructure so the work after the promotion lives in an outer scope.
2. Flat heap types. v0.1 supports:
- Value types (
Int,Float,Bool,Int64,UInt8–UInt64, payload-free enums) — no copy needed; just unwind. String—4 + load-lengthbytes.Array[T]whereTis a value type —4 + count × sizeof(T)bytes.Distinctwrappers over any of the above.
Nested heap (Array[String], Vec[Vec[Int]], sum types with heap-typed
payloads) is rejected with a structured error. A later release's
trace-and-copy extension will lift this.
Silicon avoids implicit memory operations. The same philosophy that
makes integer width casts explicit (@toInt64(x)) makes arena escape
explicit — the cost of the memcpy is visible at the call site, not
hidden in a return edge.
Two pure-read helpers let tests, dashboards, and CI memory budgets observe the bump pointer:
heap_used()— bytes bump-allocated since program start (heap - heap_base). Resets when something lowersheap— most commonly an@with_arenaexit.arena_used(saved)— bytes since a caller-supplied entry pointer. Pair withheap_get()to size the current arena without per-arena handles:
@with_arena({
saved := heap_get();
# ... do work ...
cost := arena_used(saved);
@if(cost > 1000000, { log_warn(cost) }, {});
});
Both are read-only — they don't allocate, don't fault, and don't perturb the bump pointer. Safe to call from any context, including inside the panic/trap paths of your own diagnostics.
When arenas aren't the right tool — when a value's lifetime isn't
nested in a scope — Sigil ships a single-threaded Rc smart pointer
as plain stdlib (src/stdlib/rc.si). Layout: [refcount:i32, value:i32]. Works for any 32-bit value: Int, Bool, String/Array/Sum
pointers.
@use '/path/to/sigil/src/stdlib/rc.si';
\\ share () -> Int
@fn share := {
r := rc_new(42);
@defer(rc_drop(r)); # auto-decrement on every return path
r2 := rc_clone(r); # bumps count to 2
@defer(rc_drop(r2)); # LIFO: drops in reverse declaration order
rc_get(r) + rc_get(r2)
};
Stratum composition is the value proposition. Rc is just six
@fns — no compiler changes, no new keyword. It composes with two
existing strata to cover the full lifecycle:
@deferfor scope-bound cleanup —@defer(rc_drop(r))registers the drop at any return-path exit.@with_arenafor bulk free —Rcallocations inside an arena are physically reclaimed at arena exit regardless of refcount.
Together they cover Rust's Rc / Box / Drop story without
teaching the compiler any of them. That's the v0.1 stratum power
demo: ergonomic memory management as a library, not a language
extension.
Caveat — physical free. rc_drop decrements the count but
doesn't reclaim memory (the v0.1 bump allocator has no free list).
The slot is logically dead at refcount 0; the bytes leak until the
enclosing arena resets or a later GC runs. In practice this is
fine — wrap Rc-using work in @with_arena and the leak window
collapses to one iteration's allocations.
ADR 0009 adds an opt-in WebAssembly GC
target. The same source that uses arenas and Rc under
--target=wasm-mvp (the default) compiles cleanly under
--target=wasm-gc, with every lifecycle primitive collapsing to a
no-op at lowering time:
| Primitive | Under wasm-mvp | Under wasm-gc |
|---|---|---|
@with_arena({ body }) |
Save/restore $heap |
{ body } (no envelope) |
@move_to_parent_arena(v) |
arena_promote memcpy |
v (identity) |
rc_new(value) |
Heap-allocate [1, value] |
value (identity) |
rc_clone(r) |
Bump refcount | r (identity) |
rc_drop(r) |
Decrement refcount | () (no-op) |
rc_get(r) |
Load value at r+4 |
r (identity) |
Two mechanisms, both compile-time:
- Stratum target-dispatch.
lowerWithArenaandlowerMoveToParentArenainspectctx.target; under wasm-gc they lower the body directly with no envelope. - Stdlib shadow.
src/stdlib/gc/rc.simirrorssrc/stdlib/rc.siwith identity implementations. The@useresolver auto-redirects…/stdlib/X.si→…/stdlib/gc/X.siwhen target is wasm-gc and the shadow exists. No call-site changes in user code.
What's rejected under wasm-gc (introspection has no honest GC semantics; raw pointers don't exist):
- E0012 — introspection.
rc_count,rc_is_unique,heap_used,arena_used,heap_get,heap_set. Conservative no-op values would silently change branch behavior. - E0013 — raw memory.
alloc,realloc,mem_copy,str_ptr. Managed refs ((ref $T)) aren't addressable; no pointer math.
Programs that touch any of these primitives pick a target deliberately
(sgl build --target=wasm-mvp for raw-memory work; --target=wasm-gc
for managed-ref work). Programs using only the lifecycle layer +
high-level types (String, Array[T], @type structs, sum types,
Option, Result) compile under either target.
The --max-heap=N flag caps the wasm memory at N 64KB pages. Past the
cap, memory.grow fails and the bump allocator emits a clean WASM trap
(unreachable) instead of returning a sentinel pointer. wasmtime
surfaces the failure with a documented message.
# Cap at 2 pages (128KB) and watch allocation traps fire deterministically.
sgl run --max-heap=2 src/main.siUse this in CI to verify that long-running programs stay inside their
arena bound — a --max-heap value just above the steady-state heap
size will catch any regression that leaks per-iteration allocations.
Phase 9c ships the v0.1 instantiation of ADR 0008. A later release extends the same surface without changing 0.1 program semantics:
- Mark-sweep GC stratum. Implements the same
wit/allocator.witABI as the bump allocator. Programs opt in via a compile flag; the allocator surface is byte-equal so existing code keeps running. - Deep promotion.
@move_to_parent_arenaextends to nested heap types via trace-and-copy — promoting aVec[String]walks the value graph, recursively promotes reachable heap blocks, and rewrites internal pointers. - Lift tail-position restriction. With a pointer-fixup pass, the promotion call becomes legal mid-block; the runtime tracks the in-flight pointer locals and rewrites them when the arena unwinds.
See docs/v1.1-user-stories.html for the
detailed milestones (M-6 through M-8 in ADR 0008's follow-up section).
- ADR 0008 — Memory management: explicit arenas for 1.0, AllocatorABI for 1.1
wit/allocator.wit— the ABI surface every Sigil allocator conforms to.- Phase 9c in
v1-user-stories.html— per-story acceptance bars. - Source:
src/strata/control.si(stratum declarations),src/ir/lower.ts:lowerWithArena(lowering),src/codegen/prelude-ir.ts:buildArenaPromote(runtime helper),src/stdlib/rc.si(Rc smart pointer).