Repository navigation
perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047
Description
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performance
on Aug 13, 2026 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::ShapeTableremains keyed bykeys_arrayaddress and has no authoritativeShapeId -> { rooted keys, live slot count }lookup;typed_feedback.rs::object_shapedeliberately keeps class instances on pointer tokens, andtyped_feedback/guards.rsstill compares(*obj).keys_array/expected_keysplus(*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 inproxy_reflect.rs; field_countis independently mutable and is the GC slot-publication bound (js_object_set_fieldwidens it before pointer publication), so it is not currently derivable from keys length;object_typestill stores the liveOBJECT_TYPE_CLASSmarker (js_object_mark_class/is_class_object_ptr), whileRegExpHeaderstill aliasesGC_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 codegenobject_header_size_bytesreferences, 67 runtime/externalsize_of::<...ObjectHeader>references, and the public mirror/assertions inperry-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.
Re-audited every header consumer on
mainat12f758a22(#8086, which closed #8067), as this issue's own warning requires.Verdict: the prerequisite is still not complete, and the blocker is
keys_array, notfield_count. The 40 B target is not reachable on thismain. There is, however, an independent 8-byte rung worth landing first.#8086 closed four of #8067's five blockers
#8067 blocker status evidence on 12f758a221. 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-1371b. 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_keyshas zero occurrences inperry-codegen; both write PICs derive the token from the ShapeId with a fail-closedselect; guard ABIs are(i32 class_id, i32 shape_id)4. object_typecarriesOBJECT_TYPE_CLASSclosed is_class_object_ptrreadsobject_kind == ShapeObjectKind::Class; RegExp got its own GC kind2. field_countindependently mutableauthority closed, ordering residual one choke point set_object_live_slot_count; see belowRequirement 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_arrayis still the sole strong GC edgeThe 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:4keeps 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.pybecause 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 thekeysedge has a fixed location across budgeted resumptions, and (b) an ephemeron/liveness protocol so the table rootskeysonly while a live object carries that ShapeId — noteprune_dead_shape_keyscurrently 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 withoutObjectHeader.keys_array."A consumer of
object_typethat #8067 did not list#8067 scoped
object_typetoOBJECT_TYPE_CLASS. That part is genuinely done. ButObjectHeader.object_typeis prefix-punned againsterror::ErrorHeader, whose own firstu32is alsoobject_type, and seven sites read raw offset 0 on an untyped pointer to decide Error-vs-ordinary:error.rs:750(js_error_is_error) anderror.rs:1542(gates(*error).errors)exception.rs:452and:492(print_uncaught)value/dynamic_object.rs:531,548and:728,731
Delete the word and offset 0 becomes
class_id.OBJECT_TYPE_ERRORis a small integer, so any ordinary object whoseclass_idequals it is reclassified as anErrorHeaderand read atErrorHeader's offsets — type-confusedmessage/name/stack/errorspointers. Tractable:ErrorHeaderis already allocatedGC_TYPE_ERROR(error.rs:198-203), soGcHeader.obj_typediscriminates, exactly the move #8086 made forobject_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 excludesOBJECT_TYPE_CLASS, and the comment above it records what breaks otherwise (#6595, bundled zod'sZodX.createvanishing from ClassRef static dispatch).object_is_regularis true for class objects, so that substitution re-opens #6595. Correct replacement isobject_kind == ShapeObjectKind::Ordinary.Also:
object/mod.rs:591 NullObjectBytesis astaticwith noGcHeader, laid out to alias the header, handed out byjs_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 notOne 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_descriptorreturnsNoneand every consumer falls back to the already-widened header word — which is why it is correct today.synchronize_…inserts into aHashMap, 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") anddyn_eval/env.rs.Size arithmetic, measured not assumed
rustc -Oon the exact#[repr(C)]shapes (LP64):variant size_of::<ObjectHeader>(){a,b}8-slot current 32 56 104 drop object_typeonly32 56 104 drop field_countonly32 56 104 drop object_type+field_count24 48 96 drop keys_arrayonly24 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_countare 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, theproxy.rskind check, mint-then-stamp ordering, a physical-capacity fact, the eleven+4/+8IR offsets renumbered to+0/+4,new_alloc.rs's header stores, theperry-ffimirror, and four out-of-runtimefield_countreaders.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 inlinenew, which writes all three doomed words (object_typeatraw+8,field_countpacked atraw+16,keys_arrayatraw+24).- Two silent ILP32 hazards:
expr/proxy_reflect.rs:922andstmt/loops.rs:3207doobject_header_size_bytes(..) / 8. Exact on LP64; on ILP32 the post-shrink header is{u32,u32,*mut}= 12 bytes, and12 / 8 == 1with no assert. Worse, 12 bytes puts the 8-byte JSValue slots at a 4-aligned offset, violating thei64:64arm64_32 ABI thatnew_alloc.rs:588-591already 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), andperry-ui-android/src/json.rs:354,361,364,487,490,496,598. Everything else is an extern-fn parameter namedfield_count(harmless — still needed to size allocations) or a comment. Theperry-ui-androidmodule appears to be dead code: all functions private with no callers, and its own trailing comment saysjs_json_*now lives inperry-runtime/json.rs.
Three gates that cannot catch a mistake here
scripts/shape_descriptor_census.pywas 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 matchedadd(..., "0"|"12"|"16")while all four functions it names emitblk.gep(I8, &p, &[(I64, "N")]), so planting akeys_array-offset read inemit_element_shape_field_loadleft it green. Sabotage-verified red for all four now.perry-ffi's ABI mirror assertions have never executed.object_header_matches_runtimeis#[cfg(all(test, feature = "runtime-link"))], andruntime-linkis enabled nowhere in.github/;cargo-testis a per-package loop, never--workspace. Field deletion still goes red (anoffset_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.perry-ui-androidbreakage is invisible three ways:#![cfg(target_os = "android")], outside the host-compatible--workspacescope, and the only Android job iscontinue-on-error: true.
Adjacent:
perry-ffiis published to crates.io. A wrapper built against the old published mirror and linked against a new runtime readsclass_idout of the deletedobject_typeslot with no compile error. That wants a deliberate semver decision.Also needing same-commit updates:
scripts/shape_descriptor_census.py'sFIELDStuple + fixtures + its ~350-entry baseline;scripts/raw_handle_debt_files.txt:115andscripts/addr_class_ratchet_baseline.txt:138,257(an entry matching nothing fails, andaddr_class_inventory.pyis required);experiments/llvm-inprocess-spike/*.ll(gated bycheck_llvm_corpus_currency.py, so it should surface); anddocs/src/platforms/watchos.md:35,106-111, the only user-facing statement of the LP64/ILP32 pair.Suggested split
- ci(object): make the ObjectHeader shape-descriptor census a real gate #8110 (open) — make the census a real gate. Prerequisite to everything else.
- 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.
- New issue — the
object_type+field_count8-byte rung (56 → 48 B), landable without any GC redesign. - perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 — the final
keys_arrayremoval to 40 B / 88 B, once (2) lands.
Full working notes with every citation:
gc-handoff/8047-NOTES.md.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 rewritesdescriptor.keysthrough the table without the header word, so copying evacuation, old-gen defrag and verification are handled. The header contributes only a strong mark. Andobject/descriptor_state.rs:1145shows the root-scanner protocol mutating in place inside a rehashingHashMap, solayout_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.- added 10 commits that reference this issue
on Aug 16, 2026
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, andkeys_arraywords 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
mainstill uses:GcHeader;ObjectHeader; andfor 56 bytes total on
{a,b}.Measured prize
The exact mirror probe (grow the header by 16 B and read
pad16 -> baseas the shrink) measured, with byte-exact output and exit 0 on both arms:retainretain1retain_wideretain_wide1treetree_wideFull 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:
Three of those are the fields to delete.
keys_arrayidentity 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
ObjectHeaderlayout and allobject_type,field_count, andkeys_arrayreads.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; reportsize_of::<ObjectHeader>()and total bytes includingGcHeaderand two slots.retain_widereaches the corresponding 88 B common layout.No version bump belongs in the implementation PR.