You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(gc): Phase C3a — RS roots flow into mark + clear (v0.5.226)
Remembered set populated by C2 write barriers now flows into the
GC mark phase as additional roots, and is cleared after every
collection so the next cycle starts coherent.
crates/perry-runtime/src/gc.rs:
mark_remembered_set_roots(valid_ptrs)
Snapshots REMEMBERED_SET (populated by C2 write barriers),
re-marks each old-gen header as a POINTER_TAG-tagged value
via try_mark_value. Wired into gc_collect_inner between
mark_registered_roots and trace_marked_objects.
RS clear after sweep:
REMEMBERED_SET.with(|s| s.borrow_mut().clear())
Next collection cycle starts coherent. Barriers at C2 sites
repopulate as needed during the next allocation epoch.
Today this is correctness-equivalent — the conservative C-stack
scan + 9 root scanners already kept everything alive. The
contribution is the infrastructure point: RS has a real consumer
in the GC, validated end-to-end via C2-emitted barriers feeding
through to mark + clear.
C3b will add the generational specialization (skip old-gen
objects during marking, scan only nursery from RS roots,
PERRY_GEN_GC=1 gate). That's where the time/RSS wins land.
New unit test test_remembered_set_cleared_after_full_gc:
populate RS via barrier, run full GC, assert RS is empty.
Runtime tests 155 -> 156.
Regression sweep:
10/10 test_json_*.ts under default + PERRY_WRITE_BARRIERS=1
bench_json_roundtrip: WB-off 65ms / WB-on 65ms (no diff)
Gap tests 25/28 (baseline)
Phase C now 3 sub-phases done at infrastructure:
C1 (RS storage), C2 (codegen emission), C3a (GC consumes RS).
C3b adds generational mark-phase specialization for the
bench_json_roundtrip direct RSS ≤70 MB ship criterion.
Copy file name to clipboardExpand all lines: CLAUDE.md
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
8
8
9
9
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
10
10
11
-
**Current Version:** 0.5.225
11
+
**Current Version:** 0.5.226
12
12
13
13
## TypeScript Parity Status
14
14
@@ -148,6 +148,7 @@ First-resolved directory cached in `compile_package_dirs`; subsequent imports re
148
148
Keep entries to 1-2 lines max. Full details in CHANGELOG.md.
149
149
150
150
- **v0.5.205** — Fix #183: `perry compile --target web` on a real-world app (Bloom Jump built on the Bloom engine) produced a WASM binary the browser refused to load — `Compiling function #687 failed: expected 0 elements on the stack for fallthru, found 103` (count varies with engine state). Root cause in `crates/perry-codegen-wasm/src/emit.rs`: the four direct-`Call`-instruction code paths — `Expr::Call` FuncRef arm (~4302), `Expr::Call` ExternFuncRef arm (~4324), `Expr::New` user-class ctor (~5844), `Expr::SuperCall` parent-ctor (~5894), `Expr::StaticMethodCall` direct-static path (~5979) — each emit `emit_expr(arg)` per source arg and pad up with `TAG_UNDEFINED` when `args.len() < expected`, but had no matching drop-excess branch when `args.len() > expected`. WASM `call` consumes exactly the callee's declared param count, so when JS's "extra args evaluated for side effects, then silently ignored" semantics met Perry's WASM codegen, every extra evaluated arg leaked past the call and accumulated on the enclosing block's operand stack — 103 values by the time `_start`'s final `end` hit the validator. The shape that triggered it in jump/bloom was `bloom/src/core/colors.ts`'s `Colors = new __AnonShape_2(...24 PropertyGets...)` landing on a Phase-3-synthesized ctor with lower declared arity, multiplied across bloom's 10 submodules. Fix: after each existing `for _ in args.len()..expected { I64Const(TAG_UNDEFINED) }` pad-up loop, add the mirror `for _ in expected..args.len() { Drop }` — matches JS semantics (extras evaluated for side effects but discarded) and keeps the operand stack aligned with the callee's WASM type at every direct-Call site. Verified end-to-end against the exact issue repro cloned fresh from `github.com/Bloom-Engine/jump` + `github.com/Bloom-Engine/engine`: both path A `file:./vendor/bloom/` and path B `file:../engine/` now compile to a WebAssembly-validating `.wasm` (416,923 / 413,780 bytes respectively, 140 FFI imports intact, `WebAssembly.compile` resolves clean on node 20+); a synthetic `takesFive(mc(),mc(),1,2,3,4)` minimal case that previously failed `Compiling function #213 failed: ... found 1` also validates. `cargo test --release -p perry-runtime -p perry-hir -p perry-codegen-wasm -p perry`: 262/262 passed. Note: issue #183 also claimed path A found only 1 module and emitted 9 FFI imports — could not reproduce in a fresh clone (both paths find 10 modules identically); most likely an artifact of the reporter's local `vendor/bloom` snapshot predating the `exports` map, and the "runGame silently no-ops" symptom the user actually observed was the browser refusing to instantiate the invalid WASM with the surrounding JS glue swallowing the error — fixed here.
151
+
- **v0.5.226** — Gen-GC **Phase C3a**: remembered-set roots flow into the GC mark phase + RS clears after every collection. New `mark_remembered_set_roots(valid_ptrs)` in `crates/perry-runtime/src/gc.rs` snapshots the per-thread `REMEMBERED_SET` (populated by the codegen-emitted write barriers from C2/C2-expansion) and re-marks each old-gen header as a `POINTER_TAG`-tagged value via the standard `try_mark_value` machinery. Wired into `gc_collect_inner` between `mark_registered_roots` and `trace_marked_objects`. RS cleared via `REMEMBERED_SET.with(|s| s.borrow_mut().clear())` immediately after `sweep()` returns, so the next collection cycle starts coherent — barrier emissions at C2 sites repopulate the RS as needed during the next allocation epoch. **Today this is correctness-equivalent to before** (the conservative C-stack scan + 9 root scanners already kept everything alive); the contribution is the **infrastructure point** — the RS now has a real consumer in the GC, validated end-to-end. C3b will add the actual generational specialization (skip old-gen objects during marking, scan only nursery from RS roots, gated `PERRY_GEN_GC=1`) which is where the time/RSS wins land. New unit test `test_remembered_set_cleared_after_full_gc` pins the clear-after-GC invariant: populate RS via barrier, run full GC, assert RS is empty. Runtime tests 155 → **156**. Full regression sweep clean: 10/10 `test_json_*.ts` match Node under default AND `PERRY_WRITE_BARRIERS=1` (where the RS actually fills with old→young entries during parse). `bench_json_roundtrip` best-of-5 WB-off 65 ms vs WB-on 65 ms — RS clear cost invisible (HashSet of <100 entries clearing in microseconds). Gap tests 25/28 (baseline). Phase C is now 3/3 sub-phases done at the infrastructure level: C1 (RS storage), C2 (codegen emission), C3a (GC consumes RS). C3b adds the generational mark-phase specialization that yields the bench_json_roundtrip RSS ≤70 MB ship criterion.
151
152
- **v0.5.225** — Gen-GC **Phase C2 expansion**: write-barrier emission extended to every remaining heap-store site. New `emit_write_barrier(ctx, parent_bits, child_bits)` helper at the top of `crates/perry-codegen/src/expr.rs` consolidates the gate-checked emit (gated `PERRY_WRITE_BARRIERS=1`, branchless at codegen via `OnceLock`-cached `write_barriers_enabled()`). Now wired at: (1) `Expr::PropertySet` generic `obj.x = y` path (refactored from inline emit at v0.5.224 to use the helper), (2) `Expr::IndexSet` array element path — both the local-without-slot fallback AND the no-local-id fallback at the runtime-call site, (3) `Expr::IndexSet` array element FAST path inside `lower_index_set_fast` — covers `arr[i] = v` for `arr` in `ctx.locals` (the most common shape), barrier emitted in the merge block after both inline-extend and realloc paths converge, (4) `Expr::IndexSet` string-literal-key path `obj["k"] = v`, (5) `Expr::IndexSet` runtime-string-key fallback path, (6) `Expr::LocalSet` closure capture write — both branches: boxed (parent = box ptr from `js_box_set`) AND non-boxed (parent = closure ptr from `js_closure_set_capture_f64`). NOT yet covered (deferred — separate codegen paths): class-field-set fast path with statically-typed receivers using direct field-index store. The 4-WB-site stress test (`/tmp/wb_all_sites.ts`: array indexed-set, two object key sets, one PropertySet) emits 4 `call void @js_write_barrier(...)` lines in IR and matches Node byte-for-byte. Regression sweep clean: 10/10 `test_json_*.ts` match Node under `PERRY_WRITE_BARRIERS=1`. `bench_json_roundtrip` best-of-5 WB-off 64 ms vs WB-on 64 ms — barrier overhead invisible because the bench's hot path is parse + stringify (no user-code field/element writes). The barrier becomes load-bearing once Phase C3 lands and minor GC consumes the remembered set — at that point the barrier replaces a full-arena scan with an RS-only scan, which is the actual time win the gen-GC plan promises.
152
153
- **v0.5.224** — Gen-GC **Phase C sub-phase 2**: codegen emits `js_write_barrier(parent_bits, child_bits)` after the generic `Expr::PropertySet` heap store in `crates/perry-codegen/src/expr.rs`. Gated behind `PERRY_WRITE_BARRIERS=1` env var (cached via `OnceLock` in new `crates/perry-codegen/src/codegen.rs::write_barriers_enabled()`) — default OFF, so no production-perf impact until Phase C3 lands and minor GC actually consumes the remembered set. Initial-scope coverage: the generic PropertySet path (used for `any`-typed receivers like `const h: any = {}; h.v = ...`). Class-field-set fast paths (statically-typed receivers using direct field-index lookup) are intentionally NOT instrumented in this sub-phase — those go through different codegen and will be wired in C2 follow-up. Verified end-to-end via `PERRY_SAVE_LL=<dir>`: a 3-field-write program emits exactly 3 `call void @js_write_barrier(i64 ..., i64 ...)` lines after the matching `js_object_set_field_by_name` calls, runs cleanly, output matches Node. **Regression sweep clean under WB on**: 10/10 `test_json_*.ts` match Node byte-for-byte; runtime tests 155/155 unchanged (sub-phase C1 6 tests still cover the runtime side); `bench_json_roundtrip` best-of-5 WB-off 66 ms vs WB-on 65 ms — within noise because the bench doesn't write object fields on the hot path (parse+stringify only). The barrier's per-call cost (one bitcast + one extern call + the runtime's O(blocks) old-vs-young range scan) is currently the dominant overhead per heap store; sub-phase C3's `GC_FLAG_YOUNG` bit-test will replace the range scan with a single conditional branch. **Next**: sub-phase C3 (minor GC implementation — scan precise roots + remembered set, evacuate survivors to old-gen, clear RS, gated behind `PERRY_GEN_GC=1`). C3 is where the precision and arena split actually become useful — bench_json_roundtrip direct-path RSS should drop to ≤70 MB per the Phase C ship criterion.
153
154
- **v0.5.223** — Gen-GC **Phase C sub-phase 1**: write barrier runtime infrastructure (per `docs/generational-gc-plan.md` §Phase C). New thread-local `REMEMBERED_SET: RefCell<HashSet<usize>>` in `crates/perry-runtime/src/gc.rs` holds OLD-gen `GcHeader` addresses that have been written with a YOUNG-gen pointer since the last minor GC. New `js_write_barrier(parent_bits: u64, child_bits: u64)` C-ABI entry — fires on every heap store once sub-phase C2 wires the codegen emission. Decode logic via new helper `decode_heap_addr(bits)` correctly handles all four heap-pointer NaN-box tags (POINTER_TAG / STRING_TAG / BIGINT_TAG) and returns 0 for non-pointers (numbers / booleans / undefined / null / SHORT_STRING_TAG SSO inline data / INT32 / primitives). Old-vs-young check uses two new `arena.rs` predicates `pointer_in_nursery(addr)` and `pointer_in_old_gen(addr)` — currently O(blocks) range-scans; will be replaced by a single `GcHeader::gc_flags & GC_FLAG_YOUNG` bit-test in sub-phase C3 once the nursery alloc path sets that flag. False positives (parent appears in RS but no longer holds the young child) are correctness-safe — minor GC scans extra; false negatives would skip a live young object and break correctness. HashSet of u64 GcHeader addresses dedups across NaN-box tag variants of the same heap pointer. Test-only helpers `remembered_set_size()` + `remembered_set_clear()` for unit-test isolation. 6 new unit tests pin the barrier semantics: old→young records (and dedups on duplicate calls), young→young skipped (no inter-gen edge), old→old skipped, old→young with STRING_TAG child also fires, non-pointer children (INT32 / SSO / number) all skipped, manual clear empties the set. Also declared in `crates/perry-codegen/src/runtime_decls.rs` so codegen can reference it; emission at heap-store sites is sub-phase C2. Runtime tests 149 → **155** (+6 write-barrier). 10/10 JSON regressions clean. No behavior change yet — barrier is a runtime function with no caller until C2 wires it up. Once C2 + C3 land, RS-scan during minor GC eliminates the need to walk the entire old-gen on each young-collection cycle, and `bench_json_roundtrip` direct-path RSS should drop to ≤70 MB per the Phase C ship criterion.
0 commit comments