writeonce/docs/superpowers/specs/2026-08-11-inferred-gc-mark-sweep-design.md
shoney.arickathil 79605cbb4f feat: milestone 1 complete — .wob emitter, conformance corpus, single binary; GC redesign specced
- `woc` now emits `.wob` that `wovm` runs: emit.ml lowers the typed,
  owner-annotated AST (scope-stack registers with a >64 WO-E401 diagnostic,
  Lua-style call windows, ICALL by slot, dedup const pool, drop maps, line
  tables, implicit terminators); disasm.ml backs `--dump-bc` goldens.
- Ownership lowering consumes the four owner tables verbatim; RESIDUAL is the
  only source of borrow ops, coalesced per operand. Review caught the emitter
  consuming only 2 of owner.ml's 4 residual producers — an assignment-anchored
  aliasing violation ran to exit 0 instead of trapping; fixed, plus a backstop
  raising WO-E404 for any residual region left unconsumed.
- Conformance harness `scripts/oop-e2e.sh` (`just oop-e2e`): four fixture
  kinds with exact outcomes — byte-exact stdout, one WO-E### anchored on
  `error CODE:`, numeric trap code, gc trace. 25 fixtures incl. pricing-demo
  logic, the ownership suite, and DB_STUB's parse-but-trap. `tests/` un-ignored
  so the corpus is actually tracked.
- `woc build` produces a self-contained binary: wovm copy + appended image +
  20-byte trailer, self-exec via /proc/self/exe. Verified relocated outside
  the repo, argless, and against adversarial trailer corruption.
- Milestone 1's five spec criteria all MET (`just oop-accept`). Criterion 3
  closed by WO-E405 — the entry must return `Int`, since program mode already
  says its return value is the exit code — which deletes the leak class
  without adding return-type metadata to the format. `gc/held-cycle` retired:
  an externally-held cycle is not expressible in a post-exit pump.
- New spec: inferred GC + incremental per-shard tri-color mark-sweep, retiring
  `@gc` and reference counting. Story gains iterations 7b (that work) and 9b
  (`@table`, relations, compiler-checked query); `.dev/reference` gains a
  sparse System.Linq checkout. Priority: 5→6→7 (log-watcher) then 7b, 8, 9, 9b.
2026-08-11 19:31:26 +02:00

