Skip to content

perf(gc, codegen): recover the instruction cost of the 56 B → 48 B header shrink (#8122) - #8204

Merged
proggeramlug merged 13 commits into
mainfrom
perf/8122-recover
Aug 16, 2026
Merged

perf(gc, codegen): recover the instruction cost of the 56 B → 48 B header shrink (#8122)#8204
proggeramlug merged 13 commits into
mainfrom
perf/8122-recover

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Stacks on #8122 (this branch is #8122 rebased onto current main, plus the
recovery). 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)

row #8122 as held (B0 vs A) now: instructions now: peak footprint
deeplist +9.0% −17.2% −17.7%
retain1 +8.6% −11.6% −5.6%
retain +3.7% −6.8% −9.7%
shapes +3.1% −1.7% −7.2%
retain_wide / retain_wide1 +2.3% / +2.0% −0.6% / −0.3% −5.5% / −6.0%
interp / iso_miss / pipeline +3.3% / +2.8% / +0.4% +0.0% / +0.0% / −0.3% −6.8% / −7.3% / −9.9%
tree / tree_wide +0.6% / +0.5% −0.2% / +0.1% −12.9% / −6.4%
cycles +0.8% −0.3% −29.6%
push_cls / churn_alloc / churn +5.5% / +5.5% / +3.2% +0.3% / +0.2% / +0.2% −9.5% / −9.5% / −9.6%
push_num / churn_read / fib40 ~0 +0.1% / −0.0% / −0.1% ~0
asyncpipe +0.4% +0.5% +2.9% (see below)

