Skip to content

Latest commit

 

History

History
594 lines (486 loc) · 31.2 KB

File metadata and controls

594 lines (486 loc) · 31.2 KB

Plan: Generational GC for Perry

Status: proposed, pre-implementation. Written 2026-04-24 after v0.5.206 (Step 2 lazy JSON parse complete). This doc captures the design before any code lands — the implementation is multi-week and touches codegen + runtime + GC in concert, so agreement on direction matters.

Goal: structurally beat Bun on peak RSS for general (non-JSON) workloads. Today Perry's bench_json_roundtrip with lazy flag beats Bun on time by 3.6× but still uses 30% more RSS. On any workload where lazy parse doesn't apply, Perry's flat-arena GC leaves the working set at peak-during-burst size until the next full GC cycle — Bun's generational collector reclaims most of that in the nursery between bursts.

Why generational is the right answer

The allocation distribution in typical JS workloads:

  • 90%+ of allocations die in the scope they were created.
  • Most of the remaining 10% die in the next outer scope.
  • A tiny minority survives to "old" age (module globals, caches, long-lived data structures).

Perry's current flat arena treats all allocations equally: every new X() lands in the same per-thread arena block. A mark-sweep full GC walks every reachable object across every block. On a 10k-record JSON.parse, that's ~60k objects to mark-scan per cycle, even when 99% of them are trivially dead.

Generational splits the arena by age:

  • Young generation (nursery) — small (1-4 MB), fills fast, swept often. Dead objects stay in the nursery and get reclaimed en-masse on reset. Living objects get promoted to the old generation.
  • Old generation — larger (growable), swept rarely. Same mark-sweep we have today, just with smaller input.

On a typical burst (parse + process + discard), most objects never leave the nursery. Minor GC resets the nursery in O(survivors), not O(allocations). The working-set footprint drops to approximately sizeof(survivors) + nursery_size (single-digit MB) instead of sizeof(all-allocs-since-last-major-GC) (double-digit to triple-digit MB on heavy workloads).

Prerequisite: precise root tracking

Perry's current conservative stack scanner uses setjmp to snapshot callee-saved registers, then walks the C stack scanning for bit- patterns that look like valid heap pointers. This works for non-moving GC because every valid pointer found is still a valid pointer; false positives (non-pointer words that happen to look like heap addresses) keep dead objects alive but don't corrupt memory.

Generational GC requires precise roots. Specifically:

  • Minor GC needs to know which stack slots hold young-gen pointers so it can evacuate them to old-gen (if they survive).
  • Without precise roots, we'd have to treat every looks-like-pointer word as "might be a young-gen pointer", scan its target, promote it. That's correctness-safe but re-introduces the cascade behavior issue #179 was fighting.

Precise root tracking design

Codegen emits a shadow stack at every safepoint. A safepoint is any program point where GC may run — today that's function entry, allocation sites, and explicit gc() calls. The shadow stack is a per-thread Vec<*mut u8> of live heap-pointer-typed locals.

For each function, at the IR level:

  1. Entry: push a frame onto the shadow stack with a precomputed slot count for this function.
  2. Before each safepoint: update the current frame's slots to reflect live pointer-typed locals at this point.
  3. On exit: pop the frame.

Perry already knows which locals are pointer-typed (the HIR has HirType::String/Array/Object/etc.). The shadow-stack update is just a store of each live pointer into its pre-assigned slot in the frame. Roughly 1-2 instructions per live pointer per safepoint.

Cost: measured in V8/Bun as 2-8% on pointer-heavy workloads. Effectively free on computation-heavy workloads (few pointers, few safepoints). On Perry's current benchmark mix:

  • 07_object_create: no escape, no safepoints in the hot loop → ~0%
  • 12_binary_trees: heavy pointer churn, many safepoints → ~5% est.
  • bench_json_roundtrip direct: ~3% est. (mostly inside runtime)
  • bench_gc_pressure: already GC-dominated, net win from faster GC

Tradeoff: the conservative stack scanner stops being authoritative for heap roots. We keep it for the C stack below our JS frames (runtime function local variables holding JSValues) because rewriting every Rust runtime function to use a shadow stack is infeasible. Minor GC treats conservatively-found pointers as "might be young-gen, conservatively promote". This costs some over-promotion but preserves correctness.

Generational GC design

