Contents

Memory management model (mere)

A summary of Mere's memory-management strategies, how mere currently handles them, and what's planned. Deeper design notes live in separate internal notes.


1. Comparison of memory-management strategies

StrategyWhen is memory freed?ExamplesStrengthsWeaknesses
Manual (malloc/free)The programmer calls free each timeCMaximum controlFrequent use-after-free / leaks
GCA runtime garbage collector decidesOCaml, Java, Go, PythonSafe, easyPause times, memory overhead, poor for real-time
Ownership (move + borrow)Automatically at scope exitRustCompile-time guarantees, zero costCyclic / self-references are painful; learning curve
Region (bulk per-region)The whole region scope is freed at onceCone, Vale, Cyclone, ML/Talpin researchFast alloc / bulk free; cyclic refs OKRequires designing region lifetimes
Stack-onlyAt stack-frame exitC autos, Rust letZero costStrict size/lifetime constraints

Mere is designed to let you mix and match: the programmer chooses a strategy explicitly, and the compiler verifies safety.


2. Mere's memory strategies (5)

Based on the design note 01_memory_model.md:

owned T — sole ownership

A value has one owner; freed when the owner leaves scope. Equivalent to Rust's T.


let x: owned String = String.from("hello")
let y: owned String = x    // move; x is no longer usable

&borrowed T — borrow

Pass a reference without transferring ownership. Equivalent to Rust's &T / &mut T, except Mere plans to refine the borrow annotations (&shared write etc.; design Q-004).

region R { ... } — bulk per-region

Values placed in region R are freed all together when R is destroyed. Bump-allocator backed. Only Trivial types (no Drop) can live in a region. Unified with `arena` under Q-008.

view V[R] of T — self-referential views (Q-009)

A "bundle type" built inside a region: immutable, non-moving. Lets you express self-references without unsafe.


view DocumentView[R] of Document {
  own:    &R Document,
  tokens: &R [&R str],    // points into own.text
}

stack { ... } — stack-only

Guarantees the value lives only on the stack. No heap allocation.


3. Why region (in detail)

Problems it solves

Areas where Rust's ownership struggles:

Region resolves these via bulk-free per region.

How it works


region R {
  // R is a bump allocator (just a pointer + size)
  let a = R.alloc(Node {...})    // one pointer bump
  let b = R.alloc(Node {...})    // ditto
  a.next = b                      // references freely valid inside the region
  b.next = a                      // cycle is fine
  // ... computation ...
}  // The whole region's memory is freed at once — no individual destructors

Typical uses

The Trivial constraint

Only "types without Drop" (Trivial) can be placed in a region. Why: bulk free without invoking individual destructors. Types that have Drop (DB connections, file handles, etc.) are managed separately via with (Q-011 resolved):


with db = Database.connect(...) in
  region R {
    let nodes = ...   // many allocations into R
    process(db, nodes)
  }
  // R is destroyed (Trivial only; no destructors)
// db.drop() runs (it has Drop, so it's released individually)

3.5 Region blocks reclaim values (v0.1.30–31, implemented)

The "one request = one region" use case above is no longer aspirational — as of v0.1.31 it is the implemented semantics on the C backend:

and variant nodes allocate in a thread-local current region. A region R { ... } block makes itself current for its body, so everything the body allocates is reclaimed when the block exits. Container structs (Vec / Map / StrBuf) stay in their own binding region — they carry identity and must not die with a scratch block.

used to be allocated in the default (program-lifetime) region for one reason: a closure's env is a type-erased void*, so nothing could copy it out of a dying block, and a permanent allocation was the only safe answer. A closure now carries a third member beside env and fn — a copy function generated per env type, which region-allocates a fresh env and deep-copies each captured field through that field's own __mcopy_. __mcopy for an arrow calls it, so a closure leaves a region block the way every other value does. A closure with no captures (env == NULL) and an FFI adapter holding a borrowed pointer leave copy zero and are copied shallowly, exactly as before.

