Repository navigation
perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047
Description
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
- Census every runtime/codegen/FFI consumer of
ObjectHeaderlayout and allobject_type,field_count, andkeys_arrayreads. - Prove deletion, descriptor installation, prototype mutation, exotic-object state, cross-module aliases, and realm-local class registries mint/compare the right shape identity.
- Replace remaining load-bearing header-field guards with exact ShapeId/shape-descriptor facts.
- Remove the three derivable fields and move all offsets/ABI declarations together.
- 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; reportsize_of::<ObjectHeader>()and total bytes includingGcHeaderand two slots.retain_widereaches 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
proggeramlug commentedon Aug 14, 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;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.
proggeramlug commentedon Aug 14, 2026
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
12f758a22ShapeDescriptor { keys, logical_key_count, live_inline_slot_count, object_kind }+shape_descriptor_by_id+ by-id/by-facts reverse indices,object/shapes.rs:42-137shape_id_exhausted_abort()aborts;typed_feedback.rs:768-771— "never falls back to a keys pointer"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)object_typecarriesOBJECT_TYPE_CLASSis_class_object_ptrreadsobject_kind == ShapeObjectKind::Class; RegExp got its own GC kindfield_countindependently mutableset_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:63crates/perry-runtime/src/object/shapes.rs:947-950(scan_shape_table_rekey_mut)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: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):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,731Delete 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: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: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):size_of::<ObjectHeader>(){a,b}object_typeonlyfield_countonlyobject_type+field_countkeys_arrayonlyConfirms 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).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.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.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
object_type+field_count8-byte rung (56 → 48 B), landable without any GC redesign.keys_arrayremoval to 40 B / 88 B, once (2) lands.Full working notes with every citation:
gc-handoff/8047-NOTES.md.proggeramlug commentedon Aug 14, 2026
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.fix(ci): make the shape census pass on current main before wiring it
ci(object): make the ObjectHeader shape-descriptor census a real gate (…
perf(object): remove the derivable object_type and field_count header…
perf(object): remove the derivable object_type and field_count header…
perf(gc, codegen): recover the instruction cost of the 56 B → 48 B he…
fix(gc/shape): root and rewrite the keys edge from the ShapeId descri…
fix(gc/shape): root and rewrite the keys edge from the ShapeId descri…
fix(gc/shape): root and rewrite the keys edge from the ShapeId descri…
fix(gc/shape): root and rewrite the keys edge from the ShapeId descri…
fix(gc/shape): root and rewrite the keys edge from the ShapeId descri…