Nursery layout

  • Per-thread, 2 MB default (configurable via PERRY_NURSERY_MB env).
  • Flat bump-allocator — same as today's arena but smaller.
  • Fills fast, resets on every minor GC.
  • Objects in nursery always have GcHeader.gc_flags & GC_FLAG_YOUNG.

Minor GC (nursery collection)

Triggered when the nursery fills. Runs in one pass:

  1. Root scan — walk the shadow stack (precise) + runtime register-roots (malloc side-table, parse roots, shape cache, etc.)
    • conservative scan of the C stack below JS frames.
  2. Mark & evacuate — for each reachable nursery pointer:
    • If the object survived fewer than PROMOTION_AGE minor GCs (default 2), copy it to a fresh nursery slot.
    • Otherwise, promote: copy to old-gen.
    • In both cases, update the root to point at the new location.
  3. Forward old-gen → young-gen pointers — every old-gen object that WROTE a young-gen pointer got its remembered-set bit flipped by the write barrier (next section). Scan just those, promote or update.
  4. Reset nursery — sweep is free; the nursery's bump pointer moves back to start, any un-copied objects die.

Critical: minor GC only touches the nursery + the remembered set's old-gen roots. It does NOT walk the entire old generation. That's the asymptotic win.

Major GC (full collection)

