diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index cc9561930f..f9bddbff8a 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -34,6 +34,10 @@ pub extern "C" fn js_object_delete_field( if obj.is_null() || key.is_null() { return 1; } + // A delete can rewrite key→slot mappings in place (same keys_array + // address), so cached (keys_array, key)→index plans must be flushed + // (`object::prop_plan` read-plan cache). + super::prop_plan::prop_plan_epoch_bump(); // A Proxy is a small registered id in the proxy id band, not a heap // ObjectHeader. Dereferencing it below (GC header / keys_array reads) would // segfault. Route `delete proxy.k` / `delete proxy[k]` through the proxy 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 e15cc3441d..b42c26b842 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 @@ -60,6 +60,113 @@ pub extern "C" fn js_object_get_field_by_name( } } } + // FAST LANE (store-plan-cache follow-up): resolve an OWN data field on a + // provably-plain arena class instance with no rooting scope, no + // exotic-registry probes, and no key hashing. Every gate proves a property + // the skipped slow-path checks would have tested: + // - band/tag checks: not a proxy (above) / handle / stream encoding; + // - `classify_heap_generation != Unknown`: the address is inside a + // registered arena page, so its GcHeader is real — and no malloc-backed + // exotic (BufferHeader / TypedArrayHeader / DateCell / RegExpHeader / + // Temporal cell, all mi- or gc-malloc'd) can classify as arena; + // - `GC_TYPE_OBJECT`: not a closure / array / error / Map / Set; + // - `class_id != 0` (and not the native-module id): not an arguments + // object (allocated with class 0), URL-shape object, builtin prototype + // host, or plain literal — those keep their existing paths; + // - `OBJ_FLAG_HAS_DESCRIPTORS` clear: no own accessor can shadow the + // slot (an own data property shadows inherited accessors per [[Get]]); + // `OBJ_FLAG_TYPED_ARRAY_PROTO` clear: not the per-kind TypedArray + // prototype host (its reflection accessors have empty backing fields). + // The (keys_array, interned key) → index mapping comes from the + // epoch-guarded read-plan cache (flushed on GC, descriptor / prototype / + // vtable mutations, and property deletes); a lane-local bounded scan + // populates it. An absent own key falls through — prototype and getter + // resolution stay on the existing path. + unsafe { + let bits = obj as u64; + let top16 = bits >> 48; + let raw = if top16 == 0x7FFD { + (bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else if top16 == 0 { + bits as usize + } else { + 0 + }; + if raw >= crate::gc::GC_HEADER_SIZE + 0x1000 + && !crate::value::addr_class::is_small_handle(raw) + && !crate::value::addr_class::is_stream_id_band(raw) + && crate::value::addr_class::is_above_handle_band(key as usize) + { + let key_gc = + (key as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*key_gc).gc_flags & crate::gc::GC_FLAG_INTERNED != 0 + && crate::arena::classify_heap_generation(raw) + != crate::arena::HeapGeneration::Unknown + { + let gc_hdr = + (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + const LANE_BLOCKING: u16 = + crate::gc::OBJ_FLAG_HAS_DESCRIPTORS | crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO; + if (*gc_hdr).obj_type == crate::gc::GC_TYPE_OBJECT + && (*gc_hdr)._reserved & LANE_BLOCKING == 0 + { + let o = raw as *const ObjectHeader; + let class_id = (*o).class_id; + if class_id != 0 + && class_id != super::super::native_module::NATIVE_MODULE_CLASS_ID + { + let keys = (*o).keys_array; + if !keys.is_null() + && ((keys as u64) >> 48) == 0 + && crate::value::addr_class::is_above_handle_band(keys as usize) + { + let alloc_limit = std::cmp::max((*o).field_count, 8) as usize; + if let Some(idx) = super::super::prop_plan::read_plan_lookup( + keys as usize, + key as usize, + ) { + return if (idx as usize) < alloc_limit { + super::accessors::js_object_get_field(o, idx) + } else { + match super::super::overflow_get(raw, idx as usize) { + Some(b) => JSValue::from_bits(b), + None => JSValue::undefined(), + } + }; + } + let keys_gc = (keys as *const u8).sub(crate::gc::GC_HEADER_SIZE) + as *const crate::gc::GcHeader; + if (*keys_gc).obj_type == crate::gc::GC_TYPE_ARRAY { + let key_count = + crate::array::keys_array_len_capped_to_capacity(keys); + if key_count <= 4096 { + for i in 0..key_count { + let kv = crate::array::js_array_get(keys, i as u32); + if crate::string::js_string_key_matches(kv, key) { + super::super::prop_plan::read_plan_record( + keys as usize, + key as usize, + i as u32, + ); + return if i < alloc_limit { + super::accessors::js_object_get_field(o, i as u32) + } else { + match super::super::overflow_get(raw, i) { + Some(b) => JSValue::from_bits(b), + None => JSValue::undefined(), + } + }; + } + } + } + } + } + } + } + } + } + } + // A receiver that LOOKS like a bare heap pointer (top 16 bits clear) but does // not land in the platform heap range is a MIS-decoded primitive, not an // object. The common case is a `number` whose raw f64 bits alias a sub-heap 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 ca377dfe07..befbfeae23 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -282,6 +282,167 @@ pub extern "C" fn js_object_set_field_by_name( } } } + // FAST LANE (mirror of the read lane in `js_object_get_field_by_name`, + // same gate rationale — see that comment): a provably-plain arena class + // instance whose store plan says "no interceptor for this (class, key)" + // takes the shape-transition cache directly, with no rooting scope and no + // exotic-registry probes. Additional store-only gates: the frozen family + // and the chain-divergence flags must be clear (same set the in-body fast + // path vets), and the plan hit itself certifies no vtable setter / + // prototype interceptor / URL / native-module route. Nothing on this path + // allocates from the arena, so raw pointers stay valid without handles. + unsafe { + let bits = obj as u64; + let top16 = bits >> 48; + let raw = if top16 == 0x7FFD { + (bits & 0x0000_FFFF_FFFF_FFFF) as usize + } else if top16 == 0 { + bits as usize + } else { + 0 + }; + if raw >= crate::gc::GC_HEADER_SIZE + 0x1000 + && !crate::value::addr_class::is_small_handle(raw) + && !crate::value::addr_class::is_stream_id_band(raw) + && crate::value::addr_class::is_above_handle_band(key as usize) + { + let key_gc = + (key as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*key_gc).gc_flags & crate::gc::GC_FLAG_INTERNED != 0 + && crate::arena::classify_heap_generation(raw) + != crate::arena::HeapGeneration::Unknown + { + let gc_hdr = + (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + const LANE_BLOCKING: u16 = crate::gc::OBJ_FLAG_FROZEN + | crate::gc::OBJ_FLAG_SEALED + | crate::gc::OBJ_FLAG_NO_EXTEND + | crate::gc::OBJ_FLAG_HAS_DESCRIPTORS + | crate::gc::OBJ_FLAG_PROTO_OVERRIDE + | crate::gc::OBJ_FLAG_NULL_PROTO + | crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO; + if (*gc_hdr).obj_type == crate::gc::GC_TYPE_OBJECT + && (*gc_hdr)._reserved & LANE_BLOCKING == 0 + { + let o = raw as *mut ObjectHeader; + let class_id = (*o).class_id; + if class_id != 0 + && class_id != NATIVE_MODULE_CLASS_ID + && super::prop_plan::store_plan_check(class_id, key as usize) + { + let keys = (*o).keys_array; + let keys_ok = keys.is_null() + || (((keys as u64) >> 48) == 0 + && crate::value::addr_class::is_above_handle_band(keys as usize)); + if keys_ok { + // Overwrite of an EXISTING own key: the keys array + // doesn't change, so the shape-transition cache + // (which stores append EDGES) can never serve it — + // the (keys, key) → index read-plan cache can. + // Miss → one bounded scan populates it; absent own + // key falls to the append-edge lookup below. + if !keys.is_null() { + let mut own_idx = + super::prop_plan::read_plan_lookup(keys as usize, key as usize); + if own_idx.is_none() { + let keys_gc = (keys as *const u8).sub(crate::gc::GC_HEADER_SIZE) + as *const crate::gc::GcHeader; + if (*keys_gc).obj_type == crate::gc::GC_TYPE_ARRAY { + let key_count = + crate::array::keys_array_len_capped_to_capacity(keys); + if key_count <= 4096 { + for i in 0..key_count { + let kv = crate::array::js_array_get(keys, i as u32); + if crate::string::js_string_key_matches(kv, key) { + super::prop_plan::read_plan_record( + keys as usize, + key as usize, + i as u32, + ); + own_idx = Some(i as u32); + break; + } + } + } + } + } + if let Some(idx) = own_idx { + let vbits = value.to_bits(); + let vbits = if (vbits >> 48) == 0x7FFD + && (vbits & 0x0000_FFFF_FFFF_FFFF) == 0 + { + crate::value::TAG_UNDEFINED + } else { + vbits + }; + // Layout safety (#6495 family): the slot's + // pointer-ness may change — degrade the + // layout to full-visit before the store. + super::mark_object_dynamic_shape_unknown(o); + let alloc_limit = std::cmp::max((*o).field_count, 8) as usize; + if (idx as usize) < alloc_limit { + let fields_ptr = (o as *mut u8) + .add(std::mem::size_of::()) + as *mut JSValue; + let slot = fields_ptr.add(idx as usize); + crate::gc::runtime_store_jsvalue_slot( + o as usize, + slot as usize, + idx as usize, + vbits, + ); + if idx >= (*o).field_count { + (*o).field_count = idx + 1; + } + } else { + overflow_set(o as usize, idx as usize, vbits); + } + return; + } + } + if let Some((next_keys, slot_idx)) = + transition_cache_lookup(keys as usize, key) + { + // Same store semantics as the in-body fast + // path: strip a raw-null POINTER_TAG value, + // transition the keys array, note the dynamic + // shape, then write inline or overflow. + let vbits = value.to_bits(); + let vbits = if (vbits >> 48) == 0x7FFD + && (vbits & 0x0000_FFFF_FFFF_FFFF) == 0 + { + crate::value::TAG_UNDEFINED + } else { + vbits + }; + 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, 8) as usize; + if (slot_idx as usize) < alloc_limit { + let fields_ptr = (o as *mut u8) + .add(std::mem::size_of::()) + as *mut JSValue; + let slot = fields_ptr.add(slot_idx as usize); + crate::gc::runtime_store_jsvalue_slot( + o as usize, + slot as usize, + slot_idx as usize, + vbits, + ); + if slot_idx >= (*o).field_count { + (*o).field_count = slot_idx + 1; + } + } else { + overflow_set(o as usize, slot_idx as usize, vbits); + } + return; + } + } + } + } + } + } + } // A Buffer is an ordinary object in Node (a Uint8Array), so `buf.foo = v` // stores an own property — and an own key SHADOWS the same-named prototype // method. Perry keeps buffers outside the object model (raw BufferHeader, diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index cdd902bcd7..10ae2a4998 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -762,10 +762,27 @@ pub extern "C" fn perry_key_content_hash(key: *const crate::StringHeader) -> u64 } #[inline(always)] -fn key_content_hash(key: *const crate::StringHeader) -> u64 { +pub(crate) fn key_content_hash(key: *const crate::StringHeader) -> u64 { key_content_hash_impl(key) } +/// Resolve `key` to its canonical interned `StringHeader` pointer (as a +/// `usize`), the identity the `prop_plan` store/read caches key on. Returns 0 +/// for a null / handle-band key. Mirrors the inline interning both field +/// stores do, so a plan recorded on one store path is found by another. +#[inline] +pub(crate) unsafe fn interned_key_ptr(key: *const crate::StringHeader) -> usize { + if key.is_null() || !crate::value::addr_class::is_above_handle_band(key as usize) { + return 0; + } + let gc_hdr = (key as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*gc_hdr).gc_flags & crate::gc::GC_FLAG_INTERNED != 0 { + key as usize + } else { + crate::string::js_string_intern(key, key_content_hash(key)) as usize + } +} + #[inline(always)] fn key_content_hash_impl(key: *const crate::StringHeader) -> u64 { unsafe { diff --git a/crates/perry-runtime/src/object/prop_plan.rs b/crates/perry-runtime/src/object/prop_plan.rs index aa3504c8cd..fe55090a95 100644 --- a/crates/perry-runtime/src/object/prop_plan.rs +++ b/crates/perry-runtime/src/object/prop_plan.rs @@ -159,6 +159,90 @@ pub(crate) fn store_plan_record(class_id: u32, key_ptr: usize) { } } +// ── Read-plan cache: (keys_array, interned key) → own-field index ────────── +// +// The read fast lane (`js_object_get_field_by_name`) resolves an OWN data +// field on a provably-plain arena class instance without key hashing or a +// keys-array scan: one direct-mapped probe keyed by (keys_array address, +// interned key pointer). Entries are valid for one epoch window — the same +// [`PROP_PLAN_EPOCH`] that guards store plans — which is bumped on every GC +// collection (a keys-array address cannot be freed/reused and an interned +// key cannot move within a window), on descriptor/prototype/vtable +// mutations, and on property deletes (a delete can rewrite key→slot +// mappings in place at the same keys-array address). + +#[derive(Clone, Copy)] +struct ReadPlanEntry { + keys_id: usize, + key_ptr: usize, + epoch: u64, + field_idx: u32, +} + +const READ_PLAN_SIZE: usize = 8192; +const READ_PLAN_MASK: usize = READ_PLAN_SIZE - 1; + +thread_local! { + // Heap-allocated for the same arm64_32 TLS-size reason as the store table. + static READ_PLAN_CACHE: std::cell::UnsafeCell> = + std::cell::UnsafeCell::new( + vec![ + ReadPlanEntry { + keys_id: 0, + key_ptr: 0, + epoch: 0, + field_idx: 0, + }; + READ_PLAN_SIZE + ] + .into_boxed_slice(), + ); +} + +#[inline(always)] +fn read_plan_slot(keys_id: usize, key_ptr: usize) -> usize { + let h = ((keys_id >> 4) as u64 ^ ((key_ptr >> 3) as u64).rotate_left(21)) + .wrapping_mul(0x9E37_79B9_7F4A_7C15); + (h >> 40) as usize & READ_PLAN_MASK +} + +/// Look up the cached own-field index for (keys_array, interned key). +#[inline] +pub(crate) fn read_plan_lookup(keys_id: usize, key_ptr: usize) -> Option { + if keys_id == 0 || key_ptr == 0 { + return None; + } + let slot = read_plan_slot(keys_id, key_ptr); + READ_PLAN_CACHE.with(|c| unsafe { + let e = (*c.get())[slot]; + if e.keys_id == keys_id + && e.key_ptr == key_ptr + && e.epoch == PROP_PLAN_EPOCH.load(Ordering::Relaxed) + { + Some(e.field_idx) + } else { + None + } + }) +} + +/// Record an own-field index resolved by a validated keys-array scan. +#[inline] +pub(crate) fn read_plan_record(keys_id: usize, key_ptr: usize, field_idx: u32) { + if keys_id == 0 || key_ptr == 0 { + return; + } + let slot = read_plan_slot(keys_id, key_ptr); + READ_PLAN_CACHE.with(|c| unsafe { + (*c.get())[slot] = ReadPlanEntry { + keys_id, + key_ptr, + epoch: PROP_PLAN_EPOCH.load(Ordering::Relaxed), + field_idx, + }; + }); +} + #[cfg(test)] mod tests { use super::*; @@ -188,6 +272,17 @@ mod tests { assert!(!store_plan_check(9, key)); } + #[test] + fn read_plan_roundtrip_and_epoch_flush() { + let keys = 0xAAAA_0040usize; + let key = 0xBBBB_0080usize; + read_plan_record(keys, key, 21); + assert_eq!(read_plan_lookup(keys, key), Some(21)); + assert_eq!(read_plan_lookup(keys, key + 8), None); + prop_plan_epoch_bump(); + assert_eq!(read_plan_lookup(keys, key), None); + } + #[test] fn class_zero_and_null_key_never_cache() { store_plan_record(0, 0x1000); diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 13a81b912a..f4c47d62b7 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1337,7 +1337,39 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) // walk (O(n²)). Safe to fast-path when no inherited accessor / // non-writable anywhere in the prototype chain could intercept // this key. - !crate::object::class_instance_set_may_intercept(addr, class_id, key) + // + // This receiver-based [[Set]] is the dominant real-code store + // path (codegen emits js_put_value_set for many `obj.f = v`), + // and it re-ran the full O(chain) interception walk on every + // call. Consult the SHARED store-plan cache first: the verdict + // is the identical `!class_instance_set_may_intercept(class_id, + // key)` that `js_object_set_field_by_name` already memoizes, so + // a hit on either path serves the other. Only trust a cached + // class-chain verdict for instances whose chain matches the + // class chain — SLOW_FLAGS above already excluded + // frozen/sealed/descriptor bits; add the per-instance + // divergence flags (setPrototypeOf override / null proto). + const CHAIN_DIVERGE: u16 = + crate::gc::OBJ_FLAG_PROTO_OVERRIDE | crate::gc::OBJ_FLAG_NULL_PROTO; + let key_ptr = crate::builtins::js_string_coerce(key) + as *const crate::StringHeader; + let interned = crate::object::interned_key_ptr(key_ptr); + let plan_eligible = header._reserved & CHAIN_DIVERGE == 0 + && class_id != crate::object::NATIVE_MODULE_CLASS_ID + && interned != 0; + if plan_eligible + && crate::object::prop_plan::store_plan_check(class_id, interned) + { + true + } else { + let clear = !crate::object::class_instance_set_may_intercept( + addr, class_id, key, + ); + if clear && plan_eligible { + crate::object::prop_plan::store_plan_record(class_id, interned); + } + clear + } }; if fast_safe { target_set(target, key, value); diff --git a/test-files/test_gap_field_lane_semantics.ts b/test-files/test_gap_field_lane_semantics.ts new file mode 100644 index 0000000000..ce09f0bcd9 --- /dev/null +++ b/test-files/test_gap_field_lane_semantics.ts @@ -0,0 +1,19 @@ +var out=[]; +// delete-then-access under warm read/write plans +function F(){ this.a=1; this.b=2; } +var o=new F(); +for(var i=0;i<50000;i++){ o.a=i; var x=o.a; } +delete o.a; +out.push("d1="+(o.a===undefined)+"|"+o.b); +o.a=77; out.push("d2="+o.a); +// inherited getter via class after warm own-reads on another key +class G { constructor(){ this.own=5; } get computed(){ return this.own*2; } } +var g=new G(); +for(var i=0;i<50000;i++){ var y=g.own; } +out.push("g1="+g.computed+"|"+g.own); +// getter/setter pair on class, hot loop +class H { constructor(){ this._v=0; } get v(){ return this._v; } set v(x){ this._v=x+1; } } +var h=new H(); +for(var i=0;i<20000;i++){ h.v=i; } +out.push("h1="+h.v+"|"+h._v); +console.log(out.join(" ")); diff --git a/test-files/test_gap_put_value_plan_cache.ts b/test-files/test_gap_put_value_plan_cache.ts new file mode 100644 index 0000000000..bcaef936cf --- /dev/null +++ b/test-files/test_gap_put_value_plan_cache.ts @@ -0,0 +1,31 @@ +// Store-plan cache shared into the receiver-based [[Set]] path +// (js_put_value_set -> ordinary_set_with_receiver). Verdicts must match node. +var out: string[] = []; +// (1) inherited setter fires on receiver-set AFTER the (class,key) plan warms. +class A { _n = 0; set n(v: number){ this._n = v + 100; } get n(){ return this._n; } } +const a = new A(); +for (let i = 0; i < 50000; i++) { a.n = i; } +out.push("s1=" + a.n + "|" + a._n); +// (2) plain data field on a class stays a plain store. +class B { x = 0; } +const b = new B(); +for (let i = 0; i < 50000; i++) { b.x = i; } +out.push("s2=" + b.x); +// (3) per-instance setPrototypeOf override AFTER warm-up bypasses the class plan. +class C { v = 1; k = 0; } +const c1 = new C(), c2 = new C(); +for (let i = 0; i < 50000; i++) { c1.k = i; } +const log: string[] = []; +Object.setPrototypeOf(c2, { set k(v: number){ log.push("ov:" + v); } }); +(c2 as any).k = 7; +out.push("s3=" + log.join(",") + "|" + c1.k); +// (4) prototype accessor installed AFTER warm-up intercepts fresh instances. +class E { a = 0; } +const e1 = new E(); +for (let i = 0; i < 50000; i++) { e1.w = i; } +const plog: string[] = []; +Object.defineProperty(Object.getPrototypeOf(e1), "w", { set(v: number){ plog.push("ps:" + v); }, configurable: true }); +const e2 = new E(); +(e2 as any).w = 5; +out.push("s4=" + plog.join(",") + "|" + (e2 as any).w); +console.log(out.join(" "));