275 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Inferred GC + incremental mark-sweep — design spec
**Date:** 2026-08-11
**Status:** approved design, pre-implementation
**Scope:** removing `@gc` as a developer-facing annotation, inferring GC-ness in the
compiler, and replacing reference counting with an incremental per-shard
tri-color mark-sweep collector
**Amends:** [`2026-08-01-oop-compiler-vm-design.md`](2026-08-01-oop-compiler-vm-design.md)
(decision table's GC-granularity row, §3 rule 5, §4 memory model),
[`../../00-principles.md`](../../00-principles.md) (principle 3),
[`../../plan/oop-vm/00-wob-format.md`](../../plan/oop-vm/00-wob-format.md) (opcodes 27–28, drop-table
contract, class flags), [`../../plan/oop-vm/01-error-catalog.md`](../../plan/oop-vm/01-error-catalog.md)
(WO-W201, WO-E304)
**Reference studied:** Go's collector, `.dev/reference/go/src/runtime/mgc.go` and neighbours
## Motivation
`@gc` asks the developer to answer a question the compiler is better placed to
answer: does this type need tracing? Worse, the annotation is not even
sufficient — the OOP spec's own example, `@gc class PriceCache { entries:
map<SKU, Money> }`, is acyclic. It needs GC because it is a shared cache
aliased from many places, and second-class borrows cannot be stored or
returned, so a long-lived shared alias has nowhere else to live. So the
developer is being asked to reason about two different things at once: the
shape of their types *and* how those values will be aliased across the whole
program.
The reference-counting implementation makes this worse rather than better.
RC requires the compiler to emit a balanced acquire/release at **every** alias
site, and every recorded `@gc` defect in this repo is exactly that failure:
| Defect | Cause |
| --- | --- |
| `push(multi, gcVal)` emitted no `RC_INC` | unresolved builtin skipped the transfer path — use-after-free |
| `set(m, k, v)` still emits none (open) | same gap, never closed |
| `mut`-`@gc` argument didn't clobber its root | stale rc elision — use-after-free |
| `gc/held-cycle` leaks 1184 bytes | the entry return value's reference is never released, inflating rc forever |
Inferring GC-ness would *widen* that population and therefore widen that bug
class. Tracing deletes the category outright: the compiler emits no per-alias
bookkeeping at all.
And most of what tracing needs already exists. Go's collector depends on nine
pieces of compiler metadata; the load-bearing ones are precise per-PC pointer
maps and per-frame unwinding. The emitter already produces exactly that — the
drop table carries an owned-register mask *and* a gc-register mask at every
trap-capable pc, and the class table carries per-field kinds. Go's dependence
on OS threads is incidental, not load-bearing: what the algorithm actually
requires is the ability to suspend one stack and look up a PC→pointer map per
frame, which a bytecode VM with an explicit value stack satisfies without
native stacks. Non-moving matches the arena. The per-P coordination machinery
(ragged barrier, write-barrier buffers, work stealing, assist credit) exists
to run N mutators over one heap and is deletable in a per-shard design.
## Decisions locked during brainstorming
| Question | Decision |
| --- | --- |
| Scope | **One spec: infer *and* replace the collector.** Dropping `@gc` without replacing RC would widen the exact bug class that has already bitten four times. |
| What decides GC-ness | **Hybrid: structural + reported promotion.** Cycles from a whole-program SCC over the class-reference graph; long-lived aliasing promotes on demand at the site that would otherwise error. Every promotion is reported. |
| Collector cadence | **Incremental tri-color, budgeted slices, with a Yuasa deletion barrier.** Bounded pause regardless of heap size — which principle 3 already promises and the `budget-steps` fixture already asserts. |
| Barrier scope | Pointer stores into traced objects only, and only while marking is active. Owned objects, scalars and text pay nothing. |
| `@gc` in source | **Errors**, with a diagnostic pointing at inference. Accepting a now-meaningless annotation would be a lie surface. |
Rejected: structural-only inference (leaves `PriceCache`-shaped types still
needing an annotation); demand-only inference (a distant edit silently flips a
type's memory strategy with nothing reporting it); stop-the-shard full trace
(no barrier, but the pause grows with the live set and principle 3's
"budgeted" wording would have to weaken); post-exit-only collection
(contradicts principle 6 — a service that never stops never collects);
sequencing the collector before inference (defensible, and rejected only
because the coupling argument above makes one coherent landing cheaper than
two).
## 1. Inference — `compiler/src/gcinfer.ml`
A new pass between `types` and `owner`.
**Structural half.** Build a class-reference graph: an edge from A to B when A
has a field whose type is B, `multi B`, `map<B, _>`, `map<_, B>`, or any of
those under `?`. **`ref B` creates no edge** — it is an id, not a pointer, and
already classifies as `Copy`. Run Tarjan's SCC. Every class in a non-trivial
SCC, or with a self-loop, is traced. This is decidable from declarations
alone, so it is stable under edits elsewhere in the program.
**Demand half.** Run the ownership analysis in a collect-promotions mode: at
each site where it would report an escape or aliasing error (today's
`WO-E304` and the long-lived-alias cases), record the class rather than the
error. Promote all recorded classes, then re-run ownership. The promoted set
only grows and is bounded by the class count, so this terminates; two owner
passes is the worst case in practice because promotion removes errors and
never creates them.
**Everything is reported.** Each promotion emits a note stating the reason —
the cycle path for a structural promotion, the escape site for a demand
promotion. `--dump-gc` renders the whole classification, which is the
golden-testable artifact:
```
Cache gc (alias escape, cache.wo:12)
Node gc (cycle Node -> Node)
Product owned
Price owned
```
**Field kinds follow.** A field whose type is a traced class derives
`WO_K_GCREF` automatically; `types.ml`'s existing derivation reads the
inferred set instead of `is_gc_class`.
**The annotation is removed.** The parser's `| "gc" -> is_gc := true` arm
becomes a diagnostic in the WO-E1xx range naming the inference pass and
`--dump-gc`. `class_info.is_gc` and `Types.is_gc_class` are replaced by the
inferred set; `dump.ml` stops rendering ` @gc`.
## 2. What the compiler emits
- **`RC_INC` / `RC_DEC` are no longer emitted.** Opcodes 27–28 become
reserved. This is a `.wob` version bump, recorded in the format doc.
- **No barrier opcode and no emitter barrier.** `SETF` already resolves the
field kind from the class table, so the barrier lives inside the VM's store
paths (`SETF`, `map_set`, `push`). Zero new opcodes, zero emitter change for
the barrier — a deliberate contrast with Go, which must insert barrier calls
because it compiles to machine code.
- **The drop table's gc mask keeps its bits and changes contract.** It stops
meaning "`rc_dec` these registers while unwinding" and starts meaning
"these registers are GC roots at this pc". The owned mask is unchanged, and
`DROP` placement for owned values is unchanged.
- **`wo_drop_kind` for `WO_K_GCREF` becomes a no-op** — tracing owns the
lifetime of traced objects, so an owned object dying never frees them.
- The class table's `@gc` bit survives with the same encoding; only its
*source* changes from annotation to inference.
Everything else the emitter does — register allocation, window calls, moves,
owned drops, residual borrow guards, line tables — is untouched. Inference
changes which classes are traced, not how anything is lowered.
## 3. Runtime — header and the sweep list
**The arena cannot enumerate objects.** It is bump allocation plus 16-byte
size-class free lists, with anything over 1024 bytes falling through to bare
`malloc`; `wo_arena_free` requires the caller to pass the size back, and no
size headers exist anywhere. Sweep therefore needs its own object list. This
is the single largest runtime addition in this spec.
**The header stays exactly 16 bytes.** Retiring `rc` frees four bytes, and
traced objects are exempt from borrow rules so their `borrow` word is dead
too. Those two adjacent words give exactly eight contiguous bytes — one
64-bit intrusive list link. The `_Static_assert(sizeof(wo_hdr) == 16)` and the
format doc's layout size both survive unchanged.
**A per-shard traced list.** `wo_rt` gains a list head; every traced
allocation links itself in. Sweep walks the list, recovers each object's size
from the class table via `wo_obj_size`, frees the unmarked, and unlinks. The
existing cycle-candidate buffer (`cycbuf`) and the `WO_F_BUF` flag are
retired — they exist only to serve trial deletion.
## 4. Runtime — incremental marking and the barrier
**Colors.** The two existing color bits (`WO_F_COLOR`) carry white/grey/black.
No header growth.
**Roots.** The VM's value stack and frame stack, read through the per-pc gc
masks the emitter already emits — one mask lookup per live frame, exactly the
mechanism Go gets from `FUNCDATA_LocalsPointerMaps` but already present here.
**Owned objects are traversed, never freed.** An owned object can hold a
`GCREF` field, so tracing must walk through owned subtrees to find traced
objects. To stop that costing the whole owned graph, the class table gains a
precomputed **"transitively contains a gcref"** bit, so owned subtrees that
cannot reach a traced object are skipped outright. This bit is derivable in
the same pass that computes the SCCs.
**Safepoints** go at loop back-edges and calls — the pcs that already carry
drop-table entries, so no new metadata is needed.
**Barrier.** Yuasa deletion barrier, active only while marking: on a pointer
store into a traced slot, shade the *old* value before overwriting it. This is
the half of Go's hybrid barrier that eliminates stack rescanning; the
Dijkstra insertion half is unnecessary because a shard's own stack is
re-read from its masks at each slice rather than being scanned once and
trusted.
**Budget and trigger.** `WO_GC_BUDGET` keeps its name and meaning — objects
marked per slice. The cycle starts on a heap goal over the shard's
traced-bytes since the last cycle. `WO_GC_TRACE` keeps its stderr trace, with
freed/marked counts per slice.
## 5. Error handling
No new error mechanism. The barrier and the collector cannot fail: allocation
failure already traps `WO_T_OOM`, and a sweep-list allocation failure does not
exist because the link is inside the object. Inference reports notes, never
traps. The one genuinely new failure mode is a **barrier bug**, which
manifests as silent corruption rather than a diagnostic — §7 addresses it with
a dedicated adversarial test rather than a runtime check.
## 6. What this deletes
Recording this plainly, because it is the design's main argument:
- `gc.c`'s trial deletion — `mark_gray`, `scan_black`, `scan_`,
`collect_white`, `white_free`, the candidate buffer, the zombie guard.
- `owner.ml`'s rc machinery — the rc table, elision groups, `rc_escaped`,
`gc_escape`, `resolve_rc`, `release_gc`, and the clobber rule that exists
only to invalidate elision.
- `emit.ml`'s `emit_rc` and the escape-acquire anchor.
- The `RC_INC`/`RC_DEC` interpreter cases.
- **All four recorded `@gc` defects**, by construction: the `set` gap has no
`RC_INC` to omit; the held-cycle leak was rc inflation from an unreleased
return value, and with tracing that value simply is not a root; the
`mut`-`@gc` clobber protected an elision that no longer exists; the `push`
bug cannot recur.
## 7. Testing
- **The barrier is the load-bearing test.** An adversarial fixture where the
mutator hides a traced object between marking slices — store it into an
already-blackened object and drop the original reference — must not free it.
A barrier bug is silent corruption, so this test is the design's safety net
and must fail loudly if the barrier is compiled out.
- **Inference:** unit tests for SCC classification (self-loop, mutual
recursion, `multi Self`, `map<_, Self>`, and the `ref T`-creates-no-edge
case), promotion cases, and golden `--dump-gc` output including the note
text.
- **Collector:** `runtime/test/test_cycle.c` and `test_rc.c` are rewritten —
they currently assert rc values, which cease to exist. New assertions: an
abandoned cycle is freed, a rooted cycle survives, slices are bounded, and
the sweep list has no leak after N cycles.
- **Corpus:** `tests/corpus/gc/abandoned-cycle` and `budget-steps` survive
with re-blessed traces. `held-cycle` is **redefined** — with tracing, a
post-exit heap has no roots at all, so the honest fixture is "a cycle rooted
from a live frame survives a slice", tested from inside a running program
rather than after the entry returns.
- **ASan across the corpus**, as today, plus a leak-free assertion after
repeated collection cycles.
- The milestone-1 acceptance gate's criterion 4 is restated in terms of
tracing; criterion 3's ASan clause is expected to go green, since the
held-cycle leak is one of the defects this deletes.
## 8. Migration — normative claims to amend
| Where | Change |
| --- | --- |
| `docs/00-principles.md` principle 3 | "`@gc` is a per-class opt-in" → GC-ness is inferred; keep "no global pause exists by construction" (still true — per-shard, and other shards never stop) |
| OOP spec decision table, GC-granularity row | per-class annotation → inferred, with the hybrid rule named |
| OOP spec §3 rule 5 | "`@gc` class instances alias freely" → traced classes alias freely, and which classes those are is inferred |
| OOP spec §4 memory model | replace the RC + Bacon–Rajan paragraph with tracing; header `rc` → sweep-list link; drop `IN_CYCLE_BUF` |
| `00-wob-format.md` | opcodes 27–28 reserved; version bump; drop-table gc-mask contract restated as GC roots; class-flag provenance |
| `01-error-catalog.md` | WO-W201 (`@gc` suggestion) retired — inference supersedes it; WO-E304's `@gc`-exemption wording updated; new WO-E1xx for `@gc` in source |
| `08-builtin-surface.md` | the `push` special case and the `set` gap both deleted — neither exists without RC |
| Goldens | every fixture rendering ` @gc`, `flags=gc`, `gc={rN}`, `RC_INC`/`RC_DEC`, or the `== RC ==` table section re-blessed |
| `docs/00-status.md` | records this as the iteration that supersedes part of iteration 2's memory model |
## Success criteria
1. No `.wo` file in the repo contains `@gc`, and using it is a diagnostic.
2. `--dump-gc` classifies every class in the pricing and corpus samples, and
every traced class's reason is either a cycle path or a named escape site.
3. `RC_INC`/`RC_DEC` appear in no emitted image; the opcodes are reserved in
the format doc.
4. The adversarial barrier fixture fails when the barrier is compiled out and
passes when it is in.
5. An abandoned cycle is collected within budgeted slices with no slice
exceeding the configured budget; a rooted cycle survives.
6. The whole corpus is ASan-clean, including after repeated collection cycles
— closing milestone-1 criterion 3.
## Out of scope
Cross-shard tracing (ownership moves mean no traced object spans shards);
generational collection (no remembered set, no age bits — Go's isn't
generational either); compaction (non-moving is load-bearing: no forwarding
pointers, no read barrier); scheduler-integrated pacing beyond the heap-goal
trigger, which remains sub-project 2's concern; and `ref T` semantics, which
are unchanged.