env struct starts with {__lang_region* __r; void* (*__copy)(region*, void*);}, and __mcopy for an arrow reads that header through the void* it already has. So the closure struct stays {env, fn} — two pointers, for every program. That matters because a closure is passed by value through every frame of an interpreter's dispatch: a third pointer per frame took a 512 MB-stack interpreter over the edge on a deeply recursive program, and ld caps -stack_size at 512 MB on arm64, so there is nothing to buy it back with. The __r field also makes copying idempotent: copying an env into the region it is already in returns it unchanged, so a chain of envs capturing closures does not copy without bound, and a shared env keeps its identity instead of becoming two mutable states. An FFI adapter's env is a real struct too — a header plus the borrowed pointer, in the default region, with __copy = NULL meaning "do not copy me". One field answers both questions, and every closure with a non-NULL env can be asked.

(per-type deep copy, specialized like the show/== derive family), so returning a value from a block is always safe. A container cannot be a block result — the typer's region-escape check rejects it.

deep-copies the key and value into the map's own region; vec_push / vec_set copy the element. A stored value therefore outlives whoever stored it, no matter where it was allocated. Strings are immutable, so the copies are unobservable.

the container's region is bump-allocated, vec_set/map_set copying a new string into a slot that already held one cannot reclaim the old copy — so a hot loop that overwrites the same slot with fresh strings grows O(writes), not O(1) (measured: 4M vec_set of a fresh string into index 0 ≈ 550 MB). Scalar (int/bool) elements are unaffected (their "copy" is a no-op). Eliding the copy for a value already resident in the container's region needs type-level region tracking on str (deferred); until then, prefer scalar slots for hot-overwrite loops, or a fresh per-batch region. (map_set on an existing key copies only the value, v0.1.77, so a counter keyed by repeated strings stays O(distinct keys).)

deep-copies into a malloc-backed message region; channel_recv copies out into the receiver's current region and frees the message. A sender's scratch region can die while the message is in flight.

region runs at constant ~1.5 MB RSS over 8M lines (previously 246 MB); a Redis-wire KV server with per-command regions holds flat RSS under sustained load.

per-line region loop so a tool does not have to hand-roll it: import "contrib/stream/stream.mere"; Stream.each_line path cb (side effect per line) and Stream.count_lines path pred keep peak memory at O(longest line) — measured 1.4 MB vs 61.7 MB for the naive loop on a 57 MB file. The callback must not let the line escape (storing it into an outer container deep-copies it out and reintroduces O(file) growth); this is the ergonomic path a streaming line tool wants.

Backend note: the interpreter is GC-backed (same value semantics, memory behaviour trivially fine). The Wasm backend reclaims region blocks as of v0.1.37 (mark on the value stack + result copy-out via $__mcopy_), with one honest difference from C: there is no per-container storage, so instead of copy-on-store, escaping stores are compile errors — pushing a heap value into a container created outside the block, map_set / strbuf_push on outer containers, channel_send, spawn, and closure-registering externs are rejected inside a region block (containers created inside the block mutate freely and die with it). Measured: the playground 2048 with a per-move region holds its bump pointer constant across 30,000 moves.

The default (program-lifetime) arena grows by chaining blocks rather than aborting when it fills: when an allocation would overrun the current block, a geometrically larger block is malloc'd and chained on, and blocks never move so existing pointers stay valid. The C backend gained this in v0.1.25 (a long-running server exhausted the old fixed-cap arena); the LLVM backend gained the same bounds-check-and-grow in v0.1.82 — before it, its __lang_region_alloc was a pure bump with no bounds check, so an allocation-heavy program (e.g. a per-pixel renderer) silently overran the 4 MB arena and crashed while interp/C/Wasm agreed. Guarded by test/parity/region_growth.mere (a checksum over ~6 MB of live region allocations, matching across all four backends).


4. Current state in mere (as of 2026-06-24, Phase 46)

