From 77f33962c4e8a040a7b10a7b4bbf944b1fb7897e Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Tue, 18 Aug 2026 14:14:50 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20gc-cycle=20sample=20=E2=80=94=20inferre?= =?UTF-8?q?d=20GC=20+=20mark-sweep=20address/pointer=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/examples/gc-cycle/README.md | 214 +++++++++++++++++++++++++++++++ docs/examples/gc-cycle/main.wo | 40 ++++++ docs/examples/gc-cycle/types.wo | 22 ++++ docs/examples/gc-cycle/wo.toml | 11 ++ 4 files changed, 287 insertions(+) create mode 100644 docs/examples/gc-cycle/README.md create mode 100644 docs/examples/gc-cycle/main.wo create mode 100644 docs/examples/gc-cycle/types.wo create mode 100644 docs/examples/gc-cycle/wo.toml diff --git a/docs/examples/gc-cycle/README.md b/docs/examples/gc-cycle/README.md new file mode 100644 index 0000000..dcee299 --- /dev/null +++ b/docs/examples/gc-cycle/README.md @@ -0,0 +1,214 @@ +# 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`](../../superpowers/specs/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`, 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: + +```mermaid +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
hdr, label(TEXT), next(GCREF)"] + NB["Node b
hdr, label(TEXT), next(GCREF)"] + NC["Node c
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. + +```mermaid +flowchart TD + ALLOC["allocate traced object
color = WHITE, link into traced list"] --> LIVE + LIVE["mutator runs
(program executes)"] --> TRIG{"traced bytes since last cycle
past heap goal?"} + TRIG -- no --> LIVE + TRIG -- yes --> ROOTS["START CYCLE
shade every root GREY
(value/frame slots via pc gc-mask)"] + ROOTS --> SLICE + SLICE["MARK SLICE (budgeted)
pop a GREY object,
scan its GCREF fields +
owned subtrees that may reach a gcref,
shade each WHITE child GREY,
then paint this object BLACK"] --> GREY{"grey set empty?"} + GREY -- "no (budget hit)" --> SAFE["yield at next safepoint
(loop back-edge / call)"] + SAFE --> LIVE2["mutator resumes
(barrier active)"] + LIVE2 --> SLICE + GREY -- yes --> SWEEP["SWEEP: walk traced list —
WHITE: free (wo_obj_size) and unlink;
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. + +```mermaid +flowchart LR + NEW["let s = Segment (from, len)"] --> USE["use s"] --> SCOPE["scope end"] + SCOPE --> DROP["DROP (owned mask):
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-cycle` classifies `Node gc (cycle …)` / + `Segment owned`. +- `ring_demo` prints `ring a -> b -> c -> a`, and after the roots die the ring + is collected within budgeted slices (observable via `WO_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`](types.wo) (the two shapes), [`main.wo`](main.wo) (the two +demos), [`wo.toml`](wo.toml). diff --git a/docs/examples/gc-cycle/main.wo b/docs/examples/gc-cycle/main.wo new file mode 100644 index 0000000..66b80fb --- /dev/null +++ b/docs/examples/gc-cycle/main.wo @@ -0,0 +1,40 @@ +-- gc-cycle — the smallest program that needs a tracing collector, and the +-- smallest that does not. Iteration 7b's target sample (see README). +-- +-- ring build a 3-node cycle, abandon it, let a collection slice reclaim it +-- owned build a Segment, show it dies at scope end with no collector at all + +fn main(args: multi Text) -> Int { + if len(args) >= 1 and args[0] == "owned" { return owned_demo(); } + return ring_demo(); +} + +-- The cyclic case. a -> b -> c -> a. Every Node is reachable from `a` while `a` +-- is a live root (on the value stack). The moment `a` leaves scope the whole +-- ring becomes unreachable but is NOT freed by any drop — ownership cannot +-- reclaim a cycle. The next marking slice finds no root reaching the ring, so +-- all three sweep white and are freed together. +fn ring_demo() -> Int { + let a = Node { label: "a", next: nil }; + let b = Node { label: "b", next: nil }; + let c = Node { label: "c", next: nil }; + + a.next = b; -- store into a GCREF slot: barrier-relevant while marking + b.next = c; + c.next = a; -- closes the cycle; c.next aliases the same Node as `a` + + print("ring ${a.label} -> ${a.next.label} -> ${a.next.next.label} -> ${a.next.next.next.label}"); + -- prints: ring a -> b -> c -> a + -- `a`, `b`, `c` go out of scope here. No DROP frees the Nodes (they are + -- traced, not owned). The ring is now abandoned; a later slice collects it. + return 0; +} + +-- The acyclic case, for contrast. Segment is `owned`: at the `}` the drop table +-- lists its register in the OWNED mask, the VM frees the object and its Text +-- field deterministically, and the collector never sees it. +fn owned_demo() -> Int { + let s = Segment { from: "auth.log", len: 4096 }; + print("segment ${s.from} len=${s.len}"); + return 0; -- `s` (and its Text) freed here by DROP, no tracing +} diff --git a/docs/examples/gc-cycle/types.wo b/docs/examples/gc-cycle/types.wo new file mode 100644 index 0000000..7d42d30 --- /dev/null +++ b/docs/examples/gc-cycle/types.wo @@ -0,0 +1,22 @@ +-- Two shapes, two memory strategies — decided by the compiler, not the author. +-- +-- Node has a field whose type is Node (`?Node`), so the class-reference graph +-- has an edge Node -> Node: a self-loop. Tarjan's SCC over that graph puts Node +-- in a non-trivial cycle, so the inference pass classifies it `gc` (traced). +-- Its `next` field therefore has kind WO_K_GCREF. A ring of Nodes cannot be +-- expressed with ownership alone (one node would need two owners), which is the +-- whole reason a tracing collector exists. +class Node { + label: Text + next: ?Node -- edge Node -> Node => self-loop => inferred `gc` +} + +-- Segment holds only a Text (owned) and an Int (scalar). No field's type is a +-- class, so it has no outgoing edge in the class-reference graph: it can never +-- be part of a cycle. Inference classifies it `owned` — deterministically freed +-- at scope end by the per-function drop machinery, never touched by the +-- collector. +class Segment { + from: Text + len: Int +} diff --git a/docs/examples/gc-cycle/wo.toml b/docs/examples/gc-cycle/wo.toml new file mode 100644 index 0000000..a172d30 --- /dev/null +++ b/docs/examples/gc-cycle/wo.toml @@ -0,0 +1,11 @@ +name = "gc-cycle" +version = "0.1.0" +description = "Iteration 7b target sample: inferred GC + incremental mark-sweep — a cyclic type (traced) vs an acyclic one (owned)" + +[runtime] +wo = ">= 0.1" + +# `woc ` builds target/gc-cycle. No runtime path pinned (portable); an +# installed woc self-locates wovm, an in-repo build passes WO_RUNTIME. +# NOTE: this sample targets iteration 7b (inferred GC). It does not build on +# today's toolchain — see README "Run status".