Design note: Ownership and memory performance¶
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/NewafterRcInsertion. - Call ABI: unmarked RC params sink;
borrowshares; caller owns the result (+1). Implicitthisis 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-inlinerc-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 beforeRcInsertion, 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/ plainenumoff-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 carryDREAM_TAG_WEAK_TARGET, so frees of every other object never touch the registry.RcElisionover Goto chains, transparent diamonds, transparent natural loops, postdom regions (never under-retain);RcInsertionis 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 emitsRetain. All last-use destroys use ordinaryRelease, 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/Newevaluates the RHS into a temp, thenReleases the old occupant (tmp = f(x); Release(x); x = tmp) sox = 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 inholds_raw_borrow(crates/dream-abi/src/intrinsics.rs) and@async_hostimports (seeheld_defs). Sink/take params still drop at callee return so inlining cannot copy-prop an early= nullonto a caller argument that is still live. After inlining,RcElisioncan cancel retain/release pairs that a call barrier would have kept.shared classatomic retain/release; silent SROA for non-escaping class instances.ref name: Tparameters 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¶
- Default call ABI is sink-params / own-result.
borrowopts out; no user-facingtake. Callers copy into sinks when the argument is still live (no hard use-after-sink error). - Language surface uses
borrow/refin the parameter modifier slot. - No OSSA / ownership-SSA rewrite of MIR. Elision stays statement-level on the CFG.
- CoW (if ever) stays behind an explicit copy API; collections remain classes.
- Weak teardown is a target-indexed registry; only objects tagged
DREAM_TAG_WEAK_TARGETconsult it on free (the old global list made theweak_treebench ~58× slower than C#; the registry is ~2× faster than C#).
Non-goals¶
- User
=copy/=sinkoperators (delremains the destructor) - Keeping
takeas a synonym of unmarked sink - String SSO,
@stackclasses, value-structList/Map/Set - Atomic RC by default
- CPU SIMD language surface / Dream-owned tiered JIT
externrefforjs(handles stayi32ids;externrefcannot live in linear memory)- Extra per-type object freelists (size-class
$mallocalready 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):
- 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-inlineSroaManaged(sroa/managed.rs) also splits objects reached through several locals or holding reference fields, using escape analysis (analysis/escape.rs). - Clear-and-reuse —
List/Map/Set.clearkeep capacity and zero live slots in place (no capacity-sized realloc). Preferclear+ refill overneweach batch. - Arena — mixed unmanaged raw allocations in stable blocks;
reset()retains blocks for reuse andfree()releases manually owned storage (system.core). - Contiguous / SOA hot paths — regex is PCRE2; keep other stdlib hot loops on dense arrays.
thread queues, reused mark/caps buffers, and
Buffer.elems_copyfor capture clones. - Ownership discipline — sink-default +
borrow+ field-store use-after-move (seeownership.md). - Inferred unique region — a fresh, unobserved
x = f(); … Release xgraph whose callee onlyNewsdel-free classes is allocated from a TLS bump slab and rewound in O(1) (dream_region_enter/leave). Silent; not user@stack. - 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 fordel, weak/unowned fields,shared, large objects, recursion, region openers, and async. Also silent; the user-facing non-goal of@stackstands. - 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_sourceonly recognizes locals), so a read-out element and its slot are both valid owners. Proven:Debug.ref_count= 3 afterarr[0] = w; let x = arr[0]withwalive. - 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.reallocreleases 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.