Design-first deliverable for iteration 7b (no runtime/compiler code yet). docs/examples/gc-cycle explains, by example, how pointers flow through the heap and the collector's mark-sweep logic: - types.wo: Node (self-referential ?Node -> inferred `gc`/traced) vs Segment (acyclic -> `owned`, deterministically dropped) - main.wo: ring_demo builds a->b->c->a and abandons it; owned_demo shows the drop path with no collector - README.md: the model (ownership frees the 99%, tracing only the cyclic/ aliased residue, inference decides), the 16-byte header rewrite (retire rc+borrow -> 8-byte sweep-list link, colors in flag bits), where a traced pointer lives (root via pc gc-mask / GCREF field / container), and the tri-color incremental algorithm with the Yuasa deletion barrier. Two mermaid step diagrams (heap+roots, collector cycle) + the owned contrast. Grounded in the approved spec (2026-08-11-inferred-gc-mark-sweep-design.md) and the real runtime structures (obj.h/wob.h: wo_hdr, WO_K_GCREF, arena, wo_obj_size). Run status: honest — the sample does NOT build today; woc reports WO-E301 (use-after-move at the ring-closing store), which is exactly the aliasing that "traced classes alias freely" unblocks under 7b. README records this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| main.wo | ||
| README.md | ||
| types.wo | ||
| wo.toml | ||
gc-cycle — inferred GC + incremental mark-sweep, by example
The smallest program that needs a tracing collector (ring_demo) and the
smallest that does not (owned_demo). This README defines how pointers/
addresses flow through the heap and the collector's mark-sweep logic, the
way iteration 7b specs it
(2026-08-11-inferred-gc-mark-sweep-design.md).
The one-line model: ownership frees everything it can; the collector traces only the residue ownership cannot free — cycles and long-lived aliases — and the compiler infers which types those are.
1. Inference — the compiler decides owned vs gc
A pass between types and owner (compiler/src/gcinfer.ml) builds a
class-reference graph — an edge A → B whenever A has a field of type
B, ?B, multi B, map<B,_>, or map<_,B>. ref B creates no edge (it
is a row id, Copy). Tarjan's SCC over that graph: any class in a non-trivial
SCC, or with a self-loop, is traced (gc). Everything else is owned.
For this sample, woc --dump-gc would print:
Node gc (cycle Node -> Node)
Segment owned
Node.next: ?Node is the self-loop → Node is traced, and its next field
gets kind WO_K_GCREF. Segment has no class-typed field → no edge → owned,
freed deterministically. A second, demand half promotes a type when the
ownership pass would otherwise report a long-lived-alias escape (the
PriceCache shape) — not exercised here; every promotion is reported, never
silent.
2. Address flow — where a pointer lives, and the object's shape
Every heap object is a 16-byte wo_hdr followed by field_cnt 8-byte slots
(wo_fields(o); wo_obj_size = 16 + field_cnt*8). Allocation is the arena:
bump + 16-byte size-class free lists (16…1024), larger falls to malloc. The
arena keeps no size headers and cannot enumerate objects — so traced objects
thread an intrusive sweep list.
The header is where iteration 7b pays for the collector with zero growth:
today (RC) under 7b (tracing)
┌──────────────────────────┐ ┌──────────────────────────┐
│ class_id (4 bytes) │ │ class_id (4 bytes) │
│ flags (1) + pad │ │ flags (1) ── 2 color bits (white/grey/black), + pad
│ borrow (4 bytes) │ │ sweep-list link (8 bytes)│ ← reclaimed from
│ rc (4 bytes) │ │ (next traced object) │ borrow + rc
└──────────────────────────┘ └──────────────────────────┘
16 bytes 16 bytes (unchanged)
rc is retired (no reference counting); traced objects are exempt from borrow
rules so borrow is dead too — the two adjacent 4-byte words become one 64-bit
list link. Color lives in 2 existing flag bits (WO_F_COLOR).
A traced pointer (the address of a wo_hdr) is only ever held in one of three
places, and these are exactly what the collector reads:
| Holder | How the collector sees it |
|---|---|
a VM value-stack / frame slot (a local like a) |
a root, via the per-pc gc-mask the emitter already emits |
a GCREF field slot of another object (a.next) |
followed during mark |
a container item (multi/map) whose element kind is GCREF |
followed during mark |
Here is the sample's heap after ring_demo builds the ring, while a/b/c
are still live roots:
flowchart LR
subgraph STACK["VM value stack (roots this pc, from the gc-mask)"]
A["a"]:::root
B["b"]:::root
C["c"]:::root
end
subgraph HEAP["arena (one mmap region)"]
NA["Node a<br/>hdr, label(TEXT), next(GCREF)"]
NB["Node b<br/>hdr, label(TEXT), next(GCREF)"]
NC["Node c<br/>hdr, label(TEXT), next(GCREF)"]
end
A --> NA
B --> NB
C --> NC
NA -->|next| NB
NB -->|next| NC
NC -->|next, closes cycle| NA
NA -.->|sweep link| NB
NB -.->|sweep link| NC
NC -.->|sweep link| NULL(("nil"))
classDef root fill:#2b6,stroke:#083,color:#fff;
Solid arrows are GCREF pointers the mark phase follows; the dotted chain is
the per-shard traced list the sweep phase walks (independent of
reachability). When a/b/c leave scope, the three solid root arrows
vanish — the ring still points to itself, but nothing points in, so it is
unreachable yet un-freed. Ownership cannot help: b cannot be owned by both
let b and a.next. That is the collector's entire job.
3. Mark-sweep logic — tri-color, incremental, with a deletion barrier
Marking runs in budgeted slices (WO_GC_BUDGET objects per slice), so the
pause is bounded regardless of heap size. Colors: white = unproven (candidate
to free), grey = reachable but children not yet scanned, black = reachable
and scanned.
flowchart TD
ALLOC["allocate traced object<br/>color = WHITE, link into traced list"] --> LIVE
LIVE["mutator runs<br/>(program executes)"] --> TRIG{"traced bytes since last cycle<br/>past heap goal?"}
TRIG -- no --> LIVE
TRIG -- yes --> ROOTS["START CYCLE<br/>shade every root GREY<br/>(value/frame slots via pc gc-mask)"]
ROOTS --> SLICE
SLICE["MARK SLICE (budgeted)<br/>pop a GREY object,<br/>scan its GCREF fields +<br/>owned subtrees that may reach a gcref,<br/>shade each WHITE child GREY,<br/>then paint this object BLACK"] --> GREY{"grey set empty?"}
GREY -- "no (budget hit)" --> SAFE["yield at next safepoint<br/>(loop back-edge / call)"]
SAFE --> LIVE2["mutator resumes<br/>(barrier active)"]
LIVE2 --> SLICE
GREY -- yes --> SWEEP["SWEEP: walk traced list —<br/>WHITE: free (wo_obj_size) and unlink;<br/>BLACK: repaint WHITE, keep"]
SWEEP --> DONE["cycle done"] --> LIVE
Roots. The value stack and frame stack, read through the per-pc gc-mask the emitter already produces (the drop-table's gc bits, reinterpreted from "rc_dec these on unwind" to "these registers are GC roots at this pc"). No stack scanning, no native frames — a bytecode VM with an explicit value stack has the map for free.
Owned objects are traversed, never freed. An owned object can hold a
GCREF field, so mark must walk through owned subtrees to reach traced
objects — but it never frees an owned object (drop owns those). A precomputed
class-table bit, "transitively contains a gcref", lets mark skip any owned
subtree that can reach no traced object at all.
The barrier — why incremental is safe. Between slices the mutator keeps
running and can hide a live object from a half-finished mark: store a white
object into an already-black object, then drop the original grey/white
reference to it. A Yuasa deletion barrier closes this: on any store into a
GCREF slot while marking is active, shade the slot's old value grey
before overwriting it. In the sample, a.next = b (and the ring-closing
c.next = a) go through the store paths SETF/map_set/push where the
barrier lives — no new opcode, because SETF already resolves the field kind
from the class table. Owned stores, scalars, and Text pay nothing. Reading a
shard's roots fresh from its masks each slice is what removes Go's Dijkstra
insertion-half and the stack rescan.
Trigger & budget. A cycle starts when the shard's traced bytes since the
last cycle cross a heap goal; each slice marks at most WO_GC_BUDGET objects;
WO_GC_TRACE prints freed/marked counts per slice. Collection is per-shard
— ownership moves mean no traced object spans shards, so there is no global
stop-the-world and no cross-shard tracing.
4. The owned path, for contrast (no collector at all)
owned_demo builds a Segment. At the closing } the drop table lists its
register in the owned mask; the VM frees the object and its Text field
deterministically and immediately. No color, no list link, no barrier, no
slice. This is the common case, and log-watcher proves it scales: 35 classes,
zero traced, arena + deterministic drops end to end.
flowchart LR
NEW["let s = Segment (from, len)"] --> USE["use s"] --> SCOPE["scope end"]
SCOPE --> DROP["DROP (owned mask):<br/>free s.from (Text), free s"]
DROP --> GONE["reclaimed — collector never involved"]
So a .wo program has two reclamation systems working together: ownership
(deterministic, free, the 99%) and tracing (only the cyclic/aliased residue,
inferred). The developer writes no memory annotations for either.
Run status
This is a target sample for iteration 7b, which is not yet implemented. It
does not build on today's toolchain — and the compile error is precisely
the motivation. woc --emit docs/examples/gc-cycle today reports:
main.wo: error WO-E301: use of `a` after it was moved
c.next = a; <- `a` moved here
Under the current model a class instance is owned and single-owner, so storing
b into a.next moves it and closing the ring with c.next = a re-uses a
moved value. Iteration 7b classifies Node as traced — and traced classes
alias freely (spec §3 rule 5), so the ring becomes legal and the collector,
not ownership, reclaims it. The pieces still to build: the inference pass
(gcinfer.ml), --dump-gc, the .wob opcode-27/28 retirement, the sweep list,
and the incremental collector. Today's runtime still uses RC + a Bacon–Rajan
cycle collector behind an explicit @gc annotation (runtime/src/gc.c).
When 7b lands, the acceptance is:
woc --dump-gc docs/examples/gc-cycleclassifiesNode gc (cycle …)/Segment owned.ring_demoprintsring a -> b -> c -> a, and after the roots die the ring is collected within budgeted slices (observable viaWO_GC_TRACE), ASan-clean after repeated cycles.- The adversarial barrier fixture (hide a node between slices) frees the node when the barrier is compiled out and keeps it when it is in.
Files: types.wo (the two shapes), main.wo (the two
demos), wo.toml.