Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/8313-object-header-40b.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Shrink the common JavaScript object layout by removing the derivable per-object
keys-array mirror. Ordered keys now come exclusively from the authoritative,
moving-GC-rewritten ShapeId descriptor, reducing `ObjectHeader` from 24 to 16
bytes and a two-slot object from 48 to 40 bytes (eight-slot objects: 96 to 88).
20 changes: 10 additions & 10 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1448,14 +1448,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// `js_object_get_field_by_name_f64` runtime helper which
// hashes the property name + walks the keys array. The
// ObjectHeader layout (`#[repr(C)]` in
// `crates/perry-runtime/src/object/mod.rs`) is 24 bytes on
// LP64 / 16 on ILP32 (#8113) followed by the inline field
// `crates/perry-runtime/src/object/mod.rs`) is 16 bytes on
// LP64 and ILP32 (#8047) followed by the inline field
// array of f64-sized slots:
//
// offset 0..24: ObjectHeader (class_id, parent_class_id
// [= ShapeId], keys_array, meta)
// offset 24..32: field 0
// offset 32..40: field 1
// offset 0..16: ObjectHeader (class_id, parent_class_id
// [= ShapeId], meta)
// offset 16..24: field 0
// offset 24..32: field 1
// ...
//
// Parent class fields come first in the slot order
Expand Down Expand Up @@ -1734,10 +1734,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {

ctx.current_block = fast_idx;
// arm64_32 watchOS: the object fields region begins at
// `size_of::<ObjectHeader>()` past the user pointer — 24 on
// 64-bit, 16 on ILP32 since #8113 (both trailing pointers are
// 4 bytes there). A hardcoded 24 reads every class field 8 bytes
// off on a 32-bit watch, so this inline class-field load
// `size_of::<ObjectHeader>()` past the user pointer — 16 on
// both LP64 and ILP32 since #8047. A hardcoded offset reads
// class fields from the wrong word when the header changes, so
// this inline class-field load
// disagreed with the generic-PIC load / runtime setter (both
// target-aware) and typed-object string fields came back as
// word-swapped NaN-boxes. Derive it from the target triple
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ pub(crate) fn lower_generic_property_get(
// ShapeId token and its cached slot; word 2 is non-identity scratch.
// The fast path compares the receiver's discriminated ShapeId token to
// cache[0] and, on match, loads
// the field directly at obj+24+slot*8: no function call, no hash,
// the field directly at obj+ObjectHeader::SIZE+slot*8: no function call, no hash,
// no linear scan. On miss, calls the slow helper which does the
// full lookup and primes the cache for next time.
let site_id = ctx.ic_site_counter;
Expand Down Expand Up @@ -433,10 +433,8 @@ pub(crate) fn lower_generic_property_get(
);
let offset = ctx.block().shl(I64, &slot, "3");
// arm64_32 watchOS: the object fields region begins at
// `size_of::<ObjectHeader>()` past the user pointer — 24 on 64-bit, 16 on
// ILP32 since #8113 (both trailing pointers are 4 bytes there). A hardcoded
// 24 would read every cached property 8 bytes off on a 32-bit watch. Derive
// it from the target triple (no-op on 64-bit; see `target_layout`).
// `size_of::<ObjectHeader>()` past the user pointer — 16 on LP64 and
// padded ILP32 since #8047. Derive it from the target triple.
let obj_header_size =
crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();
let base = ctx.block().add(I64, &obj_handle, &obj_header_size);
Expand Down
9 changes: 4 additions & 5 deletions crates/perry-codegen/src/expr/property_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ pub(crate) fn try_lower_sloppy_class_field_store(
ctx.current_block = fast_idx;
{
// arm64_32 watchOS: the fields region starts at `size_of::<ObjectHeader>()`
// past the user pointer (24 on 64-bit, 16 on ILP32 since #8113) —
// past the user pointer (16 on LP64 and ILP32 since #8047) —
// same derivation as the strict arm and the runtime setter.
let header_skip =
crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();
Expand Down Expand Up @@ -1442,10 +1442,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
class_field_store_needs_string_addref(ctx, value);
let raw_stored_value = {
// arm64_32 watchOS: the object fields region begins at
// `size_of::<ObjectHeader>()` past the user pointer — 24 on
// 64-bit, 16 on ILP32 since #8113 (both trailing pointers are
// 4 bytes there). A hardcoded 24 writes every class field 8
// bytes off on a 32-bit watch; the paired inline read
// `size_of::<ObjectHeader>()` past the user pointer — 16 on
// both LP64 and ILP32 since #8047. A hardcoded offset writes
// class fields to the wrong word when the header changes; the paired inline read
// (`property_get`) and the runtime setter must agree, so
// derive it from the target triple (no-op on 64-bit; see
// `target_layout`).
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/lower_call/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -690,8 +690,8 @@ fn lower_new_impl_inner<'a>(
ctx.current_block = fast_idx;
{
// arm64_32 watchOS: the fields region starts at
// `size_of::<ObjectHeader>()` past the user pointer (24 on
// 64-bit, 16 on ILP32 since #8113) — same derivation as every
// `size_of::<ObjectHeader>()` past the user pointer (16 on
// both LP64 and ILP32 since #8047) — same derivation as every
// other packed slot access.
let header_skip =
crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();
Expand Down
49 changes: 11 additions & 38 deletions crates/perry-codegen/src/lower_call/new_alloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,14 +246,14 @@ fn emit_instance_alloc_inner(
// store offset (1)
// load data + gep (2)
// write GcHeader (1) — packed i64 store
// write ObjectHeader (1) — one packed i64 store (#8113)
// write keys_ptr (1)
// total: ~12 cycles vs ~140 cycles for the function-call path.
// write ObjectHeader (1) — class id + ShapeId
// write null meta (1)
// total: ~11 cycles vs ~140 cycles for the function-call path.
//
// Layout assumption: GcHeader is 8 bytes
// {obj_type:u8, gc_flags:u8, _reserved:u16, size:u32}
// and ObjectHeader is 24 bytes on LP64 / 16 on ILP32 (#8113)
// {class_id:u32, parent_class_id:u32, keys_array:*ptr, meta:*ptr}
// and ObjectHeader is 16 bytes on LP64 and ILP32 (#8047)
// {class_id:u32, parent_class_id:u32, meta:*ptr [, ILP32 pad:u32]}
// followed by `max(field_count, INLINE_SLOT_FLOOR)` 8-byte field
// slots. The user pointer the rest of the codegen sees is `raw + 8`
// (i.e. the ObjectHeader address) — same as what
Expand Down Expand Up @@ -386,8 +386,8 @@ fn emit_instance_alloc_inner(
} else {
// Compile-time layout constants.
const GC_HEADER_SIZE: u64 = 8;
// arm64_32 watchOS: `size_of::<ObjectHeader>()` is 24 on 64-bit
// but 16 on ILP32 (two 4-byte pointers). Derive from the target
// `size_of::<ObjectHeader>()` is 16 on LP64 and padded ILP32.
// Derive from the target
// triple so the inline alloc size and field-region base match the
// target-compiled runtime (no-op on 64-bit; see `target_layout`).
let object_header_size: u64 =
Expand Down Expand Up @@ -452,10 +452,8 @@ fn emit_instance_alloc_inner(
let payload_size = object_header_size + alloc_field_count * FIELD_SLOT_SIZE;
// Round the whole allocation up to FIELD_SLOT_SIZE (8). The inline
// bump allocator's offset invariant (below) requires every
// allocation to be a multiple of 8; on ILP32 `object_header_size`
// is 20, so an unpadded total is 4-skewed (e.g. 92) and would
// misalign the next bump. No-op on 64-bit (8 + 24 + 8·n is already
// 8-aligned → 96 for ≤8 fields).
// allocation to be a multiple of 8. #8047 makes the object header
// 16 bytes on both pointer widths, so this is currently a no-op.
let total_size = (GC_HEADER_SIZE + payload_size).next_multiple_of(FIELD_SLOT_SIZE);
let total_size_str = total_size.to_string();

Expand All @@ -471,30 +469,14 @@ fn emit_instance_alloc_inner(
slot
};

// Hoist the per-class `keys_array` global load to the function
// entry block (cached in a stack slot per class). Without this
// hoisting, LLVM would reload `@perry_class_keys_<class>` on
// every loop iteration, because the loop body's `call
// @js_inline_arena_slow_alloc` blocks LICM — LLVM can't prove
// the call doesn't modify the global.
let keys_slot = if let Some(s) = ctx.class_keys_slots.get(class_name).cloned() {
s
} else {
let s = crate::expr::entry_init_load_rooted_global(ctx, &keys_global_name, I64);
ctx.class_keys_slots
.insert(class_name.to_string(), s.clone());
s
};
let keys_ptr = ctx.block().load(I64, &keys_slot);

// Inline bump-allocator IR.
let blk = ctx.block();
let state_ptr = blk.load(PTR, &arena_state_slot);

// offset = state.offset (at byte offset 8 in InlineArenaState).
// The offset is invariant 8-aligned: arena blocks start at offset 0
// (8-aligned), every allocation is a multiple of 8 (`total_size`
// includes the 8-byte GcHeader and `MIN_FIELD_SLOTS=4` slots ×
// includes the 8-byte GcHeader and `MIN_FIELD_SLOTS=2` slots ×
// 8 bytes), and `js_inline_arena_slow_alloc` only ever swings the
// state to `block.offset` which is also always 8-aligned. So we
// skip the `(offset + 7) & -8` align-up step entirely — saves
Expand Down Expand Up @@ -644,13 +626,6 @@ fn emit_instance_alloc_inner(
header_image, raw
));

// Second 8 bytes: keys_array pointer. The keys_ptr we loaded
// above is an i64 (carries the ArrayHeader address); store as
// i64 since the underlying memory is 8 bytes either way.
let oh_addr_3 = blk.gep(I8, &raw, &[(I64, "16")]);
// GC_STORE_AUDIT(INIT): keys_array edge is installed before publishing the new object.
blk.store(I64, &keys_ptr, &oh_addr_3);

// #6759 Phase B: null the `meta` record pointer — the LAST header
// field, at header offset (object_header_size - pointer_size).
// Pointer-width store: on ILP32 the field is 4 bytes at a
Expand All @@ -671,9 +646,7 @@ fn emit_instance_alloc_inner(
// observed stale arena bytes. When those bytes were a previously-freed
// `undefined`/pointer (e.g. `marked`'s `this.defaults`), the constructor
// crashed with "Cannot read properties of undefined". Slots start
// at raw + GcHeader(8) + ObjectHeader(24) = raw + 32 on LP64
// (#8113; it was raw + 40 while the header carried the two deleted
// words).
// at raw + GcHeader(8) + ObjectHeader(16) = raw + 24 (#8047).
for i in 0..alloc_field_count {
let slot_off = GC_HEADER_SIZE + object_header_size + i * FIELD_SLOT_SIZE;
let slot_ptr = blk.gep(I8, &raw, &[(I64, &slot_off.to_string())]);
Expand Down
5 changes: 2 additions & 3 deletions crates/perry-codegen/src/stmt/loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3276,9 +3276,8 @@ fn lower_object_array_write_versioned_for(
plans
};
let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);
// #8113: address inline slots in BYTES rather than dividing the header size
// by 8 to get a word index. The quotient is exact today (24/8 and 16/8), but
// #8047's ILP32 header is 12 bytes and `12 / 8 == 1` truncates silently.
// Address inline slots in bytes. #8047 makes both layouts 16 bytes, while
// retaining this target-derived form prevents future silent truncation.
let header_bytes = object_header_size.to_string();
// `meta` is the LAST ObjectHeader field (a documented invariant of the
// header layout): a POINTER-WIDTH field at byte offset
Expand Down
51 changes: 18 additions & 33 deletions crates/perry-codegen/src/target_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,11 @@ pub fn target_is_ilp32(target_triple: &str) -> bool {

/// `std::mem::size_of::<perry_runtime::object::ObjectHeader>()` for the target.
///
/// #8113: `ObjectHeader` is two `u32`s (`class_id` @0, `parent_class_id` @4 —
/// the latter carrying the runtime ShapeId after stamping) followed by two
/// pointers (`keys_array`, and the #6759 Phase B `meta` record pointer):
/// 8 bytes of words → **24 on 64-bit**; → **16 on ILP32**. It was 32/24 while
/// the header also carried `object_type` @0 and `field_count` @12; both were
/// derivable (`GcHeader.obj_type` + the ShapeId descriptor's `object_kind`, and
/// the descriptor's `live_inline_slot_count`) and removing either ALONE saved
/// nothing because the struct re-padded.
/// #8047: `ObjectHeader` is two `u32`s (`class_id` @0, `parent_class_id` @4 —
/// the latter carrying the runtime ShapeId after stamping) followed by the
/// #6759 Phase B `meta` pointer. The keys pointer is derived from that ShapeId.
/// LP64 is naturally 16 bytes; ILP32 carries explicit padding before `meta` so
/// the following 8-byte JSValue slots remain aligned. Both are therefore **16**.
///
/// Inline object allocation, header init, and the property inline-cache fast
/// path all use this as the field-region base
Expand All @@ -45,16 +42,9 @@ pub fn target_is_ilp32(target_triple: &str) -> bool {
/// runtime-side via `perry_runtime::closure::CLOSURE_TYPE_TAG_OFFSET` /
/// `offset_of!`.)
///
/// Both values stay 8-BYTE MULTIPLES, which the f64 field region after the
/// header depends on: the ILP32 struct is `{u32, u32, *4, *4}` = 16 with align
/// 4, and allocations are 8-aligned, so slot 0 lands 8-aligned and the arm64_32
/// `i64:64` ABI hazard `lower_call/new_alloc.rs` warns about does not arise.
pub fn object_header_size_bytes(target_triple: &str) -> u64 {
if target_is_ilp32(target_triple) {
16
} else {
24
}
/// The value stays an 8-BYTE MULTIPLE, which the f64 field region depends on.
pub fn object_header_size_bytes(_target_triple: &str) -> u64 {
16
}

/// Minimum number of inline field slots `perry-runtime` allocates for EVERY
Expand Down Expand Up @@ -97,9 +87,8 @@ pub(crate) const FIELD_SLOT_SIZE_BYTES: u64 = 8;
/// `field_count` declared fields: GcHeader + ObjectHeader +
/// `max(field_count, INLINE_SLOT_FLOOR)` slots, rounded up to a slot multiple.
///
/// The round-up matters only on ILP32, where the header is not a multiple of
/// 8 and an unpadded total would misalign the next bump; it is a no-op on
/// 64-bit (8 + 24 + 8·n is already 8-aligned).
/// The round-up is retained as a defensive invariant; #8047 makes the header
/// 16 bytes on both pointer widths, so the total is already 8-aligned.
pub(crate) fn inline_alloc_total_size_bytes(target_triple: &str, field_count: u32) -> u64 {
let alloc_field_count = std::cmp::max(field_count as u64, INLINE_SLOT_FLOOR);
let payload_size =
Expand Down Expand Up @@ -186,22 +175,18 @@ mod tests {

#[test]
fn object_header_size_matches_pointer_width() {
// #8113 — 64-bit targets: 2×u32 + two 8-byte-aligned pointers
// (keys_array + #6759 meta) = 24.
assert_eq!(object_header_size_bytes("aarch64-apple-darwin"), 24);
assert_eq!(object_header_size_bytes("aarch64-apple-watchos"), 24);
assert_eq!(object_header_size_bytes("aarch64-apple-watchos-sim"), 24);
assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnu"), 24);
// arm64_32 watchOS (Series 4–8 / SE): 2×u32 + two 4-byte pointers = 16.
// #8047 — 64-bit targets: 2×u32 + one pointer = 16.
assert_eq!(object_header_size_bytes("aarch64-apple-darwin"), 16);
assert_eq!(object_header_size_bytes("aarch64-apple-watchos"), 16);
assert_eq!(object_header_size_bytes("aarch64-apple-watchos-sim"), 16);
assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnu"), 16);
// ILP32 stays 16 through explicit tail padding.
assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnux32"), 16);
assert_eq!(object_header_size_bytes("arm64_32-apple-watchos"), 16);
}

/// #8113: two emitters divide the header size by 8 to get a WORD index
/// (`expr/proxy_reflect.rs`, `stmt/loops.rs`). That is only sound while the
/// size is a multiple of 8 on every target — 24/8 and 16/8 are exact, but
/// #8047's 16/12 pair would make the ILP32 division silently truncate.
/// Pin the divisibility rather than the quotient.
/// Two emitters divide the header size by 8 to get a WORD index. #8047
/// keeps ILP32 at 16 with explicit padding so that remains exact.
#[test]
fn object_header_size_is_a_whole_number_of_heap_words() {
for triple in [
Expand Down
Loading
Loading