Skip to content

perf(object): remove the derivable object_type and field_count header words — 56 B -> 48 B #8113

Description

@proggeramlug

Summary

Remove ObjectHeader::object_type and field_count, shrinking a common two-slot object from 56 B to 48 B and the eight-slot case from 104 B to 96 B. This is an independent rung of #8047 that needs none of the GC descriptor-rooting work in #8112.

Why this is a separate, landable rung

Measured with 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 both 24 48 96
drop keys_array too (#8047) 16 40 88

Neither field alone buys anything — the struct re-pads. Together they are a clean 8 bytes, exactly half of #8047's measured prize, and they do not touch the keys edge that #8112 has to redesign.

Work

1. Replace the seven raw-offset-0 Error discriminators

ObjectHeader.object_type is prefix-punned against error::ErrorHeader, whose own first u32 is also object_type. These read raw offset 0 on an untyped pointer:

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

Delete the word and offset 0 becomes class_id; an ordinary object whose class_id equals OBJECT_TYPE_ERROR is then read at ErrorHeader's offsets. ErrorHeader is already allocated GC_TYPE_ERROR (error.rs:198-203), so GcHeader.obj_type is the replacement — the same move #8086 made for object_is_regular.

2. proxy.rs:1523 needs the descriptor kind, not object_is_regular

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

gates plan_eligible. 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 it is not a valid substitution. Use object_kind == ShapeObjectKind::Ordinary.

3. Make the live-slot publication atomic

object/mod.rs:1909 set_object_live_slot_count clears the stamp, writes the header word, then re-mints. Consumers falling back to the already-widened header word is what makes that window safe today, and synchronize_object_shape_descriptor_from allocates (HashMap insert), so a collection can land inside it. With no header word, a collection there sees the old live bound and the newly exposed slot is invisible to tracing and rewriting — a fresh #7154/#7164. Restructure to mint-then-stamp.

4. Give the two capacity consumers a real fact

field_get_set/field_ops.rs:127 computes alloc_limit = max((*obj).field_count, INLINE_SLOT_FLOOR) for its OOB bound and says so ("a generous limit … to avoid false positives"). dyn_eval/env.rs uses the same idiom. Both want physical capacity, which field_count only approximates.

5. Codegen

  • lower_call/new_alloc.rs — the inline new writes object_type at raw+8 and field_count packed at raw+16; collapse to one class_id ‖ shape_id store. Also fix the three stale comments there (:250-254 says the header is 24 bytes without meta; :606 says slots start at raw+32 when it is raw+40; :380-383 says the ILP32 header is 20 when it is 24).
  • Renumber the 11 hard-coded IR offsets from class_id @+4 / ShapeId @+8 to +0/+4: 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. GcHeader-relative offsets (-8/-7/-6) are unaffected.
  • The ~21 opaque object_header_size_bytes header-skip geps need no edit.
  • ILP32: expr/proxy_reflect.rs:922 and stmt/loops.rs:3207 divide the header size by 8 for a word index. 24 / 8 is exact; the new ILP32 header is 16 (see below) so 16 / 8 stays exact — but convert them to byte geps anyway, since perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 makes the value 12 and 12 / 8 == 1 silently. Note a {u32, u32, *mut} ILP32 header is 12 bytes with align 4, which would put 8-byte JSValue slots at a 4-aligned offset and violate the i64:64 arm64_32 ABI new_alloc.rs:588-591 warns about; this rung keeps keys_array, so ILP32 stays 16 and the hazard is deferred to perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047.

6. FFI and out-of-runtime

  • perry-ffi/src/types.rs:37-53 mirror + the offset_of! asserts at :149-175.
  • perry-ext-ws/src/lib.rs:847 (let n = (*ptr).field_count;) — needs a C accessor or js_object_keys + js_array_length.
  • perry-stdlib/src/worker_threads.rs:677-683 — structured-clone walk. worker_options.rs:114 already models the replacement, but the keys_array.is_null() branch is deliberate (class instances take the field_count arm); verify js_object_keys returns the same set for class_id != 0 before collapsing, since it filters private #x keys.
  • perry-ui-android/src/json.rs:487,490,496,598 — likely just delete the module: every function is private with no callers and its own trailing comment says js_json_* now lives in perry-runtime/json.rs.

7. Gates

  • Update scripts/shape_descriptor_census.py's FIELDS tuple, self-test fixture, sabotage fixture, and the baseline JSON. (ci(object): make the ObjectHeader shape-descriptor census a real gate #8110 wires this script into lint; land that first.)
  • 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 a required lint step.
  • perry-ffi's object_header_matches_runtime has never executed — it is #[cfg(all(test, feature = "runtime-link"))] and runtime-link is enabled nowhere in .github/. Field deletion still goes red via rustc-warnings, but a size/padding divergence is invisible. Wire it up as part of this change.
  • perry-ffi is published to crates.io — an out-of-tree wrapper on the old mirror linked against a new runtime reads class_id out of the deleted object_type slot with no compile error. Needs a deliberate semver decision.
  • Docs: docs/src/platforms/watchos.md:35,106-111 is the only user-facing statement of the LP64/ILP32 pair. Four codegen doc comments already say "24 on 64-bit, 20 on ILP32" and have been wrong since meta landed (property_get.rs:1724, generic_dispatch.rs:435, property_set.rs:1443, lower_call/new.rs:693).

Acceptance

  • size_of::<ObjectHeader>() == 24 (LP64); {a,b} totals 48 B including GcHeader and two slots; the wide case reaches 96 B. Report both.
  • GcHeader stays 8 bytes.
  • Delete / defineProperty / prototype / cross-module / realm adversarial tests pass.
  • Forced evacuation, evacuation verification and protect-fromspace canaries pass with the moving collector demonstrably live — assert copied_objects > 0 or promoted_objects > 0, not merely that nothing threw.
  • The 19-program corpus is byte-exact and exit-checked; report instructions / cycles / RSS / copying-minor counts rather than extrapolating from the padding probe.
  • No version bump in the implementation PR.

Refs #8047, #8067, #8086, #8110, #8112, #6595, #7154, #7164, #7916.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions