Skip to content

Design note: Ownership and memory performance

6 min read

Decision record for ARC performance. Sink-default ABI, cursor locals, and use-after-move diagnostics are the active model (moves, copies, and cleanup are selected while compiling, above non-atomic reference counting).

Sibling permanent non-goals remain in force: 10-stack-alloc-and-mono-design-note.md — no string SSO, no user-facing @stack on class instances, no size-class-keyed unmanaged mono.

Baseline

  • Implicit HIR ownership; explicit MIR Retain / Release / New after RcInsertion.
  • Call ABI: unmarked RC params sink; borrow shares; caller owns the result (+1). Implicit this is never a sink. Call sites move on last use, otherwise retain a copy (ownership-transfer semantics).
  • Cursor locals: non-escaping field/index loads skip retain/release when no call before their last read can overwrite the source slot (type-level ModRefTable, rc/modref.rs). Loop-carried traversal variables (curr = curr.next) form cursor families (rc/cursor_family.rs) and stay retain-free while their roots are live and no traversed (type, field) slot can be stored. Union-field snapshots own a retain at insertion; post-inline rc-held-by-owner (rc/held.rs) removes it when the owner provably outlives every read.
  • Borrow inference (passes/param_modes.rs): release builds prove borrowing independently for each parameter before RcInsertion, then rewrite every direct caller. Local aliases, projections, branches, loops and recursive direct calls participate in a conservative fixed point. Strings, arrays and nested classes/records with transparent strong fields qualify; a returned alias or forwarding to an owning parameter keeps that parameter owning without disqualifying unrelated parameters. Parameter reassignment, destructors, weak/unowned fields, recursive layouts, ownership observations, managed slot writes, global writes, opaque calls, constructor/foreign/exported ABIs and suspension remain outside the proof. Scalar array element and scalar field mutations need no ownership transfer and may qualify.
  • Value struct / plain enum off-heap (shadow stack); classes / arrays / strings / collections on the heap with a 16-byte header ending in [tag][ref_count] (wasm32 leads with [size][type info]; native leads with one word holding the type info and the size class, and blocks over 64 KiB keep their size in a prefix). The RC word is one signed int: positive = thread-local count, sign bit = atomic count (shared), DREAM_RC_IMMORTAL = never mutated or freed (interned strings, frame-allocated objects), so every RC fast path is one load plus a sign test.
  • weak / unowned + structural cycle check; weak teardown via a target-indexed registry (runtime/c/core/weak.c, shared by native and wasm32). Registered targets carry DREAM_TAG_WEAK_TARGET, so frees of every other object never touch the registry.
  • RcElision over Goto chains, transparent diamonds, transparent natural loops, postdom regions (never under-retain); RcInsertion is CFG ownership-token dataflow plus a Unique/Shared lattice (last-use move, last-use destroy, split-edge release when a token is dead on one successor). Sharing still emits Retain. All last-use destroys use ordinary Release, including class/array/union values: a unique ownership token is not proof that the object has no other owners. Last-use field/index/global stores of a unique local transfer the +1 (the backend skips the retain). Rebind of an owned local through a call/New evaluates the RHS into a temp, then Releases the old occupant (tmp = f(x); Release(x); x = tmp) so x = f(x) cannot UAF. Loop headers of loop-carried owned locals start Owned/Unique so the first dataflow pass does not treat a back-edge as Empty. Last-use destroy applies to every owned RC local. StmtBorrow::Held (crates/dream-mir/src/passes/rc/lifetime.rs) delays destroy only for callees in holds_raw_borrow (crates/dream-abi/src/intrinsics.rs) and @async_host imports (see held_defs). Sink/take params still drop at callee return so inlining cannot copy-prop an early = null onto a caller argument that is still live. After inlining, RcElision can cancel retain/release pairs that a call barrier would have kept.
  • shared class atomic retain/release; silent SROA for non-escaping class instances.
  • ref name: T parameters for mutable place / value-struct aliasing.
  • Flow-sensitive use-after-move diagnostics after a sink parameter is stored into a field or index.

Locked design choices

  1. Default call ABI is sink-params / own-result. borrow opts out; no user-facing take. Callers copy into sinks when the argument is still live (no hard use-after-sink error).
  2. Language surface uses borrow / ref in the parameter modifier slot.
  3. No OSSA / ownership-SSA rewrite of MIR. Elision stays statement-level on the CFG.
  4. CoW (if ever) stays behind an explicit copy API; collections remain classes.
  5. Weak teardown is a target-indexed registry; only objects tagged DREAM_TAG_WEAK_TARGET consult it on free (the old global list made the weak_tree bench ~58× slower than C#; the registry is ~2× faster than C#).

Non-goals

  • User =copy / =sink operators (del remains the destructor)
  • Keeping take as a synonym of unmarked sink
  • String SSO, @stack classes, value-struct List/Map/Set
  • Atomic RC by default
  • CPU SIMD language surface / Dream-owned tiered JIT
  • externref for js (handles stay i32 ids; externref cannot live in linear memory)
  • Extra per-type object freelists (size-class $malloc already exists)
  • Weak teardown header side-tables (the target-indexed registry plus the header tag bit suffice)

Opt-in defer { } / defer(q) { } (native destroy queue, bounded drain) is shipped; see memory.md. WASM last-ref stays immediate.

Heap throughput (shipped levers)

ARC alone removes RC tax; remaining cost is heap hits and $malloc. These are the active levers (no SSO / @stack class / value collections):

  1. Silent SROA — including post–simple-user-ctor expand (ExpandSimpleCtors): non-escaping instances with only non-ref field accesses promote to locals (crates/dream-mir/src/passes/sroa/mod.rs). Post-inline SroaManaged (sroa/managed.rs) also splits objects reached through several locals or holding reference fields, using escape analysis (analysis/escape.rs).
  2. Clear-and-reuse — List/Map/Set.clear keep capacity and zero live slots in place (no capacity-sized realloc). Prefer clear + refill over new each batch.
  3. Arena — mixed unmanaged raw allocations in stable blocks; reset() retains blocks for reuse and free() releases manually owned storage (system.core).
  4. Contiguous / SOA hot paths — regex is PCRE2; keep other stdlib hot loops on dense arrays. thread queues, reused mark/caps buffers, and Buffer.elems_copy for capture clones.
  5. Ownership discipline — sink-default + borrow + field-store use-after-move (see ownership.md).
  6. Inferred unique region — a fresh, unobserved x = f(); … Release x graph whose callee only News del-free classes is allocated from a TLS bump slab and rewound in O(1) (dream_region_enter / leave). Silent; not user @stack.
  7. Frame allocation (passes/frame_alloc.rs) — an instance whose alias class escapes at most into non-retaining callee parameters, with a statically known count (analysis/object_life.rs), is built in the stack frame (dream_frame_object, immortal count) and its deaths release only its strong fields. Refused for del, weak/unowned fields, shared, large objects, recursion, region openers, and async. Also silent; the user-facing non-goal of @stack stands.
  8. Allocator fast path — native size classes are 16-byte steps to 256 then 8 per power of two up to 64 KiB; the freelist pop/push is inline, alloc counters are per thread, and destroy glue is iterative so long chains cannot overflow the C stack.

Authoring rule: borrow + move + dense memory + clear/reuse → ARC can beat gen0 on the same shapes; new + share a class graph every iteration will not.

Measure: ./scripts/bench.py → tests/bench/out/compare/summary.json / BASELINE.md.

Raw-buffer memory model (verified)

Hand-rolled containers (T[] buffer field + user-managed counter(s)) are memory-safe by construction — no lint, no annotation, any number of buffers or counters:

  • Indexed loads always retain (move_source only recognizes locals), so a read-out element and its slot are both valid owners. Proven: Debug.ref_count = 3 after arr[0] = w; let x = arr[0] with w alive.
  • A counter rewind therefore cannot corrupt memory — dead slots hold valid references until the next overwrite or until the array is freed, at which point release_array_t{elem} walks the full length prefix and releases every slot. Reclamation is deferred to free time for any shape; worst-case retention is bounded by live arrays' capacity.
  • Shrinking via Buffer.realloc releases dropped tail slots (dream_array_realloc_rc, runtime/c/core/strings.c) so truncation never strands retained elements.
  • Ownership-transferring pops zero their vacated slot (List/Queue/PriorityQueue) purely to reclaim eagerly, not for safety.

Leak checking: debug dream run (including -g) always prints heap counters at exit. Only the debug runtime objects (-DDREAM_HEAP_COUNTERS=1) count; release bitcode compiles the counters and the report out. Debug.live_objects deltas assert balance in goldens (tests/cases/container_clear_rc.dream). Note counters include interned strings and boxed print values, so measure into locals before printing.

Eager reclamation for churn-heavy long-lived containers is opt-in: Buffer.clear<T>(arr) / Buffer.truncate<T>(arr, n) release elements through normal ARC store semantics.