Skip to content

perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047

Description

@proggeramlug

Summary

The measured object-representation campaign still has one concrete, high-value step with no open ticket: shrink a common two-slot object from the current 56 bytes to 40 bytes by removing the derivable object_type, field_count, and keys_array words once every inline-cache consumer is shape-transition-safe.

This is the unlanded endgame identified in #7916. That issue closed after delivering its measured proposal, while #6759/#8009 migrated important shape consumers but explicitly did not perform the header shrink. Current main still uses:

  • 8-byte GcHeader;
  • 32-byte ObjectHeader; and
  • two 8-byte slots;

for 56 bytes total on {a,b}.

Measured prize

The exact mirror probe (grow the header by 16 B and read pad16 -> base as the shrink) measured, with byte-exact output and exit 0 on both arms:

benchmark instructions peak RSS copying minors
retain -25.63% -26.19% 6 -> 4
retain1 noise -18.72% 3 -> 2
retain_wide flat -10.52% 8 -> 6
retain_wide1 flat -13.42% 4 -> 3
tree flat -18.64% unchanged
tree_wide flat -10.66% unchanged

Full evidence and harness references are in #7916’s final measurement comment and gc-handoff/HDR-NOTES.md.

Why it was blocked

The hot inline guard historically loaded, in one receiver block:

object_type @0
class_id @4
field_count @12
keys_array @16

Three of those are the fields to delete. keys_array identity could not simply become a ShapeId comparison while deletion preserved class identity and cloned/compacted the keys array. The hidden-class/shape-transition migration therefore had to land first.

Several prerequisites have now landed, including birth stamps and shape-based cache migration through #8007/#8009/#8010/#8015/#8025/#8029. Re-audit all header consumers on current main before assuming the prerequisite is complete; a partial nine-consumer migration is not enough to change the ABI.

Required work

  1. Census every runtime/codegen/FFI consumer of ObjectHeader layout and all object_type, field_count, and keys_array reads.
  2. Prove deletion, descriptor installation, prototype mutation, exotic-object state, cross-module aliases, and realm-local class registries mint/compare the right shape identity.
  3. Replace remaining load-bearing header-field guards with exact ShapeId/shape-descriptor facts.
  4. Remove the three derivable fields and move all offsets/ABI declarations together.
  5. Retain the current 8-byte GcHeader; this issue is not the refuted “add another generation bit” proposal from perf(gc): write-barrier tower B3 (slot elision) + B4 (generation bit in the header) — the unstarted half of #7187 #7715.