The "Phase 2" section below is a record of the first implementation slices (the region/view syntax layer). Phase 11 → 31 implemented the 4 borrow modes (`&R T` / `&mut R T` / `&shared write R T` / `&exclusive R T`) + borrow checker + `with` Drop integration + the 4 Q-010 collections (`Vec` / `OwnedVec` / `StrBuf` / `Map`) + 4-backend codegen (interp + C / LLVM / Wasm) parity. Details: language-reference.md §3 region/view/with / codegen.md §4.

Added in Phase 36 (2026-06-22): a narrow value restriction in the typer. let v = map_get m k in ...-style let-binds through mutable containers are no longer generalized (so 'a doesn't leak). Details:

This is a narrowed variant of ML's standard value restriction (limited to types involving mutable containers) — ordinary fn lets like let inc = fn x -> x + 1 remain polymorphic.

Added in Phase 38.G-1 (2026-06-22): automatic scope-bound Drop for let v = owned_vec_new () in body (Level 1). Implements N1 of the N1/N2/N3 decomposition from the 39_nll_linear_design.md design notes:

This is a compromise from Mere's "explicitness > brevity" philosophy: explicit with is still supported and recommended (for custom Drop types), and the typical OwnedVec pattern (build → query → return scalar) gets auto-Drop.

What works (Phase 2: syntax + value expressions + escape check + view declarations + region-enforced construction + field-access region propagation)


> region R { 42 }
- : int = 42

> fn (x: &R int) -> x
- : (&R int -> &R int)

> region R { let x = &R 5 in 42 }
- : int = 42                              // &R used inside, but result is int → OK

> region R { &R 5 }
ERROR: region escape: `&R int` cannot leave region `R`

> region R { region S { 100 } }            // nesting OK
- : int = 100

> view Node[R] of int { value: int, next: int };
  region R { let n = Node { value = 1, next = 0 } in n.value }
- : int = 1                                // view construction is only inside a region

> view Slot[R] { item: &R int };
  region S { let s = Slot { item = &S 42 } in 100 }
- : int = 100                              // R is substituted to S

> view Node[R] of int { value: int };
  let n = Node { value = 1 } in n.value    // outside any region
ERROR: view Node must be constructed inside a region block

What doesn't work yet

Why "syntax only"

mere is a tree-walking interpreter written in OCaml, and in interpreter mode the actual memory management is done by OCaml's GC.

In Phase 4 codegen (C output), region becomes a real bump allocator (achieved 2026-06-18, Phase 4.17). region R { body } initializes the C runtime's __lang_region; &R v (sugar for R.alloc(v)) bump-allocates inside the region and returns T; leaving the scope releases it all at once. Combined with the typer's escape check, leaving a region scope frees memory while the type signature guarantees no `&R T` value has leaked. Details in [codegen.md](codegen.md)'s Phase 4.17.


5. View types — self-referential / cyclic structures inside a region

A region is "a box that shares the same lifetime"; we also need a way to safely express structures that point at each other inside (graphs, linked lists, ASTs, JSON trees, etc.). That's what view types are for.

Motivation: weak spots of ownership


// In Rust this is nearly unwritable: two mutually-referencing nodes
let a = Node { value = 1, next = ??? }  // want ??? to be b
let b = Node { value = 2, next = a }    // but a should also point to b

In ownership-based languages, cyclic references are fundamentally hard (Rc>, unsafe, custom arenas, etc.). Inside a region, everyone shares the same lifetime, so cycles are fine. View types capture this "in-region relational structure" as a type.

Three axioms (Q-009 paper-validated)

AxiomMeaning
immutableView values cannot be modified after construction; cannot be reassigned.
region-scopedAlways tied to some region R; cannot leave R (the type bakes in [R]).
structural identity by regionSame-typed views inside the same region are identified — this is the basis on which cyclic references work safely.

Difference from records


type Point = { x: int, y: int };       // ordinary record: lifetime via GC; region-independent
view Node[R] of int { value: int };    // view: bundle type tied to region R

Why "view"?

The physical layout (the inner type of T) and what the programmer manipulates (the Node type) are viewed as different things. Enables expressions like "internally a sequential int, but viewed as a Node struct" (a planned future feature).

Current state (Phase 2.4, 2026-06-17)

View construction is restricted to inside a region block; the region parameter R at the declaration site is substituted with the innermost active region's name at construction time; and the view value's type itself carries the region tag as `Name[R]`. Field accesses / record updates propagate the region, and the view value is subject to escape checking.


view Node[R] of int { value: int, next: int };
region R { let n = Node { value = 1, next = 0 } in n.value }    // 1

view Slot[R] { item: &R int };
region S { 
  let s = Slot { item = &S 7 } in
  s.item                                                         // : &S int (R → S propagation)
}                                                                // ERROR: &S int would escape

region S { 
  let s = Slot { item = &S 7 } in
  let take_s = fn (x: &S int) -> 99 in
  take_s s.item                                                  // 99 (s.item is &S int)
}

region S { Cell { v = 1 } }    // ERROR: Cell[S] cannot leave region S
let n = Node { ... }            // ERROR: must be inside a region block

Tightening planned for later phases

The detailed design lives in the internal design notes (Q-009 resolved).


6. Roadmap

Phase 2 (medium-sized, ~600-800 LoC, multiple slices) — in progress

Phase 3 (larger; some design needs to be revisited)

Current state of borrow modes and concurrency safety (2026-06-23 update)

The syntax and borrow checker for all 4 borrow modes are complete (Phase 11-17). However, with no concurrent backend yet, type-level requirements that T be "internally safe" (Rust's Send/Sync) for &shared write R T are not yet enforced:

ModeConcurrency-safety requirement (future)
&R T (default = shared read)Safe — read-only access on immutable T
&shared write R TRequires: T is internally safe (atomic / Mutex etc.) — not yet enforced
&exclusive R T (= exclusive read)Safe — single-thread access only
&mut R T (= exclusive write)Safe — single-thread access only

Concurrent-backend prerequisites are collected in DEFERRED §2.4. Currently Mere is single-threaded interpreter + 3 single-threaded backends (C / LLVM / Wasm); &shared write is only a syntactic distinction and runtime-wise behaves like a plain &R T pointer. Introducing a concurrent backend (e.g. via OCaml domains / Wasm threads) is the trigger for adding Send/Sync-equivalent type bounds for T.

Phase 4 (codegen)

In progress — see codegen.md.


7. Design context (in detail)

Specific design decisions live in the internal design notes:

DocContentStatus
00_design_principles.mdMere's philosophy and assumptions
01_memory_model.mdOverview of the 5 strategies
02_json_parser_example.mdRegion's canonical use case
03_lifetime_and_mutability.mdLifetime subtyping
04_fundamental_tradeoffs.mdStaged annotations, etc.
08_effect_granularity.mdQ-004 borrow refinementnarrowed
11_region_vs_arena.mdQ-008 unificationresolved
12_drop_and_with.mdQ-011 Drop orderingresolved
13_region_std_types.mdQ-010 region std typesnarrowed
14_view_types.mdQ-009 view-type axiomsresolved

8. Academic roots

ReferenceContent
Tofte & Talpin (1997)"Region-Based Memory Management" — the foundational region calculus
Cyclone (2002)A research language that extends C with lifetimes and regions
Cone (2018-)A modern language with region as a primitive
Vale (2020-)Region + generational references
Mike Acton et al."Data-Oriented Design" — practical examples of frame-arena patterns

Bottom line: Mere is designed to map memory lifetime onto program structure — looser than ownership but stricter than GC; permits cycles; predictable. Currently mere is at Phase 1 (syntax-only); the real power emerges in Phase 2+'s static checks + codegen.