Triggered when old-gen passes a threshold (starts at current GC_THRESHOLD_INITIAL_BYTES). Behaves like today's mark-sweep:

  1. Scan all roots.
  2. Mark through both generations.
  3. Sweep old-gen (block reset for dead-only blocks — same as today).
  4. Minor-GC the nursery to clear surviving-but-unreachable objects.
  5. Clear the remembered set (it's regenerated by subsequent write barriers).

Write barriers

Codegen emits a write barrier at every store of a heap pointer into a heap object:

before:  *field = new_value
after:   *field = new_value
         if (new_value is young-gen pointer && field's owner is old-gen):
             add owner to remembered_set

Minimal implementation:

  • Per-thread remembered set = Vec<*mut GcHeader>.
  • Write barrier checks owner.gc_flags & GC_FLAG_YOUNG == 0 (owner is old-gen) AND new_value.gc_flags & GC_FLAG_YOUNG != 0 (value is young).
  • If both, push owner to remembered_set (deduplicated via a HashSet or bit on gc_flags).

Cost per store: 2 loads (owner's flags, value's flags), 1 AND, 1 branch. ~3-5ns on a modern CPU. Adds up for stores-heavy workloads.

Elision opportunities: codegen can skip the barrier when:

  • Store target is the nursery (both sides young — no barrier needed).
  • Value being stored is a primitive (no barrier needed).
  • Both operands are provably the same generation via static analysis.

Promotion policy

Simple age-based: each nursery object carries a 2-bit age counter in GcHeader._reserved. Each minor GC increments the age. When age reaches PROMOTION_AGE (default 2), the object promotes to old-gen instead of re-copying in the nursery.

Why 2: single-survival-and-die pattern is very common (parse intermediates, string concat temporaries). Age 2 gives them a second minor GC to actually die before we commit to moving them. Can be tuned later with a PERRY_PROMOTION_AGE env var.

Alternative considered: size-based (large objects bypass nursery and allocate directly in old-gen). Simpler to add later as an optimization.

Phased rollout

Four phases, each independently verifiable and revertable.

Phase A — Precise root tracking (prerequisite)

Scope: codegen emits shadow-stack push/pop at function entry/exit and slot updates at safepoints. No GC behavior change yet — the GC still uses conservative scanning; the shadow stack is a parallel mechanism that's built but not yet consumed.

Ship criteria:

  • All existing tests pass byte-for-byte.
  • Shadow-stack contents verified by a new test that walks the stack at a safepoint and checks slots match expected live pointers.
  • Zero benchmark regression (shadow-stack construction should be noise-level for Perry's workloads).

Phase B — Flat-arena split into young + old regions

Scope: the existing single arena splits into NURSERY_ARENA + OLD_ARENA (both thread-local). All allocations default to NURSERY. No minor GC yet — nursery just grows until a full GC runs, same as today. This phase is a no-op behavior change that sets up the allocation paths.

Ship criteria:

  • Existing bench_json_roundtrip numbers unchanged (lazy flag on or off).
  • Nursery overflow correctly pushes new nursery blocks.
  • All gap tests still 24/28.

Phase C — Minor GC + write barriers

Scope:

  • Codegen emits write barriers at every heap store (behind an initial PERRY_WRITE_BARRIERS=1 rollout flag; barriers are now default-on with PERRY_WRITE_BARRIERS=0/off/false as the opt-out).
  • Minor GC actually collects the nursery.
  • PERRY_GEN_GC=1 gates minor GC firing.

Ship criteria:

  • bench_json_roundtrip direct path RSS drops to ≤70 MB (vs today's 144 MB), time within 10% of today.
  • 07_object_create / 12_binary_trees unchanged (tight hot loops already die entirely before GC, so minor GC would rarely fire).
  • No test regressions under PERRY_GEN_GC=1.

Phase C4b — Copying evacuation (RSS win)

Status: infrastructure landed in v0.5.229 (GC_FLAG_FORWARDED, forwarding_address / set_forwarding_address helpers, 3 unit tests pinning the data structure). Real copying lands in C4b-β + γ.

Why this is the architectural boss-fight: moving an object across arenas requires updating every reference to it. Sources of references in Perry today:

  • Shadow stack slots (precise, mutable — easy to rewrite)
  • Module globals (precise, mutable — easy)
  • 9 registered root scanners — currently expose &mut FnMut(f64) for marking; would need a parallel mutable variant for rewriting
  • Marked arena/malloc objects' fields (mutable; standard heap walk)
  • Conservative C-stack scan — discovers candidate pointers by bit-pattern matching memory words. We can't safely rewrite those words (they might not actually be pointers).

Pinning policy (the safety lever): any object reached by the conservative stack scan must NOT be evacuated. Track via a new GC_FLAG_CONS_PINNED bit set by the conservative-scan path (needs new mark variant: mark_stack_roots_with_pinning). Evacuation candidates = MARKED & TENURED & !CONS_PINNED & in-nursery.

Three sub-steps for C4b:

C4b-α — Forwarding-pointer infrastructure (✅ landed v0.5.229)

Constants + helpers + 3 round-trip tests. No actual evacuation yet. Makes the "this object has been moved" data structure addressable from elsewhere.

C4b-β — Conservative-pinning + evacuation pass

  1. Add GC_FLAG_CONS_PINNED constant.
  2. New mark_stack_roots_with_pinning(valid_ptrs) that sets CONS_PINNED on every object found by conservative scan (intercept at try_mark_value callsite from mark_stack_roots).
  3. gc_collect_minor calls the pinning variant instead of mark_stack_roots.
  4. After age-bump, walk arena nursery objects:
    • Candidate = MARKED + TENURED + !CONS_PINNED + in-nursery.
    • For each candidate: arena_alloc_gc_old(size, align, obj_type), copy bytes, set_forwarding_address on nursery header.
  5. Don't yet rewrite references. Compiled code following pointers to evacuated objects WILL crash because their nursery slots now hold forwarding pointers. Gate this whole phase on a temporary PERRY_GEN_GC_EVACUATE=1 env var so default behavior is untouched.

C4b-γ — Reference rewriting

Walk every reference site and rewrite forwarded pointers:

  • Shadow stack: walk SHADOW_STACK_FRAME_TOP chain, for each slot's NaN-boxed value, if its pointee is forwarded, write the new address back.
  • Module globals: same pattern over GLOBAL_ROOTS.
  • Registered root scanners: extend the API to include a mutable variant (scan_*_roots_for_rewrite(rewrite_fn)).
  • All marked arena+malloc objects: walk fields per obj_type using the existing trace_* family, but rewriting refs instead of marking.
  • Skip conservative C-stack — the CONS_PINNED policy guarantees evacuated objects have no conservative refs.

After rewriting, dead nursery blocks become reclaimable (none of their objects have live references). arena_reset_empty_blocks already handles this; the only change is that more blocks now qualify because their formerly-tenured occupants have moved.

Ship criterion C4b complete: bench_json_roundtrip direct- path RSS ≤70 MB (down from current 109 MB) without time regression beyond 10%. Full test corpus clean under PERRY_GEN_GC=1 PERRY_GEN_GC_EVACUATE=1.

Phase D — Flip defaults + clean up conservative scanner

Scope:

  • PERRY_GEN_GC=1 becomes default.
  • Conservative scanner shrinks to "scan only the C stack below JS frames" (the Rust runtime's local variables).
  • PERRY_GEN_GC=0 environment variable retains the old behavior for bisection.

Ship criteria:

  • Six-week soak on main with no GC-related bug reports.
  • All three bench_json benchmarks hit or exceed v0.5.206 numbers.
  • Documentation updated to describe the generational GC as the default model.

Estimated effort

  • Phase A: 1-2 weeks (codegen changes + tests)
  • Phase B: 1 week (pure allocator refactor)
  • Phase C: 2-3 weeks (write-barrier codegen + minor-GC implementation + correctness hardening)
  • Phase D: 1 week (flip flag + cleanup + documentation)

Total: 5-7 weeks for a single focused engineer; longer with other on-call / review / PR cycle overhead.

Expected wins

On bench_json_roundtrip direct path (no lazy flag):

  • Today: 372 ms / 144 MB
  • After generational: estimated 250-280 ms / 40-60 MB (RSS drops 2-3× as nursery collects per-iter allocations; time improves because minor GC is faster than the full mark-sweep we do today).

On bench_json_roundtrip with lazy flag:

  • Today: 69 ms / 108 MB
  • After generational: estimated 69 ms / 30-50 MB (lazy path doesn't allocate a tree, so the nursery stays tiny — most RSS win is from the tape allocations going through nursery → promoted on survival).

On bench_gc_pressure:

  • Today: 17-19 ms / 26 MB
  • After generational: estimated 8-12 ms / 15 MB (GC time drops because most garbage dies in the nursery; never reaches mark-sweep).

On 07_object_create:

  • Today: 0-1 ms / 6 MB
  • After generational: unchanged (working set fits in one block under nursery threshold; GC never fires).

Risks + mitigations

Risk Severity Mitigation
Write barrier overhead > 10% on some workloads medium Elision passes; benchmark gate during Phase C
Promotion bugs → dangling pointers high Rust-level cargo test -Z sanitizer=address run on every Phase C commit
Shadow stack miss → premature collection high Phase A ships with a cross-check test that compares shadow stack against conservative scan results
Minor GC slow path regresses bench_gc_pressure medium Phase C ship criteria explicitly includes this bench
Generational GC interaction with lazy JSON parse low The lazy JSON tape is arena-allocated; it just lives in the nursery initially and promotes if it survives enough minor GCs — same as any other allocation
Rust soundness (shadow stack as &mut [*mut u8]) medium Use raw pointers + UnsafeCell; audit patterns align with arena.rs's existing thread-local UnsafeCell<Arena>

Contextual lazy JSON — heuristic runtime profiling / @perry-lazy pragma

Captured here (orthogonal to generational GC but tracked together): the lazy JSON path from docs/json-typed-parse-plan.md is opt-in via PERRY_JSON_TAPE=1. That's the right default for now (lazy is a slight loss on workloads that materialize every element), but two follow-up axes let users make the tradeoff contextual:

  1. @perry-lazy JSDoc pragma — landing as part of this cycle. A function- or module-level /** @perry-lazy */ comment opts JSON.parse calls in its scope into the lazy path. Node- compatible (comments erase). See follow-up commit.

  2. Heuristic runtime profiling — parked. Observe blob size + post- parse access pattern at runtime and auto-route large blobs with .length-only access through the lazy path. Per-call-site state in a global hash. Switch-to-lazy after N successful length-only or stringify-only observations. Harder than it looks because it needs good de-opt behavior when a call site changes shape.

The generational GC change will also reduce the lazy-vs-direct RSS gap on the hot path — lazy currently wins by parse-output-tree avoidance, but generational reclaims that tree quickly either way. Once generational lands, it may be worth re-measuring whether lazy is still worth the code cost for the common case.

Log

Date Version Phase Result
2026-04-23 v0.5.217 A.1 — runtime shadow-stack scaffolding SHADOW_STACK: Vec<u64> thread-local + 4 C-ABI entries (push/pop/slot_set/slot_get); 5 unit tests cover frame isolation + 16-deep nesting
2026-04-23 v0.5.218 A.2 — codegen push/pop emission textual ret-rewrite ensures every return site pops; opt-in via PERRY_SHADOW_STACK=1
2026-04-23 v0.5.219 A.3a — slot map threading collect_pointer_typed_locals assigns shadow slot indices per function; threaded through FnCtx
2026-04-23 v0.5.220 A.3b — slot-set emission js_shadow_slot_set(idx, bits) emitted at every Stmt::Let + Expr::LocalSet for pointer-typed locals
2026-04-23 v0.5.221 A.4 — GC consumes shadow stack new 9th root scanner walks every frame's slots; runs in parallel with conservative scanner
2026-04-23 v0.5.222 B — young/old arena split OLD_ARENA thread-local parallel to ARENA + LONGLIVED_ARENA; arena_alloc_old / arena_alloc_gc_old non-inline allocator
2026-04-23 v0.5.223 C.1 — write-barrier runtime js_write_barrier(parent, child) C-ABI entry + REMEMBERED_SET: HashSet<usize> thread-local
2026-04-24 v0.5.224 C.2 — codegen barrier emission js_write_barrier emitted after generic Expr::PropertySet; opt-in via PERRY_WRITE_BARRIERS=1
2026-04-24 v0.5.225 C.2 expansion barrier wired at every remaining heap-store site (PropertySet, IndexSet array elem, IndexSet string-key, LocalSet capture)
2026-04-24 v0.5.226 C.3a — RS scanned as roots mark_remembered_set_roots snapshot+scan during full GC; RS clears post-sweep
2026-04-24 v0.5.227 C.3b — minor GC trace skips old-gen gc_collect_minor + drain_trace_worklist_minor; treats old-gen as black leaves bounded by O(young live + RS roots)
2026-04-24 v0.5.228 C.4 non-moving — flag-based aging GC_FLAG_HAS_SURVIVED / GC_FLAG_TENURED two-bit aging; bench_json_roundtrip 80→70 ms time win, RSS unchanged (no compaction yet)
2026-04-24 v0.5.229 C4b.α — forwarding-pointer infra GC_FLAG_FORWARDED = 0x80, forwarding_address / set_forwarding_address helpers; 3 unit tests pin the data-structure layer
2026-04-24 v0.5.230 C4b.β — pinning + evacuation CONS_PINNED thread-local + pin_currently_marked_as_conservative; evacuate_tenured_nursery_objects byte-copies non-pinned tenured objects to OLD_ARENA
2026-04-24 v0.5.231 C4b.γ-1 — transitive pinning safety scanner-discovered + transitively-reached objects pinned; evacuation correctness-safe as a no-op until γ-2 lands
2026-04-25 v0.5.234 C4b.γ-2 — reference rewriting 7 per-obj-type rewriters + shadow-stack walk + GLOBAL_ROOTS walk; transitive-pinning safety valve removed; evacuation actually moves objects
2026-04-25 v0.5.235 C4b.δ — block deallocation arena_reset_empty_blocks deallocs blocks idle for 2 GC cycles back to the OS; tombstoned slots reused by Arena::alloc slow path
2026-04-25 v0.5.236 C4b.δ-tune — trigger ceiling GC_TRIGGER_ABSOLUTE_CEILING (= initial 64 MB) caps next_trigger; bench_json_roundtrip direct path 142 → 107 MB (-25%)
2026-04-25 v0.5.237 D.1 — PERRY_GEN_GC default ON gen_gc_enabled() inverted; PERRY_GEN_GC=0 is the bisection escape hatch; test_memory_json_churn 115 → 91 MB
2026-04-25 v0.5.238 D.2 prep — PERRY_SHADOW_STACK default ON shadow_stack_enabled() inverted; precise JS-frame coverage live for every compiled program; bench impact within noise
2026-04-25 v0.5.239 D — architectural completion plan Log table updated; conservative-scanner shrink deferred (see "Deferred follow-ups" below)

Status: Phases A / B / C / C4 / C4b / D-architectural — all complete. The generational mark-sweep is the default model for every compiled Perry program; the architectural roadmap from the original plan is shipped end-to-end.

Deferred follow-ups

The original Phase D scope listed "Conservative scanner shrinks to 'scan only the C stack below JS frames' (the Rust runtime's local variables)" as a sub-goal. Implementing that correctly turns out to require platform-specific frame-pointer walking that this plan underestimated, and the perf benefit on measured workloads is small enough that deferring is the right call. The argument:

Why it's correctness-sensitive. The shadow stack precisely covers JS frame slots that hold pointer-typed locals — but the JS frame's position on the C stack is bounded by SP-at-push (low boundary) and SP-of-caller-at-call-time (high boundary, = where the JS frame ends and the caller's frame begins). The high boundary isn't recoverable from js_shadow_frame_push alone — it requires reading the saved frame-pointer chain from the FP register on entry, which is platform-specific (ARM64 vs x86_64) and ABI- fragile (Rust calling-convention assumptions).

Naively skipping just by SP-at-push (treating the range [push_SP, next_outer_push_SP) as "JS frame i") is unsafe because that range can include Rust runtime frames sandwiched between JS frames — concrete example: JS function A calls js_array_map (Rust runtime), which calls back into JS callback B. Stack from low to high: B's frame, js_array_map's frame, A's frame. If GC fires from inside B's call chain and we skip the range [B_push_SP, A_push_SP), we miss js_array_map's frame — and that frame holds JSValue locals (the array, the callback ptr, the current element) that are the only roots keeping those objects alive. Live objects get prematurely freed.

Why deferring is fine. Conservative scan time is sub-1% of total runtime on every measured benchmark — the per-GC-cycle stack walk is microseconds against benchmarks running hundreds of milliseconds. The "over-promotion" cost from conservative false-positives is also small in practice: the conservative-pinning policy in C4b means false-positive roots only block evacuation, not retention, and most workloads don't have enough tenured objects for evacuation to matter much. The big architectural wins from generational GC — minor-collection time, write-barrier-driven RS, trigger ceiling, block dealloc — all already lived without the scanner shrink.

What a future implementation would look like. Capture FP at js_shadow_frame_push entry (asm!("mov {}, x29", out(reg) fp) on ARM64) and store it alongside the slot count in the shadow frame header. At GC time, walk the FP chain to compute precise JS frame ranges ([saved_FP_+_16, caller_saved_FP] on ARM64), union them, and have the conservative scanner skip those address ranges. Test with deep alternating JS↔Rust call chains (js_array_map(callback) patterns) to confirm Rust frames between JS frames stay covered.

Other parked items

  • Large-object direct promotion: objects > 16 KB allocate directly in old-gen, bypassing the nursery. Simpler to add after Phase C lands.
  • Concurrent marking: old-gen major GC could run concurrently with mutator. Multi-week effort; park until generational itself is stable.
  • Compacting old-gen: fragmentation bothers nothing today but might after a few months of real-world use. Defer until measured.
  • Card-marking write barrier: per-card dirty bits instead of per-object remembered-set. Better for write-heavy workloads. Defer until we have data showing the simple remembered-set approach is the bottleneck.
  • Flip PERRY_GEN_GC_EVACUATE=1 default: evacuation is correctness-safe and tested but a no-op on workloads where nothing tenures (so the work it does is overhead, not benefit). Flipping benefits from production-soak data on programs where evacuation actually fires.

Appendix: academic + industry lineage

Perry's GC is a synthesis of well-established techniques. Each phase maps cleanly to canonical papers and real-world VM implementations — none of the design is novel; the contribution is the specific combination targeted at TypeScript-on-Rust.

Single strongest reference

Bartlett 1988, Mostly Copying Garbage Collection (DEC SRC Technical Note TN-13). This describes Perry's C4b almost verbatim:

  • Conservative scan of registers and the C stack discovers candidate pointers; objects reached this way get pinned (cannot move).
  • Precise scan of heap fields (and any other source the runtime knows about authoritatively) discovers movable objects.
  • Forwarding pointers stored in evacuated objects' headers; pinned objects stay in place.

Perry's CONS_PINNED HashSet + pin_currently_marked_as_conservative

  • GC_FLAG_FORWARDED + rewrite_forwarded_references is Bartlett's algorithm in Rust, extended to a generational layout (Bartlett's original was single-generation; the extension follows Ungar's template below).

Phase-by-phase references

Phase Concept Reference
A — shadow stack Precise stack roots in a language without GC support Henderson 2002, Accurate Garbage Collection in an Uncooperative Environment (ICCC)
B — young/old split Generational hypothesis + nursery design Ungar 1984, Generation Scavenging: A Non-Disruptive High-Performance Storage Reclamation Algorithm (SIGPLAN); Lieberman & Hewitt 1983 (predecessor)
C — write barriers + RS Tracking inter-generational pointers Hosking & Moss 1992, Remembered Sets Can Also Play Cards in Garbage Collection (OOPSLA)
C4 — tenuring Age-based promotion to old-gen Ungar (above); HotSpot's TenuringThreshold; V8's "quick promotion"
C4b — copying evacuation Mostly-copying with conservative pinning Bartlett 1988 (above); Smith & Morrisett 1998, Comparing Mostly-Copying and Mark-Sweep Conservation
(evacuation core) Forwarding-pointer copying Cheney 1970, A Nonrecursive List Compacting Algorithm (CACM) — every copying collector since 1970
Conservative + precise hybrid Discovering roots via mixed precise/conservative scan Boehm-Demers-Weiser 1988 (canonical conservative GC); Bartlett 1988 (canonical hybrid)

Industry implementations using the same techniques

Runtime Family Vs Perry
V8 (Chrome / Node) Generational, semi-space young + mark-compact old Fully precise (no conservative); compacts old-gen; uses card-marking + slot-buffer for RS
JSC (Safari / Bun) Generational, Eden + Old Card-marking write barrier; compacts old-gen
HotSpot (Java) Eden + Survivor + Old; G1, ZGC, Shenandoah, etc. OOP maps (compiler-integrated precise stack maps); compacts
SpiderMonkey (Firefox) Generational, nursery → tenured Rooted<T> / Handle<T> for precise roots (analogous to shadow stack); compacts
.NET CLR Gen 0 / Gen 1 / Gen 2 Same family; card-marking; compacts
OCaml Generational, minor heap + major heap Stop-and-copy minor; mark-sweep + compact major
Boehm-Demers-Weiser Single-gen, fully conservative No precise roots; doesn't move objects (pure mark-sweep)
Mono (early) Bartlett-style hybrid Most direct precedent for Perry's design
Go Concurrent tricolor mark-sweep, no generations Different design philosophy (pacer-driven)
LuaJIT Incremental mark-sweep, no nursery Different design philosophy

Where Perry sits in the design space

Perry's specific combination is on the simpler / more conservative end of the production-VM design space:

Choice Perry What we don't do
Generations 2 (nursery + old) HotSpot has 3+ (Eden + Survivor + Old + Permanent variants)
Old-gen compaction None V8 / JSC / HotSpot all compact old-gen
Concurrent marking None HotSpot G1, ZGC, Shenandoah; Go
Write barrier Per-object RS Card marking (HotSpot, .NET, JSC)
Stack roots Hybrid (shadow stack + conservative scan with pinning) Fully precise (V8, HotSpot via OOP maps)
Old-gen sweep Mark-sweep with block reset + dealloc In-place free list (most VMs); region-based (G1)

Each of these choices has a clear documented trade-off in the plan or commit log: e.g. card marking is an open follow-up gated on "data showing the simple remembered-set approach is the bottleneck"; old-gen compaction is parked until "fragmentation bothers" something in measurement.

The point of this appendix isn't to claim novelty — it's the opposite. Every design decision can be traced to a paper or a shipping VM that does the same thing. The contribution Perry makes is engineering: gluing these well-understood pieces together inside a Rust runtime that compiles TypeScript to native code, without inventing anything in the GC literature.

Bibliography

  • Bartlett, J. F. (1988). Compacting Garbage Collection with Ambiguous Roots. DEC SRC Research Report 88/2 / Technical Note TN-13. Western Research Laboratory.
  • Boehm, H. & Weiser, M. (1988). Garbage Collection in an Uncooperative Environment. Software: Practice and Experience, 18(9), 807–820.
  • Cheney, C. J. (1970). A Nonrecursive List Compacting Algorithm. Communications of the ACM, 13(11), 677–678.
  • Henderson, F. (2002). Accurate Garbage Collection in an Uncooperative Environment. Proceedings of the 3rd International Symposium on Memory Management (ISMM).
  • Hosking, A. L. & Moss, J. E. B. (1992). Remembered Sets Can Also Play Cards in Garbage Collection. Proceedings of OOPSLA '92.
  • Lieberman, H. & Hewitt, C. (1983). A Real-Time Garbage Collector Based on the Lifetimes of Objects. Communications of the ACM, 26(6), 419–429.
  • Smith, F. & Morrisett, G. (1998). Comparing Mostly-Copying and Mark-Sweep Conservation. Proceedings of ISMM '98.
  • Ungar, D. (1984). Generation Scavenging: A Non-Disruptive High-Performance Storage Reclamation Algorithm. ACM SIGPLAN/SIGSOFT Software Engineering Symposium on Practical Software Development Environments.

For a comprehensive textbook treatment, see:

  • Jones, R., Hosking, A., & Moss, E. (2011). The Garbage Collection Handbook: The Art of Automatic Memory Management. Chapman and Hall / CRC. — Chapter 5 (generational), Chapter 17 (conservative), Chapter 8 (copying).