From 5b2cd0bfe4169750f375e407610570622744f2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 03:43:39 +0200 Subject: [PATCH 01/13] perf(object): remove the derivable object_type and field_count header words (56 B -> 48 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectHeader` becomes `{class_id @0, parent_class_id @4, keys_array @8, meta @16}` — 24 bytes on LP64, 16 on ILP32. A two-slot object goes from 56 to 48 bytes and the eight-slot case from 104 to 96. Removing either word alone saves nothing (the struct re-pads), so this is one indivisible change. Both words were derivable: * the receiver KIND is `GcHeader.obj_type` plus the immutable ShapeId descriptor's `object_kind`; * the live inline-slot bound is that descriptor's `live_inline_slot_count`. Nine sites read raw offset 0 to answer "is this an Error?" — two more than previously catalogued (`promise/rejection.rs` x2). Since `OBJECT_TYPE_ERROR` is 2 and class ids are handed out from 1 in declaration order, leaving any of them would have reclassified every instance of the second class a program declares as an `ErrorHeader`. They now go through `error::ptr_is_native_error()`. Publication is mint-then-stamp throughout: the descriptor is the only record of the live slot bound, so a stamp-cleared window is a window in which the collector traces zero payload slots. Refs #8113, #8047. --- .github/workflows/test.yml | 20 + TYPE_LOWERING.md | 2 +- crates/perry-codegen/src/expr/array_push.rs | 5 +- .../src/expr/class_field_inline_guard.rs | 22 +- .../src/expr/element_shape_guard.rs | 7 +- crates/perry-codegen/src/expr/property_get.rs | 13 +- .../src/expr/property_get/generic_dispatch.rs | 9 +- crates/perry-codegen/src/expr/property_set.rs | 8 +- .../perry-codegen/src/expr/proxy_reflect.rs | 29 +- .../src/expr/static_field_meta.rs | 2 +- .../src/lower_call/ctor_prologue_stores.rs | 4 +- crates/perry-codegen/src/lower_call/new.rs | 4 +- .../perry-codegen/src/lower_call/new_alloc.rs | 64 +- .../src/runtime_decls/objects.rs | 2 +- crates/perry-codegen/src/stmt/loops.rs | 19 +- crates/perry-codegen/src/target_layout.rs | 80 ++- crates/perry-ext-ws/src/lib.rs | 4 +- crates/perry-ffi/src/jsvalue.rs | 8 + crates/perry-ffi/src/lib.rs | 4 +- crates/perry-ffi/src/types.rs | 84 ++- crates/perry-runtime/src/array/flat_clone.rs | 6 +- crates/perry-runtime/src/array/generic.rs | 3 +- crates/perry-runtime/src/array/header.rs | 8 +- crates/perry-runtime/src/array/push_pop.rs | 4 +- crates/perry-runtime/src/array/subclass.rs | 10 +- .../perry-runtime/src/array/subclass_tests.rs | 51 +- .../src/builtins/formatting/util_format.rs | 2 +- crates/perry-runtime/src/builtins/globals.rs | 4 +- .../src/child_process/v8_serde.rs | 2 +- .../src/collection_iter_object.rs | 4 +- crates/perry-runtime/src/dyn_eval/env.rs | 5 +- crates/perry-runtime/src/error.rs | 55 +- crates/perry-runtime/src/exception.rs | 12 +- crates/perry-runtime/src/gc/heap_snapshot.rs | 2 +- crates/perry-runtime/src/gc/layout.rs | 6 +- .../perry-runtime/src/gc/layout_slot_visit.rs | 2 +- .../src/gc/roots/runtime_handles.rs | 4 +- .../src/gc/tests/clone_keys_array_init.rs | 2 +- .../gc/tests/copying/pointer_publish_7154.rs | 4 +- .../perry-runtime/src/gc/tests/cycle_state.rs | 15 +- .../src/gc/tests/dead_owner_side_tables.rs | 4 +- .../src/gc/tests/layout_trace/typed_shape.rs | 2 +- .../gc/tests/shape_descriptor_authority.rs | 7 +- crates/perry-runtime/src/gc/tests/support.rs | 33 +- crates/perry-runtime/src/gc/types.rs | 4 +- crates/perry-runtime/src/intl/install.rs | 5 +- crates/perry-runtime/src/json/mod.rs | 8 +- crates/perry-runtime/src/json/replacer.rs | 6 +- crates/perry-runtime/src/json/stringify.rs | 6 +- .../src/json/stringify_shape_template.rs | 5 +- crates/perry-runtime/src/json_tape_tests.rs | 6 +- crates/perry-runtime/src/lib.rs | 1 + crates/perry-runtime/src/map.rs | 3 +- crates/perry-runtime/src/object/alloc.rs | 75 +-- crates/perry-runtime/src/object/arguments.rs | 12 +- .../object/class_registry/parent_static.rs | 63 +- .../perry-runtime/src/object/delete_rest.rs | 31 +- .../src/object/field_get_set/accessors.rs | 10 +- .../src/object/field_get_set/enumeration.rs | 4 +- .../src/object/field_get_set/field_ops.rs | 4 +- .../object/field_get_set/get_field_by_name.rs | 2 +- .../field_get_set/get_field_by_name_tail.rs | 12 +- .../src/object/field_get_set/ic_miss.rs | 7 +- .../src/object/field_set_by_name.rs | 4 +- .../object/field_set_by_name/fast_paths.rs | 16 +- .../src/object/field_set_by_name/tail.rs | 27 +- .../object/field_set_by_name/write_helpers.rs | 2 +- crates/perry-runtime/src/object/gc_slots.rs | 13 +- crates/perry-runtime/src/object/live_slots.rs | 89 +++ .../src/object/map_set_subclass.rs | 42 +- crates/perry-runtime/src/object/mod.rs | 166 ++--- .../src/object/native_call_method.rs | 10 +- .../perry-runtime/src/object/native_module.rs | 2 +- crates/perry-runtime/src/object/null_stub.rs | 72 +++ .../src/object/object_ops/accessors.rs | 6 +- .../src/object/object_ops/keys_array.rs | 10 +- crates/perry-runtime/src/object/shapes.rs | 273 ++++++-- crates/perry-runtime/src/object/spill.rs | 6 +- crates/perry-runtime/src/object/tests.rs | 179 +++++- crates/perry-runtime/src/promise/rejection.rs | 13 +- crates/perry-runtime/src/proxy.rs | 15 +- crates/perry-runtime/src/symbol.rs | 6 +- crates/perry-runtime/src/thread.rs | 2 +- crates/perry-runtime/src/typed_feedback.rs | 23 +- .../perry-runtime/src/typed_feedback/tests.rs | 7 +- crates/perry-runtime/src/url/url_class.rs | 4 +- .../perry-runtime/src/value/dynamic_object.rs | 31 +- crates/perry-runtime/src/weakref.rs | 7 +- crates/perry-stdlib/src/fetch/mod.rs | 2 +- crates/perry-stdlib/src/worker_threads.rs | 7 +- crates/perry-ui-android/src/json.rs | 606 ------------------ crates/perry-ui-android/src/lib.rs | 1 - docs/object-write-matrix.md | 7 +- docs/src/platforms/watchos.md | 7 +- scripts/addr_class_ratchet_baseline.txt | 1 - scripts/shape_descriptor_census.py | 211 +++++- scripts/shape_descriptor_census_baseline.json | 192 +----- 97 files changed, 1564 insertions(+), 1396 deletions(-) create mode 100644 crates/perry-runtime/src/object/live_slots.rs create mode 100644 crates/perry-runtime/src/object/null_stub.rs delete mode 100644 crates/perry-ui-android/src/json.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 56f95c0304..42e2a02d20 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -982,6 +982,26 @@ jobs: done fi + # #8113 — the perry-ffi <-> perry-runtime ABI mirror, which had NEVER + # EXECUTED. `perry_ffi::types::layout_tests` is + # `#[cfg(all(test, feature = "runtime-link"))]`, `runtime-link` was + # enabled nowhere in `.github/`, and the scope loop above runs + # `cargo test -p perry-ffi` with DEFAULT features — so the module never + # even compiled. Deleting a mirrored field still went red (an `offset_of!` + # on a missing field stops compiling), but a SIZE or PADDING divergence + # between the two structs was invisible, which is precisely the failure + # mode of a header-layout change. + # + # Unconditional, not scope-gated: perry-ffi's optional dependency on + # perry-runtime means a runtime-only diff need not pull perry-ffi into + # scope, and this is the one check that says the published ABI mirror + # still matches the runtime it mirrors. + - name: perry-ffi ABI mirror matches the runtime (#8113) + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" + CARGO_PROFILE_TEST_DEBUG: "0" + run: cargo test -p perry-ffi --features runtime-link --lib + # --------------------------------------------------------------------------- # Scoped e2e: run the integration suites NAMED BY THE DIFF (#5960) # diff --git a/TYPE_LOWERING.md b/TYPE_LOWERING.md index 508b3cc630..23840a7105 100644 --- a/TYPE_LOWERING.md +++ b/TYPE_LOWERING.md @@ -614,7 +614,7 @@ can bypass part of the generic NaN-boxing overhead: ### `ObjectHeader` Layout -Every heap object has: `object_type` (u32), `class_id` (u32), `field_count` (u32), `keys_array` pointer. Inline property slots follow immediately in memory. +Every heap object has: `class_id` (u32), `parent_class_id` (u32, which carries the runtime `ShapeId` once stamped), `keys_array` pointer, `meta` pointer — 24 bytes on LP64, 16 on ILP32. Inline property slots follow immediately in memory. (#8113 removed the `object_type` and `field_count` words: the receiver kind comes from `GcHeader.obj_type` plus the ShapeId descriptor's `object_kind`, and the live inline-slot bound from the same descriptor's `live_inline_slot_count`.) - **Shape caching**: Objects with the same key set share a `keys_array` pointer. - **`KEYS_INDEX`**: FNV-1a hash map built when `keys_array.length > 32` for O(1) lookup. diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index 1613868ca5..6fd8f1a0bc 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -827,11 +827,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // instance, which perry models as a plain `ObjectHeader` — // reached the inline store below. `ObjectHeader` overlays // `ArrayHeader` field for field, so `length` read - // `object_type` (= 1) and `capacity` read `class_id` (large): + // `class_id` and `capacity` read the ShapeId word (#8113; it + // was `object_type` (= 1) and `class_id` before): // `1 < class_id` passed the in-bounds test and the value was // stored at `handle + 8 + 1*8` — i.e. over `ObjectHeader // .keys_array`, a live GC child edge — while `length + 1` - // overwrote `object_type`. The SECOND push then SIGSEGVed + // overwrote the first header word. The SECOND push then SIGSEGVed // (exit 139) dereferencing `keys_array`, whose bytes were now // the double `1.0` (fault address `0x3ff0000000000000`). // diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index e3499005e9..c7ab68d307 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -274,13 +274,15 @@ pub(crate) fn emit_class_field_loop_preheader_check( let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); let reserved = blk.load(I16, &res_ptr); - // ObjectHeader: class_id @4 and authoritative ShapeId @8. Matching - // the immutable descriptor proves the live-slot bound and key order. - let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); + // ObjectHeader: class_id @0 and authoritative ShapeId @4 (#8113 — the + // two leading offsets moved down 4 when `object_type` was deleted). + // Matching the immutable descriptor proves the live-slot bound and key + // order. + let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); let class_id = blk.load(I32, &cid_ptr); let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "8")]); + let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); @@ -395,9 +397,9 @@ pub(crate) fn emit_proven_shape_recheck( let latched = blk.and(I16, &reserved, OBJ_FLAG_FROZEN_OR_DESCRIPTORS); let unlatched = blk.icmp_eq(I16, &latched, "0"); - // `class_id` @4 was already matched by the tower. ShapeId @8 proves the - // exact immutable layout and receiver-kind descriptor. - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "8")]); + // `class_id` @0 was already matched by the tower. ShapeId @4 proves the + // exact immutable layout and receiver-kind descriptor (#8113 offsets). + let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); @@ -491,12 +493,12 @@ pub(crate) fn emit_class_field_inline_precheck( let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); let reserved = blk.load(I16, &res_ptr); - // ObjectHeader: class_id @4, authoritative ShapeId @8. - let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); + // ObjectHeader: class_id @0, authoritative ShapeId @4 (#8113). + let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); let class_id = blk.load(I32, &cid_ptr); let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "8")]); + let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let sid_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index 79805bf8ba..62afc2f9f5 100644 --- a/crates/perry-codegen/src/expr/element_shape_guard.rs +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -163,8 +163,8 @@ pub(crate) fn emit_element_shape_loop_preheader_check( // (2) SUBCLASS BRAND (#7573/#7603). `class X extends Array` instances are // plain `ObjectHeader`s that overlay `ArrayHeader` field for field, so - // `length`/`capacity`/`elements[0]` would read `object_type`/`class_id`/ - // `parent_class_id‖field_count`. The runtime's `array_gc_header` makes the + // `length`/`capacity`/`elements[0]` would read `class_id`/`parent_class_id` + // (the ShapeId)/`keys_array` (#8113). The runtime's `array_gc_header` makes the // same test, but it is repeated here so the raw pointer handed across the // call below is already branded, and so the emitted IR carries the brand // where a reviewer (and the IR census) can see it. @@ -356,7 +356,8 @@ pub(crate) fn emit_element_shape_field_load( let hdr_masked = blk.and(I32, &hdr, ELEM_HEADER_MASK); let hdr_ok = blk.icmp_eq(I32, &hdr_masked, ELEM_HEADER_EXPECT); - let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "8")]); + // #8113: the ShapeId moved from header offset 8 to 4. + let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let shape_ok = blk.icmp_eq(I32, &shape_id, &fact.expected_shape_id); diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index d0b2dcf4ba..bde8f4e8e3 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1448,11 +1448,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `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.rs:591`) is 24 bytes - // followed by the inline field array of f64-sized slots: + // `crates/perry-runtime/src/object/mod.rs`) is 24 bytes on + // LP64 / 16 on ILP32 (#8113) followed by the inline field + // array of f64-sized slots: // - // offset 0..24: ObjectHeader (object_type, class_id, - // parent_class_id, field_count, keys_array) + // offset 0..24: ObjectHeader (class_id, parent_class_id + // [= ShapeId], keys_array, meta) // offset 24..32: field 0 // offset 32..40: field 1 // ... @@ -1734,8 +1735,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.current_block = fast_idx; // arm64_32 watchOS: the object fields region begins at // `size_of::()` past the user pointer — 24 on - // 64-bit, 20 on ILP32 (the trailing `keys_array` pointer is 4 - // bytes there). A hardcoded 24 reads every class field 4 bytes + // 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 // disagreed with the generic-PIC load / runtime setter (both // target-aware) and typed-object string fields came back as diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 613081fc7a..f6d5ca6ddc 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -386,7 +386,8 @@ pub(crate) fn lower_generic_property_get( // The receiver token is derived solely from its authoritative ShapeId. // Invalid/unstamped payloads produce zero and miss closed. - let pcid_addr = ctx.block().add(I64, &obj_handle, "8"); + // #8113: the ShapeId word moved from header offset 8 to 4. + let pcid_addr = ctx.block().add(I64, &obj_handle, "4"); let pcid_ptr = ctx.block().inttoptr(I64, &pcid_addr); let pcid = ctx.block().load(I32, &pcid_ptr); // In-range test via wrapping add + ult: (pcid - 0x8000_0000) < 0x4000_0000. @@ -432,9 +433,9 @@ 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::()` past the user pointer — 24 on 64-bit, 20 on - // ILP32 (the trailing `keys_array` pointer is 4 bytes there). A hardcoded - // 24 would read every cached property 4 bytes off on a 32-bit watch. Derive + // `size_of::()` 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`). let obj_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 013c178a75..1fc9ede104 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -360,8 +360,8 @@ pub(crate) fn try_lower_sloppy_class_field_store( ctx.current_block = fast_idx; { // arm64_32 watchOS: the fields region starts at `size_of::()` - // past the user pointer (24 on 64-bit, 20 on ILP32) — same derivation as - // the strict arm and the runtime setter. + // past the user pointer (24 on 64-bit, 16 on ILP32 since #8113) — + // 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(); let blk = ctx.block(); @@ -1443,8 +1443,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let raw_stored_value = { // arm64_32 watchOS: the object fields region begins at // `size_of::()` past the user pointer — 24 on - // 64-bit, 20 on ILP32 (the trailing `keys_array` pointer is - // 4 bytes there). A hardcoded 24 writes every class field 4 + // 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 // (`property_get`) and the runtime setter must agree, so // derive it from the target triple (no-op on 64-bit; see diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index c7cb3ab0a5..98d0dbea6c 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -556,7 +556,8 @@ fn lower_put_value_static_write_ic( .and(I16, &reserved, &WRITE_PIC_BLOCKING_FLAGS.to_string()); let flags_clear = ctx.block().icmp_eq(I16, &blocked, "0"); - let class_addr = ctx.block().add(I64, &safe_target, "4"); + // #8113: `class_id` moved from header offset 4 to 0. + let class_addr = ctx.block().add(I64, &safe_target, "0"); let class_ptr = ctx.block().inttoptr(I64, &class_addr); let class_id = ctx.block().load(I32, &class_ptr); let has_class = ctx.block().icmp_ne(I32, &class_id, "0"); @@ -572,7 +573,8 @@ fn lower_put_value_static_write_ic( let receiver_kind_ok = ctx.block().or(I1, &has_class, &plain_ordinary); // The write PIC uses the same single ShapeId token domain as the read PIC. - let shape_id_addr = ctx.block().add(I64, &safe_target, "8"); + // #8113: the ShapeId word moved from header offset 8 to 4. + let shape_id_addr = ctx.block().add(I64, &safe_target, "4"); let shape_id_ptr = ctx.block().inttoptr(I64, &shape_id_addr); let raw_shape_id = ctx.block().load(I32, &shape_id_ptr); let shape_id_rel = ctx.block().add(I32, &raw_shape_id, "-2147483648"); @@ -949,7 +951,8 @@ fn lower_put_value_dyn_ic_inline( .block() .and(I16, &reserved, &WRITE_PIC_BLOCKING_FLAGS.to_string()); let flags_clear = ctx.block().icmp_eq(I16, &blocked, "0"); - let class_addr = ctx.block().add(I64, &t_handle, "4"); + // #8113: `class_id` moved from header offset 4 to 0. + let class_addr = ctx.block().add(I64, &t_handle, "0"); let class_ptr = ctx.block().inttoptr(I64, &class_addr); let class_id = ctx.block().load(I32, &class_ptr); let has_class = ctx.block().icmp_ne(I32, &class_id, "0"); @@ -960,7 +963,8 @@ fn lower_put_value_dyn_ic_inline( .and(I16, &reserved, &PLAIN_ORDINARY_OBJ_FLAG.to_string()); let plain_ordinary = ctx.block().icmp_ne(I16, &plain_ordinary_bits, "0"); let receiver_kind_ok = ctx.block().or(I1, &has_class, &plain_ordinary); - let shape_id_addr = ctx.block().add(I64, &t_handle, "8"); + // #8113: the ShapeId word moved from header offset 8 to 4. + let shape_id_addr = ctx.block().add(I64, &t_handle, "4"); let shape_id_ptr = ctx.block().inttoptr(I64, &shape_id_addr); let raw_shape_id = ctx.block().load(I32, &shape_id_ptr); let shape_id_rel = ctx.block().add(I32, &raw_shape_id, "-2147483648"); @@ -1009,18 +1013,21 @@ fn lower_put_value_dyn_ic_inline( I64, &[(&s0, &ways_label), (&s1, &way1_label), (&s2, &way2_label)], ); - let header_bytes = crate::target_layout::object_header_size_bytes(ctx.target_triple); - let header_words = (header_bytes / 8).to_string(); - let slot_word = ctx.block().add(I64, &slot, &header_words); + // #8113: address the slot 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 — + // the same class of bug as the stale header-size comments this rung fixed. + let header_bytes = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let slot_bytes = ctx.block().shl(I64, &slot, "3"); + let slot_off = ctx.block().add(I64, &slot_bytes, &header_bytes); let obj_ptr = ctx.block().inttoptr(I64, &t_handle); - let slot_ptr = ctx - .block() - .gep_inbounds(I64, &obj_ptr, &[(I64, &slot_word)]); + let slot_ptr = ctx.block().gep_inbounds(I8, &obj_ptr, &[(I64, &slot_off)]); ctx.block() .cond_br(&v_scalar, &store_scalar_label, &store_ref_label); ctx.current_block = store_scalar_idx; - // GC_STORE_AUDIT(POINTER_FREE): the tag test above proved the value is + // GC_STORE_AUDIT(POINTER_FREE): the entry tag test proved the value is // not pointer/string/bigint — non-reference bits need no barrier. ctx.block().store(DOUBLE, v, &slot_ptr); ctx.block().br(&merge_label); diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 8638ce0f72..94b1860f30 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -436,7 +436,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let obj = ctx.block() .call(I64, "js_object_alloc", &[(I32, &tcid_str), (I32, &nfields)]); - // #1789: mark it as a class object (object_type = OBJECT_TYPE_CLASS) + // #1789: mark it as a class object (ShapeObjectKind::Class) // so `typeof` reports "function" and `new`/`instanceof` read the // class_id from this object rather than treating it as an instance. ctx.block() diff --git a/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs b/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs index 1dc43445f2..4ed51f98d1 100644 --- a/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs +++ b/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs @@ -39,9 +39,9 @@ //! |---|---| //! | `GcHeader.obj_type == GC_TYPE_OBJECT` | low byte of the packed `gc_packed` constant | //! | not forwarded | `gc_flags` is exactly `GC_FLAG_ARENA` | -//! | `object_type == OBJECT_TYPE_REGULAR` | first `ObjectHeader` word constant | +//! | receiver is an ordinary object | the emitted precheck reads `class_id` @0 and the ShapeId @4; #8113 deleted the `object_type` word this row used to name | //! | `class_id == ` | same word, `cid` is this site's class | -//! | `field_count > slot` | `field_count` is the class's own field count, and every slot in the plan indexes a declared field | +//! | live-slot bound > slot | the bound is the class's own field count (the ShapeId descriptor's `live_inline_slot_count` since #8113), and every slot in the plan indexes a declared field | //! | `keys_array == @perry_class_keys_` | the header store loads the same global the precheck compares against | //! | no per-object descriptors | `_reserved` is the constant `GC_LAYOUT_POINTER_FREE \| INTACT` | //! | not frozen | same constant | diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index ea12ccde4e..5b5337d702 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -691,8 +691,8 @@ fn lower_new_impl_inner<'a>( { // arm64_32 watchOS: the fields region starts at // `size_of::()` past the user pointer (24 on - // 64-bit, 20 on ILP32) — same derivation as every other packed - // slot access. + // 64-bit, 16 on ILP32 since #8113) — same derivation as every + // other packed slot access. let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); let blk = ctx.block(); diff --git a/crates/perry-codegen/src/lower_call/new_alloc.rs b/crates/perry-codegen/src/lower_call/new_alloc.rs index 34e9bec7e8..40ce3bd340 100644 --- a/crates/perry-codegen/src/lower_call/new_alloc.rs +++ b/crates/perry-codegen/src/lower_call/new_alloc.rs @@ -246,20 +246,29 @@ fn emit_instance_alloc_inner( // store offset (1) // load data + gep (2) // write GcHeader (1) — packed i64 store - // write ObjectHeader×2 (2) — packed i64 stores + // write ObjectHeader (1) — one packed i64 store (#8113) // write keys_ptr (1) - // total: ~13 cycles vs ~140 cycles for the function-call path. + // total: ~12 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 - // {object_type:u32, class_id:u32, parent_class_id:u32, - // field_count:u32, keys_array:*ptr} - // followed by `max(field_count, 8)` 8-byte field slots. The user - // pointer the rest of the codegen sees is `raw + 8` (i.e. the - // ObjectHeader address) — same as what + // and ObjectHeader is 24 bytes on LP64 / 16 on ILP32 (#8113) + // {class_id:u32, parent_class_id:u32, keys_array:*ptr, meta:*ptr} + // 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 // `js_object_alloc_class_inline_keys` returns. // + // #8113 note on the SHAPE WORD: `parent_class_id` carries the + // module-init ShapeId, and that descriptor is now the ONLY record of + // the object's live inline-slot bound. The `descriptor_facts_exact` + // gate below is therefore load-bearing, not an optimization: an + // inline allocation whose slot bound differs from the id's descriptor + // would publish an object the runtime bounds-checks against the WRONG + // number. Mismatches take the outlined + // `js_object_alloc_class_inline_keys_stamped` entry point, which + // installs an exact local descriptor. + // // Layout constants are duplicated here from the runtime; if // `GcHeader` or `ObjectHeader` ever change in // `crates/perry-runtime/src/{gc,object}.rs`, update both sides. @@ -377,8 +386,8 @@ fn emit_instance_alloc_inner( } else { // Compile-time layout constants. const GC_HEADER_SIZE: u64 = 8; - // arm64_32 watchOS: `size_of::()` is 24 on 64-bit but - // 20 on ILP32 (4-byte `keys_array` pointer). Derive from the target + // arm64_32 watchOS: `size_of::()` is 24 on 64-bit + // but 16 on ILP32 (two 4-byte pointers). 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 = @@ -410,7 +419,6 @@ fn emit_instance_alloc_inner( /// a raw-f64 slot directly. Runtime-side name: /// `gc::layout::GC_OBJ_TYPED_LAYOUT_INTACT`. const GC_OBJ_TYPED_LAYOUT_INTACT: u64 = 0x1000; - const OBJECT_TYPE_REGULAR: u64 = 1; // #7834: when this class's canonical layout is declarable at // allocation AND its pointer mask is statically empty, the state @@ -559,28 +567,24 @@ fn emit_instance_alloc_inner( // GC_STORE_AUDIT(INIT): inline headers initialize freshly allocated unpublished object storage. blk.store(I64, &gc_packed.to_string(), &raw); - // Write ObjectHeader at raw + 8. - // First 8 bytes: object_type (u32, low) | class_id (u32, high) - let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); - let oh_word_1: u64 = OBJECT_TYPE_REGULAR | ((cid as u64) << 32); - blk.store(I64, &oh_word_1.to_string(), &oh_addr_1); - - // Second 8 bytes: ShapeId (u32, low) | field_count (u32, high). + // Write ObjectHeader at raw + 8. #8113 collapsed the two packed + // words into one: `class_id` (u32, low) | ShapeId (u32, high). // The module-init runtime call either publishes a usable ShapeId // or fail-stops on exhaustion; there is no pointer-token fallback. - let oh_addr_2 = blk.gep(I8, &raw, &[(I64, "16")]); + // The deleted `object_type` was a constant and the deleted + // `field_count` is now the ShapeId descriptor's + // `live_inline_slot_count`, which the `descriptor_facts_exact` + // gate above proved equals this site's `field_count`. + let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); let shape_word64 = blk.zext(I32, &shape_id, I64); - let oh_word_2 = blk.or( - I64, - &shape_word64, - &((field_count as u64) << 32).to_string(), - ); - blk.store(I64, &oh_word_2, &oh_addr_2); + let oh_shifted = blk.shl(I64, &shape_word64, "32"); + let oh_word_1 = blk.or(I64, &oh_shifted, &(cid as u64).to_string()); + blk.store(I64, &oh_word_1, &oh_addr_1); - // Third 8 bytes: keys_array pointer. The keys_ptr we loaded + // 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, "24")]); + 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); @@ -603,8 +607,10 @@ fn emit_instance_alloc_inner( // read-before-write — or a GC that scans the still-constructing instance — // 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. + // 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). 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())]); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 1589430d7f..cf1d6faf7e 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -59,7 +59,7 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // JSValue (DOUBLE): nullish/primitive -> fresh {}, object passes through. module.declare_function("js_object_coerce", DOUBLE, &[DOUBLE]); // #1789: stamp a class-expression's heap object as a class object - // (object_type = OBJECT_TYPE_CLASS) so typeof → "function" and + // (ShapeObjectKind::Class) so typeof → "function" and // new/instanceof read class_id from it. module.declare_function("js_object_mark_class", VOID, &[I64]); // #6438: pin a per-evaluation class object's own parent edge. diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index e07d2c7e99..792450d1fe 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -3204,11 +3204,14 @@ fn lower_object_array_write_versioned_for( plans }; let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple); - let header_words = (object_header_size / 8).to_string(); + // #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. + 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 - // (header_size - pointer_size). On ILP32 (arm64_32) the header is 24 - // bytes with a 4-byte `meta` at offset 20 — neither 8-byte-word-indexable + // (header_size - pointer_size). On ILP32 (arm64_32) the header is 16 + // bytes with a 4-byte `meta` at offset 12 — neither 8-byte-word-indexable // nor i64-loadable — so the spill path addresses it by BYTE offset and // loads pointer-width, mirroring the `new.rs` allocator's meta store. let meta_ptr_size: u64 = if crate::target_layout::target_is_ilp32(ctx.target_triple) { @@ -3258,8 +3261,9 @@ fn lower_object_array_write_versioned_for( ctx.current_block = inline_idx; let field_ptr = { let blk = ctx.block(); - let field_word = blk.add(I64, &slot, &header_words); - blk.gep_inbounds(I64, &object_ptr, &[(I64, &field_word)]) + let slot_bytes = blk.shl(I64, &slot, "3"); + let field_off = blk.add(I64, &slot_bytes, &header_bytes); + blk.gep_inbounds(I8, &object_ptr, &[(I64, &field_off)]) }; // GC_STORE_AUDIT(POINTER_FREE): finite numeric values only, proven // by the entry guard's range analysis. @@ -3327,8 +3331,9 @@ fn lower_object_array_write_versioned_for( ctx.current_block = inline_idx; let field_ptr = { let blk = ctx.block(); - let field_word = blk.add(I64, slot, &header_words); - blk.gep_inbounds(I64, &object_ptr, &[(I64, &field_word)]) + let slot_bytes = blk.shl(I64, slot, "3"); + let field_off = blk.add(I64, &slot_bytes, &header_bytes); + blk.gep_inbounds(I8, &object_ptr, &[(I64, &field_off)]) }; // GC_STORE_AUDIT(POINTER_FREE): the versioned loop emits only numeric // values into fields proven numeric by the entry guard. diff --git a/crates/perry-codegen/src/target_layout.rs b/crates/perry-codegen/src/target_layout.rs index 136957188d..cc31467a33 100644 --- a/crates/perry-codegen/src/target_layout.rs +++ b/crates/perry-codegen/src/target_layout.rs @@ -27,22 +27,33 @@ pub fn target_is_ilp32(target_triple: &str) -> bool { /// `std::mem::size_of::()` for the target. /// -/// `ObjectHeader` is four `u32`s (`object_type`, `class_id`, `parent_class_id`, -/// `field_count` = 16 bytes) followed by two pointers (`keys_array`, and the -/// #6759 Phase B `meta` record pointer): 16 bytes → 32 on 64-bit; 8 bytes → 24 -/// on ILP32. Inline object allocation, header init, and the property -/// inline-cache fast path all use this as the field-region base +/// #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. +/// +/// Inline object allocation, header init, and the property inline-cache fast +/// path all use this as the field-region base /// (`fields = obj + object_header_size_bytes`). It MUST equal the runtime's /// `size_of::()`, or inline-constructed objects and runtime-FFI /// field access diverge and every property read/write is corrupt. (The closure /// header `type_tag` offset has the analogous problem; that one is handled /// 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) { - 24 + 16 } else { - 32 + 24 } } @@ -62,12 +73,16 @@ pub fn object_header_size_bytes(target_triple: &str) -> u64 { /// `max(field_count, INLINE_SLOT_FLOOR)` slots. A value SMALLER than the /// runtime's makes the runtime's bound checks admit slots the emitted /// allocation never reserved → writes into the neighbouring arena object. -/// - **the emitted property bounds checks** (`expr/property_get`, -/// `expr/proxy_reflect`) gate a raw inline slot load/store on -/// `slot < max(field_count, INLINE_SLOT_FLOOR)`. A value LARGER than the -/// runtime's widens those raw accesses past the allocation. +/// - **the runtime's by-index bounds checks** (`object/field_get_set`, +/// `object/field_set_by_name`) gate every slot write on +/// `slot < max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. A codegen value +/// LARGER than the runtime's would under-allocate for those admitted slots. /// -/// So codegen must be exactly equal, not conservatively either way. +/// So codegen must be exactly equal, not conservatively either way. (Emitted IR +/// no longer materializes this bound itself: #8067 moved the PIC hit path onto +/// an exact ShapeId match, and `expr/property_get/tests.rs`'s +/// `cached_slot_bound_comes_from_the_shape_descriptor_match` asserts it stays +/// off. #8113 then deleted the `field_count` word it used to reload.) pub const INLINE_SLOT_FLOOR: u64 = 2; /// `INLINE_SLOT_FLOOR` as the string literal the IR emitters splice in. @@ -104,15 +119,38 @@ mod tests { #[test] fn object_header_size_matches_pointer_width() { - // 64-bit targets: 4×u32 + two 8-byte-aligned pointers (keys_array + - // #6759 meta) = 32. - assert_eq!(object_header_size_bytes("aarch64-apple-darwin"), 32); - assert_eq!(object_header_size_bytes("aarch64-apple-watchos"), 32); - assert_eq!(object_header_size_bytes("aarch64-apple-watchos-sim"), 32); - assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnu"), 32); - // arm64_32 watchOS (Series 4–8 / SE): 4×u32 + two 4-byte pointers = 24. - assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnux32"), 24); - assert_eq!(object_header_size_bytes("arm64_32-apple-watchos"), 24); + // #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. + 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. + #[test] + fn object_header_size_is_a_whole_number_of_heap_words() { + for triple in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "arm64_32-apple-watchos", + "x86_64-unknown-linux-gnux32", + ] { + assert_eq!( + object_header_size_bytes(triple) % 8, + 0, + "{triple}: header size must be a whole number of 8-byte heap \ + words — `object_header_size_bytes(..) / 8` is used as a word \ + index and truncates silently otherwise" + ); + } } #[test] diff --git a/crates/perry-ext-ws/src/lib.rs b/crates/perry-ext-ws/src/lib.rs index b026b98563..aaf8e376fb 100644 --- a/crates/perry-ext-ws/src/lib.rs +++ b/crates/perry-ext-ws/src/lib.rs @@ -844,7 +844,9 @@ fn extract_no_server(opts_f64: f64) -> bool { return false; } unsafe { - let n = (*ptr).field_count; + // #8113: the header's `field_count` word is gone; the live inline-slot + // bound comes from the runtime accessor. + let n = perry_ffi::js_object_live_slot_count(ptr); let mut saw_true = false; let mut saw_positive_port = false; for i in 0..n { diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index aa6c317e21..a0ae4e1303 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -326,6 +326,14 @@ extern "C" { /// shape declared them). pub fn js_object_get_field(obj: *const ObjectHeader, field_index: u32) -> JsValue; + /// Number of LIVE inline field slots on `obj` — the exclusive upper bound + /// for [`js_object_get_field`]. + /// + /// #8113: this used to be readable as `(*obj).field_count`. The word is + /// gone (the authoritative bound is the object's ShapeId descriptor), so + /// ask the runtime instead of reading the header. + pub fn js_object_live_slot_count(obj: *const ObjectHeader) -> u32; + /// Write the field at `field_index`. pub fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, value: JsValue); diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 678b4807a8..75f75417e0 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -56,7 +56,7 @@ pub use async_runtime::{ mod types; pub use types::{ ArrayHeader, BigIntHeader, BufferHeader, ClosureHeader, NativeAsyncCompletion, ObjectHeader, - Promise, StringHeader, BIGINT_LIMBS, + Promise, StringHeader, BIGINT_LIMBS, OBJECT_HEADER_ABI_REVISION, }; mod handle; @@ -74,7 +74,7 @@ mod jsvalue; pub use jsvalue::{ alloc_null_proto_object, alloc_object, build_object_shape, js_array_alloc, js_array_get, js_array_length, js_array_push, js_array_set, js_object_alloc_with_shape, js_object_get_field, - js_object_set_field, object_field_by_name, JsValue, + js_object_live_slot_count, js_object_set_field, object_field_by_name, JsValue, }; mod closure; diff --git a/crates/perry-ffi/src/types.rs b/crates/perry-ffi/src/types.rs index 6748756bad..3cd931c503 100644 --- a/crates/perry-ffi/src/types.rs +++ b/crates/perry-ffi/src/types.rs @@ -8,6 +8,26 @@ /// Length of the fixed BigInt limb array. pub const BIGINT_LIMBS: usize = 16; +/// Revision of the [`ObjectHeader`] ABI this crate mirrors. +/// +/// Bump on ANY change to `ObjectHeader`'s size, field set, or field offsets, +/// and bump `perry_runtime::perry_object_header_abi_revision()` in the same +/// commit — `object_header_abi_revision_matches_the_pinned_layout` fails +/// otherwise. +/// +/// It exists because `perry-ffi` is **published to crates.io**: a wrapper built +/// against an older mirror and linked by `perry compile` against a newer +/// runtime reads the wrong offsets with no diagnostic at all. An out-of-tree +/// wrapper should assert +/// `perry_ffi::OBJECT_HEADER_ABI_REVISION == perry_object_header_abi_revision()` +/// (declared `extern "C" fn() -> u32`) once at startup and refuse to run on a +/// mismatch. +/// +/// * 1 — `{object_type, class_id, parent_class_id, field_count, keys_array, meta}`, +/// 32 bytes on LP64. +/// * 2 — `{class_id, parent_class_id, keys_array, meta}`, 24 bytes on LP64 (#8113). +pub const OBJECT_HEADER_ABI_REVISION: u32 = 2; + /// Header for a runtime-allocated JS string. #[repr(C)] pub struct StringHeader { @@ -33,16 +53,29 @@ pub struct ArrayHeader { } /// Header for a runtime-allocated JS object. +/// +/// # ABI revision 2 (#8113) — BREAKING for out-of-tree mirrors +/// +/// Revision 1 opened with `object_type: u32` and carried `field_count: u32`. +/// Both were derivable and both are gone; `class_id` moved from offset 4 to 0, +/// the shape word from 8 to 4, and the struct shrank from 32 to 24 bytes on +/// LP64 (16 on ILP32). +/// +/// A wrapper compiled against the revision-1 mirror and linked against a +/// revision-2 runtime reads `class_id` out of the deleted `object_type` slot +/// with **no compile error**. That cannot be detected retroactively — nothing in +/// revision 1 references a version symbol — so revision 1 consumers must +/// recompile. From this revision on, [`OBJECT_HEADER_ABI_REVISION`] gives the +/// tripwire: assert it against the runtime's +/// `perry_object_header_abi_revision()` at startup, and a future layout change +/// is caught instead of silently misread. #[repr(C)] pub struct ObjectHeader { - /// Runtime object type discriminator. - pub object_type: u32, - /// Runtime class identifier. + /// Runtime class identifier. Offset 0 since ABI revision 2 (#8113). pub class_id: u32, - /// Runtime parent class identifier, or zero when absent. + /// Runtime parent class identifier during allocation, then the runtime + /// ShapeId after shape stamping. Never authoritative parent data. pub parent_class_id: u32, - /// Number of inline fields. - pub field_count: u32, /// Runtime array of object keys, or null for class instances. pub keys_array: *mut ArrayHeader, /// Per-object metadata record (#6759 Phase B), or null when the object @@ -147,13 +180,16 @@ mod layout_tests { ); } + /// #8113: this test — and the whole `layout_tests` module — had **never + /// executed**. `runtime-link` is enabled nowhere in `.github/`, and + /// `cargo-test` is a per-package loop, so a size or padding divergence + /// between the mirror and the runtime was invisible; only outright field + /// DELETION went red, via `offset_of!` failing to compile. `test.yml`'s + /// `cargo-test` job now runs + /// `cargo test -p perry-ffi --features runtime-link` unconditionally. #[test] fn object_header_matches_runtime() { assert_layout!(ObjectHeader, perry_runtime::ObjectHeader); - assert_eq!( - offset_of!(ObjectHeader, object_type), - offset_of!(perry_runtime::ObjectHeader, object_type) - ); assert_eq!( offset_of!(ObjectHeader, class_id), offset_of!(perry_runtime::ObjectHeader, class_id) @@ -162,10 +198,6 @@ mod layout_tests { offset_of!(ObjectHeader, parent_class_id), offset_of!(perry_runtime::ObjectHeader, parent_class_id) ); - assert_eq!( - offset_of!(ObjectHeader, field_count), - offset_of!(perry_runtime::ObjectHeader, field_count) - ); assert_eq!( offset_of!(ObjectHeader, keys_array), offset_of!(perry_runtime::ObjectHeader, keys_array) @@ -176,6 +208,30 @@ mod layout_tests { ); } + /// The size/padding half of the mirror contract, spelled separately so a + /// failure names the actual problem. `assert_layout!` above already covers + /// it, but this pins the ABSOLUTE numbers too: a mirror that tracks the + /// runtime while BOTH drift is still an ABI break for every published + /// consumer, and that is the case `object_header_matches_runtime` cannot + /// see. + #[test] + fn object_header_abi_revision_matches_the_pinned_layout() { + assert_eq!(OBJECT_HEADER_ABI_REVISION, 2); + assert_eq!( + OBJECT_HEADER_ABI_REVISION, + perry_runtime::perry_object_header_abi_revision(), + "the runtime and the published mirror disagree about the header ABI \ + revision — bump BOTH, in the same commit, and say so in the \ + changelog: perry-ffi is published to crates.io" + ); + #[cfg(target_pointer_width = "64")] + assert_eq!(size_of::(), 24); + #[cfg(target_pointer_width = "32")] + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(ObjectHeader, class_id), 0); + assert_eq!(offset_of!(ObjectHeader, parent_class_id), 4); + } + #[test] fn buffer_header_matches_runtime() { assert_layout!(BufferHeader, perry_runtime::BufferHeader); diff --git a/crates/perry-runtime/src/array/flat_clone.rs b/crates/perry-runtime/src/array/flat_clone.rs index bc4ecc2ce4..2ebb9f8818 100644 --- a/crates/perry-runtime/src/array/flat_clone.rs +++ b/crates/perry-runtime/src/array/flat_clone.rs @@ -428,9 +428,9 @@ pub extern "C" fn js_array_clone(src: *const ArrayHeader) -> *mut ArrayHeader { // `Array.from({length: N, 0: ..., 1: ...})` (array-like object) per // ECMA-262 §23.1.2.1 step 8: read `.length`, then for each index // 0..length read `obj[i]` (missing slots → undefined). Pre-fix this - // fell through to the array-memcpy path which read ObjectHeader's - // `field_count` u32 as `length` and the inline f64 slots as elements - // — garbage. Detect via `GC_TYPE_OBJECT`. + // fell through to the array-memcpy path which read an `ObjectHeader` word + // as `length` (`class_id` since #8113) and the inline f64 slots as + // elements — garbage. Detect via `GC_TYPE_OBJECT`. if raw_addr >= crate::gc::GC_HEADER_SIZE + 0x1000 { let obj_type = unsafe { let hdr = (raw_addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index 9aade46014..d6fe826b76 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -147,7 +147,8 @@ fn to_length(v: f64) -> i64 { /// not `clean_arr_ptr` alone — must gate the array fast path: `clean_arr_ptr` /// accepts an object pointer whose leading `ObjectHeader` words happen to pass /// its `length <= capacity` bound, then `(*arr).length` / the element buffer -/// read `field_count` / inline slots as garbage (see `normalize_array_receiver`). +/// read `ObjectHeader`'s words / inline slots as garbage (see +/// `normalize_array_receiver`). #[inline] pub(super) fn as_real_array(recv: f64) -> *mut ArrayHeader { let b = recv.to_bits(); diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 9e7f7ef4bf..b0b5270af3 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -847,9 +847,15 @@ pub(crate) fn array_ptr_as_proxy(arr: *const ArrayHeader) -> Option { /// were a real ArrayHeader (reading `(*arr).length` + the inline element /// buffer). When the receiver is a plain object, `clean_arr_ptr` either nulls /// it (TypeError downstream) or — if the object's first u32s happen to pass the -/// length<=capacity sanity bound — reads the `ObjectHeader` field_count / inline +/// length<=capacity sanity bound — reads the `ObjectHeader`'s words / inline /// f64 slots as garbage elements (e.g. `8.48e-314`). /// +/// #8113 made that sanity bound WEAKER, not stronger: with `object_type` gone, +/// `length` aliases `class_id` and `capacity` aliases the ShapeId word, so a +/// plain object literal (`class_id == 0`) trivially satisfies +/// `length <= capacity`. The bound was never the defense — the GC-header +/// `obj_type` test below is — but do not reintroduce a caller that leans on it. +/// /// This helper detects the array-like case via the GC header `obj_type` /// (`GC_TYPE_OBJECT` == plain object) and materializes it into a real array via /// `js_array_from_arraylike` (which ToLength-coerces `length` and reads indexed diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 3de073abfb..98207555b8 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -835,7 +835,9 @@ pub extern "C" fn js_array_set_length_strict(arr: *mut ArrayHeader, new_length: if cleaned.is_null() { // #7574: `a.length = n` on a `class X extends Array` instance reached // here through the `is_array_expr`-keyed `property_set` lowering and - // wrote `ObjectHeader.object_type`. Perform the Array-exotic + // wrote the first `ObjectHeader` word (`class_id` since #8113 — i.e. + // the write corrupts class identity, not an inert tag). Perform the + // Array-exotic // `Set(O, "length", n, true)` on the object instead. if let Some(recv) = crate::array::subclass::array_object_receiver(arr) { crate::array::subclass::array_object_set_length(recv, new_length); diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 60105bd952..5c0db32c74 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -132,11 +132,11 @@ pub fn array_subclass_has_iterator_override(value: f64) -> bool { // perry models as a plain `ObjectHeader`. The two headers overlay field for // field: // -// ArrayHeader.length (u32 @0) <- ObjectHeader.object_type (= 1) -// ArrayHeader.capacity (u32 @4) <- ObjectHeader.class_id -// elements[0] (@8) <- parent_class_id || field_count -// elements[1] (@16) <- keys_array (a *mut ArrayHeader) -// elements[2] (@24) <- meta (a *mut ObjectMeta) +// ArrayHeader.length (u32 @0) <- ObjectHeader.class_id (#8113) +// ArrayHeader.capacity (u32 @4) <- ObjectHeader.parent_class_id (ShapeId) +// elements[0] (@8) <- keys_array (a *mut ArrayHeader) +// elements[1] (@16) <- meta (a *mut ObjectMeta) +// elements[2] (@24) <- inline field slot 0 // // so element WRITES overwrite two live GC child edges with arbitrary doubles — // the collector then traces whatever the mutator stored. `a.push(1); a.push(2)` diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index ec2c4b8037..815411e0b9 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -3,11 +3,19 @@ //! //! Every test here is **sabotage-shaped**: it first asserts that the bytes the //! pre-fix code misread are *still sitting there* — an `ObjectHeader` read as -//! an `ArrayHeader` yields `length == object_type == 1` and -//! `capacity == class_id`, both of which sail through `clean_arr_ptr`'s -//! length/capacity sanity check — and only then that the entry point refuses -//! or resolves it. A green run therefore proves the brand check FIRED, not that -//! the receiver happened to look invalid for some unrelated reason. +//! an `ArrayHeader` yields a `(length, capacity)` pair that sails through +//! `clean_arr_ptr`'s length/capacity sanity check — and only then that the +//! entry point refuses or resolves it. A green run therefore proves the brand +//! check FIRED, not that the receiver happened to look invalid for some +//! unrelated reason. +//! +//! #8113 MOVED the overlay. `ObjectHeader::object_type` is gone, so +//! `ArrayHeader.length` now aliases `class_id` and `capacity` aliases the shape +//! word. That makes the class ids used here load-bearing: the pre-fix sanity +//! check is `length <= capacity && length <= 100M`, and `length` is the class +//! id, so every fixture below uses an id under 100,000,000. A larger id would +//! fail that check for an unrelated reason and silently turn these tests +//! vacuous — which is exactly the failure mode the module is written to avoid. use super::subclass::{ array_object_receiver, is_array_subclass_class_id, raw_receiver_is_heap_object, @@ -23,24 +31,29 @@ fn as_array_header(obj: *mut ObjectHeader) -> *const ArrayHeader { } /// The overlay that makes this bug possible, pinned. If `ObjectHeader` ever -/// stops starting with `object_type: u32, class_id: u32`, the misread this +/// stops starting with `class_id: u32, parent_class_id: u32`, the misread this /// whole family defends against changes shape and these tests must be revisited. #[test] fn object_header_still_overlays_array_header_length_and_capacity() { - let class_id = 0x7574_0001; + let class_id = 0x0074_0001; let obj = js_object_alloc(class_id, 2); assert!(!obj.is_null()); let hdr = as_array_header(obj); unsafe { assert_eq!( (*hdr).length, - 1, - "ArrayHeader.length must still alias ObjectHeader.object_type (= 1)" + class_id, + "#8113: ArrayHeader.length must alias ObjectHeader.class_id" ); assert_eq!( (*hdr).capacity, - class_id, - "ArrayHeader.capacity must still alias ObjectHeader.class_id" + (*obj).parent_class_id, + "#8113: ArrayHeader.capacity must alias the ObjectHeader shape word" + ); + assert!( + crate::object::shapes::is_shape_id((*obj).parent_class_id), + "test premise: a birth-stamped object carries a ShapeId in word 1, \ + which is what keeps the forged capacity above the forged length" ); // The sanity check `clean_arr_ptr` applied BEFORE the fix: `length <= // capacity && length <= 100M`. Both hold, which is precisely why the @@ -52,7 +65,7 @@ fn object_header_still_overlays_array_header_length_and_capacity() { #[test] fn clean_arr_ptr_refuses_a_plain_object_receiver() { - let obj = js_object_alloc(0x7574_0002, 2); + let obj = js_object_alloc(0x0074_0002, 2); let hdr = as_array_header(obj); unsafe { // Sabotage precondition: the forged (length, capacity) pair is still @@ -88,7 +101,7 @@ fn a_genuine_array_takes_the_fast_path_and_is_never_redirected() { #[test] fn array_object_receiver_admits_an_array_subclass_instance() { - let class_id = 0x7574_0003; + let class_id = 0x0074_0003; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); assert!( is_array_subclass_class_id(class_id), @@ -97,9 +110,11 @@ fn array_object_receiver_admits_an_array_subclass_instance() { let obj = js_object_alloc(class_id, 2); let hdr = as_array_header(obj); unsafe { - // Sabotage precondition: the misread is still available. - assert_eq!((*hdr).length, 1); - assert_eq!((*hdr).capacity, class_id); + // Sabotage precondition: the misread is still available (#8113 overlay). + assert_eq!((*hdr).length, class_id); + assert_eq!((*hdr).capacity, (*obj).parent_class_id); + assert!((*hdr).length <= (*hdr).capacity); + assert!((*hdr).length <= 100_000_000); } assert!( raw_receiver_is_heap_object(hdr), @@ -118,8 +133,8 @@ fn array_object_receiver_admits_an_array_subclass_instance() { #[test] fn array_object_receiver_rejects_an_ordinary_class_instance() { - let class_id = 0x7574_0004; - crate::object::js_register_class_parent(class_id, 0x7574_0005); + let class_id = 0x0074_0004; + crate::object::js_register_class_parent(class_id, 0x0074_0005); assert!(!is_array_subclass_class_id(class_id)); let obj = js_object_alloc(class_id, 2); assert!( diff --git a/crates/perry-runtime/src/builtins/formatting/util_format.rs b/crates/perry-runtime/src/builtins/formatting/util_format.rs index cb37ff5023..0df8d59c8b 100644 --- a/crates/perry-runtime/src/builtins/formatting/util_format.rs +++ b/crates/perry-runtime/src/builtins/formatting/util_format.rs @@ -93,7 +93,7 @@ unsafe fn util_format_json_object_has_cycle(ptr: *const u8, stack: &mut Vec()) as *const f64; let alloc_limit = std::cmp::max(num_fields, crate::object::INLINE_SLOT_FLOOR as u32); (0..keys_len).any(|f| { diff --git a/crates/perry-runtime/src/builtins/globals.rs b/crates/perry-runtime/src/builtins/globals.rs index 7d100276ba..364b61d474 100644 --- a/crates/perry-runtime/src/builtins/globals.rs +++ b/crates/perry-runtime/src/builtins/globals.rs @@ -813,7 +813,7 @@ fn js_structured_clone_inner(value: f64, depth: usize) -> f64 { } else { 0 }; - if key_count > (*src_obj).field_count as usize { + if key_count > crate::object::object_live_slot_count(src_obj) as usize { let scope = crate::gc::RuntimeHandleScope::new(); let src_handle = scope.root_raw_const_ptr(src_obj); let new_obj = crate::object::js_object_alloc(0, key_count as u32); @@ -856,7 +856,7 @@ fn js_structured_clone_inner(value: f64, depth: usize) -> f64 { let cloned_obj = crate::object::js_object_clone_with_extra(value, 0, std::ptr::null(), 0); if !cloned_obj.is_null() && (cloned_obj as usize) > 0x10000 { - let field_count = (*cloned_obj).field_count; + let field_count = crate::object::object_live_slot_count(cloned_obj); let fields = (cloned_obj as *mut u8) .add(std::mem::size_of::()) as *mut f64; diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index d57f1c5e4b..6b8823f522 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -589,7 +589,7 @@ impl Serializer { return 0; } let keys_len = (*keys_arr).length; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let alloc_limit = std::cmp::max(num_fields, crate::object::INLINE_SLOT_FLOOR as u32); let fields_ptr = (obj as *const u8).add(std::mem::size_of::()) as *const f64; let mut count = 0u64; diff --git a/crates/perry-runtime/src/collection_iter_object.rs b/crates/perry-runtime/src/collection_iter_object.rs index 27d000378d..a90e58489a 100644 --- a/crates/perry-runtime/src/collection_iter_object.rs +++ b/crates/perry-runtime/src/collection_iter_object.rs @@ -86,8 +86,8 @@ unsafe fn map_iter_obj_raw(map: *const MapHeader, kind: i32) -> i64 { // #7570: these entries are reached from the DECLARED-type lowering of // `m.entries()`/`.keys()`/`.values()`, so `map` can be a `class X extends // Map` instance (a plain ObjectHeader) rather than a `MapHeader`. Every - // `next()` would then read `parent_class_id ‖ field_count` as the entries - // pointer. Resolve onto the hidden backing before the iterator captures it. + // `next()` would then read `keys_array` as the entries pointer (#8113 moved + // the confusable word; the hazard is unchanged). Resolve onto the hidden backing before the iterator captures it. // Unlike the `js_map_*` entries this is not a `clean_map_ptr` caller — it // stores the raw pointer into the iterator object, so the redirect has to // happen here. diff --git a/crates/perry-runtime/src/dyn_eval/env.rs b/crates/perry-runtime/src/dyn_eval/env.rs index 85fc8fd17f..738b1ded11 100644 --- a/crates/perry-runtime/src/dyn_eval/env.rs +++ b/crates/perry-runtime/src/dyn_eval/env.rs @@ -261,7 +261,10 @@ fn scope_probe(env: f64, key: *const crate::string::StringHeader) -> ScopeProbe if keys.is_null() { return ScopeProbe::Bail; } - let alloc_limit = std::cmp::max((*o).field_count, crate::object::INLINE_SLOT_FLOOR as u32); + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(o), + crate::object::INLINE_SLOT_FLOOR as u32, + ); if let Some(idx) = crate::object::prop_plan::read_plan_lookup(keys as usize, key as usize) { if idx < alloc_limit { let v = crate::object::js_object_get_field(o, idx); diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index e7c9494f9d..516e691a8d 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -4,17 +4,36 @@ use crate::string::{js_string_from_bytes, StringHeader}; -/// Object type tag for runtime type discrimination -pub const OBJECT_TYPE_REGULAR: u32 = 1; +/// `ErrorHeader`'s own leading discriminator. #8113 removed the punned +/// `ObjectHeader::object_type` word, so this is no longer an ObjectHeader tag — +/// it is only `ErrorHeader`'s first field, and NOTHING may read it off an +/// untyped pointer. Use [`ptr_is_native_error`] to ask the question. pub const OBJECT_TYPE_ERROR: u32 = 2; -/// #1789: a heap "class object" — the value a class EXPRESSION evaluates to -/// (a regular object stamped with the compile-time template's `class_id`, -/// carrying per-evaluation static fields as own properties). Marks the value -/// as the CLASS itself (vs an instance) so `typeof` is "function", and -/// `new`/`instanceof` read `class_id` from the object. Own-field get/set -/// treat it like OBJECT_TYPE_REGULAR (the get/set paths are gated on -/// `gc_type`/`class_id`, not on this tag). -pub const OBJECT_TYPE_CLASS: u32 = 3; + +/// The authoritative "is this a native `ErrorHeader`?" test. +/// +/// # Why this exists (#8113) +/// +/// `ObjectHeader` used to open with an `object_type: u32` prefix-punned against +/// `ErrorHeader`'s, so seven sites answered this question with a raw +/// `*(ptr as *const u32) == OBJECT_TYPE_ERROR` on an untyped pointer. Deleting +/// that word makes offset 0 `class_id`, and `OBJECT_TYPE_ERROR` is **2** — an +/// ordinary user class id. The raw read would therefore reclassify the second +/// class a program declares as an `ErrorHeader` and read `message` / `name` / +/// `stack` / `errors` out of its field slots: a silent type confusion of +/// exactly the #8100 shape. +/// +/// Every `ErrorHeader` is arena-allocated with `GC_TYPE_ERROR` (`alloc_error`, +/// the only allocation site), and no other cell uses that kind, so the GC +/// header discriminates exactly. This is the same move #8086 made for +/// `object_is_regular`. +#[inline] +pub(crate) unsafe fn ptr_is_native_error(addr: usize) -> bool { + crate::value::addr_class::try_read_gc_header(addr).is_some_and(|header| { + header.obj_type == crate::gc::GC_TYPE_ERROR + && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 + }) +} /// Error subclass discriminator (stored in `error_kind`). /// Used by `instanceof TypeError` etc. to check kind without name string compare. @@ -758,8 +777,7 @@ pub extern "C" fn js_error_is_error(value: f64) -> f64 { if ptr.is_null() || !crate::object::is_valid_obj_ptr(ptr) { return f64::from_bits(crate::value::TAG_FALSE); } - let object_type = std::ptr::read(ptr as *const u32); - if object_type == OBJECT_TYPE_ERROR { + if ptr_is_native_error(ptr as usize) { return f64::from_bits(crate::value::TAG_TRUE); } } @@ -1523,8 +1541,8 @@ pub extern "C" fn js_error_get_cause(error: *mut ErrorHeader) -> f64 { /// re-applies `POINTER_TAG` to the result. That is only sound when `obj` is a /// genuine native error: the `errors` field lives at a fixed byte offset in /// `ErrorHeader`, so applying it to a *regular* user object reads an unrelated -/// property slot. Observed in the wild: a plain object (`object_type == -/// OBJECT_TYPE_REGULAR`) whose `+48` slot held NaN-boxed `undefined` +/// property slot. Observed in the wild: a plain object whose `+48` slot held +/// NaN-boxed `undefined` /// (`0x7FFC_0000_0000_0001`); codegen OR-ed `POINTER_TAG` onto it to produce /// `0x7FFD_0000_0000_0001` — a handle-band id (`raw = 1`), not a heap array — /// which `for…of` then mis-iterated ("Iterator result is not an object"). @@ -1547,11 +1565,10 @@ pub extern "C" fn js_error_get_errors(error: *mut ErrorHeader) -> *mut crate::ar if !crate::value::addr_class::is_plausible_heap_addr(addr) { return std::ptr::null_mut(); } - // Native error objects carry `object_type == OBJECT_TYPE_ERROR` in - // their first u32; only those have the `errors` field at a fixed - // offset. (Matches the validation in `js_error_is_error`.) - let object_type = std::ptr::read(error as *const u32); - if object_type == OBJECT_TYPE_ERROR { + // Native error objects are the `GC_TYPE_ERROR` cells `alloc_error` + // makes; only those have the `errors` field at a fixed offset. + // (Matches the validation in `js_error_is_error`.) + if ptr_is_native_error(error as usize) { return (*error).errors; } // Not a native error — resolve `.errors` as an ordinary own property diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index 3766297e2a..3f9961f716 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -511,8 +511,12 @@ pub(crate) fn print_uncaught(value: f64) { if top16 == 0x7FFD { let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; if ptr >= 0x10000 { - let object_type = unsafe { *(ptr as *const u32) }; - if object_type == crate::error::OBJECT_TYPE_ERROR { + // #8113: both discriminators come from the GC header / ShapeId + // descriptor now. Offset 0 is `class_id`, so the old raw + // `*(ptr as *const u32)` read would classify the second class a + // program declares (`class_id == 2 == OBJECT_TYPE_ERROR`) as an + // Error and print `name`/`message`/`stack` out of its field slots. + if unsafe { crate::error::ptr_is_native_error(ptr) } { // ErrorHeader: object_type, error_kind, message, name, stack, cause, errors let eh = ptr as *const crate::error::ErrorHeader; let name_str = unsafe { string_header_to_string((*eh).name) }; @@ -551,7 +555,9 @@ pub(crate) fn print_uncaught(value: f64) { } return; } - if object_type == crate::error::OBJECT_TYPE_REGULAR { + if unsafe { + crate::object::object_is_regular(ptr as *const crate::object::ObjectHeader) + } { // Probe for `.message` and `.stack` properties the way // Node does for thrown non-Error objects. Users commonly // throw custom error shapes like `{ message, stack }` or diff --git a/crates/perry-runtime/src/gc/heap_snapshot.rs b/crates/perry-runtime/src/gc/heap_snapshot.rs index a662247855..3690dd6373 100644 --- a/crates/perry-runtime/src/gc/heap_snapshot.rs +++ b/crates/perry-runtime/src/gc/heap_snapshot.rs @@ -311,7 +311,7 @@ pub fn gc_build_v8_heap_snapshot_json() -> String { let fc = unsafe { crate::object::shapes::object_shape_descriptor(obj) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*obj).field_count as usize) + .unwrap_or(crate::object::object_live_slot_count(obj) as usize) }; if fc <= 10_000 { ( diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index a081d9999e..c5b7d65820 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -218,7 +218,7 @@ unsafe fn with_shape_shared_descriptor( let object = user_ptr as *const crate::object::ObjectHeader; let field_count = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*object).field_count as usize); + .unwrap_or(crate::object::object_live_slot_count(object) as usize); let map = hot_shape_layouts().borrow(); let desc = map.get(&keys)?.as_ref()?; if desc.slot_count != field_count { @@ -690,7 +690,7 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits let object = parent_user as *const crate::object::ObjectHeader; let live_slots = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*object).field_count as usize); + .unwrap_or(crate::object::object_live_slot_count(object) as usize); if slot_index < live_slots { return; } @@ -1030,7 +1030,7 @@ unsafe fn init_typed_shape_layout( let shape_descriptor = crate::object::shapes::object_shape_descriptor(obj_header); let object_slot_count = shape_descriptor .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*obj_header).field_count as usize); + .unwrap_or(crate::object::object_live_slot_count(obj_header) as usize); if object_slot_count != slot_count { layout_set_typed_unknown(header, user_ptr); return; diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index fa917ca842..83b9b8f5fe 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -27,7 +27,7 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( .unwrap_or((*obj).keys_array); let live_inline_slot_count = descriptor .map(|facts| facts.live_inline_slot_count) - .unwrap_or((*obj).field_count); + .unwrap_or(crate::object::object_live_slot_count(obj)); if old_keys.is_null() { Some((obj, 0, 0, live_inline_slot_count)) } else if crate::value::addr_class::try_read_tracked_gc_header(old_keys as usize) diff --git a/crates/perry-runtime/src/gc/roots/runtime_handles.rs b/crates/perry-runtime/src/gc/roots/runtime_handles.rs index 9d535a6166..b1fc83658f 100644 --- a/crates/perry-runtime/src/gc/roots/runtime_handles.rs +++ b/crates/perry-runtime/src/gc/roots/runtime_handles.rs @@ -246,7 +246,7 @@ impl<'scope> RuntimeHandle<'scope> { /// ```ignore /// let obj = obj_h.get_raw_mut_ptr::(); /// let found = class_instance_has_member(class_id, "size"); // ALLOCATES - /// (*obj).field_count // from-space + /// crate::object::object_live_slot_count(obj) // from-space /// ``` /// /// The defect is not a missing root. It is that `obj` is still *nameable* @@ -257,7 +257,7 @@ impl<'scope> RuntimeHandle<'scope> { /// let (found, obj) = obj_h.across_mut::( /// || class_instance_has_member(class_id, "size"), /// ); - /// (*obj).field_count // post-collection + /// crate::object::object_live_slot_count(obj) // post-collection /// ``` /// /// # What it does NOT do diff --git a/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs b/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs index 584b59f37f..0961254d5e 100644 --- a/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs +++ b/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs @@ -3,7 +3,7 @@ //! //! # The hazard //! -//! Both branches set `object_type`, `class_id`, `parent_class_id`, +//! Both branches set `class_id`, `parent_class_id`, //! `field_count` and `meta` immediately after allocation, then set //! `keys_array` only at the END, via `set_object_keys_array`. In between sits //! `crate::array::js_array_alloc`. diff --git a/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs b/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs index 78454322ad..cd2b46e64e 100644 --- a/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs +++ b/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs @@ -291,7 +291,7 @@ fn test_ffi_index_field_set_widens_field_count_7164() { // Mirrors `perry_ffi::alloc_object()` exactly: class_id=0, field_count=0. let obj = crate::object::js_object_alloc(0, 0); assert_eq!( - unsafe { (*obj).field_count }, + unsafe { crate::object::object_live_slot_count(obj) }, 0, "test setup: alloc_object()'s field_count starts at 0" ); @@ -324,7 +324,7 @@ fn test_ffi_index_field_set_widens_field_count_7164() { the whole payload range empty, so the mask is never consulted)" ); assert_eq!( - unsafe { (*obj).field_count }, + unsafe { crate::object::object_live_slot_count(obj) }, 1, "#7164: js_object_set_field must widen field_count to cover the \ written index, mirroring field_set_by_name/tail.rs's publication order" diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 34150afa7b..d4e66e2a76 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -103,10 +103,8 @@ fn alloc_tracked_test_object() -> *mut crate::object::ObjectHeader { let child = gc_malloc(header_size + fields_size, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; unsafe { - (*child).object_type = crate::error::OBJECT_TYPE_REGULAR; (*child).class_id = 0; (*child).parent_class_id = 0; - (*child).field_count = 0; (*child).keys_array = std::ptr::null_mut(); (*child).meta = std::ptr::null_mut(); let fields_ptr = (child as *mut u8).add(header_size) as *mut crate::JSValue; @@ -932,11 +930,20 @@ fn gap_born_child_stored_between_finalize_and_sweep_survives() { ); unsafe { let obj = child as *mut crate::object::ObjectHeader; + // #8113: `object_type == 1` (the old canary at offset 0) is gone. The + // shape word replaces it and is a STRONGER canary: the overflow store + // above published a ShapeId into it, so it holds a value in a narrow + // 2^30-wide range that arbitrary recycled bytes would not land in. assert_eq!( - (*obj).object_type, - 1, + (*obj).class_id, + 0, "gap-born child payload clobbered after sweep" ); + assert!( + crate::object::shapes::is_shape_id((*obj).parent_class_id), + "gap-born child payload clobbered after sweep: shape word is {:#x}", + (*obj).parent_class_id + ); } crate::object::test_clear_overflow_fields_root(); } diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs index a300a4630a..e2ecf93076 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs @@ -80,10 +80,10 @@ unsafe fn alloc_malloc_test_object() -> *mut crate::object::ObjectHeader { std::mem::size_of::(), GC_TYPE_OBJECT, ) as *mut crate::object::ObjectHeader; - (*obj).object_type = 1; (*obj).class_id = 0; + // #8113: zero live slots, so no descriptor is needed — the derived bound + // for an unstamped receiver is 0, which is the right answer here. (*obj).parent_class_id = 0; - (*obj).field_count = 0; (*obj).keys_array = std::ptr::null_mut(); (*obj).meta = std::ptr::null_mut(); obj diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs b/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs index d7840aff82..82b80dbd6c 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs @@ -462,7 +462,7 @@ fn test_typed_shape_descriptor_growing_new_field_falls_back() { crate::object::js_object_set_field_by_name(obj, extra_key, 42.0); unsafe { - assert_eq!((*obj).field_count, 2); + assert_eq!(crate::object::object_live_slot_count(obj), 2); } assert_eq!(test_layout_pointer_slot_count(obj as usize, 2), None); diff --git a/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs b/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs index 5b11c4746e..ffacd8fd46 100644 --- a/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs +++ b/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs @@ -35,11 +35,12 @@ fn gc_recovers_keys_and_live_slots_from_shape_id_after_header_sabotage() { assert_eq!(descriptor.logical_key_count, 2); assert_eq!(descriptor.live_inline_slot_count, 2); - // These are ABI mirrors until #8047. Corrupt both to prove the GC walk - // derives its strong keys edge and exact payload range from ShapeId. + // `keys_array` is the last ABI mirror (#8047 removes it; #8113 + // already removed `field_count`). Corrupt it to prove the GC walk + // derives its strong keys edge — and, since the payload range now + // has NO header mirror at all, its exact slot count — from ShapeId. // GC_STORE_AUDIT(POINTER_FREE): test sabotage removes the compatibility edge by storing null. (*obj).keys_array = std::ptr::null_mut(); - (*obj).field_count = 0; let slots = super::support::test_heap_child_slots_for_user(obj as *mut u8); assert_eq!((*obj).keys_array as u64, descriptor.keys); diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index 38bb9da2e5..d3e961b835 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -810,13 +810,25 @@ pub(super) fn tracked_malloc_headers_matching(headers: &[usize]) -> usize { pub(super) unsafe fn alloc_old_test_object( field_count: u32, ) -> (*mut crate::object::ObjectHeader, *mut u64) { + // #8113: the live inline-slot bound lives ONLY in the ShapeId descriptor, + // so a raw fixture has to publish one or the collector traces zero slots. + // Mint the id BEFORE the object exists: minting inserts into the shape + // table and can therefore collect, and this fixture holds no handle on the + // fresh header. + // A zero-slot fixture needs no descriptor at all — the derived bound is 0 + // either way — and minting one would perturb the descriptor-count + // accounting that sibling tests assert on. + let shape_id = if field_count == 0 { + 0 + } else { + crate::object::shapes::shape_descriptor_ensure(std::ptr::null(), 0, field_count) + .expect("shape id range exhausted in a test fixture") + }; let payload = std::mem::size_of::() + field_count as usize * 8; let obj = crate::arena::arena_alloc_gc_old(payload, 8, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; - (*obj).object_type = 1; (*obj).class_id = 0; - (*obj).parent_class_id = 0; - (*obj).field_count = field_count; + (*obj).parent_class_id = shape_id; (*obj).keys_array = std::ptr::null_mut(); (*obj).meta = std::ptr::null_mut(); let fields = @@ -830,13 +842,22 @@ pub(super) unsafe fn alloc_old_test_object( pub(super) unsafe fn alloc_nursery_test_object( field_count: u32, ) -> (*mut crate::object::ObjectHeader, *mut u64) { + // #8113: see `alloc_old_test_object` — mint the descriptor first, then + // stamp the fresh header with a plain store. + // A zero-slot fixture needs no descriptor at all — the derived bound is 0 + // either way — and minting one would perturb the descriptor-count + // accounting that sibling tests assert on. + let shape_id = if field_count == 0 { + 0 + } else { + crate::object::shapes::shape_descriptor_ensure(std::ptr::null(), 0, field_count) + .expect("shape id range exhausted in a test fixture") + }; let payload = std::mem::size_of::() + field_count as usize * 8; let obj = crate::arena::arena_alloc_gc(payload, 8, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; - (*obj).object_type = 1; (*obj).class_id = 0; - (*obj).parent_class_id = 0; - (*obj).field_count = field_count; + (*obj).parent_class_id = shape_id; (*obj).keys_array = std::ptr::null_mut(); (*obj).meta = std::ptr::null_mut(); let fields = diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index b20cc30127..4af28ec24d 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1091,7 +1091,9 @@ pub const OBJ_FLAG_HAS_DESCRIPTORS: u16 = 0x800; /// Heap class-expression value (`class C {}`), as distinct from an ordinary /// instance carrying the same `GC_TYPE_OBJECT` allocation tag. This is the /// authoritative replacement for `ObjectHeader::object_type == -/// OBJECT_TYPE_CLASS`; the legacy payload word remains an ABI mirror until +/// OBJECT_TYPE_CLASS`; #8113 deleted that legacy payload word — the note below +/// is history, kept because it explains why the kind lives in the descriptor +/// rather than in /// #8047 removes it. Bit 13 is preserved by survival-age and layout-state /// updates and is otherwise unused for `GC_TYPE_OBJECT`. // #2145: this object is a per-kind `.prototype` whose diff --git a/crates/perry-runtime/src/intl/install.rs b/crates/perry-runtime/src/intl/install.rs index 9d0309bf19..356796248b 100644 --- a/crates/perry-runtime/src/intl/install.rs +++ b/crates/perry-runtime/src/intl/install.rs @@ -43,8 +43,9 @@ pub(super) fn install_constructor( let ctor_value = js_nanbox_pointer(ctor as i64); // Generous inline capacity so installing methods plus an accessor getter and - // the toStringTag symbol never bumps `field_count` past the physical slot - // count (which would expose an overflow slot — keys_array.rs #4099). + // the toStringTag symbol never bumps the live inline-slot count past the + // physical slot count (which would expose an overflow slot — + // keys_array.rs #4099). let proto = js_object_alloc(0, 16); set_field(proto, "constructor", ctor_value); set_builtin_attrs(proto, "constructor", PropertyAttrs::new(true, false, true)); diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index c64d190f89..a543e7bce8 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -1364,7 +1364,11 @@ mod tests { let arr = (value.bits() & POINTER_MASK) as *mut crate::ArrayHeader; let elem0 = crate::array::js_array_get(arr, 0); let obj = (elem0.bits() & POINTER_MASK) as *const crate::ObjectHeader; - (value, (*obj).field_count, (*(*obj).keys_array).length) + ( + value, + crate::object::object_live_slot_count(obj), + (*(*obj).keys_array).length, + ) } #[test] @@ -1497,7 +1501,7 @@ mod tests { let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); crate::object::js_object_set_field_by_name(obj, key, base + i as f64); } - assert!((*obj).field_count >= (*(*obj).keys_array).length); + assert!(crate::object::object_live_slot_count(obj) >= (*(*obj).keys_array).length); arr = crate::array::js_array_push(arr, JSValue::object_ptr(obj as *mut u8)); } let boxed = crate::value::js_nanbox_pointer(arr as i64); diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index bd707c3a4e..7db5087c08 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -378,7 +378,7 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( let obj_root = gc_scope.root_raw_const_ptr(ptr); let replacer_root = gc_scope.root_raw_const_ptr(replacer); let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that // reached here via a static TYPE_OBJECT hint). Node serializes those as @@ -958,7 +958,7 @@ pub(crate) unsafe fn stringify_object_pretty( } let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that // reached here via a static TYPE_OBJECT hint). Node serializes those as @@ -1148,7 +1148,7 @@ pub(crate) unsafe fn stringify_object_with_array_replacer( STRINGIFY_STACK.with(|s| s.borrow_mut().push(ptr as usize)); let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that // reached here via a static TYPE_OBJECT hint). Node serializes those as diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index e37c3f1650..8ce59f23f7 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -98,7 +98,7 @@ pub(crate) unsafe fn is_object_pointer(ptr: *const u8) -> bool { let keys_arr = (*obj).keys_array; let keys_len = (*keys_arr).length; let keys_cap = (*keys_arr).capacity; - let field_count = (*obj).field_count; + let field_count = crate::object::object_live_slot_count(obj); // keys_len is authoritative — the logical property count. field_count // can be EITHER less than keys_len (parser-built objects with ≥9 // fields cap field_count at the inline alloc_limit; closes #307; @@ -972,7 +972,7 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de } let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); // Templated fast path (#64 follow-up): if this object's shape has been // seen before in this stringify call, emit via the cached prefix table @@ -1661,7 +1661,7 @@ pub(crate) unsafe fn estimate_json_size(value: f64, type_hint: u32) -> usize { } if type_hint == TYPE_OBJECT || is_object_pointer(ptr) { let obj = ptr as *const crate::ObjectHeader; - let fields = (*obj).field_count as usize; + let fields = crate::object::object_live_slot_count(obj) as usize; return (fields * 200).max(256); } } diff --git a/crates/perry-runtime/src/json/stringify_shape_template.rs b/crates/perry-runtime/src/json/stringify_shape_template.rs index 3f5e26b567..087fbc29f2 100644 --- a/crates/perry-runtime/src/json/stringify_shape_template.rs +++ b/crates/perry-runtime/src/json/stringify_shape_template.rs @@ -241,7 +241,10 @@ pub(crate) unsafe fn build_shape_prefix_template(first_elem_bits: u64) -> Option /// above it live in overflow storage, not in the inline region. #[inline] unsafe fn object_alloc_limit(obj: *const crate::ObjectHeader) -> u32 { - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) + std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) } /// Read shape-template field slot `f` of `obj`: inline when it fits in the diff --git a/crates/perry-runtime/src/json_tape_tests.rs b/crates/perry-runtime/src/json_tape_tests.rs index d5f335e01f..deab72b966 100644 --- a/crates/perry-runtime/src/json_tape_tests.rs +++ b/crates/perry-runtime/src/json_tape_tests.rs @@ -129,7 +129,7 @@ fn recursive_materializer_reserves_exact_spill_per_object_depth() { unsafe { assert_eq!( - (*object).field_count, + crate::object::object_live_slot_count(object), crate::object::INLINE_SLOT_FLOOR as u32, "known width must not enlarge the primary object" ); @@ -140,7 +140,7 @@ fn recursive_materializer_reserves_exact_spill_per_object_depth() { assert_eq!((*spill).length, 3); assert_eq!( - (*nested).field_count, + crate::object::object_live_slot_count(nested), crate::object::INLINE_SLOT_FLOOR as u32 ); let nested_spill = @@ -187,7 +187,7 @@ fn iterative_materializer_reserves_exact_spill_without_widening_object() { let object = (value.bits() & crate::value::POINTER_MASK) as *const crate::ObjectHeader; unsafe { assert_eq!( - (*object).field_count, + crate::object::object_live_slot_count(object), crate::object::INLINE_SLOT_FLOOR as u32 ); let spill = diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 863e19337d..c598efcadd 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -279,6 +279,7 @@ pub use buffer::BufferHeader; pub use closure::ClosureHeader; pub use map::MapHeader; pub use object::ObjectHeader; +pub use object::{object_live_slot_count, perry_object_header_abi_revision}; pub use promise::Promise; pub use regex::RegExpHeader; pub use set::SetHeader; diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 65555e5141..856915bf0a 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -782,7 +782,8 @@ fn map_receiver_identity(map: *const MapHeader) -> *const MapHeader { /// redirected onto that backing (#7570); /// * a plain object that was merely *annotated* `Map` — resolved to /// null, so every entry point degrades through its existing null branch -/// instead of reading `parent_class_id ‖ field_count` as `entries`. +/// instead of reading `ObjectHeader.keys_array` as `entries` (#8113 moved +/// which word lands there; the hazard is unchanged). /// /// Anything with no readable `GcHeader` (handle-band ids, tag remnants, /// non-pointer garbage) is passed through unchanged: that is exactly the diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 397b3b0456..cd09978894 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -167,10 +167,8 @@ pub extern "C" fn js_object_alloc_with_parent( unsafe { // Initialize header - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated object starts with no keys-array edge. @@ -185,7 +183,8 @@ pub extern "C" fn js_object_alloc_with_parent( ptr::write(fields_ptr.add(i), JSValue::undefined()); } crate::gc::layout_init_pointer_free(ptr as *mut u8); - crate::object::shapes::synchronize_object_shape_descriptor(ptr); + // #8113: the birth live-slot bound is published here and nowhere else. + crate::object::shapes::birth_publish_object_shape(ptr, field_count); ptr } @@ -205,16 +204,15 @@ pub extern "C" fn js_object_alloc_fast(class_id: u32, field_count: u32) -> *mut unsafe { // Initialize header only - fields left uninitialized for constructor to fill - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = 0; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated object starts with no keys-array edge. (*ptr).keys_array = ptr::null_mut(); crate::gc::layout_init_pointer_free(ptr as *mut u8); - crate::object::shapes::synchronize_object_shape_descriptor(ptr); + // #8113: the birth live-slot bound is published here and nowhere else. + crate::object::shapes::birth_publish_object_shape(ptr, field_count); } ptr @@ -240,16 +238,15 @@ pub extern "C" fn js_object_alloc_fast_with_parent( let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated object starts with no keys-array edge. (*ptr).keys_array = ptr::null_mut(); crate::gc::layout_init_pointer_free(ptr as *mut u8); - crate::object::shapes::synchronize_object_shape_descriptor(ptr); + // #8113: the birth live-slot bound is published here and nowhere else. + crate::object::shapes::birth_publish_object_shape(ptr, field_count); } ptr @@ -270,12 +267,15 @@ pub extern "C" fn js_object_alloc_fast_with_parent( /// the `arena_alloc_gc` call — into the user's `new ClassName()` /// site, eliminating function-call overhead from the hot loop. #[inline] +/// Returns the header plus the BIRTH live inline-slot bound the allocation was +/// sized for. #8113: the header no longer carries a `field_count` word, so the +/// widened bound this computes has to travel back to the caller that stamps it. fn object_alloc_class_inline_keys_impl( class_id: u32, parent_class_id: u32, field_count: u32, keys_array: *mut ArrayHeader, -) -> *mut ObjectHeader { +) -> (*mut ObjectHeader, u32) { if parent_class_id != 0 { register_class(class_id, parent_class_id); } @@ -297,13 +297,13 @@ fn object_alloc_class_inline_keys_impl( let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = logical_field_count as u32; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); - set_object_keys_array(ptr, keys_array); + // #8113: the birth live-slot bound is a PARAMETER now — it used to be + // read back out of the `(*ptr).field_count` store that stood here. + set_object_keys_array_with_live(ptr, keys_array, logical_field_count as u32); // PerryTS/perry#4717: initialize ALL `max(field_count, 8)` field slots to // `undefined`, mirroring `js_object_alloc_with_parent`. The arena hands back @@ -321,7 +321,7 @@ fn object_alloc_class_inline_keys_impl( } crate::gc::layout_init_pointer_free(ptr as *mut u8); } - ptr + (ptr, logical_field_count as u32) } /// Compatibility entry point for runtime callers that do not have a @@ -341,7 +341,7 @@ pub extern "C" fn js_object_alloc_class_inline_keys( field_count: u32, keys_array: *mut ArrayHeader, ) -> *mut ObjectHeader { - let ptr = + let (ptr, birth_slots) = object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array); unsafe { let key_count = if keys_array.is_null() { @@ -353,7 +353,7 @@ pub extern "C" fn js_object_alloc_class_inline_keys( keys_array as *const ArrayHeader, key_count, ); - crate::object::shapes::birth_stamp_object_shape(ptr, id); + crate::object::shapes::birth_stamp_object_shape(ptr, id, birth_slots); } ptr } @@ -374,10 +374,10 @@ pub extern "C" fn js_object_alloc_class_inline_keys_stamped( keys_array: *mut ArrayHeader, shape_id: u32, ) -> *mut ObjectHeader { - let ptr = + let (ptr, birth_slots) = object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array); unsafe { - crate::object::shapes::birth_stamp_object_shape(ptr, shape_id); + crate::object::shapes::birth_stamp_object_shape(ptr, shape_id, birth_slots); } ptr } @@ -494,10 +494,8 @@ pub extern "C" fn js_object_alloc_class_with_keys( let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); crate::gc::layout_init_pointer_free(ptr as *mut u8); @@ -544,14 +542,14 @@ pub extern "C" fn js_object_alloc_class_with_keys( }; unsafe { - set_object_keys_array(ptr, keys_arr); + set_object_keys_array_with_live(ptr, keys_arr, field_count); // #6759 C3 rung 2, completed: birth-stamp here too. #8009 stamped the // COMPILED entry point (`js_object_alloc_class_inline_keys_stamped`) // and left this one lazily self-healing, which is a SPLIT population // for every class that lands here — and a split population is a // permanent PIC miss, not a slow start. See // `shapes::birth_stamp_object_shape`. - crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id); + crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id, field_count); } remember_class_keys_array(class_id, field_count, keys_arr); ptr @@ -663,10 +661,8 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( let total_size = header_size + fields_size; let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_cid; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); let fields_ptr = (ptr as *mut u8).add(header_size) as *mut JSValue; @@ -674,11 +670,11 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( // GC_STORE_AUDIT(INIT): freshly allocated object field slot is initialized pointer-free. ptr::write(fields_ptr.add(i), JSValue::undefined()); } - set_object_keys_array(ptr, merged_arr); + set_object_keys_array_with_live(ptr, merged_arr, field_count); crate::gc::layout_init_pointer_free(ptr as *mut u8); // The dynamically-parented subclass shape needs the same birth stamp // as every other class instance, or its sites split the same way. - crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id); + crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id, field_count); } remember_class_keys_array(class_id, field_count, merged_arr); ptr @@ -715,12 +711,8 @@ pub extern "C" fn js_object_alloc_with_shape( let obj_ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*obj_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*obj_ptr).class_id = 0; (*obj_ptr).parent_class_id = 0; - // field_count tracks the logical number of fields; extra allocated slots - // are available for dynamic property growth via js_object_set_field_by_name - (*obj_ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*obj_ptr).meta = ptr::null_mut(); @@ -780,12 +772,14 @@ pub extern "C" fn js_object_alloc_with_shape( unsafe { let obj_ptr = obj_handle.get_raw_mut_ptr::(); - set_object_keys_array(obj_ptr, keys_arr); + set_object_keys_array_with_live(obj_ptr, keys_arr, field_count); // #6804: birth-stamp the runtime ShapeId (see `ShapeCacheEntry`) — // newborn literals carry their stable identity immediately, so // typed_feedback tokens and the id-keyed FIELD_CACHE never see a // pre-stamp window for shape-cached objects. - crate::object::shapes::birth_stamp_object_shape(obj_ptr, runtime_shape_id); + // #8113: `field_count` is the LOGICAL live-slot bound; the extra + // physical slots above it stay available for dynamic growth. + crate::object::shapes::birth_stamp_object_shape(obj_ptr, runtime_shape_id, field_count); } obj_handle.get_raw_mut_ptr::() @@ -845,10 +839,8 @@ pub unsafe extern "C" fn js_object_clone_with_extra( let phys_slots = std::cmp::max(extra_count, crate::object::INLINE_SLOT_FLOOR as u32); let total_size = header_size + phys_slots as usize * 8; let new_ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; - (*new_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*new_ptr).class_id = 0; (*new_ptr).parent_class_id = 0; - (*new_ptr).field_count = 0; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*new_ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated clone starts with no keys-array @@ -874,7 +866,7 @@ pub unsafe extern "C" fn js_object_clone_with_extra( } let src_ptr = src_raw as *const ObjectHeader; - let src_field_count = (*src_ptr).field_count; + let src_field_count = crate::object::object_live_slot_count(src_ptr); // Physical slot capacity: src_field_count + extra_count, but at least max(fc, 8) to match // js_object_set_field's alloc_limit check. Extra slots are scratch space for subsequent @@ -885,12 +877,8 @@ pub unsafe extern "C" fn js_object_clone_with_extra( ); let total_size = header_size + phys_slots as usize * 8; let new_ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; - (*new_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*new_ptr).class_id = 0; (*new_ptr).parent_class_id = 0; - // Logical field count starts at src's count. js_object_set_field_by_name bumps it when - // appending new keys. - (*new_ptr).field_count = src_field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*new_ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated clone starts with no keys-array @@ -929,6 +917,13 @@ pub unsafe extern "C" fn js_object_clone_with_extra( } rebuild_object_field_layout(new_ptr, src_field_count as usize); + // #8113: publish the clone's live inline-slot bound BEFORE the first + // allocation below. `gc_field_slot_range` reads the bound from the ShapeId + // descriptor now, and everything from the arena allocation above to here is + // allocation-free, so this closes the window in which the copied + // pointer-bearing slots would be invisible to tracing (#7154/#7164). + crate::object::shapes::birth_publish_object_shape(new_ptr, src_field_count); + // Build keys array: copy ONLY src keys. Static keys are NOT added here — codegen uses // js_object_set_field_by_name for each static prop, which appends new keys via // js_array_push. Pre-size the keys capacity to avoid immediate reallocation on append. @@ -1025,7 +1020,7 @@ pub unsafe extern "C" fn js_object_copy_own_fields(dst_i64: i64, src_f64: f64) { return; } let key_count = crate::array::js_array_length(src_keys) as usize; - let src_field_count = (*src).field_count as usize; + let src_field_count = crate::object::object_live_slot_count(src) as usize; let alloc_limit = std::cmp::max(src_field_count, crate::object::INLINE_SLOT_FLOOR); let header_size = std::mem::size_of::(); let src_fields = (src as *const u8).add(header_size) as *const u64; diff --git a/crates/perry-runtime/src/object/arguments.rs b/crates/perry-runtime/src/object/arguments.rs index 3704d43449..786ab8976c 100644 --- a/crates/perry-runtime/src/object/arguments.rs +++ b/crates/perry-runtime/src/object/arguments.rs @@ -632,8 +632,10 @@ unsafe fn read_ordinary_own_value( ) -> JSValue { let keys = (*obj).keys_array; let key_count = crate::array::js_array_length(keys) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); if crate::string::js_string_key_matches(key_val, key) { @@ -655,8 +657,10 @@ unsafe fn write_ordinary_own_value( ) { let keys = (*obj).keys_array; let key_count = crate::array::js_array_length(keys) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); if crate::string::js_string_key_matches(key_val, key) { diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 7d2e136432..d5ab57adbf 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -419,9 +419,10 @@ pub extern "C" fn js_get_dynamic_parent_value(class_id: u32) -> f64 { /// #1789: stamp a freshly-allocated object as a heap "class object" (the /// value a class EXPRESSION evaluates to). Transitions the authoritative -/// ShapeId descriptor kind and updates `object_type` only as a compatibility -/// mirror. Called by codegen right after `js_object_alloc` in the -/// `ClassExprFresh` lowering. +/// ShapeId descriptor kind. #8113 deleted the `object_type` compatibility +/// mirror this also used to write; the descriptor kind is the only record. +/// Called by codegen right after `js_object_alloc` in the `ClassExprFresh` +/// lowering. #[no_mangle] pub extern "C" fn js_object_mark_class(obj: i64) { if obj != 0 { @@ -434,9 +435,6 @@ pub extern "C" fn js_object_mark_class(obj: i64) { { return; } - // Compatibility mirror only; all semantic reads use the ShapeId - // descriptor kind so #8047 can remove this payload word atomically. - (*(obj as *mut ObjectHeader)).object_type = crate::error::OBJECT_TYPE_CLASS; // Becoming a class object changes dispatch semantics even though // the rooted keys and slot layout stay the same. crate::object::shapes::transition_object_shape_to_class(obj as *mut ObjectHeader); @@ -1731,8 +1729,21 @@ mod shape_authority_tests_8067 { super::js_object_mark_class(1); } + /// #8113 replaces #8067's "saved lineage beats an interim self-heal" test. + /// + /// The self-heal it modelled is GONE: `typed_feedback::object_shape` used to + /// mint a lineage-free descriptor for an unstamped receiver, which under + /// #8113 would also publish a live inline-slot bound of ZERO — a read-only + /// observation path silently truncating the object's payload. The property + /// worth pinning is now the stronger one: an unstamped receiver MISSES, and + /// observing it publishes nothing at all. + /// + /// The clear here is manufactured with a test-only helper. No production + /// path clears a stamp any more (`shapes::clear_object_shape_stamp` is + /// `#[cfg(test)]`), which is what makes the window this used to model + /// unreachable rather than merely narrow. #[test] - fn saved_class_lineage_beats_an_interim_shape_self_heal() { + fn an_unstamped_receiver_misses_instead_of_being_self_healed() { let _lock = crate::gc::global_side_table_test_lock(); unsafe { const CID: u32 = 0x8068; @@ -1749,30 +1760,36 @@ mod shape_authority_tests_8067 { predecessor.object_kind, crate::object::shapes::ShapeObjectKind::Class ); + assert_eq!(predecessor.live_inline_slot_count, 1); - // Model a re-entrant shape observer in the narrow mutation window: - // the structural mutator has saved its predecessor and cleared the - // stamp, then typed feedback defensively self-heals the object. assert!(crate::object::shapes::clear_object_shape_stamp(obj)); let (interim, obj) = obj_handle.across_mut::(|| { crate::typed_feedback::test_object_shape_token(obj as usize) }); assert_eq!( - crate::object::shapes::shape_descriptor_by_id(interim as u32) - .expect("interim descriptor") - .object_kind, - crate::object::shapes::ShapeObjectKind::Ordinary, - "test premise: a lineage-free self-heal is ordinary" + interim, 0, + "an unstamped receiver must MISS; minting a lineage-free \ + descriptor for it would publish a zero live-slot bound" + ); + assert!( + crate::object::shapes::object_shape_descriptor(obj).is_none(), + "observing an unstamped receiver must not publish a descriptor" ); - crate::object::shapes::synchronize_object_shape_descriptor_from(obj, Some(predecessor)); + // The mutator's saved lineage still restores both facts exactly. + crate::object::shapes::synchronize_object_shape_descriptor_from( + obj, + Some(predecessor), + predecessor.live_inline_slot_count, + ); + let restored = + crate::object::shapes::object_shape_descriptor(obj).expect("restored descriptor"); assert_eq!( - crate::object::shapes::object_shape_descriptor(obj) - .expect("restored descriptor") - .object_kind, + restored.object_kind, crate::object::shapes::ShapeObjectKind::Class, - "the mutator's saved semantic lineage must outrank an interim self-heal" + "the mutator's saved semantic lineage must survive the window" ); + assert_eq!(restored.live_inline_slot_count, 1); } } @@ -1803,9 +1820,9 @@ mod shape_authority_tests_8067 { crate::object::shapes::ShapeObjectKind::Class ); - // Sabotage the compatibility mirror. Classification must remain - // driven by the ShapeId descriptor transition above. - (*obj).object_type = crate::error::OBJECT_TYPE_REGULAR; + // #8113 removed the `object_type` compatibility mirror this used to + // sabotage. Classification is driven by the ShapeId descriptor + // transition above and by nothing else, so assert that directly. assert!(super::is_class_object_ptr(obj.cast())); assert!(!crate::object::object_is_regular(obj)); diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 2f8443f4d9..ddc7941daf 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -294,7 +294,7 @@ pub extern "C" fn js_object_delete_field( // `Object.entries`, `for-in` etc. all still saw the deleted // property. Bun and Node remove the property entirely; we // match that. - let field_count = (*obj).field_count; + let field_count = crate::object::object_live_slot_count(obj); let alloc_limit = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); let new_count = key_count - 1; @@ -411,8 +411,23 @@ pub extern "C" fn js_object_delete_field( // IN PLACE (which is what the comment above describes and what // `shape_slot_lookup`'s shrink check already anticipates), so // deleting it would silently make that path wrong. - crate::object::shapes::clear_object_shape_stamp(obj); - crate::object::shapes::synchronize_object_shape_descriptor_from(obj, predecessor); + // #8113: no `clear_object_shape_stamp` here any more. The stamp is now + // the ONLY record of the live inline-slot bound, so clearing it — even + // for the two statements it used to be cleared across — makes the + // object's payload untraceable if a collection lands in between (the + // publication below inserts into the shape table and can allocate). The + // republication is mint-then-stamp, which subsumes what the clear was + // for: the successor descriptor is minted while the predecessor is + // still installed, and the receiver's shape changes at the single + // `parent_class_id` store. + // The bound is the POST-compaction one published in step 3 above; + // `predecessor` contributes only semantic lineage (class kind / + // generation), never structural facts. + crate::object::shapes::synchronize_object_shape_descriptor_from( + obj, + predecessor, + crate::object::object_live_slot_count(obj), + ); 1 } @@ -692,7 +707,10 @@ mod shape_transition_tests_6759 { .expect("delete must publish a by-id descriptor"); assert_eq!(descriptor.keys, (*obj).keys_array as u64); assert_eq!(descriptor.logical_key_count, 2); - assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count); + assert_eq!( + descriptor.live_inline_slot_count, + crate::object::object_live_slot_count(obj) + ); } } @@ -769,7 +787,10 @@ mod shape_transition_tests_6759 { .expect("class delete must publish a by-id descriptor"); assert_eq!(descriptor.keys, (*obj).keys_array as u64); assert_eq!(descriptor.logical_key_count, 2); - assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count); + assert_eq!( + descriptor.live_inline_slot_count, + crate::object::object_live_slot_count(obj) + ); // Still true, and still what the guard compares until rung 3. assert_ne!( diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 110bfe7e00..7a7b6e3837 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -36,7 +36,7 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 } unsafe { // Bounds check: check inline fields first, then overflow map - let fc = (*obj).field_count; + let fc = crate::object::object_live_slot_count(obj); if field_index >= fc { // Check overflow map for fields that didn't fit in inline storage return match overflow_get(obj as usize, field_index as usize) { @@ -58,7 +58,7 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 obj, field_index, (*obj).class_id, - (*obj).field_count + crate::object::object_live_slot_count(obj) ); return JSValue::undefined(); } @@ -94,8 +94,10 @@ pub(crate) unsafe fn own_data_field_by_name( if key_count > 65536 { return None; } - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); // #1781: accept inline SSO short keys — `is_string()` is diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index fb91554dc6..5d73a44a14 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1389,7 +1389,7 @@ pub extern "C" fn js_object_values(obj: *const ObjectHeader) -> *mut ArrayHeader let count = if !keys.is_null() { crate::array::js_array_length(keys) as usize } else { - (*obj).field_count as usize + crate::object::object_live_slot_count(obj) as usize }; let result = crate::array::js_array_alloc(count as u32); @@ -1579,7 +1579,7 @@ pub extern "C" fn js_object_entries(obj: *const ObjectHeader) -> *mut ArrayHeade let count = if !keys.is_null() { crate::array::js_array_length(keys) as usize } else { - (*obj).field_count as usize + crate::object::object_live_slot_count(obj) as usize }; let result = crate::array::js_array_alloc(count as u32); diff --git a/crates/perry-runtime/src/object/field_get_set/field_ops.rs b/crates/perry-runtime/src/object/field_get_set/field_ops.rs index e82c912915..1b4da41048 100644 --- a/crates/perry-runtime/src/object/field_get_set/field_ops.rs +++ b/crates/perry-runtime/src/object/field_get_set/field_ops.rs @@ -124,7 +124,7 @@ pub extern "C" fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, // js_object_alloc_class_with_keys use exactly field_count slots. // We use a generous limit of max(field_count, 8) to avoid false positives from // js_object_alloc_with_shape's extra padding while still catching real overflows. - let stored_field_count = (*obj).field_count; + let stored_field_count = crate::object::object_live_slot_count(obj); let alloc_limit = std::cmp::max(stored_field_count, crate::object::INLINE_SLOT_FLOOR as u32); if field_index >= alloc_limit { @@ -162,7 +162,7 @@ pub extern "C" fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, // is undefined-initialized at allocation (`object/alloc.rs`), so // widening here can only ever expose non-pointer sentinels ahead of // the store that is about to fill this one in. - if field_index >= (*obj).field_count { + if field_index >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, field_index + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 98c1b10265..9152420c31 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -152,7 +152,7 @@ pub extern "C" fn js_object_get_field_by_name( && crate::value::addr_class::is_above_handle_band(keys as usize) { let alloc_limit = std::cmp::max( - (*o).field_count, + crate::object::object_live_slot_count(o), crate::object::INLINE_SLOT_FLOOR as u32, ) as usize; if let Some(idx) = super::super::prop_plan::read_plan_lookup( diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index a16fe7f09a..36acc0b52c 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1590,10 +1590,12 @@ pub(crate) fn get_field_by_name_object_tail( } // Slow path: linear scan through keys array - let _field_count = (*obj).field_count as usize; + let _field_count = crate::object::object_live_slot_count(obj) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; // #5054: wide objects get a validated key→index map so per-key reads // stay O(1) instead of O(key_count). A `None` falls through to the @@ -1640,10 +1642,14 @@ pub(crate) fn get_field_by_name_object_tail( // grow-reallocs and GC moves that retire `keys_id`. // #6759 C3 rung 1: class instances are stamped here too. { + // #8113: the live inline-slot bound is a parameter now. + // This is a READ path — it must not change the bound, so it + // republishes exactly what the receiver already carries. let id = super::super::shapes::stamp_object_shape( obj as *mut ObjectHeader, keys, key_count as u32, + crate::object::object_live_slot_count(obj as *const ObjectHeader), ); let store_key = if id != 0 { id as usize } else { keys_id }; let store_idx = diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index bca9274a3b..48f1f25f33 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -621,9 +621,10 @@ pub extern "C" fn js_object_get_field_ic_miss( unsafe { // Issue #72: validate this really is a GC_TYPE_OBJECT before reading // (*obj).keys_array — otherwise an Array/String/Buffer/etc. receiver - // (whose `object_type` byte at offset 0 happens to be 1, matching - // OBJECT_TYPE_REGULAR for a length-1 array) would be treated as - // cacheable and seed the per-site PIC with garbage from element[1]. + // (whose word at offset 0 collides with a real `class_id` — since + // #8113 that is an array's `length`, so ANY length-N array impersonates + // class N) would be treated as cacheable and seed the per-site PIC with + // garbage from element[1]. // The codegen guard funnels non-OBJECT receivers here too, so this // belt-and-braces check keeps the cache from being primed with // values that would survive into the inline hot path. diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index bae615a342..c4ffe7c349 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -161,7 +161,7 @@ pub extern "C" fn js_object_set_field_by_name( set_object_keys_array(o, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(o); let alloc_limit = std::cmp::max( - (*o).field_count, + crate::object::object_live_slot_count(o), crate::object::INLINE_SLOT_FLOOR as u32, ) as usize; if (slot_idx as usize) < alloc_limit { @@ -169,7 +169,7 @@ pub extern "C" fn js_object_set_field_by_name( .add(std::mem::size_of::()) as *mut JSValue; let slot = fields_ptr.add(slot_idx as usize); - if slot_idx >= (*o).field_count { + if slot_idx >= crate::object::object_live_slot_count(o) { set_object_live_slot_count(o, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index 53162e8adf..6cc2c33785 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -99,10 +99,12 @@ pub(crate) unsafe fn try_existing_own_data_overwrite( vbits }; super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; if (idx as usize) < alloc_limit { - if idx >= (*obj).field_count { + if idx >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, idx + 1); } store_object_field_slot(obj, idx as usize, vbits); @@ -258,8 +260,10 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; let slot_usize = slot_idx as usize; let vbits = value.to_bits(); let vbits = if (vbits >> 48) == 0x7FFD && (vbits & 0x0000_FFFF_FFFF_FFFF) == 0 { @@ -269,7 +273,7 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( }; if slot_usize < alloc_limit { - if slot_idx >= (*obj).field_count { + if slot_idx >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, slot_idx + 1); } store_object_field_slot(obj, slot_usize, vbits); diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index e0bec0aa48..8e717383e6 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -50,7 +50,7 @@ pub(super) fn set_field_by_name_object_tail( // Safety: obj is a valid heap pointer (> 0x10000) at this point unsafe { // Validate this is an ObjectHeader, not some other heap type. Every - // shaped object has a tracked GcHeader; payload `object_type` is only + // shaped object has a tracked GcHeader; the payload's first word is only // a compatibility mirror and is never a kind fallback. // Guard: ensure we can safely read GC_HEADER_SIZE bytes before obj if (obj as usize) < crate::gc::GC_HEADER_SIZE + 0x1000 { @@ -157,7 +157,7 @@ pub(super) fn set_field_by_name_object_tail( // A RECOGNIZED non-object heap type (Map/Set/Buffer/TypedArray/…) // must never fall through to the plain-object write below: their // layouts alias ObjectHeader fields. A Map with EXACTLY one entry - // had MapHeader.size aliasing object_type == OBJECT_TYPE_REGULAR, + // had MapHeader.size aliasing the first ObjectHeader word, // so `m.customProp = 5` walked the Map's bytes as object fields — // deterministic heap corruption (2026-07-02 audit P1). The return; @@ -454,9 +454,10 @@ pub(super) fn set_field_by_name_object_tail( }; set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) - as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; if (slot_idx as usize) < alloc_limit { // Inline the field write — `obj` has already been // validated (GC header read, type check, closure @@ -468,7 +469,7 @@ pub(super) fn set_field_by_name_object_tail( let slot = fields_ptr.add(slot_idx as usize); // Publish the expanded traced range and its exact // descriptor before the pointer-bearing slot value. - if slot_idx >= (*obj).field_count { + if slot_idx >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( @@ -522,7 +523,7 @@ pub(super) fn set_field_by_name_object_tail( // slot is undefined-initialized at allocation, so the widened range // can only expose non-pointer sentinels — then publish the value. // Bump field_count so Object.keys()/values()/entries() see the new property. - if (*obj).field_count == 0 { + if crate::object::object_live_slot_count(obj) == 0 { set_object_live_slot_count(obj, 1); } js_object_set_field(obj, 0, JSValue::from_bits(value.to_bits())); @@ -538,7 +539,7 @@ pub(super) fn set_field_by_name_object_tail( // #6759 C3 rung 1: no `class_id == 0` gate — a keyless class // instance gaining its first by-name property is stamped like // any other receiver. - super::shapes::stamp_object_shape(obj, new_keys, 1); + super::shapes::stamp_object_shape(obj, new_keys, 1, 1); return; } @@ -605,8 +606,10 @@ pub(super) fn set_field_by_name_object_tail( // Search through the keys array for a match let key_count = crate::array::js_array_length(keys) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; // Sidecar O(1) lookup when keys_array has grown past the // linear-scan break-even. Without this, the build-then-fill @@ -744,7 +747,7 @@ pub(super) fn set_field_by_name_object_tail( // and evacuation rewriting. Widen the count FIRST — every physical // slot is undefined-initialized at allocation, so the widened range // can only expose non-pointer sentinels — then publish the value. - if new_index as u32 >= (*obj).field_count { + if new_index as u32 >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, new_index as u32 + 1); } js_object_set_field(obj, new_index as u32, JSValue::from_bits(value.to_bits())); @@ -961,7 +964,7 @@ pub(super) fn set_field_by_name_object_tail( // slot is undefined-initialized at allocation, so the widened range // can only expose non-pointer sentinels — then publish the value. // Bump field_count to reflect the newly added property - if new_index as u32 >= (*obj).field_count { + if new_index as u32 >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, new_index as u32 + 1); } js_object_set_field(obj, new_index as u32, JSValue::from_bits(value.to_bits())); diff --git a/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs b/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs index db63cdffee..88a5c88f1d 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs @@ -47,7 +47,7 @@ pub(super) unsafe fn string_key_eq(key: *const crate::StringHeader, expected: &[ /// round-trips via `closure_set_via_function_prototype_descriptor` before /// falling back to a plain own-property write. /// #6530: mirror a SUCCESSFUL own-data write on a per-evaluation CLASS OBJECT -/// (`object_type == OBJECT_TYPE_CLASS` — what a capture-carrying class +/// (`ShapeObjectKind::Class` — what a capture-carrying class /// statement materializes as) into the class_id-keyed `CLASS_DYNAMIC_PROPS` /// side table. Compiled method bodies reference sibling classes as INT32 /// ClassRefs (bundled zod's `ZodOptional.create(this, this._def)` inside diff --git a/crates/perry-runtime/src/object/gc_slots.rs b/crates/perry-runtime/src/object/gc_slots.rs index d3f1971971..ce038c1f82 100644 --- a/crates/perry-runtime/src/object/gc_slots.rs +++ b/crates/perry-runtime/src/object/gc_slots.rs @@ -31,11 +31,18 @@ pub(crate) unsafe fn gc_field_slot_range( if obj.is_null() { return None; } + // #8113: the descriptor is now the SOLE record of the live inline-slot + // bound — there is no header word left to fall back to. An unstamped + // receiver therefore traces zero payload slots, which is the fail-closed + // answer for the only population that can be unstamped: synthetic/raw test + // fixtures that bypass every runtime allocator, and which hold no heap + // edges. Every runtime allocator publishes a descriptor before its header + // escapes (`object/alloc.rs`), and every bound change is mint-then-stamp + // (`shapes::publish_object_live_slot_count`), so a live object is never + // observed here without one. let field_count = shapes::object_shape_descriptor(obj) .map(|descriptor| descriptor.live_inline_slot_count as usize) - // Compatibility only for synthetic/raw test fixtures that bypass all - // runtime allocators. Published runtime objects are always stamped. - .unwrap_or((*obj).field_count as usize); + .unwrap_or(0); if field_count > 1_000_000 { return None; } diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs new file mode 100644 index 0000000000..e4ac594096 --- /dev/null +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -0,0 +1,89 @@ +//! #8113: the live inline-slot bound, and the `ObjectHeader` ABI revision. +//! +//! `ObjectHeader` used to carry a `field_count: u32` word. It was derivable +//! from the object's immutable ShapeId descriptor, and removing it together +//! with the equally derivable `object_type` word took the header from 32 bytes +//! to 24 (a two-slot object from 56 to 48). These four items are what took its +//! place; they live in their own module because `object/mod.rs` is at the +//! repository's 2000-line cap. + +use super::shapes; +use super::ObjectHeader; +use super::INLINE_SLOT_FLOOR; + +/// Revision of the [`ObjectHeader`] ABI, paired with +/// `perry_ffi::OBJECT_HEADER_ABI_REVISION`. +/// +/// `perry-ffi` is published to crates.io, and a wrapper compiled against an old +/// mirror linked against a new runtime reads the wrong header offsets with no +/// compile error. Bump this and the perry-ffi constant together on ANY change +/// to the header's size, field set, or field offsets; perry-ffi's +/// `object_header_abi_revision_matches_the_pinned_layout` (now actually run in +/// CI, see `test.yml`) fails otherwise. +/// +/// * 1 — `{object_type, class_id, parent_class_id, field_count, keys_array, meta}`. +/// * 2 — `{class_id, parent_class_id, keys_array, meta}` (#8113). +#[no_mangle] +pub extern "C" fn perry_object_header_abi_revision() -> u32 { + 2 +} + +/// The authoritative live inline-slot bound (#8113: the replacement for the +/// deleted `ObjectHeader::field_count` word). +/// +/// Zero for a receiver with no published descriptor. That is deliberately +/// fail-CLOSED: a bound of 0 rejects field writes instead of admitting an +/// unbounded one, and every runtime allocator publishes a descriptor before its +/// header escapes, so the zero case is a raw/synthetic fixture, not a live +/// object. +#[inline] +pub unsafe fn object_live_slot_count(obj: *const ObjectHeader) -> u32 { + shapes::object_shape_descriptor(obj) + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0) +} + +/// C-ABI accessor for [`object_live_slot_count`], for out-of-runtime consumers +/// (`perry-ext-*`) that mirror `ObjectHeader` through `perry-ffi` and used to +/// read the deleted `field_count` word directly (#8113). +/// +/// # Safety +/// `obj` must be a live `GC_TYPE_OBJECT` allocation or null. +#[no_mangle] +pub unsafe extern "C" fn js_object_live_slot_count(obj: *const ObjectHeader) -> u32 { + if obj.is_null() { + return 0; + } + object_live_slot_count(obj) +} + +/// The OOB bound every by-index field write is checked against: +/// `max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. Every allocator reserves +/// at least `INLINE_SLOT_FLOOR` physical slots (`object/alloc.rs`), and +/// `live_inline_slot_count` is a fixed point of the same expression — the +/// by-name append path only ever bumps it for a slot it placed inline — so this +/// can never exceed the physical slot count. +#[inline] +pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { + std::cmp::max(object_live_slot_count(obj), INLINE_SLOT_FLOOR as u32) +} + +/// Publish a new authoritative live-inline-slot bound. +/// +/// #8113 MINT-THEN-STAMP. There is no longer a header word to fall back on, so +/// this must never leave the receiver without a descriptor, not even +/// transiently: `shape_descriptor_ensure_*` inserts into a `HashMap` and can +/// therefore collect, and a collection landing in a stamp-cleared window would +/// see a live bound of 0 and stop tracing the object's payload entirely. +/// +/// The successor descriptor is minted while the PREDECESSOR is still stamped +/// (so a collection during the mint sees the old, still-correct bound — the +/// newly exposed slot has not been written yet), and publication is the single +/// `parent_class_id` store, which cannot collect. +/// +/// Callers growing the traced range must invoke this before publishing the +/// pointer-bearing field value (#7154): mint → stamp → value-slot store. +#[inline] +pub(crate) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { + shapes::publish_object_live_slot_count(obj, field_count); +} diff --git a/crates/perry-runtime/src/object/map_set_subclass.rs b/crates/perry-runtime/src/object/map_set_subclass.rs index aa937c931e..44f5f79ad8 100644 --- a/crates/perry-runtime/src/object/map_set_subclass.rs +++ b/crates/perry-runtime/src/object/map_set_subclass.rs @@ -383,14 +383,18 @@ pub extern "C" fn js_map_set_subclass_init(this: f64, kind: i32, iterable: f64) /// entry points. /// /// These are *sabotage* tests, not smoke tests: each one first asserts that the -/// header byte the pre-fix code would have misread is still sitting there -/// (`object_type == 1` at `MapHeader.size`'s offset), and only then that the -/// entry point returns the resolved answer instead. A green run therefore -/// proves the redirect fired, not merely that nothing crashed. +/// header word the pre-fix code would have misread is still sitting there at +/// `MapHeader.size`'s offset, and only then that the entry point returns the +/// resolved answer instead. A green run therefore proves the redirect fired, +/// not merely that nothing crashed. +/// +/// #8113 moved which word that is: `ObjectHeader::object_type` is gone, so +/// offset 0 — `MapHeader.size` / `SetHeader.size` — is now `class_id`. The +/// misread value changed from a constant 1 to the receiver's class id; the +/// hazard, and therefore the sabotage, is identical. #[cfg(test)] mod tests { use super::*; - use crate::error::OBJECT_TYPE_REGULAR; use crate::object::js_object_alloc; fn boxed(obj: *mut ObjectHeader) -> f64 { @@ -456,14 +460,14 @@ mod tests { assert_ne!(backing as usize, obj as usize); // The pre-fix hazard, still present in the bytes: `MapHeader.size` - // overlays `ObjectHeader.object_type`, so `js_map_size` used to report - // 1 for an EMPTY subclass instance and `MapHeader.entries` was - // `parent_class_id ‖ field_count`. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); + // overlays `ObjectHeader.class_id` (#8113), so `js_map_size` used to + // report the class id for an EMPTY subclass instance and + // `MapHeader.entries` was the shape word. + assert_eq!(unsafe { (*obj).class_id }, 9001); assert_eq!( js_map_size_of(obj), 0, - "an empty Map subclass instance must report size 0, not object_type" + "an empty Map subclass instance must report size 0, not class_id" ); // Writes land in the backing; the receiver is what comes back. @@ -480,8 +484,8 @@ mod tests { ); // The instance header is untouched — no forged-pointer store landed in // it, and it is still an ordinary object. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); assert_eq!(unsafe { (*obj).class_id }, 9001); + assert!(unsafe { crate::object::object_is_regular(obj) }); } #[test] @@ -492,11 +496,11 @@ mod tests { _ => panic!("super() should have installed a Set backing"), }; assert_ne!(backing as usize, obj as usize); - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); + assert_eq!(unsafe { (*obj).class_id }, 9002); assert_eq!( crate::set::js_set_size(obj as *const crate::set::SetHeader), 0, - "an empty Set subclass instance must report size 0, not object_type" + "an empty Set subclass instance must report size 0, not class_id" ); let returned = crate::set::js_set_add(obj as *mut crate::set::SetHeader, 7.0); @@ -534,9 +538,9 @@ mod tests { ); // Pre-fix these read the ObjectHeader as a MapHeader: `size` was - // `object_type` (= 1) and the very next `.set()` stored through - // `parent_class_id ‖ field_count`. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); + // `class_id` (#8113; `object_type` before that) and the very next + // `.set()` stored through the shape word. + assert_eq!(unsafe { (*obj).class_id }, 9003); assert_eq!(js_map_size_of(obj), 0); assert_eq!( crate::map::js_map_get(obj as *const crate::map::MapHeader, 1.0).to_bits(), @@ -555,9 +559,11 @@ mod tests { crate::set::js_set_clear(obj as *mut crate::set::SetHeader); // Nothing wrote into the object's header. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); assert_eq!(unsafe { (*obj).class_id }, 9003); - assert_eq!(unsafe { (*obj).field_count }, 3); + assert!(crate::object::shapes::is_shape_id(unsafe { + (*obj).parent_class_id + })); + assert_eq!(unsafe { crate::object::object_live_slot_count(obj) }, 3); } fn js_map_size_of(obj: *mut ObjectHeader) -> u32 { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index ccb9c3397a..e11a76bf45 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -93,6 +93,15 @@ mod global_this_tables; mod groupby; pub(crate) mod has_own_helpers; mod instanceof; +mod live_slots; +mod null_stub; +pub(crate) use live_slots::set_object_live_slot_count; +pub use live_slots::{ + js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, + perry_object_header_abi_revision, +}; +pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; +pub(crate) use null_stub::{NullObjectBytes, NULL_OBJECT_BYTES}; pub(crate) mod iterator_prototypes; pub(crate) mod map_set_subclass; mod namespace_create; @@ -538,70 +547,6 @@ mod call_method_depth; use call_method_depth::CallMethodDepthGuard; pub(crate) use call_method_depth::{call_method_depth_restore, call_method_depth_savepoint}; -/// Static "null object" used as a safe return value when the depth guard triggers. -/// Instead of returning undefined (which callers may dereference as a null pointer), -/// we return a pointer to this valid-but-empty object so downstream code doesn't crash. -/// -/// Uses a raw byte array with matching layout to avoid Sync issues with raw pointers. -#[repr(C, align(8))] -struct NullObjectBytes { - object_type: u32, // 1 = OBJECT_TYPE_REGULAR - class_id: u32, // 0 - parent_class_id: u32, // 0 - field_count: u32, // 0 - keys_array: u64, // 0 (null pointer as u64) -} -// Safety: this is a read-only zero-initialized struct with no interior mutability -unsafe impl Sync for NullObjectBytes {} - -/// Issue #629: namespace imports for unresolved modules -/// (`import * as fsp from "node:fs/promises"` when the module isn't -/// implemented) used to fall back to `TAG_TRUE` at the codegen -/// catch-all, which made `typeof fsp === "boolean"` and every -/// `fsp.method` access return undefined silently — confusing because -/// the user sees `(boolean).method is not a function`. Returning a -/// stable empty-object stub makes `typeof === "object"` (matches -/// Node's module-namespace shape) and property access cleanly returns -/// undefined via the existing object-field path. -#[no_mangle] -pub extern "C" fn js_unresolved_namespace_stub() -> f64 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) -} - -/// Issue #692: default-import calls against unresolved modules -/// (`import jwt from "jsonwebtoken"; jwt.sign(...)` when no perry-stdlib -/// binding matched the method, or `import sanitizeHtml from -/// "sanitize-html"; sanitizeHtml(x)` when sanitize-html doesn't resolve -/// to a NativeCompiled module) used to lower to an LLVM extern named -/// literally `default`, which the system linker can't resolve — -/// surfaced as `undefined reference to 'default'`. Route those calls -/// here so the binary links; the runtime stub prints a one-shot -/// diagnostic and returns NaN-boxed undefined. The user gets a clear -/// signal at first call rather than a cryptic link error. -#[no_mangle] -pub extern "C" fn js_unresolved_default_call() -> f64 { - use std::sync::atomic::{AtomicBool, Ordering}; - static WARNED: AtomicBool = AtomicBool::new(false); - if !WARNED.swap(true, Ordering::Relaxed) { - eprintln!( - "perry: called a default-imported binding from an unresolved module \ - (returns undefined). The module's default export was not found in \ - perry-stdlib or perry.compilePackages — run `perry --print-api-manifest` \ - to see what's supported." - ); - } - f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED -} - -static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { - object_type: 1, - class_id: 0, - parent_class_id: 0, - field_count: 0, - keys_array: 0, -}; - /// Fast direct-mapped inline cache for class shape keys arrays. /// Indexed by `shape_id mod CACHE_SIZE`. Each slot stores /// `(shape_id, keys_array_ptr)`. A 256-entry direct-mapped cache costs @@ -1693,19 +1638,27 @@ pub fn overflow_fields_is_empty() -> bool { pub(crate) use crate::value::addr_class::is_valid_obj_ptr; /// Object header - precedes the fields in memory +/// +/// # #8113: two derivable words are gone +/// +/// The header used to open with `object_type: u32` (an ABI mirror of +/// `error::ErrorHeader`'s first word) and carry `field_count: u32` (the live +/// inline-slot bound). Both were derivable and neither alone saved a byte — the +/// struct re-padded — so they went together: 32 bytes to 24, and a two-slot +/// object from 56 to 48. The kind now comes from `GcHeader.obj_type` plus +/// [`shapes::ShapeObjectKind`] ([`object_is_regular`], +/// [`crate::error::ptr_is_native_error`]); the bound from +/// [`object_live_slot_count`]. See `object/live_slots.rs` for the consequence +/// every allocator has to honour. #[repr(C)] pub struct ObjectHeader { - /// Type tag to distinguish from Error objects (must be first field!) - /// Uses OBJECT_TYPE_REGULAR (1) for regular objects - pub object_type: u32, - /// Class ID for this object (used for instanceof, vtable lookup) + /// Class ID for this object (used for instanceof, vtable lookup). + /// MUST stay first: codegen guards load it at header offset 0. pub class_id: u32, /// Compatibility word: the parent class ID during allocation, then the /// runtime `ShapeId` after shape stamping. Parent lookup must use the class /// registry; direct reads of this word are not authoritative parent data. pub parent_class_id: u32, - /// Number of fields in this object - pub field_count: u32, /// Pointer to array of key strings (for Object.keys() support). /// /// A class instance HAS one: `object_alloc_class_inline_keys_impl` installs @@ -1718,7 +1671,7 @@ pub struct ObjectHeader { pub keys_array: *mut ArrayHeader, /// #6759 Phase B: per-object metadata record — null for ordinary /// objects (the common case). MUST stay the LAST field: codegen reads - /// the earlier header fields at fixed offsets (0/4/8/12/16), and the + /// the earlier header fields at fixed offsets (0/4/8), and the /// field-slot region begins at `size_of::()`, mirrored /// by `perry-codegen/src/target_layout.rs::object_header_size_bytes`. /// See [`ObjectMeta`]. @@ -1789,8 +1742,10 @@ pub(crate) const OBJECT_META_FLAG_PROTO_OVERRIDE: u64 = 1; /// Authoritative ordinary-object discriminator. RegExp has its own GC kind, /// and heap class-expression values carry their kind in the immutable ShapeId -/// descriptor. The legacy `ObjectHeader::object_type` word is only an ABI -/// mirror pending #8047. +/// descriptor. #8113 deleted the legacy `ObjectHeader::object_type` ABI mirror, +/// so this is the ONLY spelling of "is an ordinary object" — note it is FALSE +/// for a class object (`ShapeObjectKind::Class`), which is exactly what the +/// retired `object_type == OBJECT_TYPE_REGULAR` test meant (#6595). #[inline] pub(crate) unsafe fn object_is_regular(obj: *const ObjectHeader) -> bool { if obj.is_null() { @@ -1881,16 +1836,40 @@ pub(crate) unsafe fn gc_object_meta_slot(user_ptr: usize) -> Option<*mut u64> { #[inline] unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader) { + let live = object_live_slot_count(obj); + set_object_keys_array_with_live(obj, keys_array, live); +} + +/// `set_object_keys_array` for a receiver whose live inline-slot bound is not +/// yet published — i.e. the allocators, which used to write +/// `(*ptr).field_count` before installing the keys edge (#8113). Passing the +/// birth count here keeps the published descriptor identical to the pre-#8113 +/// one; deriving it from the (absent) predecessor instead would mint a +/// spurious `live = 0` intermediate for every allocation. +#[inline] +unsafe fn set_object_keys_array_with_live( + obj: *mut ObjectHeader, + keys_array: *mut ArrayHeader, + live_inline_slot_count: u32, +) { // #6759 C3c: a stamped shape id (carried in the `parent_class_id` word) - // described the OLD keys array — clear it on a pointer CHANGE so no stale - // id is visible while the authoritative header changes. A same-pointer - // append is versioned by `synchronize_object_shape_descriptor` below; an - // immutable old descriptor is never silently changed in place. + // describes the OLD keys array on a pointer CHANGE. A same-pointer append is + // versioned inside the publication helper; an immutable old descriptor is + // never silently changed in place. + // + // #8113 MINT-THEN-STAMP — this used to CLEAR the stamp here and re-mint + // after the header store. That is no longer legal: the descriptor is the + // only record of the live inline-slot bound, so an unstamped window is a + // window in which the collector traces ZERO payload slots, and the window + // contains both a write barrier and a `HashMap` insert. Instead the + // successor descriptor for the NEW edge is published FIRST (the predecessor + // still describes the header's current edge across every allocation inside), + // and the header store follows with nothing allocating in between. // // #6759 C3 rung 1: no `class_id == 0` gate. The word is a ShapeId iff - // `is_shape_id` says so, for class instances too — and `clear_object_shape_stamp` - // tests exactly that, so an instance still carrying its allocation-time - // `parent_class_id` (never in the ShapeId range) is left alone. + // `is_shape_id` says so, for class instances too, so an instance still + // carrying its allocation-time `parent_class_id` (never in the ShapeId + // range) is left alone. let predecessor = shapes::object_shape_descriptor(obj); let keys_changed = (*obj).keys_array != keys_array; if keys_changed { @@ -1906,8 +1885,11 @@ unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHe // lookup publish an Ordinary descriptor for a class object; the // structural synchronization below then inherited the wrong kind. mark_object_dynamic_shape_unknown(obj); - shapes::clear_object_shape_stamp(obj); } + // #8067/#8113: every visible ShapeId resolves to the exact rooted + // ordered-keys/live-slot descriptor. Same-pointer appends are versioned + // inside the helper. + shapes::publish_object_shape_from(obj, predecessor, keys_array, live_inline_slot_count); // GC_STORE_AUDIT(BARRIERED): keys_array pointer field is followed by an object-slot barrier. (*obj).keys_array = keys_array; crate::gc::runtime_write_barrier_slot( @@ -1915,28 +1897,6 @@ unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHe &(*obj).keys_array as *const _ as usize, keys_array as u64, ); - // #8067: the old header edge remains authoritative, but every visible - // ShapeId must now resolve to the exact rooted ordered-keys/live-slot - // descriptor. Same-pointer appends are versioned inside the helper. - shapes::synchronize_object_shape_descriptor_from(obj, predecessor); -} - -/// Publish a new authoritative live-inline-slot bound without ever exposing a -/// ShapeId whose descriptor disagrees with `ObjectHeader.field_count`. -/// -/// Callers growing the traced range must invoke this before publishing the -/// pointer-bearing field value (#7154): old stamp clear → header count write → -/// complete descriptor install → new stamp → value-slot store. -#[inline] -pub(super) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { - 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); - } else { - shapes::debug_assert_object_shape_parity(obj); - } } #[inline] diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index d73f341fbe..81341fb045 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1685,9 +1685,13 @@ pub unsafe extern "C-unwind" fn js_native_call_method( if jsval().is_pointer() { let obj = jsval().as_pointer::(); - // Validate this is an ObjectHeader, not some other heap type. - // Check GcHeader first (reliable for heap objects), then fallback to ObjectHeader.object_type - // for static/const objects that don't have GcHeaders. + // Validate this is an ObjectHeader, not some other heap type, from the + // GcHeader. (The comment here used to promise an `ObjectHeader.object_type` + // fallback "for static/const objects that don't have GcHeaders". No such + // fallback was ever written — the read below is unconditional — and + // #8113 deleted the word it named. `NULL_OBJECT_BYTES`, the one + // GcHeader-less receiver, therefore classifies from whatever precedes it + // in `.data`; that was already true before this change.) // Guard: ensure we can safely read GC_HEADER_SIZE bytes before obj if (obj as usize) < crate::gc::GC_HEADER_SIZE + 0x1000 { return 0.0; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index ba5b996ccf..0b6849de4a 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1572,7 +1572,7 @@ pub(super) fn class_id_from_method_receiver(instance: f64) -> Option { } // #7563: the closure guard above fixed ONE instance of that type // confusion; a bare `(*obj).class_id` read has it for every other - // non-object allocation too. `ObjectHeader` is `{ object_type: u32, + // non-object allocation too. `ObjectHeader` is `{ class_id: u32, // class_id: u32, … }` while `ArrayHeader` is `{ length: u32, // capacity: u32 }`, so the `class_id` slot of an ARRAY overlays its // **capacity** — an N-capacity array literal was read back as diff --git a/crates/perry-runtime/src/object/null_stub.rs b/crates/perry-runtime/src/object/null_stub.rs new file mode 100644 index 0000000000..87413c2ace --- /dev/null +++ b/crates/perry-runtime/src/object/null_stub.rs @@ -0,0 +1,72 @@ +//! The unresolved-module namespace stub — a static, GcHeader-less "empty +//! object" handed to user code when a module import or a method dispatch has +//! nowhere to go. +//! +//! Split out of `object/mod.rs` (2000-line cap) by #8113, which also gave the +//! mirror its missing `meta` word. + +/// Static "null object" used as a safe return value when the depth guard triggers. +/// Instead of returning undefined (which callers may dereference as a null pointer), +/// we return a pointer to this valid-but-empty object so downstream code doesn't crash. +/// +/// Uses a raw byte array with matching layout to avoid Sync issues with raw pointers. +/// +/// #8113: mirrors the post-shrink `ObjectHeader` word for word, including the +/// trailing `meta` slot the pre-#8113 spelling omitted (a `(*obj).meta` read on +/// the stub used to run off the end of the static). +#[repr(C, align(8))] +pub(crate) struct NullObjectBytes { + class_id: u32, // 0 + parent_class_id: u32, // 0 (never a ShapeId: the stub has no descriptor) + keys_array: u64, // 0 (null pointer as u64) + meta: u64, // 0 (null pointer as u64) +} +// Safety: this is a read-only zero-initialized struct with no interior mutability +unsafe impl Sync for NullObjectBytes {} + +/// Issue #629: namespace imports for unresolved modules +/// (`import * as fsp from "node:fs/promises"` when the module isn't +/// implemented) used to fall back to `TAG_TRUE` at the codegen +/// catch-all, which made `typeof fsp === "boolean"` and every +/// `fsp.method` access return undefined silently — confusing because +/// the user sees `(boolean).method is not a function`. Returning a +/// stable empty-object stub makes `typeof === "object"` (matches +/// Node's module-namespace shape) and property access cleanly returns +/// undefined via the existing object-field path. +#[no_mangle] +pub extern "C" fn js_unresolved_namespace_stub() -> f64 { + let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; + f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) +} + +/// Issue #692: default-import calls against unresolved modules +/// (`import jwt from "jsonwebtoken"; jwt.sign(...)` when no perry-stdlib +/// binding matched the method, or `import sanitizeHtml from +/// "sanitize-html"; sanitizeHtml(x)` when sanitize-html doesn't resolve +/// to a NativeCompiled module) used to lower to an LLVM extern named +/// literally `default`, which the system linker can't resolve — +/// surfaced as `undefined reference to 'default'`. Route those calls +/// here so the binary links; the runtime stub prints a one-shot +/// diagnostic and returns NaN-boxed undefined. The user gets a clear +/// signal at first call rather than a cryptic link error. +#[no_mangle] +pub extern "C" fn js_unresolved_default_call() -> f64 { + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + eprintln!( + "perry: called a default-imported binding from an unresolved module \ + (returns undefined). The module's default export was not found in \ + perry-stdlib or perry.compilePackages — run `perry --print-api-manifest` \ + to see what's supported." + ); + } + f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED +} + +pub(crate) static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { + class_id: 0, + parent_class_id: 0, + keys_array: 0, + meta: 0, +}; diff --git a/crates/perry-runtime/src/object/object_ops/accessors.rs b/crates/perry-runtime/src/object/object_ops/accessors.rs index 8a462de49e..342346ecf2 100644 --- a/crates/perry-runtime/src/object/object_ops/accessors.rs +++ b/crates/perry-runtime/src/object/object_ops/accessors.rs @@ -158,8 +158,10 @@ pub extern "C" fn js_object_get_own_field_or_undef( if key_count > 65536 { return f64::from_bits(TAG_UNDEF); } - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); // #1781: SSO-aware match by byte slice — the diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 1a8c416e07..52a52c1136 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -33,7 +33,7 @@ pub(crate) unsafe fn ensure_key_in_keys_array( let new_keys = crate::array::js_array_push(new_keys, JSValue::string_ptr(key as *mut _)); refresh_define_property_roots!(); set_object_keys_array(obj, new_keys); - if (*obj).field_count == 0 { + if crate::object::object_live_slot_count(obj) == 0 { set_object_live_slot_count(obj, 1); } return; @@ -147,9 +147,11 @@ pub(crate) unsafe fn ensure_key_in_keys_array( // getter here bumped field_count from 8 (the proto's physical capacity) to // 11, exposing the overflowed `values` slot and corrupting the boundary. let new_index = key_count as u32; - let inline_capacity = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32); - if new_index < inline_capacity && new_index >= (*obj).field_count { + let inline_capacity = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ); + if new_index < inline_capacity && new_index >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, new_index + 1); } } diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index a57d1d543a..296d11d7a0 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -16,9 +16,17 @@ //! ordered-keys edge plus the exact logical-key and live-inline-slot bounds. //! The descriptor table is agent-local while ids are process-global. A live //! object's ShapeId is authoritative for its ordered keys, logical-key count, -//! live inline-slot bound, and semantic generation. The legacy -//! `ObjectHeader::{keys_array,field_count}` words remain ABI mirrors until -//! #8047 removes them; guards and GC must not use their values as shape facts. +//! live inline-slot bound, and semantic generation. +//! +//! #8113 removed `ObjectHeader::field_count`, so the descriptor's +//! `live_inline_slot_count` is no longer a mirror of a header word — it is the +//! ONLY record of the bound. Every publication below is therefore +//! MINT-THEN-STAMP: the successor descriptor is fully installed while the +//! predecessor stamp is still readable, and the `parent_class_id` store is the +//! single, allocation-free publication point. A stamp-cleared window would be a +//! window in which the collector sees a live bound of 0 (#7154/#7164). +//! `ObjectHeader::keys_array` remains an ABI mirror until #8047 removes it; +//! guards and GC must not use its value as a shape fact. use crate::array::ArrayHeader; use std::cell::RefCell; @@ -469,12 +477,13 @@ pub(crate) unsafe fn stamp_object_shape( obj: *mut crate::object::ObjectHeader, keys: *const ArrayHeader, key_count: u32, + live_inline_slot_count: u32, ) -> u32 { if !shape_word_is_writable(obj) { return 0; } let Some(lineage) = object_shape_descriptor(obj) else { - let id = shape_descriptor_ensure(keys, key_count, (*obj).field_count) + let id = shape_descriptor_ensure(keys, key_count, live_inline_slot_count) .unwrap_or_else(|error| shape_descriptor_error_abort(error)); (*obj).parent_class_id = id; debug_assert_object_shape_parity(obj); @@ -502,54 +511,138 @@ pub(crate) unsafe fn stamp_object_shape( /// `ObjectHeader` must call this so all runtime and emitted guards observe the /// same descriptor identity from birth. /// -/// No `shape_word_is_writable` check: the callers have just written -/// `object_type`/`class_id` into a header they allocated, so the receiver is a -/// genuine `ObjectHeader` and never the `RegExpHeader` alias. +/// `live_inline_slot_count` is the birth bound the allocator sized the object +/// with. #8113: it is a parameter rather than a `(*obj).field_count` read +/// because the header no longer carries the word — the descriptor this +/// publishes is the only record of it. +/// +/// No `shape_word_is_writable` check beyond the null test: the callers have just +/// written `class_id` into a header they allocated, so the receiver is a genuine +/// `ObjectHeader` and never the `RegExpHeader` alias. #[inline] pub(crate) unsafe fn birth_stamp_object_shape( obj: *mut crate::object::ObjectHeader, runtime_shape_id: u32, + live_inline_slot_count: u32, ) { if obj.is_null() || !shape_word_is_writable(obj) { return; } let current = object_shape_descriptor(obj).unwrap_or_else(|| { - synchronize_object_shape_descriptor(obj); + birth_publish_object_shape(obj, live_inline_slot_count); object_shape_descriptor(obj).expect("shape synchronization must publish a descriptor") }); let keys = current.keys as usize as *mut ArrayHeader; let key_count = current.logical_key_count; - let supplied_id_is_local = descriptor_matches_object(runtime_shape_id, obj) - || install_external_shape_id(runtime_shape_id, keys, key_count, (*obj).field_count); + let supplied_id_is_local = + descriptor_matches_object(runtime_shape_id, obj, live_inline_slot_count) + || install_external_shape_id(runtime_shape_id, keys, key_count, live_inline_slot_count); if supplied_id_is_local { (*obj).parent_class_id = runtime_shape_id; debug_assert_object_shape_parity(obj); } else { - synchronize_object_shape_descriptor(obj); + birth_publish_object_shape(obj, live_inline_slot_count); + } +} + +/// Publish the exact descriptor for a FRESHLY ALLOCATED header. #8113: the +/// birth live-slot bound must be supplied because no header word carries it. +/// +/// Mint-then-stamp: `shape_descriptor_ensure_with_generation` can collect, and +/// at that point the object is still unstamped, which is sound only because it +/// is also still unpublished — the allocator has not returned it and no live +/// edge reaches it. Every LATER bound change goes through +/// [`publish_object_live_slot_count`], which keeps a valid predecessor stamp +/// across the mint. +#[inline] +pub(crate) unsafe fn birth_publish_object_shape( + obj: *mut crate::object::ObjectHeader, + live_inline_slot_count: u32, +) -> u32 { + synchronize_object_shape_descriptor_from(obj, None, live_inline_slot_count) +} + +/// Publish a new live inline-slot bound for an ALREADY PUBLISHED object. +/// +/// This is the #8113 replacement for `(*obj).field_count = n`. The successor +/// descriptor is minted while the predecessor stamp is still installed, so a +/// collection inside the mint observes the OLD bound — correct, because the +/// slot the caller is about to expose has not been written yet — and the new +/// bound becomes visible at the single `parent_class_id` store, which cannot +/// allocate and therefore cannot collect. +pub(crate) unsafe fn publish_object_live_slot_count( + obj: *mut crate::object::ObjectHeader, + live_inline_slot_count: u32, +) -> u32 { + if obj.is_null() || !shape_word_is_writable(obj) { + return 0; + } + let predecessor = object_shape_descriptor(obj); + if let Some(current) = predecessor { + if current.live_inline_slot_count == live_inline_slot_count { + debug_assert_object_shape_parity(obj); + return object_shape_stamp(obj); + } } + synchronize_object_shape_descriptor_from(obj, predecessor, live_inline_slot_count) } -/// Install the exact descriptor for the object's current authoritative header -/// facts. This is the only structural shape publication operation used by -/// mutations. Keyless objects receive a descriptor too. +/// Install the exact descriptor for the object's current authoritative keys +/// edge, preserving the live inline-slot bound the receiver already carries. +/// This is the only structural shape publication operation used by mutations. +/// Keyless objects receive a descriptor too. +/// +/// #8113: an UNSTAMPED receiver has no recorded bound anywhere, so this +/// publishes 0 for it rather than inventing one. Callers that know the bound +/// (allocators, the by-name append path) must use +/// [`birth_publish_object_shape`] / [`publish_object_live_slot_count`]. pub(crate) unsafe fn synchronize_object_shape_descriptor( obj: *mut crate::object::ObjectHeader, ) -> u32 { let predecessor = object_shape_descriptor(obj); - synchronize_object_shape_descriptor_from(obj, predecessor) + let live = predecessor + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0); + synchronize_object_shape_descriptor_from(obj, predecessor, live) } -/// Structural synchronization after a caller has temporarily cleared the -/// stamp. `predecessor` carries semantic lineage (including class kind) across -/// the pointer/count mutation without exposing stale structural facts. +/// Structural synchronization across a keys-edge or slot-bound mutation. +/// `predecessor` carries semantic lineage (including class kind) across the +/// mutation without exposing stale structural facts. +/// +/// MINT-THEN-STAMP (#8113): every allocation below happens with the +/// predecessor stamp still installed; the receiver's published shape changes at +/// the final `parent_class_id` store and nowhere else. pub(crate) unsafe fn synchronize_object_shape_descriptor_from( obj: *mut crate::object::ObjectHeader, predecessor: Option, + live_inline_slot_count: u32, +) -> u32 { + if obj.is_null() { + return 0; + } + publish_object_shape_from(obj, predecessor, (*obj).keys_array, live_inline_slot_count) +} + +/// Publish the exact descriptor for an EXPLICIT keys edge — which may not be +/// the one the header currently holds. +/// +/// This is what makes the keys-edge mutation mint-then-stamp (#8113). The +/// caller stamps the successor here, with the predecessor still describing the +/// header's current edge throughout every allocation inside, and only then +/// stores the header word. The gap between the stamp store and the header store +/// is allocation-free, and `object::gc_keys_array_slot` materializes +/// `descriptor.keys` into the header slot anyway, so a collection inside it +/// still sees exactly one authoritative edge. +pub(crate) unsafe fn publish_object_shape_from( + obj: *mut crate::object::ObjectHeader, + predecessor: Option, + keys: *mut ArrayHeader, + live_inline_slot_count: u32, ) -> u32 { if obj.is_null() || !shape_word_is_writable(obj) { return 0; } - let keys = (*obj).keys_array; let key_count = if keys.is_null() { 0 } else { @@ -562,14 +655,17 @@ pub(crate) unsafe fn synchronize_object_shape_descriptor_from( let old_id = object_shape_stamp(obj); if let Some(old) = shape_descriptor_by_id(old_id) { if old.keys == keys as u64 && old.logical_key_count != key_count { + // #8113: these three arms are unreachable-by-construction defenses + // (`debug_assert!` below). They deliberately leave the receiver + // STAMPED with its predecessor rather than clearing: an unstamped + // object now has no live-slot bound at all, so clearing would turn + // a shape-identity fault into heap-payload loss. let Some(gc) = crate::value::addr_class::try_read_tracked_gc_header(keys as usize) else { - clear_object_shape_stamp(obj); - return 0; + return old_id; }; if (*gc.as_ptr()).obj_type != crate::gc::GC_TYPE_ARRAY { - clear_object_shape_stamp(obj); - return 0; + return old_id; } let shared = (*gc.as_ptr()).gc_flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0; debug_assert!( @@ -577,8 +673,7 @@ pub(crate) unsafe fn synchronize_object_shape_descriptor_from( "shared keys array mutated in place under an immutable ShapeId" ); if shared { - clear_object_shape_stamp(obj); - return 0; + return old_id; } retain_key_count_versions(keys as u64); } @@ -599,12 +694,12 @@ pub(crate) unsafe fn synchronize_object_shape_descriptor_from( let id = publish_shape_result(shape_descriptor_ensure_with_generation( keys, key_count, - (*obj).field_count, + live_inline_slot_count, semantic_generation, object_kind, )); (*obj).parent_class_id = id; - debug_assert_object_shape_parity(obj); + debug_assert_object_shape_parity_for_keys(obj, keys); id } @@ -720,29 +815,63 @@ fn retain_key_count_versions(keys: u64) { } } -fn descriptor_matches_object(shape_id: u32, obj: *const crate::object::ObjectHeader) -> bool { +/// Exact-facts test for a candidate id against the receiver's authoritative +/// header facts. #8113: the live bound is a PARAMETER — the header no longer +/// mirrors it, so the caller supplies the bound it is claiming. +fn descriptor_matches_object( + shape_id: u32, + obj: *const crate::object::ObjectHeader, + live_inline_slot_count: u32, +) -> bool { let Some(d) = shape_descriptor_by_id(shape_id) else { return false; }; unsafe { - let keys = (*obj).keys_array; - let key_count = if keys.is_null() { - 0 - } else { - crate::array::keys_array_len_capped_to_capacity(keys) as u32 - }; - d.keys == keys as u64 - && d.logical_key_count == key_count - && d.live_inline_slot_count == (*obj).field_count + d.keys == (*obj).keys_array as u64 + && d.logical_key_count == object_header_key_count(obj) + && d.live_inline_slot_count == live_inline_slot_count } } +#[inline] +unsafe fn object_header_key_count(obj: *const crate::object::ObjectHeader) -> u32 { + let keys = (*obj).keys_array; + if keys.is_null() { + 0 + } else { + crate::array::keys_array_len_capped_to_capacity(keys) as u32 + } +} + +/// #8113: the live-slot bound is no longer independently observable, so parity +/// is now exactly "the stamp resolves, and its structural keys facts match the +/// keys edge the receiver is about to carry". The bound cannot disagree with +/// itself. #[inline] pub(crate) unsafe fn debug_assert_object_shape_parity(obj: *const crate::object::ObjectHeader) { + debug_assert_object_shape_parity_for_keys(obj, (*obj).keys_array); +} + +/// Parity against an EXPLICIT keys edge. +/// +/// `publish_object_shape_from` stamps the successor before the header store +/// (that is what makes the keys mutation mint-then-stamp), so for that one +/// window the authoritative edge is the caller's argument, not the header word. +#[inline] +pub(crate) unsafe fn debug_assert_object_shape_parity_for_keys( + obj: *const crate::object::ObjectHeader, + keys: *mut ArrayHeader, +) { let id = object_shape_stamp(obj); if id != 0 { + let key_count = if keys.is_null() { + 0 + } else { + crate::array::keys_array_len_capped_to_capacity(keys) as u32 + }; debug_assert!( - descriptor_matches_object(id, obj), + shape_descriptor_by_id(id) + .is_some_and(|d| { d.keys == keys as u64 && d.logical_key_count == key_count }), "published ShapeId disagrees with authoritative ObjectHeader facts" ); } @@ -814,9 +943,16 @@ pub(crate) unsafe fn synchronize_live_object_shape_descriptor_after_header_visit /// Drop the stamp iff the word currently holds one, leaving a real /// `parent_class_id` untouched. Returns true when a stamp was cleared. /// -/// Ids are never reused, so clearing makes every stale id-keyed cache entry a -/// permanent miss; the next resolve re-stamps from whatever record the live -/// keys array has then. +/// # TEST-ONLY since #8113 +/// +/// Production code must never clear a stamp. The descriptor is now the sole +/// record of the live inline-slot bound, so an unstamped receiver reports a +/// bound of ZERO — its payload stops being traced, rewritten, and writable. +/// Every mutation that used to clear-then-re-mint is mint-then-stamp instead +/// (`publish_object_live_slot_count`, `publish_object_shape_from`), which has no +/// window at all. This survives only so tests can MANUFACTURE the unstamped +/// state and assert what the runtime does with it. +#[cfg(test)] #[inline] pub(crate) unsafe fn clear_object_shape_stamp(obj: *mut crate::object::ObjectHeader) -> bool { if is_shape_id((*obj).parent_class_id) { @@ -1194,7 +1330,10 @@ mod c3c_tests { descriptor.logical_key_count, crate::array::js_array_length((*obj).keys_array) ); - assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count); + assert_eq!( + descriptor.live_inline_slot_count, + crate::object::object_live_slot_count(obj) + ); debug_assert_object_shape_parity(obj); } @@ -1246,11 +1385,19 @@ mod c6804_tests { } } - /// #6804: `object_shape()` self-heals — an unstamped plain object gets - /// stamped at first observation, and the token equals the id every - /// sibling already carries (no pre/post-stamp token split). + /// #6804 wanted "no pre/post-stamp token split", and got it with a + /// self-heal inside `object_shape()`. #8113 removes the self-heal and keeps + /// the property, by a stronger route: **the split population is empty**, + /// because every allocator birth-stamps. + /// + /// The self-heal had to go because it derived the live inline-slot bound + /// from `ObjectHeader::field_count`. With that word deleted, healing an + /// unstamped receiver would publish a descriptor claiming a bound of ZERO — + /// a read-only observation silently truncating the object's traced and + /// writable payload. Missing closed costs a PIC miss; healing wrongly loses + /// fields. #[test] - fn object_shape_token_self_heals_to_shared_id() { + fn object_shape_token_is_birth_stamped_and_an_unstamped_one_misses_closed() { let _lock = crate::gc::global_side_table_test_lock(); unsafe { let packed = b"m6804_x\0m6804_y"; @@ -1261,20 +1408,38 @@ mod c6804_tests { packed.len() as u32, ); let birth_stamp = (*obj).parent_class_id; - assert!(is_shape_id(birth_stamp), "test premise: birth-stamped"); + assert!(is_shape_id(birth_stamp), "every literal is birth-stamped"); + assert_eq!( + crate::typed_feedback::test_object_shape_token(obj as usize), + birth_stamp as usize, + "the observed token is the birth stamp — no split to heal" + ); + assert_eq!( + shape_descriptor_by_id(birth_stamp) + .expect("birth descriptor") + .live_inline_slot_count, + 2 + ); - // Simulate a pre-#6804 / cleared-stamp object of the same shape. + // Manufacture the pre-#6804 unstamped state and prove observing it + // is INERT: no token, no descriptor, and — the part that matters — + // no rewritten live-slot bound. (*obj).parent_class_id = 0; - let token = crate::typed_feedback::test_object_shape_token(obj as usize); assert_eq!( - token, birth_stamp as usize, - "self-healed token must equal the shape's canonical id" + crate::typed_feedback::test_object_shape_token(obj as usize), + 0, + "an unstamped receiver must miss closed, not be re-stamped" ); assert_eq!( (*obj).parent_class_id, - birth_stamp, - "observation must re-stamp the object" + 0, + "observation must not publish a descriptor for an unstamped receiver" ); + + // Restoring the birth stamp restores the exact bound, which is the + // proof that nothing was lost by refusing to heal. + (*obj).parent_class_id = birth_stamp; + assert_eq!(crate::object::object_live_slot_count(obj), 2); } } @@ -1523,10 +1688,8 @@ mod descriptor_tests_8067 { let id = shape_descriptor_ensure(keys as *const ArrayHeader, 3, 2) .expect("shape range unexpectedly exhausted"); let obj = crate::object::ObjectHeader { - object_type: 1, class_id: 0, parent_class_id: id, - field_count: 2, keys_array: keys as *mut ArrayHeader, meta: std::ptr::null_mut(), }; diff --git a/crates/perry-runtime/src/object/spill.rs b/crates/perry-runtime/src/object/spill.rs index 1a0e327764..b991c2fbf4 100644 --- a/crates/perry-runtime/src/object/spill.rs +++ b/crates/perry-runtime/src/object/spill.rs @@ -166,8 +166,10 @@ pub(crate) fn reserve_object_spill(obj_ptr: usize, field_count: u32) { unsafe { let obj = obj_ptr as *mut ObjectHeader; - let inline_capacity = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32); + let inline_capacity = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ); if field_count <= inline_capacity { return; } diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 8bf7527f2c..6f6d6aa33c 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -635,14 +635,15 @@ fn symbol_keys_keep_creation_order_across_accessor_redefine() { } } -/// #7916: the per-object footprint accounting this issue is about, pinned as an -/// executable fact rather than a comment. +/// #7916 / #8113: the per-object footprint accounting this issue is about, +/// pinned as an executable fact rather than a comment. /// -/// A two-field object literal is `GcHeader (8) + ObjectHeader (32) + 8 * -/// max(field_count, INLINE_SLOT_FLOOR)`. At `INLINE_SLOT_FLOOR = 4` that is -/// **72 bytes to store 16 bytes of payload** and `gc-handoff/bench/retain.ts` -/// writes 216 MB to hold 48 MB of doubles. Lowering the floor to 2 removes the -/// two unusable slots. +/// A two-field object literal is `GcHeader (8) + ObjectHeader (24) + 8 * +/// max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. It was 72 bytes at floor 4 +/// (#7916 took it to 56 by lowering the floor to 2) and #8113 took it to **48** +/// by deleting the header's two derivable words. 48 bytes to store 16 bytes of +/// payload; `gc-handoff/bench/retain.ts` writes 3x its data volume, down from +/// 4.5x. /// /// This reads the size the ALLOCATOR recorded (`GcHeader::size`), not a /// recomputation of the same formula, so it fails if any allocation path @@ -651,8 +652,10 @@ fn symbol_keys_keep_creation_order_across_accessor_redefine() { fn two_field_literal_footprint_is_exactly_accounted() { assert_eq!( std::mem::size_of::(), - 32, - "the ObjectHeader half of the accounting: 4 u32 + 2 pointers" + 24, + "the ObjectHeader half of the accounting: 2 u32 + 2 pointers (#8113 \ + removed `object_type` and `field_count`; either alone saved nothing \ + because the struct re-padded, both together saved 8 bytes)" ); assert_eq!(crate::gc::GC_HEADER_SIZE, 8); @@ -677,10 +680,11 @@ fn two_field_literal_footprint_is_exactly_accounted() { "a 2-field literal must occupy exactly {expected} bytes" ); assert_eq!( - recorded, 56, - "#7916: the 2-field literal footprint is 56 bytes (was 72 at floor 4). \ - Raising INLINE_SLOT_FLOOR back to 4 re-adds 16 bytes of unusable slots \ - to every small object" + recorded, 48, + "#8113: the 2-field literal footprint is 48 bytes (56 before the header \ + shrink, 72 at floor 4). Raising INLINE_SLOT_FLOOR back to 4 re-adds 16 \ + bytes of unusable slots to every small object; re-adding a header word \ + re-adds 8 to every object regardless of width" ); } @@ -688,11 +692,11 @@ fn two_field_literal_footprint_is_exactly_accounted() { /// `perry-codegen/src/target_layout.rs` (#7916). /// /// perry-codegen cannot depend on perry-runtime, so it carries its own copy of -/// this constant and uses it BOTH to size the inline-`new` bump allocation and -/// to emit `slot < max(field_count, FLOOR)` bounds checks around raw inline -/// slot loads/stores. The two failure modes point in opposite directions -/// (codegen too small under-allocates; codegen too large over-reads), so the -/// values must be exactly equal — pin the number on both sides. +/// this constant and uses it to size the inline-`new` bump allocation, which +/// must match the floor every runtime bounds check applies. The two failure +/// modes point in opposite directions (codegen too small under-allocates; +/// codegen too large over-reads), so the values must be exactly equal — pin the +/// number on both sides. #[test] fn inline_slot_floor_matches_codegen() { assert_eq!( @@ -1493,9 +1497,12 @@ fn stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops() { /// #7563: an ARRAY receiver must never be read back as a class instance. /// -/// `ObjectHeader` is `{ object_type: u32, class_id: u32, … }` and `ArrayHeader` -/// is `{ length: u32, capacity: u32 }`, so the two u32s at offset 4 alias — an -/// array read as an `ObjectHeader` reports its **capacity** as a `class_id`. +/// `ObjectHeader` is `{ class_id: u32, parent_class_id: u32, … }` and +/// `ArrayHeader` is `{ length: u32, capacity: u32 }`, so the two u32s at offset +/// 0 alias — an array read as an `ObjectHeader` reports its **length** as a +/// `class_id`. (#8113 moved this from offset 4 / `capacity` when it deleted the +/// leading `object_type` word. Note that makes the collision DENSER, not +/// sparser: array lengths are small and consecutive, and so are class ids.) /// /// That mattered because `arr[Symbol.iterator]` resolves through /// `js_class_method_bind(arr, "values")`, whose receiver→class step used a bare @@ -1509,11 +1516,13 @@ fn stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops() { fn array_receiver_is_never_read_as_a_class_id() { let arr = crate::array::js_array_alloc(3); assert!(!arr.is_null()); + crate::array::js_array_push(arr, crate::JSValue::from_bits(1.0f64.to_bits())); // Impersonate exactly the class id this array's bytes would have yielded. - let impersonated = unsafe { (*arr).capacity }; + // #8113: that is `length`, at offset 0, not `capacity`. + let impersonated = unsafe { (*arr).length }; assert_ne!( impersonated, 0, - "the test is vacuous unless the capacity is a non-zero (i.e. lookup-able) class id" + "the test is vacuous unless the length is a non-zero (i.e. lookup-able) class id" ); let arr_value = crate::value::js_nanbox_pointer(arr as i64); @@ -1655,3 +1664,127 @@ fn buffer_own_key_comes_from_the_expando_table_not_the_object_walk() { "an unknown key is not an own key" ); } +// --------------------------------------------------------------------------- +// #8113 — the trap this header shrink had to disarm. +// +// `ObjectHeader` used to open with `object_type: u32`, prefix-punned against +// `error::ErrorHeader`'s first word, and NINE sites read raw offset 0 to answer +// "is this an Error?". Deleting the word makes offset 0 `class_id` — and +// `OBJECT_TYPE_ERROR` is **2**, while class ids are handed out from 1, densely, +// in source-declaration order. So a surviving raw read reclassifies every +// instance of the SECOND class a program declares as an `ErrorHeader` and reads +// `message`/`name`/`stack`/`errors` out of its field slots: a silent wrong +// answer of exactly the #8100 shape. +// +// These tests are SABOTAGE-SHAPED. Each first asserts that the confusable value +// really is sitting at offset 0 — so a green run proves the GcHeader-kind test +// fired, not that the fixture happened to look harmless. +// --------------------------------------------------------------------------- + +/// The premise: an ordinary object CAN carry `class_id == OBJECT_TYPE_ERROR`, +/// and that value really is the first word of its header. +#[test] +fn an_ordinary_object_can_carry_the_error_type_tag_as_its_class_id() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + assert!(!obj.is_null()); + unsafe { + assert_eq!((*obj).class_id, crate::error::OBJECT_TYPE_ERROR); + // Offset 0, read the way the retired discriminators read it. + let raw_word_0 = std::ptr::read(obj as *const u32); + assert_eq!( + raw_word_0, + crate::error::OBJECT_TYPE_ERROR, + "test premise: the pre-#8113 raw offset-0 read now yields \ + OBJECT_TYPE_ERROR for an ordinary object" + ); + } +} + +/// `Error.isError()` must not be fooled by it. (`error.rs:750`.) +#[test] +fn error_is_error_rejects_an_object_whose_class_id_equals_the_error_tag() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + let value = crate::value::js_nanbox_pointer(obj as i64); + assert_eq!( + crate::error::js_error_is_error(value).to_bits(), + crate::value::TAG_FALSE, + "class_id == OBJECT_TYPE_ERROR must not read as a native Error" + ); + + // Not over-narrowed: a real Error still answers true. + let real = crate::error::js_error_new_with_message(crate::string::js_string_from_bytes( + b"boom".as_ptr(), + 4, + )); + let real_value = crate::value::js_nanbox_pointer(real as i64); + assert_eq!( + crate::error::js_error_is_error(real_value).to_bits(), + crate::value::TAG_TRUE, + "a genuine ErrorHeader must still classify as an Error" + ); +} + +/// `js_error_get_errors` must resolve `.errors` GENERICALLY for it rather than +/// returning the fixed `ErrorHeader.errors` slot. (`error.rs:1542`; the doc +/// there records the for-of corruption the fixed-slot read caused.) +#[test] +fn error_get_errors_does_not_read_a_fixed_slot_off_a_colliding_class_id() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + unsafe { + assert_eq!((*obj).class_id, crate::error::OBJECT_TYPE_ERROR); + // Poison the slot the ErrorHeader layout would call `errors`. + let key = crate::string::js_string_from_bytes(b"errors".as_ptr(), 6); + let arr = crate::array::js_array_alloc(1); + crate::object::js_object_set_field_by_name( + obj, + key, + f64::from_bits(crate::value::js_nanbox_pointer(arr as i64).to_bits()), + ); + let got = crate::error::js_error_get_errors(obj as *mut crate::error::ErrorHeader); + assert_eq!( + got as usize, arr as usize, + "`.errors` on a class_id == 2 object must resolve as an ordinary \ + own property, not as ErrorHeader's fixed slot" + ); + } +} + +/// `js_dynamic_object_keys` must return the object's real keys, not the Error +/// triple. (`value/dynamic_object.rs:728`.) +#[test] +fn dynamic_object_keys_are_not_the_error_triple_for_a_colliding_class_id() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + unsafe { + let key = crate::string::js_string_from_bytes(b"kk8113".as_ptr(), 6); + crate::object::js_object_set_field_by_name(obj, key, 1.0); + let keys = crate::value::js_dynamic_object_keys(obj as i64); + assert_eq!( + crate::array::js_array_length(keys), + 1, + "a class_id == 2 object must enumerate its OWN keys, not \ + [message, name, stack]" + ); + } +} + +/// The #6595 half: the store-plan gate must stay FALSE for a heap class object. +/// `object_is_regular` is the replacement for the deleted +/// `object_type == OBJECT_TYPE_REGULAR` read at `proxy.rs:1523`, and it is only +/// a valid one because it means `descriptor.object_kind == Ordinary` — not the +/// weaker "is an ObjectHeader". +#[test] +fn object_is_regular_excludes_a_heap_class_object() { + let obj = js_object_alloc(0x8113_0001, 1); + unsafe { + assert!( + crate::object::object_is_regular(obj), + "a fresh ordinary object is regular" + ); + crate::object::class_registry::js_object_mark_class(obj as i64); + assert!( + !crate::object::object_is_regular(obj), + "#6595: a heap class object must NOT be 'regular' — the store-plan \ + gate at proxy.rs keys off exactly this" + ); + } +} diff --git a/crates/perry-runtime/src/promise/rejection.rs b/crates/perry-runtime/src/promise/rejection.rs index 7a1bdb7090..dc3aaf6f19 100644 --- a/crates/perry-runtime/src/promise/rejection.rs +++ b/crates/perry-runtime/src/promise/rejection.rs @@ -177,9 +177,10 @@ fn describe_rejection_reason(v: f64) -> String { } if jv.is_pointer() { let ptr = jv.as_pointer::() as usize; - if crate::value::addr_class::is_plausible_heap_addr(ptr) - && unsafe { *(ptr as *const u32) } == crate::error::OBJECT_TYPE_ERROR - { + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR`, not a raw offset-0 read. + // Offset 0 is `class_id` now, and `OBJECT_TYPE_ERROR` is 2 — an + // ordinary user class id. + if unsafe { crate::error::ptr_is_native_error(ptr) } { let eh = ptr as *const crate::error::ErrorHeader; let stack = unsafe { crate::exception::string_header_to_string((*eh).stack) }; return format!("error(0x{ptr:x}) stack={stack:?}"); @@ -460,9 +461,9 @@ fn print_unhandled_diagnostic(reason: f64) { // band — `fetch().then(r => { throw r })` uncaught) — the old bare // `>= 0x10000` deref'd the id as memory instead of printing the // fallback line. - if crate::value::addr_class::is_plausible_heap_addr(ptr) - && unsafe { *(ptr as *const u32) } == crate::error::OBJECT_TYPE_ERROR - { + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR` (which subsumes the + // band+plausibility gate above), not a raw offset-0 read. + if unsafe { crate::error::ptr_is_native_error(ptr) } { let eh = ptr as *const crate::error::ErrorHeader; let stack_str = unsafe { crate::exception::string_header_to_string((*eh).stack) }; if !stack_str.is_empty() { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 01308cf928..81abd6d37f 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1525,7 +1525,7 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) let interned = crate::object::interned_key_ptr(key_ptr); // #6595: a per-evaluation CLASS OBJECT (what a // capture-carrying class materializes as, - // `object_type == OBJECT_TYPE_CLASS`) shares its + // `ShapeObjectKind::Class`) shares its // template cid with its instances, and its own-data // writes must reach the #6530 // `mirror_class_object_static_write` hook in @@ -1542,8 +1542,17 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) addr, ) && class_id != crate::object::NATIVE_MODULE_CLASS_ID - && (*(addr as *const crate::ObjectHeader)).object_type - == crate::error::OBJECT_TYPE_REGULAR + // #8113: this asks for ORDINARY specifically — + // it must stay FALSE for a class object or + // #6595 reopens. `object_is_regular` is exactly + // `descriptor.object_kind == Ordinary` since + // #8086, so it is the same predicate the + // deleted `object_type == OBJECT_TYPE_REGULAR` + // word expressed, not the weaker + // "is an ObjectHeader" test. + && crate::object::object_is_regular( + addr as *const crate::ObjectHeader, + ) && interned != 0; let verdict = if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 5160bd4ff5..4a30664ad0 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -374,8 +374,10 @@ static SYMBOL_EVER_REGISTERED: crate::registry_latch::RegistryLatch = /// **`false` is exact** — no symbol reads `false` — while `true` is merely /// "ask the registry". A non-symbol whose first word happens to equal /// `SYMBOL_MAGIC` (a `StringHeader` would need `utf16_len == 0x5359_4D42`, i.e. -/// a 2.8 GB string; an `ObjectHeader`'s `object_type` is a small tag) simply -/// pays the old probe and gets the old, correct answer. +/// a 2.8 GB string; an `ObjectHeader`'s first word is `class_id`, and ids are +/// handed out from 1 — #8113 deleted the `object_type` tag that used to sit +/// there, which does not change this argument) simply pays the old probe and +/// gets the old, correct answer. /// /// # Safety /// `ptr` must be readable for 4 bytes. Every caller is one that already diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index ac48468410..61952e90d5 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -631,7 +631,7 @@ unsafe fn serialize_object(obj: *const crate::object::ObjectHeader) -> Serialize } else { 0 }; - let field_count = (*obj).field_count as usize; + let field_count = crate::object::object_live_slot_count(obj) as usize; // Serialize field values let fields_ptr = diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 8a3500dee1..71557fc166 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -766,16 +766,19 @@ fn object_shape(addr: usize) -> (usize, u32, u16) { } let class_id = (*ptr).class_id; // #8067 rung 3: every genuine ObjectHeader uses one token domain. - // Runtime allocators birth-stamp objects; the synchronization call is - // a defensive self-heal for old/synthetic callers and never falls back - // to a keys pointer. - let mut shape = crate::object::shapes::object_shape_id(ptr); - if shape == 0 { - shape = crate::object::shapes::synchronize_object_shape_descriptor( - ptr as *mut ObjectHeader, - ); - } - let shape = shape as usize; + // + // #8113 REMOVED the defensive self-heal that used to run here. It + // called `synchronize_object_shape_descriptor`, which derived the live + // inline-slot bound from the header's `field_count` word. That word is + // gone, so a self-heal on an UNSTAMPED receiver would now publish a + // descriptor claiming a bound of ZERO — silently truncating the + // object's traced and writable payload from a read-only observation + // path. Missing closed costs a PIC miss; healing wrongly loses fields. + // + // Nothing is expected to reach here unstamped: every allocator in + // `object/alloc.rs` birth-publishes, and the inline-`new` path stamps a + // module-init ShapeId. + let shape = crate::object::shapes::object_shape_id(ptr) as usize; (shape, class_id, gc_type) } } diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index b421ac202e..44c845571a 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1691,14 +1691,12 @@ fn typed_feedback_class_field_guard_ignores_object_header_shape_mirrors() { let class_id = 0x7EED_8067; let (obj, original_keys, key_x, receiver) = class_instance(class_id, b"x"); let expected_shape_id = shape_id(obj); - let original_field_count = unsafe { (*obj).field_count }; unsafe { - // These are ABI mirrors retained until the later header-shrink issue. - // An authoritative guard must not consult either one. + // `keys_array` is the last ABI mirror (#8113 deleted `field_count`; + // #8047 removes this one). An authoritative guard must not consult it. // GC_STORE_AUDIT(POINTER_FREE): test sabotage removes the compatibility edge by storing null. (*obj).keys_array = std::ptr::null_mut(); - (*obj).field_count = 0; } let passed = js_typed_feedback_class_field_get_guard( 8067, @@ -1717,7 +1715,6 @@ fn typed_feedback_class_field_guard_ignores_object_header_shape_mirrors() { &(*obj).keys_array as *const _ as usize, original_keys as u64, ); - (*obj).field_count = original_field_count; } assert_eq!(passed, 1, "guard must consume ShapeDescriptor facts"); diff --git a/crates/perry-runtime/src/url/url_class.rs b/crates/perry-runtime/src/url/url_class.rs index 952bd94061..c0e1082528 100644 --- a/crates/perry-runtime/src/url/url_class.rs +++ b/crates/perry-runtime/src/url/url_class.rs @@ -447,7 +447,9 @@ pub(crate) fn is_url_object_shape(url: *mut ObjectHeader) -> bool { return false; } unsafe { - if !is_gc_object_header(url) || (*url).class_id != 0 || (*url).field_count < URL_FIELD_COUNT + if !is_gc_object_header(url) + || (*url).class_id != 0 + || crate::object::object_live_slot_count(url) < URL_FIELD_COUNT { return false; } diff --git a/crates/perry-runtime/src/value/dynamic_object.rs b/crates/perry-runtime/src/value/dynamic_object.rs index c8872c9eab..7c1356f0bf 100644 --- a/crates/perry-runtime/src/value/dynamic_object.rs +++ b/crates/perry-runtime/src/value/dynamic_object.rs @@ -408,11 +408,14 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( }; // #7930: TypedArrayHeader starts with `length: u32`, at the same payload - // offset where ObjectHeader stores its object-type word. Classify the - // receiver through the authoritative side table before any header-shaped - // dispatch below: a two-element typed array otherwise reads as - // `OBJECT_TYPE_ERROR == 2`, so `.length` / `.byteLength` enter the Error - // branch and return `undefined` even though construction was correct. + // offset where ObjectHeader used to store its object-type word. Classify + // the receiver through the authoritative side table before any + // header-shaped dispatch below: a two-element typed array otherwise read as + // `OBJECT_TYPE_ERROR == 2`, so `.length` / `.byteLength` entered the Error + // branch and returned `undefined` even though construction was correct. + // (#8113 replaced that raw read with a `GcHeader` kind test, which no + // longer confuses the two — but the side-table classification below is + // still what gives the typed array its property semantics.) // // Delegate to the normal by-name typed-array path rather than duplicating // its property semantics here. It gives an own expando/accessor precedence @@ -434,7 +437,7 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( // Check if this is a ClosureHeader (CLOSURE_MAGIC at offset 12). // ClosureHeader layout: func_ptr (8B), capture_count u32 (4B), type_tag u32 (4B), captures at 16+ - // ObjectHeader layout: object_type u32 (4B), class_id u32 (4B), parent_class_id u32 (4B), field_count u32 (4B), keys_array (8B), ... + // ObjectHeader layout (#8113): class_id u32 (4B), parent_class_id u32 (4B), keys_array (8B), meta (8B) // Without this check, the closure's capture[0] at offset 16 would be read as keys_array → crash. if crate::closure::is_closure_ptr(ptr as usize) { return crate::closure::closure_get_dynamic_prop(ptr as usize, property_name); @@ -537,8 +540,12 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( } } - // Check the object type tag (first u32 field of both ObjectHeader and ErrorHeader) - let object_type = *(ptr as *const u32); + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR`. This used to read the punned + // `object_type` word at offset 0; offset 0 is `class_id` now, so the raw + // read would classify every object whose class id happens to be + // `OBJECT_TYPE_ERROR` (= 2) as an Error and hand its field slots to + // `ErrorHeader`'s accessors. + let is_native_error = crate::error::ptr_is_native_error(ptr as usize); // Handle native module namespace objects (e.g., `const fn = fs.lstatSync`) // Create a bound method closure so the method reference can be called @@ -555,7 +562,7 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( } // Handle Error objects specially - if object_type == crate::error::OBJECT_TYPE_ERROR { + if is_native_error { // An own expando / accessor property (installed via defineProperty, or a // reassigned `message`/`stack`) lives in the exotic side tables and wins // over the builtin slot. The compiled member-get path consults these, @@ -734,11 +741,9 @@ pub unsafe extern "C" fn js_dynamic_object_keys(ptr: i64) -> *mut crate::array:: return crate::array::js_array_alloc(0); } - // Check the object type tag (first u32 field of both ObjectHeader and ErrorHeader) - let object_type = *(ptr as *const u32); - + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR` — see `js_dynamic_get_property`. // Handle Error objects specially - they have fixed keys - if object_type == crate::error::OBJECT_TYPE_ERROR { + if crate::error::ptr_is_native_error(ptr as usize) { // Error objects have keys: "message", "name", "stack" let keys = crate::array::js_array_alloc(3); diff --git a/crates/perry-runtime/src/weakref.rs b/crates/perry-runtime/src/weakref.rs index 4a4b5f19ae..0dba392992 100644 --- a/crates/perry-runtime/src/weakref.rs +++ b/crates/perry-runtime/src/weakref.rs @@ -376,7 +376,7 @@ pub(crate) unsafe fn is_weak_target_trace_slot( // Field 0 is the weak target for both: WeakRef's referent and a // WeakMap/WeakSet entry's key. CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY => { - (*obj).field_count > 0 && slot == object_field_slot(obj, 0) + crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0) } // A finalization record's target (field 0) AND its unregister token // (field 1) are both weak. The spec's [[UnregisterToken]] is an @@ -384,8 +384,9 @@ pub(crate) unsafe fn is_weak_target_trace_slot( // `registry.register(obj, held, obj)` pin the target immortal // (2026-07-09 GC audit). CLASS_ID_FINALIZATION_RECORD => { - ((*obj).field_count > 0 && slot == object_field_slot(obj, 0)) - || ((*obj).field_count > 1 && slot == object_field_slot(obj, 1)) + (crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0)) + || (crate::object::object_live_slot_count(obj) > 1 + && slot == object_field_slot(obj, 1)) } _ => false, } diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index a882ebef41..839ea4ce76 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -356,7 +356,7 @@ pub extern "C" fn js_fetch_response_count() -> i64 { /// rejection. Pre-fix (#236) every fetch error site NaN-boxed a bare /// `*StringHeader` with `POINTER_TAG` (0x7FFD), which the uncaught-exception /// printer in `perry-runtime/src/exception.rs` then read as an -/// `*ObjectHeader.object_type` u32 — `byte_len` of the message string is +/// the first `ObjectHeader` u32 (`class_id` since #8113) — `byte_len` of the message string is /// neither `OBJECT_TYPE_ERROR` (2) nor `OBJECT_TYPE_REGULAR` (1), so the /// printer fell through to the generic stringifier which printed /// `Uncaught exception: [object Object]`. Allocating a real diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs index f69e9f9289..d82efc80fa 100644 --- a/crates/perry-stdlib/src/worker_threads.rs +++ b/crates/perry-stdlib/src/worker_threads.rs @@ -673,9 +673,14 @@ fn message_value_is_uncloneable(value: f64, visited: &mut HashSet) -> boo let Some(object) = object_ptr_from_value(value) else { return false; }; + // #8113: the header no longer carries `field_count`; the authoritative live + // inline-slot bound is the ShapeId descriptor's, exposed as + // `object_live_slot_count`. The `keys_array.is_null()` arm is deliberate — + // class instances have no keys array, and `js_object_keys` filters private + // `#x` fields, so it is NOT the same set. let field_count = unsafe { if (*object).keys_array.is_null() { - (*object).field_count + perry_runtime::object_live_slot_count(object) } else { perry_runtime::array::js_array_length((*object).keys_array) } diff --git a/crates/perry-ui-android/src/json.rs b/crates/perry-ui-android/src/json.rs deleted file mode 100644 index b69183ed83..0000000000 --- a/crates/perry-ui-android/src/json.rs +++ /dev/null @@ -1,606 +0,0 @@ -//! JSON handling for Android — copied from perry-stdlib/src/framework/json.rs -//! -//! perry-stdlib can't cross-compile for Android (OpenSSL dependency), so we -//! include the essential JSON functions directly. These replace the no-op stubs -//! in stdlib_stubs.rs. - -use perry_runtime::{ - js_array_alloc, js_array_push, js_object_alloc, js_object_set_field, js_object_set_keys, - js_string_from_bytes, JSValue, StringHeader, -}; -use std::fmt::Write as FmtWrite; - -// ─── Zero-copy string access ────────────────────────────────────────────────── - -#[inline] -unsafe fn str_from_header<'a>(ptr: *const StringHeader) -> Option<&'a str> { - if ptr.is_null() { - return None; - } - let len = (*ptr).byte_len as usize; - let data_ptr = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data_ptr, len); - Some(std::str::from_utf8_unchecked(bytes)) -} - -unsafe fn string_from_header(ptr: *const StringHeader) -> Option { - str_from_header(ptr).map(|s| s.to_string()) -} - -// ─── Direct JSON parser ──────────────────────────────────────────────────────── - -struct DirectParser<'a> { - input: &'a [u8], - pos: usize, -} - -impl<'a> DirectParser<'a> { - fn new(input: &'a [u8]) -> Self { - Self { input, pos: 0 } - } - - #[inline] - fn peek(&self) -> Option { - self.input.get(self.pos).copied() - } - - #[inline] - fn advance(&mut self) { - self.pos += 1; - } - - #[inline] - fn skip_whitespace(&mut self) { - while self.pos < self.input.len() { - match self.input[self.pos] { - b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1, - _ => break, - } - } - } - - #[inline] - fn expect(&mut self, ch: u8) -> bool { - self.skip_whitespace(); - if self.peek() == Some(ch) { - self.advance(); - true - } else { - false - } - } - - unsafe fn parse_value(&mut self) -> JSValue { - self.skip_whitespace(); - match self.peek() { - Some(b'"') => self.parse_string_value(), - Some(b'{') => self.parse_object(), - Some(b'[') => self.parse_array(), - Some(b't') => self.parse_true(), - Some(b'f') => self.parse_false(), - Some(b'n') => self.parse_null(), - Some(c) if c == b'-' || c.is_ascii_digit() => self.parse_number(), - _ => JSValue::null(), - } - } - - unsafe fn parse_string_value(&mut self) -> JSValue { - if let Some(s) = self.parse_string_bytes() { - let ptr = js_string_from_bytes(s.as_ptr(), s.len() as u32); - JSValue::string_ptr(ptr) - } else { - JSValue::null() - } - } - - fn parse_string_bytes(&mut self) -> Option> { - if self.peek() != Some(b'"') { - return None; - } - self.advance(); - - let mut result = Vec::new(); - loop { - if self.pos >= self.input.len() { - return None; - } - let ch = self.input[self.pos]; - self.pos += 1; - match ch { - b'"' => return Some(result), - b'\\' => { - if self.pos >= self.input.len() { - return None; - } - let esc = self.input[self.pos]; - self.pos += 1; - match esc { - b'"' => result.push(b'"'), - b'\\' => result.push(b'\\'), - b'/' => result.push(b'/'), - b'n' => result.push(b'\n'), - b'r' => result.push(b'\r'), - b't' => result.push(b'\t'), - b'b' => result.push(0x08), - b'f' => result.push(0x0C), - b'u' => { - if self.pos + 4 > self.input.len() { - return None; - } - let hex = - std::str::from_utf8(&self.input[self.pos..self.pos + 4]).ok()?; - let code = u16::from_str_radix(hex, 16).ok()?; - self.pos += 4; - if (0xD800..=0xDBFF).contains(&code) { - if self.pos + 6 <= self.input.len() - && self.input[self.pos] == b'\\' - && self.input[self.pos + 1] == b'u' - { - let hex2 = std::str::from_utf8( - &self.input[self.pos + 2..self.pos + 6], - ) - .ok()?; - let low = u16::from_str_radix(hex2, 16).ok()?; - self.pos += 6; - let codepoint = 0x10000 - + ((code as u32 - 0xD800) << 10) - + (low as u32 - 0xDC00); - if let Some(c) = char::from_u32(codepoint) { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - result.extend_from_slice(s.as_bytes()); - } - } - } else { - if let Some(c) = char::from_u32(code as u32) { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - result.extend_from_slice(s.as_bytes()); - } - } - } - _ => result.push(esc), - } - } - _ => result.push(ch), - } - } - } - - unsafe fn parse_object(&mut self) -> JSValue { - self.advance(); - self.skip_whitespace(); - - let mut pairs: Vec<(Vec, JSValue)> = Vec::new(); - - if self.peek() == Some(b'}') { - self.advance(); - let js_obj = js_object_alloc(0, 0); - let keys_arr = js_array_alloc(0); - js_object_set_keys(js_obj, keys_arr); - return JSValue::object_ptr(js_obj as *mut u8); - } - - loop { - self.skip_whitespace(); - let key = match self.parse_string_bytes() { - Some(k) => k, - None => break, - }; - - if !self.expect(b':') { - break; - } - - let value = self.parse_value(); - pairs.push((key, value)); - - self.skip_whitespace(); - if self.peek() == Some(b',') { - self.advance(); - } else { - break; - } - } - self.expect(b'}'); - - let count = pairs.len(); - let js_obj = js_object_alloc(0, count as u32); - let keys_arr = js_array_alloc(count as u32); - - for (idx, (key, value)) in pairs.into_iter().enumerate() { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - js_array_push(keys_arr, JSValue::string_ptr(key_ptr)); - js_object_set_field(js_obj, idx as u32, value); - } - js_object_set_keys(js_obj, keys_arr); - JSValue::object_ptr(js_obj as *mut u8) - } - - unsafe fn parse_array(&mut self) -> JSValue { - self.advance(); - self.skip_whitespace(); - - let js_arr = js_array_alloc(16); - - if self.peek() == Some(b']') { - self.advance(); - return JSValue::object_ptr(js_arr as *mut u8); - } - - loop { - let value = self.parse_value(); - js_array_push(js_arr, value); - - self.skip_whitespace(); - if self.peek() == Some(b',') { - self.advance(); - } else { - break; - } - } - self.expect(b']'); - JSValue::object_ptr(js_arr as *mut u8) - } - - unsafe fn parse_number(&mut self) -> JSValue { - let start = self.pos; - if self.peek() == Some(b'-') { - self.advance(); - } - while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { - self.pos += 1; - } - if self.pos < self.input.len() && self.input[self.pos] == b'.' { - self.pos += 1; - while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { - self.pos += 1; - } - } - if self.pos < self.input.len() - && (self.input[self.pos] == b'e' || self.input[self.pos] == b'E') - { - self.pos += 1; - if self.pos < self.input.len() - && (self.input[self.pos] == b'+' || self.input[self.pos] == b'-') - { - self.pos += 1; - } - while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { - self.pos += 1; - } - } - - let num_str = std::str::from_utf8_unchecked(&self.input[start..self.pos]); - let value: f64 = num_str.parse().unwrap_or(0.0); - JSValue::number(value) - } - - unsafe fn parse_true(&mut self) -> JSValue { - if self.pos + 4 <= self.input.len() && &self.input[self.pos..self.pos + 4] == b"true" { - self.pos += 4; - JSValue::bool(true) - } else { - JSValue::null() - } - } - - unsafe fn parse_false(&mut self) -> JSValue { - if self.pos + 5 <= self.input.len() && &self.input[self.pos..self.pos + 5] == b"false" { - self.pos += 5; - JSValue::bool(false) - } else { - JSValue::null() - } - } - - unsafe fn parse_null(&mut self) -> JSValue { - if self.pos + 4 <= self.input.len() && &self.input[self.pos..self.pos + 4] == b"null" { - self.pos += 4; - } - JSValue::null() - } -} - -// ─── NaN-boxing constants ───────────────────────────────────────────────────── - -const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; -const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; -const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -const TYPE_UNKNOWN: u32 = 0; -const TYPE_OBJECT: u32 = 1; -const TYPE_ARRAY: u32 = 2; - -/// #7448: an UNTAGGED heap pointer that reached a type-erased JSON walk. -/// -/// This used to be a hand-rolled bit test: -/// -/// ```ignore -/// exponent == 0 && mantissa != 0 && sign == 0 -/// ``` -/// -/// which is bit-for-bit the IEEE-754 POSITIVE-SUBNORMAL predicate, so every -/// positive denormal `Number` was classified as a pointer and dereferenced. In -/// the main runtime the identical code SIGSEGV'd on `JSON.stringify(1e-317)` -/// and returned a silent `null` for `5e-324`, both reachable from untrusted -/// input through `JSON.stringify(JSON.parse(text))` (#7447). -/// -/// No bit test can fix it: a raw pointer and a positive subnormal occupy the -/// same bit patterns by construction, which is why the runtime's version went -/// through two failed narrowings (`top16 < 0x7FF8`, then `top16 == 0`) before -/// landing on allocation membership. So this asks the runtime instead of -/// keeping a third divergent copy — `ptr_is_tracked_heap_object` decides from -/// the page map and the malloc registry, both dereference-free, so a forged or -/// unmapped address is rejected before any field is read. -#[inline] -unsafe fn extract_pointer(bits: u64) -> Option<*const u8> { - let tag = bits & 0xFFFF_0000_0000_0000; - if tag == POINTER_TAG { - Some((bits & POINTER_MASK) as *const u8) - } else if perry_runtime::json::ptr_is_tracked_heap_object(bits as *const u8) { - Some(bits as *const u8) - } else { - None - } -} - -#[inline] -unsafe fn is_object_pointer(ptr: *const u8) -> bool { - let obj = ptr as *const perry_runtime::ObjectHeader; - let potential_keys_ptr = (*obj).keys_array as u64; - let top_16_bits = potential_keys_ptr >> 48; - let is_likely_heap_pointer = top_16_bits == 0 || top_16_bits == 1; - let looks_like_valid_pointer = - is_likely_heap_pointer && potential_keys_ptr > 0x10000 && (potential_keys_ptr & 0x7) == 0; - - if looks_like_valid_pointer { - let keys_arr = (*obj).keys_array; - let keys_len = (*keys_arr).length; - let keys_cap = (*keys_arr).capacity; - let field_count = (*obj).field_count; - keys_len <= keys_cap - && keys_len > 0 - && keys_cap < 1000 - && field_count == keys_len - && field_count < 1000 - } else { - false - } -} - -#[inline] -unsafe fn write_number(buf: &mut String, value: f64) { - if value.is_nan() || value.is_infinite() { - buf.push_str("null"); - } else if value.fract() == 0.0 && value.abs() < (i64::MAX as f64) { - let mut itoa_buf = itoa::Buffer::new(); - buf.push_str(itoa_buf.format(value as i64)); - } else { - let mut ryu_buf = ryu::Buffer::new(); - buf.push_str(ryu_buf.format(value)); - } -} - -#[inline] -unsafe fn write_escaped_string(buf: &mut String, s: &str) { - buf.push('"'); - let bytes = s.as_bytes(); - let mut start = 0; - for (i, &b) in bytes.iter().enumerate() { - let escape = match b { - b'"' => Some("\\\""), - b'\\' => Some("\\\\"), - b'\n' => Some("\\n"), - b'\r' => Some("\\r"), - b'\t' => Some("\\t"), - 0..=0x1f => { - if start < i { - buf.push_str(&s[start..i]); - } - let _ = write!(buf, "\\u{:04x}", b); - start = i + 1; - continue; - } - _ => None, - }; - if let Some(esc) = escape { - if start < i { - buf.push_str(&s[start..i]); - } - buf.push_str(esc); - start = i + 1; - } - } - if start < bytes.len() { - buf.push_str(&s[start..]); - } - buf.push('"'); -} - -unsafe fn stringify_value(value: f64, type_hint: u32, buf: &mut String) { - let bits: u64 = value.to_bits(); - - if bits == TAG_NULL { - buf.push_str("null"); - return; - } - if bits == TAG_TRUE { - buf.push_str("true"); - return; - } - if bits == TAG_FALSE { - buf.push_str("false"); - return; - } - - let tag = bits & 0xFFFF_0000_0000_0000; - if tag == STRING_TAG { - let str_ptr = (bits & POINTER_MASK) as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - return; - } - - if let Some(ptr) = extract_pointer(bits) { - if type_hint == TYPE_OBJECT { - stringify_object(ptr, buf); - return; - } - if type_hint == TYPE_ARRAY { - stringify_array(ptr, buf); - return; - } - if is_object_pointer(ptr) { - stringify_object(ptr, buf); - } else { - let arr = ptr as *const perry_runtime::ArrayHeader; - if !arr.is_null() { - let len = (*arr).length; - let cap = (*arr).capacity; - if len <= cap && cap > 0 && cap < 10000 { - stringify_array(ptr, buf); - return; - } - } - let str_ptr = ptr as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } - return; - } - - write_number(buf, value); -} - -unsafe fn stringify_object(ptr: *const u8, buf: &mut String) { - let obj = ptr as *const perry_runtime::ObjectHeader; - let num_fields = (*obj).field_count; - buf.push('{'); - - let keys_arr = (*obj).keys_array; - let keys_len = (*keys_arr).length; - let keys_elements = (keys_arr as *const u8) - .add(std::mem::size_of::()) - as *const f64; - let fields_ptr = - (ptr as *const u8).add(std::mem::size_of::()) as *const f64; - - for f in 0..num_fields { - if f > 0 { - buf.push(','); - } - if (f as u32) < keys_len { - let key_f64 = *keys_elements.add(f as usize); - let key_bits = key_f64.to_bits(); - let key_tag = key_bits & 0xFFFF_0000_0000_0000; - let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG { - (key_bits & POINTER_MASK) as *const StringHeader - } else { - key_bits as *const StringHeader - }; - if let Some(key_str) = str_from_header(key_ptr) { - buf.push('"'); - buf.push_str(key_str); - buf.push_str("\":"); - } else { - let _ = write!(buf, "\"field{}\":", f); - } - } else { - let _ = write!(buf, "\"field{}\":", f); - } - let field_val = *fields_ptr.add(f as usize); - stringify_value(field_val, TYPE_UNKNOWN, buf); - } - buf.push('}'); -} - -unsafe fn stringify_array(ptr: *const u8, buf: &mut String) { - let arr = ptr as *const perry_runtime::ArrayHeader; - let len = (*arr).length; - let elements = - (ptr as *const u8).add(std::mem::size_of::()) as *const f64; - - buf.push('['); - for i in 0..len { - if i > 0 { - buf.push(','); - } - let elem = *elements.add(i as usize); - let elem_bits = elem.to_bits(); - let elem_tag = elem_bits & 0xFFFF_0000_0000_0000; - - if elem_tag == STRING_TAG { - let str_ptr = (elem_bits & POINTER_MASK) as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } else if elem_bits == TAG_NULL { - buf.push_str("null"); - } else if elem_bits == TAG_TRUE { - buf.push_str("true"); - } else if elem_bits == TAG_FALSE { - buf.push_str("false"); - // #7448 converted the object path to `extract_pointer` but left this - // array-element path calling the `is_raw_pointer` it deleted, so - // perry-ui-android stopped compiling for any Android target. Routing - // it through the same helper also gives array elements the fix the - // object path already had: the old bit test was the IEEE-754 - // positive-subnormal predicate, so every positive denormal element was - // classified as a pointer and dereferenced (#7447). - } else if let Some(elem_ptr) = extract_pointer(elem_bits) { - if is_object_pointer(elem_ptr) { - stringify_object(elem_ptr, buf); - } else { - let arr_elem = elem_ptr as *const perry_runtime::ArrayHeader; - let arr_len = (*arr_elem).length; - let arr_cap = (*arr_elem).capacity; - if arr_len <= arr_cap && arr_cap > 0 && arr_cap < 10000 { - stringify_array(elem_ptr, buf); - } else { - let str_ptr = elem_ptr as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } - } - } else { - write_number(buf, elem); - } - } - buf.push(']'); -} - -#[inline] -unsafe fn estimate_json_size(value: f64, type_hint: u32) -> usize { - let bits = value.to_bits(); - if let Some(ptr) = extract_pointer(bits) { - if type_hint == TYPE_ARRAY || (!is_object_pointer(ptr) && type_hint != TYPE_OBJECT) { - let arr = ptr as *const perry_runtime::ArrayHeader; - let len = (*arr).length as usize; - return (len * 300).max(256); - } - if type_hint == TYPE_OBJECT || is_object_pointer(ptr) { - let obj = ptr as *const perry_runtime::ObjectHeader; - let fields = (*obj).field_count as usize; - return (fields * 200).max(256); - } - } - 4096 -} - -// ─── Exported FFI functions ─────────────────────────────────────────────────── -// js_json_* functions are now provided by perry-runtime/json.rs diff --git a/crates/perry-ui-android/src/lib.rs b/crates/perry-ui-android/src/lib.rs index f3f0b140f5..2cf555b3a4 100644 --- a/crates/perry-ui-android/src/lib.rs +++ b/crates/perry-ui-android/src/lib.rs @@ -24,7 +24,6 @@ pub mod geisterhand_style; pub mod geolocation; pub mod image_picker; pub mod jni_bridge; -pub mod json; pub mod keyboard; pub mod keychain; pub mod location; diff --git a/docs/object-write-matrix.md b/docs/object-write-matrix.md index a7e43410c6..38333ee51a 100644 --- a/docs/object-write-matrix.md +++ b/docs/object-write-matrix.md @@ -22,8 +22,11 @@ lack of benefit. `proxy/put_value.rs::js_put_value_set_ic_miss`): static (interned/const) key, target ≡ receiver expression, safepoint-free RHS, heap object, non-forwarded, blocking flags clear (frozen/sealed/no-extend/descriptors/ - typed-intact), `object_type == REGULAR`, **`class_id != 0`**, shape-token - match (id-or-keys discriminated), slot in bounds. + typed-intact), **`class_id != 0`** OR the runtime's plain-ordinary birth flag + (#8098), shape-token match on the ShapeId, slot in bounds. (The + `object_type == REGULAR` term this list used to carry was already stale — the + emitted precheck reads `class_id` and the ShapeId, never offset 0 — and #8113 + deleted the word.) 3. **Runtime fast path** (header-first classification + existing-own-data overwrite routing in `js_object_set_field_by_name` / `put_value_set`): everything else that is still an ordinary data write. diff --git a/docs/src/platforms/watchos.md b/docs/src/platforms/watchos.md index fef41f7e5e..11202ee324 100644 --- a/docs/src/platforms/watchos.md +++ b/docs/src/platforms/watchos.md @@ -104,11 +104,14 @@ them into a fat binary — see [Publishing to the App Store](watchos-app-store.m > whose layout includes a pointer shifts on arm64_32 — e.g. `ClosureHeader`'s > `type_tag` sits at +12 after an 8-byte `func_ptr` on 64-bit but at +8 after a > 4-byte one on ILP32, and `ObjectHeader`'s field region starts at +24 on 64-bit -> but +20 on ILP32 (the trailing `keys_array` pointer is 4 bytes). NEVER hardcode +> but +16 on ILP32 (both trailing pointers — `keys_array` and `meta` — are 4 +> bytes there). Those two numbers were +32/+24 until #8113 deleted the header's +> `object_type` and `field_count` words; that is exactly why they must be +> derived, not written down. NEVER hardcode > such an offset: in `perry-runtime` use `std::mem::offset_of!` / `size_of` > (these track the target); in `perry-codegen` (which runs on the host but emits > for the target) derive it from the target triple via `crate::target_layout`. -> Hardcoded `12` (closure magic) and `24` (`ObjectHeader` size) were the original +> Hardcoded `12` (closure magic) and a hardcoded `ObjectHeader` size were the original > arm64_32 startup-crash root causes — a real getter failed its `CLOSURE_MAGIC` > probe, was judged non-callable, and the resulting `TypeError` value-coercion > dereferenced the closure as an object. diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 27c8cc7288..b0156607e1 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -239,7 +239,6 @@ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/get_field_by_ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/has_property.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/ic_miss.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs | 2 -lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/tail.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/array_error.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/fetch_globals.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/typed_array.rs | 1 diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index ebb69b1ff3..487b1204c7 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 -"""#8067 exact shape-header census plus authority-order sabotage tests.""" +"""#8067/#8113 exact shape-header census plus authority-order sabotage tests. + +#8113 deleted `ObjectHeader::object_type` and `::field_count`, so `keys_array` +is the last compatibility mirror and the only field this census tracks. The +deleted pair is now guarded structurally instead: `assert_header_fields` pins +the exact declared field list, so re-adding a word is red rather than merely +un-baselined. +""" from __future__ import annotations @@ -13,7 +20,11 @@ ROOT = Path(__file__).resolve().parents[1] BASELINE_PATH = ROOT / "scripts" / "shape_descriptor_census_baseline.json" -FIELDS = ("object_type", "field_count", "keys_array") +FIELDS = ("keys_array",) +# The exact `ObjectHeader` field list, in order. #8113 took it from six fields +# (32 bytes LP64) to four (24). Changing it is an ABI change with a published +# crates.io mirror (`perry-ffi`), so it must be a deliberate edit here too. +OBJECT_HEADER_FIELDS = ("class_id", "parent_class_id", "keys_array", "meta") RAW_STRING_START = re.compile(r'(?:br|r)(?P#{0,255})"') RUST_SPECIAL = re.compile( r"//|/\*|(?:b)?'(?:\\(?:x[0-9A-Fa-f]{2}|u\{[0-9A-Fa-f_]+\}|.)|[^'\\\n])'|(?:br|r)#{0,255}\"|(?:b|c)?\"" @@ -214,6 +225,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: authority_paths = ( "crates/perry-runtime/src/object/shapes.rs", "crates/perry-runtime/src/object/mod.rs", + "crates/perry-runtime/src/object/live_slots.rs", "crates/perry-codegen/src/lower_call/new_alloc.rs", "crates/perry-runtime/src/gc/layout_slot_visit.rs", "crates/perry-runtime/src/object/field_set_by_name/tail.rs", @@ -238,6 +250,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: clean = stripped_sources({path: sources[path] for path in authority_paths}) shapes = clean["crates/perry-runtime/src/object/shapes.rs"] object_mod = clean["crates/perry-runtime/src/object/mod.rs"] + live_slots = clean["crates/perry-runtime/src/object/live_slots.rs"] codegen_alloc = clean["crates/perry-codegen/src/lower_call/new_alloc.rs"] layout_visit = clean["crates/perry-runtime/src/gc/layout_slot_visit.rs"] transition_tail = clean[ @@ -341,13 +354,37 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: "inner.ids_by_facts.entry", "by-id descriptor before reverse accelerator", ) - sync = function_body(shapes, "synchronize_object_shape_descriptor_from") + sync = function_body(shapes, "publish_object_shape_from") assert_before( sync, "shape_descriptor_ensure", "(*obj).parent_class_id = id", "descriptor before ObjectHeader ShapeId", ) + # #8113 MINT-THEN-STAMP. With `field_count` deleted, the descriptor is the + # only record of the live inline-slot bound, so a stamp-cleared window is a + # window in which the collector traces ZERO payload slots. No publication + # path may clear, and the only surviving `clear_object_shape_stamp` must be + # test-only. + for name in ( + "publish_object_shape_from", + "publish_object_live_slot_count", + "birth_publish_object_shape", + "stamp_object_shape", + "birth_stamp_object_shape", + ): + if "clear_object_shape_stamp" in function_body(shapes, name): + raise CensusError(f"{name} clears the shape stamp: the live-slot bound has no mirror") + if "clear_object_shape_stamp" in function_body(object_mod, "set_object_keys_array_with_live"): + raise CensusError( + "set_object_keys_array_with_live clears the shape stamp: " + "the live-slot bound has no mirror" + ) + if not re.search( + r"#\[cfg\(test\)\]\s*\n\s*#\[inline\]\s*\n\s*pub\(crate\) unsafe fn clear_object_shape_stamp", + shapes, + ): + raise CensusError("clear_object_shape_stamp escaped its #[cfg(test)] gate") retirement = function_body(shapes, "retain_key_count_versions") require_code( retirement, @@ -362,7 +399,27 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if "descriptors.remove" in function_body(shapes, name): raise CensusError(f"{name} eagerly deletes a sibling descriptor") - require_code(object_mod, r"\bfn\s+set_object_live_slot_count\b", "central live-slot publication helper") + require_code( + live_slots, + r"\bfn\s+set_object_live_slot_count\b", + "central live-slot publication helper", + ) + # #8113: that helper must delegate to the mint-then-stamp primitive, not + # write a header word of its own (there is no longer one to write). + require_code( + function_body(live_slots, "set_object_live_slot_count"), + r"shapes::publish_object_live_slot_count\s*\(", + "live-slot publication goes through mint-then-stamp", + ) + # #8113: the derived bound has no header mirror, so it must come from the + # descriptor and fail CLOSED (0) when there is none. + live_body = function_body(live_slots, "object_live_slot_count") + require_code( + live_body, + r"live_inline_slot_count", + "live-slot bound derived from the ShapeId descriptor", + ) + require_code(live_body, r"unwrap_or\s*\(\s*0\s*\)", "live-slot bound fails closed") alloc_body = function_body(codegen_alloc, "emit_instance_alloc_inner") require_code(alloc_body, r"\bdescriptor_facts_exact\b", "raw-inline exact-facts admission gate") @@ -455,7 +512,17 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if re.search(r"else\s*\{\s*(?:keys|\(\s*\*\s*obj\s*\)\.keys_array)\s+as\s+u64", body): raise CensusError(f"{label} reintroduced a keys-pointer token") - # Emitted guards must not read the three payload offsets #8047 will remove. + # Emitted guards may read exactly two header offsets: `class_id` @0 and the + # ShapeId @4. Everything at or past 8 is a mirror (`keys_array` @8, `meta` + # @16) that #8047 removes, and reading one as a shape fact is the bug this + # census exists to catch. + # + # #8113 also fixed this arm's VACUITY. It used to match only + # `add(..., "N")`, while all four functions below emit + # `gep(I8, &p, &[(I64, "N")])` — so planting a keys-offset read left it + # green. Both spellings are matched now, and each function must be shown to + # read the ShapeId at all, so a guard that stops reading the header + # entirely cannot pass by emitting nothing. for source, names in ( (raw_class_guard, ( "emit_class_field_loop_preheader_check", @@ -466,22 +533,28 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ): for name in names: body = function_body(source, name) - # Match BOTH emission forms. These four guards build their header - # address with `blk.gep(I8, &p, &[(I64, "N")])`, not `add(..)`, so - # an `add`-only pattern was vacuous for every function in this - # list -- planting `gep(I8, &elem_ptr, &[(I64, "16")])` in - # `emit_element_shape_field_load` left the census green. - if re.search( - r"expected_keys" - r"|add\s*\([^\n]*\"(?:0|12|16)\"" - r"|gep\s*\([^\n]*\(\s*I64\s*,\s*\"(?:0|12|16)\"\s*\)", - body, - ): + # #8113: `class_id` lives at offset 0 and the ShapeId at 4 now, so + # the offset rule is `forbidden_header_offsets` (below), which + # encodes the removed words' offsets for the CURRENT layout and + # matches both the `add(..)` and `gep(I8, ..)` spellings. + if re.search(r"expected_keys", body): raise CensusError(f"{name} emits a removed ObjectHeader fact") + if forbidden_header_offsets(body): + raise CensusError(f"{name} emits a removed ObjectHeader fact") + require_code( + body, + r"\(\s*I64\s*,\s*\"4\"\s*\)", + f"{name} reads the authoritative ShapeId at header offset 4", + ) generic_body = function_body(raw_generic_pic, "lower_generic_property_get") - if re.search(r"add\s*\(\s*I64\s*,\s*&obj_handle\s*,\s*\"(?:12|16)\"", generic_body): + if re.search(r"add\s*\(\s*I64\s*,\s*&obj_handle\s*,\s*\"(?:8|16)\"", generic_body): raise CensusError("generic read PIC emits a removed ObjectHeader fact") + require_code( + generic_body, + r"add\s*\(\s*I64\s*,\s*&obj_handle\s*,\s*\"4\"\s*\)", + "generic read PIC reads the authoritative ShapeId at header offset 4", + ) require_code( generic_body, r"select\s*\(\s*I1\s*,\s*&is_stamp\s*,\s*I64\s*,\s*&id_token\s*,\s*\"0\"\s*\)", @@ -489,8 +562,13 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) for name in ("lower_put_value_static_write_ic", "lower_put_value_dyn_ic_inline"): body = function_body(raw_write_pics, name) - if re.search(r"add\s*\(\s*I64\s*,\s*&(safe_target|t_handle)\s*,\s*\"(?:12|16)\"", body): + if re.search(r"add\s*\(\s*I64\s*,\s*&(safe_target|t_handle)\s*,\s*\"(?:8|16)\"", body): raise CensusError(f"{name} emits a removed ObjectHeader fact") + require_code( + body, + r"add\s*\(\s*I64\s*,\s*&(?:safe_target|t_handle)\s*,\s*\"4\"\s*\)", + f"{name} reads the authoritative ShapeId at header offset 4", + ) require_code(gc_types, r"GC_TYPE_REGEXP\s*:\s*u8", "RegExp external discriminator") regexp_info_match = re.search( @@ -512,6 +590,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") + assert_header_fields(object_mod) class_probe = function_body(object_mod, "object_is_regular") require_code( class_probe, @@ -520,6 +599,47 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) +def forbidden_header_offsets(body: str) -> list[str]: + """Positive `ObjectHeader` byte offsets an emitted guard must not address. + + Matches both emitter spellings: a gep index tuple `(I64, "N")` and an + `add(I64, &base, "N")`. `sub(...)` is deliberately NOT matched — it is how + the GcHeader bytes at -8/-7/-6 are reached — and neither is a negative + literal. + """ + forbidden = {"8", "16"} + gep = re.findall(r'\(\s*I64\s*,\s*"(-?\d+)"\s*\)', body) + add = re.findall(r'\.add\s*\(\s*I64\s*,\s*&\w[\w.]*\s*,\s*"(-?\d+)"\s*\)', body) + return sorted({off for off in gep + add if off in forbidden}) + + +def assert_header_fields(object_mod: str) -> None: + """Pin `ObjectHeader`'s exact declared field list (#8113). + + The multiset census only sees fields named in `FIELDS`, so re-adding a + `field_count` word would slip past it entirely. This does not: the header is + an ABI with a published crates.io mirror (`perry-ffi::ObjectHeader`) and a + runtime revision constant (`perry_object_header_abi_revision`), and a change + here has to be made on purpose in all three places. + """ + match = re.search( + r"pub struct ObjectHeader\s*\{(?P[^}]*)\}", + object_mod, + ) + if not match: + raise CensusError("shape descriptor authority surface missing: ObjectHeader declaration") + fields = tuple(re.findall(r"pub\s+(\w+)\s*:", match.group("body"))) + if fields != OBJECT_HEADER_FIELDS: + raise CensusError( + "ObjectHeader field list changed: " + f"{fields} != {OBJECT_HEADER_FIELDS}. This is an ABI change — update " + "OBJECT_HEADER_FIELDS here, perry-ffi's mirror + " + "OBJECT_HEADER_ABI_REVISION, perry_object_header_abi_revision(), " + "target_layout::object_header_size_bytes, and the emitted header " + "offsets, in one commit." + ) + + def swap_once(source: str, left: str, right: str) -> str: left_at = source.find(left) right_at = source.find(right) @@ -593,7 +713,7 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) inverted_publication = dict(sources) path = "crates/perry-runtime/src/object/shapes.rs" publication_body = function_body( - inverted_publication[path], "synchronize_object_shape_descriptor_from" + inverted_publication[path], "publish_object_shape_from" ) inverted_body = swap_once( publication_body, @@ -632,7 +752,7 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) legacy_ir = dict(sources) path = "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs" legacy_body, substitutions = re.subn( - r'add\(I64, &obj_handle, "8"\)', + r'add\(I64, &obj_handle, "4"\)', 'add(I64, &obj_handle, "16")', legacy_ir[path], count=1, @@ -645,6 +765,57 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(legacy_ir), ) + # #8113: the gep-spelled emitted guards. This arm was VACUOUS before — + # it matched only `add(..., "N")` — so plant a keys-offset gep and prove + # it is caught now. + gep_ir = dict(sources) + path = "crates/perry-codegen/src/expr/class_field_inline_guard.rs" + gep_body, substitutions = re.subn( + r'gep\(I8, &obj_ptr, &\[\(I64, "4"\)\]\)', + 'gep(I8, &obj_ptr, &[(I64, "8")])', + gep_ir[path], + count=1, + ) + if substitutions != 1: + raise CensusError("gep emitted-offset sabotage fixture missing") + gep_ir[path] = gep_body + expect_rejected( + "keys-array header offset in a gep-spelled emitted guard", + lambda: assert_authority_surfaces(gep_ir), + ) + + # #8113: re-adding a deleted header word must be red, not merely + # un-baselined (the multiset census cannot see a field it does not track). + readded_field = dict(sources) + path = "crates/perry-runtime/src/object/mod.rs" + readded_field[path] = readded_field[path].replace( + " pub keys_array: *mut ArrayHeader,", + " pub field_count: u32,\n pub keys_array: *mut ArrayHeader,", + 1, + ) + expect_rejected( + "re-added ObjectHeader payload word", + lambda: assert_authority_surfaces(readded_field), + ) + + # #8113: a re-introduced clear-then-remint window. + cleared_publication = dict(sources) + path = "crates/perry-runtime/src/object/shapes.rs" + cleared_body = function_body(cleared_publication[path], "publish_object_live_slot_count") + cleared_publication[path] = cleared_publication[path].replace( + cleared_body, + cleared_body.replace( + "let predecessor = object_shape_descriptor(obj);", + "let predecessor = object_shape_descriptor(obj);\n clear_object_shape_stamp(obj);", + 1, + ), + 1, + ) + expect_rejected( + "clear-then-remint window in the live-slot publication", + lambda: assert_authority_surfaces(cleared_publication), + ) + stale_summary = json.loads(json.dumps(baseline)) stale_summary["summary"]["raw_member_files"] += 1 expect_rejected( diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index a3f7e73134..5f1a027a14 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -10,69 +10,38 @@ "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(": 3, "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 1, "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, - "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, - "crates/perry-codegen/src/expr/proxy_reflect.rs|let header_bytes = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, + "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, "crates/perry-codegen/src/lower_call/scalar_method.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs|8 + crate::target_layout::object_header_size_bytes( ) + 8 * slots;": 1, "crates/perry-codegen/src/stmt/loops.rs|let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, - "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 24);": 2, - "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 32);": 4, + "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 16);": 2, + "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 24);": 4, "crates/perry-codegen/src/target_layout.rs|let total = 8 + object_header_size_bytes(triple) + 8 * INLINE_SLOT_FLOOR;": 1, + "crates/perry-codegen/src/target_layout.rs|object_header_size_bytes(triple) % 8,": 1, "crates/perry-codegen/src/target_layout.rs|pub fn object_header_size_bytes(target_triple: &str) -> u64 {": 1 }, "raw_member_callsite_multiset": { - "crates/perry-codegen/src/lower_call/typed_shape_init.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-codegen/tests/native_proof_regressions.rs|field_count|declaration|let loop_body = |field_count: usize| {": 1, - "crates/perry-ext-events/src/lib.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, - "crates/perry-ext-ws/src/lib.rs|field_count|access|let n = (*ptr).field_count;": 1, - "crates/perry-ffi/src/jsvalue.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-ffi/src/jsvalue.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, - "crates/perry-ffi/src/jsvalue.rs|field_count|declaration|fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, "crates/perry-ffi/src/jsvalue.rs|keys_array|declaration|fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader);": 1, - "crates/perry-ffi/src/types.rs|field_count|declaration|pub field_count: u32,": 1, "crates/perry-ffi/src/types.rs|keys_array|declaration|pub keys_array: *mut ArrayHeader,": 1, - "crates/perry-ffi/src/types.rs|object_type|declaration|pub object_type: u32,": 1, "crates/perry-runtime/src/builtins/console.rs|keys_array|access|let keys_array = (*obj_ptr).keys_array;": 2, "crates/perry-runtime/src/builtins/formatting.rs|keys_array|access|let _keys_array = (*obj_ptr).keys_array;": 1, "crates/perry-runtime/src/builtins/formatting.rs|keys_array|access|let keys_array = (*obj_ptr).keys_array;": 2, - "crates/perry-runtime/src/builtins/formatting/util_format.rs|field_count|access|let num_fields = (*obj).field_count;": 1, "crates/perry-runtime/src/builtins/formatting/util_format.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, - "crates/perry-runtime/src/builtins/globals.rs|field_count|access|for i in 0..field_count as usize {": 1, - "crates/perry-runtime/src/builtins/globals.rs|field_count|access|if key_count > (*src_obj).field_count as usize {": 1, - "crates/perry-runtime/src/builtins/globals.rs|field_count|access|let field_count = (*cloned_obj).field_count;": 1, "crates/perry-runtime/src/builtins/globals.rs|keys_array|access|let keys_now = (*src_now).keys_array;": 1, "crates/perry-runtime/src/builtins/globals.rs|keys_array|access|let src_keys = (*src_obj).keys_array;": 1, "crates/perry-runtime/src/builtins/table.rs|keys_array|access|let keys_array = (*obj_ptr).keys_array;": 1, - "crates/perry-runtime/src/child_process/v8_serde.rs|field_count|access|let num_fields = (*obj).field_count;": 1, "crates/perry-runtime/src/child_process/v8_serde.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, - "crates/perry-runtime/src/cluster.rs|field_count|declaration|fn alloc_object_value(field_count: u32) -> f64 {": 1, - "crates/perry-runtime/src/dyn_eval/env.rs|field_count|access|let alloc_limit = std::cmp::max((*o).field_count, crate::object::INLINE_SLOT_FLOOR as u32);": 1, "crates/perry-runtime/src/dyn_eval/env.rs|keys_array|access|let keys = (*o).keys_array;": 1, - "crates/perry-runtime/src/error.rs|object_type|access|(*ptr).object_type = OBJECT_TYPE_ERROR;": 1, - "crates/perry-runtime/src/error.rs|object_type|declaration|pub object_type: u32,": 1, "crates/perry-runtime/src/fs/dirent.rs|keys_array|access|let keys = (*obj_ptr).keys_array;": 1, - "crates/perry-runtime/src/gc/heap_snapshot.rs|field_count|access|.unwrap_or((*obj).field_count as usize)": 1, "crates/perry-runtime/src/gc/heap_snapshot.rs|keys_array|access|.unwrap_or((*obj).keys_array as u64);": 1, - "crates/perry-runtime/src/gc/layout.rs|field_count|access|.unwrap_or((*obj_header).field_count as usize);": 1, - "crates/perry-runtime/src/gc/layout.rs|field_count|access|.unwrap_or((*object).field_count as usize);": 2, "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*obj_header).keys_array as usize);": 1, "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*object).keys_array as usize)": 1, - "crates/perry-runtime/src/gc/layout_slot_visit.rs|field_count|access|.unwrap_or((*obj).field_count);": 1, "crates/perry-runtime/src/gc/layout_slot_visit.rs|keys_array|access|.unwrap_or((*obj).keys_array);": 1, "crates/perry-runtime/src/gc/layout_slot_visit.rs|keys_array|access|let new_keys = (*obj).keys_array as u64;": 1, - "crates/perry-runtime/src/gc/tests/alloc.rs|object_type|declaration|object_type: crate::error::OBJECT_TYPE_ERROR,": 1, - "crates/perry-runtime/src/gc/tests/barrier.rs|field_count|access|for i in 0..field_count as usize {": 2, - "crates/perry-runtime/src/gc/tests/barrier.rs|field_count|declaration|unsafe fn field_index_not_on_last_page(fields: *mut u64, field_count: u32) -> usize {": 1, - "crates/perry-runtime/src/gc/tests/barrier.rs|field_count|declaration|unsafe fn field_indices_on_distinct_pages(fields: *mut u64, field_count: u32) -> (usize, usize) {": 1, "crates/perry-runtime/src/gc/tests/copying.rs|keys_array|access|let keys = (*obj_after).keys_array;": 1, - "crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs|field_count|access|unsafe { (*obj).field_count },": 2, - "crates/perry-runtime/src/gc/tests/cycle_state.rs|field_count|access|(*child).field_count = 0;": 1, "crates/perry-runtime/src/gc/tests/cycle_state.rs|keys_array|access|(*child).keys_array = std::ptr::null_mut();": 1, - "crates/perry-runtime/src/gc/tests/cycle_state.rs|object_type|access|(*child).object_type = crate::error::OBJECT_TYPE_REGULAR;": 1, - "crates/perry-runtime/src/gc/tests/cycle_state.rs|object_type|access|(*obj).object_type,": 1, - "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|field_count|access|(*obj).field_count = 0;": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|(*a).keys_array = keys;": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|(*b).keys_array = keys;": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, @@ -83,26 +52,16 @@ "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|assert_eq!(descriptor.keys, (*a_after).keys_array as u64);": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|assert_ne!((*a_after).keys_array as usize, old_keys);": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|let header_keys_slot = unsafe { std::ptr::addr_of_mut!((*owner).keys_array) as *mut u64 };": 2, - "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|object_type|access|(*obj).object_type = 1;": 1, - "crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs|field_count|access|assert_eq!((*obj).field_count, 2);": 1, "crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs|keys_array|access|(*first).keys_array,": 1, "crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs|keys_array|access|(*second).keys_array,": 1, "crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/gc/tests/runtime_roots/transient_handles.rs|keys_array|access|assert!(!(*obj_after).keys_array.is_null());": 1, "crates/perry-runtime/src/gc/tests/runtime_roots/transient_handles.rs|keys_array|access|let key_value = crate::array::js_array_get((*obj_after).keys_array, 0).bits();": 1, - "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|field_count|access|(*obj).field_count = 0;": 1, "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|keys_array|access|assert_eq!((*obj).keys_array as u64, descriptor.keys);": 1, - "crates/perry-runtime/src/gc/tests/support.rs|field_count|access|(*obj).field_count = field_count;": 2, - "crates/perry-runtime/src/gc/tests/support.rs|field_count|access|for i in 0..field_count as usize {": 2, - "crates/perry-runtime/src/gc/tests/support.rs|field_count|declaration|field_count: u32,": 2, "crates/perry-runtime/src/gc/tests/support.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 2, - "crates/perry-runtime/src/gc/tests/support.rs|object_type|access|(*obj).object_type = 1;": 2, - "crates/perry-runtime/src/gc/tests/support.rs|object_type|declaration|object_type: crate::error::OBJECT_TYPE_ERROR,": 1, - "crates/perry-runtime/src/json/mod.rs|field_count|access|(value, (*obj).field_count, (*(*obj).keys_array).length)": 1, - "crates/perry-runtime/src/json/mod.rs|field_count|access|assert!((*obj).field_count >= (*(*obj).keys_array).length);": 1, - "crates/perry-runtime/src/json/mod.rs|keys_array|access|(value, (*obj).field_count, (*(*obj).keys_array).length)": 1, - "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!((*obj).field_count >= (*(*obj).keys_array).length);": 1, + "crates/perry-runtime/src/json/mod.rs|keys_array|access|(*(*obj).keys_array).length,": 1, + "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!(crate::object::object_live_slot_count(obj) >= (*(*obj).keys_array).length);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!(unsafe { (*empty).keys_array.is_null() });": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert_eq!((*(*nested).keys_array).length, 1);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert_eq!((*(*obj).keys_array).length, 2);": 1, @@ -110,60 +69,28 @@ "crates/perry-runtime/src/json/mod.rs|keys_array|access|visitor.visit_raw_mut_ptr_slot(&mut entry.keys_array);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|declaration|keys_array: arr,": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|declaration|pub(crate) keys_array: *mut crate::ArrayHeader,": 1, - "crates/perry-runtime/src/json/parse_api.rs|field_count|declaration|field_count: u32,": 2, - "crates/perry-runtime/src/json/parser.rs|field_count|access|shape.field_count,": 1, - "crates/perry-runtime/src/json/parser.rs|field_count|access|std::cmp::max(shape.field_count as usize, crate::object::INLINE_SLOT_FLOOR);": 1, - "crates/perry-runtime/src/json/parser.rs|field_count|declaration|pub(crate) field_count: u32,": 1, "crates/perry-runtime/src/json/parser.rs|keys_array|access|shape.keys_array,": 1, "crates/perry-runtime/src/json/parser.rs|keys_array|declaration|pub(crate) keys_array: *mut crate::array::ArrayHeader,": 1, - "crates/perry-runtime/src/json/replacer.rs|field_count|access|let num_fields = (*obj).field_count;": 3, - "crates/perry-runtime/src/json/stringify.rs|field_count|access|let field_count = (*obj).field_count;": 1, - "crates/perry-runtime/src/json/stringify.rs|field_count|access|let fields = (*obj).field_count as usize;": 1, - "crates/perry-runtime/src/json/stringify.rs|field_count|access|let num_fields = (*obj).field_count;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|if (*(ptr as *const crate::ObjectHeader)).keys_array.is_null() {": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys = (*(ptr as *const crate::ObjectHeader)).keys_array;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys = (*obj).keys_array as *const crate::ArrayHeader;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys_arr = (*cur_obj()).keys_array;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let potential_keys_ptr = (*obj).keys_array as u64;": 1, - "crates/perry-runtime/src/json/stringify_shape_template.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32)": 1, "crates/perry-runtime/src/json/stringify_shape_template.rs|keys_array|access|if (*obj).keys_array != template.keys_arr.get() {": 1, "crates/perry-runtime/src/json/stringify_shape_template.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, "crates/perry-runtime/src/json/stringify_tojson_probe.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/json/stringify_tojson_probe.rs|keys_array|access|let keys = (*proto).keys_array;": 1, - "crates/perry-runtime/src/json_tape_tests.rs|field_count|access|(*nested).field_count,": 1, - "crates/perry-runtime/src/json_tape_tests.rs|field_count|access|(*object).field_count,": 2, - "crates/perry-runtime/src/navigator.rs|field_count|declaration|let field_count: u32 = 6;": 1, "crates/perry-runtime/src/node_stream_json.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/node_stream_readwrite.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*new_ptr).field_count = 0;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*new_ptr).field_count = src_field_count;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*obj_ptr).field_count = field_count;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*ptr).field_count = field_count;": 5, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*ptr).field_count = logical_field_count as u32;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|let src_field_count = (*src).field_count as usize;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|let src_field_count = (*src_ptr).field_count;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|field_count: u32,": 8, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|fn remember_class_keys_array(class_id: u32, field_count: u32, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|pub extern fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|pub extern fn js_object_alloc_fast(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|pub extern fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|(*new_ptr).keys_array = ptr::null_mut();": 2, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|(*ptr).keys_array = ptr::null_mut();": 3, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|let src_keys = (*src).keys_array;": 2, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|let src_keys_arr = (*src_ptr).keys_array;": 1, "crates/perry-runtime/src/object/alloc.rs|keys_array|declaration|fn remember_class_keys_array(class_id: u32, field_count: u32, keys_array: *mut ArrayHeader) {": 1, "crates/perry-runtime/src/object/alloc.rs|keys_array|declaration|keys_array: *mut ArrayHeader,": 3, - "crates/perry-runtime/src/object/alloc.rs|object_type|access|(*new_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;": 2, - "crates/perry-runtime/src/object/alloc.rs|object_type|access|(*obj_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;": 1, - "crates/perry-runtime/src/object/alloc.rs|object_type|access|(*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;": 6, - "crates/perry-runtime/src/object/arguments.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 2, "crates/perry-runtime/src/object/arguments.rs|keys_array|access|let keys = (*obj).keys_array;": 2, "crates/perry-runtime/src/object/class_registry/parent_static.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/class_registry/parent_static.rs|object_type|access|(*(obj as *mut ObjectHeader)).object_type = crate::error::OBJECT_TYPE_CLASS;": 1, - "crates/perry-runtime/src/object/class_registry/parent_static.rs|object_type|access|(*obj).object_type = crate::error::OBJECT_TYPE_REGULAR;": 1, - "crates/perry-runtime/src/object/delete_rest.rs|field_count|access|assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count);": 2, - "crates/perry-runtime/src/object/delete_rest.rs|field_count|access|let field_count = (*obj).field_count;": 1, "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|(*obj).keys_array,": 1, "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|assert_eq!(descriptor.keys, (*obj).keys_array as u64);": 2, "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|crate::object::shapes::shape_drop((*obj).keys_array);": 1, @@ -172,48 +99,20 @@ "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|let keys_before = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/descriptor_state.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/descriptors.rs|keys_array|access|let keys = (*obj).keys_array;": 2, - "crates/perry-runtime/src/object/field_get_set/accessors.rs|field_count|access|(*obj).field_count": 1, - "crates/perry-runtime/src/object/field_get_set/accessors.rs|field_count|access|let fc = (*obj).field_count;": 1, - "crates/perry-runtime/src/object/field_get_set/accessors.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/field_get_set/accessors.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/field_get_set/enumeration.rs|field_count|access|(*obj).field_count as usize": 2, "crates/perry-runtime/src/object/field_get_set/enumeration.rs|keys_array|access|let keys = (*obj).keys_array;": 3, - "crates/perry-runtime/src/object/field_get_set/field_ops.rs|field_count|access|if field_index >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_get_set/field_ops.rs|field_count|access|let stored_field_count = (*obj).field_count;": 1, "crates/perry-runtime/src/object/field_get_set/field_ops.rs|keys_array|declaration|pub extern fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs|field_count|access|(*o).field_count,": 1, "crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs|keys_array|access|let keys = (*o).keys_array;": 1, - "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs|field_count|access|let _field_count = (*obj).field_count as usize;": 1, - "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/field_get_set/has_property.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/field_get_set/ic_miss.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/field_set_by_name.rs|field_count|access|(*o).field_count,": 1, - "crates/perry-runtime/src/object/field_set_by_name.rs|field_count|access|if slot_idx >= (*o).field_count {": 1, "crates/perry-runtime/src/object/field_set_by_name.rs|keys_array|access|let keys = (*o).keys_array;": 1, - "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|field_count|access|if idx >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|field_count|access|if slot_idx >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 2, "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|keys_array|access|let keys = (*obj).keys_array;": 2, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|if (*obj).field_count == 0 {": 1, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|if new_index as u32 >= (*obj).field_count {": 2, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|if slot_idx >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32)": 1, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/field_set_by_name/tail.rs|keys_array|access|(*obj).keys_array,": 2, "crates/perry-runtime/src/object/field_set_by_name/tail.rs|keys_array|access|let keys = (*obj).keys_array;": 3, - "crates/perry-runtime/src/object/gc_slots.rs|field_count|access|.unwrap_or((*obj).field_count as usize);": 1, "crates/perry-runtime/src/object/gc_slots.rs|keys_array|access|(*obj).keys_array = descriptor.keys as usize as *mut ArrayHeader;": 1, "crates/perry-runtime/src/object/gc_slots.rs|keys_array|access|Some(&mut (*obj).keys_array as *mut _ as *mut u64)": 1, "crates/perry-runtime/src/object/gc_slots.rs|keys_array|access|if (*obj).keys_array.is_null() {": 1, - "crates/perry-runtime/src/object/map_set_subclass.rs|field_count|access|assert_eq!(unsafe { (*obj).field_count }, 3);": 1, - "crates/perry-runtime/src/object/map_set_subclass.rs|object_type|access|assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR);": 5, - "crates/perry-runtime/src/object/mod.rs|field_count|access|(*obj).field_count = field_count;": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|access|if (*obj).field_count != field_count {": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|field_count: 0,": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|pub field_count: u32,": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|pub(super) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) {": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|&(*obj).keys_array as *const _ as usize,": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|(*obj).keys_array = keys_array;": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|crate::gc::runtime_store_root_raw_mut_ptr_slot(&mut entry.keys_array, keys_array);": 2, @@ -222,27 +121,20 @@ "crates/perry-runtime/src/object/mod.rs|keys_array|access|return (entry.keys_array, entry.runtime_shape_id);": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|visitor.visit_raw_mut_ptr_slot(&mut entry.keys_array);": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|fn shape_cache_insert(shape_id: u32, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: *mut ArrayHeader,": 1, - "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: 0,": 1, + "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: *mut ArrayHeader,": 2, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: std::ptr::null_mut(),": 1, - "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: u64,": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|pub keys_array: *mut ArrayHeader,": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|pub(crate) fn test_seed_shape_cache_root(shape_id: u32, keys_array: *mut ArrayHeader) {": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/mod.rs|object_type|declaration|object_type: 1,": 1, - "crates/perry-runtime/src/object/mod.rs|object_type|declaration|object_type: u32,": 1, - "crates/perry-runtime/src/object/mod.rs|object_type|declaration|pub object_type: u32,": 1, "crates/perry-runtime/src/object/namespace_create.rs|keys_array|access|(*obj).keys_array = 0x2800_0203usize as *mut _;": 1, "crates/perry-runtime/src/object/native_call_method/collection_methods.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/native_call_method/handle_methods.rs|keys_array|access|let keys = (*obj).keys_array;": 1, + "crates/perry-runtime/src/object/null_stub.rs|keys_array|declaration|keys_array: 0,": 1, + "crates/perry-runtime/src/object/null_stub.rs|keys_array|declaration|keys_array: u64,": 1, "crates/perry-runtime/src/object/object_ops.rs|keys_array|declaration|pub(crate) use keys_array::{": 1, - "crates/perry-runtime/src/object/object_ops/accessors.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/object_ops/accessors.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs|keys_array|access|let keys = (*(ptr as *const ObjectHeader)).keys_array;": 1, "crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/object_ops/keys_array.rs|field_count|access|if (*obj).field_count == 0 {": 1, - "crates/perry-runtime/src/object/object_ops/keys_array.rs|field_count|access|if new_index < inline_capacity && new_index >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/object_ops/keys_array.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32);": 1, "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|(*obj).keys_array,": 1, "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|assert_eq!((*first).keys_array, (*sibling).keys_array);": 2, "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|assert_eq!(first_descriptor.keys, (*first).keys_array as u64);": 1, @@ -251,12 +143,6 @@ "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|let keys = (*obj).keys_array;": 4, "crates/perry-runtime/src/object/object_ops_frozen.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/reflect_support.rs|keys_array|access|let keys_handle = scope.root_raw_mut_ptr((*obj).keys_array);": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|&& d.live_inline_slot_count == (*obj).field_count": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|(*obj).field_count,": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count);": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|let id = shape_descriptor_ensure(keys, key_count, (*obj).field_count)": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access||| install_external_shape_id(runtime_shape_id, keys, key_count, (*obj).field_count);": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|declaration|field_count: 2,": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|(*a).keys_array,": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|(*b).keys_array,": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|assert_eq!((*b).keys_array, shared_keys);": 1, @@ -265,37 +151,21 @@ "crates/perry-runtime/src/object/shapes.rs|keys_array|access|assert_eq!(transitioned.keys, (*a).keys_array as u64);": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|assert_ne!((*a).keys_array, shared_keys);": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|crate::array::js_array_length((*obj).keys_array)": 1, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|d.keys == (*obj).keys_array as u64": 1, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|debug_assert_object_shape_parity_for_keys(obj, (*obj).keys_array);": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let keys = (*obj).keys_array as usize;": 1, - "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let keys = (*obj).keys_array;": 2, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let shared_keys = (*a).keys_array;": 1, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|publish_object_shape_from(obj, predecessor, (*obj).keys_array, live_inline_slot_count)": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|unsafe { (*obj).keys_array },": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|declaration|keys_array: keys as *mut ArrayHeader,": 1, - "crates/perry-runtime/src/object/shapes.rs|object_type|declaration|object_type: 1,": 1, - "crates/perry-runtime/src/object/spill.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32);": 1, - "crates/perry-runtime/src/object/spill.rs|field_count|declaration|pub(crate) fn reserve_object_spill(obj_ptr: usize, field_count: u32) {": 1, - "crates/perry-runtime/src/os.rs|field_count|declaration|let field_count: u32 = 14;": 1, - "crates/perry-runtime/src/param_type_guard.rs|field_count|access|for _ in 0..field_count {": 1, - "crates/perry-runtime/src/param_type_guard.rs|field_count|access|let inline_fields = ((*object).field_count as usize).max(crate::object::INLINE_SLOT_FLOOR);": 1, - "crates/perry-runtime/src/param_type_guard.rs|field_count|access||| (*object).field_count as usize > MAX_CONTAINER_LEN": 1, "crates/perry-runtime/src/param_type_guard.rs|keys_array|access|let keys = (*object).keys_array;": 1, - "crates/perry-runtime/src/param_type_guard.rs|object_type|access|if (*object).object_type != crate::error::OBJECT_TYPE_REGULAR": 1, "crates/perry-runtime/src/perf_hooks.rs|keys_array|access|let keys_ptr = (*obj).keys_array as usize;": 1, "crates/perry-runtime/src/perf_hooks.rs|keys_array|access|recorded != 0 && (*obj).keys_array as usize == recorded": 1, - "crates/perry-runtime/src/pointer_event.rs|field_count|declaration|let field_count: u32 = 4;": 1, "crates/perry-runtime/src/process/node_module/source_map.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, "crates/perry-runtime/src/promise/then_probe.rs|keys_array|access|let keys = (*obj).keys_array;": 2, - "crates/perry-runtime/src/proxy.rs|object_type|access|&& (*(addr as *const crate::ObjectHeader)).object_type": 1, - "crates/perry-runtime/src/proxy/put_value.rs|field_count|access|object_array_numeric_write_slots(array, &keys[..field_count as usize], receiver_count)": 1, - "crates/perry-runtime/src/proxy/put_value.rs|field_count|access|slots[..field_count as usize]": 1, - "crates/perry-runtime/src/proxy/put_value.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-runtime/src/safe_area.rs|field_count|declaration|let field_count: u32 = 4;": 1, - "crates/perry-runtime/src/thread.rs|field_count|access|for i in 0..field_count {": 1, - "crates/perry-runtime/src/thread.rs|field_count|access|let field_count = (*obj).field_count as usize;": 1, "crates/perry-runtime/src/thread.rs|keys_array|access|let keys = if !(*obj).keys_array.is_null() {": 1, "crates/perry-runtime/src/thread.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, - "crates/perry-runtime/src/typed_feedback/tests.rs|field_count|access|(*obj).field_count = 0;": 1, - "crates/perry-runtime/src/typed_feedback/tests.rs|field_count|access|(*obj).field_count = original_field_count;": 1, - "crates/perry-runtime/src/typed_feedback/tests.rs|field_count|access|let original_field_count = unsafe { (*obj).field_count };": 1, "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|&(*obj).keys_array as *const _ as usize,": 1, "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|(*obj).keys_array = original_keys;": 1, "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, @@ -303,42 +173,14 @@ "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|let keys = unsafe { (*obj).keys_array };": 1, "crates/perry-runtime/src/url/search_params.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, "crates/perry-runtime/src/url/search_params.rs|keys_array|access|let keys_arr = (*params).keys_array;": 1, - "crates/perry-runtime/src/url/url_class.rs|field_count|access|if !is_gc_object_header(url) || (*url).class_id != 0 || (*url).field_count < URL_FIELD_COUNT": 1, - "crates/perry-runtime/src/weakref.rs|field_count|access|((*obj).field_count > 0 && slot == object_field_slot(obj, 0))": 1, - "crates/perry-runtime/src/weakref.rs|field_count|access|(*obj).field_count > 0 && slot == object_field_slot(obj, 0)": 1, - "crates/perry-runtime/src/weakref.rs|field_count|access||| ((*obj).field_count > 1 && slot == object_field_slot(obj, 1))": 1, - "crates/perry-stdlib/src/streams.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, - "crates/perry-stdlib/src/streams.rs|field_count|declaration|fn provider_js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, - "crates/perry-stdlib/src/worker_threads.rs|field_count|access|(*object).field_count": 1, - "crates/perry-stdlib/src/worker_threads.rs|field_count|access|(0..field_count).any(|index| {": 1, "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|if (*object).keys_array.is_null() {": 1, - "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|perry_runtime::array::js_array_length((*object).keys_array)": 1, - "crates/perry-ui-android/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-android/src/json.rs|field_count|access|let field_count = (*obj).field_count;": 1, - "crates/perry-ui-android/src/json.rs|field_count|access|let fields = (*obj).field_count as usize;": 1, - "crates/perry-ui-android/src/json.rs|field_count|access|let num_fields = (*obj).field_count;": 1, - "crates/perry-ui-android/src/json.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, - "crates/perry-ui-android/src/json.rs|keys_array|access|let potential_keys_ptr = (*obj).keys_array as u64;": 1, - "crates/perry-ui-android/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-gtk4/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-gtk4/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-ios/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-ios/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-macos/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-macos/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-tvos/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-visionos/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-visionos/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-windows/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32)": 1, - "crates/perry-ui-windows/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1 + "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|perry_runtime::array::js_array_length((*object).keys_array)": 1 }, "summary": { - "codegen_object_header_size_sites": 32, - "raw_member_files": 101, + "codegen_object_header_size_sites": 33, + "raw_member_files": 65, "raw_member_sites": { - "field_count": 162, - "keys_array": 183, - "object_type": 32 + "keys_array": 183 } } } From 0ee9a4997b12f9985b595a57765824ea8396e8d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 03:50:04 +0200 Subject: [PATCH 02/13] test(object): pin the 48/96-byte footprint and the exact header offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the wide-case (8-slot) footprint assertion — 96 bytes, isolating the header term from the INLINE_SLOT_FLOOR padding term — and an offsets test that names the field that moved rather than only the total. Plus the changelog fragment. Refs #8113. --- .../8122-object-header-shrink-56-to-48.md | 129 ++++++++++++++++++ crates/perry-runtime/src/object/tests.rs | 38 ++++++ 2 files changed, 167 insertions(+) create mode 100644 changelog.d/8122-object-header-shrink-56-to-48.md diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md new file mode 100644 index 0000000000..106fc4f6f0 --- /dev/null +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -0,0 +1,129 @@ +### perf(object): remove the derivable `object_type` and `field_count` header words — 56 B → 48 B + +`ObjectHeader` is now `{class_id @0, parent_class_id @4, keys_array @8, meta @16}` +— **24 bytes on LP64, 16 on ILP32**, down from 32/24. A two-field object literal +costs **48 bytes instead of 56** (`GcHeader 8 + header 24 + 2 slots`), and the +eight-slot case 96 instead of 104. Measured with `rustc -O` on the exact +`#[repr(C)]` shapes: removing **either** word alone saves **zero** — the struct +re-pads — so the two had to go together. This is half of #8047's prize and needs +none of its GC descriptor-rooting work (#8112). + +Both words were derivable from facts the object already carries: + +* the receiver **kind** — ordinary / class / native error — from + `GcHeader.obj_type` plus the immutable ShapeId descriptor's `object_kind`; +* the **live inline-slot bound** from that descriptor's `live_inline_slot_count`. + +#### The offset-0 type confusion this had to disarm + +`ObjectHeader::object_type` was prefix-punned against `error::ErrorHeader`'s +first word, and **nine** sites read raw offset 0 to decide Error-vs-ordinary — +two more than previously catalogued (`promise/rejection.rs:181` and `:464`). +Deleting the word makes offset 0 `class_id`, and `OBJECT_TYPE_ERROR` is **2** +while class ids are handed out from 1, densely, in source-declaration order +(`run_pipeline.rs`: `let mut next_class_id = 1`). Left alone, those reads would +have reclassified **every instance of the second class a program declares** as an +`ErrorHeader` and served `message`/`name`/`stack`/`errors` out of its field +slots — a silent wrong answer of exactly the #8100 shape. Plain object literals +are `class_id == 0`, so the `OBJECT_TYPE_REGULAR` arms would have inverted in +both directions at once. + +All nine now go through `error::ptr_is_native_error()` (`GcHeader.obj_type == +GC_TYPE_ERROR`, the only kind `alloc_error` uses). Five sabotage-shaped +acceptance tests in `object/tests.rs` pin it: each first asserts the confusable +value really is sitting at offset 0, then asserts the answer. Reverting the +discriminator to the raw read turns three of them red. + +`proxy.rs`'s #6595 store-plan gate moves to `object_is_regular()`. #8047's census +warned that this substitution re-opens #6595 — that warning was **stale**: #8086 +rewrote `object_is_regular` to mean exactly `descriptor.object_kind == Ordinary`, +so it is still false for a heap class object. A test pins that too. + +#### Mint-then-stamp + +With `field_count` gone, the ShapeId descriptor is the **only** record of a live +object's slot bound, so a stamp-cleared window is a window in which the collector +traces zero payload slots — a fresh #7154/#7164. Every clear-then-remint sequence +is restructured: `shapes::publish_object_shape_from` mints the successor while +the predecessor stamp is still installed, and the single `parent_class_id` store +(which cannot allocate, hence cannot collect) is the publication point. +`set_object_keys_array`, `set_object_live_slot_count` and +`js_object_delete_field` no longer clear; `shapes::clear_object_shape_stamp` is +now `#[cfg(test)]`, surviving only so tests can manufacture the unstamped state. + +`typed_feedback::object_shape`'s defensive self-heal is **deleted**. It called +`synchronize_object_shape_descriptor`, which derived the bound from the header +word; without that word it would publish `live = 0` for an unstamped receiver — +a read-only observation path silently truncating the object's traced and writable +payload. It misses closed instead. #6804's "no pre/post-stamp token split" +property survives by the stronger route: every allocator birth-publishes, so the +population needing a heal is empty. + +#### Codegen + +`object_header_size_bytes` 32 → 24 (LP64) and 24 → 16 (ILP32). The inline-`new` +path's two packed header stores collapse to one (`class_id ‖ ShapeId`). Eleven +hard-coded IR offsets renumber `class_id @+4` → `@+0` and ShapeId `@+8` → `@+4`; +GcHeader-relative offsets (`-8`/`-7`/`-6`) are untouched. Emitted IR never read +`field_count` — #8067 moved the PIC hit path onto an exact ShapeId match — so +codegen only ever wrote it. + +The two `object_header_size_bytes(..) / 8` word-index sites (`expr/proxy_reflect.rs`, +`stmt/loops.rs`) become byte geps. Both quotients are exact today (24/8, 16/8), +but #8047's ILP32 header is 12 bytes and `12 / 8 == 1` truncates silently; a new +`object_header_size_is_a_whole_number_of_heap_words` test pins the divisibility +rather than the quotient. + +Four codegen doc comments claiming "24 on 64-bit, 20 on ILP32" — wrong since +`meta` landed in #6759 — are corrected, along with `docs/src/platforms/watchos.md` +(the only user-facing statement of the pair), `docs/object-write-matrix.md`, and +`TYPE_LOWERING.md`. + +#### Two gates that could not have caught this + +**`perry-ffi`'s ABI mirror had never executed.** `object_header_matches_runtime` +is `#[cfg(all(test, feature = "runtime-link"))]`, `runtime-link` was enabled +nowhere in `.github/`, and `cargo-test` is a per-package loop with default +features — so the module never compiled. Field *deletion* still went red (an +`offset_of!` on a missing field stops compiling), but a **size or padding +divergence was invisible**, which is precisely this change's failure mode. +`cargo-test` now runs `cargo test -p perry-ffi --features runtime-link --lib` +unconditionally. It earned its keep on the first run: it caught a real bug in +this change — the parity `debug_assert` inside the new mint-then-stamp +publication compared the freshly stamped descriptor against a header keys word +the new ordering has not written yet. + +**`perry-ffi` is published to crates.io.** This is a **breaking ABI change** for +out-of-tree wrappers: one compiled against the old mirror and linked against the +new runtime reads `class_id` out of the deleted `object_type` slot with no +compile error. That cannot be guarded retroactively — the old mirror references +no version symbol, so there is nothing the runtime can withhold. Recorded as a +deliberate break, with a tripwire introduced for the *next* one: +`perry_ffi::OBJECT_HEADER_ABI_REVISION` (= 2) paired with the runtime's +`extern "C" perry_object_header_abi_revision()`, asserted equal by the +now-running mirror test. + +#### Gate updates + +`scripts/shape_descriptor_census.py` narrows to `keys_array` (the last mirror) +and gains three rules the deletion needs: the exact `ObjectHeader` field list, so +re-adding a word is red rather than merely un-baselined; a ban on any publication +path clearing the stamp, plus a check that `clear_object_shape_stamp` stays +`#[cfg(test)]`; and a fixed emitted-guard offset rule. That last one was +**vacuous** — it matched only `add(..., "N")` while all four functions it names +emit `gep(I8, &p, &[(I64, "N")])` — so it now matches both spellings and requires +each guard to be shown reading the ShapeId at all. Three new sabotage self-tests +cover the new rules. + +#### Also + +* `perry-ui-android/src/json.rs` deleted — 606 lines, every function private with + no callers, its own trailing comment saying `js_json_*` now lives in + `perry-runtime/json.rs`. It read `field_count` in three places and is invisible + to CI three ways (`#![cfg(target_os = "android")]`, outside the host-compatible + workspace scope, and the only Android job is `continue-on-error`). +* `NullObjectBytes` gains the `meta` word it has been missing since #6759 — a + `(*obj).meta` read on the unresolved-namespace stub was running 8 bytes past + the end of the static. +* `object/mod.rs` reached the 2000-line cap, so `live_slots.rs` (the bound plus + the ABI revision) and `null_stub.rs` split out. diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 6f6d6aa33c..841a3a1e2a 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -686,6 +686,44 @@ fn two_field_literal_footprint_is_exactly_accounted() { bytes of unusable slots to every small object; re-adding a header word \ re-adds 8 to every object regardless of width" ); + + // #8113 acceptance: the WIDE case too. The floor does not apply at 8 + // fields, so this isolates the header term from the padding term — it is + // the number that says the saving is per-OBJECT, not per-small-object. + let wide_keys = b"a\0b\0c\0d\0e\0f\0g\0h\0"; + let wide = + js_object_alloc_with_shape(0x8113_0008, 8, wide_keys.as_ptr(), wide_keys.len() as u32); + assert!(!wide.is_null()); + let wide_recorded = unsafe { + crate::value::addr_class::try_read_gc_header(wide as usize) + .expect("a freshly allocated object must carry a readable GcHeader") + .size as usize + }; + assert_eq!( + wide_recorded, 96, + "#8113: the 8-slot footprint is 96 bytes (104 before the header shrink)" + ); +} + +/// #8113 acceptance, spelled as offsets rather than a total so a failure names +/// the field that moved. `GcHeader` staying 8 bytes is part of the contract: +/// the whole 8-byte saving is the header's, not a GcHeader change. +#[test] +fn object_header_is_two_words_plus_two_pointers() { + use std::mem::{align_of, offset_of, size_of}; + assert_eq!(crate::gc::GC_HEADER_SIZE, 8); + assert_eq!(size_of::(), 8); + assert_eq!(align_of::(), size_of::<*const u8>()); + assert_eq!(offset_of!(ObjectHeader, class_id), 0); + assert_eq!(offset_of!(ObjectHeader, parent_class_id), 4); + assert_eq!(offset_of!(ObjectHeader, keys_array), size_of::<*const u8>()); + assert_eq!(offset_of!(ObjectHeader, meta), 2 * size_of::<*const u8>()); + assert_eq!(size_of::(), 3 * size_of::<*const u8>()); + // The emitted-IR offsets in perry-codegen are literals; these two are the + // ones `class_field_inline_guard` / `proxy_reflect` / `generic_dispatch` + // splice in, and `stmt/loops.rs` + `expr/proxy_reflect.rs` used to divide + // the size by 8 for a word index. + assert_eq!(size_of::() % 8, 0); } /// Paired with `inline_slot_floor_matches_runtime` in From 5f7fafe2ec1cc0d3323e33ec8186282d3f4a9d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 04:53:49 +0200 Subject: [PATCH 03/13] perf(object): stop paying two shape-table probes per bound read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the 19-program corpus: the first cut of #8113 regressed instructions retired by up to +30% (deeplist +30.5%, cycles +28.4%, tree +25.4%) while delivering the RSS win. The cause was mechanical, not inherent. * Five GC-side sites already read the bound descriptor-first and used the header word as an `unwrap_or` fallback. `unwrap_or` is EAGER, so the substitution made every call do TWO shape-table probes — and one of them, `gc/layout.rs`'s `layout_note_slot`, runs on every object field store. With the word gone the fallback could only return 0, so they now do. * `weakref::is_weak_target_trace_slot` (per traced slot) went from three probes to one. * Six write paths read the bound twice — once for `alloc_limit`, once for the widen test. They read it once. * `object_live_slot_count` gains a 64-way direct-mapped ShapeId -> count memo. It needs no invalidation: ids are never reused and the bound is part of the exact facts an id is minted for. The two test helpers that DO break that premise (`test_clear_shape_table`, `test_drop_shape_descriptors`) clear it. Refs #8113. --- crates/perry-runtime/src/gc/heap_snapshot.rs | 3 +- crates/perry-runtime/src/gc/layout.rs | 13 +- .../perry-runtime/src/gc/layout_slot_visit.rs | 6 +- .../src/object/field_get_set/accessors.rs | 2 +- .../src/object/field_get_set/field_ops.rs | 7 +- .../src/object/field_set_by_name.rs | 6 +- .../object/field_set_by_name/fast_paths.rs | 19 +- .../src/object/field_set_by_name/tail.rs | 10 +- crates/perry-runtime/src/object/live_slots.rs | 166 +++++++++++++++++- crates/perry-runtime/src/object/mod.rs | 2 + .../src/object/object_ops/keys_array.rs | 9 +- crates/perry-runtime/src/object/shapes.rs | 7 + crates/perry-runtime/src/weakref.rs | 20 ++- 13 files changed, 233 insertions(+), 37 deletions(-) diff --git a/crates/perry-runtime/src/gc/heap_snapshot.rs b/crates/perry-runtime/src/gc/heap_snapshot.rs index 3690dd6373..93c89c8405 100644 --- a/crates/perry-runtime/src/gc/heap_snapshot.rs +++ b/crates/perry-runtime/src/gc/heap_snapshot.rs @@ -311,7 +311,8 @@ pub fn gc_build_v8_heap_snapshot_json() -> String { let fc = unsafe { crate::object::shapes::object_shape_descriptor(obj) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(obj) as usize) + // #8113: 0, not a second (eager) descriptor probe. + .unwrap_or(0) }; if fc <= 10_000 { ( diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index c5b7d65820..d836834708 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -216,9 +216,13 @@ unsafe fn with_shape_shared_descriptor( // Defense-in-depth: both descriptor families must agree on the exact live // bound. The ObjectHeader count is only an ABI mirror pending #8047. let object = user_ptr as *const crate::object::ObjectHeader; + // #8113: 0, not a second descriptor probe. `unwrap_or` is EAGER, so + // re-deriving the bound cost a whole extra shape-table lookup on every + // call — and the bound has no other source now, so the fallback could only + // ever have returned 0 anyway. let field_count = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(object) as usize); + .unwrap_or(0); let map = hot_shape_layouts().borrow(); let desc = map.get(&keys)?.as_ref()?; if desc.slot_count != field_count { @@ -688,9 +692,11 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits && (*header).obj_type == GC_TYPE_OBJECT { let object = parent_user as *const crate::object::ObjectHeader; + // #8113: 0, not a second (eager) descriptor probe. This is + // `layout_note_slot`, i.e. every object field store. let live_slots = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(object) as usize); + .unwrap_or(0); if slot_index < live_slots { return; } @@ -1028,9 +1034,10 @@ unsafe fn init_typed_shape_layout( } let obj_header = user_ptr as *const crate::object::ObjectHeader; let shape_descriptor = crate::object::shapes::object_shape_descriptor(obj_header); + // #8113: 0, not a second (eager) descriptor probe. let object_slot_count = shape_descriptor .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(obj_header) as usize); + .unwrap_or(0); if object_slot_count != slot_count { layout_set_typed_unknown(header, user_ptr); return; diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 83b9b8f5fe..0d7088aba1 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -27,7 +27,11 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( .unwrap_or((*obj).keys_array); let live_inline_slot_count = descriptor .map(|facts| facts.live_inline_slot_count) - .unwrap_or(crate::object::object_live_slot_count(obj)); + // #8113: 0, not a second descriptor probe. `unwrap_or` is EAGER, + // so re-deriving the bound here cost a whole extra shape-table + // lookup on every call — and the bound has no other source now, so + // the fallback could only ever have returned 0 anyway. + .unwrap_or(0); if old_keys.is_null() { Some((obj, 0, 0, live_inline_slot_count)) } else if crate::value::addr_class::try_read_tracked_gc_header(old_keys as usize) diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 7a7b6e3837..4241017ce9 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -58,7 +58,7 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 obj, field_index, (*obj).class_id, - crate::object::object_live_slot_count(obj) + fc ); return JSValue::undefined(); } diff --git a/crates/perry-runtime/src/object/field_get_set/field_ops.rs b/crates/perry-runtime/src/object/field_get_set/field_ops.rs index 1b4da41048..7bd16e2d59 100644 --- a/crates/perry-runtime/src/object/field_get_set/field_ops.rs +++ b/crates/perry-runtime/src/object/field_get_set/field_ops.rs @@ -162,7 +162,12 @@ pub extern "C" fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, // is undefined-initialized at allocation (`object/alloc.rs`), so // widening here can only ever expose non-pointer sentinels ahead of // the store that is about to fill this one in. - if field_index >= crate::object::object_live_slot_count(obj) { + // + // #8113: `stored_field_count` is reused rather than re-read. The bound + // is a shape-table probe now, not a header word, and nothing between + // the read above and here can change it (the null-pointer guard only + // substitutes the VALUE). + if field_index >= stored_field_count { set_object_live_slot_count(obj, field_index + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index c4ffe7c349..e9aee248fa 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -160,8 +160,10 @@ pub extern "C" fn js_object_set_field_by_name( }; set_object_keys_array(o, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(o); + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(o); let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(o), + live_slots, crate::object::INLINE_SLOT_FLOOR as u32, ) as usize; if (slot_idx as usize) < alloc_limit { @@ -169,7 +171,7 @@ pub extern "C" fn js_object_set_field_by_name( .add(std::mem::size_of::()) as *mut JSValue; let slot = fields_ptr.add(slot_idx as usize); - if slot_idx >= crate::object::object_live_slot_count(o) { + if slot_idx >= live_slots { set_object_live_slot_count(o, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index 6cc2c33785..f51141343b 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -99,12 +99,11 @@ pub(crate) unsafe fn try_existing_own_data_overwrite( vbits }; super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // #8113: one bound probe, reused. It is a shape-table lookup now. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; if (idx as usize) < alloc_limit { - if idx >= crate::object::object_live_slot_count(obj) { + if idx >= live_slots { set_object_live_slot_count(obj, idx + 1); } store_object_field_slot(obj, idx as usize, vbits); @@ -260,10 +259,10 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = + std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; let slot_usize = slot_idx as usize; let vbits = value.to_bits(); let vbits = if (vbits >> 48) == 0x7FFD && (vbits & 0x0000_FFFF_FFFF_FFFF) == 0 { @@ -273,7 +272,7 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( }; if slot_usize < alloc_limit { - if slot_idx >= crate::object::object_live_slot_count(obj) { + if slot_idx >= live_slots { set_object_live_slot_count(obj, slot_idx + 1); } store_object_field_slot(obj, slot_usize, vbits); diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 8e717383e6..ceac43c09a 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -454,10 +454,10 @@ pub(super) fn set_field_by_name_object_tail( }; set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = + std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; if (slot_idx as usize) < alloc_limit { // Inline the field write — `obj` has already been // validated (GC header read, type check, closure @@ -469,7 +469,7 @@ pub(super) fn set_field_by_name_object_tail( let slot = fields_ptr.add(slot_idx as usize); // Publish the expanded traced range and its exact // descriptor before the pointer-bearing slot value. - if slot_idx >= crate::object::object_live_slot_count(obj) { + if slot_idx >= live_slots { set_object_live_slot_count(obj, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs index e4ac594096..7bdc8a50ba 100644 --- a/crates/perry-runtime/src/object/live_slots.rs +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -28,6 +28,42 @@ pub extern "C" fn perry_object_header_abi_revision() -> u32 { 2 } +/// Direct-mapped `ShapeId -> live_inline_slot_count` memo. +/// +/// The bound used to be a single `u32` load off the header. It is now a +/// shape-table probe — a TLS resolution, a `RefCell` borrow, a SipHash and a +/// bucket walk — on a path that includes every by-index field write. This memo +/// puts a plain array index in front of that. +/// +/// # Why it needs no invalidation +/// +/// Two facts, both load-bearing: +/// +/// * **ShapeIds are never reused.** `shapes::SHAPE_ID_NEXT` is a monotonic +/// process-global counter and exhaustion fail-STOPS (`shape_id_exhausted_abort`), +/// so an id names one fact set for the life of the process. +/// * **`live_inline_slot_count` is part of the exact facts an id is minted +/// for.** `shape_descriptor_ensure_with_generation` dedupes on those facts, so +/// two different bounds get two different ids. The only field ever mutated in +/// place on a published descriptor is `keys` (rewritten by the evacuator), and +/// this memo does not hold it. +/// +/// `prune_dead_shape_keys` can REMOVE an id, which would leave a stale entry — +/// but its documented contract is that "a descriptor removed here cannot be +/// named by a live object", so a stale entry is only reachable through a dead +/// receiver. (Keyless descriptors, whose `keys` is 0, are never pruned: the +/// dead-owner predicate classifies address 0 as not-in-any-heap-space.) +/// +/// The memo is per-thread because descriptor tables are per-agent: a +/// process-global id can name different local facts in two agents +/// (`install_external_shape_id`). +const LIVE_SLOT_MEMO_WAYS: usize = 64; + +thread_local! { + static LIVE_SLOT_MEMO: [std::cell::Cell<(u32, u32)>; LIVE_SLOT_MEMO_WAYS] = + [const { std::cell::Cell::new((0, 0)) }; LIVE_SLOT_MEMO_WAYS]; +} + /// The authoritative live inline-slot bound (#8113: the replacement for the /// deleted `ObjectHeader::field_count` word). /// @@ -38,9 +74,40 @@ pub extern "C" fn perry_object_header_abi_revision() -> u32 { /// object. #[inline] pub unsafe fn object_live_slot_count(obj: *const ObjectHeader) -> u32 { - shapes::object_shape_descriptor(obj) - .map(|descriptor| descriptor.live_inline_slot_count) - .unwrap_or(0) + let shape_id = shapes::object_shape_stamp(obj); + if shape_id == 0 { + return 0; + } + let way = (shape_id as usize) & (LIVE_SLOT_MEMO_WAYS - 1); + LIVE_SLOT_MEMO.with(|memo| { + let entry = &memo[way]; + let (cached_id, cached_count) = entry.get(); + if cached_id == shape_id { + return cached_count; + } + let count = shapes::shape_descriptor_by_id(shape_id) + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0); + // A missing descriptor is NOT cached: it is the fail-closed answer for + // a stale/foreign id, and caching it would make a later legitimate + // install of that id invisible. + if count != 0 { + entry.set((shape_id, count)); + } + count + }) +} + +/// Test hook: drop every memo entry. The memo needs no invalidation in +/// production (see [`LIVE_SLOT_MEMO`]), but a test that plants a synthetic id, +/// drops its descriptor and re-mints under the same id must be able to say so. +#[cfg(test)] +pub(crate) fn test_clear_live_slot_memo() { + LIVE_SLOT_MEMO.with(|memo| { + for entry in memo.iter() { + entry.set((0, 0)); + } + }); } /// C-ABI accessor for [`object_live_slot_count`], for out-of-runtime consumers @@ -87,3 +154,96 @@ pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { pub(crate) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { shapes::publish_object_live_slot_count(obj, field_count); } + +#[cfg(test)] +mod tests { + use super::*; + + /// #8113: the memo must be keyed by ShapeId, and an entry must be REPLACED + /// when a different id maps to the same way. + /// + /// Sabotage-shaped, and the premise is the load-bearing part: two arbitrary + /// shapes get consecutive ids and therefore different ways, so alternating + /// between them proves nothing. This mints enough shapes to FIND a pair + /// that collides, asserts it found one, and only then alternates. Replacing + /// the `cached_id == shape_id` test with `cached_id != 0` turns it red. + #[test] + fn the_live_slot_memo_is_keyed_by_shape_id_not_by_way() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + // Distinct widths so a mixed-up answer is observable, and enough + // shapes that two of them must share one of the 64 ways. + let mut minted: Vec<(*mut ObjectHeader, u32, u32)> = Vec::new(); + for width in 1u32..=(LIVE_SLOT_MEMO_WAYS as u32 + 8) { + let mut packed = Vec::new(); + for i in 0..width { + packed.extend_from_slice(format!("m8113w{width}_{i}").as_bytes()); + packed.push(0); + } + let obj = crate::object::js_object_alloc_with_shape( + 0x8113_2000 + width, + width, + packed.as_ptr(), + packed.len() as u32, + ); + let id = (*obj).parent_class_id; + assert!(shapes::is_shape_id(id)); + minted.push((obj, id, width)); + } + + let mut collision: Option<((*mut ObjectHeader, u32), (*mut ObjectHeader, u32))> = None; + 'outer: for i in 0..minted.len() { + for j in (i + 1)..minted.len() { + let (a, ida, wa) = minted[i]; + let (b, idb, wb) = minted[j]; + if ida != idb + && wa != wb + && (ida as usize) & (LIVE_SLOT_MEMO_WAYS - 1) + == (idb as usize) & (LIVE_SLOT_MEMO_WAYS - 1) + { + collision = Some(((a, wa), (b, wb))); + break 'outer; + } + } + } + let ((a, wa), (b, wb)) = collision.expect( + "test premise: two distinct shapes with different widths must share a memo way", + ); + + // Alternate. A memo that returns whatever is in the way, without + // checking the id, hands one object the other's bound. + for _ in 0..4 { + assert_eq!(object_live_slot_count(a), wa); + assert_eq!(object_live_slot_count(b), wb); + } + } + } + + /// The bound must FOLLOW a re-stamp: growing an object past its birth width + /// mints a successor ShapeId, and the memo is keyed by that id, so the new + /// bound must be visible immediately. + #[test] + fn the_live_slot_memo_follows_a_reshape() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let obj = crate::object::js_object_alloc(0, 1); + let before_id = (*obj).parent_class_id; + assert_eq!(object_live_slot_count(obj), 1); + + let key = crate::string::js_string_from_bytes(b"m8113_grow".as_ptr(), 10); + crate::object::js_object_set_field_by_name(obj, key, 7.0); + let after_id = (*obj).parent_class_id; + assert_ne!( + before_id, after_id, + "test premise: the append re-stamps the receiver" + ); + assert_eq!( + object_live_slot_count(obj), + shapes::shape_descriptor_by_id(after_id) + .expect("successor descriptor") + .live_inline_slot_count, + "the memo must follow the successor ShapeId, not hold the birth bound" + ); + } + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e11a76bf45..71ba0bbb9f 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -96,6 +96,8 @@ mod instanceof; mod live_slots; mod null_stub; pub(crate) use live_slots::set_object_live_slot_count; +#[cfg(test)] +pub(crate) use live_slots::test_clear_live_slot_memo; pub use live_slots::{ js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, perry_object_header_abi_revision, diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 52a52c1136..90d11de5df 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -147,11 +147,10 @@ pub(crate) unsafe fn ensure_key_in_keys_array( // getter here bumped field_count from 8 (the proto's physical capacity) to // 11, exposing the overflowed `values` slot and corrupting the boundary. let new_index = key_count as u32; - let inline_capacity = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ); - if new_index < inline_capacity && new_index >= crate::object::object_live_slot_count(obj) { + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(obj); + let inline_capacity = std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32); + if new_index < inline_capacity && new_index >= live_slots { set_object_live_slot_count(obj, new_index + 1); } } diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 296d11d7a0..00e52a0cda 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -1184,6 +1184,11 @@ pub(crate) fn test_shape_descriptor_count() -> usize { #[cfg(test)] pub(crate) fn test_clear_shape_table() { + // #8113: the live-slot memo is keyed by ShapeId and needs no invalidation + // in production (ids are never reused). A test that wipes the table and + // re-mints from the same id space is exactly the case that breaks that + // premise, so drop it here. + crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); inner.indices.clear(); inner.descriptors.clear(); @@ -1193,6 +1198,8 @@ pub(crate) fn test_clear_shape_table() { #[cfg(test)] pub(crate) fn test_drop_shape_descriptors(keys_id: usize) { + // #8113: see `test_clear_shape_table`. + crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); let stale = inner .ids_by_keys diff --git a/crates/perry-runtime/src/weakref.rs b/crates/perry-runtime/src/weakref.rs index 0dba392992..53ff9ac38b 100644 --- a/crates/perry-runtime/src/weakref.rs +++ b/crates/perry-runtime/src/weakref.rs @@ -372,11 +372,22 @@ pub(crate) unsafe fn is_weak_target_trace_slot( return false; } let obj = (header as *mut u8).add(crate::gc::GC_HEADER_SIZE) as *mut ObjectHeader; - match (*obj).class_id { + let class_id = (*obj).class_id; + if !matches!( + class_id, + CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY | CLASS_ID_FINALIZATION_RECORD + ) { + return false; + } + // #8113: ONE bound lookup. This runs per traced slot, and the bound is a + // shape-table probe now rather than a header word, so the three separate + // reads the arms below used to make were three probes. + let live_slots = crate::object::object_live_slot_count(obj); + match class_id { // Field 0 is the weak target for both: WeakRef's referent and a // WeakMap/WeakSet entry's key. CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY => { - crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0) + live_slots > 0 && slot == object_field_slot(obj, 0) } // A finalization record's target (field 0) AND its unregister token // (field 1) are both weak. The spec's [[UnregisterToken]] is an @@ -384,9 +395,8 @@ pub(crate) unsafe fn is_weak_target_trace_slot( // `registry.register(obj, held, obj)` pin the target immortal // (2026-07-09 GC audit). CLASS_ID_FINALIZATION_RECORD => { - (crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0)) - || (crate::object::object_live_slot_count(obj) > 1 - && slot == object_field_slot(obj, 1)) + (live_slots > 0 && slot == object_field_slot(obj, 0)) + || (live_slots > 1 && slot == object_field_slot(obj, 1)) } _ => false, } From cf4766d8f2b5a8c999ce8b6a8745b3319a06b160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:02:05 +0200 Subject: [PATCH 04/13] =?UTF-8?q?perf(object):=20delete=20the=20ShapeId->c?= =?UTF-8?q?ount=20memo=20=E2=80=94=20measured=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built, sabotage-tested (the way-collision test goes red when the id check is removed) and measured on the 19-program corpus against the same baseline: row with memo without retain +4.26% +3.26% retain_wide +4.46% +2.89% retain_wide1 +4.18% +2.61% deeplist +8.69% +8.20% shapes +1.85% +4.96% Worse on four of the five rows that pay the bound at all, better on one. The memo pays its own TLS resolution and a closure, which is most of what `state()` plus a small `HashMap` probe costs. Deleted rather than left in as an unmeasured configuration; the measurement is kept as a doc comment so the next person does not rebuild it. Refs #8113. --- crates/perry-runtime/src/object/live_slots.rs | 179 ++---------------- crates/perry-runtime/src/object/mod.rs | 2 - crates/perry-runtime/src/object/shapes.rs | 7 - 3 files changed, 16 insertions(+), 172 deletions(-) diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs index 7bdc8a50ba..225206088f 100644 --- a/crates/perry-runtime/src/object/live_slots.rs +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -28,42 +28,6 @@ pub extern "C" fn perry_object_header_abi_revision() -> u32 { 2 } -/// Direct-mapped `ShapeId -> live_inline_slot_count` memo. -/// -/// The bound used to be a single `u32` load off the header. It is now a -/// shape-table probe — a TLS resolution, a `RefCell` borrow, a SipHash and a -/// bucket walk — on a path that includes every by-index field write. This memo -/// puts a plain array index in front of that. -/// -/// # Why it needs no invalidation -/// -/// Two facts, both load-bearing: -/// -/// * **ShapeIds are never reused.** `shapes::SHAPE_ID_NEXT` is a monotonic -/// process-global counter and exhaustion fail-STOPS (`shape_id_exhausted_abort`), -/// so an id names one fact set for the life of the process. -/// * **`live_inline_slot_count` is part of the exact facts an id is minted -/// for.** `shape_descriptor_ensure_with_generation` dedupes on those facts, so -/// two different bounds get two different ids. The only field ever mutated in -/// place on a published descriptor is `keys` (rewritten by the evacuator), and -/// this memo does not hold it. -/// -/// `prune_dead_shape_keys` can REMOVE an id, which would leave a stale entry — -/// but its documented contract is that "a descriptor removed here cannot be -/// named by a live object", so a stale entry is only reachable through a dead -/// receiver. (Keyless descriptors, whose `keys` is 0, are never pruned: the -/// dead-owner predicate classifies address 0 as not-in-any-heap-space.) -/// -/// The memo is per-thread because descriptor tables are per-agent: a -/// process-global id can name different local facts in two agents -/// (`install_external_shape_id`). -const LIVE_SLOT_MEMO_WAYS: usize = 64; - -thread_local! { - static LIVE_SLOT_MEMO: [std::cell::Cell<(u32, u32)>; LIVE_SLOT_MEMO_WAYS] = - [const { std::cell::Cell::new((0, 0)) }; LIVE_SLOT_MEMO_WAYS]; -} - /// The authoritative live inline-slot bound (#8113: the replacement for the /// deleted `ObjectHeader::field_count` word). /// @@ -72,42 +36,24 @@ thread_local! { /// unbounded one, and every runtime allocator publishes a descriptor before its /// header escapes, so the zero case is a raw/synthetic fixture, not a live /// object. +/// +/// # A ShapeId -> count memo in front of this measured NULL (#8113) +/// +/// The bound used to be one `u32` load off the header and is now a shape-table +/// probe, so a 64-way direct-mapped `ShapeId -> count` cache looked like the +/// obvious recovery. It was built, sabotage-tested, and measured on the +/// 19-program corpus against the same baseline: `retain` +4.26% vs +3.26% +/// WITHOUT it, `retain_wide` +4.46% vs +2.89%, `retain_wide1` +4.18% vs +2.61%, +/// `deeplist` +8.69% vs +8.20% — worse on four of the five rows that pay the +/// bound at all, better only on `shapes`. The memo pays its own TLS resolution +/// and a closure, which is most of what `state()` + a small `HashMap` +/// probe costs. It was deleted rather than left in as an unmeasured +/// configuration. #[inline] pub unsafe fn object_live_slot_count(obj: *const ObjectHeader) -> u32 { - let shape_id = shapes::object_shape_stamp(obj); - if shape_id == 0 { - return 0; - } - let way = (shape_id as usize) & (LIVE_SLOT_MEMO_WAYS - 1); - LIVE_SLOT_MEMO.with(|memo| { - let entry = &memo[way]; - let (cached_id, cached_count) = entry.get(); - if cached_id == shape_id { - return cached_count; - } - let count = shapes::shape_descriptor_by_id(shape_id) - .map(|descriptor| descriptor.live_inline_slot_count) - .unwrap_or(0); - // A missing descriptor is NOT cached: it is the fail-closed answer for - // a stale/foreign id, and caching it would make a later legitimate - // install of that id invisible. - if count != 0 { - entry.set((shape_id, count)); - } - count - }) -} - -/// Test hook: drop every memo entry. The memo needs no invalidation in -/// production (see [`LIVE_SLOT_MEMO`]), but a test that plants a synthetic id, -/// drops its descriptor and re-mints under the same id must be able to say so. -#[cfg(test)] -pub(crate) fn test_clear_live_slot_memo() { - LIVE_SLOT_MEMO.with(|memo| { - for entry in memo.iter() { - entry.set((0, 0)); - } - }); + shapes::object_shape_descriptor(obj) + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0) } /// C-ABI accessor for [`object_live_slot_count`], for out-of-runtime consumers @@ -154,96 +100,3 @@ pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { pub(crate) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { shapes::publish_object_live_slot_count(obj, field_count); } - -#[cfg(test)] -mod tests { - use super::*; - - /// #8113: the memo must be keyed by ShapeId, and an entry must be REPLACED - /// when a different id maps to the same way. - /// - /// Sabotage-shaped, and the premise is the load-bearing part: two arbitrary - /// shapes get consecutive ids and therefore different ways, so alternating - /// between them proves nothing. This mints enough shapes to FIND a pair - /// that collides, asserts it found one, and only then alternates. Replacing - /// the `cached_id == shape_id` test with `cached_id != 0` turns it red. - #[test] - fn the_live_slot_memo_is_keyed_by_shape_id_not_by_way() { - let _lock = crate::gc::global_side_table_test_lock(); - unsafe { - // Distinct widths so a mixed-up answer is observable, and enough - // shapes that two of them must share one of the 64 ways. - let mut minted: Vec<(*mut ObjectHeader, u32, u32)> = Vec::new(); - for width in 1u32..=(LIVE_SLOT_MEMO_WAYS as u32 + 8) { - let mut packed = Vec::new(); - for i in 0..width { - packed.extend_from_slice(format!("m8113w{width}_{i}").as_bytes()); - packed.push(0); - } - let obj = crate::object::js_object_alloc_with_shape( - 0x8113_2000 + width, - width, - packed.as_ptr(), - packed.len() as u32, - ); - let id = (*obj).parent_class_id; - assert!(shapes::is_shape_id(id)); - minted.push((obj, id, width)); - } - - let mut collision: Option<((*mut ObjectHeader, u32), (*mut ObjectHeader, u32))> = None; - 'outer: for i in 0..minted.len() { - for j in (i + 1)..minted.len() { - let (a, ida, wa) = minted[i]; - let (b, idb, wb) = minted[j]; - if ida != idb - && wa != wb - && (ida as usize) & (LIVE_SLOT_MEMO_WAYS - 1) - == (idb as usize) & (LIVE_SLOT_MEMO_WAYS - 1) - { - collision = Some(((a, wa), (b, wb))); - break 'outer; - } - } - } - let ((a, wa), (b, wb)) = collision.expect( - "test premise: two distinct shapes with different widths must share a memo way", - ); - - // Alternate. A memo that returns whatever is in the way, without - // checking the id, hands one object the other's bound. - for _ in 0..4 { - assert_eq!(object_live_slot_count(a), wa); - assert_eq!(object_live_slot_count(b), wb); - } - } - } - - /// The bound must FOLLOW a re-stamp: growing an object past its birth width - /// mints a successor ShapeId, and the memo is keyed by that id, so the new - /// bound must be visible immediately. - #[test] - fn the_live_slot_memo_follows_a_reshape() { - let _lock = crate::gc::global_side_table_test_lock(); - unsafe { - let obj = crate::object::js_object_alloc(0, 1); - let before_id = (*obj).parent_class_id; - assert_eq!(object_live_slot_count(obj), 1); - - let key = crate::string::js_string_from_bytes(b"m8113_grow".as_ptr(), 10); - crate::object::js_object_set_field_by_name(obj, key, 7.0); - let after_id = (*obj).parent_class_id; - assert_ne!( - before_id, after_id, - "test premise: the append re-stamps the receiver" - ); - assert_eq!( - object_live_slot_count(obj), - shapes::shape_descriptor_by_id(after_id) - .expect("successor descriptor") - .live_inline_slot_count, - "the memo must follow the successor ShapeId, not hold the birth bound" - ); - } - } -} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 71ba0bbb9f..e11a76bf45 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -96,8 +96,6 @@ mod instanceof; mod live_slots; mod null_stub; pub(crate) use live_slots::set_object_live_slot_count; -#[cfg(test)] -pub(crate) use live_slots::test_clear_live_slot_memo; pub use live_slots::{ js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, perry_object_header_abi_revision, diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 00e52a0cda..296d11d7a0 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -1184,11 +1184,6 @@ pub(crate) fn test_shape_descriptor_count() -> usize { #[cfg(test)] pub(crate) fn test_clear_shape_table() { - // #8113: the live-slot memo is keyed by ShapeId and needs no invalidation - // in production (ids are never reused). A test that wipes the table and - // re-mints from the same id space is exactly the case that breaks that - // premise, so drop it here. - crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); inner.indices.clear(); inner.descriptors.clear(); @@ -1198,8 +1193,6 @@ pub(crate) fn test_clear_shape_table() { #[cfg(test)] pub(crate) fn test_drop_shape_descriptors(keys_id: usize) { - // #8113: see `test_clear_shape_table`. - crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); let stale = inner .ids_by_keys From 017149b018c940c0bba729fb255cd81bd5f4bea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:12:08 +0200 Subject: [PATCH 05/13] chore(object): drop the unused object_inline_alloc_limit helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was added with the rest of #8113's live-slot API and never called: every alloc_limit site computes max(bound, INLINE_SLOT_FLOOR) from a bound it already has in hand after the CSE pass. Removing an uncalled function cannot change the generated code — verified: libperry_runtime.a stays byte-identical to the artifact the corpus numbers were measured on. Refs #8113. --- crates/perry-runtime/src/object/live_slots.rs | 12 ------------ crates/perry-runtime/src/object/mod.rs | 3 +-- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs index 225206088f..3aa6423ab5 100644 --- a/crates/perry-runtime/src/object/live_slots.rs +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -9,7 +9,6 @@ use super::shapes; use super::ObjectHeader; -use super::INLINE_SLOT_FLOOR; /// Revision of the [`ObjectHeader`] ABI, paired with /// `perry_ffi::OBJECT_HEADER_ABI_REVISION`. @@ -70,17 +69,6 @@ pub unsafe extern "C" fn js_object_live_slot_count(obj: *const ObjectHeader) -> object_live_slot_count(obj) } -/// The OOB bound every by-index field write is checked against: -/// `max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. Every allocator reserves -/// at least `INLINE_SLOT_FLOOR` physical slots (`object/alloc.rs`), and -/// `live_inline_slot_count` is a fixed point of the same expression — the -/// by-name append path only ever bumps it for a slot it placed inline — so this -/// can never exceed the physical slot count. -#[inline] -pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { - std::cmp::max(object_live_slot_count(obj), INLINE_SLOT_FLOOR as u32) -} - /// Publish a new authoritative live-inline-slot bound. /// /// #8113 MINT-THEN-STAMP. There is no longer a header word to fall back on, so diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index e11a76bf45..5cd839d2de 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -97,8 +97,7 @@ mod live_slots; mod null_stub; pub(crate) use live_slots::set_object_live_slot_count; pub use live_slots::{ - js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, - perry_object_header_abi_revision, + js_object_live_slot_count, object_live_slot_count, perry_object_header_abi_revision, }; pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; pub(crate) use null_stub::{NullObjectBytes, NULL_OBJECT_BYTES}; From 710f8818ff52941264afcdda61fc5b32658e8647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:18:44 +0200 Subject: [PATCH 06/13] docs(changelog): record the measured corpus result for #8113 --- .../8122-object-header-shrink-56-to-48.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md index 106fc4f6f0..5c0d2692ee 100644 --- a/changelog.d/8122-object-header-shrink-56-to-48.md +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -115,6 +115,53 @@ emit `gep(I8, &p, &[(I64, "N")])` — so it now matches both spellings and requi each guard to be shown reading the ShapeId at all. Three new sabotage self-tests cover the new rules. +#### Measured + +19-program corpus, both arms built from one worktree with `-p perry +-p perry-runtime-static -p perry-stdlib-static`, `PERRY_RUNTIME_DIR` pinned per +arm, the two `libperry_runtime.a` files `cmp`-verified to differ, all 19 stdout +byte-compared against `expected/` and exit-checked in both arms: + +| prog | Δ instructions | Δ peak RSS | +|---|---:|---:| +| `retain` | +3.26% | **−9.10%** | +| `retain1` | +7.99% | −5.29% | +| `retain_wide` | +2.89% | −5.45% | +| `retain_wide1` | +2.61% | −6.04% | +| `tree` | +0.54% | **−12.79%** | +| `tree_wide` | +0.44% | −6.30% | +| `deeplist` | +8.20% | −4.19% | +| `shapes` | +4.96% | −0.61% | +| `churn_alloc` / `push_cls` | +4.3% | ~0 | +| `fib40` / `push_num` / `churn_read` | ~0 | ~0 | + +The rows with no object population move by ~0 — that is the control. The +instruction cost is the price of the change: the bound is a shape-table probe +where it used to be a `u32` load. + +Two findings worth carrying forward, both from measuring rather than assuming: + +1. The first cut regressed instructions by up to **+30%** (`deeplist` +30.5%, + `cycles` +28.4%, `tree` +25.4%). Five GC-side sites already read the bound + descriptor-first with the header word as an `unwrap_or` fallback — and + **`unwrap_or` is eager**, so the substitution made each do *two* shape-table + probes, one of them (`gc/layout.rs`'s `layout_note_slot`) on every object + field store. Fixed, along with `weakref::is_weak_target_trace_slot` (three + probes per traced slot → one) and six write paths that read the bound twice. +2. A 64-way direct-mapped `ShapeId → count` memo — sound without invalidation, + and the obvious recovery — **measured null and was deleted**: `retain` +4.26% + with it vs +3.26% without, `retain_wide` +4.46% vs +2.89%, better only on + `shapes`. Its first sabotage test was *vacuous* (two arbitrary shapes get + consecutive ids and so never share a memo way) and the sabotage run caught + that. The numbers survive as a doc comment so it is not rebuilt. + +GC canaries (`retain`/`tree`/`churn`/`shapes` × plain / `FORCE_EVACUATE` + +`VERIFY_EVACUATION` / `FORCE_EVACUATE` + `PROTECT_FROMSPACE DEPTH=32`, all under +`PERRY_GC_DIAG=1`): all exit 0 and byte-exact, with `copied_objects > 0` or +`promoted_objects > 0` on every row (`retain` copies 368,635 and promotes 2.1 M), +and the protect arm printing 8 `[gc-fromspace-protect]` lines against +`copying_minors=8` — so no arm is vacuous. + #### Also * `perry-ui-android/src/json.rs` deleted — 606 lines, every function private with From 5d7e11b6e3d6bbed7198e3b32e10f14bf9e48a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:21:22 +0200 Subject: [PATCH 07/13] perf(proxy): stop re-deriving a GcHeader the store-plan gate already holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-callsite counter (#[track_caller] + libc::atexit, on tls_hot.rs's pattern) found `object_is_regular` firing EXACTLY ONCE PER ALLOCATED OBJECT from proxy.rs's #6595 store-plan gate: 3,000,000 calls on retain, 20,000,002 on churn, and still 1.00 per object on retain_wide's 8-field literals — the per-object, flat-in-width signature the corpus showed. That gate used to be `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free u32 compare on the word this rung deleted. The call site has already read the very same GcHeader for its blocking-flags test, so `object_is_regular_with_header` takes it instead of re-deriving it through `try_read_gc_header` (handle-band check, heap-range check, small-buffer-slab check, reload). The predicate is character-for-character unchanged, so #6595 stays closed. `interned != 0` — a free compare that sat AFTER the probe in the && chain — moves ahead of it. The remaining shape-table probe is NOT removed here: every cheap substitute (the narrow PLAIN_ORDINARY_OBJ_FLAG birth marker, a global has-class-objects short-circuit) changes the answer for some receiver class, and that is a #6595-adjacent design call rather than a mechanical fix. The census follows the predicate to its new home and gains a sabotage test that the two spellings cannot drift. Refs #8113. --- crates/perry-runtime/src/object/mod.rs | 28 ++++++++++++++++++++++++++ crates/perry-runtime/src/proxy.rs | 18 ++++++++++++++--- scripts/shape_descriptor_census.py | 26 ++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 5cd839d2de..9e7be50866 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1753,6 +1753,34 @@ pub(crate) unsafe fn object_is_regular(obj: *const ObjectHeader) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { return false; }; + object_is_regular_with_header(header, obj) +} + +/// [`object_is_regular`] for a caller that has ALREADY read the receiver's +/// `GcHeader`. The predicate is character-for-character the same — this only +/// moves where the header comes from. +/// +/// # Why it exists (#8113) +/// +/// `proxy.rs`'s #6595 store-plan gate used to be +/// `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free `u32` compare on a word +/// this rung deleted. Its replacement, `object_is_regular`, is the correct +/// predicate — but it is a `try_read_gc_header` (band check, heap-range check, +/// small-buffer-slab check, then the load) plus a shape-table probe, and a +/// per-callsite counter measured it firing **exactly once per allocated +/// object**: 3,000,000 on `retain`, 20,000,002 on `churn`, and still 1.00 per +/// object on `retain_wide`'s 8-field literals. That gate is the single largest +/// piece of this rung's instruction cost. +/// +/// The caller there has already read the very same `GcHeader` for its +/// blocking-flags test, so passing it in deletes the whole re-derivation at +/// zero semantic cost. The remaining shape-table probe is a real design +/// question (#6595 forbids weakening the predicate) and is tracked separately. +#[inline] +pub(crate) unsafe fn object_is_regular_with_header( + header: &crate::gc::GcHeader, + obj: *const ObjectHeader, +) -> bool { header.obj_type == crate::gc::GC_TYPE_OBJECT && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 && shapes::object_shape_descriptor(obj) diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 81abd6d37f..40fa87765d 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1542,6 +1542,12 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) addr, ) && class_id != crate::object::NATIVE_MODULE_CLASS_ID + // #8113: `interned != 0` is a free compare and + // moves AHEAD of the descriptor probe below — + // an un-interned key can never be plan-eligible, + // so there is no reason to pay for the receiver + // test first. + && interned != 0 // #8113: this asks for ORDINARY specifically — // it must stay FALSE for a class object or // #6595 reopens. `object_is_regular` is exactly @@ -1550,10 +1556,16 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) // deleted `object_type == OBJECT_TYPE_REGULAR` // word expressed, not the weaker // "is an ObjectHeader" test. - && crate::object::object_is_regular( + // + // `_with_header` because `header` above IS this + // receiver's `GcHeader`: re-deriving it here + // cost a band/heap-range/slab classification + // plus a reload, once per allocated object, + // measured by a per-callsite counter. + && crate::object::object_is_regular_with_header( + header, addr as *const crate::ObjectHeader, - ) - && interned != 0; + ); let verdict = if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) { diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 487b1204c7..cdcccd8df3 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -473,7 +473,16 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # RegExp identity lives in the GcHeader kind. No ObjectHeader payload word # or registry/magic conjunction may decide these ordinary-object forks. - for name in ("object_is_regular", "object_is_shaped"): + # #8113: `object_is_regular` delegates its predicate to + # `object_is_regular_with_header` so `proxy.rs`'s store-plan gate can pass a + # `GcHeader` it has already read. The two must not drift, so the delegation + # itself is asserted and the predicate is checked where it now lives. + require_code( + function_body(object_mod, "object_is_regular"), + r"object_is_regular_with_header\s*\(", + "object_is_regular delegates to the header-taking form", + ) + for name in ("object_is_regular_with_header", "object_is_shaped"): body = function_body(object_mod, name) require_code(body, r"obj_type\s*==\s*crate::gc::GC_TYPE_OBJECT", f"{name} GC kind") if re.search(r"regex_header_has_magic|object_type", body): @@ -591,7 +600,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") assert_header_fields(object_mod) - class_probe = function_body(object_mod, "object_is_regular") + class_probe = function_body(object_mod, "object_is_regular_with_header") require_code( class_probe, r"ShapeObjectKind::Ordinary", @@ -816,6 +825,19 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(cleared_publication), ) + # #8113: the two spellings of the ordinary-object predicate must not drift. + undelegated = dict(sources) + path = "crates/perry-runtime/src/object/mod.rs" + undelegated[path] = undelegated[path].replace( + " object_is_regular_with_header(header, obj)\n", + " header.obj_type == crate::gc::GC_TYPE_OBJECT\n", + 1, + ) + expect_rejected( + "object_is_regular stopped delegating to the header-taking form", + lambda: assert_authority_surfaces(undelegated), + ) + stale_summary = json.loads(json.dumps(baseline)) stale_summary["summary"]["raw_member_files"] += 1 expect_rejected( From b2668291f9bfca7101c5fe18afbf5c0058b51934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:34:14 +0200 Subject: [PATCH 08/13] Revert "perf(proxy): stop re-deriving a GcHeader the store-plan gate already holds" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 599fe97ee. The change was argued to be semantically free — same predicate, strictly less work — and it MEASURED as a reproducible regression: row pre-fix post-fix (3-run best-of, quiet host) interp +0.29% +9.59% pipeline +0.34% +4.43% retain +3.26% +3.04% deeplist +8.20% +9.31% It did not help the rows the per-callsite counter said it would (retain moved 3.26 -> 3.04, inside noise) and it cost ~1.25 BILLION instructions on interp. The predicate is provably unchanged (same `&&` chain over pure operands, and the removed `try_read_gc_header` had already been performed by the caller), so the mechanism is a codegen/inlining effect, not semantics — plausibly the inlined shape probe bloating proxy.rs's hot path for interpreter-shaped workloads. That is a hypothesis, not a finding. Reverting rather than shipping an unexplained regression under a 'free' label. The underlying cost is real and localised; it belongs in the follow-up issue with the other two candidates, where it can be measured on its own. Refs #8113. --- crates/perry-runtime/src/object/mod.rs | 28 -------------------------- crates/perry-runtime/src/proxy.rs | 18 +++-------------- scripts/shape_descriptor_census.py | 26 ++---------------------- 3 files changed, 5 insertions(+), 67 deletions(-) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 9e7be50866..5cd839d2de 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1753,34 +1753,6 @@ pub(crate) unsafe fn object_is_regular(obj: *const ObjectHeader) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { return false; }; - object_is_regular_with_header(header, obj) -} - -/// [`object_is_regular`] for a caller that has ALREADY read the receiver's -/// `GcHeader`. The predicate is character-for-character the same — this only -/// moves where the header comes from. -/// -/// # Why it exists (#8113) -/// -/// `proxy.rs`'s #6595 store-plan gate used to be -/// `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free `u32` compare on a word -/// this rung deleted. Its replacement, `object_is_regular`, is the correct -/// predicate — but it is a `try_read_gc_header` (band check, heap-range check, -/// small-buffer-slab check, then the load) plus a shape-table probe, and a -/// per-callsite counter measured it firing **exactly once per allocated -/// object**: 3,000,000 on `retain`, 20,000,002 on `churn`, and still 1.00 per -/// object on `retain_wide`'s 8-field literals. That gate is the single largest -/// piece of this rung's instruction cost. -/// -/// The caller there has already read the very same `GcHeader` for its -/// blocking-flags test, so passing it in deletes the whole re-derivation at -/// zero semantic cost. The remaining shape-table probe is a real design -/// question (#6595 forbids weakening the predicate) and is tracked separately. -#[inline] -pub(crate) unsafe fn object_is_regular_with_header( - header: &crate::gc::GcHeader, - obj: *const ObjectHeader, -) -> bool { header.obj_type == crate::gc::GC_TYPE_OBJECT && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 && shapes::object_shape_descriptor(obj) diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 40fa87765d..81abd6d37f 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1542,12 +1542,6 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) addr, ) && class_id != crate::object::NATIVE_MODULE_CLASS_ID - // #8113: `interned != 0` is a free compare and - // moves AHEAD of the descriptor probe below — - // an un-interned key can never be plan-eligible, - // so there is no reason to pay for the receiver - // test first. - && interned != 0 // #8113: this asks for ORDINARY specifically — // it must stay FALSE for a class object or // #6595 reopens. `object_is_regular` is exactly @@ -1556,16 +1550,10 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) // deleted `object_type == OBJECT_TYPE_REGULAR` // word expressed, not the weaker // "is an ObjectHeader" test. - // - // `_with_header` because `header` above IS this - // receiver's `GcHeader`: re-deriving it here - // cost a band/heap-range/slab classification - // plus a reload, once per allocated object, - // measured by a per-callsite counter. - && crate::object::object_is_regular_with_header( - header, + && crate::object::object_is_regular( addr as *const crate::ObjectHeader, - ); + ) + && interned != 0; let verdict = if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) { diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index cdcccd8df3..487b1204c7 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -473,16 +473,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # RegExp identity lives in the GcHeader kind. No ObjectHeader payload word # or registry/magic conjunction may decide these ordinary-object forks. - # #8113: `object_is_regular` delegates its predicate to - # `object_is_regular_with_header` so `proxy.rs`'s store-plan gate can pass a - # `GcHeader` it has already read. The two must not drift, so the delegation - # itself is asserted and the predicate is checked where it now lives. - require_code( - function_body(object_mod, "object_is_regular"), - r"object_is_regular_with_header\s*\(", - "object_is_regular delegates to the header-taking form", - ) - for name in ("object_is_regular_with_header", "object_is_shaped"): + for name in ("object_is_regular", "object_is_shaped"): body = function_body(object_mod, name) require_code(body, r"obj_type\s*==\s*crate::gc::GC_TYPE_OBJECT", f"{name} GC kind") if re.search(r"regex_header_has_magic|object_type", body): @@ -600,7 +591,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") assert_header_fields(object_mod) - class_probe = function_body(object_mod, "object_is_regular_with_header") + class_probe = function_body(object_mod, "object_is_regular") require_code( class_probe, r"ShapeObjectKind::Ordinary", @@ -825,19 +816,6 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(cleared_publication), ) - # #8113: the two spellings of the ordinary-object predicate must not drift. - undelegated = dict(sources) - path = "crates/perry-runtime/src/object/mod.rs" - undelegated[path] = undelegated[path].replace( - " object_is_regular_with_header(header, obj)\n", - " header.obj_type == crate::gc::GC_TYPE_OBJECT\n", - 1, - ) - expect_rejected( - "object_is_regular stopped delegating to the header-taking form", - lambda: assert_authority_surfaces(undelegated), - ) - stale_summary = json.loads(json.dumps(baseline)) stale_summary["summary"]["raw_member_files"] += 1 expect_rejected( From 86b2b62e43700674ea69eb5d8a984957cc0073d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:37:10 +0200 Subject: [PATCH 09/13] docs(changelog): record where the residual instruction cost is, and the zero Adds the per-callsite counter result to the fragment: the residual is one site (proxy.rs's #6595 store-plan gate, one probe per allocated object, flat in width), `object_live_slot_count` is called ZERO times on every hot row so a memo in front of it is structurally pointless, and the 'free' repair for the site measured as an interp +9.59% regression and was reverted. Refs #8113, #8125. --- .../8122-object-header-shrink-56-to-48.md | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md index 5c0d2692ee..f30ac23786 100644 --- a/changelog.d/8122-object-header-shrink-56-to-48.md +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -139,7 +139,26 @@ The rows with no object population move by ~0 — that is the control. The instruction cost is the price of the change: the bound is a shape-table probe where it used to be a `u32` load. -Two findings worth carrying forward, both from measuring rather than assuming: +**Where the residual actually is.** A per-callsite counter (`#[track_caller]` + +`libc::atexit`, on `tls_hot.rs::maybe_install_stats_hook`'s pattern) over every +shape-table entry point found it is **one site**: `proxy.rs`'s #6595 store-plan +gate, which this rung changed from `object_type == OBJECT_TYPE_REGULAR` (a free +`u32` compare) to `object_is_regular` — a `GcHeader` re-derivation plus a +shape-table probe, firing **exactly once per allocated object** (3,000,000 on +`retain`, 20,000,002 on `churn`, and still 1.00 per object on `retain_wide`'s +8-field literals, which is the per-object/flat-in-width signature the corpus +showed). Reducing it means weakening a predicate #6595 constrains, so it is +tracked separately with the counts attached; the obvious "free" repair was tried +here and reverted (see below). + +The same counter established something that matters more for anyone optimising +this later: **`object_live_slot_count` — the bound derivation this rung +introduces — is called ZERO times on every hot row.** Not once, across all nine +programs measured. Two separate memo attempts in front of it measured null +because they were caching a function that never runs on the measured path. Check +the call count before reaching for a memo there. + +Three findings worth carrying forward, all from measuring rather than assuming: 1. The first cut regressed instructions by up to **+30%** (`deeplist` +30.5%, `cycles` +28.4%, `tree` +25.4%). Five GC-side sites already read the bound @@ -155,6 +174,16 @@ Two findings worth carrying forward, both from measuring rather than assuming: consecutive ids and so never share a memo way) and the sabotage run caught that. The numbers survive as a doc comment so it is not rebuilt. +3. The counter-guided repair for the site above — pass the `GcHeader` the caller + already holds, hoist a free compare ahead of the probe — is semantically + identical and strictly less work, and **measured as a reproducible + regression**: `interp` +0.29% -> **+9.59%**, `pipeline` +0.34% -> +4.43%, + while doing nothing for `retain` (+3.26% -> +3.04%, noise). Implemented, + measured, reverted. The mechanism is codegen/inlining rather than semantics + and is not established. "Semantically identical and strictly less work" is an + argument about the source; only the corpus can make it a claim about the + binary. + GC canaries (`retain`/`tree`/`churn`/`shapes` × plain / `FORCE_EVACUATE` + `VERIFY_EVACUATION` / `FORCE_EVACUATE` + `PROTECT_FROMSPACE DEPTH=32`, all under `PERRY_GC_DIAG=1`): all exit 0 and byte-exact, with `copied_objects > 0` or From 2d0ff00af18a2fb301e4be1862c0bad35b16b71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 11:06:44 +0200 Subject: [PATCH 10/13] fix(runtime): param_type_guard reads the receiver kind and live bound from one ShapeId descriptor probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #8094 landed after #8113's base and reads both deleted header words (`object_type`, `field_count`). Route it through the descriptor — one `object_shape_descriptor` probe per guarded object (kind + live bound), and `own_data_field` reads inline slots against that bound (`object_field_at_with_live`, now also `js_object_get_field`'s body) instead of a per-field `js_object_get_field` that re-probed. Measured: `interp` +3.3% / `iso_miss` +2.8% -> -0.0% / -0.1% vs main. --- .../src/object/field_get_set/accessors.rs | 69 ++++++++++++------- crates/perry-runtime/src/param_type_guard.rs | 51 +++++++++++--- 2 files changed, 86 insertions(+), 34 deletions(-) diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 4241017ce9..eecc2e455c 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -37,33 +37,50 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 unsafe { // Bounds check: check inline fields first, then overflow map let fc = crate::object::object_live_slot_count(obj); - if field_index >= fc { - // Check overflow map for fields that didn't fit in inline storage - return match overflow_get(obj as usize, field_index as usize) { - Some(bits) => JSValue::from_bits(bits), - None => JSValue::undefined(), - }; - } - // Guard: corrupted objects with unreasonably large field_count - if fc > 10000 { - return JSValue::undefined(); - } - let fields_ptr = - (obj as *const u8).add(std::mem::size_of::()) as *const JSValue; - let val = *fields_ptr.add(field_index as usize); - // Guard: null POINTER_TAG (0x7FFD_0000_0000_0000) is never legitimate — replace with undefined - if val.bits() == 0x7FFD_0000_0000_0000 { - eprintln!( - "[NULL_PTR_FIELD_GET] obj={:p} field_index={} class_id={} field_count={}", - obj, - field_index, - (*obj).class_id, - fc - ); - return JSValue::undefined(); - } - val + object_field_at_with_live(obj, field_index, fc) + } +} + +/// [`js_object_get_field`]'s body against a live inline-slot bound the caller +/// already resolved (#8122): the by-name lookup tail and the parameter guard +/// resolve the receiver's descriptor once per call and read every field they +/// need against that bound, instead of paying `object_live_slot_count`'s +/// shape-table probe per field. +/// +/// # Safety +/// `obj` must be a live, non-null `GC_TYPE_OBJECT` and `live` its published +/// live inline-slot bound. +#[inline] +pub(crate) unsafe fn object_field_at_with_live( + obj: *const ObjectHeader, + field_index: u32, + live: u32, +) -> JSValue { + if field_index >= live { + // Check overflow map for fields that didn't fit in inline storage + return match overflow_get(obj as usize, field_index as usize) { + Some(bits) => JSValue::from_bits(bits), + None => JSValue::undefined(), + }; + } + // Guard: corrupted objects with unreasonably large field_count + if live > 10000 { + return JSValue::undefined(); + } + let fields_ptr = (obj as *const u8).add(std::mem::size_of::()) as *const JSValue; + let val = *fields_ptr.add(field_index as usize); + // Guard: null POINTER_TAG (0x7FFD_0000_0000_0000) is never legitimate — replace with undefined + if val.bits() == 0x7FFD_0000_0000_0000 { + eprintln!( + "[NULL_PTR_FIELD_GET] obj={:p} field_index={} class_id={} field_count={}", + obj, + field_index, + (*obj).class_id, + live + ); + return JSValue::undefined(); } + val } pub(crate) unsafe fn own_data_field_by_name( diff --git a/crates/perry-runtime/src/param_type_guard.rs b/crates/perry-runtime/src/param_type_guard.rs index a8123c36e9..bec2932a7c 100644 --- a/crates/perry-runtime/src/param_type_guard.rs +++ b/crates/perry-runtime/src/param_type_guard.rs @@ -175,7 +175,11 @@ impl GuardState<'_> { Some((array, length)) } - unsafe fn plain_object(&self, value: JSValue) -> Option<(*const ObjectHeader, usize)> { + /// A validated ordinary object: its header pointer, its address, and the + /// live inline-slot bound its ShapeId descriptor publishes (#8113/#8122 — + /// the one fact `own_data_field` needs to read a slot without probing the + /// shape table again per field). + unsafe fn plain_object(&self, value: JSValue) -> Option<(*const ObjectHeader, usize, usize)> { if !value.is_pointer() { return None; } @@ -187,19 +191,30 @@ impl GuardState<'_> { return None; } let object = address as *const ObjectHeader; - if (*object).object_type != crate::error::OBJECT_TYPE_REGULAR - || (*object).field_count as usize > MAX_CONTAINER_LEN - { + // #8113: the header no longer carries `object_type` / `field_count`; + // both the receiver kind and the live inline-slot bound come from the + // ShapeId descriptor. ONE probe: this runs on every guarded call, and + // `object_is_regular` + `object_live_slot_count` would be two probes + // plus a second read of the GcHeader already validated above + // (measured +3% instructions on `interp`/`iso_miss`). + let Some(descriptor) = crate::object::shapes::object_shape_descriptor(object) else { + return None; + }; + if descriptor.object_kind != crate::object::shapes::ShapeObjectKind::Ordinary { return None; } - let inline_fields = ((*object).field_count as usize).max(crate::object::INLINE_SLOT_FLOOR); + let live_slots = descriptor.live_inline_slot_count as usize; + if live_slots > MAX_CONTAINER_LEN { + return None; + } + let inline_fields = live_slots.max(crate::object::INLINE_SLOT_FLOOR); let required = crate::gc::GC_HEADER_SIZE .checked_add(std::mem::size_of::())? .checked_add(inline_fields.checked_mul(std::mem::size_of::())?)?; if required > header.size as usize { return None; } - Some((object, address)) + Some((object, address, live_slots)) } unsafe fn plain_map(&self, value: JSValue) -> Option<(*const crate::map::MapHeader, usize)> { @@ -252,6 +267,7 @@ impl GuardState<'_> { &self, object: *const ObjectHeader, object_address: usize, + live_slots: usize, name: &[u8], ) -> OwnField { let keys = (*object).keys_array; @@ -294,6 +310,25 @@ impl GuardState<'_> { if crate::object::get_accessor_descriptor(object_address, name).is_some() { return OwnField::Invalid; } + // #8122: read the inline slot directly against the bound + // `plain_object` already resolved from the descriptor. Going + // through `js_object_get_field` re-derived that bound with a + // shape-table probe per field per guarded call — measured +3% + // instructions on `interp`/`iso_miss` after #8113 replaced the + // header's `field_count` word with the descriptor. Slots past + // the inline bound (spill) still take the runtime getter, + // which owns that path. + if index < live_slots { + let fields = (object as *const u8).add(std::mem::size_of::()) + as *const u64; + let bits = std::ptr::read(fields.add(index)); + // Mirror `js_object_get_field`: a null POINTER_TAG payload + // is never a legitimate value and reads as `undefined`. + if bits == 0x7FFD_0000_0000_0000 { + return OwnField::Data(JSValue::undefined()); + } + return OwnField::Data(JSValue::from_bits(bits)); + } return OwnField::Data(crate::object::js_object_get_field(object, index as u32)); } } @@ -393,7 +428,7 @@ impl GuardState<'_> { ) else { return false; }; - let Some((object, address)) = self.plain_object(value) else { + let Some((object, address, live_slots)) = self.plain_object(value) else { return false; }; if class_id != 0 @@ -427,7 +462,7 @@ impl GuardState<'_> { break; }; cursor = name_end + 4; - match self.own_data_field(object, address, name) { + match self.own_data_field(object, address, live_slots, name) { OwnField::Data(field) if optional != 0 && field.is_undefined() => {} OwnField::Data(field) if self.matches(field, child, depth + 1) => {} OwnField::Missing if optional != 0 => {} From d027138ffdf5febbe84d1aa8491c31ef3c2371a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 11:06:44 +0200 Subject: [PATCH 11/13] perf(gc, codegen): recover the instruction cost of the 56 B -> 48 B header shrink Every regressed corpus row measured to a mechanism and fixed; the shrunk representation is now at or below main on instructions with the whole footprint win intact: * the FIRST copying minor fired on a 16 MB BYTE cap before any object census (seeded at 72 B), so smaller objects put 17% more objects into the one TRACED cycle, at ~1,600 instructions per traced object because the collector resolved the ShapeDescriptor five times per object -> allocation census before minor #0 (gc/tenuring.rs, arena/walk.rs), one descriptor lookup per traced object (gc/layout*.rs, object/gc_slots.rs), untraced threshold 990 -> 980 (its first cycle reads 988 object-denominated); * +4.5 instructions per inline `new`: with `object_type` gone the two header words no longer merged into one constant-pool vector store, so LLVM rematerialised the 40-bit GcHeader constant per allocation -> a per-class <2 x i64> header image composed once at module init (target_layout::inline_alloc_gc_packed shared by site and table); * LTO folded the typed-shape install tail into the per-construction hot path between two builds of the same code (pipeline +3.9%) -> #[cold] #[inline(never)] install_typed_shape_layout_slow; * the property-get IC-miss path, the by-name slow scan and js_method_direct_shape_class probed two to three times per call -> once. Measured vs main@bfb0707be (instructions / peak footprint): deeplist -17.2% / -17.7%, retain1 -11.6% / -5.6%, retain -6.8% / -9.7%, shapes -1.7% / -7.2%, tree -0.2% / -12.9%, cycles -0.3% / -29.6%, pipeline -0.3% / -9.9%, push_cls +0.3% / -9.5%. Full table and method in changelog.d/8122-recover-header-shrink-instruction-cost.md. --- ...-recover-header-shrink-instruction-cost.md | 160 +++++++++++++++ crates/perry-codegen/src/codegen/artifacts.rs | 5 + crates/perry-codegen/src/codegen/closure.rs | 2 + crates/perry-codegen/src/codegen/entry.rs | 4 + crates/perry-codegen/src/codegen/function.rs | 2 + crates/perry-codegen/src/codegen/method.rs | 4 + crates/perry-codegen/src/codegen/mod.rs | 84 ++++++++ crates/perry-codegen/src/codegen/opts.rs | 10 + .../perry-codegen/src/codegen/string_pool.rs | 28 +++ crates/perry-codegen/src/expr/mod.rs | 21 ++ crates/perry-codegen/src/function.rs | 58 ++++++ .../src/lower_call/alloc_hot_tests.rs | 60 +++++- crates/perry-codegen/src/lower_call/mod.rs | 2 +- .../perry-codegen/src/lower_call/new_alloc.rs | 97 +++++++-- .../src/lower_call/typed_shape_bake_tests.rs | 6 +- .../src/lower_call/typed_shape_init.rs | 74 +++++-- crates/perry-codegen/src/target_layout.rs | 67 ++++++ crates/perry-codegen/src/typed_shape.rs | 46 ++++- crates/perry-runtime/src/arena/mod.rs | 5 +- crates/perry-runtime/src/arena/walk.rs | 50 +++++ crates/perry-runtime/src/gc/layout.rs | 194 +++++++++++++++--- .../perry-runtime/src/gc/layout_slot_visit.rs | 5 +- crates/perry-runtime/src/gc/policy.rs | 11 +- .../perry-runtime/src/gc/promote_in_place.rs | 19 +- crates/perry-runtime/src/gc/tenuring.rs | 123 ++++++++++- .../src/gc/tests/copying/adaptive_tenuring.rs | 83 ++++++++ .../src/gc/tests/promote_in_place.rs | 14 +- .../field_get_set/get_field_by_name_tail.rs | 22 +- .../src/object/field_get_set/ic_miss.rs | 27 ++- crates/perry-runtime/src/object/gc_slots.rs | 21 +- .../src/typed_feedback/guards.rs | 12 +- docs/src/internals/garbage-collector.md | 4 +- scripts/shape_descriptor_census_baseline.json | 5 +- 33 files changed, 1214 insertions(+), 111 deletions(-) create mode 100644 changelog.d/8122-recover-header-shrink-instruction-cost.md diff --git a/changelog.d/8122-recover-header-shrink-instruction-cost.md b/changelog.d/8122-recover-header-shrink-instruction-cost.md new file mode 100644 index 0000000000..2a87036a52 --- /dev/null +++ b/changelog.d/8122-recover-header-shrink-instruction-cost.md @@ -0,0 +1,160 @@ +### perf(gc, codegen): recover the instruction cost of the 56 B → 48 B header shrink — smaller objects now cost fewer instructions too + +#8122 (`ObjectHeader` 56 → 48 B) held on the standing directive — minimize RSS +**and** keep best compute, both — because it bought its footprint win with ++2…+9% instructions on 18 of 22 corpus rows. This measures where every one of +those instructions went and removes them; the shrunk representation now runs +faster than `main` on the rows that regressed most, with the whole footprint +win intact and several rows' peak footprint lower again. + +Numbers are `/usr/bin/time -l` instructions retired and peak memory footprint, +best-of-3, both arms built from their own tree with the same `-p perry +-p perry-runtime-static -p perry-stdlib-static`, `PERRY_RUNTIME_DIR` and +`PERRY_CACHE_DIR` pinned per arm, all 19 corpus stdouts byte-compared and +exit-checked. `A` = `main@bfb0707be`, `B` = this branch (#8122 rebased + this). +"#8122 as held" is the same PR before this recovery, on the same host. + +| row | #8122 as held | now: instructions | now: peak footprint | +|---|---:|---:|---:| +| `deeplist` | +9.0% | **−17.2%** | **−17.7%** | +| `retain1` | +8.6% | **−11.6%** | −5.6% | +| `retain` | +3.7% | **−6.8%** | −9.7% | +| `shapes` | +3.1% | −1.7% | −7.2% | +| `retain_wide` / `retain_wide1` | +2.3% / +2.0% | −0.6% / −0.3% | −5.5% / −6.0% | +| `interp` / `iso_miss` / `pipeline` | +3.3% / +2.8% / +0.4% | +0.0% / +0.0% / −0.3% | −6.8% / −7.3% / −9.9% | +| `tree` / `tree_wide` | +0.6% / +0.5% | −0.2% / +0.1% | −12.9% / −6.4% | +| `cycles` | +0.8% | −0.3% | **−29.6%** | +| `push_cls` / `churn_alloc` / `churn` | +5.5% / +5.5% / +3.2% | +0.3% / +0.2% / +0.2% | −9.5% / −9.5% / −9.6% | +| `push_num` / `churn_read` / `fib40` | ~0 | +0.1% / −0.0% / −0.1% | ~0 | +| `asyncpipe` | +0.4% | +0.5% | +2.9% (see below) | + +The churn family's +0.2–0.3% and `asyncpipe`'s +0.5% are inside those rows' +run-to-run spread (0.2–0.9%); the rest of the column is outside it. + +#### What the instructions actually were — four mechanisms, none of them "the probe" + +The cost had been attributed to shape-table probes replacing the deleted +`field_count` word. Per-row measurement (GC traces, `sample` on symbolised +arms, `otool` diffs) says otherwise: + +1. **`deeplist` / `retain1` / `retain` — the FIRST copying minor was + byte-denominated.** With no GC at all the mutator is byte-identical between + arms (`deeplist` at 250k objects: 151.7 M vs 151.5 M). Both arms run exactly + two minors; but the first fires when Eden holds 16 MB, before any object + census exists (`MEAN_SURVIVING_OBJECT_BYTES` seeded at the 72 B reference), + so 48 B objects put **371k** objects into that cycle where 56 B put 318k — + and the first cycle is the one that must TRACE (no survival estimate yet). + A traced in-place-promotion cycle cost **~1,600 instructions per object** + because it resolved the receiver's `ShapeDescriptor` **five times per + object** (`gc_field_slot_range`, `gc_keys_array_slot`, the slot visitor, + `object_keys_array_ptr` and `with_shape_shared_descriptor`'s bound check), + plus a `hot_shape_layouts` probe. 53k extra objects × that price is the + whole +100 M. +2. **`push_cls` / `churn_alloc` / `churn` — an LLVM store-merging artefact, + +4.5 instructions per `new`.** IR identical modulo offsets (the shrunk arm + even had one store fewer); the machine code was not. Before, the two header + words were both constants and LLVM merged them into one 16-byte + constant-pool store; after, the second word is `class_id | ShapeId << 32` + with the ShapeId from a global, so nothing merges and the 40-bit `gc_packed` + immediate is rematerialised (`mov` + two `movk`) at every allocation. +3. **`interp` / `iso_miss` — a consumer that landed after the PR.** #8094's + `param_type_guard::plain_object` reads both deleted words; the rebase turned + two free `u32` loads into `object_is_regular` + `object_live_slot_count` + (two probes plus a re-read of the already-validated GcHeader), and its + per-field `js_object_get_field` reads probed once more each. +4. **`pipeline` (+3.9% between two builds of the SAME hot path)** — LTO folded + `shape_install_shared`+`record` into `init_typed_shape_layout` in one build + and not the other, making the per-construction memo-hit path an + 811-instruction function whose prologue and spills were paid on every hit. + +#### The changes + +* **`gc/tenuring.rs`: allocation census before the first minor.** Halfway to + the base cap (8 MB of from-space), once per process, hop the young + generation's headers (`arena::young_allocation_census`, ~1 M instructions) + and seed the object denomination with the ALLOCATED mean, so the first cycle + buys the same object budget every later one does. The collector's survivor + census overwrites it at the first minor; the one-sided clamp still applies. + Side effect: the first minor fires earlier on small-object workloads, which + is where the extra footprint wins above come from (`cycles` −29.6%, + `pipeline` −10%, the churn family −9.5%). +* **`gc/promote_in_place.rs`: `UNTRACED_PROMOTION_SURVIVAL_PERMILLE` 990 → 980.** + The 992 that 990 was read off came from a first cycle at the raw 16 MB band; + object-denominated, `retain`/`retain1`'s first cycle reads **988** (the same + ~131 KB of abandoned `all.push` backing stores over a smaller nursery), and + at 990 their second cycle traced again — `retain1` +13%. Its own exposure + bound becomes 2.56 MB against the same 32 MB cap; the untraced-bytes budget + stays the binding bound. Doc fact, `check_gc_doc_claims.py` and the two + threshold-shaped tests updated to the constant. +* **`gc/layout.rs`, `gc/layout_slot_visit.rs`, `object/gc_slots.rs`: one + descriptor lookup per traced object.** `gc_child_slots` resolves the + receiver's `ShapeDescriptor` once and threads it through the field range, + the keys slot (`gc_keys_array_slot` / `gc_field_slot_range` now take it), the + shared pointer-mask selection (`HeapChildSlotIterator::new_object`, + `heap_payload_slot_selection_from`, `with_shape_shared_descriptor_from`) and + the slot visitor. `with_shape_shared_descriptor` itself drops from two probes + to one for every field store that reaches it; `object_keys_array_ptr` is + gone. +* **`gc/layout.rs`: `init_typed_shape_layout` split** — the memo-miss install + tail is `install_typed_shape_layout_slow`, `#[cold] #[inline(never)]`, so + the per-construction hit path keeps its shape whatever LTO decides + elsewhere in the crate. +* **`lower_call/new_alloc.rs`, `codegen/mod.rs`, `codegen/string_pool.rs`, + `function.rs`: the header image.** The 16-byte prefix `[gc_packed | + class_id | ShapeId << 32]` is composed ONCE at module init — beside the + ShapeId mint, into a per-class `<2 x i64>` global — from + `target_layout::inline_alloc_gc_packed`, the single definition of the packed + word the site also uses (`inline_alloc_total_size_bytes` alongside it). The + inline allocator entry-hoists that global like the keys global and stores it + with one vector store; the site cross-checks the table's packed word and + class id against its own derivation and falls back to a per-function + compose (`LlFunction::entry_init_object_header_image`) if they differ. + Per-function was tried first and fixed loops but not recursion (`tree` + allocates once per call: +0.6%), hence module init. + `layout_declared_at_allocation` / `layout_pointer_free_at_allocation` gain + `_in` forms over the module-level maps so the site and the table run the + same predicate. +* **`param_type_guard.rs`: one probe per guarded object**, and + `own_data_field` reads inline slots against the bound `plain_object` already + resolved (`object/field_get_set/accessors.rs::object_field_at_with_live`, + which is now also `js_object_get_field`'s body). +* **`object/field_get_set/ic_miss.rs`, `get_field_by_name_tail.rs`, + `typed_feedback/guards.rs`: one probe per call** on the property-get IC-miss + path (was three: regularity, descriptor, `object_shape_id` for the token), + the by-name slow scan (was two, one into an unused binding, plus one inside + every field read it returned through) and `js_method_direct_shape_class` + (was two). Same discipline as the collector: resolve once, derive. + +#### Tests + +* `gc::tests::copying::adaptive_tenuring::allocation_census_seeds_the_first_cap_before_any_minor` + drives a real nursery past half the base cap, asserts the seed equals THIS + population's header-walk mean (independently recomputed) and differs from + the 72 B seed, that the effective cap moved before any collection, and that + the walk is one-shot; `gc::tenuring::tests::allocation_census_seed_is_gated_and_one_shot` + pins the gate (below half-cap: nothing; a survivor census disarms it; reset + re-arms). +* `lower_call::alloc_hot_tests::the_inline_allocator_stores_its_header_prefix_as_one_vector_image`: + exactly one compose, in module init after the mint; the site loads the + image global and stores the vector; the allocating function does not compose + its own. +* `perry-runtime --lib` 2482/0/4; `perry-codegen` VALIDATION_CODEGEN; + `check_gc_doc_claims.py` and every other `lint` python gate OK; the GC + canaries (`retain`/`tree`/`churn`/`shapes`/`deeplist`/`retain1`/`push_cls`/ + `interp` × forced evacuation + evacuation verifier, from-space protect + depth 32, and both together) byte-exact with the instruments demonstrably + live (`copied_objects` 237k per cycle on `retain` under forced evacuation; + 87 `[gc-fromspace-protect]` retire lines on `churn`). + +#### Not closed here + +* `asyncpipe` peak footprint +2.8% at 120 batches (+7.7% at 1200). The GC + arena is identical between arms (same triggers, same 6767 copies, 23 MB + reserved); the footprint is ~100 MB of **non-arena** memory at 1200 batches + — the async activation-box retention, growing at the same rate per MB + allocated in both arms — and mimalloc's own peak (`MIMALLOC_SHOW_STATS`) and + `maximum resident set size` are both LOWER for the shrunk arm (140.3 vs + 140.9 MiB; 147.0 vs 149.7 MB) while `peak memory footprint` is higher. The + two OS metrics disagree in direction, i.e. this is about how much + freed-but-resident memory is marked reusable at the peak instant, not more + live data. Left as measured. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 956d5434ec..1fffe3e63a 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -89,6 +89,9 @@ pub(super) struct ModuleArtifactsCtx<'a> { pub closure_arrow_functions: &'a std::collections::HashSet, pub closures: &'a [(perry_hir::types::FuncId, perry_hir::Expr)], pub class_keys_init_data: &'a [(String, String, u32, Vec, Vec)], + /// #8122: keys global → (class id, packed GcHeader word) for the classes + /// whose inline-`new` header image module init must compose. + pub class_header_image_inits: &'a std::collections::HashMap, pub imported_class_stubs: &'a [perry_hir::Class], pub cross_module: &'a CrossModuleCtx, } @@ -211,6 +214,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { closure_arrow_functions, closures, class_keys_init_data, + class_header_image_inits, imported_class_stubs, cross_module, } = c; @@ -1972,6 +1976,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, module_prefix, class_keys_init_data, + class_header_image_inits, class_ids, class_table, &hir.class_display_names, diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 36dc94ee75..3e3f9d1450 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -950,6 +950,7 @@ pub(super) fn compile_closure( class_keys_globals: &cross_module.class_keys_globals, class_field_counts: &cross_module.class_field_counts, class_init_chains: &cross_module.class_init_chains, + class_header_image_globals: &cross_module.class_header_images, imported_class_ctors: &cross_module.imported_class_ctors, func_signatures, func_synthetic_arguments, @@ -1006,6 +1007,7 @@ pub(super) fn compile_closure( arena_state_slot: None, class_keys_slots: HashMap::new(), class_shape_slots: HashMap::new(), + class_header_images: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index d649eaae85..4173325146 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -782,6 +782,7 @@ pub(super) fn compile_module_entry( class_keys_globals: &cross_module.class_keys_globals, class_field_counts: &cross_module.class_field_counts, class_init_chains: &cross_module.class_init_chains, + class_header_image_globals: &cross_module.class_header_images, imported_class_ctors: &cross_module.imported_class_ctors, func_signatures, func_synthetic_arguments, @@ -836,6 +837,7 @@ pub(super) fn compile_module_entry( arena_state_slot: None, class_keys_slots: HashMap::new(), class_shape_slots: HashMap::new(), + class_header_images: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), @@ -1470,6 +1472,7 @@ pub(super) fn compile_module_entry( class_keys_globals: &cross_module.class_keys_globals, class_field_counts: &cross_module.class_field_counts, class_init_chains: &cross_module.class_init_chains, + class_header_image_globals: &cross_module.class_header_images, imported_class_ctors: &cross_module.imported_class_ctors, func_signatures, func_synthetic_arguments, @@ -1524,6 +1527,7 @@ pub(super) fn compile_module_entry( arena_state_slot: None, class_keys_slots: HashMap::new(), class_shape_slots: HashMap::new(), + class_header_images: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index d0a85761dc..a2ce24068b 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -933,6 +933,7 @@ pub(super) fn compile_function( class_keys_globals: &cross_module.class_keys_globals, class_field_counts: &cross_module.class_field_counts, class_init_chains: &cross_module.class_init_chains, + class_header_image_globals: &cross_module.class_header_images, imported_class_ctors: &cross_module.imported_class_ctors, func_signatures, func_synthetic_arguments, @@ -987,6 +988,7 @@ pub(super) fn compile_function( arena_state_slot: None, class_keys_slots: HashMap::new(), class_shape_slots: HashMap::new(), + class_header_images: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 651db12686..3c5b0f4b32 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -466,6 +466,7 @@ pub(super) fn compile_method( class_keys_globals: &cross_module.class_keys_globals, class_field_counts: &cross_module.class_field_counts, class_init_chains: &cross_module.class_init_chains, + class_header_image_globals: &cross_module.class_header_images, imported_class_ctors: &cross_module.imported_class_ctors, func_signatures, func_synthetic_arguments, @@ -522,6 +523,7 @@ pub(super) fn compile_method( arena_state_slot: None, class_keys_slots: HashMap::new(), class_shape_slots: HashMap::new(), + class_header_images: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), @@ -1537,6 +1539,7 @@ pub(super) fn compile_static_method( class_keys_globals: &cross_module.class_keys_globals, class_field_counts: &cross_module.class_field_counts, class_init_chains: &cross_module.class_init_chains, + class_header_image_globals: &cross_module.class_header_images, imported_class_ctors: &cross_module.imported_class_ctors, func_signatures, func_synthetic_arguments, @@ -1593,6 +1596,7 @@ pub(super) fn compile_static_method( arena_state_slot: None, class_keys_slots: HashMap::new(), class_shape_slots: HashMap::new(), + class_header_images: HashMap::new(), cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 33496cc2a7..132772e142 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -990,6 +990,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> I32, "0", ); + // #8122: the inline-`new` header image, composed at module init + // (`string_pool.rs`) for the classes `class_header_images` admits. + llmod.add_internal_global( + &crate::typed_shape::header_image_global_name_from_keys_global(&global_name), + "<2 x i64>", + "zeroinitializer", + ); // Build the packed-keys string. Format: each field name // followed by `\0`. Parent classes contribute their fields @@ -1173,6 +1180,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> I32, "0", ); + // #8122: the inline-`new` header image, composed at module init + // (`string_pool.rs`) for the classes `class_header_images` admits. + llmod.add_internal_global( + &crate::typed_shape::header_image_global_name_from_keys_global(&global_name), + "<2 x i64>", + "zeroinitializer", + ); class_keys_globals_map.insert(c.name.clone(), global_name.clone()); let mut packed_keys = String::new(); let mut total_field_count = c.fields.len() as u32; @@ -1821,6 +1835,74 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } } + // #8122: the inline-`new` header-image table. For every class with a keys + // global (local or imported stub), derive the packed GcHeader word the + // inline allocator will store — with `target_layout::inline_alloc_gc_packed`, + // the SAME function the allocation site uses — from the same module-level + // maps the site consults through its `FnCtx`. Module init composes + // `[gc_packed | class_id | ShapeId << 32]` into the class's image global; + // the site loads that instead of composing per site (or per call in a + // recursive allocator, where the per-function compose measured +0.6% on + // `tree`). + // + // Module init writes one image per KEYS global (aliases share one), so the + // init table is keyed by keys global and the site table is DERIVED from it: + // a class only gets a site entry if module init will actually compose its + // image. A site entry with no init store would hand every instance a + // zeroed header, so that direction of the dependency is load-bearing. + let class_header_image_inits: std::collections::HashMap = { + let mut inits: std::collections::HashMap = + std::collections::HashMap::new(); + for (class_name, keys_global) in &class_keys_globals_map { + let Some(&field_count) = class_field_counts_map.get(class_name) else { + continue; + }; + let Some(&class_id) = class_ids.get(class_name) else { + continue; + }; + let typed_intact = + crate::lower_call::typed_shape_init::layout_pointer_free_at_allocation_in( + &class_table, + &class_keys_globals_map, + &class_init_chains_map, + class_name, + field_count, + ); + let gc_packed = + crate::target_layout::inline_alloc_gc_packed(&triple, field_count, typed_intact); + match inits.get(keys_global) { + // Two names (an alias) sharing one keys global must agree on + // the word module init writes; if they do not, neither may use + // the image — drop the keys global from the table. + Some(&(existing_id, existing_gc)) => { + if existing_id != class_id || existing_gc != gc_packed { + inits.insert(keys_global.clone(), (u32::MAX, 0)); + } + } + None => { + inits.insert(keys_global.clone(), (class_id, gc_packed)); + } + } + } + inits.retain(|_, (class_id, _)| *class_id != u32::MAX); + inits + }; + let class_header_images_map: std::collections::HashMap = + class_keys_globals_map + .iter() + .filter_map(|(class_name, keys_global)| { + let &(class_id, gc_packed) = class_header_image_inits.get(keys_global)?; + Some(( + class_name.clone(), + ( + crate::typed_shape::header_image_global_name_from_keys_global(keys_global), + gc_packed, + class_id, + ), + )) + }) + .collect(); + let mut cross_module = CrossModuleCtx { namespace_imports: opts.namespace_imports.iter().cloned().collect(), namespace_member_nested: opts.namespace_member_nested.iter().cloned().collect(), @@ -1850,6 +1932,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> class_keys_globals: class_keys_globals_map, class_field_counts: class_field_counts_map, class_init_chains: class_init_chains_map, + class_header_images: class_header_images_map, imported_class_ctors: opts .imported_classes .iter() @@ -2923,6 +3006,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> closure_arrow_functions: &closure_arrow_functions, closures: &closures, class_keys_init_data: &class_keys_init_data, + class_header_image_inits: &class_header_image_inits, imported_class_stubs: &imported_class_stubs, cross_module: &cross_module, })?; diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index ac3daaca75..d7e22db179 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -730,6 +730,16 @@ pub(crate) struct CrossModuleCtx { /// describes — not a colliding same-named cross-module parent's fields. pub class_init_chains: std::collections::HashMap)>>, + /// #8122: per-class inline-`new` header image — `class_name → (image + /// global name, packed GcHeader word, class id)`. The global is a `<2 x i64>` holding + /// `[gc_packed | class_id | ShapeId << 32]`, composed once at module init + /// right after the ShapeId mint (`string_pool.rs`); the inline allocator + /// loads it and stores an instance's 16-byte header prefix in one vector + /// store. The packed word is recorded so the allocation site can + /// cross-check the table against its own derivation before using it — + /// both come from `target_layout::inline_alloc_gc_packed`, but a header + /// word is not something to trust by argument. + pub class_header_images: std::collections::HashMap, /// Imported class constructor function names. Maps class_name → /// full constructor symbol (e.g. "Editor" → "hone_editor_...__Editor_constructor"). /// Populated from `opts.imported_classes`. diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 3a3b0f61a4..fd55ec14d5 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -106,6 +106,7 @@ pub(super) fn emit_string_pool( strings: &StringPool, module_prefix: &str, class_keys_init_data: &[(String, String, u32, Vec, Vec)], + class_header_image_inits: &std::collections::HashMap, class_ids: &HashMap, classes: &HashMap, // #5592: user-visible `.name` overrides keyed by ClassId, for classes @@ -491,6 +492,33 @@ pub(super) fn emit_string_pool( crate::typed_shape::shape_id_global_name_from_keys_global(global_name) ); blk.store(I32, &shape_id, &shape_global); + + // #8122: compose the class's inline-`new` header image — + // `[packed GcHeader word | class_id | ShapeId << 32]` — beside the + // ShapeId it consumes, ONCE. Every inline allocation of this class + // then stores its 16-byte header prefix with one `<2 x i64>` store + // instead of composing the pair per site (or per call in a recursive + // allocator). The packed word came from + // `target_layout::inline_alloc_gc_packed`, the same derivation the + // site performs and cross-checks before it trusts this global. + if let Some(&(image_class_id, gc_packed)) = class_header_image_inits.get(global_name) { + let image_global = format!( + "@{}", + crate::typed_shape::header_image_global_name_from_keys_global(global_name) + ); + let shape_i64 = blk.zext(I32, &shape_id, I64); + let shape_shifted = blk.shl(I64, &shape_i64, "32"); + let header_word = blk.or(I64, &shape_shifted, &image_class_id.to_string()); + let image = blk.fresh_reg(); + blk.emit_raw(format!( + "{} = insertelement <2 x i64> , i64 {}, i32 1", + image, gc_packed, header_word + )); + blk.emit_raw(format!( + "store <2 x i64> {}, ptr {}, align 8", + image, image_global + )); + } } // Register the parent-class chain for every class with a parent. diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index c0bfee3045..f85ffa4971 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -208,6 +208,16 @@ pub(crate) struct InlineCtorReturn { } /// Per-function codegen context. Held briefly during lowering, never stored. +/// #8122: where an inline-`new` site gets its `<2 x i64>` header image from. +#[derive(Clone, Debug)] +pub enum HeaderImageSource { + /// An entry alloca holding the module-init image global's value; each + /// site emits its own `load <2 x i64>` from it. + EntrySlot(String), + /// An SSA value composed in the entry region (dominates every site). + EntryValue(String), +} + pub(crate) struct FnCtx<'a> { /// Function being built (blocks, params, registers). pub func: &'a mut LlFunction, @@ -474,6 +484,9 @@ pub(crate) struct FnCtx<'a> { /// `ctx.classes` chain (which mis-picks same-named cross-module parents). pub class_init_chains: &'a std::collections::HashMap)>>, + /// #8122: per-class inline-`new` header-image globals, see + /// `CrossModuleCtx::class_header_images`. + pub class_header_image_globals: &'a std::collections::HashMap, /// Imported class constructor metadata, keyed by effective imported class name. pub imported_class_ctors: &'a std::collections::HashMap, /// Per-function param signature: `(declared_param_count, @@ -833,6 +846,14 @@ pub(crate) struct FnCtx<'a> { /// [`Self::class_keys_slots`]. Shape ids are scalar metadata rather than GC /// pointers, so these entry-hoisted copies need no shadow-slot binding. pub class_shape_slots: std::collections::HashMap, + /// #8122: per-class `<2 x i64>` object-header prefix image, keyed by class + /// name + the packed GcHeader word it was built with. Read by the inline + /// `new` path so every allocation stores the prefix with ONE vector store + /// instead of rematerialising the packed constant per site. Either an + /// entry-hoisted stack slot holding the module-level image global (loaded + /// per site — a value loaded at one site does not dominate another) or, + /// as the fallback, an SSA value composed in the entry region. + pub class_header_images: std::collections::HashMap<(String, u64), HeaderImageSource>, /// Per-arr-local cached `arr.length` slots — populated by /// `lower_for` when it spots the well-known shape diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 36b294ce28..4ad9b1f99e 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -538,6 +538,64 @@ impl LlFunction { slot } + /// Compose the 16-byte object-header prefix the inline `new` path stores + /// per allocation — `[GcHeader packed word | class_id | ShapeId << 32]` — + /// ONCE per function, as a `<2 x i64>` SSA value, and return its register. + /// + /// # Why a vector, and why once per function (#8122) + /// + /// The header prefix is two adjacent 8-byte words. Before #8113 BOTH were + /// compile-time constants (`gc_packed` and `object_type | class_id << 32`), + /// so LLVM merged them into a single 16-byte constant-pool store + /// (`ldr q0, [pool]; str q0, [obj]` on AArch64). #8113 deleted + /// `object_type` and moved the ShapeId — a MODULE-INIT value loaded from a + /// global — into the second word, so the pair is no longer a constant and + /// LLVM stores it as two scalars: the 40-bit `gc_packed` immediate is then + /// rematerialised (`mov` + two `movk`) at every allocation and the second + /// word is shifted/or-ed per allocation. Measured: +4.5 instructions per + /// `new` on `push_cls`/`churn_alloc` (+5.5% total), with the IR + /// byte-identical modulo offsets and one store FEWER — a backend + /// store-merging artefact, invisible at the IR level. + /// + /// Building the pair here, after the init prelude (the ShapeId global is + /// populated by `__perry_init_strings_*`), yields one live `q` register + /// (or one stack reload) and one `str q` per allocation — the shape LLVM + /// produced for the pre-#8113 constant pair, now independent of whether + /// the words happen to be constants. + /// + /// `shape_slot` is the i32 entry alloca returned by `entry_init_load_global` + /// for the class's ShapeId global; the lines emitted here are appended to + /// the SAME region, so they run after its load+store. + pub fn entry_init_object_header_image( + &mut self, + shape_slot: &str, + gc_packed: u64, + class_id: u32, + ) -> String { + let shape_i32 = format!("%r{}", self.reg_counter.next()); + let shape_i64 = format!("%r{}", self.reg_counter.next()); + let shape_shifted = format!("%r{}", self.reg_counter.next()); + let header_word = format!("%r{}", self.reg_counter.next()); + let image = format!("%r{}", self.reg_counter.next()); + let lines = [ + format!(" {} = load i32, ptr {}", shape_i32, shape_slot), + format!(" {} = zext i32 {} to i64", shape_i64, shape_i32), + format!(" {} = shl i64 {}, 32", shape_shifted, shape_i64), + format!(" {} = or i64 {}, {}", header_word, shape_shifted, class_id), + format!( + " {} = insertelement <2 x i64> , i64 {}, i32 1", + image, gc_packed, header_word + ), + ]; + let region = if self.entry_init_boundary.is_some() { + &mut self.entry_post_init_setup + } else { + &mut self.entry_allocas + }; + region.extend(lines); + image + } + /// Emit a one-time function-entry load of a module global into a /// stack slot, returning the slot pointer. Used by the inline /// bump allocator to cache class-static values like the per-class diff --git a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs index eec0d32b91..eed47393d3 100644 --- a/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs +++ b/crates/perry-codegen/src/lower_call/alloc_hot_tests.rs @@ -41,6 +41,13 @@ const STAMPED_OUTLINED_CALL: &str = "call i64 @js_object_alloc_class_inline_keys const SHAPE_MINT_CALL: &str = "call i32 @js_object_shape_id_for_keys("; /// The immutable id is hoisted to the function-entry setup like keys_array. const SHAPE_GLOBAL_LOAD: &str = "load i32, ptr @perry_class_shape_id_"; +/// #8122: the inline allocator's 16-byte header prefix — packed GcHeader word +/// + `class_id | ShapeId << 32` — is composed ONCE at module init into a +/// per-class `<2 x i64>` global, entry-hoisted like the keys global, and +/// stored with one vector store per allocation. +const HEADER_IMAGE_COMPOSE: &str = "insertelement <2 x i64> ` header image:\n{ir}" + ); + // The compose lives beside the ShapeId mint in module init, i.e. after the + // mint call and outside the allocating function's own body. + let compose_at = ir.find(HEADER_IMAGE_COMPOSE).unwrap(); + let mint_at = ir.find(SHAPE_MINT_CALL).unwrap(); + assert!( + compose_at > mint_at, + "the header image must be composed from the ShapeId the mint returned:\n{ir}" + ); + // And the per-function fallback compose (from the ShapeId slot) is NOT + // used when the module-level image exists: no `shl i64 %x, 32` in the + // allocating function's entry region. + let fast_at = ir.find(INLINE_FAST_BLOCK).unwrap(); + let fn_start = ir[..fast_at].rfind("\ndefine ").unwrap_or(0); + let entry_region = &ir[fn_start..fast_at]; + assert!( + !entry_region.contains(HEADER_IMAGE_COMPOSE), + "the allocating function composed its own header image although the \ + module-level image global exists:\n{ir}" ); } diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 18549221c0..5b1452210a 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -84,7 +84,7 @@ mod timer_rooting_tests; mod typed_shape_bake_tests; /// #7510: which of the two typed-shape layout entry points a `new` site emits, /// and where. Split out of `new.rs` to keep it under the 2000-line cap. -mod typed_shape_init; +pub(crate) mod typed_shape_init; mod ui_styling; mod ui_tables; mod web_storage; diff --git a/crates/perry-codegen/src/lower_call/new_alloc.rs b/crates/perry-codegen/src/lower_call/new_alloc.rs index 40ce3bd340..20fd462e58 100644 --- a/crates/perry-codegen/src/lower_call/new_alloc.rs +++ b/crates/perry-codegen/src/lower_call/new_alloc.rs @@ -486,8 +486,6 @@ fn emit_instance_alloc_inner( s }; let keys_ptr = ctx.block().load(I64, &keys_slot); - let shape_id = - crate::typed_shape::load_class_shape_id(ctx, class_name, &keys_global_name); // Inline bump-allocator IR. let blk = ctx.block(); @@ -554,20 +552,16 @@ fn emit_instance_alloc_inner( &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], ); - // Write GcHeader (8 bytes) as a single i64 store. Field - // packing (little-endian): + // Write the 16-byte header prefix — GcHeader (8 bytes) followed by + // the first ObjectHeader word — with ONE `<2 x i64>` store. + // + // GcHeader packing (little-endian): // bits 0..7 = obj_type (u8) // bits 8..15 = gc_flags (u8) // bits 16..31 = _reserved (u16) // bits 32..63 = size (u32) - let gc_packed: u64 = GC_TYPE_OBJECT - | (GC_FLAG_ARENA << 8) - | ((GC_LAYOUT_POINTER_FREE | typed_intact_bits) << 16) - | ((total_size as u64) << 32); - // GC_STORE_AUDIT(INIT): inline headers initialize freshly allocated unpublished object storage. - blk.store(I64, &gc_packed.to_string(), &raw); - - // Write ObjectHeader at raw + 8. #8113 collapsed the two packed + // + // The ObjectHeader word at raw + 8: #8113 collapsed the two packed // words into one: `class_id` (u32, low) | ShapeId (u32, high). // The module-init runtime call either publishes a usable ShapeId // or fail-stops on exhaustion; there is no pointer-token fallback. @@ -575,11 +569,80 @@ fn emit_instance_alloc_inner( // `field_count` is now the ShapeId descriptor's // `live_inline_slot_count`, which the `descriptor_facts_exact` // gate above proved equals this site's `field_count`. - let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); - let shape_word64 = blk.zext(I32, &shape_id, I64); - let oh_shifted = blk.shl(I64, &shape_word64, "32"); - let oh_word_1 = blk.or(I64, &oh_shifted, &(cid as u64).to_string()); - blk.store(I64, &oh_word_1, &oh_addr_1); + // + // #8122: the pair is composed ONCE per function + // (`LlFunction::entry_init_object_header_image`) rather than + // stored as two scalars here. With `object_type` gone the second + // word is no longer a constant, so two scalar stores made LLVM + // rematerialise the 40-bit `gc_packed` immediate at every + // allocation (`mov` + two `movk`) and shift/or the ShapeId per + // site — measured +4.5 instructions per `new`. The vector image is + // one live register (or one reload) and one `str q` per + // allocation, the shape the pre-#8113 constant pair compiled to. + let gc_packed: u64 = crate::target_layout::inline_alloc_gc_packed( + ctx.target_triple, + field_count, + *typed_layout_baked, + ); + debug_assert_eq!( + gc_packed, + GC_TYPE_OBJECT + | (GC_FLAG_ARENA << 8) + | ((GC_LAYOUT_POINTER_FREE | typed_intact_bits) << 16) + | ((total_size as u64) << 32), + "inline_alloc_gc_packed must reproduce this site's packed header word" + ); + // Prefer the module-level image global — composed once at module + // init from the SAME `inline_alloc_gc_packed` derivation — and use + // it only when the table's packed word equals this site's own. A + // header word is not something to take on trust; a mismatch means + // the per-function compose (below) is used instead of a wrong + // header. `image_slot` is an entry-hoisted copy (like the keys + // global), so a site inside a loop or a recursive allocator pays + // one vector load per function entry and one `str q` per `new`. + let image_key = (class_name.to_string(), gc_packed); + let image_source = if let Some(source) = ctx.class_header_images.get(&image_key) { + source.clone() + } else { + let module_image = ctx + .class_header_image_globals + .get(class_name) + .filter(|(_, module_gc_packed, module_cid)| { + *module_gc_packed == gc_packed && *module_cid == cid + }) + .map(|(global, _, _)| global.clone()); + let source = if let Some(image_global) = module_image { + crate::expr::HeaderImageSource::EntrySlot( + ctx.func.entry_init_load_global(&image_global, "<2 x i64>"), + ) + } else { + // Fallback: compose the pair once per function from the + // ShapeId global's entry slot. + let shape_slot = crate::typed_shape::ensure_class_shape_slot( + ctx, + class_name, + &keys_global_name, + ); + crate::expr::HeaderImageSource::EntryValue( + ctx.func + .entry_init_object_header_image(&shape_slot, gc_packed, cid), + ) + }; + ctx.class_header_images.insert(image_key, source.clone()); + source + }; + let header_image = match image_source { + crate::expr::HeaderImageSource::EntrySlot(slot) => { + ctx.block().load("<2 x i64>", &slot) + } + crate::expr::HeaderImageSource::EntryValue(value) => value, + }; + let blk = ctx.block(); + // GC_STORE_AUDIT(INIT): inline headers initialize freshly allocated unpublished object storage. + blk.emit_raw(format!( + "store <2 x i64> {}, ptr {}, align 8", + header_image, raw + )); // Second 8 bytes: keys_array pointer. The keys_ptr we loaded // above is an i64 (carries the ArrayHeader address); store as diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index 3f932fdc08..0e0da6e39b 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -82,7 +82,11 @@ fn header_word(intact: bool) -> String { 0 }; let word = (size << 32) | (reserved << 16) | (GC_FLAG_ARENA << 8) | GC_TYPE_OBJECT; - format!("store i64 {word},") + // #8122: the packed word is no longer a per-site scalar store — it is the + // constant lane of the per-class `<2 x i64>` header image composed once at + // module init (`insertelement <2 x i64> , i64 %shape_word, + // i32 1`), which every inline `new` of the class stores as one vector. + format!("insertelement <2 x i64> ,") } /// The packed word WITH the baked `GC_OBJ_TYPED_LAYOUT_INTACT`. diff --git a/crates/perry-codegen/src/lower_call/typed_shape_init.rs b/crates/perry-codegen/src/lower_call/typed_shape_init.rs index 98077b7a3f..fcadf53e93 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_init.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_init.rs @@ -35,10 +35,22 @@ use crate::types::{I32, I64, PTR}; /// [`crate::typed_shape::class_layout_declarable_at_allocation`], which /// documents what they are and why they are enough. pub(super) fn layout_declared_at_allocation(ctx: &FnCtx<'_>, class_name: &str) -> bool { - if !ctx.class_keys_globals.contains_key(class_name) { + layout_declared_at_allocation_in(ctx.classes, ctx.class_keys_globals, class_name) +} + +/// [`layout_declared_at_allocation`] over the module-level maps a `FnCtx` +/// carries by reference (#8122: the module-level header-image table needs the +/// same answer before any function is lowered — one implementation, two +/// callers). +pub(crate) fn layout_declared_at_allocation_in( + classes: &std::collections::HashMap, + class_keys_globals: &std::collections::HashMap, + class_name: &str, +) -> bool { + if !class_keys_globals.contains_key(class_name) { return false; } - let single = ctx.classes.get(class_name).is_some_and(|class| { + let single = classes.get(class_name).is_some_and(|class| { let prologue = super::field_init::ctor_prologue_param_assigned_fields(class); crate::typed_shape::class_layout_declarable_at_allocation(class, &prologue) }); @@ -49,11 +61,9 @@ pub(super) fn layout_declared_at_allocation(ctx: &FnCtx<'_>, class_name: &str) - // which denies an at-allocation declaration to every subclass instance and // puts every constructor store on the whole chain — the base class's own // included — on the by-name fallback. Try the chain form. - super::field_init::chain_prologue_assigned_fields(ctx.classes, class_name).is_some_and( - |chain| { - crate::typed_shape::class_chain_layout_declarable_at_allocation(ctx.classes, &chain) - }, - ) + super::field_init::chain_prologue_assigned_fields(classes, class_name).is_some_and(|chain| { + crate::typed_shape::class_chain_layout_declarable_at_allocation(classes, &chain) + }) } /// #7834: is `class_name`'s at-allocation declaration expressible as a @@ -81,10 +91,33 @@ pub(super) fn layout_pointer_free_at_allocation( class_name: &str, field_count: u32, ) -> bool { - if !layout_declared_at_allocation(ctx, class_name) { + layout_pointer_free_at_allocation_in( + ctx.classes, + ctx.class_keys_globals, + ctx.class_init_chains, + class_name, + field_count, + ) +} + +/// [`layout_pointer_free_at_allocation`] over the module-level maps (#8122; see +/// [`layout_declared_at_allocation_in`]). +pub(crate) fn layout_pointer_free_at_allocation_in( + classes: &std::collections::HashMap, + class_keys_globals: &std::collections::HashMap, + class_init_chains: &std::collections::HashMap< + String, + Vec<(String, Vec)>, + >, + class_name: &str, + field_count: u32, +) -> bool { + if !layout_declared_at_allocation_in(classes, class_keys_globals, class_name) { return false; } - let Some(typed_layout) = resolve_typed_layout(ctx, class_name) else { + let Some(typed_layout) = + resolve_typed_layout_in(classes, class_keys_globals, class_init_chains, class_name) + else { return false; }; typed_layout.pointer_mask_words.is_empty() && typed_layout.slot_count == field_count @@ -187,12 +220,29 @@ fn resolve_typed_layout( ctx: &FnCtx<'_>, class_name: &str, ) -> Option { - ctx.class_keys_globals.get(class_name)?; + resolve_typed_layout_in( + ctx.classes, + ctx.class_keys_globals, + ctx.class_init_chains, + class_name, + ) +} + +fn resolve_typed_layout_in( + classes: &std::collections::HashMap, + class_keys_globals: &std::collections::HashMap, + class_init_chains: &std::collections::HashMap< + String, + Vec<(String, Vec)>, + >, + class_name: &str, +) -> Option { + class_keys_globals.get(class_name)?; Some( - ctx.class_init_chains + class_init_chains .get(class_name) .map(|chain| crate::typed_shape::class_typed_layout_from_chain(chain)) - .unwrap_or_else(|| crate::typed_shape::class_typed_layout(ctx.classes, class_name)), + .unwrap_or_else(|| crate::typed_shape::class_typed_layout(classes, class_name)), ) } diff --git a/crates/perry-codegen/src/target_layout.rs b/crates/perry-codegen/src/target_layout.rs index cc31467a33..7eb6155772 100644 --- a/crates/perry-codegen/src/target_layout.rs +++ b/crates/perry-codegen/src/target_layout.rs @@ -88,6 +88,73 @@ pub const INLINE_SLOT_FLOOR: u64 = 2; /// `INLINE_SLOT_FLOOR` as the string literal the IR emitters splice in. pub const INLINE_SLOT_FLOOR_LIT: &str = "2"; +/// The GcHeader (8 bytes). +pub(crate) const GC_HEADER_SIZE_BYTES: u64 = 8; +/// One inline field slot (a NaN-boxed f64). +pub(crate) const FIELD_SLOT_SIZE_BYTES: u64 = 8; + +/// Total bytes the inline `new` path bump-allocates for a class instance with +/// `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). +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 = + object_header_size_bytes(target_triple) + alloc_field_count * FIELD_SLOT_SIZE_BYTES; + (GC_HEADER_SIZE_BYTES + payload_size).next_multiple_of(FIELD_SLOT_SIZE_BYTES) +} + +/// The packed `GcHeader` word the inline `new` path stores at byte 0 of a +/// freshly bump-allocated class instance (little-endian): +/// +/// ```text +/// bits 0..7 = obj_type (u8) GC_TYPE_OBJECT +/// bits 8..15 = gc_flags (u8) GC_FLAG_ARENA +/// bits 16..31 = _reserved (u16) GC_LAYOUT_POINTER_FREE [| GC_OBJ_TYPED_LAYOUT_INTACT] +/// bits 32..63 = size (u32) inline_alloc_total_size_bytes +/// ``` +/// +/// `typed_intact` is #7834's bake: when the class's canonical layout is +/// declarable at allocation AND its pointer mask is statically empty, the +/// intact bit is folded into this constant and the per-instance +/// `js_gc_declare_typed_shape_layout` call is skipped. +/// +/// #8122: ONE definition, shared by the allocation site +/// (`lower_call/new_alloc.rs`) and the module-level header-image table +/// (`codegen/mod.rs`) that pre-composes `[gc_packed | class_id | ShapeId<<32]` +/// into a per-class global at module init. Both sides must agree byte for +/// byte — a divergence would publish objects whose recorded size or layout +/// state the collector cannot trust — so the arithmetic lives here and the +/// site cross-checks the table's value against its own before using it. +pub(crate) fn inline_alloc_gc_packed( + target_triple: &str, + field_count: u32, + typed_intact: bool, +) -> u64 { + const GC_TYPE_OBJECT: u64 = 2; + const GC_FLAG_ARENA: u64 = 0x02; + // PR #1146: pointer-free hint for inline-allocated regular objects. The + // field-store sites issue per-slot `js_gc_note_slot_layout` so the GC + // sees real pointer-bearing slots regardless of this initial tag. + const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; + /// `GC_OBJ_TYPED_LAYOUT_INTACT` — the bit `class_field_inline_guard` + /// requires before it will read or write a raw-f64 slot directly. + /// Runtime-side name: `gc::layout::GC_OBJ_TYPED_LAYOUT_INTACT`. + const GC_OBJ_TYPED_LAYOUT_INTACT: u64 = 0x1000; + let typed_intact_bits = if typed_intact { + GC_OBJ_TYPED_LAYOUT_INTACT + } else { + 0 + }; + GC_TYPE_OBJECT + | (GC_FLAG_ARENA << 8) + | ((GC_LAYOUT_POINTER_FREE | typed_intact_bits) << 16) + | (inline_alloc_total_size_bytes(target_triple, field_count) << 32) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-codegen/src/typed_shape.rs b/crates/perry-codegen/src/typed_shape.rs index 7fd73ea465..17d89af661 100644 --- a/crates/perry-codegen/src/typed_shape.rs +++ b/crates/perry-codegen/src/typed_shape.rs @@ -384,6 +384,19 @@ pub(crate) fn shape_id_global_name_from_keys_global(keys_global_name: &str) -> S .unwrap_or_else(|| format!("perry_class_shape_id_{}", keys_global_name)) } +/// #8122: the per-class `<2 x i64>` header-image global paired with a class's +/// canonical keys global — `[packed GcHeader word | class_id | ShapeId << 32]`, +/// composed once at module init right after the ShapeId mint, so the inline +/// `new` path stores an instance's 16-byte header prefix with one vector +/// store instead of composing it per site (or per call, in a recursive +/// allocator). +pub(crate) fn header_image_global_name_from_keys_global(keys_global_name: &str) -> String { + keys_global_name + .strip_prefix("perry_class_keys_") + .map(|suffix| format!("perry_class_header_image_{}", suffix)) + .unwrap_or_else(|| format!("perry_class_header_image_{}", keys_global_name)) +} + /// Load the immutable ShapeId paired with a class's canonical keys global. /// /// Cache it in a function-entry alloca: an opaque allocation/runtime call can @@ -394,16 +407,27 @@ pub(crate) fn load_class_shape_id( class_name: &str, keys_global_name: &str, ) -> String { - let shape_slot = if let Some(slot) = ctx.class_shape_slots.get(class_name).cloned() { - slot - } else { - let shape_global = shape_id_global_name_from_keys_global(keys_global_name); - let slot = ctx - .func - .entry_init_load_global(&shape_global, crate::types::I32); - ctx.class_shape_slots - .insert(class_name.to_string(), slot.clone()); - slot - }; + let shape_slot = ensure_class_shape_slot(ctx, class_name, keys_global_name); ctx.block().load(crate::types::I32, &shape_slot) } + +/// The function-entry alloca that caches a class's ShapeId global (see +/// [`load_class_shape_id`]), created on first use. Split out (#8122) so the +/// inline `new` path can compose its per-function header image from the slot +/// without emitting a per-site load it does not need. +pub(crate) fn ensure_class_shape_slot( + ctx: &mut crate::expr::FnCtx<'_>, + class_name: &str, + keys_global_name: &str, +) -> String { + if let Some(slot) = ctx.class_shape_slots.get(class_name).cloned() { + return slot; + } + let shape_global = shape_id_global_name_from_keys_global(keys_global_name); + let slot = ctx + .func + .entry_init_load_global(&shape_global, crate::types::I32); + ctx.class_shape_slots + .insert(class_name.to_string(), slot.clone()); + slot +} diff --git a/crates/perry-runtime/src/arena/mod.rs b/crates/perry-runtime/src/arena/mod.rs index 4ac7e1aebe..86be28aeb5 100644 --- a/crates/perry-runtime/src/arena/mod.rs +++ b/crates/perry-runtime/src/arena/mod.rs @@ -86,8 +86,9 @@ pub use walk::{ }; pub(crate) use walk::{ arena_block_snapshots, arena_telemetry_snapshot, general_block_in_recent_window, - general_block_sizes, old_arena_walk_all_headers_filtered, ArenaBlockSnapshot, - ArenaObjectCursor, ArenaObjectCursorBuilder, ArenaTelemetrySnapshot, ArenaWalkOrder, + general_block_sizes, old_arena_walk_all_headers_filtered, young_allocation_census, + ArenaBlockSnapshot, ArenaObjectCursor, ArenaObjectCursorBuilder, ArenaTelemetrySnapshot, + ArenaWalkOrder, }; // reset.rs diff --git a/crates/perry-runtime/src/arena/walk.rs b/crates/perry-runtime/src/arena/walk.rs index 2eb35cc0eb..63d860fe09 100644 --- a/crates/perry-runtime/src/arena/walk.rs +++ b/crates/perry-runtime/src/arena/walk.rs @@ -604,6 +604,56 @@ pub fn arena_walk_objects(mut callback: impl FnMut(*mut u8)) { }); } +/// Count the objects the young generation currently holds — Eden plus the +/// ACTIVE survivor semispace, i.e. the copying collector's from-space — and +/// their total bytes, by hopping headers exactly as [`arena_walk_objects`] +/// does. Live and dead alike: this is an ALLOCATION census, not a liveness +/// one. +/// +/// #8122: feeds `gc::tenuring::maybe_seed_object_census_from_allocation`, +/// the one-time seed of the nursery cap's object denomination BEFORE the +/// first copying minor has produced a survivor census. It reads only header +/// `size`/`obj_type` words and stops at the first implausible header the way +/// the general walk does, so it is safe at the block-allocation trigger point +/// where it runs (every prior allocation is complete there; the current one +/// has not been carved yet). +pub(crate) fn young_allocation_census() -> (usize, usize) { + use crate::gc::GcHeader; + + sync_inline_arena_state(); + let mut bytes = 0usize; + let mut objects = 0usize; + let mut census_region = |blocks: &[ArenaBlock]| { + for block in blocks { + let mut offset = 0usize; + while offset < block.offset { + let aligned = (offset + 7) & !7; + if aligned >= block.offset { + break; + } + let header = unsafe { block.data.add(aligned) } as *const GcHeader; + let (total_size, obj_type) = + unsafe { ((*header).size as usize, (*header).obj_type) }; + if total_size == 0 || total_size > block.size { + break; + } + if crate::gc::gc_type_is_arena_walkable(obj_type) { + bytes += total_size; + objects += 1; + } + offset = aligned + total_size; + } + } + }; + ARENA.with(|arena| { + let arena = unsafe { &*arena.get() }; + census_region(&arena.blocks); + }); + let active = ACTIVE_SURVIVOR.with(|active| active.get()); + with_survivor_arena(active, |arena| census_region(&arena.blocks)); + (bytes, objects) +} + /// Walk only objects physically allocated in the old-generation arena. /// Dirty-page remembered scanning uses this to process old-gen modbuf /// pages without touching nursery or longlived blocks. diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index d836834708..2dcbc1f7b4 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -176,51 +176,68 @@ fn shape_layout_keyed_enabled() -> bool { *E.get_or_init(|| super::env_default_on_enabled("PERRY_SHAPE_LAYOUT_KEYED")) } -/// keys_array only exists on genuine shaped objects (`ObjectFields`). Arrays, -/// closures, RegExps etc. also flow through `layout_note_slot` / -/// `layout_visit_pointer_slots`, and reading `ObjectHeader::keys_array` off one -/// would interpret unrelated payload bytes as a pointer. Returns 0 for anything -/// that is not an ObjectFields object (⟹ callers skip the shared shape path). +/// Borrow the shared canonical descriptor for `user_ptr`'s shape, if +/// shape-keying is on, the object carries a keys_array, and the shape is +/// unambiguous (`Some`). Runs `f` against the descriptor in place — the GC +/// trace path and the store fast path both consult it per object/per store, and +/// a `Heap` mask would allocate a `Vec` on every clone. #[inline] -unsafe fn object_keys_array_ptr(user_ptr: usize) -> usize { +unsafe fn with_shape_shared_descriptor( + user_ptr: usize, + f: impl Fn(&TypedLayoutDescriptor) -> R, +) -> Option { + if !shape_layout_keyed_enabled() { + return None; + } + // keys_array / ShapeId only exist on genuine shaped objects + // (`ObjectFields`). Arrays, closures, RegExps etc. also flow through + // `layout_note_slot` / `layout_visit_pointer_slots`, and reading those + // header words off one would interpret unrelated payload bytes as a + // pointer. Anything else skips the shared shape path. if user_ptr < GC_HEADER_SIZE + 0x1000 { - return 0; + return None; } let header = header_from_user_ptr(user_ptr as *const u8); if gc_type_layout_slot_kind((*header).obj_type) != GcLayoutSlotKind::ObjectFields { - return 0; + return None; } let object = user_ptr as *const crate::object::ObjectHeader; - crate::object::shapes::object_shape_descriptor(object) - .map(|descriptor| descriptor.keys as usize) - .unwrap_or((*object).keys_array as usize) + // ONE shape-table probe (#8122). This used to be two — one for the keys + // edge (the retired `object_keys_array_ptr`) and one here for the live + // bound — on every field store that reaches it and on every traced object. + let descriptor = crate::object::shapes::object_shape_descriptor(object); + with_shape_shared_descriptor_from(user_ptr, descriptor, f) } -/// Borrow the shared canonical descriptor for `user_ptr`'s shape, if -/// shape-keying is on, the object carries a keys_array, and the shape is -/// unambiguous (`Some`). Runs `f` against the descriptor in place — the GC -/// trace path and the store fast path both consult it per object/per store, and -/// a `Heap` mask would allocate a `Vec` on every clone. +/// [`with_shape_shared_descriptor`] against a receiver `ShapeDescriptor` the +/// caller has already resolved (or found absent). The receiver MUST be an +/// ObjectFields object — this skips the kind screen the probing form applies. +/// +/// #8122: the collector's per-object path resolves the descriptor once in +/// `gc_child_slots` and hands it down here through +/// [`HeapChildSlotIterator::new_object`], instead of re-probing the shape +/// table for the keys edge and again for the live bound. #[inline] -unsafe fn with_shape_shared_descriptor( +unsafe fn with_shape_shared_descriptor_from( user_ptr: usize, + descriptor: Option, f: impl Fn(&TypedLayoutDescriptor) -> R, ) -> Option { if !shape_layout_keyed_enabled() { return None; } - let keys = object_keys_array_ptr(user_ptr); + let object = user_ptr as *const crate::object::ObjectHeader; + // Keys edge: the descriptor's when stamped, else the header mirror. + let keys = descriptor + .map(|descriptor| descriptor.keys as usize) + .unwrap_or((*object).keys_array as usize); if keys == 0 { return None; } // Defense-in-depth: both descriptor families must agree on the exact live - // bound. The ObjectHeader count is only an ABI mirror pending #8047. - let object = user_ptr as *const crate::object::ObjectHeader; - // #8113: 0, not a second descriptor probe. `unwrap_or` is EAGER, so - // re-deriving the bound cost a whole extra shape-table lookup on every - // call — and the bound has no other source now, so the fallback could only - // ever have returned 0 anyway. - let field_count = crate::object::shapes::object_shape_descriptor(object) + // bound. #8113: an unstamped receiver has no bound anywhere, so 0 — not a + // second probe (`unwrap_or` is eager). + let field_count = descriptor .map(|descriptor| descriptor.live_inline_slot_count as usize) .unwrap_or(0); let map = hot_shape_layouts().borrow(); @@ -291,6 +308,21 @@ unsafe fn shape_shared_pointer_mask( with_shape_shared_descriptor(user_ptr, |d| d.pointer_mask.clone()) } +/// [`shape_shared_pointer_mask`] for an ObjectFields receiver whose +/// `ShapeDescriptor` the caller already resolved (#8122, see +/// [`with_shape_shared_descriptor_from`]). +#[inline] +unsafe fn shape_shared_pointer_mask_from( + user_ptr: usize, + header: *const GcHeader, + descriptor: Option, +) -> Option { + if (*header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT == 0 { + return None; + } + with_shape_shared_descriptor_from(user_ptr, descriptor, |d| d.pointer_mask.clone()) +} + /// Install `descriptor` as the canonical layout for `keys` and set the object's /// header state (INTACT + POINTER_FREE/SIDE_MASK), WITHOUT any per-object map /// entry. Returns `true` if the object now rides the shared shape descriptor; @@ -1084,9 +1116,9 @@ unsafe fn init_typed_shape_layout( // program constants. See `gc::shape_install` for the full staleness // argument. // - // `object_keys_array_ptr`'s two guards are already discharged above (the - // low addresses were rejected, `GcLayoutSlotKind::ObjectFields` was - // checked), so read the field directly rather than re-walking the header. + // The receiver-kind guards are already discharged above (the low + // addresses were rejected, `GcLayoutSlotKind::ObjectFields` was checked), + // so read the field directly rather than re-walking the header. let keys = shape_descriptor .map(|descriptor| descriptor.keys as usize) .unwrap_or((*obj_header).keys_array as usize); @@ -1113,6 +1145,44 @@ unsafe fn init_typed_shape_layout( return; } + install_typed_shape_layout_slow( + user_ptr, + header, + keys, + slot_count, + raw_f64_words, + raw_f64_word_count, + pointer_words, + pointer_word_count, + ); +} + +/// The memo-MISS tail of [`init_typed_shape_layout`]: build the descriptor, +/// install it (shared by shape, else per object), record the memo. +/// +/// #8122: kept OUT OF LINE on purpose. Everything above the memo probe runs on +/// every construction; this runs once per shape (plus downgrades). When LLVM's +/// LTO inliner chose to fold this tail — with `shape_install_shared` and +/// `shape_install::record` folded into it in turn — into the hot prologue, the +/// per-construction path became an 811-instruction function whose prologue, +/// spills and register pressure were paid on every memo hit: measured as a +/// reproducible +3.9% instructions on `pipeline` between two builds of the +/// SAME hot-path code, differing only in an unrelated module's size. `#[cold]` +/// + `#[inline(never)]` pins the shape the profile wants regardless of what +/// else moves in the crate. +#[cold] +#[inline(never)] +#[allow(clippy::too_many_arguments)] +unsafe fn install_typed_shape_layout_slow( + user_ptr: usize, + header: *mut GcHeader, + keys: usize, + slot_count: usize, + raw_f64_words: *const u64, + raw_f64_word_count: u32, + pointer_words: *const u64, + pointer_word_count: u32, +) { let raw_f64_slice = mask_words(raw_f64_words, raw_f64_word_count); let pointer_slice = mask_words(pointer_words, pointer_word_count); @@ -1593,6 +1663,12 @@ pub(crate) struct HeapChildSlotIterator { pub(super) meta_slot: Option<*mut u64>, pub(super) payload: HeapSlotRange, pub(super) selection: HeapPayloadSlotSelection, + /// #8122: the receiver's `ShapeDescriptor`, resolved ONCE by + /// [`gc_child_slots`] for an ObjectFields object and carried here so + /// `visit_gc_layout_slot_descriptors` reads the same facts instead of + /// probing the shape table again. `None` for every other kind, and for + /// an unstamped object. + pub(super) object_shape: Option, } impl HeapChildSlotIterator { @@ -1602,6 +1678,7 @@ impl HeapChildSlotIterator { meta_slot: None, payload: HeapSlotRange::new(std::ptr::null_mut(), 0), selection: HeapPayloadSlotSelection::Empty, + object_shape: None, } } @@ -1616,6 +1693,27 @@ impl HeapChildSlotIterator { meta_slot: None, payload, selection, + object_shape: None, + } + } + + /// [`Self::new`] for an ObjectFields receiver whose `ShapeDescriptor` the + /// caller already resolved (#8122). The payload-mask selection reuses it + /// instead of probing the shape table, and it is retained on the iterator + /// for the slot visitor. + pub(super) fn new_object( + header: *mut GcHeader, + prefix_slot: Option<*mut u64>, + payload: HeapSlotRange, + object_shape: Option, + ) -> Self { + let selection = unsafe { heap_payload_slot_selection_from(header, payload, object_shape) }; + Self { + prefix_slot, + meta_slot: None, + payload, + selection, + object_shape, } } @@ -1737,6 +1835,30 @@ impl Iterator for HeapChildSlotIterator { pub(super) unsafe fn heap_payload_slot_selection( header: *mut GcHeader, payload: HeapSlotRange, +) -> HeapPayloadSlotSelection { + heap_payload_slot_selection_impl(header, payload, |user_ptr, header| { + shape_shared_pointer_mask(user_ptr, header) + }) +} + +/// [`heap_payload_slot_selection`] for an ObjectFields receiver whose +/// `ShapeDescriptor` the caller already resolved (#8122): the shared-shape +/// pointer-mask lookup reuses it instead of probing the shape table twice. +pub(super) unsafe fn heap_payload_slot_selection_from( + header: *mut GcHeader, + payload: HeapSlotRange, + descriptor: Option, +) -> HeapPayloadSlotSelection { + heap_payload_slot_selection_impl(header, payload, |user_ptr, header| { + shape_shared_pointer_mask_from(user_ptr, header, descriptor) + }) +} + +#[inline] +unsafe fn heap_payload_slot_selection_impl( + header: *mut GcHeader, + payload: HeapSlotRange, + shared_mask: impl FnOnce(usize, *const GcHeader) -> Option, ) -> HeapPayloadSlotSelection { if header.is_null() || payload.is_empty() { return HeapPayloadSlotSelection::Empty; @@ -1774,8 +1896,7 @@ pub(super) unsafe fn heap_payload_slot_selection( raw_numeric_recorded: false, }; } - let mask = per_object_slot_mask(user_ptr) - .or_else(|| shape_shared_pointer_mask(user_ptr, header)); + let mask = per_object_slot_mask(user_ptr).or_else(|| shared_mask(user_ptr, header)); match mask { Some(mask) => HeapPayloadSlotSelection::Masked { mask, @@ -1807,17 +1928,24 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera } GcLayoutSlotKind::ObjectFields => { let obj = user_ptr as *mut crate::object::ObjectHeader; - let Some(range) = crate::object::gc_field_slot_range(obj) else { + // #8122: resolve the receiver's ShapeDescriptor ONCE and thread it + // through every step that needs a shape fact — the field range, + // the keys edge, the shared pointer mask (`new_object`) and the + // slot visitor (`object_shape` on the iterator). These used to be + // five independent `shape_descriptor_by_id` probes per traced + // object, the top leaf of a traced in-place-promotion cycle. + let descriptor = crate::object::shapes::object_shape_descriptor(obj); + let Some(range) = crate::object::gc_field_slot_range(obj, descriptor) else { return HeapChildSlotIterator::empty(); }; - let keys_slot = crate::object::gc_keys_array_slot(obj); + let keys_slot = crate::object::gc_keys_array_slot(obj, descriptor); // #6812: the meta record is a raw-pointer child edge; before the // spill buffer it was enumerated only on the rewrite path, which // left it invisible to MARKING (latent for custom prototypes, // which are usually rooted elsewhere; fatal for the spill // buffer, reachable through meta alone). A second prefix slot // keeps payload slot indices aligned with the layout masks. - HeapChildSlotIterator::new(header, keys_slot, range) + HeapChildSlotIterator::new_object(header, keys_slot, range, descriptor) .with_meta_slot(crate::object::gc_object_meta_slot(user_ptr as usize)) } GcLayoutSlotKind::RegExpFields => { diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 0d7088aba1..d2579a8f63 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -21,7 +21,10 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( // table is updated below. let object_shape_facts = if (*header).obj_type == GC_TYPE_OBJECT { let obj = (header as *mut u8).add(GC_HEADER_SIZE) as *mut crate::object::ObjectHeader; - let descriptor = crate::object::shapes::object_shape_descriptor(obj); + // #8122: the descriptor `gc_child_slots` resolved for this receiver. + // Nothing between that probe and here can allocate or mutate the + // shape table, so it is the same value a fresh probe would return. + let descriptor = child_slots.object_shape; let old_keys = descriptor .map(|facts| facts.keys as usize as *mut crate::array::ArrayHeader) .unwrap_or((*obj).keys_array); diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 4ff95fe2a6..e77a6e6db5 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -108,7 +108,16 @@ pub(super) fn young_scavenge_cap_due() -> bool { if !nursery_cap_active() { return false; } - crate::arena::copying_from_space_in_use_bytes() >= scavenge_nursery_cap_dueness_bytes() + let from_space_in_use = crate::arena::copying_from_space_in_use_bytes(); + // #8122: before the first copying minor has measured survivors, denominate + // the FIRST cap in this program's objects too (one header walk, once per + // process, halfway to the base cap). Not while a collection is in + // progress or a budgeted cycle is active — the young generation is being + // rewritten then and the walk would read forwarding stubs. + if GC_FLAGS.with(|f| f.get()) & GC_FLAG_IN_ALLOC == 0 && !gc_budgeted_cycle_active() { + super::tenuring::maybe_seed_object_census_from_allocation(from_space_in_use); + } + from_space_in_use >= scavenge_nursery_cap_dueness_bytes() } /// The cap value [`young_scavenge_cap_due`] compares against. diff --git a/crates/perry-runtime/src/gc/promote_in_place.rs b/crates/perry-runtime/src/gc/promote_in_place.rs index fa81c07078..9e36e15ad0 100644 --- a/crates/perry-runtime/src/gc/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/promote_in_place.rs @@ -101,7 +101,24 @@ pub(super) const PROMOTE_SURVIVAL_THRESHOLD_PERMILLE: u64 = 950; /// paragraph above described a bound nothing enforced. The untraced-bytes /// budget remains the binding bound, and it is now itself capped — see /// [`untraced_promotion_budget_bytes`]. -pub(super) const UNTRACED_PROMOTION_SURVIVAL_PERMILLE: u64 = 990; +/// +/// # Why this is 980 and not 990 (#8122) +/// +/// The 992 above was read off a FIRST cycle that fired on the raw 16 MB byte +/// band, before any object census existed. #8122 seeds the nursery cap's +/// object denomination from an allocation census before the first minor +/// (`gc::tenuring::maybe_seed_object_census_from_allocation`), so that cycle +/// now fires when the band's OBJECT budget is spent — ~11 MB for a 48 B +/// two-field object. The startup garbage is unchanged (the same ~131 KB of +/// abandoned `all.push` backing stores), so as a fraction of a smaller first +/// nursery `retain`/`retain1` measure **988** on their first cycle — and at +/// 990 the second cycle traced again, costing `retain1` +13% instructions. +/// The live mode is now 988–1000; the garbage mode is still 0–4. 980 keeps a +/// wider margin under the live mode than 990 kept under 992, and the exposure +/// arithmetic above becomes 2.56 MB against the same 32 MB cap — still an +/// order of magnitude under it, and the untraced-bytes budget is still the +/// binding bound. +pub(super) const UNTRACED_PROMOTION_SURVIVAL_PERMILLE: u64 = 980; /// Floor for the untraced-promotion budget on an UNCONSTRAINED heap — see /// [`untraced_promotion_budget_bytes`]. diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 83d098cda3..4fd221261f 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -168,9 +168,15 @@ thread_local! { static CAP_SHRINK_STREAK: Cell = const { Cell::new(0) }; /// #7929: mean size of the objects the last copying minor moved. Seeded at /// the calibration reference so a process with no completed copying minor - /// paces exactly as it did before the object denomination existed. + /// paces exactly as it did before the object denomination existed — until + /// #8122's allocation census (below) replaces the seed with a measurement + /// of THIS program's objects, halfway to the first cap. static MEAN_SURVIVING_OBJECT_BYTES: Cell = const { Cell::new(NURSERY_CAP_REFERENCE_OBJECT_BYTES) }; + /// #8122: has ANY census — the collector's survivor census or the one-time + /// allocation census — replaced the seed? Once true the allocation probe + /// never runs again (its walk is paid at most once per process). + static OBJECT_CENSUS_SEEDED: Cell = const { Cell::new(false) }; } /// The survivals threshold the next copying minor should promote at: @@ -347,6 +353,7 @@ pub(super) fn note_surviving_object_census(moved_bytes: usize, moved_objects: us if mean == 0 { return; } + OBJECT_CENSUS_SEEDED.with(|seeded| seeded.set(true)); let previous = MEAN_SURVIVING_OBJECT_BYTES.replace(mean); if previous != mean && crate::gc::gc_diag_enabled() { eprintln!( @@ -360,6 +367,71 @@ pub(super) fn note_surviving_object_census(moved_bytes: usize, moved_objects: us } } +/// #8122: seed the object denomination from an ALLOCATION census, once, before +/// the first copying minor exists to measure survivors. +/// +/// # Why +/// +/// The constant band is denominated in objects (#7929) only once a copying +/// minor has reported what it moved. Until then `MEAN_SURVIVING_OBJECT_BYTES` +/// holds the 72 B calibration reference, so the FIRST minor fires on the raw +/// 16 MB byte band whatever the program's objects actually weigh: for a 56 B +/// object that is 300k objects, for a 48 B one 350k, against the 233k the band +/// was calibrated to buy. That first minor is also the one that has to TRACE +/// (no survival estimate exists yet, so it cannot promote untraced), and a +/// traced in-place-promotion cycle costs ~1,600 instructions per object. +/// Measured on `deeplist`/`retain1` when the two-field object went 56 → 48 B: +/// the whole +8–9% instruction regression was those extra first-cycle objects; +/// the mutator with no collection at all was byte-identical. +/// +/// # What it does +/// +/// Halfway to the configured base cap (8 MB of from-space by default) — a +/// point every program that will ever reach the cap passes exactly once — hop +/// the young generation's headers (`arena::young_allocation_census`, ~1M +/// instructions for 8 MB of small objects, paid ONCE per process) and install +/// `bytes / objects` as the mean. The first minor is then object-denominated +/// like every later one, and a smaller representation no longer buys the +/// collector a bigger first trace. The one-sided clamp still applies: a mean +/// above the reference leaves the 16 MB band untouched, so an array-dominated +/// allocation stream cannot raise the cap. The collector's survivor census +/// overwrites this seed at the first minor, so steady state is unchanged. +/// +/// Returns without walking when the base cap is not yet half full, when a +/// census (either kind) already exists, or when the nursery is empty. +pub(super) fn maybe_seed_object_census_from_allocation(from_space_in_use_bytes: usize) { + if OBJECT_CENSUS_SEEDED.with(Cell::get) { + return; + } + if from_space_in_use_bytes < super::policy::gc_scavenge_nursery_cap_bytes() / 2 { + return; + } + // Take the walk at most once even if it yields nothing (an empty or + // header-less nursery): the flag is the bound on its cost. + OBJECT_CENSUS_SEEDED.with(|seeded| seeded.set(true)); + let (bytes, objects) = crate::arena::young_allocation_census(); + if objects == 0 { + return; + } + let mean = bytes / objects; + if mean == 0 { + return; + } + let previous = MEAN_SURVIVING_OBJECT_BYTES.replace(mean); + if crate::gc::gc_diag_enabled() { + eprintln!( + "[gc-tenuring] nursery cap object denomination: allocation census seeded \ + mean_object_bytes {} -> {} ({} objects / {} B in from-space; scale {} permille, band {} B)", + previous, + mean, + objects, + bytes, + nursery_cap_object_scale_permille(mean), + influx_driven_nursery_cap_bytes() + ); + } +} + /// The cap policy as a **pure function of its two inputs**, so it is testable /// without arranging a heap state. /// @@ -622,6 +694,14 @@ pub(super) fn reset_for_test() { CAP_GROW_STREAK.with(|s| s.set(0)); CAP_SHRINK_STREAK.with(|s| s.set(0)); MEAN_SURVIVING_OBJECT_BYTES.with(|s| s.set(NURSERY_CAP_REFERENCE_OBJECT_BYTES)); + OBJECT_CENSUS_SEEDED.with(|s| s.set(false)); +} + +/// Test-only view of the seed flag (#8122): has any census replaced the +/// calibration seed yet? +#[cfg(test)] +pub(super) fn object_census_seeded_for_test() -> bool { + OBJECT_CENSUS_SEEDED.with(Cell::get) } #[cfg(test)] @@ -723,6 +803,47 @@ mod tests { assert_eq!(influx_driven_nursery_cap_bytes(), base); } + /// #8122: the allocation census seeds the mean ONCE, only past half the + /// base cap, and never after a census of either kind exists. + /// + /// Pure-policy half. The walk itself (a real nursery, real headers) is + /// driven in `gc::tests::copying::adaptive_tenuring`; this pins the gate + /// logic that decides whether the walk runs, in isolation from the heap. + #[test] + fn allocation_census_seed_is_gated_and_one_shot() { + reset_for_test(); + let base = gc_scavenge_nursery_cap_bytes(); + assert!(!object_census_seeded_for_test()); + + // Below half the base cap: nothing happens, the seed stays armed. + maybe_seed_object_census_from_allocation(base / 2 - 1); + assert!( + !object_census_seeded_for_test(), + "the probe must not fire below half the base cap" + ); + assert_eq!( + mean_surviving_object_bytes(), + NURSERY_CAP_REFERENCE_OBJECT_BYTES + ); + + // A collector census arriving first disarms the probe for good: the + // survivor mean is strictly better information than the allocation + // mean, and the walk must never overwrite it. + note_surviving_object_census(56 * 1000, 1000); + assert!(object_census_seeded_for_test()); + maybe_seed_object_census_from_allocation(base); + assert_eq!( + mean_surviving_object_bytes(), + 56, + "an allocation census must never replace a survivor census" + ); + reset_for_test(); + assert!( + !object_census_seeded_for_test(), + "reset must re-arm the probe" + ); + } + /// The tenured-proportional term is representation-invariant by /// cancellation (`tenured_bytes / 2` is `tenured_objects / 2` objects), so /// the object denomination must apply to the constant band ONLY. If it diff --git a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs index 7a51bd802e..9abfa5a6e2 100644 --- a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs +++ b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs @@ -126,6 +126,89 @@ fn copying_minor_feeds_the_object_denomination_census() { ); } +/// #8122: BEFORE any copying minor has run, once the young generation is +/// half-way to the base cap, one header walk seeds the object denomination +/// with the mean size of what was actually allocated — so the FIRST minor is +/// object-denominated too, and a smaller representation stops buying the +/// collector a bigger first trace. +/// +/// ★ The discriminating quantities: (1) the seeded mean equals the mean of +/// THIS nursery's headers, computed independently here from the same objects, +/// and differs from the 72 B seed; (2) the effective cap has already moved +/// with it, before a single collection; (3) the walk is one-shot — allocating +/// a different population afterwards leaves the seed untouched. +#[test] +fn allocation_census_seeds_the_first_cap_before_any_minor() { + let _guard = CopyingNurseryTestGuard::new(SLOTS); + // Pin the shipped (moving) pacing so `young_scavenge_cap_due` — the probe's + // caller — is live rather than inheriting the process default; and keep the + // automatic triggers out so nothing collects under the fixture. + let _pacing = crate::gc::policy::force_moving_gc_pacing(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let base = crate::gc::policy::gc_scavenge_nursery_cap_bytes(); + assert_eq!( + crate::gc::tenuring::mean_surviving_object_bytes(), + crate::gc::tenuring::NURSERY_CAP_REFERENCE_OBJECT_BYTES, + "guard must start every test at the calibration seed" + ); + assert!(!crate::gc::tenuring::object_census_seeded_for_test()); + + // Fill Eden past half the base cap with two-field object literals — the + // representation whose shrink motivated this. Unrooted is fine: nothing + // collects here, and an allocation census counts dead objects too. + let (mut allocated_bytes, mut allocated_objects) = (0usize, 0usize); + while crate::arena::copying_from_space_in_use_bytes() < base / 2 + 64 * 1024 { + for _ in 0..1024 { + let obj = crate::object::js_object_alloc(0, 2); + let header = unsafe { crate::value::addr_class::try_read_gc_header(obj as usize) } + .expect("fresh literal has a GcHeader"); + allocated_bytes += header.size as usize; + allocated_objects += 1; + } + } + let expected_mean = allocated_bytes / allocated_objects; + assert!( + expected_mean < crate::gc::tenuring::NURSERY_CAP_REFERENCE_OBJECT_BYTES, + "fixture must exercise the SCALING arm (mean {expected_mean} B)" + ); + + // The probe runs from the cap-dueness check the block allocator drives. + let _ = crate::gc::policy::young_scavenge_cap_due(); + assert!( + crate::gc::tenuring::object_census_seeded_for_test(), + "half-way to the base cap the allocation census must have run" + ); + let seeded = crate::gc::tenuring::mean_surviving_object_bytes(); + // The nursery may hold a few pre-existing objects from the guard's own + // setup; allow the mean to differ from the literal-only figure by one + // byte of rounding, but it must be THIS population's size, not the seed. + assert!( + seeded.abs_diff(expected_mean) <= 1, + "seed must be the allocated population's mean: {seeded} B vs {expected_mean} B" + ); + assert_ne!( + seeded, + crate::gc::tenuring::NURSERY_CAP_REFERENCE_OBJECT_BYTES + ); + // ...and the first cap already reflects it — before any collection. + assert_eq!( + crate::gc::tenuring::influx_driven_nursery_cap_bytes(), + base * crate::gc::tenuring::nursery_cap_object_scale_permille(seeded) / 1000 + ); + + // One-shot: a different population allocated afterwards does not move + // the seed (the collector's survivor census owns it from here on). + for _ in 0..4096 { + let _ = crate::object::js_object_alloc(0, 8); + } + let _ = crate::gc::policy::young_scavenge_cap_due(); + assert_eq!( + crate::gc::tenuring::mean_surviving_object_bytes(), + seeded, + "the allocation walk is paid at most once per process" + ); +} + #[test] fn quiet_cycles_restore_power_on_threshold_debounced() { let _guard = CopyingNurseryTestGuard::new(SLOTS); diff --git a/crates/perry-runtime/src/gc/tests/promote_in_place.rs b/crates/perry-runtime/src/gc/tests/promote_in_place.rs index 8aa1c81ba0..8b4dc5b516 100644 --- a/crates/perry-runtime/src/gc/tests/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/tests/promote_in_place.rs @@ -233,14 +233,17 @@ fn an_untraced_cycle_charges_the_dead_bytes_its_last_measurement_implies() { // one bound; they differ only in whether the dead figure is measured or // extrapolated. let _guard = InPlacePromotionTestGuard::untraced(); - seed_young_survival_for_tests(990); + seed_young_survival_for_tests(UNTRACED_PROMOTION_SURVIVAL_PERMILLE); let before = promoted_dead_bytes_since_full(); note_untraced_promotion(100 * 1000, 1); + let expected_dead = 100 * 1000 * (1000 - UNTRACED_PROMOTION_SURVIVAL_PERMILLE as usize) / 1000; assert_eq!( promoted_dead_bytes_since_full() - before, - 1000, - "10 permille of 100_000 bytes is the dead figure a 990 permille \ - measurement implies" + expected_dead, + "{} permille of 100_000 bytes is the dead figure a {} permille \ + measurement implies", + 1000 - UNTRACED_PROMOTION_SURVIVAL_PERMILLE, + UNTRACED_PROMOTION_SURVIVAL_PERMILLE ); // ...and it is the SAME cap: enough implied dead bytes stop in-place @@ -278,8 +281,9 @@ fn a_stationary_fully_live_predictor_still_charges_implied_dead_bytes() { let before = promoted_dead_bytes_since_full(); note_untraced_promotion(100 * 1000, 1); let charged = promoted_dead_bytes_since_full() - before; + let at_threshold = 100 * 1000 * (1000 - UNTRACED_PROMOTION_SURVIVAL_PERMILLE as usize) / 1000; assert_eq!( - charged, 1000, + charged, at_threshold, "a 1000 permille predictor must still be charged at the threshold the \ decision admits ({UNTRACED_PROMOTION_SURVIVAL_PERMILLE} permille), not \ at its own optimism" diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 36acc0b52c..c38c72dfc8 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1589,13 +1589,15 @@ pub(crate) fn get_field_by_name_object_tail( } } - // Slow path: linear scan through keys array - let _field_count = crate::object::object_live_slot_count(obj) as usize; - - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // Slow path: linear scan through keys array. + // + // #8122: ONE shape-table probe for the live inline-slot bound, reused + // by every field read below and by the stamp; this used to be two + // probes here (one of them into an unused binding) plus one more + // inside every `js_object_get_field` the scan returned through. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = + std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; // #5054: wide objects get a validated key→index map so per-key reads // stay O(1) instead of O(key_count). A `None` falls through to the @@ -1616,7 +1618,7 @@ pub(crate) fn get_field_by_name_object_tail( } } return if (i as usize) < alloc_limit { - js_object_get_field(obj, i) + super::accessors::object_field_at_with_live(obj, i, live_slots) } else { match overflow_get(obj as usize, i as usize) { Some(bits) => JSValue::from_bits(bits), @@ -1649,7 +1651,7 @@ pub(crate) fn get_field_by_name_object_tail( obj as *mut ObjectHeader, keys, key_count as u32, - crate::object::object_live_slot_count(obj as *const ObjectHeader), + live_slots, ); let store_key = if id != 0 { id as usize } else { keys_id }; let store_idx = @@ -1675,7 +1677,7 @@ pub(crate) fn get_field_by_name_object_tail( } } if i < alloc_limit { - return js_object_get_field(obj, i as u32); + return super::accessors::object_field_at_with_live(obj, i as u32, live_slots); } else { return match overflow_get(obj as usize, i) { Some(bits) => JSValue::from_bits(bits), diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 48f1f25f33..9e7a9dda87 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -636,13 +636,32 @@ pub extern "C" fn js_object_get_field_ic_miss( (*gc_header).obj_type == crate::gc::GC_TYPE_OBJECT }; let has_own_descriptors = is_object && super::super::object_has_descriptors(obj as usize); - let is_regular = is_object && crate::object::object_is_regular(obj); + // #8122: ONE shape-table probe. `object_is_regular` is `GC_TYPE_OBJECT + // && !FORWARDED && descriptor.object_kind == Ordinary`; the kind test + // was already `GC_TYPE_OBJECT` above, so read the descriptor once and + // take the kind, the keys edge, the key count and the live bound from + // it — this path used to probe three times (regularity, the + // descriptor, then `object_shape_id` for the PIC token). + let shape = if is_object { + let gc_header = + (obj as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 { + crate::object::shapes::object_shape_descriptor(obj) + } else { + None + } + } else { + None + }; + let is_regular = shape.is_some_and(|shape| { + shape.object_kind == crate::object::shapes::ShapeObjectKind::Ordinary + }); // Gate-neutral builtin accessors deliberately leave the process-wide // accessor latch clear. Their owner bit must still block this PIC: // its generated hit path is a raw slot load and would otherwise turn // `Set.prototype.size` into `undefined` instead of invoking the getter. if can_cache && is_regular && !has_own_descriptors { - let Some(shape) = crate::object::shapes::object_shape_descriptor(obj) else { + let Some(shape) = shape else { let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); }; @@ -679,7 +698,9 @@ pub extern "C" fn js_object_get_field_ic_miss( // // The runtime and emitted hit path share one identity: // the authoritative, never-reused ShapeId token. - let stamp = crate::object::shapes::object_shape_id(obj); + // The descriptor resolved above, so the header stamp IS the + // shape id — no second probe. + let stamp = crate::object::shapes::object_shape_stamp(obj); let token = (stamp as u64 | crate::object::shapes::PIC_ID_TOKEN_BIT) as i64; pic_prime_get(cache, token, i as i64); let field_ptr = (obj as *const u8) diff --git a/crates/perry-runtime/src/object/gc_slots.rs b/crates/perry-runtime/src/object/gc_slots.rs index ce038c1f82..e13bf0cde5 100644 --- a/crates/perry-runtime/src/object/gc_slots.rs +++ b/crates/perry-runtime/src/object/gc_slots.rs @@ -1,11 +1,23 @@ use super::{shapes, ObjectHeader}; use crate::ArrayHeader; -pub(crate) unsafe fn gc_keys_array_slot(obj: *mut ObjectHeader) -> Option<*mut u64> { +/// The object's keys-array child slot, given the receiver's `ShapeDescriptor`. +/// +/// #8122: the collector resolves the receiver's `ShapeDescriptor` ONCE per +/// traced object (`gc::layout::gc_child_slots`) and threads it through the +/// keys-slot, field-range, payload-mask and slot-visit steps, which used to +/// probe the shape table independently — five `shape_descriptor_by_id` +/// lookups per traced object, the top leaf of a traced in-place-promotion +/// cycle's profile. Callers therefore pass the descriptor rather than the +/// function re-deriving it. +pub(crate) unsafe fn gc_keys_array_slot( + obj: *mut ObjectHeader, + descriptor: Option, +) -> Option<*mut u64> { if obj.is_null() { return None; } - if let Some(descriptor) = shapes::object_shape_descriptor(obj) { + if let Some(descriptor) = descriptor { // Compatibility scratch slot: GC obtains the authoritative edge from // the ShapeId descriptor, then lets the existing slot visitor rewrite // it in place. #8047 can replace this scratch with a descriptor-table @@ -25,8 +37,11 @@ pub(crate) unsafe fn gc_keys_array_slot(obj: *mut ObjectHeader) -> Option<*mut u Some(&mut (*obj).keys_array as *mut _ as *mut u64) } +/// The object's inline field-slot range, given the receiver's `ShapeDescriptor` +/// (see [`gc_keys_array_slot`]). pub(crate) unsafe fn gc_field_slot_range( obj: *mut ObjectHeader, + descriptor: Option, ) -> Option { if obj.is_null() { return None; @@ -40,7 +55,7 @@ pub(crate) unsafe fn gc_field_slot_range( // escapes (`object/alloc.rs`), and every bound change is mint-then-stamp // (`shapes::publish_object_live_slot_count`), so a live object is never // observed here without one. - let field_count = shapes::object_shape_descriptor(obj) + let field_count = descriptor .map(|descriptor| descriptor.live_inline_slot_count as usize) .unwrap_or(0); if field_count > 1_000_000 { diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 994fe23628..0a780bd725 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -1055,14 +1055,22 @@ pub unsafe extern "C" fn js_method_direct_shape_class( return 0; } let obj = object_addr as *const ObjectHeader; - if !crate::object::object_is_regular(obj) { + // #8122: ONE shape-table probe. `object_is_regular` re-derived the GcHeader + // this function has already validated (kind + not forwarded) and probed + // for the kind; `object_shape_id` then probed again to prove the stamp + // resolves. One descriptor read answers both, and the header stamp is the + // id once it has resolved. + let Some(shape) = crate::object::shapes::object_shape_descriptor(obj) else { + return 0; + }; + if shape.object_kind != crate::object::shapes::ShapeObjectKind::Ordinary { return 0; } let class_id = (*obj).class_id; if class_id == 0 { return 0; } - let shape_id = crate::object::shapes::object_shape_id(obj); + let shape_id = crate::object::shapes::object_shape_stamp(obj); if shape_id == 0 { return 0; } diff --git a/docs/src/internals/garbage-collector.md b/docs/src/internals/garbage-collector.md index 1947007ff7..a9a39a43b7 100644 --- a/docs/src/internals/garbage-collector.md +++ b/docs/src/internals/garbage-collector.md @@ -77,8 +77,8 @@ the next copying minor relabels the young blocks as old-gen instead of evacuating them object by object: nothing moves, so nothing in the heap or in any address-keyed side table is rewritten. A promoting cycle still traces — that is what keeps the next cycle's decision measured rather than assumed — except in -the fully-live regime at or above 99% -, +the fully-live regime at or above 98% +, where the trace itself is skipped and every object on the block is registered as live. Two budgets bound the retained garbage that costs: a running cap on promoted dead bytes diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 5f1a027a14..4a3199b493 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -19,6 +19,7 @@ "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 16);": 2, "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 24);": 4, "crates/perry-codegen/src/target_layout.rs|let total = 8 + object_header_size_bytes(triple) + 8 * INLINE_SLOT_FLOOR;": 1, + "crates/perry-codegen/src/target_layout.rs|object_header_size_bytes(target_triple) + alloc_field_count * FIELD_SLOT_SIZE_BYTES;": 1, "crates/perry-codegen/src/target_layout.rs|object_header_size_bytes(triple) % 8,": 1, "crates/perry-codegen/src/target_layout.rs|pub fn object_header_size_bytes(target_triple: &str) -> u64 {": 1 }, @@ -37,7 +38,7 @@ "crates/perry-runtime/src/fs/dirent.rs|keys_array|access|let keys = (*obj_ptr).keys_array;": 1, "crates/perry-runtime/src/gc/heap_snapshot.rs|keys_array|access|.unwrap_or((*obj).keys_array as u64);": 1, "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*obj_header).keys_array as usize);": 1, - "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*object).keys_array as usize)": 1, + "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*object).keys_array as usize);": 1, "crates/perry-runtime/src/gc/layout_slot_visit.rs|keys_array|access|.unwrap_or((*obj).keys_array);": 1, "crates/perry-runtime/src/gc/layout_slot_visit.rs|keys_array|access|let new_keys = (*obj).keys_array as u64;": 1, "crates/perry-runtime/src/gc/tests/copying.rs|keys_array|access|let keys = (*obj_after).keys_array;": 1, @@ -177,7 +178,7 @@ "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|perry_runtime::array::js_array_length((*object).keys_array)": 1 }, "summary": { - "codegen_object_header_size_sites": 33, + "codegen_object_header_size_sites": 34, "raw_member_files": 65, "raw_member_sites": { "keys_array": 183 From 553e8e2262f1e2751930aa83d112df4110cb586a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 11:46:15 +0200 Subject: [PATCH 12/13] test(gc): re-pin the ratchet after the accepted pacing shift (#8122 recovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured on the pinned quiet host (Apple M1 mini, load 2.18) at f2ab19447 with the shipped 7 repeats. Every GC-accounting fingerprint moves because the allocation census before minor #0 object-denominates the first nursery cap (the first cycle fires earlier on small-object workloads) and the untraced threshold is 980. Retention: 12_large_live_set heap_used -75%; 13_large_eden_survivors +85 KB because its cycle 0 now holds up an in-place promotion at 581 permille (its 64 MB cap becomes ~49 MB object-denominated) instead of rolling back at 470 — main at cap 49 does the same and retains 651 KB, so this is the regime, not the code. All other retention cells 0%. --- .../gc_ratchet/baseline/gc-ratchet-v1.json | 4311 ++++++++--------- 1 file changed, 2034 insertions(+), 2277 deletions(-) diff --git a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json index 5ec82a4db1..eb8998a45a 100644 --- a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json +++ b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json @@ -3,28 +3,28 @@ "kind": "gc-ratchet-baseline", "artifact_id": "gc-ratchet-v1", "not_the_public_baseline": "Internal Perry-vs-Perry GC ratchet. The public Node/Bun evidence is benchmarks/results/public-node-bun-v1.json, owned by benchmarks/run_public_baseline.sh. Never regenerate one from the other.", - "commit": "98e9ecdb50075170e4be4469226b5379ebd67e73", - "code_tree": "d55c2f44d230c2eb25a40a187d7a941ee0afbed6", - "generated_at": "2026-08-12T06:33:42+00:00", + "commit": "f2ab19447194eeaa3cb47d968877745614024451", + "code_tree": "901ab8ccaee0972a92cfde44077fde961ae58ede", + "generated_at": "2026-08-16T09:38:07+00:00", "platform": "darwin-arm64", "host": { "platform": "darwin-arm64", - "hostname": "perry-macos.local", + "hostname": "perry-macos.fritz.box", "system": "Darwin", "release": "25.5.0", "machine": "arm64", "cpu_count": 8, "load_average": { - "1m": 2.03, - "5m": 1.81, - "15m": 1.77 + "1m": 2.18, + "5m": 1.7, + "15m": 1.57 }, "cpu_brand": "Apple M1", "memory_bytes": 8589934592, "product_version": "26.5.1" }, "toolchain": { - "perry_version": "perry 0.5.1484", + "perry_version": "perry 0.5.1512", "rustc": "rustc 1.97.1 (8bab26f4f 2026-07-14)", "cargo": "cargo 1.97.1 (c980f4866 2026-06-30)", "cc": "Apple clang version 21.0.0 (clang-2100.1.1.101)", @@ -36,20 +36,20 @@ }, "binaries": { "perry": { - "path": "~/artifacts-7843-main/perry", - "size": 111255808, - "sha256": "9c39c3dc6a9c90cccafd9be8e68f483f2c1f2607652f9cce4aa654b706318184" + "path": "~/perry-bench.noindex/dist8122/perry", + "size": 127920120, + "sha256": "2283b47a584d74997b07da5fc21089033b19a3917b38d889169e048af9eb10d9" }, - "runtime_dir": "~/artifacts-7843-main", + "runtime_dir": "~/perry-bench.noindex/dist8122", "libperry_runtime.a": { - "path": "~/artifacts-7843-main/libperry_runtime.a", - "size": 30923592, - "sha256": "dd4d82e98cda2087c6af46055c834fb2f41eab3c7894fcd724fa999372079066" + "path": "~/perry-bench.noindex/dist8122/libperry_runtime.a", + "size": 31671784, + "sha256": "b5472108c83559f5bd17f0a055d4363b8cf5782ddd182c0305a87fc65b3656f2" }, "libperry_stdlib.a": { - "path": "~/artifacts-7843-main/libperry_stdlib.a", - "size": 80774200, - "sha256": "b76bd465e4d611ba2519b55d75afc0dc11b627fec979af616af4197f556f0531" + "path": "~/perry-bench.noindex/dist8122/libperry_stdlib.a", + "size": 81603232, + "sha256": "f918fe23a1cd5e4682594b395c9c091289e6e64b626ddd023380265dc2d9449b" } } }, @@ -297,7 +297,7 @@ }, "probe_overrides": {} }, - "notes": "Accepted by the merges of #7886, #7888, and #7895: heapUsed now reports live bytes, medium array backing stores enter the nursery, and a retain-to-churn phase flip may carry one bounded untraced promotion before the next measured cycle disarms it. Two fresh seven-repeat runs at 98e9ecdb5 reproduced identical shared-CI counters; re-pinned for #7843.", + "notes": "Re-pinned for #8122-recover: the allocation census before minor #0 object-denominates the first nursery cap (first cycle fires earlier on small-object workloads), one descriptor lookup per traced object, untraced-promotion threshold 990 -> 980. Every GC-accounting fingerprint shifts; retention improves on 12_large_live_set (-75%) and 13_large_eden_survivors moves +85 KB because its cycle 0 now holds up an in-place promotion at 581 permille (main at cap 49 retains 651 KB the same way).", "probes": { "01_nursery_churn": { "stdout": "probe:01_nursery_churn\nchecksum:-1399701504\n", @@ -310,93 +310,93 @@ "metrics": { "heap_used_bytes": { "samples": [ - 211216, - 211216, - 211216, - 211216, - 211216, - 211216, - 211216 + 212168, + 212168, + 212168, + 212168, + 212168, + 212168, + 212168 ], "sample_count": 7, - "median": 211216, - "min": 211216, - "max": 211216, + "median": 212168, + "min": 212168, + "max": 212168, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520 + 16777216, + 16777216, + 16777216, + 16777216, + 16777216, + 16777216, + 16777216 ], "sample_count": 7, - "median": 20971520, - "min": 20971520, - "max": 20971520, + "median": 16777216, + "min": 16777216, + "max": 16777216, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 30359552, - 30375936, - 30359552, - 30375936, - 30375936, - 30359552, - 30359552 + 28966912, + 28950528, + 28934144, + 28966912, + 28950528, + 28934144, + 28950528 ], "sample_count": 7, - "median": 30359552, - "min": 30359552, - "max": 30375936, - "stdev": 8107.977266, - "spread": 16384, - "spread_pct": 0.053967 + "median": 28950528, + "min": 28934144, + "max": 28966912, + "stdev": 12385.139852, + "spread": 32768, + "spread_pct": 0.113186 }, "peak_rss_bytes": { "samples": [ - 30769152, - 30785536, - 30769152, - 30785536, - 30785536, - 30769152, - 30769152 + 29507584, + 29491200, + 29474816, + 29507584, + 29491200, + 29474816, + 29491200 ], "sample_count": 7, - "median": 30769152, - "min": 30769152, - "max": 30785536, - "stdev": 8107.977266, - "spread": 16384, - "spread_pct": 0.053248 + "median": 29491200, + "min": 29474816, + "max": 29507584, + "stdev": 12385.139852, + "spread": 32768, + "spread_pct": 0.111111 }, "wall_ms": { "samples": [ - 45.173041, - 45.133542, - 44.775917, - 44.878875, - 44.760416, - 45.10875, - 44.815875 + 54.18525, + 54.330833, + 54.166459, + 54.155542, + 54.224042, + 54.35625, + 54.38125 ], "sample_count": 7, - "median": 44.878875, - "min": 44.760416, - "max": 45.173041, - "stdev": 0.168154, - "spread": 0.412625, - "spread_pct": 0.919419 + "median": 54.224042, + "min": 54.155542, + "max": 54.38125, + "stdev": 0.089015, + "spread": 0.225708, + "spread_pct": 0.416251 }, "minor_cycles": { "samples": [ @@ -426,26 +426,26 @@ }, "copied_objects": { "samples": [ - 6264, - 6264 + 6354, + 6354 ], "sample_count": 2, - "median": 6264, - "min": 6264, - "max": 6264, + "median": 6354, + "min": 6354, + "max": 6354, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 431328, - 431328 + 430008, + 430008 ], "sample_count": 2, - "median": 431328, - "min": 431328, - "max": 431328, + "median": 430008, + "min": 430008, + "max": 430008, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -478,13 +478,13 @@ }, "freed_bytes": { "samples": [ - 17393872, - 17393872 + 13201144, + 13201144 ], "sample_count": 2, - "median": 17393872, - "min": 17393872, - "max": 17393872, + "median": 13201144, + "min": 13201144, + "max": 13201144, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -502,93 +502,93 @@ "metrics": { "heap_used_bytes": { "samples": [ - 3615488, - 3615488, - 3615488, - 3615488, - 3615488, - 3615488, - 3615488 + 2656440, + 2656440, + 2656440, + 2656440, + 2656440, + 2656440, + 2656440 ], "sample_count": 7, - "median": 3615488, - "min": 3615488, - "max": 3615488, + "median": 2656440, + "min": 2656440, + "max": 2656440, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 24117248, - 24117248, - 24117248, - 24117248, - 24117248, - 24117248, - 24117248 + 16777216, + 16777216, + 16777216, + 16777216, + 16777216, + 16777216, + 16777216 ], "sample_count": 7, - "median": 24117248, - "min": 24117248, - "max": 24117248, + "median": 16777216, + "min": 16777216, + "max": 16777216, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 37306368, - 37289984, - 37289984, - 37273600, - 37289984, - 37289984, - 37273600 + 32538624, + 32522240, + 32505856, + 32522240, + 32522240, + 32538624, + 32538624 ], "sample_count": 7, - "median": 37289984, - "min": 37273600, - "max": 37306368, - "stdev": 10467.353641, + "median": 32522240, + "min": 32505856, + "max": 32538624, + "stdev": 11466.411413, "spread": 32768, - "spread_pct": 0.087873 + "spread_pct": 0.100756 }, "peak_rss_bytes": { "samples": [ - 37732352, - 37715968, - 37715968, - 37699584, - 37715968, - 37715968, - 37699584 + 33112064, + 33095680, + 33079296, + 33095680, + 33095680, + 33112064, + 33112064 ], "sample_count": 7, - "median": 37715968, - "min": 37699584, - "max": 37732352, - "stdev": 10467.353641, + "median": 33095680, + "min": 33079296, + "max": 33112064, + "stdev": 11466.411413, "spread": 32768, - "spread_pct": 0.086881 + "spread_pct": 0.09901 }, "wall_ms": { "samples": [ - 47.384833, - 46.75175, - 47.011959, - 46.754292, - 46.692209, - 46.740708, - 46.66225 + 57.28675, + 57.529625, + 57.582708, + 57.294667, + 57.452334, + 57.379792, + 57.207667 ], "sample_count": 7, - "median": 46.75175, - "min": 46.66225, - "max": 47.384833, - "stdev": 0.239897, - "spread": 0.722583, - "spread_pct": 1.545574 + "median": 57.379792, + "min": 57.207667, + "max": 57.582708, + "stdev": 0.127399, + "spread": 0.375041, + "spread_pct": 0.653612 }, "minor_cycles": { "samples": [ @@ -618,26 +618,26 @@ }, "copied_objects": { "samples": [ - 44539, - 44539 + 34550, + 34550 ], "sample_count": 2, - "median": 44539, - "min": 44539, - "max": 44539, + "median": 34550, + "min": 34550, + "max": 34550, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 2568760, - 2568760 + 1780080, + 1780080 ], "sample_count": 2, - "median": 2568760, - "min": 2568760, - "max": 2568760, + "median": 1780080, + "min": 1780080, + "max": 1780080, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -670,13 +670,13 @@ }, "freed_bytes": { "samples": [ - 15256464, - 15256464 + 9754128, + 9754128 ], "sample_count": 2, - "median": 15256464, - "min": 15256464, - "max": 15256464, + "median": 9754128, + "min": 9754128, + "max": 9754128, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -712,98 +712,98 @@ }, "heap_total_bytes": { "samples": [ - 22020096, - 22020096, - 22020096, - 22020096, - 22020096, - 22020096, - 22020096 + 14680064, + 14680064, + 14680064, + 14680064, + 14680064, + 14680064, + 14680064 ], "sample_count": 7, - "median": 22020096, - "min": 22020096, - "max": 22020096, + "median": 14680064, + "min": 14680064, + "max": 14680064, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 25788416, - 25804800, - 25788416, - 25788416, - 25788416, - 25788416, - 25804800 + 21708800, + 21725184, + 21708800, + 21708800, + 21708800, + 21708800, + 21708800 ], "sample_count": 7, - "median": 25788416, - "min": 25788416, - "max": 25804800, - "stdev": 7401.536741, + "median": 21708800, + "min": 21708800, + "max": 21725184, + "stdev": 5733.205707, "spread": 16384, - "spread_pct": 0.063532 + "spread_pct": 0.075472 }, "peak_rss_bytes": { "samples": [ - 29196288, - 29212672, - 29212672, - 29196288, - 29196288, - 29196288, - 29212672 + 26542080, + 26558464, + 26542080, + 26542080, + 26542080, + 26542080, + 26542080 ], "sample_count": 7, - "median": 29196288, - "min": 29196288, - "max": 29212672, - "stdev": 8107.977266, + "median": 26542080, + "min": 26542080, + "max": 26558464, + "stdev": 5733.205707, "spread": 16384, - "spread_pct": 0.056117 + "spread_pct": 0.061728 }, "wall_ms": { "samples": [ - 34.648916, - 30.82525, - 30.850709, - 30.714166, - 30.716959, - 30.64425, - 30.78625 + 38.699291, + 36.954917, + 37.003709, + 37.047208, + 37.016084, + 37.064625, + 37.01325 ], "sample_count": 7, - "median": 30.78625, - "min": 30.64425, - "max": 34.648916, - "stdev": 1.36374, - "spread": 4.004666, - "spread_pct": 13.007969 + "median": 37.016084, + "min": 36.954917, + "max": 38.699291, + "stdev": 0.589684, + "spread": 1.744374, + "spread_pct": 4.712476 }, "minor_cycles": { "samples": [ - 2, - 2 + 1, + 1 ], "sample_count": 2, - "median": 2, - "min": 2, - "max": 2, + "median": 1, + "min": 1, + "max": 1, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 2, - 2 + 1, + 1 ], "sample_count": 2, - "median": 2, - "min": 2, - "max": 2, + "median": 1, + "min": 1, + "max": 1, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -823,13 +823,13 @@ }, "copied_bytes": { "samples": [ - 262592, - 262592 + 229824, + 229824 ], "sample_count": 2, - "median": 262592, - "min": 262592, - "max": 262592, + "median": 229824, + "min": 229824, + "max": 229824, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -862,13 +862,13 @@ }, "freed_bytes": { "samples": [ - 17562728, - 17562728 + 11304368, + 11304368 ], "sample_count": 2, - "median": 17562728, - "min": 17562728, - "max": 17562728, + "median": 11304368, + "min": 11304368, + "max": 11304368, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -904,124 +904,124 @@ }, "heap_total_bytes": { "samples": [ - 23068672, - 23068672, - 23068672, - 23068672, - 23068672, - 23068672, - 23068672 + 20971520, + 20971520, + 20971520, + 20971520, + 20971520, + 20971520, + 20971520 ], "sample_count": 7, - "median": 23068672, - "min": 23068672, - "max": 23068672, + "median": 20971520, + "min": 20971520, + "max": 20971520, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 25542656, - 25542656, - 25542656, - 25542656, - 25542656, - 25542656, - 25542656 + 24100864, + 24084480, + 24100864, + 24100864, + 24084480, + 24084480, + 24100864 ], "sample_count": 7, - "median": 25542656, - "min": 25542656, - "max": 25542656, - "stdev": 0, - "spread": 0, - "spread_pct": 0 + "median": 24100864, + "min": 24084480, + "max": 24100864, + "stdev": 8107.977266, + "spread": 16384, + "spread_pct": 0.067981 }, "peak_rss_bytes": { "samples": [ - 28917760, - 28917760, - 28917760, - 28917760, - 28917760, - 28917760, - 28917760 + 28950528, + 28950528, + 28950528, + 28950528, + 28934144, + 28934144, + 28950528 ], "sample_count": 7, - "median": 28917760, - "min": 28917760, - "max": 28917760, - "stdev": 0, - "spread": 0, - "spread_pct": 0 + "median": 28950528, + "min": 28934144, + "max": 28950528, + "stdev": 7401.536741, + "spread": 16384, + "spread_pct": 0.056593 }, "wall_ms": { "samples": [ - 31.4635, - 25.192792, - 24.908792, - 25.181292, - 25.040792, - 24.949875, - 24.841167 + 69.59925, + 69.329458, + 69.173625, + 69.482792, + 69.231459, + 69.255667, + 69.145208 ], "sample_count": 7, - "median": 25.040792, - "min": 24.841167, - "max": 31.4635, - "stdev": 2.258405, - "spread": 6.622333, - "spread_pct": 26.44618 + "median": 69.255667, + "min": 69.145208, + "max": 69.59925, + "stdev": 0.155129, + "spread": 0.454042, + "spread_pct": 0.655603 }, "minor_cycles": { "samples": [ - 7, - 7 + 6, + 6 ], "sample_count": 2, - "median": 7, - "min": 7, - "max": 7, + "median": 6, + "min": 6, + "max": 6, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 7, - 7 + 6, + 6 ], "sample_count": 2, - "median": 7, - "min": 7, - "max": 7, + "median": 6, + "min": 6, + "max": 6, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_objects": { "samples": [ - 461, - 461 + 450, + 450 ], "sample_count": 2, - "median": 461, - "min": 461, - "max": 461, + "median": 450, + "min": 450, + "max": 450, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 48720, - 48720 + 25832, + 25832 ], "sample_count": 2, - "median": 48720, - "min": 48720, - "max": 48720, + "median": 25832, + "min": 25832, + "max": 25832, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1054,13 +1054,13 @@ }, "freed_bytes": { "samples": [ - 94363824, - 94363824 + 83880432, + 83880432 ], "sample_count": 2, - "median": 94363824, - "min": 94363824, - "max": 94363824, + "median": 83880432, + "min": 83880432, + "max": 83880432, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1078,142 +1078,142 @@ "metrics": { "heap_used_bytes": { "samples": [ - 7200, - 7200, - 7200, - 7200, - 7200, - 7200, - 7200 + 5664, + 5664, + 5664, + 5664, + 5664, + 5664, + 5664 ], "sample_count": 7, - "median": 7200, - "min": 7200, - "max": 7200, + "median": 5664, + "min": 5664, + "max": 5664, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 22020096, - 22020096, - 22020096, - 22020096, - 22020096, - 22020096, - 22020096 + 14680064, + 14680064, + 14680064, + 14680064, + 14680064, + 14680064, + 14680064 ], "sample_count": 7, - "median": 22020096, - "min": 22020096, - "max": 22020096, + "median": 14680064, + "min": 14680064, + "max": 14680064, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 26312704, - 26312704, - 26312704, - 26312704, - 26296320, - 26312704, - 26312704 + 19169280, + 19169280, + 19169280, + 19169280, + 19185664, + 19169280, + 19185664 ], "sample_count": 7, - "median": 26312704, - "min": 26296320, - "max": 26312704, - "stdev": 5733.205707, + "median": 19169280, + "min": 19169280, + "max": 19185664, + "stdev": 7401.536741, "spread": 16384, - "spread_pct": 0.062267 + "spread_pct": 0.08547 }, "peak_rss_bytes": { "samples": [ - 29278208, - 29278208, - 29278208, - 29278208, - 29261824, - 29278208, - 29278208 + 24248320, + 24248320, + 24231936, + 24248320, + 24248320, + 24231936, + 24248320 ], "sample_count": 7, - "median": 29278208, - "min": 29261824, - "max": 29278208, - "stdev": 5733.205707, + "median": 24248320, + "min": 24231936, + "max": 24248320, + "stdev": 7401.536741, "spread": 16384, - "spread_pct": 0.05596 + "spread_pct": 0.067568 }, "wall_ms": { "samples": [ - 52.472208, - 52.413542, - 52.098208, - 52.249834, - 52.080708, - 52.125125, - 52.139958 + 68.497292, + 68.497708, + 68.30825, + 68.242, + 68.291, + 68.511333, + 68.171334 ], "sample_count": 7, - "median": 52.139958, - "min": 52.080708, - "max": 52.472208, - "stdev": 0.147055, - "spread": 0.3915, - "spread_pct": 0.750864 + "median": 68.30825, + "min": 68.171334, + "max": 68.511333, + "stdev": 0.129654, + "spread": 0.339999, + "spread_pct": 0.497742 }, "minor_cycles": { "samples": [ - 2, - 2 + 3, + 3 ], "sample_count": 2, - "median": 2, - "min": 2, - "max": 2, + "median": 3, + "min": 3, + "max": 3, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 2, - 2 + 3, + 3 ], "sample_count": 2, - "median": 2, - "min": 2, - "max": 2, + "median": 3, + "min": 3, + "max": 3, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_objects": { "samples": [ - 3960, - 3960 + 5272, + 5272 ], "sample_count": 2, - "median": 3960, - "min": 3960, - "max": 3960, + "median": 5272, + "min": 5272, + "max": 5272, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 172040, - 172040 + 233384, + 233384 ], "sample_count": 2, - "median": 172040, - "min": 172040, - "max": 172040, + "median": 233384, + "min": 233384, + "max": 233384, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1246,13 +1246,13 @@ }, "freed_bytes": { "samples": [ - 29271264, - 29271264 + 32422176, + 32422176 ], "sample_count": 2, - "median": 29271264, - "min": 29271264, - "max": 29271264, + "median": 32422176, + "min": 32422176, + "max": 32422176, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1270,18 +1270,18 @@ "metrics": { "heap_used_bytes": { "samples": [ - 211608, - 211608, - 211608, - 211608, - 211608, - 211608, - 211608 + 212640, + 212640, + 212640, + 212640, + 212640, + 212640, + 212640 ], "sample_count": 7, - "median": 211608, - "min": 211608, - "max": 211608, + "median": 212640, + "min": 212640, + "max": 212640, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1306,145 +1306,145 @@ }, "rss_bytes": { "samples": [ - 30736384, - 30687232, - 30752768, - 30736384, - 30703616, - 30720000, - 30736384 + 32980992, + 33013760, + 32931840, + 32980992, + 32948224, + 32948224, + 32964608 ], "sample_count": 7, - "median": 30736384, - "min": 30687232, - "max": 30752768, - "stdev": 20934.707282, - "spread": 65536, - "spread_pct": 0.21322 + "median": 32964608, + "min": 32931840, + "max": 33013760, + "stdev": 25425.113653, + "spread": 81920, + "spread_pct": 0.248509 }, "peak_rss_bytes": { "samples": [ - 31244288, - 31195136, - 31260672, - 31244288, - 31211520, - 31227904, - 31244288 + 33505280, + 33538048, + 33456128, + 33505280, + 33472512, + 33472512, + 33488896 ], "sample_count": 7, - "median": 31244288, - "min": 31195136, - "max": 31260672, - "stdev": 20934.707282, - "spread": 65536, - "spread_pct": 0.209754 + "median": 33488896, + "min": 33456128, + "max": 33538048, + "stdev": 25425.113653, + "spread": 81920, + "spread_pct": 0.244618 }, "wall_ms": { "samples": [ - 56.560167, - 56.668208, - 56.682208, - 56.584125, - 56.503875, - 56.727, - 56.75125 + 76.033334, + 76.106041, + 76.084417, + 75.704375, + 76.004416, + 75.976, + 75.890792 ], "sample_count": 7, - "median": 56.668208, - "min": 56.503875, - "max": 56.75125, - "stdev": 0.084976, - "spread": 0.247375, - "spread_pct": 0.436532 + "median": 76.004416, + "min": 75.704375, + "max": 76.106041, + "stdev": 0.127418, + "spread": 0.401666, + "spread_pct": 0.528477 }, "minor_cycles": { "samples": [ - 4, - 4 + 5, + 5 ], "sample_count": 2, - "median": 4, - "min": 4, - "max": 4, + "median": 5, + "min": 5, + "max": 5, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 4, - 4 + 5, + 5 ], "sample_count": 2, - "median": 4, - "min": 4, - "max": 4, + "median": 5, + "min": 5, + "max": 5, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_objects": { "samples": [ - 11026, - 11026 + 11138, + 11138 ], "sample_count": 2, - "median": 11026, - "min": 11026, - "max": 11026, + "median": 11138, + "min": 11138, + "max": 11138, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 701088, - 701088 + 682240, + 682240 ], "sample_count": 2, - "median": 701088, - "min": 701088, - "max": 701088, + "median": 682240, + "min": 682240, + "max": 682240, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_objects": { "samples": [ - 4754, - 4754 + 4960, + 4960 ], "sample_count": 2, - "median": 4754, - "min": 4754, - "max": 4754, + "median": 4960, + "min": 4960, + "max": 4960, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_bytes": { "samples": [ - 211296, - 211296 + 248512, + 248512 ], "sample_count": 2, - "median": 211296, - "min": 211296, - "max": 211296, + "median": 248512, + "min": 248512, + "max": 248512, "stdev": 0, "spread": 0, "spread_pct": 0 }, "freed_bytes": { "samples": [ - 75243368, - 75243368 + 75244808, + 75244808 ], "sample_count": 2, - "median": 75243368, - "min": 75243368, - "max": 75243368, + "median": 75244808, + "min": 75244808, + "max": 75244808, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1462,18 +1462,18 @@ "metrics": { "heap_used_bytes": { "samples": [ - 983888, - 983888, - 983888, - 983888, - 983888, - 983888, - 983888 + 984840, + 984840, + 984840, + 984840, + 984840, + 984840, + 984840 ], "sample_count": 7, - "median": 983888, - "min": 983888, - "max": 983888, + "median": 984840, + "min": 984840, + "max": 984840, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1498,57 +1498,57 @@ }, "rss_bytes": { "samples": [ - 31440896, - 31440896, - 31473664, - 31457280, - 31457280, - 31457280, - 31457280 + 34029568, + 34029568, + 34029568, + 34029568, + 34029568, + 34029568, + 34045952 ], "sample_count": 7, - "median": 31457280, - "min": 31440896, - "max": 31473664, - "stdev": 10467.353641, - "spread": 32768, - "spread_pct": 0.104167 + "median": 34029568, + "min": 34029568, + "max": 34045952, + "stdev": 5733.205707, + "spread": 16384, + "spread_pct": 0.048146 }, "peak_rss_bytes": { "samples": [ - 31932416, - 31932416, - 31965184, - 31948800, - 31948800, - 31948800, - 31948800 + 34553856, + 34553856, + 34553856, + 34553856, + 34553856, + 34553856, + 34570240 ], "sample_count": 7, - "median": 31948800, - "min": 31932416, - "max": 31965184, - "stdev": 10467.353641, - "spread": 32768, - "spread_pct": 0.102564 + "median": 34553856, + "min": 34553856, + "max": 34570240, + "stdev": 5733.205707, + "spread": 16384, + "spread_pct": 0.047416 }, "wall_ms": { "samples": [ - 34.273625, - 30.579917, - 30.737666, - 30.547791, - 30.536917, - 30.678667, - 30.739708 + 143.92975, + 143.755125, + 143.732292, + 143.698917, + 143.408375, + 143.517375, + 143.663125 ], "sample_count": 7, - "median": 30.678667, - "min": 30.536917, - "max": 34.273625, - "stdev": 1.275079, - "spread": 3.736708, - "spread_pct": 12.180151 + "median": 143.698917, + "min": 143.408375, + "max": 143.92975, + "stdev": 0.156383, + "spread": 0.521375, + "spread_pct": 0.362825 }, "minor_cycles": { "samples": [ @@ -1578,65 +1578,65 @@ }, "copied_objects": { "samples": [ - 6302, - 6302 + 6387, + 6387 ], "sample_count": 2, - "median": 6302, - "min": 6302, - "max": 6302, + "median": 6387, + "min": 6387, + "max": 6387, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 2671456, - 2671456 + 2671936, + 2671936 ], "sample_count": 2, - "median": 2671456, - "min": 2671456, - "max": 2671456, + "median": 2671936, + "min": 2671936, + "max": 2671936, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_objects": { "samples": [ - 4803, - 4803 + 4879, + 4879 ], "sample_count": 2, - "median": 4803, - "min": 4803, - "max": 4803, + "median": 4879, + "min": 4879, + "max": 4879, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_bytes": { "samples": [ - 614744, - 614744 + 615696, + 615696 ], "sample_count": 2, - "median": 614744, - "min": 614744, - "max": 614744, + "median": 615696, + "min": 615696, + "max": 615696, "stdev": 0, "spread": 0, "spread_pct": 0 }, "freed_bytes": { "samples": [ - 83728816, - 83728816 + 83727056, + 83727056 ], "sample_count": 2, - "median": 83728816, - "min": 83728816, - "max": 83728816, + "median": 83727056, + "min": 83727056, + "max": 83727056, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1672,124 +1672,124 @@ }, "heap_total_bytes": { "samples": [ - 23068672, - 23068672, - 23068672, - 23068672, - 23068672, - 23068672, - 23068672 + 16777216, + 16777216, + 16777216, + 16777216, + 16777216, + 16777216, + 16777216 ], "sample_count": 7, - "median": 23068672, - "min": 23068672, - "max": 23068672, + "median": 16777216, + "min": 16777216, + "max": 16777216, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 25444352, - 25444352, - 25444352, - 25427968, - 25444352, - 25427968, - 25444352 + 21037056, + 21037056, + 21053440, + 21037056, + 21037056, + 21037056, + 21037056 ], "sample_count": 7, - "median": 25444352, - "min": 25427968, - "max": 25444352, - "stdev": 7401.536741, + "median": 21037056, + "min": 21037056, + "max": 21053440, + "stdev": 5733.205707, "spread": 16384, - "spread_pct": 0.064392 + "spread_pct": 0.077882 }, "peak_rss_bytes": { "samples": [ - 28868608, - 28868608, - 28868608, - 28852224, - 28868608, - 28852224, - 28868608 + 25329664, + 25346048, + 25346048, + 25346048, + 25329664, + 25329664, + 25329664 ], "sample_count": 7, - "median": 28868608, - "min": 28852224, - "max": 28868608, - "stdev": 7401.536741, + "median": 25329664, + "min": 25329664, + "max": 25346048, + "stdev": 8107.977266, "spread": 16384, - "spread_pct": 0.056754 + "spread_pct": 0.064683 }, "wall_ms": { "samples": [ - 150.860834, - 151.330875, - 151.528542, - 150.763042, - 151.05725, - 150.89875, - 150.989542 + 170.958333, + 172.366667, + 170.576083, + 170.632541, + 170.892167, + 170.647125, + 170.8975 ], "sample_count": 7, - "median": 150.989542, - "min": 150.763042, - "max": 151.528542, - "stdev": 0.25403, - "spread": 0.7655, - "spread_pct": 0.506989 + "median": 170.892167, + "min": 170.576083, + "max": 172.366667, + "stdev": 0.577034, + "spread": 1.790584, + "spread_pct": 1.047786 }, "minor_cycles": { "samples": [ - 6, - 6 + 5, + 5 ], "sample_count": 2, - "median": 6, - "min": 6, - "max": 6, + "median": 5, + "min": 5, + "max": 5, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 6, - 6 + 5, + 5 ], "sample_count": 2, - "median": 6, - "min": 6, - "max": 6, + "median": 5, + "min": 5, + "max": 5, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_objects": { "samples": [ - 3398, - 3398 + 3017, + 3017 ], "sample_count": 2, - "median": 3398, - "min": 3398, - "max": 3398, + "median": 3017, + "min": 3017, + "max": 3017, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 189496, - 189496 + 144384, + 144384 ], "sample_count": 2, - "median": 189496, - "min": 189496, - "max": 189496, + "median": 144384, + "min": 144384, + "max": 144384, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1822,13 +1822,13 @@ }, "freed_bytes": { "samples": [ - 72310784, - 72310784 + 57655296, + 57655296 ], "sample_count": 2, - "median": 72310784, - "min": 72310784, - "max": 72310784, + "median": 57655296, + "min": 57655296, + "max": 57655296, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -1846,93 +1846,93 @@ "metrics": { "heap_used_bytes": { "samples": [ - 211216, - 211216, - 211216, - 211216, - 211216, - 211216, - 211216 + 212168, + 212168, + 212168, + 212168, + 212168, + 212168, + 212168 ], "sample_count": 7, - "median": 211216, - "min": 211216, - "max": 211216, + "median": 212168, + "min": 212168, + "max": 212168, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520 + 14680064, + 14680064, + 14680064, + 14680064, + 14680064, + 14680064, + 14680064 ], "sample_count": 7, - "median": 20971520, - "min": 20971520, - "max": 20971520, + "median": 14680064, + "min": 14680064, + "max": 14680064, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 33718272, - 33734656, - 33652736, - 33767424, - 33669120, - 33734656, - 33669120 + 30015488, + 30064640, + 30081024, + 30015488, + 29999104, + 29999104, + 30015488 ], "sample_count": 7, - "median": 33718272, - "min": 33652736, - "max": 33767424, - "stdev": 39858.495174, - "spread": 114688, - "spread_pct": 0.340136 + "median": 30015488, + "min": 29999104, + "max": 30081024, + "stdev": 29973.939287, + "spread": 81920, + "spread_pct": 0.272926 }, "peak_rss_bytes": { "samples": [ - 33996800, - 34013184, - 33931264, - 34045952, - 33947648, - 34013184, - 33947648 + 30457856, + 30507008, + 30523392, + 30457856, + 30441472, + 30441472, + 30457856 ], "sample_count": 7, - "median": 33996800, - "min": 33931264, - "max": 34045952, - "stdev": 39858.495174, - "spread": 114688, - "spread_pct": 0.337349 + "median": 30457856, + "min": 30441472, + "max": 30523392, + "stdev": 29973.939287, + "spread": 81920, + "spread_pct": 0.268962 }, "wall_ms": { "samples": [ - 665.876583, - 665.624208, - 665.155541, - 665.60325, - 665.535, - 665.302042, - 666.838042 + 743.581625, + 743.879542, + 744.132083, + 744.399833, + 744.5145, + 743.478625, + 743.375375 ], "sample_count": 7, - "median": 665.60325, - "min": 665.155541, - "max": 666.838042, - "stdev": 0.510362, - "spread": 1.682501, - "spread_pct": 0.252778 + "median": 743.879542, + "min": 743.375375, + "max": 744.5145, + "stdev": 0.420091, + "spread": 1.139125, + "spread_pct": 0.153133 }, "minor_cycles": { "samples": [ @@ -1988,39 +1988,39 @@ }, "promoted_objects": { "samples": [ - 6150, - 6150 + 6240, + 6240 ], "sample_count": 2, - "median": 6150, - "min": 6150, - "max": 6150, + "median": 6240, + "min": 6240, + "max": 6240, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_bytes": { "samples": [ - 421632, - 421632 + 421800, + 421800 ], "sample_count": 2, - "median": 421632, - "min": 421632, - "max": 421632, + "median": 421800, + "min": 421800, + "max": 421800, "stdev": 0, "spread": 0, "spread_pct": 0 }, "freed_bytes": { "samples": [ - 17403752, - 17403752 + 11112472, + 11112472 ], "sample_count": 2, - "median": 17403752, - "min": 17403752, - "max": 17403752, + "median": 11112472, + "min": 11112472, + "max": 11112472, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2038,93 +2038,93 @@ "metrics": { "heap_used_bytes": { "samples": [ - 219432, - 219432, - 219432, - 219432, - 219432, - 219432, - 219432 + 220384, + 220384, + 220384, + 220384, + 220384, + 220384, + 220384 ], "sample_count": 7, - "median": 219432, - "min": 219432, - "max": 219432, + "median": 220384, + "min": 220384, + "max": 220384, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520 + 12582912, + 12582912, + 12582912, + 12582912, + 12582912, + 12582912, + 12582912 ], "sample_count": 7, - "median": 20971520, - "min": 20971520, - "max": 20971520, + "median": 12582912, + "min": 12582912, + "max": 12582912, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 29949952, - 29949952, - 29949952, - 29949952, - 29949952, - 29949952, - 29949952 + 24723456, + 24756224, + 24756224, + 24674304, + 24756224, + 24756224, + 24756224 ], "sample_count": 7, - "median": 29949952, - "min": 29949952, - "max": 29949952, - "stdev": 0, - "spread": 0, - "spread_pct": 0 + "median": 24756224, + "min": 24674304, + "max": 24756224, + "stdev": 29045.727574, + "spread": 81920, + "spread_pct": 0.330907 }, "peak_rss_bytes": { "samples": [ - 30441472, - 30441472, - 30441472, - 30441472, - 30441472, - 30441472, - 30441472 + 25264128, + 25296896, + 25296896, + 25214976, + 25296896, + 25296896, + 25296896 ], "sample_count": 7, - "median": 30441472, - "min": 30441472, - "max": 30441472, - "stdev": 0, - "spread": 0, - "spread_pct": 0 + "median": 25296896, + "min": 25214976, + "max": 25296896, + "stdev": 29045.727574, + "spread": 81920, + "spread_pct": 0.323834 }, "wall_ms": { "samples": [ - 35.036666, - 33.038458, - 32.941416, - 32.894125, - 32.973209, - 32.890084, - 32.998875 + 44.054542, + 43.316959, + 43.201375, + 43.188708, + 43.136042, + 43.233125, + 43.086291 ], "sample_count": 7, - "median": 32.973209, - "min": 32.890084, - "max": 35.036666, - "stdev": 0.729769, - "spread": 2.146582, - "spread_pct": 6.510079 + "median": 43.201375, + "min": 43.086291, + "max": 44.054542, + "stdev": 0.308638, + "spread": 0.968251, + "spread_pct": 2.24125 }, "minor_cycles": { "samples": [ @@ -2154,26 +2154,26 @@ }, "copied_objects": { "samples": [ - 8058, - 8058 + 8160, + 8160 ], "sample_count": 2, - "median": 8058, - "min": 8058, - "max": 8058, + "median": 8160, + "min": 8160, + "max": 8160, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 524744, - 524744 + 506200, + 506200 ], "sample_count": 2, - "median": 524744, - "min": 524744, - "max": 524744, + "median": 506200, + "min": 506200, + "max": 506200, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2206,13 +2206,13 @@ }, "freed_bytes": { "samples": [ - 17300304, - 17300304 + 8930928, + 8930928 ], "sample_count": 2, - "median": 17300304, - "min": 17300304, - "max": 17300304, + "median": 8930928, + "min": 8930928, + "max": 8930928, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2230,93 +2230,93 @@ "metrics": { "heap_used_bytes": { "samples": [ - 211216, - 211216, - 211216, - 211216, - 211216, - 211216, - 211216 + 212168, + 212168, + 212168, + 212168, + 212168, + 212168, + 212168 ], "sample_count": 7, - "median": 211216, - "min": 211216, - "max": 211216, + "median": 212168, + "min": 212168, + "max": 212168, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520, - 20971520 + 13631488, + 13631488, + 13631488, + 13631488, + 13631488, + 13631488, + 13631488 ], "sample_count": 7, - "median": 20971520, - "min": 20971520, - "max": 20971520, + "median": 13631488, + "min": 13631488, + "max": 13631488, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 30425088, - 30441472, - 30441472, - 30441472, - 30441472, - 30441472, - 30441472 + 26525696, + 26525696, + 26542080, + 26525696, + 26525696, + 26542080, + 26509312 ], "sample_count": 7, - "median": 30441472, - "min": 30425088, - "max": 30441472, - "stdev": 5733.205707, - "spread": 16384, - "spread_pct": 0.053821 + "median": 26525696, + "min": 26509312, + "max": 26542080, + "stdev": 10467.353641, + "spread": 32768, + "spread_pct": 0.123533 }, "peak_rss_bytes": { "samples": [ - 30916608, - 30932992, - 30932992, - 30932992, - 30932992, - 30932992, - 30932992 + 26984448, + 26984448, + 27000832, + 26984448, + 26984448, + 27000832, + 26968064 ], "sample_count": 7, - "median": 30932992, - "min": 30916608, - "max": 30932992, - "stdev": 5733.205707, - "spread": 16384, - "spread_pct": 0.052966 + "median": 26984448, + "min": 26968064, + "max": 27000832, + "stdev": 10467.353641, + "spread": 32768, + "spread_pct": 0.121433 }, "wall_ms": { "samples": [ - 38.055541, - 36.863167, - 36.853458, - 36.804, - 36.825292, - 36.910084, - 36.91075 + 48.136375, + 47.914791, + 47.78675, + 47.705959, + 47.640792, + 47.730208, + 47.758625 ], "sample_count": 7, - "median": 36.863167, - "min": 36.804, - "max": 38.055541, - "stdev": 0.419574, - "spread": 1.251541, - "spread_pct": 3.395099 + "median": 47.758625, + "min": 47.640792, + "max": 48.136375, + "stdev": 0.154227, + "spread": 0.495583, + "spread_pct": 1.037683 }, "minor_cycles": { "samples": [ @@ -2346,26 +2346,26 @@ }, "copied_objects": { "samples": [ - 6141, - 6141 + 6245, + 6245 ], "sample_count": 2, - "median": 6141, - "min": 6141, - "max": 6141, + "median": 6245, + "min": 6245, + "max": 6245, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 418624, - 418624 + 422744, + 422744 ], "sample_count": 2, - "median": 418624, - "min": 418624, - "max": 418624, + "median": 422744, + "min": 422744, + "max": 422744, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2398,13 +2398,13 @@ }, "freed_bytes": { "samples": [ - 17406768, - 17406768 + 10062936, + 10062936 ], "sample_count": 2, - "median": 17406768, - "min": 17406768, - "max": 17406768, + "median": 10062936, + "min": 10062936, + "max": 10062936, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2422,93 +2422,93 @@ "metrics": { "heap_used_bytes": { "samples": [ - 4462040, - 4462040, - 4462040, - 4462040, - 4462040, - 4462040, - 4462040 + 1104848, + 1104848, + 1104848, + 1104848, + 1104848, + 1104848, + 1104848 ], "sample_count": 7, - "median": 4462040, - "min": 4462040, - "max": 4462040, + "median": 1104848, + "min": 1104848, + "max": 1104848, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 108003328, - 108003328, - 108003328, - 108003328, - 108003328, - 108003328, - 108003328 + 82837504, + 82837504, + 82837504, + 82837504, + 82837504, + 82837504, + 82837504 ], "sample_count": 7, - "median": 108003328, - "min": 108003328, - "max": 108003328, + "median": 82837504, + "min": 82837504, + "max": 82837504, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 169295872, - 169295872, - 169279488, - 169295872, - 169279488, - 169361408, - 169574400 + 122044416, + 122044416, + 122060800, + 122060800, + 122044416, + 122060800, + 122044416 ], "sample_count": 7, - "median": 169295872, - "min": 169279488, - "max": 169574400, - "stdev": 98970.475429, - "spread": 294912, - "spread_pct": 0.174199 + "median": 122044416, + "min": 122044416, + "max": 122060800, + "stdev": 8107.977266, + "spread": 16384, + "spread_pct": 0.013425 }, "peak_rss_bytes": { "samples": [ - 170917888, - 170917888, - 170901504, - 170917888, - 170901504, - 170983424, - 171196416 + 124665856, + 124665856, + 124682240, + 124682240, + 124665856, + 124682240, + 124665856 ], "sample_count": 7, - "median": 170917888, - "min": 170901504, - "max": 171196416, - "stdev": 98970.475429, - "spread": 294912, - "spread_pct": 0.172546 + "median": 124665856, + "min": 124665856, + "max": 124682240, + "stdev": 8107.977266, + "spread": 16384, + "spread_pct": 0.013142 }, "wall_ms": { "samples": [ - 2206.650375, - 2208.627542, - 2206.958292, - 2207.147125, - 2232.191958, - 2205.938833, - 2207.535084 + 1953.79225, + 1954.107416, + 1954.775125, + 1954.2065, + 1954.526834, + 1954.803459, + 1954.595208 ], "sample_count": 7, - "median": 2207.147125, - "min": 2205.938833, - "max": 2232.191958, - "stdev": 8.798574, - "spread": 26.253125, - "spread_pct": 1.18946 + "median": 1954.526834, + "min": 1953.79225, + "max": 1954.803459, + "stdev": 0.348485, + "spread": 1.011209, + "spread_pct": 0.051737 }, "minor_cycles": { "samples": [ @@ -2538,65 +2538,65 @@ }, "copied_objects": { "samples": [ - 79006, - 79006 + 59405, + 59405 ], "sample_count": 2, - "median": 79006, - "min": 79006, - "max": 79006, + "median": 59405, + "min": 59405, + "max": 59405, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 4424080, - 4424080 + 2851312, + 2851312 ], "sample_count": 2, - "median": 4424080, - "min": 4424080, - "max": 4424080, + "median": 2851312, + "min": 2851312, + "max": 2851312, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_objects": { "samples": [ - 524306, - 524306 + 529075, + 529075 ], "sample_count": 2, - "median": 524306, - "min": 524306, - "max": 524306, + "median": 529075, + "min": 529075, + "max": 529075, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_bytes": { "samples": [ - 29589352, - 29589352 + 25395472, + 25395472 ], "sample_count": 2, - "median": 29589352, - "min": 29589352, - "max": 29589352, + "median": 25395472, + "min": 25395472, + "max": 25395472, "stdev": 0, "spread": 0, "spread_pct": 0 }, "freed_bytes": { "samples": [ - 88897096, - 88897096 + 76315456, + 76315456 ], "sample_count": 2, - "median": 88897096, - "min": 88897096, - "max": 88897096, + "median": 76315456, + "min": 76315456, + "max": 76315456, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2616,181 +2616,181 @@ "metrics": { "heap_used_bytes": { "samples": [ - 414152, - 414152, - 414152, - 414152, - 414152, - 414152, - 414152 + 499576, + 499576, + 499576, + 499576, + 499576, + 499576, + 499576 ], "sample_count": 7, - "median": 414152, - "min": 414152, - "max": 414152, + "median": 499576, + "min": 499576, + "max": 499576, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 77594624, - 77594624, - 77594624, - 77594624, - 77594624, - 77594624, - 77594624 + 59768832, + 59768832, + 59768832, + 59768832, + 59768832, + 59768832, + 59768832 ], "sample_count": 7, - "median": 77594624, - "min": 77594624, - "max": 77594624, + "median": 59768832, + "min": 59768832, + "max": 59768832, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 191299584, - 192348160, - 192331776, - 191299584, - 191299584, - 191299584, - 193642496 + 145522688, + 145539072, + 145522688, + 145522688, + 145522688, + 145522688, + 145539072 ], "sample_count": 7, - "median": 191299584, - "min": 191299584, - "max": 193642496, - "stdev": 833118.501068, - "spread": 2342912, - "spread_pct": 1.224734 + "median": 145522688, + "min": 145522688, + "max": 145539072, + "stdev": 7401.536741, + "spread": 16384, + "spread_pct": 0.011259 }, "peak_rss_bytes": { "samples": [ - 191741952, - 192790528, - 192774144, - 191741952, - 191741952, - 191741952, - 194084864 + 145850368, + 145866752, + 145850368, + 145850368, + 145850368, + 145850368, + 145866752 ], "sample_count": 7, - "median": 191741952, - "min": 191741952, - "max": 194084864, - "stdev": 833118.501068, - "spread": 2342912, - "spread_pct": 1.221909 + "median": 145850368, + "min": 145850368, + "max": 145866752, + "stdev": 7401.536741, + "spread": 16384, + "spread_pct": 0.011233 }, "wall_ms": { "samples": [ - 963.962125, - 962.0635, - 964.339625, - 963.541583, - 964.327917, - 962.85475, - 963.868792 + 964.639667, + 964.053958, + 965.723666, + 964.926917, + 965.098875, + 966.04925, + 978.1995 ], "sample_count": 7, - "median": 963.868792, - "min": 962.0635, - "max": 964.339625, - "stdev": 0.773821, - "spread": 2.276125, - "spread_pct": 0.236145 + "median": 965.098875, + "min": 964.053958, + "max": 978.1995, + "stdev": 4.630805, + "spread": 14.145542, + "spread_pct": 1.465709 }, "minor_cycles": { "samples": [ - 4, - 4 + 3, + 3 ], "sample_count": 2, - "median": 4, - "min": 4, - "max": 4, + "median": 3, + "min": 3, + "max": 3, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 5, - 5 + 3, + 3 ], "sample_count": 2, - "median": 5, - "min": 5, - "max": 5, + "median": 3, + "min": 3, + "max": 3, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_objects": { "samples": [ - 532787, - 532787 + 0, + 0 ], "sample_count": 2, - "median": 532787, - "min": 532787, - "max": 532787, + "median": 0, + "min": 0, + "max": 0, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 32052048, - 32052048 + 0, + 0 ], "sample_count": 2, - "median": 32052048, - "min": 32052048, - "max": 32052048, + "median": 0, + "min": 0, + "max": 0, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_objects": { "samples": [ - 541926, - 541926 + 541614, + 541614 ], "sample_count": 2, - "median": 541926, - "min": 541926, - "max": 541926, + "median": 541614, + "min": 541614, + "max": 541614, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_bytes": { "samples": [ - 32599432, - 32599432 + 30316904, + 30316904 ], "sample_count": 2, - "median": 32599432, - "min": 32599432, - "max": 32599432, + "median": 30316904, + "min": 30316904, + "max": 30316904, "stdev": 0, "spread": 0, "spread_pct": 0 }, "freed_bytes": { "samples": [ - 116705744, - 116705744 + 97086288, + 97086288 ], "sample_count": 2, - "median": 116705744, - "min": 116705744, - "max": 116705744, + "median": 97086288, + "min": 97086288, + "max": 97086288, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2811,181 +2811,181 @@ "metrics": { "heap_used_bytes": { "samples": [ - 399152, - 399152, - 399152, - 399152, - 399152, - 399152, - 399152 + 402968, + 402968, + 402968, + 402968, + 402968, + 402968, + 402968 ], "sample_count": 7, - "median": 399152, - "min": 399152, - "max": 399152, + "median": 402968, + "min": 402968, + "max": 402968, "stdev": 0, "spread": 0, "spread_pct": 0 }, "heap_total_bytes": { "samples": [ - 26214400, - 26214400, - 26214400, - 26214400, - 26214400, - 26214400, - 26214400 + 23068672, + 23068672, + 23068672, + 23068672, + 23068672, + 23068672, + 23068672 ], "sample_count": 7, - "median": 26214400, - "min": 26214400, - "max": 26214400, + "median": 23068672, + "min": 23068672, + "max": 23068672, "stdev": 0, "spread": 0, "spread_pct": 0 }, "rss_bytes": { "samples": [ - 291667968, - 291667968, - 291651584, - 291667968, - 291667968, - 291667968, - 291651584 + 286490624, + 286556160, + 286556160, + 286556160, + 286326784, + 286343168, + 286326784 ], "sample_count": 7, - "median": 291667968, - "min": 291651584, - "max": 291667968, - "stdev": 7401.536741, - "spread": 16384, - "spread_pct": 0.005617 + "median": 286490624, + "min": 286326784, + "max": 286556160, + "stdev": 105039.254896, + "spread": 229376, + "spread_pct": 0.080064 }, "peak_rss_bytes": { "samples": [ - 292126720, - 292126720, - 292110336, - 292126720, - 292126720, - 292126720, - 292110336 + 286834688, + 286900224, + 286900224, + 286900224, + 286670848, + 286687232, + 286670848 ], "sample_count": 7, - "median": 292126720, - "min": 292110336, - "max": 292126720, - "stdev": 7401.536741, - "spread": 16384, - "spread_pct": 0.005609 + "median": 286834688, + "min": 286670848, + "max": 286900224, + "stdev": 105039.254896, + "spread": 229376, + "spread_pct": 0.079968 }, "wall_ms": { "samples": [ - 361.815459, - 362.701334, - 362.254791, - 361.316667, - 362.955208, - 363.004541, - 362.711917 + 403.921792, + 402.589041, + 402.488125, + 403.455875, + 403.15675, + 402.567125, + 404.484167 ], "sample_count": 7, - "median": 362.701334, - "min": 361.316667, - "max": 363.004541, - "stdev": 0.585293, - "spread": 1.687874, - "spread_pct": 0.465362 + "median": 403.15675, + "min": 402.488125, + "max": 404.484167, + "stdev": 0.707925, + "spread": 1.996042, + "spread_pct": 0.495103 }, "minor_cycles": { "samples": [ - 19, - 19 + 21, + 21 ], "sample_count": 2, - "median": 19, - "min": 19, - "max": 19, + "median": 21, + "min": 21, + "max": 21, "stdev": 0, "spread": 0, "spread_pct": 0 }, "step_cycles": { "samples": [ - 20, - 20 + 22, + 22 ], "sample_count": 2, - "median": 20, - "min": 20, - "max": 20, + "median": 22, + "min": 22, + "max": 22, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_objects": { "samples": [ - 241, - 241 + 307, + 307 ], "sample_count": 2, - "median": 241, - "min": 241, - "max": 241, + "median": 307, + "min": 307, + "max": 307, "stdev": 0, "spread": 0, "spread_pct": 0 }, "copied_bytes": { "samples": [ - 950784, - 950784 + 1222176, + 1222176 ], "sample_count": 2, - "median": 950784, - "min": 950784, - "max": 950784, + "median": 1222176, + "min": 1222176, + "max": 1222176, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_objects": { "samples": [ - 403514, - 403514 + 404022, + 404022 ], "sample_count": 2, - "median": 403514, - "min": 403514, - "max": 403514, + "median": 404022, + "min": 404022, + "max": 404022, "stdev": 0, "spread": 0, "spread_pct": 0 }, "promoted_bytes": { "samples": [ - 161104600, - 161104600 + 147516064, + 147516064 ], "sample_count": 2, - "median": 161104600, - "min": 161104600, - "max": 161104600, + "median": 147516064, + "min": 147516064, + "max": 147516064, "stdev": 0, "spread": 0, "spread_pct": 0 }, "freed_bytes": { "samples": [ - 104582496, - 104582496 + 118903288, + 118903288 ], "sample_count": 2, - "median": 104582496, - "min": 104582496, - "max": 104582496, + "median": 118903288, + "min": 118903288, + "max": 118903288, "stdev": 0, "spread": 0, "spread_pct": 0 @@ -2995,8 +2995,8 @@ }, "suite": { "schema_version": 2, - "commit": "98e9ecdb5", - "generated_at": "2026-08-12T06:33:42Z", + "commit": "f2ab194471", + "generated_at": "2026-08-16T09:38:07Z", "run_config": { "requested_samples": 5, "expected_benchmarks": [ @@ -3029,12 +3029,12 @@ "runtimes": { "perry": { "available": true, - "version": "perry 0.5.1484", + "version": "perry 0.5.1512", "command": [ "" ], "compile_command": [ - "~/artifacts-7843-main/perry", + "~/perry-bench.noindex/dist8122/perry", "", "-o", "" @@ -3064,33 +3064,33 @@ "perry": { "wall_ms": { "samples": [ - 131, - 94, - 94, - 94, - 94 + 0, + 0, + 0, + 0, + 0 ], "sample_count": 5, - "median": 94, - "p95": 131, - "min": 94, - "max": 131, + "median": 0, + "p95": 0, + "min": 0, + "max": 0, "mad": 0, - "stdev": 14.8 + "stdev": 0 }, "rss_kb": { "samples": [ - 4480, - 4480, - 4480, - 4480, - 4480 + 4592, + 4592, + 4592, + 4592, + 4592 ], "sample_count": 5, - "median": 4480, - "p95": 4480, - "min": 4480, - "max": 4480, + "median": 4592, + "p95": 4592, + "min": 4592, + "max": 4592, "mad": 0, "stdev": 0 } @@ -3098,42 +3098,42 @@ "node": { "wall_ms": { "samples": [ - 51, - 51, + 61, 52, 52, - 52 + 51, + 51 ], "sample_count": 5, "median": 52, - "p95": 52, + "p95": 61, "min": 51, - "max": 52, - "mad": 0, - "stdev": 0.489898 + "max": 61, + "mad": 1, + "stdev": 3.826225 }, "rss_kb": { "samples": [ - 82384, - 82272, - 82240, - 82208, - 82384 + 82256, + 82336, + 82368, + 82400, + 82320 ], "sample_count": 5, - "median": 82272, - "p95": 82384, - "min": 82208, - "max": 82384, - "mad": 64, - "stdev": 73.391008 + "median": 82336, + "p95": 82400, + "min": 82256, + "max": 82400, + "mad": 32, + "stdev": 48.530403 } } }, "ratios": { "perry_to_node": { - "wall_time": 1.807692, - "rss": 0.054454 + "wall_time": 0.0, + "rss": 0.055771 }, "perry_to_bun": null }, @@ -3148,12 +3148,12 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 94, - "perry_rss_kb": 4480, + "perry_ms": 0, + "perry_rss_kb": 4592, "node_ms": 52, - "node_rss_kb": 82272, - "speed_ratio": 1.807692, - "memory_ratio": 0.054454 + "node_rss_kb": 82336, + "speed_ratio": 0.0, + "memory_ratio": 0.055771 }, "03_array_write": { "runtimes": { @@ -3161,10 +3161,10 @@ "wall_ms": { "samples": [ 3, - 2, 1, 1, - 1 + 1, + 2 ], "sample_count": 5, "median": 1, @@ -3176,25 +3176,25 @@ }, "rss_kb": { "samples": [ - 97824, - 97824, - 97824, - 97840, - 97824 + 98304, + 98304, + 98288, + 98288, + 98304 ], "sample_count": 5, - "median": 97824, - "p95": 97840, - "min": 97824, - "max": 97840, + "median": 98304, + "p95": 98304, + "min": 98288, + "max": 98304, "mad": 0, - "stdev": 6.4 + "stdev": 7.838367 } }, "node": { "wall_ms": { "samples": [ - 7, + 8, 7, 7, 7, @@ -3202,34 +3202,34 @@ ], "sample_count": 5, "median": 7, - "p95": 7, + "p95": 8, "min": 7, - "max": 7, + "max": 8, "mad": 0, - "stdev": 0 + "stdev": 0.4 }, "rss_kb": { "samples": [ - 386624, - 386624, - 386688, - 386496, - 386544 + 386768, + 386720, + 386576, + 386592, + 386784 ], "sample_count": 5, - "median": 386624, - "p95": 386688, - "min": 386496, - "max": 386688, + "median": 386720, + "p95": 386784, + "min": 386576, + "max": 386784, "mad": 64, - "stdev": 67.428184 + "stdev": 87.635609 } } }, "ratios": { "perry_to_node": { "wall_time": 0.142857, - "rss": 0.253021 + "rss": 0.254199 }, "perry_to_bun": null }, @@ -3245,46 +3245,46 @@ "reason": "all 5 Perry sample(s) matched node semantic output" }, "perry_ms": 1, - "perry_rss_kb": 97824, + "perry_rss_kb": 98304, "node_ms": 7, - "node_rss_kb": 386624, + "node_rss_kb": 386720, "speed_ratio": 0.142857, - "memory_ratio": 0.253021 + "memory_ratio": 0.254199 }, "04_array_read": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 59, - 38, - 38, - 38, - 37 + 66, + 44, + 43, + 44, + 44 ], "sample_count": 5, - "median": 38, - "p95": 59, - "min": 37, - "max": 59, + "median": 44, + "p95": 66, + "min": 43, + "max": 66, "mad": 0, - "stdev": 8.508819 + "stdev": 8.908423 }, "rss_kb": { "samples": [ - 97824, - 97824, - 97808, - 97808, - 97808 + 98336, + 98352, + 98336, + 98336, + 98336 ], "sample_count": 5, - "median": 97808, - "p95": 97824, - "min": 97808, - "max": 97824, + "median": 98336, + "p95": 98352, + "min": 98336, + "max": 98352, "mad": 0, - "stdev": 7.838367 + "stdev": 6.4 } }, "node": { @@ -3306,26 +3306,26 @@ }, "rss_kb": { "samples": [ - 387296, - 387504, - 387472, + 387456, + 387440, + 387328, 387344, - 387248 + 387552 ], "sample_count": 5, - "median": 387344, - "p95": 387504, - "min": 387248, - "max": 387504, + "median": 387440, + "p95": 387552, + "min": 387328, + "max": 387552, "mad": 96, - "stdev": 99.354718 + "stdev": 81.584312 } } }, "ratios": { "perry_to_node": { - "wall_time": 3.166667, - "rss": 0.252509 + "wall_time": 3.666667, + "rss": 0.25381 }, "perry_to_bun": null }, @@ -3340,45 +3340,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 38, - "perry_rss_kb": 97808, + "perry_ms": 44, + "perry_rss_kb": 98336, "node_ms": 12, - "node_rss_kb": 387344, - "speed_ratio": 3.166667, - "memory_ratio": 0.252509 + "node_rss_kb": 387440, + "speed_ratio": 3.666667, + "memory_ratio": 0.25381 }, "05_fibonacci": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 431, - 390, - 390, - 390, - 390 + 797, + 756, + 757, + 757, + 756 ], "sample_count": 5, - "median": 390, - "p95": 431, - "min": 390, - "max": 431, - "mad": 0, - "stdev": 16.4 + "median": 757, + "p95": 797, + "min": 756, + "max": 797, + "mad": 1, + "stdev": 16.206172 }, "rss_kb": { "samples": [ - 4560, - 4560, - 4560, - 4560, - 4560 + 4720, + 4720, + 4720, + 4720, + 4720 ], "sample_count": 5, - "median": 4560, - "p95": 4560, - "min": 4560, - "max": 4560, + "median": 4720, + "p95": 4720, + "min": 4720, + "max": 4720, "mad": 0, "stdev": 0 } @@ -3386,42 +3386,42 @@ "node": { "wall_ms": { "samples": [ - 968, + 969, 968, 969, 968, - 968 + 969 ], "sample_count": 5, - "median": 968, + "median": 969, "p95": 969, "min": 968, "max": 969, "mad": 0, - "stdev": 0.4 + "stdev": 0.489898 }, "rss_kb": { "samples": [ 82096, - 82096, - 82128, - 82128, - 81936 + 82160, + 82112, + 82160, + 82096 ], "sample_count": 5, - "median": 82096, - "p95": 82128, - "min": 81936, - "max": 82128, - "mad": 32, - "stdev": 71.839822 + "median": 82112, + "p95": 82160, + "min": 82096, + "max": 82160, + "mad": 16, + "stdev": 29.328484 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.402893, - "rss": 0.055545 + "wall_time": 0.781218, + "rss": 0.057482 }, "perry_to_bun": null }, @@ -3436,45 +3436,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 390, - "perry_rss_kb": 4560, - "node_ms": 968, - "node_rss_kb": 82096, - "speed_ratio": 0.402893, - "memory_ratio": 0.055545 + "perry_ms": 757, + "perry_rss_kb": 4720, + "node_ms": 969, + "node_rss_kb": 82112, + "speed_ratio": 0.781218, + "memory_ratio": 0.057482 }, "06_math_intensive": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 86, - 49, - 49, - 49, - 49 + 87, + 47, + 47, + 47, + 47 ], "sample_count": 5, - "median": 49, - "p95": 86, - "min": 49, - "max": 86, + "median": 47, + "p95": 87, + "min": 47, + "max": 87, "mad": 0, - "stdev": 14.8 + "stdev": 16 }, "rss_kb": { "samples": [ - 4528, - 4528, - 4528, - 4528, - 4528 + 4656, + 4656, + 4656, + 4656, + 4656 ], "sample_count": 5, - "median": 4528, - "p95": 4528, - "min": 4528, - "max": 4528, + "median": 4656, + "p95": 4656, + "min": 4656, + "max": 4656, "mad": 0, "stdev": 0 } @@ -3498,26 +3498,26 @@ }, "rss_kb": { "samples": [ - 83648, - 83760, + 83840, + 83616, 83616, - 83696, - 83728 + 83792, + 83760 ], "sample_count": 5, - "median": 83696, - "p95": 83760, + "median": 83760, + "p95": 83840, "min": 83616, - "max": 83760, - "mad": 48, - "stdev": 52.190421 + "max": 83840, + "mad": 80, + "stdev": 92.412986 } } }, "ratios": { "perry_to_node": { - "wall_time": 1.020833, - "rss": 0.054101 + "wall_time": 0.979167, + "rss": 0.055587 }, "perry_to_bun": null }, @@ -3532,45 +3532,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 49, - "perry_rss_kb": 4528, + "perry_ms": 47, + "perry_rss_kb": 4656, "node_ms": 48, - "node_rss_kb": 83696, - "speed_ratio": 1.020833, - "memory_ratio": 0.054101 + "node_rss_kb": 83760, + "speed_ratio": 0.979167, + "memory_ratio": 0.055587 }, "07_object_create": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 11, - 3, - 3, - 2, - 2 + 25, + 8, + 7, + 8, + 8 ], "sample_count": 5, - "median": 3, - "p95": 11, - "min": 2, - "max": 11, - "mad": 1, - "stdev": 3.429286 + "median": 8, + "p95": 25, + "min": 7, + "max": 25, + "mad": 0, + "stdev": 6.910861 }, "rss_kb": { "samples": [ - 5344, - 5344, - 5344, - 5344, - 5344 + 5520, + 5520, + 5520, + 5520, + 5520 ], "sample_count": 5, - "median": 5344, - "p95": 5344, - "min": 5344, - "max": 5344, + "median": 5520, + "p95": 5520, + "min": 5520, + "max": 5520, "mad": 0, "stdev": 0 } @@ -3581,39 +3581,39 @@ 8, 8, 8, - 8, + 9, 8 ], "sample_count": 5, "median": 8, - "p95": 8, + "p95": 9, "min": 8, - "max": 8, + "max": 9, "mad": 0, - "stdev": 0 + "stdev": 0.4 }, "rss_kb": { "samples": [ - 85184, - 85072, 85248, - 85232, - 85232 + 85248, + 85280, + 85200, + 85184 ], "sample_count": 5, - "median": 85232, - "p95": 85248, - "min": 85072, - "max": 85248, - "mad": 16, - "stdev": 64.478213 + "median": 85248, + "p95": 85280, + "min": 85184, + "max": 85280, + "mad": 32, + "stdev": 35.054244 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.375, - "rss": 0.062699 + "wall_time": 1.0, + "rss": 0.064752 }, "perry_to_bun": null }, @@ -3628,56 +3628,56 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 3, - "perry_rss_kb": 5344, + "perry_ms": 8, + "perry_rss_kb": 5520, "node_ms": 8, - "node_rss_kb": 85232, - "speed_ratio": 0.375, - "memory_ratio": 0.062699 + "node_rss_kb": 85248, + "speed_ratio": 1.0, + "memory_ratio": 0.064752 }, "08_string_concat": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 7, - 2, + 5, 2, 1, + 1, 1 ], "sample_count": 5, - "median": 2, - "p95": 7, + "median": 1, + "p95": 5, "min": 1, - "max": 7, - "mad": 1, - "stdev": 2.244994 + "max": 5, + "mad": 0, + "stdev": 1.549193 }, "rss_kb": { "samples": [ - 4848, - 4848, - 4848, - 4848, - 4848 + 4912, + 4928, + 4912, + 4912, + 4912 ], "sample_count": 5, - "median": 4848, - "p95": 4848, - "min": 4848, - "max": 4848, + "median": 4912, + "p95": 4928, + "min": 4912, + "max": 4928, "mad": 0, - "stdev": 0 + "stdev": 6.4 } }, "node": { "wall_ms": { "samples": [ 4, - 4, - 3, 3, + 4, + 4, 4 ], "sample_count": 5, @@ -3686,30 +3686,30 @@ "min": 3, "max": 4, "mad": 0, - "stdev": 0.489898 + "stdev": 0.4 }, "rss_kb": { "samples": [ + 88896, 88992, - 88944, - 88912, - 88768, - 88784 + 89024, + 89040, + 88864 ], "sample_count": 5, - "median": 88912, - "p95": 88992, - "min": 88768, - "max": 88992, - "mad": 80, - "stdev": 88.796396 + "median": 88992, + "p95": 89040, + "min": 88864, + "max": 89040, + "mad": 48, + "stdev": 70.4 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.5, - "rss": 0.054526 + "wall_time": 0.25, + "rss": 0.055196 }, "perry_to_bun": null }, @@ -3724,88 +3724,88 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 2, - "perry_rss_kb": 4848, + "perry_ms": 1, + "perry_rss_kb": 4912, "node_ms": 4, - "node_rss_kb": 88912, - "speed_ratio": 0.5, - "memory_ratio": 0.054526 + "node_rss_kb": 88992, + "speed_ratio": 0.25, + "memory_ratio": 0.055196 }, "09_method_calls": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 21, - 10, + 19, + 9, 9, 9, 9 ], "sample_count": 5, "median": 9, - "p95": 21, + "p95": 19, "min": 9, - "max": 21, + "max": 19, "mad": 0, - "stdev": 4.71593 + "stdev": 4 }, "rss_kb": { "samples": [ - 5648, - 5648, - 5648, - 5648, - 5648 + 5792, + 5808, + 5792, + 5792, + 5792 ], "sample_count": 5, - "median": 5648, - "p95": 5648, - "min": 5648, - "max": 5648, + "median": 5792, + "p95": 5808, + "min": 5792, + "max": 5808, "mad": 0, - "stdev": 0 + "stdev": 6.4 } }, "node": { "wall_ms": { "samples": [ + 10, 11, 11, - 10, - 10, - 10 + 11, + 11 ], "sample_count": 5, - "median": 10, + "median": 11, "p95": 11, "min": 10, "max": 11, "mad": 0, - "stdev": 0.489898 + "stdev": 0.4 }, "rss_kb": { "samples": [ - 82928, + 82768, 82880, - 82976, - 82832, - 82992 + 83008, + 83008, + 82880 ], "sample_count": 5, - "median": 82928, - "p95": 82992, - "min": 82832, - "max": 82992, - "mad": 48, - "stdev": 59.523441 + "median": 82880, + "p95": 83008, + "min": 82768, + "max": 83008, + "mad": 112, + "stdev": 90.73566 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.9, - "rss": 0.068107 + "wall_time": 0.818182, + "rss": 0.069884 }, "perry_to_bun": null }, @@ -3821,55 +3821,55 @@ "reason": "all 5 Perry sample(s) matched node semantic output" }, "perry_ms": 9, - "perry_rss_kb": 5648, - "node_ms": 10, - "node_rss_kb": 82928, - "speed_ratio": 0.9, - "memory_ratio": 0.068107 + "perry_rss_kb": 5792, + "node_ms": 11, + "node_rss_kb": 82880, + "speed_ratio": 0.818182, + "memory_ratio": 0.069884 }, "10_nested_loops": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 91, - 57, - 56, - 56, - 56 + 106, + 74, + 73, + 73, + 73 ], "sample_count": 5, - "median": 56, - "p95": 91, - "min": 56, - "max": 91, + "median": 73, + "p95": 106, + "min": 73, + "max": 106, "mad": 0, - "stdev": 13.905395 + "stdev": 13.105724 }, "rss_kb": { "samples": [ - 9440, - 9456, - 9456, - 9440, - 9440 + 11168, + 11168, + 11184, + 11168, + 11168 ], "sample_count": 5, - "median": 9440, - "p95": 9456, - "min": 9440, - "max": 9456, + "median": 11168, + "p95": 11184, + "min": 11168, + "max": 11184, "mad": 0, - "stdev": 7.838367 + "stdev": 6.4 } }, "node": { "wall_ms": { "samples": [ - 18, 18, 16, 18, + 18, 18 ], "sample_count": 5, @@ -3882,26 +3882,26 @@ }, "rss_kb": { "samples": [ - 84912, - 84944, - 83936, - 84928, - 84800 + 84688, + 83872, + 84864, + 84656, + 84864 ], "sample_count": 5, - "median": 84912, - "p95": 84944, - "min": 83936, - "max": 84944, - "mad": 32, - "stdev": 387.31899 + "median": 84688, + "p95": 84864, + "min": 83872, + "max": 84864, + "mad": 176, + "stdev": 368.681109 } } }, "ratios": { "perry_to_node": { - "wall_time": 3.111111, - "rss": 0.111174 + "wall_time": 4.055556, + "rss": 0.131872 }, "perry_to_bun": null }, @@ -3916,88 +3916,88 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 56, - "perry_rss_kb": 9440, + "perry_ms": 73, + "perry_rss_kb": 11168, "node_ms": 18, - "node_rss_kb": 84912, - "speed_ratio": 3.111111, - "memory_ratio": 0.111174 + "node_rss_kb": 84688, + "speed_ratio": 4.055556, + "memory_ratio": 0.131872 }, "11_prime_sieve": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 41, - 29, - 30, - 30, - 29 + 42, + 33, + 33, + 33, + 33 ], "sample_count": 5, - "median": 30, - "p95": 41, - "min": 29, - "max": 41, - "mad": 1, - "stdev": 4.621688 + "median": 33, + "p95": 42, + "min": 33, + "max": 42, + "mad": 0, + "stdev": 3.6 }, "rss_kb": { "samples": [ - 27888, - 27888, - 27872, - 27888, - 27872 + 29296, + 29296, + 29296, + 29296, + 29296 ], "sample_count": 5, - "median": 27888, - "p95": 27888, - "min": 27872, - "max": 27888, + "median": 29296, + "p95": 29296, + "min": 29296, + "max": 29296, "mad": 0, - "stdev": 7.838367 + "stdev": 0 } }, "node": { "wall_ms": { "samples": [ - 6, - 6, + 7, + 7, 6, 6, 6 ], "sample_count": 5, "median": 6, - "p95": 6, + "p95": 7, "min": 6, - "max": 6, + "max": 7, "mad": 0, - "stdev": 0 + "stdev": 0.489898 }, "rss_kb": { "samples": [ - 113776, - 114208, - 113776, + 113824, 113808, - 113728 + 114288, + 113872, + 113872 ], "sample_count": 5, - "median": 113776, - "p95": 114208, - "min": 113728, - "max": 114208, - "mad": 32, - "stdev": 176.261624 + "median": 113872, + "p95": 114288, + "min": 113808, + "max": 114288, + "mad": 48, + "stdev": 179.428426 } } }, "ratios": { "perry_to_node": { - "wall_time": 5.0, - "rss": 0.245113 + "wall_time": 5.5, + "rss": 0.257271 }, "perry_to_bun": null }, @@ -4012,57 +4012,57 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 30, - "perry_rss_kb": 27888, + "perry_ms": 33, + "perry_rss_kb": 29296, "node_ms": 6, - "node_rss_kb": 113776, - "speed_ratio": 5.0, - "memory_ratio": 0.245113 + "node_rss_kb": 113872, + "speed_ratio": 5.5, + "memory_ratio": 0.257271 }, "12_binary_trees": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 15, - 4, - 4, - 3, - 3 + 36, + 11, + 11, + 11, + 11 ], "sample_count": 5, - "median": 4, - "p95": 15, - "min": 3, - "max": 15, - "mad": 1, - "stdev": 4.621688 + "median": 11, + "p95": 36, + "min": 11, + "max": 36, + "mad": 0, + "stdev": 10 }, "rss_kb": { "samples": [ - 5312, - 5312, - 5312, - 5312, - 5312 + 5552, + 5568, + 5552, + 5552, + 5552 ], "sample_count": 5, - "median": 5312, - "p95": 5312, - "min": 5312, - "max": 5312, + "median": 5552, + "p95": 5568, + "min": 5552, + "max": 5568, "mad": 0, - "stdev": 0 + "stdev": 6.4 } }, "node": { "wall_ms": { "samples": [ - 9, 9, 10, 10, - 10 + 10, + 9 ], "sample_count": 5, "median": 10, @@ -4074,26 +4074,26 @@ }, "rss_kb": { "samples": [ - 85056, - 85136, - 85216, + 85232, + 85120, 85216, - 85264 + 85024, + 85248 ], "sample_count": 5, "median": 85216, - "p95": 85264, - "min": 85056, - "max": 85264, - "mad": 48, - "stdev": 73.391008 + "p95": 85248, + "min": 85024, + "max": 85248, + "mad": 32, + "stdev": 84.664042 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.4, - "rss": 0.062336 + "wall_time": 1.1, + "rss": 0.065152 }, "perry_to_bun": null }, @@ -4108,60 +4108,60 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 4, - "perry_rss_kb": 5312, + "perry_ms": 11, + "perry_rss_kb": 5552, "node_ms": 10, "node_rss_kb": 85216, - "speed_ratio": 0.4, - "memory_ratio": 0.062336 + "speed_ratio": 1.1, + "memory_ratio": 0.065152 }, "13_factorial": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 135, - 94, + 137, + 93, 94, 94, - 94 + 97 ], "sample_count": 5, "median": 94, - "p95": 135, - "min": 94, - "max": 135, - "mad": 0, - "stdev": 16.4 + "p95": 137, + "min": 93, + "max": 137, + "mad": 1, + "stdev": 17.052859 }, "rss_kb": { "samples": [ - 4512, - 4496, - 4496, - 4496, - 4496 + 4640, + 4640, + 4640, + 4640, + 4640 ], "sample_count": 5, - "median": 4496, - "p95": 4512, - "min": 4496, - "max": 4512, + "median": 4640, + "p95": 4640, + "min": 4640, + "max": 4640, "mad": 0, - "stdev": 6.4 + "stdev": 0 } }, "node": { "wall_ms": { "samples": [ + 578, 579, - 579, - 579, 578, - 579 + 578, + 578 ], "sample_count": 5, - "median": 579, + "median": 578, "p95": 579, "min": 578, "max": 579, @@ -4170,26 +4170,26 @@ }, "rss_kb": { "samples": [ - 84464, - 84544, + 84320, 84448, - 84480, - 84416 + 84576, + 84448, + 84384 ], "sample_count": 5, - "median": 84464, - "p95": 84544, - "min": 84416, - "max": 84544, - "mad": 16, - "stdev": 42.452797 + "median": 84448, + "p95": 84576, + "min": 84320, + "max": 84576, + "mad": 64, + "stdev": 84.905595 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.162349, - "rss": 0.05323 + "wall_time": 0.16263, + "rss": 0.054945 }, "perry_to_bun": null }, @@ -4205,44 +4205,44 @@ "reason": "all 5 Perry sample(s) matched node semantic output" }, "perry_ms": 94, - "perry_rss_kb": 4496, - "node_ms": 579, - "node_rss_kb": 84464, - "speed_ratio": 0.162349, - "memory_ratio": 0.05323 + "perry_rss_kb": 4640, + "node_ms": 578, + "node_rss_kb": 84448, + "speed_ratio": 0.16263, + "memory_ratio": 0.054945 }, "14_closure": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 87, + 89, 47, 47, - 46, - 47 + 47, + 46 ], "sample_count": 5, "median": 47, - "p95": 87, + "p95": 89, "min": 46, - "max": 87, + "max": 89, "mad": 0, - "stdev": 16.104658 + "stdev": 16.904437 }, "rss_kb": { "samples": [ - 4592, - 4592, - 4592, - 4592, - 4592 + 4800, + 4800, + 4800, + 4800, + 4800 ], "sample_count": 5, - "median": 4592, - "p95": 4592, - "min": 4592, - "max": 4592, + "median": 4800, + "p95": 4800, + "min": 4800, + "max": 4800, "mad": 0, "stdev": 0 } @@ -4266,26 +4266,26 @@ }, "rss_kb": { "samples": [ - 84000, - 84128, - 84368, - 84192, + 84288, + 84240, + 84144, + 84032, 84304 ], "sample_count": 5, - "median": 84192, - "p95": 84368, - "min": 84000, - "max": 84368, - "mad": 112, - "stdev": 129.826962 + "median": 84240, + "p95": 84304, + "min": 84032, + "max": 84304, + "mad": 64, + "stdev": 101.49601 } } }, "ratios": { "perry_to_node": { "wall_time": 0.158249, - "rss": 0.054542 + "rss": 0.05698 }, "perry_to_bun": null }, @@ -4301,46 +4301,46 @@ "reason": "all 5 Perry sample(s) matched node semantic output" }, "perry_ms": 47, - "perry_rss_kb": 4592, + "perry_rss_kb": 4800, "node_ms": 297, - "node_rss_kb": 84192, + "node_rss_kb": 84240, "speed_ratio": 0.158249, - "memory_ratio": 0.054542 + "memory_ratio": 0.05698 }, "15_mandelbrot": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 59, - 21, - 21, - 22, - 22 + 64, + 26, + 25, + 26, + 26 ], "sample_count": 5, - "median": 22, - "p95": 59, - "min": 21, - "max": 59, - "mad": 1, - "stdev": 15.006665 + "median": 26, + "p95": 64, + "min": 25, + "max": 64, + "mad": 0, + "stdev": 15.304901 }, "rss_kb": { "samples": [ - 4448, - 4448, - 4448, - 4448, - 4448 + 4560, + 4560, + 4560, + 4576, + 4560 ], "sample_count": 5, - "median": 4448, - "p95": 4448, - "min": 4448, - "max": 4448, + "median": 4560, + "p95": 4576, + "min": 4560, + "max": 4576, "mad": 0, - "stdev": 0 + "stdev": 6.4 } }, "node": { @@ -4350,10 +4350,10 @@ 24, 23, 23, - 23 + 24 ], "sample_count": 5, - "median": 23, + "median": 24, "p95": 24, "min": 23, "max": 24, @@ -4362,26 +4362,26 @@ }, "rss_kb": { "samples": [ - 83792, - 83840, - 83840, + 84288, 83904, - 83904 + 84032, + 83840, + 84064 ], "sample_count": 5, - "median": 83840, - "p95": 83904, - "min": 83792, - "max": 83904, - "mad": 48, - "stdev": 42.932505 + "median": 84032, + "p95": 84288, + "min": 83840, + "max": 84288, + "mad": 128, + "stdev": 154.662988 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.956522, - "rss": 0.053053 + "wall_time": 1.083333, + "rss": 0.054265 }, "perry_to_bun": null }, @@ -4396,45 +4396,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 22, - "perry_rss_kb": 4448, - "node_ms": 23, - "node_rss_kb": 83840, - "speed_ratio": 0.956522, - "memory_ratio": 0.053053 + "perry_ms": 26, + "perry_rss_kb": 4560, + "node_ms": 24, + "node_rss_kb": 84032, + "speed_ratio": 1.083333, + "memory_ratio": 0.054265 }, "16_matrix_multiply": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 127, - 85, - 85, - 85, - 85 + 189, + 142, + 142, + 142, + 142 ], "sample_count": 5, - "median": 85, - "p95": 127, - "min": 85, - "max": 127, + "median": 142, + "p95": 189, + "min": 142, + "max": 189, "mad": 0, - "stdev": 16.8 + "stdev": 18.8 }, "rss_kb": { "samples": [ - 8272, - 8288, - 8272, - 8272, - 8272 + 8624, + 8624, + 8624, + 8624, + 8640 ], "sample_count": 5, - "median": 8272, - "p95": 8288, - "min": 8272, - "max": 8288, + "median": 8624, + "p95": 8640, + "min": 8624, + "max": 8640, "mad": 0, "stdev": 6.4 } @@ -4442,42 +4442,42 @@ "node": { "wall_ms": { "samples": [ + 32, + 32, + 32, 33, - 33, - 33, - 33, - 33 + 32 ], "sample_count": 5, - "median": 33, + "median": 32, "p95": 33, - "min": 33, + "min": 32, "max": 33, "mad": 0, - "stdev": 0 + "stdev": 0.4 }, "rss_kb": { "samples": [ - 89584, - 89360, + 89632, 89536, - 89584, - 89360 + 89664, + 89568, + 89632 ], "sample_count": 5, - "median": 89536, - "p95": 89584, - "min": 89360, - "max": 89584, - "mad": 48, - "stdev": 103.395164 + "median": 89632, + "p95": 89664, + "min": 89536, + "max": 89664, + "mad": 32, + "stdev": 47.030203 } } }, "ratios": { "perry_to_node": { - "wall_time": 2.575758, - "rss": 0.092387 + "wall_time": 4.4375, + "rss": 0.096216 }, "perry_to_bun": null }, @@ -4492,45 +4492,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 85, - "perry_rss_kb": 8272, - "node_ms": 33, - "node_rss_kb": 89536, - "speed_ratio": 2.575758, - "memory_ratio": 0.092387 + "perry_ms": 142, + "perry_rss_kb": 8624, + "node_ms": 32, + "node_rss_kb": 89632, + "speed_ratio": 4.4375, + "memory_ratio": 0.096216 }, "bench_gc_pressure": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 57, - 20, + 58, + 19, + 19, 19, - 20, 19 ], "sample_count": 5, - "median": 20, - "p95": 57, + "median": 19, + "p95": 58, "min": 19, - "max": 57, - "mad": 1, - "stdev": 15.006665 + "max": 58, + "mad": 0, + "stdev": 15.6 }, "rss_kb": { "samples": [ - 24384, - 24400, - 24400, - 24400, - 24400 + 16480, + 16480, + 16496, + 16480, + 16480 ], "sample_count": 5, - "median": 24400, - "p95": 24400, - "min": 24384, - "max": 24400, + "median": 16480, + "p95": 16496, + "min": 16480, + "max": 16496, "mad": 0, "stdev": 6.4 } @@ -4554,26 +4554,26 @@ }, "rss_kb": { "samples": [ + 91136, 91280, 91104, - 91264, - 91264, + 91360, 91376 ], "sample_count": 5, - "median": 91264, + "median": 91280, "p95": 91376, "min": 91104, "max": 91376, - "mad": 16, - "stdev": 87.401602 + "mad": 96, + "stdev": 112.410676 } } }, "ratios": { "perry_to_node": { - "wall_time": 1.538462, - "rss": 0.267356 + "wall_time": 1.461538, + "rss": 0.180543 }, "perry_to_bun": null }, @@ -4588,88 +4588,88 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 20, - "perry_rss_kb": 24400, + "perry_ms": 19, + "perry_rss_kb": 16480, "node_ms": 13, - "node_rss_kb": 91264, - "speed_ratio": 1.538462, - "memory_ratio": 0.267356 + "node_rss_kb": 91280, + "speed_ratio": 1.461538, + "memory_ratio": 0.180543 }, "bench_json_roundtrip": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 228, - 221, - 222, - 220, - 222 + 229, + 223, + 223, + 223, + 223 ], "sample_count": 5, - "median": 222, - "p95": 228, - "min": 220, - "max": 228, - "mad": 1, - "stdev": 2.8 + "median": 223, + "p95": 229, + "min": 223, + "max": 229, + "mad": 0, + "stdev": 2.4 }, "rss_kb": { "samples": [ - 86816, - 86816, - 86816, - 86816, - 86816 + 89200, + 89200, + 89216, + 89216, + 89200 ], "sample_count": 5, - "median": 86816, - "p95": 86816, - "min": 86816, - "max": 86816, + "median": 89200, + "p95": 89216, + "min": 89200, + "max": 89216, "mad": 0, - "stdev": 0 + "stdev": 7.838367 } }, "node": { "wall_ms": { "samples": [ - 243, - 242, - 237, - 239, - 237 + 247, + 251, + 246, + 241, + 244 ], "sample_count": 5, - "median": 239, - "p95": 243, - "min": 237, - "max": 243, + "median": 246, + "p95": 251, + "min": 241, + "max": 251, "mad": 2, - "stdev": 2.497999 + "stdev": 3.310589 }, "rss_kb": { "samples": [ - 164048, + 164000, 163920, - 164048, - 164160, - 164048 + 164304, + 164096, + 164256 ], "sample_count": 5, - "median": 164048, - "p95": 164160, + "median": 164096, + "p95": 164304, "min": 163920, - "max": 164160, - "mad": 0, - "stdev": 75.995789 + "max": 164304, + "mad": 160, + "stdev": 146.432783 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.92887, - "rss": 0.529211 + "wall_time": 0.906504, + "rss": 0.543584 }, "perry_to_bun": null }, @@ -4684,47 +4684,47 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 222, - "perry_rss_kb": 86816, - "node_ms": 239, - "node_rss_kb": 164048, - "speed_ratio": 0.92887, - "memory_ratio": 0.529211 + "perry_ms": 223, + "perry_rss_kb": 89200, + "node_ms": 246, + "node_rss_kb": 164096, + "speed_ratio": 0.906504, + "memory_ratio": 0.543584 }, "bench_object_property": { "runtimes": { "perry": { "wall_ms": { "samples": [ + 150, + 117, 118, - 81, - 81, - 81, - 80 + 118, + 117 ], "sample_count": 5, - "median": 81, - "p95": 118, - "min": 80, - "max": 118, - "mad": 0, - "stdev": 14.905033 + "median": 118, + "p95": 150, + "min": 117, + "max": 150, + "mad": 1, + "stdev": 13.00769 }, "rss_kb": { "samples": [ - 23568, - 23552, - 23568, - 23552, - 23552 + 27392, + 27392, + 27376, + 27392, + 27392 ], "sample_count": 5, - "median": 23552, - "p95": 23568, - "min": 23552, - "max": 23568, + "median": 27392, + "p95": 27392, + "min": 27376, + "max": 27392, "mad": 0, - "stdev": 7.838367 + "stdev": 6.4 } }, "node": { @@ -4746,26 +4746,26 @@ }, "rss_kb": { "samples": [ - 84800, - 84768, - 84640, - 84720, - 84656 + 84704, + 84752, + 84816, + 84784, + 84880 ], "sample_count": 5, - "median": 84720, - "p95": 84800, - "min": 84640, - "max": 84800, - "mad": 64, - "stdev": 61.885055 + "median": 84784, + "p95": 84880, + "min": 84704, + "max": 84880, + "mad": 32, + "stdev": 59.351158 } } }, "ratios": { "perry_to_node": { - "wall_time": 6.230769, - "rss": 0.277998 + "wall_time": 9.076923, + "rss": 0.32308 }, "perry_to_bun": null }, @@ -4780,88 +4780,88 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 81, - "perry_rss_kb": 23552, + "perry_ms": 118, + "perry_rss_kb": 27392, "node_ms": 13, - "node_rss_kb": 84720, - "speed_ratio": 6.230769, - "memory_ratio": 0.277998 + "node_rss_kb": 84784, + "speed_ratio": 9.076923, + "memory_ratio": 0.32308 }, "bench_int_arithmetic": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 370, - 338, - 338, - 338, - 339 + 364, + 332, + 332, + 332, + 332 ], "sample_count": 5, - "median": 338, - "p95": 370, - "min": 338, - "max": 370, + "median": 332, + "p95": 364, + "min": 332, + "max": 364, "mad": 0, - "stdev": 12.705904 + "stdev": 12.8 }, "rss_kb": { "samples": [ - 4784, - 4784, - 4784, - 4784, - 4784 + 4944, + 4960, + 4944, + 4944, + 4944 ], "sample_count": 5, - "median": 4784, - "p95": 4784, - "min": 4784, - "max": 4784, + "median": 4944, + "p95": 4960, + "min": 4944, + "max": 4960, "mad": 0, - "stdev": 0 + "stdev": 6.4 } }, "node": { "wall_ms": { "samples": [ - 63, - 63, - 63, - 63, + 62, + 65, + 62, + 62, 63 ], "sample_count": 5, - "median": 63, - "p95": 63, - "min": 63, - "max": 63, + "median": 62, + "p95": 65, + "min": 62, + "max": 65, "mad": 0, - "stdev": 0 + "stdev": 1.16619 }, "rss_kb": { "samples": [ - 83456, - 83376, + 83296, 83488, - 83312, - 83456 + 83472, + 83376, + 83520 ], "sample_count": 5, - "median": 83456, - "p95": 83488, - "min": 83312, - "max": 83488, - "mad": 32, - "stdev": 64.478213 + "median": 83472, + "p95": 83520, + "min": 83296, + "max": 83520, + "mad": 48, + "stdev": 82.582323 } } }, "ratios": { "perry_to_node": { - "wall_time": 5.365079, - "rss": 0.057324 + "wall_time": 5.354839, + "rss": 0.059229 }, "perry_to_bun": null }, @@ -4876,45 +4876,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 338, - "perry_rss_kb": 4784, - "node_ms": 63, - "node_rss_kb": 83456, - "speed_ratio": 5.365079, - "memory_ratio": 0.057324 + "perry_ms": 332, + "perry_rss_kb": 4944, + "node_ms": 62, + "node_rss_kb": 83472, + "speed_ratio": 5.354839, + "memory_ratio": 0.059229 }, "bench_buffer_readwrite": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 131, - 94, + 133, + 93, 94, 94, 94 ], "sample_count": 5, "median": 94, - "p95": 131, - "min": 94, - "max": 131, + "p95": 133, + "min": 93, + "max": 133, "mad": 0, - "stdev": 14.8 + "stdev": 15.704776 }, "rss_kb": { "samples": [ - 5744, - 5744, - 5744, - 5760, - 5744 + 5856, + 5840, + 5840, + 5840, + 5840 ], "sample_count": 5, - "median": 5744, - "p95": 5760, - "min": 5744, - "max": 5760, + "median": 5840, + "p95": 5856, + "min": 5840, + "max": 5856, "mad": 0, "stdev": 6.4 } @@ -4925,39 +4925,39 @@ 81, 81, 82, - 82, - 82 + 81, + 81 ], "sample_count": 5, - "median": 82, + "median": 81, "p95": 82, "min": 81, "max": 82, "mad": 0, - "stdev": 0.489898 + "stdev": 0.4 }, "rss_kb": { "samples": [ - 83072, + 83120, + 83104, 82992, - 83072, - 82944, - 83040 + 82928, + 82864 ], "sample_count": 5, - "median": 83040, - "p95": 83072, - "min": 82944, - "max": 83072, - "mad": 32, - "stdev": 49.574187 + "median": 82992, + "p95": 83120, + "min": 82864, + "max": 83120, + "mad": 112, + "stdev": 98.941599 } } }, "ratios": { "perry_to_node": { - "wall_time": 1.146341, - "rss": 0.069171 + "wall_time": 1.160494, + "rss": 0.070368 }, "perry_to_bun": null }, @@ -4973,11 +4973,11 @@ "reason": "all 5 Perry sample(s) matched node semantic output" }, "perry_ms": 94, - "perry_rss_kb": 5744, - "node_ms": 82, - "node_rss_kb": 83040, - "speed_ratio": 1.146341, - "memory_ratio": 0.069171 + "perry_rss_kb": 5840, + "node_ms": 81, + "node_rss_kb": 82992, + "speed_ratio": 1.160494, + "memory_ratio": 0.070368 }, "bench_array_grow": { "runtimes": { @@ -4985,34 +4985,34 @@ "wall_ms": { "samples": [ 18, - 8, 7, 7, - 8 + 7, + 7 ], "sample_count": 5, - "median": 8, + "median": 7, "p95": 18, "min": 7, "max": 18, - "mad": 1, - "stdev": 4.223742 + "mad": 0, + "stdev": 4.4 }, "rss_kb": { "samples": [ - 43296, - 43312, - 43312, - 43312, - 43312 + 43616, + 43632, + 43632, + 43616, + 43616 ], "sample_count": 5, - "median": 43312, - "p95": 43312, - "min": 43296, - "max": 43312, + "median": 43616, + "p95": 43632, + "min": 43616, + "max": 43632, "mad": 0, - "stdev": 6.4 + "stdev": 7.838367 } }, "node": { @@ -5021,39 +5021,39 @@ 11, 11, 10, - 10, - 12 + 11, + 11 ], "sample_count": 5, "median": 11, - "p95": 12, + "p95": 11, "min": 10, - "max": 12, - "mad": 1, - "stdev": 0.748331 + "max": 11, + "mad": 0, + "stdev": 0.4 }, "rss_kb": { "samples": [ - 141168, - 145856, - 141344, - 145904, - 145952 + 141440, + 145984, + 141328, + 145808, + 145920 ], "sample_count": 5, - "median": 145856, - "p95": 145952, - "min": 141168, - "max": 145952, - "mad": 96, - "stdev": 2277.928041 + "median": 145808, + "p95": 145984, + "min": 141328, + "max": 145984, + "mad": 176, + "stdev": 2215.338529 } } }, "ratios": { "perry_to_node": { - "wall_time": 0.727273, - "rss": 0.29695 + "wall_time": 0.636364, + "rss": 0.299133 }, "perry_to_bun": null }, @@ -5070,45 +5070,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 8, - "perry_rss_kb": 43312, + "perry_ms": 7, + "perry_rss_kb": 43616, "node_ms": 11, - "node_rss_kb": 145856, - "speed_ratio": 0.727273, - "memory_ratio": 0.29695 + "node_rss_kb": 145808, + "speed_ratio": 0.636364, + "memory_ratio": 0.299133 }, "bench_string_heavy": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 94, - 50, + 93, + 51, 51, 51, 51 ], "sample_count": 5, "median": 51, - "p95": 94, - "min": 50, - "max": 94, + "p95": 93, + "min": 51, + "max": 93, "mad": 0, - "stdev": 17.304335 + "stdev": 16.8 }, "rss_kb": { "samples": [ - 24288, - 24288, - 24288, - 24288, - 24272 + 23568, + 23552, + 23552, + 23552, + 23552 ], "sample_count": 5, - "median": 24288, - "p95": 24288, - "min": 24272, - "max": 24288, + "median": 23552, + "p95": 23568, + "min": 23552, + "max": 23568, "mad": 0, "stdev": 6.4 } @@ -5117,10 +5117,10 @@ "wall_ms": { "samples": [ 43, - 42, + 43, 43, 42, - 43 + 42 ], "sample_count": 5, "median": 43, @@ -5132,26 +5132,26 @@ }, "rss_kb": { "samples": [ - 82512, - 82432, - 82272, + 82352, + 82480, 82464, - 82512 + 82384, + 82384 ], "sample_count": 5, - "median": 82464, - "p95": 82512, - "min": 82272, - "max": 82512, - "mad": 48, - "stdev": 88.565456 + "median": 82384, + "p95": 82480, + "min": 82352, + "max": 82480, + "mad": 32, + "stdev": 49.985598 } } }, "ratios": { "perry_to_node": { "wall_time": 1.186047, - "rss": 0.294529 + "rss": 0.285881 }, "perry_to_bun": null }, @@ -5167,56 +5167,56 @@ "reason": "all 5 Perry sample(s) matched node semantic output" }, "perry_ms": 51, - "perry_rss_kb": 24288, + "perry_rss_kb": 23552, "node_ms": 43, - "node_rss_kb": 82464, + "node_rss_kb": 82384, "speed_ratio": 1.186047, - "memory_ratio": 0.294529 + "memory_ratio": 0.285881 }, "bench_numeric_array_numeric": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 2728, - 2730, - 2719, - 2760, - 2706 + 92, + 92, + 92, + 91, + 92 ], "sample_count": 5, - "median": 2728, - "p95": 2760, - "min": 2706, - "max": 2760, - "mad": 9, - "stdev": 17.839282 + "median": 92, + "p95": 92, + "min": 91, + "max": 92, + "mad": 0, + "stdev": 0.4 }, "rss_kb": { "samples": [ - 48256, - 48240, - 48240, - 48240, - 48224 + 23888, + 23888, + 23904, + 23904, + 23888 ], "sample_count": 5, - "median": 48240, - "p95": 48256, - "min": 48224, - "max": 48256, + "median": 23888, + "p95": 23904, + "min": 23888, + "max": 23904, "mad": 0, - "stdev": 10.119289 + "stdev": 7.838367 } }, "node": { "wall_ms": { "samples": [ - 4, 5, + 4, 5, 5, - 5 + 4 ], "sample_count": 5, "median": 5, @@ -5224,30 +5224,30 @@ "min": 4, "max": 5, "mad": 0, - "stdev": 0.4 + "stdev": 0.489898 }, "rss_kb": { "samples": [ - 103008, - 102832, 102960, - 102896, - 102960 + 102928, + 102928, + 102880, + 102992 ], "sample_count": 5, - "median": 102960, - "p95": 103008, - "min": 102832, - "max": 103008, - "mad": 48, - "stdev": 61.052109 + "median": 102928, + "p95": 102992, + "min": 102880, + "max": 102992, + "mad": 32, + "stdev": 37.318092 } } }, "ratios": { "perry_to_node": { - "wall_time": 545.6, - "rss": 0.468531 + "wall_time": 18.4, + "rss": 0.232085 }, "perry_to_bun": null }, @@ -5262,45 +5262,45 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 2728, - "perry_rss_kb": 48240, + "perry_ms": 92, + "perry_rss_kb": 23888, "node_ms": 5, - "node_rss_kb": 102960, - "speed_ratio": 545.6, - "memory_ratio": 0.468531 + "node_rss_kb": 102928, + "speed_ratio": 18.4, + "memory_ratio": 0.232085 }, "bench_numeric_array_downgrade": { "runtimes": { "perry": { "wall_ms": { "samples": [ - 17, - 16, - 17, - 16, - 17 + 23, + 24, + 23, + 23, + 24 ], "sample_count": 5, - "median": 17, - "p95": 17, - "min": 16, - "max": 17, + "median": 23, + "p95": 24, + "min": 23, + "max": 24, "mad": 0, "stdev": 0.489898 }, "rss_kb": { "samples": [ - 24144, - 24144, - 24144, - 24144, - 24160 + 29776, + 29792, + 29792, + 29792, + 29792 ], "sample_count": 5, - "median": 24144, - "p95": 24160, - "min": 24144, - "max": 24160, + "median": 29792, + "p95": 29792, + "min": 29776, + "max": 29792, "mad": 0, "stdev": 6.4 } @@ -5308,11 +5308,11 @@ "node": { "wall_ms": { "samples": [ - 5, 5, 4, 5, - 4 + 5, + 5 ], "sample_count": 5, "median": 5, @@ -5320,30 +5320,30 @@ "min": 4, "max": 5, "mad": 0, - "stdev": 0.489898 + "stdev": 0.4 }, "rss_kb": { "samples": [ - 103056, + 103120, 103184, 103120, - 103232, - 103008 + 103056, + 103216 ], "sample_count": 5, "median": 103120, - "p95": 103232, - "min": 103008, - "max": 103232, + "p95": 103216, + "min": 103056, + "max": 103216, "mad": 64, - "stdev": 81.584312 + "stdev": 55.793906 } } }, "ratios": { "perry_to_node": { - "wall_time": 3.4, - "rss": 0.234135 + "wall_time": 4.6, + "rss": 0.288906 }, "perry_to_bun": null }, @@ -5358,256 +5358,13 @@ ], "reason": "all 5 Perry sample(s) matched node semantic output" }, - "perry_ms": 17, - "perry_rss_kb": 24144, + "perry_ms": 23, + "perry_rss_kb": 29792, "node_ms": 5, "node_rss_kb": 103120, - "speed_ratio": 3.4, - "memory_ratio": 0.234135 + "speed_ratio": 4.6, + "memory_ratio": 0.288906 } } - }, - "accepted_deterministic_deltas": { - "commit": "0da668c95150d78ba5aa2b8eff7b78c04cc381c1", - "code_tree": "736f79847869bc9fb9fbd06f15537a5304a2bf46", - "generated_at": "2026-08-14T02:54:24+00:00", - "measurement": { - "platform": "darwin-arm64", - "repeats": 7, - "traced_runs": 2, - "binaries": { - "perry": { - "size": 104946752, - "sha256": "a9074f78d3368b908bce1f8b514236583ecb66dd942dc3330d448f68cf690829" - }, - "libperry_runtime.a": { - "size": 31142648, - "sha256": "80a98f3e5227c4927ab0de3d500124ca48e9bf2fc650d34c0b5c78caeb07160b" - }, - "libperry_stdlib.a": { - "size": 80871000, - "sha256": "191a26f29af113b94c7cf58769742cffcf6653098026755db9564dd05f87e018" - } - } - }, - "notes": "Only the 21 deterministic cells outside their existing bands were refreshed. RSS, timing, tolerances, and 58 within-band deterministic deltas retain the older pin. All 126 deterministic medians were identical at exact main 22005f433 and 0da668c95.", - "causes": { - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c": { - "pull_request": 7928, - "category": "right-size small objects from four inline slots to two", - "evidence": "A local exact-main-minus-commit boundary, with three normal repeats and duplicate identical traces, restored the broad object/byte accounting shifts; source changes the common small-object footprint from 72 to 56 bytes." - }, - "a87bf3e3feaaaad234eca2d193b951e747494fa1": { - "pull_request": 7961, - "category": "denominate the nursery constant band in survivor objects", - "evidence": "A local exact-main-minus-commit boundary, with three normal repeats and duplicate identical traces, isolated the changed collection cadence. On probe 13 it exchanged exactly 94,192 bytes from promoted to nursery-live placement; total live bytes after both layout changes are 236,176 below the old pin." - }, - "c35234de278566b9d3785c7286deff5196531bc4": { - "pull_request": 7960, - "category": "let the first copying minor choose promotion from its own trace", - "evidence": "A local exact-main-minus-commit boundary, with three normal repeats and duplicate identical traces, changed probe 14 from 31,021 to 241 copied objects while moving the same survivors to promotion; this directly proves the destination and cumulative-work shift." - } - }, - "cells": [ - { - "probe": "02_survivor_promotion", - "metric": "copied_objects", - "previous_median": 35943, - "accepted_median": 44539, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "03_cross_gen_writes", - "metric": "copied_objects", - "previous_median": 8214, - "accepted_median": 4107, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "03_cross_gen_writes", - "metric": "copied_bytes", - "previous_median": 656256, - "accepted_median": 262592, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "03_cross_gen_writes", - "metric": "freed_bytes", - "previous_median": 34273736, - "accepted_median": 17562728, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "04_dead_after_deep_stack", - "metric": "copied_objects", - "previous_median": 605, - "accepted_median": 461, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "04_dead_after_deep_stack", - "metric": "freed_bytes", - "previous_median": 118479632, - "accepted_median": 94363824, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "05_closure_capture", - "metric": "copied_objects", - "previous_median": 3078, - "accepted_median": 3960, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "05_closure_capture", - "metric": "freed_bytes", - "previous_median": 34510672, - "accepted_median": 29271264, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "06_string_retention", - "metric": "freed_bytes", - "previous_median": 67926328, - "accepted_median": 75243368, - "causes": [ - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "08_map_set_sidetables", - "metric": "copied_objects", - "previous_median": 3924, - "accepted_median": 3398, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "08_map_set_sidetables", - "metric": "copied_bytes", - "previous_median": 281016, - "accepted_median": 189496, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "08_map_set_sidetables", - "metric": "freed_bytes", - "previous_median": 101670912, - "accepted_median": 72310784, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "12_large_live_set", - "metric": "copied_objects", - "previous_median": 61453, - "accepted_median": 79006, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c" - ] - }, - { - "probe": "12_large_live_set", - "metric": "promoted_bytes", - "previous_median": 37749496, - "accepted_median": 29589352, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "12_large_live_set", - "metric": "freed_bytes", - "previous_median": 113377528, - "accepted_median": 88897096, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "13_large_eden_survivors", - "metric": "heap_used_bytes", - "previous_median": 239024, - "accepted_median": 414152, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "13_large_eden_survivors", - "metric": "freed_bytes", - "previous_median": 208564912, - "accepted_median": 116705744, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "a87bf3e3feaaaad234eca2d193b951e747494fa1" - ] - }, - { - "probe": "14_grow_then_churn", - "metric": "copied_objects", - "previous_median": 13013, - "accepted_median": 241, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "c35234de278566b9d3785c7286deff5196531bc4" - ] - }, - { - "probe": "14_grow_then_churn", - "metric": "copied_bytes", - "previous_median": 2003112, - "accepted_median": 950784, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "c35234de278566b9d3785c7286deff5196531bc4" - ] - }, - { - "probe": "14_grow_then_churn", - "metric": "promoted_bytes", - "previous_median": 151713056, - "accepted_median": 161104600, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "c35234de278566b9d3785c7286deff5196531bc4" - ] - }, - { - "probe": "14_grow_then_churn", - "metric": "freed_bytes", - "previous_median": 122137312, - "accepted_median": 104582496, - "causes": [ - "f110261a434b6aefcb6e452d97ed3e3ba7d6091c", - "c35234de278566b9d3785c7286deff5196531bc4" - ] - } - ] } } From 2bd4f0a9db6935d36946c7d7835123ac1b3a1846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 14:56:04 +0200 Subject: [PATCH 13/13] fix(fs): extract_string_ptr must test the STRING tag before reading a StringHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It accepted any non-finite NaN-box with a plausible payload, so mkdir_mode_from_options's string_value(options) read a StringHeader off the OPTIONS OBJECT. On main that misread byte_len from ObjectHeader::class_id (a small number, harmless garbage); with #8113's layout it reads the ShapeId (0x8000_0000+) and every fs.mkdirSync(dir, { recursive: true }) segfaulted in a 2 GB from_utf8_lossy — test_gap_fs_fd_2749 and both fs_errprop gap tests CRASH on the held #8122. Heap-STRING_TAG only now; string_value and stream::bytes_from_value go through str_bytes_from_jsvalue so inline SSO strings are read correctly instead of as garbage pointers; numeric_fd_value uses is_any_string. --- ...-recover-header-shrink-instruction-cost.md | 18 ++++++++ crates/perry-runtime/src/fs/mod.rs | 43 +++++++++++++------ crates/perry-runtime/src/fs/stream.rs | 14 +++--- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/changelog.d/8122-recover-header-shrink-instruction-cost.md b/changelog.d/8122-recover-header-shrink-instruction-cost.md index 2a87036a52..18818fb71a 100644 --- a/changelog.d/8122-recover-header-shrink-instruction-cost.md +++ b/changelog.d/8122-recover-header-shrink-instruction-cost.md @@ -146,6 +146,24 @@ arms, `otool` diffs) says otherwise: live (`copied_objects` 237k per cycle on `retain` under forced evacuation; 87 `[gc-fromspace-protect]` retire lines on `churn`). +#### A crash the shrink exposed, found by the gap suite + +`fs::extract_string_ptr` accepted ANY non-finite NaN-box with a plausible +payload — no `STRING_TAG` test — so `mkdir_mode_from_options`'s +`string_value(options)` read a `StringHeader` off the **options object**. On +`main` that misread `byte_len` from `ObjectHeader::class_id` (a small number: +a harmless one-byte garbage string that `parse_mode_string` rejected). With +the #8113 layout the same read lands on the ShapeId (`0x8000_0000` and up), +`from_utf8_lossy` walks 2 GB, and every `fs.mkdirSync(dir, { recursive: true +})` segfaulted — `test_gap_fs_fd_2749`, `test_gap_fs_errprop_2735plus` and +`test_gap_fs_errprop2_2745plus` CRASH on the held #8122. Fixed at the source: +the pointer read is now preceded by the tag that says what it points at +(heap `STRING_TAG` only); `string_value` and `stream::bytes_from_value` go +through `str_bytes_from_jsvalue` so inline SSO strings are read correctly +instead of as garbage pointers; `numeric_fd_value` uses `is_any_string`. +The gap suite (564 tests, all against Node 26.5.1) is otherwise at parity +with `main`: every remaining mismatch reproduces on `main`'s binary. + #### Not closed here * `asyncpipe` peak footprint +2.8% at 120 batches (+7.7% at 1200). The GC diff --git a/crates/perry-runtime/src/fs/mod.rs b/crates/perry-runtime/src/fs/mod.rs index b2e173c402..5b46d5416d 100644 --- a/crates/perry-runtime/src/fs/mod.rs +++ b/crates/perry-runtime/src/fs/mod.rs @@ -237,16 +237,30 @@ pub(crate) fn is_fs_filehandle_value(value: f64) -> bool { object_class_id(value) == Some(CLASS_ID_FS_FILEHANDLE) } -/// Extract a string pointer from a NaN-boxed f64 value -/// Handles both NaN-boxed strings (with STRING_TAG) and raw pointers. -/// Returns null for invalid/small pointers (e.g. from TAG_UNDEFINED extraction). +/// Extract a heap string pointer from a NaN-boxed f64 value. +/// +/// Returns a pointer ONLY for a `STRING_TAG` value; null for everything else, +/// including `SHORT_STRING_TAG` (its payload is inline bytes, not a pointer — +/// callers that want those go through `str_bytes_from_jsvalue`) and every +/// non-string NaN-box. +/// +/// # Why the tag test is load-bearing (#8122) +/// +/// This used to accept ANY non-finite value with a plausible payload, so +/// `string_value(options)` in `mkdir_mode_from_options` read a `StringHeader` +/// off the OPTIONS OBJECT: `byte_len` at +4 aliased `ObjectHeader::class_id`, +/// which for an object literal is a small number, so the misread was a +/// harmless one-byte garbage string that `parse_mode_string` rejected. #8113 +/// moved the ShapeId to +4 (`0x8000_0000` and up), the same misread became a +/// 2 GB `from_utf8_lossy`, and every `fs.mkdirSync(dir, { recursive: true })` +/// segfaulted (`test_gap_fs_fd_2749` and both `fs_errprop` gap tests). A +/// pointer read must be preceded by the tag that says what it points at. #[inline] fn extract_string_ptr(value: f64) -> *const StringHeader { - if value.is_finite() { + let bits = value.to_bits(); + if bits & crate::value::TAG_MASK != crate::value::STRING_TAG { return std::ptr::null(); } - let bits = value.to_bits(); - // Mask off the tag bits to get the raw pointer let ptr = (bits & POINTER_MASK) as usize; if ptr < 0x1000 { std::ptr::null() @@ -270,7 +284,7 @@ fn numeric_fd_value(value: f64) -> Option { bits as usize }; if crate::buffer::js_buffer_is_buffer(value.to_bits() as i64) == 1 - || !extract_string_ptr(value).is_null() + || crate::value::JSValue::from_bits(bits).is_any_string() { return None; } @@ -1185,14 +1199,15 @@ pub(crate) unsafe fn decode_path_value_named(path_value: f64, arg_name: &str) -> } fn string_value(value: f64) -> Option { + // Both string representations (heap `STRING_TAG` and inline + // `SHORT_STRING_TAG`); `None` for anything that is not a string. + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let (ptr, len) = crate::string::str_bytes_from_jsvalue(value, &mut scratch)?; + if ptr.is_null() { + return None; + } unsafe { - let ptr = extract_string_ptr(value); - if ptr.is_null() { - return None; - } - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - Some(String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned()) + Some(String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len as usize)).into_owned()) } } diff --git a/crates/perry-runtime/src/fs/stream.rs b/crates/perry-runtime/src/fs/stream.rs index efd497cac6..981c5ab8f7 100644 --- a/crates/perry-runtime/src/fs/stream.rs +++ b/crates/perry-runtime/src/fs/stream.rs @@ -246,13 +246,15 @@ pub(crate) fn bytes_from_value(v: f64) -> Vec { return bytes.to_vec(); } } - let ptr = extract_string_ptr(v); - if ptr.is_null() { - return Vec::new(); + // Both string representations; empty for anything that is not a + // string (`extract_string_ptr` is heap-`STRING_TAG` only, #8122). + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + match crate::string::str_bytes_from_jsvalue(v, &mut scratch) { + Some((ptr, len)) if !ptr.is_null() => { + std::slice::from_raw_parts(ptr, len as usize).to_vec() + } + _ => Vec::new(), } - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - std::slice::from_raw_parts(data, len).to_vec() } }