Acceptance

  • {a,b} is 40 B, not merely 48/56 B; report size_of::<ObjectHeader>() and total bytes including GcHeader and two slots.
  • retain_wide reaches the corresponding 88 B common layout.
  • Exact source/IR census proves there are no stale old-offset or keys-pointer identity consumers.
  • Delete/defineProperty/prototype/cross-module/realm-isolation adversarial tests pass.
  • Forced evacuation, evacuation verification, and protect-fromspace canaries pass with the moving collector demonstrably live.
  • The 19-program corpus is byte-exact and exit-checked; report instructions/cycles/RSS/copying-minor counts rather than extrapolating from the old padding probe.
  • Any asyncpipe measurement first re-derives its current plateau using both copying minors and incremental cycle starts (bench: asyncpipe@120 sits exactly on a nursery trigger boundary and is not a usable A/B target #7926).

No version bump belongs in the implementation PR.

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 13, 2026
  2. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Audit on fresh origin/main (08173f8b4) found the shape-transition prerequisite is not complete, so I did not start the ABI/layout edit or any acceptance measurement.

    Dedicated blocker: #8067.

    The decisive findings are source-visible:

    • object/shapes.rs::ShapeTable remains keyed by keys_array address and has no authoritative ShapeId -> { rooted keys, live slot count } lookup;
    • typed_feedback.rs::object_shape deliberately keeps class instances on pointer tokens, and typed_feedback/guards.rs still compares (*obj).keys_array / expected_keys plus (*obj).field_count;
    • emitted IR still loads old offsets in class_field_inline_guard.rs, element_shape_guard.rs, property_get/generic_dispatch.rs, and both write PICs in proxy_reflect.rs;
    • field_count is independently mutable and is the GC slot-publication bound (js_object_set_field widens it before pointer publication), so it is not currently derivable from keys length;
    • object_type still stores the live OBJECT_TYPE_CLASS marker (js_object_mark_class / is_class_object_ptr), while RegExpHeader still aliases GC_TYPE_OBJECT.

    Exact syntax census: 43/104/151 direct raw-pointer member sites for object_type/field_count/keys_array (298 sites across 77 files), plus 34 codegen object_header_size_bytes references, 67 runtime/external size_of::<...ObjectHeader> references, and the public mirror/assertions in perry-ffi.

    #8067 scopes the missing authoritative-shape/guard-migration rung without changing header size. #8047 remains the atomic runtime/codegen/FFI shrink once that prerequisite lands.

  3. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Re-audited every header consumer on main at 12f758a22 (#8086, which closed #8067), as this issue's own warning requires.

    Verdict: the prerequisite is still not complete, and the blocker is keys_array, not field_count. The 40 B target is not reachable on this main. There is, however, an independent 8-byte rung worth landing first.

    #8086 closed four of #8067's five blockers

    #8067 blocker status evidence on 12f758a22
    1. ShapeId cannot recover the deleted fields closed ShapeDescriptor { keys, logical_key_count, live_inline_slot_count, object_kind } + shape_descriptor_by_id + by-id/by-facts reverse indices, object/shapes.rs:42-137
    1b. exhausted id falls back to a keys pointer closed, fail-closed shape_id_exhausted_abort() aborts; typed_feedback.rs:768-771 — "never falls back to a keys pointer"
    3. keys-pointer consumers in guards/IR closed expected_keys has zero occurrences in perry-codegen; both write PICs derive the token from the ShapeId with a fail-closed select; guard ABIs are (i32 class_id, i32 shape_id)
    4. object_type carries OBJECT_TYPE_CLASS closed is_class_object_ptr reads object_kind == ShapeObjectKind::Class; RegExp got its own GC kind
    2. field_count independently mutable authority closed, ordering residual one choke point set_object_live_slot_count; see below

    Requirement 2 of this issue (delete / descriptor / prototype / exotic / cross-module / realm identity) is met by #8086's tests in shapes.rs: delete_compaction_never_compares_equal_to_the_predelete_layout, descriptor_and_prototype_changes_mint_semantic_successors, absent_descriptor_clears_do_not_mint_semantic_successors, a_foreign_agent_id_misses_instead_of_aliasing_same_address, process_global_module_shape_id_installs_with_agent_local_keys, shape_drop_does_not_delete_a_potential_siblings_descriptor, shared_sibling_append_clones_before_descriptor_version_changes.

    The blocker: keys_array is still the sole strong GC edge

    The descriptor owns the keys value. The header word is still the only thing that roots the keys array and the only rewritable location the evacuator can hand a slot visitor. Three statements, all written by #8086 itself:

    crates/perry-runtime/src/gc/layout_slot_visit.rs:63

    // #8067: the header keys slot above is the sole strong edge.

    crates/perry-runtime/src/object/shapes.rs:947-950 (scan_shape_table_rekey_mut)

    /// Metadata-only forwarding repair for the weak descriptor table … Mark/copy
    /// mode does not root anything; live object scans provide descriptor
    /// reachability, and post-copy rewrite follows only forwarding records those
    /// live edges already created.

    crates/perry-runtime/src/object/gc_slots.rs:4 keeps the field on purpose as a "Compatibility scratch slot", and names this issue as where it goes:

    // #8047 can replace this scratch with a descriptor-table rewrite without changing the source of the edge.

    Delete the word and every keys array loses its only strong referent — the unrooted-cache class CLAUDE.md warns about, invisible to gc_root_dominance_check.py because it is a table, not a register.

    The obvious fix is explicitly ruled out in the same file (layout_slot_visit.rs:64-68):

    // Never enumerate the HashMap bucket as a GC slot: dirty-page work may retain
    // enumerated slot addresses across budgeted resumptions, during which
    // descriptor insertion can reallocate the table.

    So a sound replacement needs both: (a) stable-address descriptor storage (a chunked arena, not a rehashing HashMap) so the keys edge has a fixed location across budgeted resumptions, and (b) an ephemeron/liveness protocol so the table roots keys only while a live object carries that ShapeId — note prune_dead_shape_keys currently asks "is the keys array dead?", which becomes circular the moment the table roots it. That is #8067's unmet acceptance bullet: "GC mark/rewrite/evacuation can recover and rewrite keys plus the exact live field range without ObjectHeader.keys_array."

    A consumer of object_type that #8067 did not list

    #8067 scoped object_type to OBJECT_TYPE_CLASS. That part is genuinely done. But ObjectHeader.object_type is prefix-punned against error::ErrorHeader, whose own first u32 is also object_type, and seven sites read raw offset 0 on an untyped pointer to decide Error-vs-ordinary:

    • error.rs:750 (js_error_is_error) and error.rs:1542 (gates (*error).errors)
    • exception.rs:452 and :492 (print_uncaught)
    • value/dynamic_object.rs:531,548 and :728,731

    Delete the word and offset 0 becomes class_id. OBJECT_TYPE_ERROR is a small integer, so any ordinary object whose class_id equals it is reclassified as an ErrorHeader and read at ErrorHeader's offsets — type-confused message/name/stack/errors pointers. Tractable: ErrorHeader is already allocated GC_TYPE_ERROR (error.rs:198-203), so GcHeader.obj_type discriminates, exactly the move #8086 made for object_is_regular.

    One further site needs care rather than mechanics — proxy.rs:1523:

    && (*(addr as *const crate::ObjectHeader)).object_type == crate::error::OBJECT_TYPE_REGULAR

    This is not object_is_regular(). It deliberately excludes OBJECT_TYPE_CLASS, and the comment above it records what breaks otherwise (#6595, bundled zod's ZodX.create vanishing from ClassRef static dispatch). object_is_regular is true for class objects, so that substitution re-opens #6595. Correct replacement is object_kind == ShapeObjectKind::Ordinary.

    Also: object/mod.rs:591 NullObjectBytes is a static with no GcHeader, laid out to alias the header, handed out by js_unresolved_namespace_stub. Any discriminator moved from "read offset 0" to "read the GcHeader" must stay correct for it.

    field_count: authority is done, the publication window is not

    One choke point now, object/mod.rs:1909:

    if (*obj).field_count != field_count {
        let predecessor = shapes::object_shape_descriptor(obj);
        shapes::clear_object_shape_stamp(obj);
        (*obj).field_count = field_count;
        shapes::synchronize_object_shape_descriptor_from(obj, predecessor);
    }

    Between the clear and the re-mint, object_shape_descriptor returns None and every consumer falls back to the already-widened header word — which is why it is correct today. synchronize_… inserts into a HashMap, so that window contains an allocation and therefore a possible collection. Remove the word and a collection landing there sees the old live bound, and the newly exposed slot is again invisible to tracing and rewriting: a fresh #7154/#7164. The shrink must make mint-then-stamp atomic w.r.t. collection, not merely delete the field.

    Two smaller ones need a real capacity fact rather than max(field_count, INLINE_SLOT_FLOOR): field_get_set/field_ops.rs:127 (the OOB bound, explicitly "a generous limit") and dyn_eval/env.rs.

    Size arithmetic, measured not assumed

    rustc -O on the exact #[repr(C)] shapes (LP64):

    variant size_of::<ObjectHeader>() {a,b} 8-slot
    current 32 56 104
    drop object_type only 32 56 104
    drop field_count only 32 56 104
    drop object_type + field_count 24 48 96
    drop keys_array only 24 48 96
    drop all three 16 40 88

    Confirms the 40 B / 88 B target and the one-u32-buys-nothing warning. It also shows something not in the issue: object_type + field_count are an independent 8-byte rung — 56 → 48 B — needing none of the GC redesign. That looks like the right next PR, with prerequisites: the seven Error discriminators, the proxy.rs kind check, mint-then-stamp ordering, a physical-capacity fact, the eleven +4/+8 IR offsets renumbered to +0/+4, new_alloc.rs's header stores, the perry-ffi mirror, and four out-of-runtime field_count readers.

    Codegen / FFI census

    • object_header_size_bytes: 32 sites. 21 of 24 call sites are opaque header-skip geps and keep working. One breaks — lower_call/new_alloc.rs:385, the inline new, which writes all three doomed words (object_type at raw+8, field_count packed at raw+16, keys_array at raw+24).
    • Two silent ILP32 hazards: expr/proxy_reflect.rs:922 and stmt/loops.rs:3207 do object_header_size_bytes(..) / 8. Exact on LP64; on ILP32 the post-shrink header is {u32,u32,*mut} = 12 bytes, and 12 / 8 == 1 with no assert. Worse, 12 bytes puts the 8-byte JSValue slots at a 4-aligned offset, violating the i64:64 arm64_32 ABI that new_alloc.rs:588-591 already warns about — the ILP32 header needs explicit tail padding to 16.
    • 11 hard-coded per-field IR offsets read class_id @+4 / ShapeId @+8 (class_field_inline_guard.rs:279,283,400,495,499; element_shape_guard.rs:359; generic_dispatch.rs:389; proxy_reflect.rs:550,557,867,872). All break, but both words survive — this is renumbering to +0/+4, not redesign. Everything GcHeader-relative (-8/-7/-6) is unaffected.
    • Only 11 real header reads exist outside perry-runtime, in 3 files: perry-ext-ws/src/lib.rs:847 (field_count), perry-stdlib/src/worker_threads.rs:677-683 (keys_array/field_count, structured-clone walk), and perry-ui-android/src/json.rs:354,361,364,487,490,496,598. Everything else is an extern-fn parameter named field_count (harmless — still needed to size allocations) or a comment. The perry-ui-android module appears to be dead code: all functions private with no callers, and its own trailing comment says js_json_* now lives in perry-runtime/json.rs.

    Three gates that cannot catch a mistake here

    1. scripts/shape_descriptor_census.py was orphaned. feat(object): make ShapeId authoritative for runtime guards #8086 built exactly the instrument feat(object/shape): make ShapeId authoritative for keys, live slots, and class-object kind before #8047 #8067 demanded and referenced it from no workflow. Fixed in ci(object): make the ObjectHeader shape-descriptor census a real gate #8110 — which also found that its emitted-guard arm was vacuous: it matched add(..., "0"|"12"|"16") while all four functions it names emit blk.gep(I8, &p, &[(I64, "N")]), so planting a keys_array-offset read in emit_element_shape_field_load left it green. Sabotage-verified red for all four now.
    2. perry-ffi's ABI mirror assertions have never executed. object_header_matches_runtime is #[cfg(all(test, feature = "runtime-link"))], and runtime-link is enabled nowhere in .github/; cargo-test is a per-package loop, never --workspace. Field deletion still goes red (an offset_of! on a deleted field stops compiling), but a size or padding divergence with both structs populated is invisible — precisely the failure mode a size change has.
    3. perry-ui-android breakage is invisible three ways: #![cfg(target_os = "android")], outside the host-compatible --workspace scope, and the only Android job is continue-on-error: true.

    Adjacent: perry-ffi is published to crates.io. A wrapper built against the old published mirror and linked against a new runtime reads class_id out of the deleted object_type slot with no compile error. That wants a deliberate semver decision.

    Also needing same-commit updates: scripts/shape_descriptor_census.py's FIELDS tuple + fixtures + its ~350-entry baseline; scripts/raw_handle_debt_files.txt:115 and scripts/addr_class_ratchet_baseline.txt:138,257 (an entry matching nothing fails, and addr_class_inventory.py is required); experiments/llvm-inprocess-spike/*.ll (gated by check_llvm_corpus_currency.py, so it should surface); and docs/src/platforms/watchos.md:35,106-111, the only user-facing statement of the LP64/ILP32 pair.

    Suggested split

    1. ci(object): make the ObjectHeader shape-descriptor census a real gate #8110 (open) — make the census a real gate. Prerequisite to everything else.
    2. New issue — descriptor-table keys rooting: stable-address storage + ephemeron liveness. This is the actual perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 prerequisite.
    3. New issue — the object_type + field_count 8-byte rung (56 → 48 B), landable without any GC redesign.
    4. perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 — the final keys_array removal to 40 B / 88 B, once (2) lands.

    Full working notes with every citation: gc-handoff/8047-NOTES.md.

  4. proggeramlug commented on Aug 14, 2026

    @proggeramlug
    ContributorAuthor

    Update: the blocker identified in the census above is smaller than I concluded, verified independently in #8112.

    scan_shape_table_rekey_mut (object/shapes.rs:953) already rewrites descriptor.keys through the table without the header word, so copying evacuation, old-gen defrag and verification are handled. The header contributes only a strong mark. And object/descriptor_state.rs:1145 shows the root-scanner protocol mutating in place inside a rehashing HashMap, so layout_slot_visit.rs:63's "never enumerate the HashMap bucket" is a constraint on the layout-slot protocol specifically — not a universal one. I over-read it as universal when I wrote the census verdict.

    Two consequences for sequencing here: the class-keys and compile-time-literal keys arrays are already immortal, and retain/retain_wide/tree — the rows carrying this issue's measured prize — are object-literal workloads needing no ephemeron at all. So the prerequisite can be scoped to the runtime-minted tail rather than solved in general.

    #8113 (object_type + field_count, 56 → 48 B) remains independent of all of this and needs none of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions