From d3c553d279d3d0c8643b424973c43a935e5796f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 05:21:13 +0000 Subject: [PATCH] perf(runtime): make an absent read linear in prototype-chain depth (#10877) The object-getter tail read the receiver's prototype twice on an own-key miss, and each read re-enters the tail one level up, so an absent read cost 2^depth generic-getter entries. - Object.create chains: inherited_field_if_overridden walked the recorded chain and missed, then the tail's closing resolve_inherited_field walked it again. It now returns InheritedRead::Missed and the tail skips its walk. - F.prototype = new G() chains: F.prototype was read through F's synthetic class id and again through the instance's per-object link. The class-id walk now reports a prototype it fully read that answered exactly undefined; the tail skips the per-object walk when that is the receiver's recorded prototype (null answers still fall through). An inherited getter returning undefined also ran 2^depth times per read; it now runs once, as in node. --- changelog.d/10877-proto-chain-miss-linear.md | 41 +++++++ .../src/object/class_registry.rs | 3 +- .../class_registry/prototype_objects.rs | 38 ++++++- .../field_get_set/get_field_by_name_tail.rs | 67 +++++++---- .../field_get_set/prototype_override.rs | 66 +++++++++-- .../test_gap_10877_proto_chain_miss_linear.ts | 106 ++++++++++++++++++ 6 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 changelog.d/10877-proto-chain-miss-linear.md create mode 100644 test-files/test_gap_10877_proto_chain_miss_linear.ts diff --git a/changelog.d/10877-proto-chain-miss-linear.md b/changelog.d/10877-proto-chain-miss-linear.md new file mode 100644 index 0000000000..c20ebeb199 --- /dev/null +++ b/changelog.d/10877-proto-chain-miss-linear.md @@ -0,0 +1,41 @@ +Fixed an absent property read costing `2^depth` generic-getter entries on a +prototype chain (#10877). The object-getter tail read the receiver's prototype +twice on an own-key miss, and each read re-enters the tail one level up, so the +work doubled per hop: + +- **`Object.create` chains**: `prototype_override::inherited_field_if_overridden` + walked the recorded chain, missed, and the tail's closing + `resolve_inherited_field` walked the same chain again from the same + receiver. It now reports `InheritedRead::Missed` and the tail skips its own + walk. +- **`F.prototype = new G()` chains**: a `new F()` instance reaches + `F.prototype` both through F's synthetic class id + (`resolve_proto_chain_field_*`) and through its recorded per-object link + (`resolve_inherited_field`). The class-id walk now reports a prototype it read + in full that answered exactly `undefined` + (`resolve_proto_chain_field_noting_miss`); the tail skips the per-object walk + when that is the receiver's recorded prototype. A `null` answer does not + qualify — the class-id walk skips `null`, which the per-object read must + still return. + +Observable effect beyond speed: an inherited getter that returns `undefined` +ran `2^depth` times per read (once per re-entry); it now runs once, as in node. +`class C extends B` chains were already linear and are unchanged. + +Measured (`perry-dev`, 20,000 absent reads, min of 3 wall-clock): + +| chain | depth | before | after | +|---|---:|---:|---:| +| `Object.create` | 1 | 0.103 s | 0.046 s | +| `Object.create` | 4 | 0.803 s | 0.075 s | +| `Object.create` | 8 | 12.50 s | 0.120 s | +| `Object.create` | 10 | 51.4 s | 0.151 s | +| `F.prototype = new G()` | 1 | 0.115 s | 0.054 s | +| `F.prototype = new G()` | 4 | 0.833 s | 0.082 s | +| `F.prototype = new G()` | 8 | 13.52 s | 0.132 s | +| `F.prototype = new G()` | 10 | > 60 s | 0.156 s | + +Regression coverage: `test-files/test_gap_10877_proto_chain_miss_linear.ts` +pins exact getter call counts on both chain kinds (keyless and shaped +receivers), a `null`-valued inherited property, hits and middle links, and a +1,000-miss loop on 24-deep chains that did not finish in 120 s before. diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index ded1f978f9..ff395fb362 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -109,7 +109,8 @@ pub use state::{ pub(crate) use prototype_objects::{ class_prototype_object, ensure_function_prototype_object, function_class_id, function_value_for_class_id, instance_class_prototype_object, object_proto_chain_symbol_slot, - resolve_proto_chain_field, resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, + resolve_proto_chain_field, resolve_proto_chain_field_noting_miss, + resolve_proto_chain_field_with_receiver, resolve_proto_chain_symbol, synthetic_class_prototype_object, SYNTHETIC_CLASS_ID_BASE, }; pub use prototype_objects::{ diff --git a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs index 3aae2cbc3c..47a93f9ae0 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_objects.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_objects.rs @@ -515,7 +515,7 @@ pub(crate) unsafe fn resolve_proto_chain_field( class_id: u32, key: *const crate::StringHeader, ) -> Option { - resolve_proto_chain_field_inner(class_id, key, None, true) + resolve_proto_chain_field_inner(class_id, key, None, true, None) } pub(crate) unsafe fn resolve_proto_chain_field_with_receiver( @@ -523,7 +523,31 @@ pub(crate) unsafe fn resolve_proto_chain_field_with_receiver( key: *const crate::StringHeader, receiver: f64, ) -> Option { - resolve_proto_chain_field_inner(class_id, key, Some(receiver), false) + resolve_proto_chain_field_inner(class_id, key, Some(receiver), false, None) +} + +/// [`resolve_proto_chain_field_with_receiver`] that also reports, through +/// `read_miss`, a prototype object this walk read IN FULL — the generic getter, +/// which walks that object's own chain — and which answered exactly +/// `undefined`. Its bits are left as they were when nothing qualified. +/// +/// #10877: a `new F()` instance reaches `F.prototype` twice, through its +/// synthetic class id (this walk) and through its recorded per-object +/// prototype link (`resolve_inherited_field`), and the object-getter tail asks +/// both. Each is a full read of the same object, which re-enters the tail one +/// level up, so an absent read cost `2^depth` getter entries on a +/// constructor-function chain. The tail compares this to the receiver's +/// recorded prototype (`prototype_override::static_prototype_already_read`) +/// and skips the second read when they are the same object. Only an exact +/// `undefined` qualifies: this walk skips a `null` value, which the per-object +/// read must still return. +pub(crate) unsafe fn resolve_proto_chain_field_noting_miss( + class_id: u32, + key: *const crate::StringHeader, + receiver: f64, + read_miss: &mut u64, +) -> Option { + resolve_proto_chain_field_inner(class_id, key, Some(receiver), false, Some(read_miss)) } unsafe fn inherited_proto_accessor_value( @@ -659,6 +683,7 @@ unsafe fn resolve_proto_chain_field_inner( key: *const crate::StringHeader, receiver: Option, constructor_side: bool, + mut read_miss: Option<&mut u64>, ) -> Option { if let Some(receiver) = receiver { let receiver_value = JSValue::from_bits(receiver.to_bits()); @@ -844,6 +869,15 @@ unsafe fn resolve_proto_chain_field_inner( if !field_val.is_undefined() && !field_val.is_null() { return Some(field_val); } + if field_val.is_undefined() { + if let Some(read_miss) = read_miss.as_deref_mut() { + // The read can collect; re-read the address it now names. + let proto_now = class_prototype_object(cid); + if proto_now == proto_obj { + *read_miss = JSValue::pointer(proto_now as *const u8).bits(); + } + } + } } match get_parent_class_id(cid) { Some(p) if p != 0 && p != cid => { 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 7809b88368..38c561c75c 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 @@ -1235,25 +1235,29 @@ pub(crate) fn get_field_by_name_object_tail( if keys.is_null() { // #9131; see `prototype_override::inherited_field_if_overridden`. - // A miss returns None so the synthesized arms below stay reachable - // (#9244). - if let Some(v) = super::prototype_override::inherited_field_if_overridden(obj, key) { - return v; - } + // A miss is not a `Hit` so the synthesized arms below stay + // reachable (#9244). + let chain_walked = + match super::prototype_override::inherited_field_if_overridden(obj, key) { + super::prototype_override::InheritedRead::Hit(v) => return v, + read => read.walked(), + }; // #809: an object with no own keys (e.g. an `Object.create(proto)` // result, or a `Function.prototype = obj` instance) still has to // resolve inherited props/methods. Pre-fix this returned undefined // here — BEFORE the `class_id` prototype-walk below — so // `Object.create(P).m()` threw `TypeError: m is not a function`. let class_id = (*obj).class_id; + let mut proto_read_miss = 0u64; if class_id != 0 { let receiver = f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); - if let Some(v) = - super::super::class_registry::resolve_proto_chain_field_with_receiver( - class_id, key, receiver, - ) - { + if let Some(v) = super::super::class_registry::resolve_proto_chain_field_noting_miss( + class_id, + key, + receiver, + &mut proto_read_miss, + ) { return v; } let key_bytes = std::slice::from_raw_parts( @@ -1342,12 +1346,19 @@ pub(crate) fn get_field_by_name_object_tail( } // #2820: a keyless object (`{}`, `Object.create(...)`) may still // carry an explicit `Object.setPrototypeOf` prototype — walk it so - // inherited reads resolve. + // inherited reads resolve. Not a second time (#10877). if !key.is_null() { - if let Some(v) = - super::super::prototype_chain::resolve_inherited_field(obj as usize, key) + if !chain_walked + && !super::prototype_override::static_prototype_already_read( + obj, + proto_read_miss, + ) { - return v; + if let Some(v) = + super::super::prototype_chain::resolve_inherited_field(obj as usize, key) + { + return v; + } } if let Some(v) = super::accessors::array_subclass_prototype_field(obj, key) { return v; @@ -1626,9 +1637,13 @@ pub(crate) fn get_field_by_name_object_tail( } // Shaped-receiver own-key miss; same rule as the keyless arm above. - if let Some(v) = super::prototype_override::inherited_field_if_overridden(obj, key) { - return v; - } + let chain_walked = match super::prototype_override::inherited_field_if_overridden(obj, key) + { + super::prototype_override::InheritedRead::Hit(v) => return v, + read => read.walked(), + }; + // Set by the class-chain walk below; see `static_prototype_already_read`. + let mut proto_read_miss = 0u64; // Key not found in the keys_array — fall back to the class // vtable's getter map. Refs #486 (hono): cross-module class @@ -1685,7 +1700,12 @@ pub(crate) fn get_field_by_name_object_tail( { let receiver = f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()); - if let Some(v) = resolve_proto_chain_field_with_receiver(class_id, key, receiver) { + if let Some(v) = super::super::class_registry::resolve_proto_chain_field_noting_miss( + class_id, + key, + receiver, + &mut proto_read_miss, + ) { return v; } } @@ -1769,11 +1789,16 @@ pub(crate) fn get_field_by_name_object_tail( // #2820: before giving up, walk an explicit `Object.setPrototypeOf` // prototype chain recorded for this object so inherited property reads // (`obj.x` where `x` is an own property of the set prototype) resolve. + // Not a second time (#10877). if !key.is_null() { - if let Some(v) = - super::super::prototype_chain::resolve_inherited_field(obj as usize, key) + if !chain_walked + && !super::prototype_override::static_prototype_already_read(obj, proto_read_miss) { - return v; + if let Some(v) = + super::super::prototype_chain::resolve_inherited_field(obj as usize, key) + { + return v; + } } if let Some(v) = super::accessors::array_subclass_prototype_field(obj, key) { return v; diff --git a/crates/perry-runtime/src/object/field_get_set/prototype_override.rs b/crates/perry-runtime/src/object/field_get_set/prototype_override.rs index 8517a622b6..b3efd44d33 100644 --- a/crates/perry-runtime/src/object/field_get_set/prototype_override.rs +++ b/crates/perry-runtime/src/object/field_get_set/prototype_override.rs @@ -7,12 +7,30 @@ use crate::object::ObjectHeader; use crate::value::JSValue; +/// What [`inherited_field_if_overridden`] learned about an own-key miss. +pub(super) enum InheritedRead { + /// Authoritative: return this value. + Hit(JSValue), + /// The recorded chain was walked and does not carry the key. The caller's + /// own `resolve_inherited_field` would repeat that walk — skip it. + Missed, + /// No walk happened; the caller's fallbacks run unchanged. + NotWalked, +} + +impl InheritedRead { + /// Whether the recorded prototype chain has already been walked. + pub(super) fn walked(&self) -> bool { + !matches!(self, InheritedRead::NotWalked) + } +} + /// An explicit per-instance `[[Prototype]]` REPLACES the class's declaration /// prototype, so when the own-key scan misses, that chain is what decides — -/// `Some(value)` here is authoritative and the caller must not fall back to the +/// `Hit(value)` here is authoritative and the caller must not fall back to the /// class vtable. /// -/// A miss on the custom chain returns `None`, NOT `Some(undefined)`. #9131 +/// A miss on the custom chain is `Missed`, NOT `Hit(undefined)`. #9131 /// originally returned `Some(undefined)` to avoid resurrecting the old class /// surface, but the arms BELOW both call sites are not only the class vtable: /// they are also everything Perry *synthesizes* rather than stores on a real @@ -27,24 +45,34 @@ use crate::value::JSValue; /// heritage can differ between evaluations of one template. Other internal /// runtime wiring retains its existing fallback behavior. /// -/// `None` therefore means either no override, or an override that does not +/// A non-`Hit` answer means either no override, or an override that does not /// carry this key — in both cases the caller keeps its existing fallback. +/// +/// #10877: the two non-`Hit` answers differ in whether the recorded chain was +/// already walked. Both callers end in a `resolve_inherited_field` of their +/// own, for receivers WITHOUT the override flag. After a `Missed` they must +/// skip it: it is the same walk from the same receiver, so it cannot find +/// anything this one did not, and each hop of that walk re-enters the generic +/// getter on the prototype, which asks this function again. Walking twice per +/// level made an absent read cost `2^depth` generic-getter entries on an +/// `Object.create` chain (536,047 instructions at 8 hops), and ran an +/// inherited getter that returned `undefined` twice. pub(super) fn inherited_field_if_overridden( obj: *const ObjectHeader, key: *const crate::string::StringHeader, -) -> Option { +) -> InheritedRead { if key.is_null() { - return None; + return InheritedRead::NotWalked; } if !crate::object::prototype_chain::object_has_individual_class_prototype(obj as usize) { - return None; + return InheritedRead::NotWalked; } if class_prototype_declares_own_getter(obj, key) { - return None; + return InheritedRead::NotWalked; } if let Some(value) = crate::object::prototype_chain::resolve_inherited_field(obj as usize, key) { - return Some(value); + return InheritedRead::Hit(value); } // #10827: the two reasons this walk can miss are not the same reason. // @@ -61,9 +89,27 @@ pub(super) fn inherited_field_if_overridden( // plain function's `.prototype`, the boxed-wrapper builtins, the iterator // helpers), and swallowing those made them unreachable. if crate::object::prototype_chain::prototype_chain_ends_in_explicit_null(obj as usize) { - return Some(JSValue::undefined()); + return InheritedRead::Hit(JSValue::undefined()); } - None + InheritedRead::Missed +} + +/// #10877: whether `read_miss` — reported by +/// `class_registry::resolve_proto_chain_field_noting_miss` — is `obj`'s +/// recorded prototype. That prototype was then already read in full and +/// answered `undefined`, so `resolve_inherited_field(obj, key)` would repeat +/// the read and cannot find anything it did not. +pub(super) fn static_prototype_already_read(obj: *const ObjectHeader, read_miss: u64) -> bool { + if read_miss == 0 { + return false; + } + let Some(proto_bits) = crate::object::prototype_chain::object_static_prototype(obj as usize) + else { + return false; + }; + let noted = JSValue::from_bits(read_miss); + let proto = JSValue::from_bits(proto_bits); + proto.is_pointer() && noted.is_pointer() && proto.as_pointer::() == noted.as_pointer::() } /// A class prototype object's ClassBody getters are not stored on the object: diff --git a/test-files/test_gap_10877_proto_chain_miss_linear.ts b/test-files/test_gap_10877_proto_chain_miss_linear.ts new file mode 100644 index 0000000000..22673cd86b --- /dev/null +++ b/test-files/test_gap_10877_proto_chain_miss_linear.ts @@ -0,0 +1,106 @@ +// #10877: an absent read walks each prototype ONCE. The own-key-miss path +// read the receiver's prototype twice — for an `Object.create` chain, the +// recorded chain walked twice; for a `new F()` chain, `F.prototype` read once +// through F's class id and once through the instance's prototype link — and +// each read re-enters the generic getter one level up, so a miss cost 2^depth +// getter entries. An inherited getter that returns `undefined` is a miss to +// those walks, so it ran once per re-entry: the call counts below are exact +// where the cost was only a slowdown. + +let calls = 0; +const root: any = { + get probe() { + calls++; + return undefined; + }, + present: "root-value", +}; + +function chain(depth: number): any { + let p: any = root; + for (let i = 0; i < depth; i++) p = Object.create(p); + return p; +} + +// ── 1. an inherited getter answering undefined runs once per read ─────────── +for (const depth of [1, 2, 3, 5, 8]) { + const o = chain(depth); + calls = 0; + const v = o.probe; + console.log("getter", depth, String(v), calls); +} + +// ── 2. the same with an own key on the receiver (shaped receiver) ──────────── +for (const depth of [1, 3, 8]) { + const o = chain(depth); + o.own = 1; + calls = 0; + const v = o.probe; + console.log("shaped", depth, String(v), calls); +} + +// ── 3. hits and misses still resolve at every depth ───────────────────────── +for (const depth of [1, 4, 12]) { + const o = chain(depth); + console.log("hit", depth, o.present, String(o.absent), "absent" in o); +} + +// ── 4. a deep miss finishes: 2^depth entries would not ────────────────────── +{ + const o = chain(24); + let misses = 0; + for (let i = 0; i < 1000; i++) if (o.nothingHere === undefined) misses++; + console.log("deep", misses, o.present); +} + +// ── 5. constructor-function chains: `F.prototype = new G()` ──────────────── +function F0(this: any) {} +(F0 as any).prototype = root; +function F1(this: any) {} +(F1 as any).prototype = new (F0 as any)(); +function F2(this: any) {} +(F2 as any).prototype = new (F1 as any)(); +function F3(this: any) {} +(F3 as any).prototype = new (F2 as any)(); +(F3 as any).prototype.nullish = null; +for (const C of [F0, F1, F2, F3]) { + const o = new (C as any)(); + calls = 0; + const v = o.probe; + const keyless = calls; + o.own = 1; + calls = 0; + o.probe; + console.log("ctor", C.name, String(v), keyless, calls, o.present, String(o.absent)); +} +{ + const o = new (F3 as any)(); + console.log("ctor-null", o.nullish === null, "nullish" in o); + o.own = 1; + console.log("ctor-null-shaped", o.nullish === null); +} +{ + let Prev: any = F0; + for (let q = 0; q < 24; q++) { + const P = Prev; + const G: any = function (this: any) {}; + G.prototype = new P(); + Prev = G; + } + const o = new Prev(); + let misses = 0; + for (let i = 0; i < 1000; i++) if (o.nothingHere === undefined) misses++; + console.log("ctor-deep", misses, o.present); +} + +// ── 6. a middle link and Object.prototype are still consulted ─────────────── +{ + const mid: any = Object.create(chain(3)); + mid.midValue = "mid"; + const leaf: any = Object.create(Object.create(mid)); + console.log("mid", leaf.midValue, leaf.present, typeof leaf.hasOwnProperty); + (Object.prototype as any).fromObjectProto = "op"; + console.log("objproto", leaf.fromObjectProto); + delete (Object.prototype as any).fromObjectProto; + console.log("objproto-gone", String(leaf.fromObjectProto)); +}