Menu

Refcount + copy-on-write — why this model

Status: shipped. Rationale for refcounting with copy-on-write instead of a tracing GC.

Rationale for Manticore's memory model. The layout and encoding live in memory-abi.md; this doc is the argument for the design, kept because the question "why not a GC?" keeps coming back.

Trimmed from the former docs/bootstrap/10-refcount-cow-design.md. Its alias-site audit and six-phase implementation plan were an audit of the deleted AST backend — every file they cited (Assignment.php, CallSites.php, ObjectDispatch.php, StmtCompile.php, ExceptionRuntime.php) is gone, and the work shipped years of commits ago. §5 below says what replaced them.

Goal#

Predictable, deterministic memory management for compiled PHP: match Zend's refcount + CoW semantics so user code observes correct value-vs-reference behaviour, then use escape analysis to elide most of the refcount traffic for non-escaping locals.

Why refcount + CoW — not GC, not arenas, not Boehm#

Rejected:

Layering#

Layer 1: refcount + CoW        ← the foundation
Layer 2: cycle collector       ← correctness for cycles; manual-trigger today
Layer 3: escape analysis       ← the biggest perf win; elides most refcount ops
Layer 4: arena / stack alloc for non-escaping allocations

Each layer is independent, and 3–4 are optimisations over a correct layer-1 baseline. All four are in the tree; layer 2 is the least finished (see docs/ROADMAP.md).

The three primitives#

CoW invariant: every mutating helper calls cow on the buffer before touching it, so mutation only ever lands on a singly-owned buffer and aliases stay invariant.

The two ownership transitions#

Every site where a pointer crosses a slot boundary is one of:

In practice transfer is return $x and a few internal moves; almost everything else is an alias.

Scope-exit rule: a local that owns a buffer contributed +1, and every exit path must release it — explicit return, fall-through, throw, and re-entry through a catch handler. return $x drops $x from the owned set first, so its reference transfers to the caller instead of being over-released.

What actually implements this#

Not an expression predicate — a MIR pass chain:

InferEffects → InferAllocKind → ApplyMemoryMode → SpillFreshBases → InsertMemoryOps → OwnershipFlow → Verify

InferAllocKind is layers 3–4: it decides per allocation whether it can be arena-allocated, must be heap-refcounted, or needs no refcount at all. ApplyMemoryMode applies the strategy selected by --memory / MANTICORE_MEMORY (hybrid by default, rc, arena). InsertMemoryOps plants the actual calls.

Conditional expressions are the one place where "who owns the result" cannot be read off a single node, because the arms can disagree. ?:, ??, ternary and match therefore share one predicate — Compile\Mir\CondOwn — consulted by both the pass and the emitter. Fixing only the emitter leaks; fixing only the pass double-frees.

Escape hatch#

#[Struct] opts a class out of the whole model: no header, no retain / release, no cycle-collector hook, fully static dispatch. It is the right answer for POD value types and is documented in ../attributes.md.

There is no #[NoRefcount] attribute, despite what older design notes proposed. AllocationKind::NoRefcount is an escape-analysis verdict produced by InferAllocKind, not a user-facing marker.

Edit this page on GitHub