perf(gc, codegen): recover the instruction cost of the 56 B → 48 B header shrink (#8122) - #8204
Merged
Conversation
added 12 commits
August 16, 2026 11:46
… words (56 B -> 48 B)
`ObjectHeader` becomes `{class_id @0, parent_class_id @4, keys_array @8,
meta @16}` — 24 bytes on LP64, 16 on ILP32. A two-slot object goes from 56 to
48 bytes and the eight-slot case from 104 to 96. Removing either word alone
saves nothing (the struct re-pads), so this is one indivisible change.
Both words were derivable:
* the receiver KIND is `GcHeader.obj_type` plus the immutable ShapeId
descriptor's `object_kind`;
* the live inline-slot bound is that descriptor's `live_inline_slot_count`.
Nine sites read raw offset 0 to answer "is this an Error?" — two more than
previously catalogued (`promise/rejection.rs` x2). Since `OBJECT_TYPE_ERROR` is
2 and class ids are handed out from 1 in declaration order, leaving any of them
would have reclassified every instance of the second class a program declares
as an `ErrorHeader`. They now go through `error::ptr_is_native_error()`.
Publication is mint-then-stamp throughout: the descriptor is the only record of
the live slot bound, so a stamp-cleared window is a window in which the
collector traces zero payload slots.
Refs #8113, #8047.
Adds the wide-case (8-slot) footprint assertion — 96 bytes, isolating the header term from the INLINE_SLOT_FLOOR padding term — and an offsets test that names the field that moved rather than only the total. Plus the changelog fragment. Refs #8113.
Measured on the 19-program corpus: the first cut of #8113 regressed instructions retired by up to +30% (deeplist +30.5%, cycles +28.4%, tree +25.4%) while delivering the RSS win. The cause was mechanical, not inherent. * Five GC-side sites already read the bound descriptor-first and used the header word as an `unwrap_or` fallback. `unwrap_or` is EAGER, so the substitution made every call do TWO shape-table probes — and one of them, `gc/layout.rs`'s `layout_note_slot`, runs on every object field store. With the word gone the fallback could only return 0, so they now do. * `weakref::is_weak_target_trace_slot` (per traced slot) went from three probes to one. * Six write paths read the bound twice — once for `alloc_limit`, once for the widen test. They read it once. * `object_live_slot_count` gains a 64-way direct-mapped ShapeId -> count memo. It needs no invalidation: ids are never reused and the bound is part of the exact facts an id is minted for. The two test helpers that DO break that premise (`test_clear_shape_table`, `test_drop_shape_descriptors`) clear it. Refs #8113.
Built, sabotage-tested (the way-collision test goes red when the id check is removed) and measured on the 19-program corpus against the same baseline: row with memo without retain +4.26% +3.26% retain_wide +4.46% +2.89% retain_wide1 +4.18% +2.61% deeplist +8.69% +8.20% shapes +1.85% +4.96% Worse on four of the five rows that pay the bound at all, better on one. The memo pays its own TLS resolution and a closure, which is most of what `state()` plus a small `HashMap<u32, _>` probe costs. Deleted rather than left in as an unmeasured configuration; the measurement is kept as a doc comment so the next person does not rebuild it. Refs #8113.
It was added with the rest of #8113's live-slot API and never called: every alloc_limit site computes max(bound, INLINE_SLOT_FLOOR) from a bound it already has in hand after the CSE pass. Removing an uncalled function cannot change the generated code — verified: libperry_runtime.a stays byte-identical to the artifact the corpus numbers were measured on. Refs #8113.
…holds A per-callsite counter (#[track_caller] + libc::atexit, on tls_hot.rs's pattern) found `object_is_regular` firing EXACTLY ONCE PER ALLOCATED OBJECT from proxy.rs's #6595 store-plan gate: 3,000,000 calls on retain, 20,000,002 on churn, and still 1.00 per object on retain_wide's 8-field literals — the per-object, flat-in-width signature the corpus showed. That gate used to be `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free u32 compare on the word this rung deleted. The call site has already read the very same GcHeader for its blocking-flags test, so `object_is_regular_with_header` takes it instead of re-deriving it through `try_read_gc_header` (handle-band check, heap-range check, small-buffer-slab check, reload). The predicate is character-for-character unchanged, so #6595 stays closed. `interned != 0` — a free compare that sat AFTER the probe in the && chain — moves ahead of it. The remaining shape-table probe is NOT removed here: every cheap substitute (the narrow PLAIN_ORDINARY_OBJ_FLAG birth marker, a global has-class-objects short-circuit) changes the answer for some receiver class, and that is a #6595-adjacent design call rather than a mechanical fix. The census follows the predicate to its new home and gains a sabotage test that the two spellings cannot drift. Refs #8113.
…already holds" This reverts 599fe97. The change was argued to be semantically free — same predicate, strictly less work — and it MEASURED as a reproducible regression: row pre-fix post-fix (3-run best-of, quiet host) interp +0.29% +9.59% pipeline +0.34% +4.43% retain +3.26% +3.04% deeplist +8.20% +9.31% It did not help the rows the per-callsite counter said it would (retain moved 3.26 -> 3.04, inside noise) and it cost ~1.25 BILLION instructions on interp. The predicate is provably unchanged (same `&&` chain over pure operands, and the removed `try_read_gc_header` had already been performed by the caller), so the mechanism is a codegen/inlining effect, not semantics — plausibly the inlined shape probe bloating proxy.rs's hot path for interpreter-shaped workloads. That is a hypothesis, not a finding. Reverting rather than shipping an unexplained regression under a 'free' label. The underlying cost is real and localised; it belongs in the follow-up issue with the other two candidates, where it can be measured on its own. Refs #8113.
…he zero Adds the per-callsite counter result to the fragment: the residual is one site (proxy.rs's #6595 store-plan gate, one probe per allocated object, flat in width), `object_live_slot_count` is called ZERO times on every hot row so a memo in front of it is structurally pointless, and the 'free' repair for the site measured as an interp +9.59% regression and was reverted. Refs #8113, #8125.
… from one ShapeId descriptor probe #8094 landed after #8113's base and reads both deleted header words (`object_type`, `field_count`). Route it through the descriptor — one `object_shape_descriptor` probe per guarded object (kind + live bound), and `own_data_field` reads inline slots against that bound (`object_field_at_with_live`, now also `js_object_get_field`'s body) instead of a per-field `js_object_get_field` that re-probed. Measured: `interp` +3.3% / `iso_miss` +2.8% -> -0.0% / -0.1% vs main.
…eader shrink Every regressed corpus row measured to a mechanism and fixed; the shrunk representation is now at or below main on instructions with the whole footprint win intact: * the FIRST copying minor fired on a 16 MB BYTE cap before any object census (seeded at 72 B), so smaller objects put 17% more objects into the one TRACED cycle, at ~1,600 instructions per traced object because the collector resolved the ShapeDescriptor five times per object -> allocation census before minor #0 (gc/tenuring.rs, arena/walk.rs), one descriptor lookup per traced object (gc/layout*.rs, object/gc_slots.rs), untraced threshold 990 -> 980 (its first cycle reads 988 object-denominated); * +4.5 instructions per inline `new`: with `object_type` gone the two header words no longer merged into one constant-pool vector store, so LLVM rematerialised the 40-bit GcHeader constant per allocation -> a per-class <2 x i64> header image composed once at module init (target_layout::inline_alloc_gc_packed shared by site and table); * LTO folded the typed-shape install tail into the per-construction hot path between two builds of the same code (pipeline +3.9%) -> #[cold] #[inline(never)] install_typed_shape_layout_slow; * the property-get IC-miss path, the by-name slow scan and js_method_direct_shape_class probed two to three times per call -> once. Measured vs main@bfb0707be (instructions / peak footprint): deeplist -17.2% / -17.7%, retain1 -11.6% / -5.6%, retain -6.8% / -9.7%, shapes -1.7% / -7.2%, tree -0.2% / -12.9%, cycles -0.3% / -29.6%, pipeline -0.3% / -9.9%, push_cls +0.3% / -9.5%. Full table and method in changelog.d/8122-recover-header-shrink-instruction-cost.md.
…ecovery) Captured on the pinned quiet host (Apple M1 mini, load 2.18) at f2ab194 with the shipped 7 repeats. Every GC-accounting fingerprint moves because the allocation census before minor #0 object-denominates the first nursery cap (the first cycle fires earlier on small-object workloads) and the untraced threshold is 980. Retention: 12_large_live_set heap_used -75%; 13_large_eden_survivors +85 KB because its cycle 0 now holds up an in-place promotion at 581 permille (its 64 MB cap becomes ~49 MB object-denominated) instead of rolling back at 470 — main at cap 49 does the same and retains 651 KB, so this is the regime, not the code. All other retention cells 0%.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (127)
📝 WalkthroughWalkthroughThe PR shrinks ChangesObjectHeader and ShapeId migration
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
… StringHeader It accepted any non-finite NaN-box with a plausible payload, so mkdir_mode_from_options's string_value(options) read a StringHeader off the OPTIONS OBJECT. On main that misread byte_len from ObjectHeader::class_id (a small number, harmless garbage); with #8113's layout it reads the ShapeId (0x8000_0000+) and every fs.mkdirSync(dir, { recursive: true }) segfaulted in a 2 GB from_utf8_lossy — test_gap_fs_fd_2749 and both fs_errprop gap tests CRASH on the held #8122. Heap-STRING_TAG only now; string_value and stream::bytes_from_value go through str_bytes_from_jsvalue so inline SSO strings are read correctly instead of as garbage pointers; numeric_fd_value uses is_any_string.
proggeramlug
force-pushed
the
perf/8122-recover
branch
from
August 16, 2026 13:16
1af8c6c to
2bd4f0a
Compare
proggeramlug
marked this pull request as ready for review
August 16, 2026 13:18
This was referenced Aug 16, 2026
Closed
fix(async): release and reuse a completed plain-async activation's box cells (#7933 follow-up)
#8208
Merged
proggeramlug
added a commit
that referenced
this pull request
Aug 16, 2026
…line cap (unblocks lint on main) (#8212) * refactor: split gc/layout.rs and codegen/artifacts.rs under the 2000-line cap #8204 pushed both files over scripts/check_file_size.sh's hard cap (layout.rs 1975 -> 2110, artifacts.rs 2000 -> 2005), turning the required lint context red on main for every PR. Pure code moves, no logic change: - gc/layout.rs: the typed-shape layout installation protocol (TypedShapeProof, mask_words, init_typed_shape_layout, install_typed_shape_layout_slow, typed_shape_layout_entry, js_gc_init_typed_shape_layout, js_gc_declare_typed_shape_layout) moves to gc/layout/typed_shape.rs, next to the existing layout/slot_mask.rs. 2110 -> 1778 lines. The two extern "C" entry points keep their crate::gc:: paths via an explicit named re-export. - codegen/artifacts.rs: synthesized_ctor_param_count moves to a new sibling codegen/ctor_arity.rs. 2005 -> 1930 lines. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * chore: refresh shape-descriptor census baseline for the moved keys_array site Pure path rename in the exact callsite multiset: the one keys_array access inside the moved typed-shape install block now lives in gc/layout/typed_shape.rs (raw_member_files 65 -> 66, total sites unchanged). Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: add changelog fragment for #8212 Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196, neither of which moved it), and reports instructions and peak RSS together per corpus row against a stated noise floor. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196, neither of which moved it), and reports instructions and peak RSS together per corpus row against a stated noise floor. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Rebase moved the base to current main, so both arms were rebuilt there and the whole measurement retaken. Counters are bit-identical (releases == allocs, residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of #8204/#8196/#8211/#8212/#8162 moves this residue. Also records, rather than rounds away, the fixed +80 KB per-process startup cost the change adds: it is page-granular first touch, not code size (binary +80 B, __TEXT unchanged) and not the pool data (144 B of empty Vec headers). Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
This was referenced Aug 16, 2026
proggeramlug
added a commit
that referenced
this pull request
Aug 16, 2026
…ceipt (#8214) * test(gc-ratchet): stop the self-tests demanding a selective re-pin receipt `windows-build` is red on every open PR. It fails at "GC structural audits (Windows)" with three errors in the ratchet's own test suite: KeyError: 'accepted_deterministic_deltas' FAILED (errors=3, skipped=11) The artifact is not at fault, and neither is #8204. `accepted_deterministic_ deltas` is the receipt for a SELECTIVE re-pin -- the dangerous kind, which can turn one red row green while leaving no machine-readable answer to which rows moved or why. A FULL re-pin carries artifact-wide provenance instead. The inspector's own docstring says so ("Older and synthetic artifacts may omit the receipt") and the validator implements it: `if receipt is None: return`. #8204 moved 130 of 168 cells -- a full re-pin -- so it correctly shipped no receipt. Three tests here hard-subscripted the key on the *live pinned baseline* and errored. The gate punished the correct action. What those tests actually pinned was one historical selective re-pin: #8069's exact 21 cells and causes {7928, 7960, 7961}, frozen into assertions against whatever baseline happens to be current. That is a snapshot, not an invariant. It could only stay green by the world never changing, and any later full re-pin breaks it by construction. So: - The two tamper tests (a receipt disagreeing with the pin; a malformed timestamp) are genuinely valuable -- they test the VALIDATOR. They now build their fixture synthetically from the pin rather than assuming the pinned artifact carries a receipt. A fixture taken from the artifact under test cannot independently test it. Two cells, not one, so an inspector that validated only `cells[0]` would not pass. - #8069's specific 21 cells are gone. The durable invariant they reached for stays: a receipt, IF present, must name real probes/metrics, agree with the pinned medians, and reference declared causes. - Added the case #8204 exercised and nothing covered: a full re-pin with no receipt is VALID. That contract existed only as a docstring, which is why the trap was armed. Without this test, the next full re-pin reds the gate again. Sabotage-tested, because three assertions that cannot fail would be worse than the errors they replace. Baseline: all three pass. Remove the pinned-median comparison and the disagreement test fails; accept any timestamp and the timestamp test fails; make a missing receipt a defect and the full-re-pin test fails. 98 tests, OK (1 skipped -- the receipt-present invariant, correctly skipped while the pin is a full re-pin). * docs(changelog): add the 8214 fragment --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196, neither of which moved it), and reports instructions and peak RSS together per corpus row against a stated noise floor. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Rebase moved the base to current main, so both arms were rebuilt there and the whole measurement retaken. Counters are bit-identical (releases == allocs, residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of #8204/#8196/#8211/#8212/#8162 moves this residue. Also records, rather than rounds away, the fixed +80 KB per-process startup cost the change adds: it is page-granular first touch, not code size (binary +80 B, __TEXT unchanged) and not the pool data (144 B of empty Vec headers). Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug
added a commit
that referenced
this pull request
Aug 16, 2026
…codegen emits (#8228) (#8241) * fix(codegen): teach the in-process IR reader the vector instructions codegen emits The dialect reader had no `insertelement` case, so `#8204`'s object-header image compose fell through to the binary-op arm and failed every module large enough to split across native codegen units. Adds `insertelement`, `extractelement`, `shufflevector`, vector-typed `add`/`mul`, constant vector literals, and vector `poison`/`undef`/ `zeroinitializer` — the closed set perry-codegen actually emits. Also replaces the reader's snapshot-only gate with a live emit -> re-parse test, so the next new emission form fails in `cargo-test` rather than in a user build of a multi-unit module. * docs(changelog): fragment for #8241 --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196, neither of which moved it), and reports instructions and peak RSS together per corpus row against a stated noise floor. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug
pushed a commit
that referenced
this pull request
Aug 16, 2026
Rebase moved the base to current main, so both arms were rebuilt there and the whole measurement retaken. Counters are bit-identical (releases == allocs, residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of #8204/#8196/#8211/#8212/#8162 moves this residue. Also records, rather than rounds away, the fixed +80 KB per-process startup cost the change adds: it is page-granular first touch, not code size (binary +80 B, __TEXT unchanged) and not the pool data (144 B of empty Vec headers). Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj
proggeramlug
added a commit
that referenced
this pull request
Aug 17, 2026
…x cells (#7933 follow-up) (#8208) * fix(async): release a completed plain-async activation's box cells for reuse (#7933 follow-up) The async-to-generator transform's #7933 release cleared cells but kept them registered and malloc-resident forever: ~500 B of cell + registry bytes per completed activation, ~119 MB over an asyncpipe_big run whose live heap is ~250 KB. Replace the LocalSet(id, undefined) release with a Stmt::ReleaseBoxes HIR statement that codegen lowers to js_*box_release: clear + de-register + park the cell in a quarantine that drains into a per-kind free pool at the outermost microtask-pump boundary once the task queue is empty; js_*box_alloc* then reuses pooled cells instead of touching std::alloc. Also release the state-machine control cells, with parked values chosen so a stray duplicate resume takes byte-for-byte the pre-release terminal path (bool cells park true = the done short-circuit; i32 cells park -1 = no dispatch case). Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * test(transform,runtime): cover the ReleaseBoxes shape; route release plausibility through the canonical predicate Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * test(codegen): pin the ReleaseBoxes lowering — kind selection, capture path, hint skip Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: changelog fragment for #8208 Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * fix(async,gc): close the ReleaseBoxes id-remap holes and re-argue the box exemption Follow-up hardening on the #8208 release/reuse change, from an audit of the 94 exhaustive-match arms the new `Stmt::ReleaseBoxes` variant required. Six sites were NOT among those 94, because `ReleaseBoxes` falls into a pre-existing `_ => {}` catch-all there — so rustc said nothing. Three of them renumber LocalIds, which is exactly the case the variant's own doc comment declares incorrect: an unremapped `PreallocateBoxes` merely allocates a cell nobody reads, but an unremapped `ReleaseBoxes` releases a STILL-LIVE local's cell and hands it to the next allocation. None is reachable today — intra-module inlining runs before the async transform, the cross-module harvest refuses bodies containing a release, and the two max-id scans feed a `next_local_id` computed earlier — but that safety rests entirely on pipeline ordering that nothing enforces. Remapped rather than left latent: - `inline/substitute.rs` `substitute_locals_in_stmts_inner` — the neighbouring prealloc arm already remaps (issue #569); the release now does too. - `perry-hir/src/analysis.rs` `remap_local_ids_in_stmt{,_propagating}` — the canonical HIR remappers, whose own doc says to keep the variant list in sync. - `generator/per_iteration.rs` `rename_in_stmt` — a LocalId renamer inside the generator transform itself; its `each_expr_mut` helper only reaches ids that live inside an Expr, so all three bare-id-list variants were walked past. - `generator/id_scan.rs` and `deforest/walk.rs` max-id scans now include the release ids, matching the deliberate #1029/#5143 defence on the prealloc arm. - `perry-codegen/src/boxed_vars.rs` keeps NOT collecting release ids (a reclamation hint must not decide a local's representation) but says so explicitly instead of falling into the catch-all. The invariant those last two lean on — the transform never releases an id it did not also preallocate, or `emit_release_boxes` skips it and the release goes silently inert with every test still green — is now asserted in both directions (`every_released_id_is_also_preallocated`, with vacuity guards). gc_root_dominance_check.py: - The "box" immovable-source exemption rested on "boxes are never freed", which this PR falsified, while its probe only grepped for `dealloc(`/`arena_alloc(` — all of which a *recycle* path passes. The exemption stayed green on a dead premise, which the script's own docstring calls strictly worse than no exemption. Re-argued on the property #8208 actually preserves (cell memory is never returned to the allocator, so an address never stops naming box-cell memory and can never become another kind of object), and the probe now also requires the reuse path to stay quarantine-gated. Sabotage-tested: bypassing the quarantine and introducing a real `dealloc` each turn it red. - Added the three `js_*box_release` names to NONCOLLECTING. This PR had added them to `gc_call_effects.rs` only, breaking the documented one-way containment — the same one-sided drift that cost #7510 358 spurious violations. `cannot_collect_stays_a_subset_of_the_checker_authority` now machine-checks that relation instead of trusting four comments that assert it. Also refreshes the monotonicity docs the release invalidated, including the load-bearing correctness argument in `expr/literals_vars.rs` that let a `box_ptr` outlive a collecting call on the strength of "never freed". Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * fix(hir,transform): scope the new id-remap arms strictly to ReleaseBoxes The previous commit grouped `ReleaseBoxes` with `PreallocateBoxes` / `PreallocateTdzBoxes` in `analysis.rs`'s two canonical remappers and in `per_iteration.rs`'s renamer. In those three places the prealloc variants were previously UNHANDLED, so the grouping quietly started remapping them too — a behaviour change to existing programs riding along inside a PR about a new statement variant. That prealloc gap is real but pre-existing and benign in its failure direction: an unremapped prealloc allocates a cell nobody reads, whereas an unremapped release frees a live local's cell. Closing it can shift codegen and deserves its own evidence, so it is documented at both sites and left alone. With this, the hardening changes alter behaviour only for `ReleaseBoxes`, which no pass in the tree can reach today — so they cannot move codegen output at all. The sites where `ReleaseBoxes` was grouped with an arm that ALREADY handled the prealloc variants (`inline/substitute.rs`, `generator/id_scan.rs`, `deforest/walk.rs`) are unaffected and keep the grouping. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: re-measure #8208 on 07c8040 and record the hardening Corrects the residue figure (65,906 -> 65,915 after the rebase onto #8204/#8196, neither of which moved it), and reports instructions and peak RSS together per corpus row against a stated noise floor. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: record the flush-boundary limitation and the exit-path coverage Adds the measured degenerate case (an await cascade with no timer or I/O never reaches the flush boundary, so releases are performed but never harvested: +1.32% instructions, +0.3 MB RSS) and the seven-shape exit-path fixture that matches the Node oracle byte-for-byte on both arms. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * test(gap): pin every async exit path across the #8208 box release Behavioural half of the #8208 gate. Drives normal return, throw after an await, early return from inside a loop after a suspend, await on a rejected promise, try/finally across a suspend on both terminal arms, loop-created closures capturing a per-iteration binding across a suspend, and async-generator .return() versus a full drain — 400 iterations each — and prints values that only come out right if every cell outlived its last reader. A cell released while still reachable, or reused by a second live activation, is a wrong answer rather than a crash, which is why this asserts printed values against the Node oracle instead of merely running to completion. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: re-measure #8208 with both arms rebuilt at b8d32ab Rebase moved the base to current main, so both arms were rebuilt there and the whole measurement retaken. Counters are bit-identical (releases == allocs, residue constant at 65,915) and peak RSS reproduces within 0.3 MB, so none of #8204/#8196/#8211/#8212/#8162 moves this residue. Also records, rather than rounds away, the fixed +80 KB per-process startup cost the change adds: it is page-granular first touch, not code size (binary +80 B, __TEXT unchanged) and not the pool data (144 B of empty Vec headers). Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * perf(runtime): thread the box reuse pool through the cells, deleting the side table The free pool was a `Vec<usize>` per kind: one 8-byte slot per pooled cell, on top of the cell. Its high-water mark is ~330 cells per unit of PEAK CONCURRENCY (measured: resident_cells/SIZE is 329-334 across a 16x sweep of the fan-out width), held for the life of the thread, so at SIZE=200 it was ~1 MB of side table and made small async workloads a net RSS REGRESSION. A free cell's own 8 bytes are dead, and every box kind is exactly pointer-sized (now asserted at compile time), so the free list is threaded through the cells themselves and costs zero side-table bytes. Overwriting the cell is why only POST-QUARANTINE cells join the list: a quarantined cell must keep the parked terminal value a stray duplicate resume reads, and `flush_released_boxes` publishing it is exactly the point at which the task queue is empty and no such resume can exist. The checker probe is updated to fail if a release ever publishes directly. The quarantine is deliberately NOT shrunk on flush: it refills to the same size every interval, and handing the buffer back cost +5.3 MB peak RSS at BATCHES=1200 in allocator churn (measured). Measured on asyncpipe, matched arms at b8d32ab (peak RSS, best-of-5): BATCHES 30 60 90 120 300 600 1200 delta MB +0.80 +0.92 -0.19 -0.19 -8.17 -25.06 -69.73 Crossover moves from ~200 batches to between 60 and 90, and the 1200 row improves from -63.8 MB to -69.7 MB. stdout is byte-identical at every size. The residual sub-crossover cost is NOT this pool -- see the changelog. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: record the RSS sweep, the remaining floor, and why a cap cannot fix it Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: record why no earlier publish point is safe (per-kind split refuted) Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * docs: final numbers on matched 9233429 arms; gc-ratchet shared_ci OK Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj * fix(async): publish box cells at activation reachability zero * test(async): close PR review and CI coverage gaps * ci: classify the stale loop safepoint assertion * ci: record inherited codegen integration failures * fix(async): complete final review coverage --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
11 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacks on #8122 (this branch is #8122 rebased onto current
main, plus therecovery). Closes the hold on #8122: the header shrink now costs no
instructions on the corpus — the rows that regressed most are now the fastest
rows on the branch — with the whole footprint win intact and several rows'
peak footprint lower again.
Numbers (this branch vs
main, same host, best-of-3, instructions retired and peak memory footprint together)deeplistretain1retainshapesretain_wide/retain_wide1interp/iso_miss/pipelinetree/tree_widecyclespush_cls/churn_alloc/churnpush_num/churn_read/fib40asyncpipeA=main@bfb0707be,B= this branch at that base; an independentbest-of-5 on the quiet bench mini (same protocol, another session) reads the
corpus SUM at −0.17% instructions / −7.28% peak RSS and reproduces
deeplist−17.8% / −17.8%.Both arms built from their own tree with the same
-p perry -p perry-runtime-static -p perry-stdlib-static,PERRY_RUNTIME_DIRandPERRY_CACHE_DIRpinned per arm,PERRY_NO_AUTO_OPTIMIZE=1, the twolibperry_runtime.acmp-verified todiffer, and all 19 corpus stdouts byte-compared against node's expected output
and exit-checked in every arm. Instructions retired are load-independent
(run-to-run spread 0.1–0.9% on these rows, higher on the sub-second ones);
peak footprint is
/usr/bin/time -l's.asyncpipe's +0.6% instructions isinside its 1% spread; the churn family's +0.1–0.3% is inside theirs.
What the instructions actually were
The residual had been attributed to shape-table probes replacing the deleted
field_countword (#8125). Measuring each row — GC traces (PERRY_GC_TRACE=1 PERRY_GC_DIAG=1) diffed between arms, an N-sweep to split mutator fromcollector,
sampleonCARGO_PROFILE_RELEASE_STRIP=none+PERRY_DEBUG_SYMBOLS=1binaries with equal coverage per arm,
--trace llvmdiffs and finallyotool -tVdiffs where the IR was identical — says four different things, andthe probe was the whole story on none of the big rows:
deeplist/retain1/retain: the FIRST copying minor wasbyte-denominated. The mutator with no GC at all is byte-identical between
arms (
deeplistat 250k objects: 151.7 M vs 151.5 M instructions). Botharms run exactly two minors — but the first fires when Eden holds 16 MB,
before any object census exists (
MEAN_SURVIVING_OBJECT_BYTESis seeded atthe 72 B reference), so 48 B objects put 371k objects into it where
56 B put 318k. And that first cycle is the one that must trace (no
survival estimate yet): a traced in-place-promotion cycle cost ~1,600
instructions per object, because it resolved the receiver's
ShapeDescriptorfive times per traced object (gc_field_slot_range,gc_keys_array_slot, the slot visitor's own,object_keys_array_ptr, andwith_shape_shared_descriptor's bound check) plus ahot_shape_layoutsprobe. 53k extra objects × that price is the whole +100 M.
push_cls/churn_alloc/churn(+5.5%, zero RSS): an LLVMstore-merging artefact, +4.5 instructions per
new. IR identical modulooffsets (the shrunk arm even had one store fewer); the machine code was not.
Before, both header words were compile-time constants and LLVM merged them
into one 16-byte constant-pool store (
ldr q0; str q0); after, the secondword is
class_id | ShapeId << 32with the ShapeId from a global, nothingmerges, and the 40-bit
gc_packedimmediate is rematerialised (mov+ twomovk) at every allocation.interp/iso_miss(+3%): a consumer that landed after the PR.perf(codegen): guarded ordinary-parameter specialization #8094's
param_type_guard::plain_objectread both deleted words; the rebaseturned two free
u32loads into two probes plus a re-read of thealready-validated GcHeader, and its per-field
js_object_get_fieldreadsprobed once more each.
pipeline: +3.9% between two builds of the SAME hot-path code, differingonly in an unrelated module's size — LTO had folded
shape_install_shared+recordintoinit_typed_shape_layout, turning the per-constructionmemo-hit path into an 811-instruction function whose prologue and spills
were paid on every hit. (This is the mechanism behind perf(proxy): the #6595 store-plan gate costs one shape-table probe per allocated object #8125's "candidate (1)
looked free and cost
interp+9.6%": inlining, not semantics.)The changes
gc/tenuring.rs— allocation census before the first minor. Halfway tothe base cap (8 MB of from-space), once per process, hop the young
generation's headers (
arena::young_allocation_census, ~1 M instructions)and seed the object denomination with the allocated mean, so the first
cycle buys the same object budget every later one does. The collector's
survivor census overwrites it at the first minor; the one-sided clamp still
applies. This is also where the extra footprint wins come from — the first
minor fires earlier on small-object workloads (
cycles−29.7%,pipeline−10%, the churn family −9.5%,
interp/iso_miss−7%).gc/promote_in_place.rs—UNTRACED_PROMOTION_SURVIVAL_PERMILLE990 → 980.The 992 that 990 was read off came from a first cycle at the raw 16 MB band;
object-denominated,
retain/retain1's first cycle reads 988 (the same~131 KB of abandoned
all.pushbacking stores over a smaller nursery) and at990 their second cycle traced again (
retain1+13%). Its own exposure boundbecomes 2.56 MB against the same 32 MB cap; the untraced-bytes budget stays
binding. Doc fact,
check_gc_doc_claims.pyand the threshold-shaped testsupdated to the constant.
gc/layout.rs,gc/layout_slot_visit.rs,object/gc_slots.rs— onedescriptor lookup per traced object.
gc_child_slotsresolves thereceiver's
ShapeDescriptoronce and threads it through the field range, thekeys slot, the shared pointer-mask selection (
HeapChildSlotIterator::new_object)and the slot visitor.
with_shape_shared_descriptordrops from two probes toone for every field store that reaches it too;
object_keys_array_ptris gone.gc/layout.rs—init_typed_shape_layoutsplit: the memo-miss installtail is
install_typed_shape_layout_slow,#[cold] #[inline(never)], so theper-construction hit path keeps its shape whatever LTO decides elsewhere.
lower_call/new_alloc.rs,codegen/mod.rs,codegen/string_pool.rs,function.rs,target_layout.rs— the header image. The 16-byte prefix[gc_packed | class_id | ShapeId << 32]is composed once at module init,beside the ShapeId mint, into a per-class
<2 x i64>global, fromtarget_layout::inline_alloc_gc_packed— the single definition of the packedword, which the allocation site also uses. The inline allocator entry-hoists
that global like the keys global and stores it with one vector store; the
site cross-checks the table's packed word and class id against its own
derivation and falls back to a per-function compose if they differ, and the
table only lists classes whose image module init actually writes. A
per-function compose was the first cut: it fixed the loops but not
recursion (
treeallocates once per call: +0.6%), hence module init.param_type_guard.rs— one probe per guarded object;own_data_fieldreads inline slots against the bound
plain_objectalready resolved(
object_field_at_with_live, now alsojs_object_get_field's body).object/field_get_set/ic_miss.rs,get_field_by_name_tail.rs,typed_feedback/guards.rs— one probe per call on the property-get IC-misspath (was three), the by-name slow scan (was two, one into an unused
binding, plus one inside every field read it returned through) and
js_method_direct_shape_class(was two). That is what turnedshapesfrom+2.8% into −1.9%.
Tests and gates
allocation_census_seeds_the_first_cap_before_any_minor(real nursery,seed equals an independently recomputed header-walk mean and differs from the
72 B seed, cap moved before any collection, walk is one-shot),
allocation_census_seed_is_gated_and_one_shot,the_inline_allocator_stores_its_header_prefix_as_one_vector_image(exactlyone compose, in module init after the mint; the site loads the global; the
allocating function composes nothing itself).
cargo test -p perry-runtime --lib: 2482 passed / 0 failed / 4 ignored (with every runtime change).cargo test -p perry-codegen: same 9 pre-existing failures asmain(verified by running both trees); the twotyped_shape_bake_testsnow assert the packed word inside the module-init<2 x i64>compose.retain/tree/churn/shapes/deeplist/retain1/push_cls/interp× {FORCE_EVACUATE+VERIFY_EVACUATION,PROTECT_FROMSPACEdepth 32, both} — 24 runs byte-exact, rc 0, with the instruments demonstrably live (retaincopies 237k objects per cycle under forced evacuation; 87[gc-fromspace-protect]retire lines onchurn,bytes_protected=16 MB).cargo test -p perry-ffi --features runtime-link --lib: 51/0.benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json,captured on the pinned quiet mini, load 2.18, 7 repeats): every GC-accounting
fingerprint shifts as the pacing change predicts; retention moves on two
probes only —
12_large_live_setheap_used−75%, and13_large_eden_survivors+85 KB, because its cycle 0 now holds up anin-place promotion at 581‰ (its 64 MB cap becomes ~49 MB object-denominated)
instead of rolling back at 470‰. That is the regime, not the code:
main'sown binary at
PERRY_GC_SCAVENGE_NURSERY_MB=49promotes in place at 610‰and retains 651 KB.
gc_ratchet.py check --profile shared_cion the rebasedtree with 7 repeats: OK.
cargo fmt --check,check_file_size.sh, and thelintpython gates allclean, including
shape_descriptor_census.py(baseline refreshed for onereformatted
layout.rsread and the newtarget_layout.rsheader-size use).real defect turned up:
test_gap_fs_fd_2749and bothfs_errproptestscrash on the held perf(object): remove the derivable object_type and field_count header words (56 B -> 48 B) [HELD: #8157 refuted; footprint-coupled residual + new #8094 guard cost] #8122 (see next section); fixed here, the whole
fsfamily passes, and every remaining gap mismatch reproduces on
main's ownbinary (A/B'd test by test); 3 more that failed in-suite pass in isolation
on both arms (host-load flakes).
A crash the shrink exposed (fixed here)
fs::extract_string_ptraccepted any non-finite NaN-box with a plausiblepayload — no
STRING_TAGtest — somkdir_mode_from_options'sstring_value(options)read aStringHeaderoff the options object. Onmainthat misreadbyte_lenfromObjectHeader::class_id(a small number:a harmless one-byte garbage string that
parse_mode_stringrejected). Withthe #8113 layout the same read lands on the ShapeId (
0x8000_0000+),from_utf8_lossywalks 2 GB, and everyfs.mkdirSync(dir, { recursive: true })segfaults. It is the tenth offset-punning site — the census could not seeit because it reads a different struct through the pointer — and the gap
suite is what caught it. Fixed at the source (tag test before the read; the
two SSO-unaware callers go through
str_bytes_from_jsvalue).Not closed here
asyncpipepeak footprint +2.8% at 120 batches (+7.7% at 1200). The GC arenais identical between arms (same triggers, same 6,767 copies, 23 MB reserved);
at 1200 batches the footprint is ~100 MB of non-arena memory — the async
activation-box retention (
crate::box), growing at the same rate per MBallocated in both arms — and mimalloc's own peak (
MIMALLOC_SHOW_STATS) andmaximum resident set sizeare both lower for the shrunk arm (140.3 vs140.9 MiB; 147.0 vs 149.7 MB) while
peak memory footprintis higher. The twoOS metrics disagree in direction, i.e. this is about how much freed-but-resident
memory is marked reusable at the peak instant, not about more live data. Left
as measured, not explained.
A sequencing option for the maintainer
Over half of the headline is pacing that is representation-independent —
main's own two-field literal is 56 B against the 72 BNURSERY_CAP_REFERENCE_OBJECT_BYTESanchor, somainalready runs its firstcycle ~29% oversized by its own calibration (
PERRY_GC_SCAVENGE_NURSERY_MB=12on stock
mainbinaries buysdeeplist−11% instructions alone). If it ispreferable to judge the shrink purely on the size-proportional footprint it
delivers, the census + threshold + ratchet re-pin can be split out and landed
first as their own runtime PR, with the shrink rebased on top. This PR is
built so either order works.
Follow-ups worth filing rather than widening this: the untraced-promotion
predicate compares a survival RATIO whose denominator the pacing policy itself
just moved against an absolute numerator (~131 KB of startup garbage), so it is
a composition cliff by construction — re-denominating it in absolute implied-
dead bytes (the budget arithmetic already computes them) decouples it
permanently; and the ratchet could record each probe's survival reading and
fail when a gating probe sits within a few ‰ of a threshold.
No version bump.
Summary by CodeRabbit
Performance
Bug Fixes
Compatibility