A = main@bfb0707be, B = this branch at that base; an independent
best-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_DIR and PERRY_CACHE_DIR pinned per arm,
PERRY_NO_AUTO_OPTIMIZE=1, the two libperry_runtime.a cmp-verified to
differ, 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 is
inside 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_count word (#8125). Measuring each row — GC traces (PERRY_GC_TRACE=1 PERRY_GC_DIAG=1) diffed between arms, an N-sweep to split mutator from
collector, sample on CARGO_PROFILE_RELEASE_STRIP=none + PERRY_DEBUG_SYMBOLS=1
binaries with equal coverage per arm, --trace llvm diffs and finally
otool -tV diffs where the IR was identical — says four different things, and
the probe was the whole story on none of the big rows:

  1. deeplist / retain1 / retain: the FIRST copying minor was
    byte-denominated.
    The mutator with no GC at all is byte-identical between
    arms (deeplist at 250k objects: 151.7 M vs 151.5 M instructions). Both
    arms run exactly two minors — but the first fires when Eden holds 16 MB,
    before any object census exists (MEAN_SURVIVING_OBJECT_BYTES is seeded at
    the 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
    ShapeDescriptor five times per traced object (gc_field_slot_range,
    gc_keys_array_slot, the slot visitor's own, object_keys_array_ptr, and
    with_shape_shared_descriptor's bound check) plus a hot_shape_layouts
    probe. 53k extra objects × that price is the whole +100 M.
  2. push_cls / churn_alloc / churn (+5.5%, zero RSS): an LLVM
    store-merging artefact, +4.5 instructions per new.
    IR identical modulo
    offsets (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 second
    word is class_id | ShapeId << 32 with the ShapeId from a global, nothing
    merges, and the 40-bit gc_packed immediate is rematerialised (mov + two
    movk) at every allocation.
  3. interp / iso_miss (+3%): a consumer that landed after the PR.
    perf(codegen): guarded ordinary-parameter specialization #8094's param_type_guard::plain_object read both deleted words; the rebase
    turned two free u32 loads into two probes plus a re-read of the
    already-validated GcHeader, and its per-field js_object_get_field reads
    probed once more each.
  4. pipeline: +3.9% between two builds of the SAME hot-path code, differing
    only in an unrelated module's size — LTO had folded shape_install_shared +
    record into init_typed_shape_layout, turning the per-construction
    memo-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 to
    the 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.rsUNTRACED_PROMOTION_SURVIVAL_PERMILLE 990 → 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.push backing stores over a smaller nursery) and at
    990 their second cycle traced again (retain1 +13%). Its own exposure bound
    becomes 2.56 MB against the same 32 MB cap; the untraced-bytes budget stays
    binding. Doc fact, check_gc_doc_claims.py and the threshold-shaped tests
    updated to the constant.
  • gc/layout.rs, gc/layout_slot_visit.rs, object/gc_slots.rs — one
    descriptor lookup per traced object.
    gc_child_slots resolves the
    receiver's ShapeDescriptor once and threads it through the field range, the
    keys slot, the shared pointer-mask selection (HeapChildSlotIterator::new_object)
    and the slot visitor. with_shape_shared_descriptor drops from two probes to
    one for every field store that reaches it too; object_keys_array_ptr is gone.
  • gc/layout.rsinit_typed_shape_layout split: the memo-miss install
    tail is install_typed_shape_layout_slow, #[cold] #[inline(never)], so the
    per-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, from
    target_layout::inline_alloc_gc_packed — the single definition of the packed
    word, 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 (tree allocates once per call: +0.6%), hence module init.
  • param_type_guard.rs — one probe per guarded object; own_data_field
    reads inline slots against the bound plain_object already resolved
    (object_field_at_with_live, now also js_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-miss
    path (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 turned shapes from
    +2.8% into −1.9%.

Tests and gates

  • New: 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 (exactly
    one 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 as main (verified by running both trees); the two typed_shape_bake_tests now assert the packed word inside the module-init <2 x i64> compose.
  • GC canaries: retain/tree/churn/shapes/deeplist/retain1/push_cls/interp × {FORCE_EVACUATE+VERIFY_EVACUATION, PROTECT_FROMSPACE depth 32, both} — 24 runs byte-exact, rc 0, with the instruments demonstrably live (retain copies 237k objects per cycle under forced evacuation; 87 [gc-fromspace-protect] retire lines on churn, bytes_protected=16 MB).
  • cargo test -p perry-ffi --features runtime-link --lib: 51/0.
  • gc-ratchet re-pinned (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_set heap_used −75%, and
    13_large_eden_survivors +85 KB, because its cycle 0 now holds up an
    in-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's
    own binary at PERRY_GC_SCAVENGE_NURSERY_MB=49 promotes in place at 610‰
    and retains 651 KB. gc_ratchet.py check --profile shared_ci on the rebased
    tree with 7 repeats: OK.
  • cargo fmt --check, check_file_size.sh, and the lint python gates all
    clean, including shape_descriptor_census.py (baseline refreshed for one
    reformatted layout.rs read and the new target_layout.rs header-size use).
  • Gap suite (564 parity tests vs Node 26.5.1) — this is where the one
    real defect turned up: test_gap_fs_fd_2749 and both fs_errprop tests
    crash 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 fs
    family passes, and every remaining gap mismatch reproduces on main's own
    binary (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_ptr accepted any non-finite NaN-box with a plausible
payload — no STRING_TAG test — 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:
a harmless one-byte garbage string that parse_mode_string rejected). With
the #8113 layout the same read lands on the ShapeId (0x8000_0000+),
from_utf8_lossy walks 2 GB, and every fs.mkdirSync(dir, { recursive: true }) segfaults. It is the tenth offset-punning site — the census could not see
it 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

asyncpipe peak footprint +2.8% at 120 batches (+7.7% at 1200). The GC arena
is 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 MB
allocated in both arms — and mimalloc's own peak (MIMALLOC_SHOW_STATS) and
maximum resident set size are both lower for the shrunk arm (140.3 vs
140.9 MiB; 147.0 vs 149.7 MB) while peak memory footprint is higher. The two
OS 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 B
NURSERY_CAP_REFERENCE_OBJECT_BYTES anchor, so main already runs its first
cycle ~29% oversized by its own calibration (PERRY_GC_SCAVENGE_NURSERY_MB=12
on stock main binaries buys deeplist −11% instructions alone). If it is
preferable 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

    • Reduced object memory overhead, improving memory efficiency for object-heavy workloads.
    • Improved inline allocation and garbage-collection tuning.
  • Bug Fixes

    • Improved object, error, string, field, serialization, and JSON handling accuracy.
    • Fixed platform-specific layout handling, including watchOS and ILP32 targets.
    • Prevented invalid values from being misidentified as strings or native errors.
  • Compatibility

    • Updated the object-header ABI and exposed live-slot information for integrations.

Ralph Küpper 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%.
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e8da781-c811-4f69-9d28-0041f291675a

📥 Commits

Reviewing files that changed from the base of the PR and between eac0e19 and 2bd4f0a.

📒 Files selected for processing (127)
  • .github/workflows/test.yml
  • TYPE_LOWERING.md
  • benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json
  • changelog.d/8122-object-header-shrink-56-to-48.md
  • changelog.d/8122-recover-header-shrink-instruction-cost.md
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/codegen/opts.rs
  • crates/perry-codegen/src/codegen/string_pool.rs
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/class_field_inline_guard.rs
  • crates/perry-codegen/src/expr/element_shape_guard.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/expr/property_get/generic_dispatch.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/proxy_reflect.rs
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/function.rs
  • crates/perry-codegen/src/lower_call/alloc_hot_tests.rs
  • crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/lower_call/new_alloc.rs
  • crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs
  • crates/perry-codegen/src/lower_call/typed_shape_init.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-codegen/src/stmt/loops.rs
  • crates/perry-codegen/src/target_layout.rs
  • crates/perry-codegen/src/typed_shape.rs
  • crates/perry-ext-ws/src/lib.rs
  • crates/perry-ffi/src/jsvalue.rs
  • crates/perry-ffi/src/lib.rs
  • crates/perry-ffi/src/types.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/walk.rs
  • crates/perry-runtime/src/array/flat_clone.rs
  • crates/perry-runtime/src/array/generic.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry-runtime/src/array/subclass.rs
  • crates/perry-runtime/src/array/subclass_tests.rs
  • crates/perry-runtime/src/builtins/formatting/util_format.rs
  • crates/perry-runtime/src/builtins/globals.rs
  • crates/perry-runtime/src/child_process/v8_serde.rs
  • crates/perry-runtime/src/collection_iter_object.rs
  • crates/perry-runtime/src/dyn_eval/env.rs
  • crates/perry-runtime/src/error.rs
  • crates/perry-runtime/src/exception.rs
  • crates/perry-runtime/src/fs/mod.rs
  • crates/perry-runtime/src/fs/stream.rs
  • crates/perry-runtime/src/gc/heap_snapshot.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/layout_slot_visit.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/promote_in_place.rs
  • crates/perry-runtime/src/gc/roots/runtime_handles.rs
  • crates/perry-runtime/src/gc/tenuring.rs
  • crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs
  • crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs
  • crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs
  • crates/perry-runtime/src/gc/tests/cycle_state.rs
  • crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs
  • crates/perry-runtime/src/gc/tests/promote_in_place.rs
  • crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs
  • crates/perry-runtime/src/gc/tests/support.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/intl/install.rs
  • crates/perry-runtime/src/json/mod.rs
  • crates/perry-runtime/src/json/replacer.rs
  • crates/perry-runtime/src/json/stringify.rs
  • crates/perry-runtime/src/json/stringify_shape_template.rs
  • crates/perry-runtime/src/json_tape_tests.rs
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/object/alloc.rs
  • crates/perry-runtime/src/object/arguments.rs
  • crates/perry-runtime/src/object/class_registry/parent_static.rs
  • crates/perry-runtime/src/object/delete_rest.rs
  • crates/perry-runtime/src/object/field_get_set/accessors.rs
  • crates/perry-runtime/src/object/field_get_set/enumeration.rs
  • crates/perry-runtime/src/object/field_get_set/field_ops.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs
  • crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs
  • crates/perry-runtime/src/object/field_get_set/ic_miss.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs
  • crates/perry-runtime/src/object/field_set_by_name/tail.rs
  • crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs
  • crates/perry-runtime/src/object/gc_slots.rs
  • crates/perry-runtime/src/object/live_slots.rs
  • crates/perry-runtime/src/object/map_set_subclass.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_call_method.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/null_stub.rs
  • crates/perry-runtime/src/object/object_ops/accessors.rs
  • crates/perry-runtime/src/object/object_ops/keys_array.rs
  • crates/perry-runtime/src/object/shapes.rs
  • crates/perry-runtime/src/object/spill.rs
  • crates/perry-runtime/src/object/tests.rs
  • crates/perry-runtime/src/param_type_guard.rs
  • crates/perry-runtime/src/promise/rejection.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/symbol.rs
  • crates/perry-runtime/src/thread.rs
  • crates/perry-runtime/src/typed_feedback.rs
  • crates/perry-runtime/src/typed_feedback/guards.rs
  • crates/perry-runtime/src/typed_feedback/tests.rs
  • crates/perry-runtime/src/url/url_class.rs
  • crates/perry-runtime/src/value/dynamic_object.rs
  • crates/perry-runtime/src/weakref.rs
  • crates/perry-stdlib/src/fetch/mod.rs
  • crates/perry-stdlib/src/worker_threads.rs
  • crates/perry-ui-android/src/json.rs
  • crates/perry-ui-android/src/lib.rs
  • docs/object-write-matrix.md
  • docs/src/internals/garbage-collector.md
  • docs/src/platforms/watchos.md
  • scripts/addr_class_ratchet_baseline.txt
  • scripts/shape_descriptor_census.py
  • scripts/shape_descriptor_census_baseline.json

📝 Walkthrough

Walkthrough

The PR shrinks ObjectHeader, makes ShapeId descriptors authoritative for object kind and live inline-slot counts, updates runtime and codegen paths, adds vectorized inline-allocation headers, and strengthens ABI, census, GC, and regression tests.

Changes

ObjectHeader and ShapeId migration

Layer / File(s) Summary
ABI and target layout contracts
crates/perry-ffi/*, crates/perry-runtime/src/object/*, crates/perry-codegen/src/target_layout.rs
Removes object_type and field_count, adds ABI revision 2, and exposes ShapeId-derived live-slot access.
Shape publication and object allocation
crates/perry-runtime/src/object/alloc.rs, crates/perry-runtime/src/object/shapes.rs, crates/perry-runtime/src/object/mod.rs
Passes explicit live-slot bounds through allocation, mutation, cloning, and keys-array publication using mint-then-stamp ordering.
Inline allocation header images
crates/perry-codegen/src/codegen/*, crates/perry-codegen/src/lower_call/new_alloc.rs, crates/perry-codegen/src/function.rs
Builds, caches, initializes, and stores packed <2 x i64> class header images for inline allocation.
Runtime consumers and validation
crates/perry-runtime/src/gc/*, crates/perry-runtime/src/object/*, scripts/shape_descriptor_census.py, .github/workflows/test.yml
Updates field access, GC traversal, error classification, serialization, string handling, census validation, and unconditional FFI ABI testing.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#8122 — Contains the same ObjectHeader ABI, ShapeId, codegen, FFI, and runtime change set.
  • PerryTS/perry#8074 — Provides the ShapeId descriptor infrastructure extended by this PR.
  • PerryTS/perry#8009 — Introduces the class ShapeId-at-birth mechanism extended by the new header-image flow.

Suggested labels: performance, bug

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/8122-recover

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

… 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
proggeramlug marked this pull request as ready for review August 16, 2026 13:18
@proggeramlug
proggeramlug merged commit bf8fd86 into main Aug 16, 2026
16 of 20 checks passed
@proggeramlug
proggeramlug deleted the perf/8122-recover branch August 16, 2026 13:21
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
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant