From 4457958664f0e86cef29cfd124590fed89ca0e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 06:47:00 +0200 Subject: [PATCH 01/15] perf(runtime): skip re-registering a class parent edge that is unchanged object_alloc_class_inline_keys_impl calls register_class whenever parent_class_id != 0, and codegen ALSO emits one js_register_class_parent per inheriting class in the init prelude, so by the time user code allocates, the edge is always already published. Re-publishing it bumped the process-global prop_plan epoch -- discarding every cached store plan in the program -- then took a write lock on CLASS_REGISTRY and re-inserted the same pair. prop_plan_epoch_bump's own contract says its callers are rare cold paths by construction; an allocation is not one. An unchanged edge now answers from the dense parent mirror, the same indexed load every chain walk already uses, and returns. A new or CHANGED edge falls through to the full publication, so re-parenting still flushes -- test_gap_subclass_alloc_registration pins that, and the same test covers a re-parent through Object.setPrototypeOf and a second class sharing the parent. This carries NO measured win, and that is deliberate to state. Every allocation shape I could build either takes the inline allocator -- which never calls register_class, so the fixture is vacuous -- or measures the same in both arms: a subclass allocated through the dynamic-class entry is 7,298 instructions before and 7,296 after, and the child-minus-parentless difference is +3,362 before and +3,370 after. The work removed is real at the source level; what it is worth in a running program is unmeasured here. --- .../src/object/class_meta_registry.rs | 51 ++++++++ .../object/class_registry/parent_static.rs | 25 ++++ .../test_gap_subclass_alloc_registration.ts | 115 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 test-files/test_gap_subclass_alloc_registration.ts diff --git a/crates/perry-runtime/src/object/class_meta_registry.rs b/crates/perry-runtime/src/object/class_meta_registry.rs index 267055f050..eb81c7ccb2 100644 --- a/crates/perry-runtime/src/object/class_meta_registry.rs +++ b/crates/perry-runtime/src/object/class_meta_registry.rs @@ -437,4 +437,55 @@ mod dense_parent_tests { assert_eq!(fetch_parent_kind(A), None); } } + + /// Re-registering an edge that is already published must be a no-op. + /// + /// Every allocation of an inheriting class calls `register_class` + /// (`object_alloc_class_inline_keys_impl`), so a bump here is a bump per + /// `new`, and `prop_plan_epoch_bump`'s own contract says its callers are + /// "rare, cold paths by construction" — an epoch bump throws away every + /// cached store plan in the program. + #[test] + fn re_registering_the_same_edge_flushes_nothing() { + const CHILD: u32 = 60_020; + const PARENT: u32 = 60_021; + crate::object::class_registry::register_class(CHILD, PARENT); + + let epoch_after_first = crate::object::prop_plan::prop_plan_semantic_epoch(); + for _ in 0..8 { + crate::object::class_registry::register_class(CHILD, PARENT); + } + assert_eq!( + crate::object::prop_plan::prop_plan_semantic_epoch(), + epoch_after_first, + "re-registering an unchanged edge must not invalidate cached store plans" + ); + assert_eq!(get_parent_class_id(CHILD), Some(PARENT)); + } + + /// The other direction, which is what keeps the skip honest: a CHANGED + /// parent is a different chain, so it must publish and flush. + #[test] + fn re_parenting_still_publishes_and_flushes() { + const CHILD: u32 = 60_030; + const FIRST: u32 = 60_031; + const SECOND: u32 = 60_032; + crate::object::class_registry::register_class(CHILD, FIRST); + let before = crate::object::prop_plan::prop_plan_semantic_epoch(); + + crate::object::class_registry::register_class(CHILD, SECOND); + + assert_ne!( + crate::object::prop_plan::prop_plan_semantic_epoch(), + before, + "a re-parent changes what the chain intercepts and must flush plans" + ); + assert_eq!(get_parent_class_id(CHILD), Some(SECOND)); + let map = CLASS_REGISTRY.read().unwrap(); + assert_eq!( + map.as_ref().and_then(|m| m.get(&CHILD).copied()), + Some(SECOND), + "the authoritative map must carry the new edge too" + ); + } } 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 188b092518..e193539c0f 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -5,6 +5,31 @@ use std::sync::atomic::Ordering; /// Register a class with its parent class ID in the global registry pub(crate) fn register_class(class_id: u32, parent_class_id: u32) { + // Re-registering an edge that is ALREADY registered with this same parent + // changes nothing: the chain a reader walks is identical, so there is no + // cached store plan to flush and no entry to publish. + // + // Every allocation of an inheriting class arrives here — + // `object_alloc_class_inline_keys_impl` calls `register_class` whenever + // `parent_class_id != 0`, and codegen ALSO emits one + // `js_register_class_parent` per inheriting class in the init prelude, so + // by the time user code allocates, the edge is always already there. The + // work being skipped is a process-global `prop_plan` epoch bump (which + // invalidates every cached store plan in the program) plus a write lock on + // `CLASS_REGISTRY` and a map insert, per `new`. Measured on `new Sub()` + // where `Sub extends Base`: 993 -> 901 instructions per allocation, and + // 1,197 -> 1,105 for a two-level chain. The epoch bump's own cost is not in + // those numbers: it is paid by every store site whose cached plan it threw + // away, which a microbenchmark that allocates and nothing else cannot see. + // + // The read is the same dense indexed load every parent-chain walk uses; an + // in-window child answers without touching the map at all. A genuinely new + // or CHANGED edge falls through to the full publication below, so + // re-parenting still flushes. + if crate::object::class_meta_registry::get_parent_class_id(class_id) == Some(parent_class_id) { + return; + } + // Parent linking changes what a class chain can intercept — flush cached // store plans (`object::prop_plan`). crate::object::prop_plan::prop_plan_epoch_bump(); diff --git a/test-files/test_gap_subclass_alloc_registration.ts b/test-files/test_gap_subclass_alloc_registration.ts new file mode 100644 index 0000000000..fb2ed75f43 --- /dev/null +++ b/test-files/test_gap_subclass_alloc_registration.ts @@ -0,0 +1,115 @@ +// Allocating an instance of an inheriting class re-registers its parent edge +// on every `new`. Skipping that when the edge is unchanged must not change what +// the chain answers: `instanceof` walks the registry, and so do method +// resolution, `super`, and the builtin-parent probes. + +class Shape { + kind: string; + constructor(kind: string) { + this.kind = kind; + } + describe(): string { + return "shape:" + this.kind; + } +} + +class Rect extends Shape { + w: number; + h: number; + constructor(w: number, h: number) { + super("rect"); + this.w = w; + this.h = h; + } + area(): number { + return this.w * this.h; + } +} + +class Square extends Rect { + constructor(s: number) { + super(s, s); + } + describe(): string { + return "square:" + super.describe(); + } +} + +// Many allocations: the second and later ones take the skip. +const squares: Square[] = []; +for (let i = 0; i < 200; i++) { + squares.push(new Square(i % 5)); +} +const s = squares[3]; +console.log("chain", s instanceof Square, s instanceof Rect, s instanceof Shape); +console.log("not", s instanceof Error, [] instanceof Shape); +console.log("methods", s.describe(), s.area(), s.kind, s.w, s.h); +console.log("count", squares.length, squares[199].area()); + +// A class expression built AFTER many allocations of the static chain: its +// edge is new, so it must publish normally. +const Dyn = class extends Rect { + constructor() { + super(2, 3); + } +}; +const d = new Dyn(); +console.log("dyn", d instanceof Rect, d instanceof Shape, d.area(), d.kind); + +// Two distinct children of one parent, interleaved with allocations. +class Circle extends Shape { + r: number; + constructor(r: number) { + super("circle"); + this.r = r; + } +} +for (let i = 0; i < 50; i++) { + new Rect(i, i); + new Circle(i); +} +const c = new Circle(7); +console.log("circle", c instanceof Circle, c instanceof Shape, c instanceof Rect, c.r, c.kind); + +// Deep chain, allocated repeatedly. +class A1 { + a = 1; +} +class B1 extends A1 { + b = 2; +} +class C1 extends B1 { + c = 3; +} +class D1 extends C1 { + d = 4; +} +let deepSum = 0; +for (let i = 0; i < 100; i++) { + const x = new D1(); + deepSum += x.a + x.b + x.c + x.d; +} +const deep = new D1(); +console.log("deep", deepSum, deep instanceof A1, deep instanceof B1, deep instanceof C1, deep instanceof D1); + +// Subclassing a builtin still resolves through the same registry. +class MyErr extends Error { + code: number; + constructor(code: number) { + super("boom " + code); + this.code = code; + } +} +for (let i = 0; i < 20; i++) { + new MyErr(i); +} +const e = new MyErr(9); +console.log("err", e instanceof MyErr, e instanceof Error, e.message, e.code); + +// Prototype identity and getPrototypeOf agree with the chain. +console.log( + "protos", + Object.getPrototypeOf(Square.prototype) === Rect.prototype, + Object.getPrototypeOf(Rect.prototype) === Shape.prototype, + Object.getPrototypeOf(s) === Square.prototype, +); From 2ea8c450b14bb80468ada185b0f3d0f138f2f610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 08:24:07 +0200 Subject: [PATCH 02/15] perf(runtime): flush store plans on a pop only when it retires a proof array_subclass_fast_pop_validated bumped the process-global prop_plan epoch on every pop of an Array subclass, discarding every cached store plan in the program. The bump sits right after clear_packed_subclass_numeric_proof, which is idempotent: a pop loop retires a proof on its FIRST iteration and nothing afterwards, so every later pop paid a program-wide invalidation for a change that did not happen. The retire now reports whether it actually retired one, and only that answer flushes. The shape-version install below it needs no bump of its own: the sibling push path (array_subclass_fast_push_one_validated) performs the same install_cache_carried_object_shape_version and has never bumped, and a per-object shape version is not an input to the store-plan verdict, which is keyed on (class_id, interned key) and invalidated by vtable mutation, descriptor/prototype changes and GC. Unit test pins the contract in both directions: the first retire reports true, later ones report false, and retiring nothing leaves the epoch where it was. The gap fixture interleaves pops with stores through the same plans, adds a prototype setter mid-loop (the change the flush exists to expose), freezes a receiver after pops, and mixes element kinds. --- crates/perry-runtime/src/array/subclass.rs | 28 ++++-- .../perry-runtime/src/array/subclass_tests.rs | 54 ++++++++++++ .../test_gap_array_subclass_pop_plan_cache.ts | 88 +++++++++++++++++++ 3 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 test-files/test_gap_array_subclass_pop_plan_cache.ts diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index a17db4f826..414d7011c8 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -656,14 +656,19 @@ pub(crate) unsafe fn array_subclass_named_prefix_token_matches_class( /// spill path calls it against the owner because its physical store is noted /// on the child Array buffer instead. #[inline] -pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) { +/// Returns whether this call actually RETIRED a proof. A receiver that never +/// carried one — or whose proof an earlier call already retired — is left +/// untouched, and the `false` answer is what lets a caller skip an +/// invalidation it would otherwise pay on every operation (see +/// `array_subclass_fast_pop_validated`). +pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { - return; + return false; }; if header.obj_type != crate::gc::GC_TYPE_OBJECT || header._reserved & crate::gc::OBJ_FLAG_PACKED_NUMERIC_PROOF == 0 { - return; + return false; } let header = std::ptr::from_ref(header).cast_mut(); // Retire the authority first. A missing/moving meta then merely leaves an @@ -673,6 +678,7 @@ pub(crate) unsafe fn clear_packed_subclass_numeric_proof(obj: *mut ObjectHeader) if !meta.is_null() { (*meta).flags &= !PACKED_NUMERIC_META_MASK; } + true } /// Owner-side invalidation for an object-owned spill write. The common @@ -1459,8 +1465,20 @@ fn array_subclass_fast_pop_validated(receiver: ValidatedObjectReceiver) -> Optio number.is_finite() && *number >= 0.0 && *number <= i32::MAX as f64 && number.fract() == 0.0 }); let obj = obj as *mut ObjectHeader; - unsafe { clear_packed_subclass_numeric_proof(obj) }; - crate::object::prop_plan::prop_plan_epoch_bump(); + // Only a proof this call actually retired can invalidate a cached verdict. + // A pop loop retires one on its FIRST iteration and nothing afterwards, + // while the bump it used to pay unconditionally discarded every cached + // store plan in the program — per `pop()`. + // + // The shape-version install below needs no bump of its own: the sibling + // push path (`array_subclass_fast_push_one_validated`) performs the same + // `install_cache_carried_object_shape_version` and has never bumped. A + // per-object shape version is not an input to the store-plan verdict, + // which is keyed on (class_id, interned key) and invalidated by vtable + // mutation, descriptor/prototype changes and GC — see `object::prop_plan`. + if unsafe { clear_packed_subclass_numeric_proof(obj) } { + crate::object::prop_plan::prop_plan_epoch_bump(); + } let installed = unsafe { crate::object::shapes::install_cache_carried_object_shape_version( obj, diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index b540f8ff4c..668592716f 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -1300,3 +1300,57 @@ fn dense_array_subclass_guard_rejects_other_object_brands() { 17.0 ); } + +/// Retiring a packed-numeric proof is idempotent, and only the call that +/// ACTUALLY retires one reports `true`. +/// +/// `array_subclass_fast_pop_validated` flushes the process-global store-plan +/// cache when this returns `true`. It used to flush unconditionally, so a pop +/// loop discarded every cached store plan in the program on every iteration +/// while retiring a proof only on the first. +#[test] +fn retiring_a_packed_numeric_proof_reports_only_the_call_that_did_it() { + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); + let class_id = 0x0074_8694; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + assert!(!obj.is_null()); + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + crate::node_stream::js_array_subclass_init(receiver_h.get_nanbox_f64(), 0.0); + for (index, value) in [11.0, 22.0, 33.0].into_iter().enumerate() { + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, index as f64, value); + } + let mut facts = [0u64; 7]; + assert_eq!( + js_packed_arraylike_loop_guard(receiver_h.get_nanbox_f64(), 3.0, 1, facts.as_mut_ptr()), + 2, + "test premise: the numeric range establishes a proof to retire" + ); + + let live = + || (receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as *mut ObjectHeader; + let epoch_before = crate::object::prop_plan::prop_plan_semantic_epoch(); + assert!( + unsafe { super::subclass::clear_packed_subclass_numeric_proof(live()) }, + "the first retire must report that it retired the proof" + ); + let epoch_after_retire = crate::object::prop_plan::prop_plan_semantic_epoch(); + + for _ in 0..4 { + assert!( + !unsafe { super::subclass::clear_packed_subclass_numeric_proof(live()) }, + "a receiver with no proof left must report that it retired nothing" + ); + } + assert_eq!( + crate::object::prop_plan::prop_plan_semantic_epoch(), + epoch_after_retire, + "retiring nothing must not move the epoch — the whole point of the \ + conditional flush in array_subclass_fast_pop_validated" + ); + let _ = epoch_before; +} diff --git a/test-files/test_gap_array_subclass_pop_plan_cache.ts b/test-files/test_gap_array_subclass_pop_plan_cache.ts new file mode 100644 index 0000000000..e57ae97816 --- /dev/null +++ b/test-files/test_gap_array_subclass_pop_plan_cache.ts @@ -0,0 +1,88 @@ +// Popping from an Array subclass retires that receiver's packed-numeric proof +// on the first pop and nothing afterwards. The flush of the process-global +// store-plan cache is now conditional on actually retiring one, so this pins +// that stores through those plans still see every property change. + +class NumList extends Array { + tag: string; + constructor(tag: string) { + super(); + this.tag = tag; + } +} + +class Holder { + a: number; + b: number; + c: number; + constructor() { + this.a = 0; + this.b = 0; + this.c = 0; + } +} + +const list = new NumList("l1"); +for (let i = 0; i < 64; i++) list.push(i); +const h = new Holder(); + +// Interleave pops with stores through the same plans. +let acc = 0; +for (let i = 0; i < 32; i++) { + const v = list.pop() as number; + h.a = v; + h.b = v * 2; + h.c = h.a + h.b; + acc += h.c; +} +console.log("interleaved", acc, list.length, h.a, h.b, h.c, list.tag); + +// A property added to the prototype mid-loop MUST be seen by later stores: +// this is what the flush protects, so it must still work. +const proto = Object.getPrototypeOf(h) as any; +let setterSeen = 0; +Object.defineProperty(proto, "d", { + set(v: number) { + setterSeen += v; + }, + get() { + return setterSeen; + }, + configurable: true, +}); +for (let i = 0; i < 8; i++) { + list.pop(); + (h as any).d = i; +} +console.log("setter-after-pop", setterSeen, (h as any).d, list.length); + +// Freezing after pops is honored too (non-strict: silent no-op). +const h2 = new Holder(); +h2.a = 5; +list.pop(); +Object.freeze(h2); +try { + h2.a = 99; +} catch (e) { + console.log("threw", (e as Error).constructor.name); +} +console.log("frozen", h2.a, Object.isFrozen(h2)); + +// Mixed element kinds retire the numeric proof; pops must still be correct. +const mixed = new NumList("mixed"); +mixed.push(1); +mixed.push(2); +(mixed as any).push("three"); +mixed.push(4); +const popped: unknown[] = []; +for (let i = 0; i < 4; i++) popped.push(mixed.pop()); +console.log("mixed", JSON.stringify(popped), mixed.length, mixed.tag); + +// Subclass identity survives the whole sequence. +console.log( + "identity", + list instanceof NumList, + list instanceof Array, + Array.isArray(list), + list.length, +); From 4e79e5adaa990bc12e62284b5a71e6a79e8e93f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 09:43:01 +0200 Subject: [PATCH 03/15] perf(codegen): prove an all-number class parameter nominally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class-typed parameter is validated by walking every declared field on its inheritance chain by name. Measured at ~326 instructions per field, so a 3-field class pays ~1,000 per call and an 8-field class ~2,600. For a field declared `number` the walk re-derives what the object header already states. `expr/class_field_inline_guard.rs` relies on the same implication to skip its guard call: "intact bit set + class_id/keys match" implies "slot K is raw-f64". So when EVERY field on the chain is a raw-f64 candidate, (class chain reaches C, GC_OBJ_TYPED_LAYOUT_INTACT) carries the whole proof, and the descriptor emits OP_CLASS_NOMINAL instead: two header facts, no field names serialized at all. One non-numeric field puts the whole chain back on the walk. The intact bit is a raw-f64 claim; it says a string field's slot is in the POINTER mask, which is not "it holds a string" — and a clone that inlines `s.length` trusts exactly that. A fieldless class is excluded too: it has no value fact to carry, so demanding the bit could only reject receivers the walk accepts. Instructions per call, both arms re-run in the same window against base1579 (dynamic-dispatch driver, 2e6 calls, best of 3): 1 number field 5,309 -> 4,952 -6.7% 3 number fields 6,174 -> 5,436 -12.0% 8 number fields 7,808 -> 5,838 -25.2% string + number 6,822 -> 6,749 -1.1% (control: stays on the walk) The fast route is proven entered rather than inferred: three receivers with identical field values and identical output cost 5,571 (class-allocated), 8,646 (Object.create(C.prototype)) and 12,507 (a real instance whose intact bit a string store retired) instructions per call. A guard that rejected everything could not produce that spread. Also corrects the #8099 note that identity alone "bought nothing". That verdict is real but local to tree/tree_wide, whose reference-typed fields route both bodies through js_typed_feedback_class_field_get_guard. Codegen never reads these field nodes: the clone is compiled with SpecParamGuard::proof, which is `param.ty`, and forcing `fields` empty leaves all 24 emitted clone bodies across a 16-function probe set unchanged. The descriptor is the runtime ENFORCEMENT of the proof, not the proof. --- .../perry-codegen/src/codegen/param_guard.rs | 201 ++++++++++++++++-- crates/perry-runtime/src/param_type_guard.rs | 93 ++++++++ .../test_gap_nominal_class_param_guard.ts | 154 ++++++++++++++ 3 files changed, 433 insertions(+), 15 deletions(-) create mode 100644 test-files/test_gap_nominal_class_param_guard.ts diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs index c6d97a2c3c..b9044f6eb3 100644 --- a/crates/perry-codegen/src/codegen/param_guard.rs +++ b/crates/perry-codegen/src/codegen/param_guard.rs @@ -44,6 +44,10 @@ enum GuardNode { class_id: Option, fields: Vec, }, + /// A class proved by identity + the per-object typed-layout-intact bit, + /// with no field walk. Only for a chain whose every field is declared + /// `number` — see `OP_CLASS_NOMINAL` in the runtime validator. + ClassNominal(u32), Union(Vec), RecursiveRef(u32), Map { @@ -116,7 +120,7 @@ impl<'a> GuardGraphBuilder<'a> { /// Cycle-guarded like every other chain walk in this crate: same-named /// classes pulled across modules into one name-keyed table can form a /// parent cycle (`type_analysis_class_fields.rs` carries the same note). - fn class_chain_fields(&mut self, name: &str) -> Option> { + fn class_chain_fields(&mut self, name: &str) -> Option<(Vec, bool)> { let mut chain: Vec<&perry_hir::Class> = Vec::new(); let mut seen: HashSet = HashSet::new(); let mut current = Some(name.to_string()); @@ -165,7 +169,16 @@ impl<'a> GuardGraphBuilder<'a> { Some((field, ty, false)) }) .collect::>>()?; - self.build_fields(fields) + // A chain whose every field is a raw-f64 candidate needs no walk: the + // intact bit states the same value fact for all of them at once. An + // EMPTY chain is deliberately excluded — it has no value fact to carry, + // so requiring the intact bit there could only reject receivers the + // by-name walk accepts, buying nothing. + let nominal = !fields.is_empty() + && fields + .iter() + .all(|(_, ty, _)| crate::typed_shape::type_is_raw_f64_candidate(ty)); + Some((self.build_fields(fields)?, nominal)) } fn build_named(&mut self, name: &str) -> Option { @@ -217,15 +230,33 @@ impl<'a> GuardGraphBuilder<'a> { // stale claim came from the doc comment on // `ObjectHeader::keys_array`, corrected alongside this. // - // Identity ALONE was measured and rejected: with `fields` empty the - // emitted clone comes out structurally identical to the `$generic` - // sibling it routes around — same line count, same call multiset, - // `js_typed_feedback_class_field_get_guard` already present in both - // — so a class-annotated receiver reaches the class-field guard - // path with no parameter evidence at all. It bought nothing and - // cost one guard call per invocation: -51% on `tree`, -30% on - // `tree_wide`. The field VALUE facts are the whole payload, which - // is why they are not optional here. + // Identity ALONE was measured and rejected on `tree`/`tree_wide`: + // the clone came out structurally identical to the `$generic` + // sibling it routed around, so the guard was pure cost — -51% and + // -30%. That verdict is REAL BUT LOCAL, and the conclusion once + // drawn from it here ("the field VALUE facts are the whole + // payload") was too broad. Two corrections: + // + // 1. Codegen never reads these field nodes. The clone is compiled + // with `SpecParamGuard::proof`, which is `param.ty` — a NAME — + // and looks the class's fields up from `ctx.classes`, which it + // has from the annotation either way. Forcing `fields` empty + // and recompiling leaves all 24 emitted specialized and + // generic clone bodies across a 16-function probe set + // unchanged. The + // descriptor is the runtime ENFORCEMENT of the proof, not the + // proof. `tree` is identical to its `$generic` because its + // fields are reference-typed and both bodies route through + // `js_typed_feedback_class_field_get_guard` — a property of + // that class shape, not of identity-only descriptors. + // 2. That regression cannot recur regardless: wave 1's + // `spec_clone_consumes_no_proof` (`codegen/function.rs`) now + // detects a clone identical to its generic sibling and emits a + // plain forwarder, dropping the guard. + // + // What the walk still buys is the VALUE half of the proof, and + // only for fields the intact bit cannot speak for — see the + // `nominal` branch below. // // Cost is bounded by #8094's existing rule rather than a new one: // a field-bearing descriptor claims heap CONTENTS, so a @@ -233,10 +264,18 @@ impl<'a> GuardGraphBuilder<'a> { // that contains a call. A recursive class (`Tree.left: Tree`) // therefore cannot be guarded in the recursive walker that would // make its validation O(nodes x depth). - let fields = self.class_chain_fields(name)?; - GuardNode::Object { - class_id: Some(class_id), - fields, + let (fields, nominal) = self.class_chain_fields(name)?; + if nominal { + // Every declared field is `number`, so (class chain reaches C, + // typed-layout-intact) implies each one holds a plain double — + // the whole payload of the walk this replaces. Measured at + // ~326 instructions per field walked. + GuardNode::ClassNominal(class_id) + } else { + GuardNode::Object { + class_id: Some(class_id), + fields, + } } } else { self.building_named.remove(name); @@ -548,6 +587,10 @@ fn encode_node(node: &GuardNode) -> Option> { put_u32(&mut out, field.ty); } } + GuardNode::ClassNominal(class_id) => { + out.push(17); + put_u32(&mut out, *class_id); + } GuardNode::Union(variants) => { out.push(12); put_u32(&mut out, variants.len().try_into().ok()?); @@ -1708,6 +1751,134 @@ mod tests { ); } + /// A chain whose every field is declared `number` needs no walk: the + /// per-object typed-layout-intact bit states "this slot holds a plain + /// double" for all of them at once, which is exactly what walking them by + /// name would establish. Measured at ~326 instructions per field walked. + #[test] + fn an_all_number_class_is_proved_nominally_without_a_field_walk() { + let descriptor = class_descriptor( + "Vec3", + &[class( + 21, + "Vec3", + None, + vec![ + ("x", Type::Number), + ("y", Type::Number), + ("z", Type::Number), + ], + )], + ) + .expect("a plain numeric class is guardable"); + let nominal = descriptor + .windows(5) + .find(|window| window[0] == 17) + .unwrap_or_else(|| panic!("an OP_CLASS_NOMINAL node: {descriptor:?}")); + assert_eq!( + u32::from_le_bytes(nominal[1..5].try_into().unwrap()), + 21, + "the class id is the identity half of the proof: {descriptor:?}" + ); + assert!( + !descriptor.windows(1).any(|window| window[0] == 11), + "a nominal class must not also emit the OP_OBJECT walk it \ + replaces: {descriptor:?}" + ); + assert!( + !descriptor.windows(1).any(|window| window[0] == b'x'), + "no field name should be serialized at all: {descriptor:?}" + ); + } + + /// The intact bit is a raw-f64 claim. It says a `string` field's slot is in + /// the POINTER mask, which is not "it holds a string" — and a clone that + /// inlines `s.length` trusts exactly that. One non-numeric field therefore + /// puts the whole chain back on the by-name walk. + #[test] + fn one_non_numeric_field_keeps_the_whole_chain_on_the_by_name_walk() { + for (label, ty) in [ + ("string", Type::String), + ("boolean", Type::Boolean), + ("class-typed", Type::Named("Vec3".to_string())), + ] { + let descriptor = class_descriptor( + "Mixed", + &[ + class(22, "Vec3", None, vec![("x", Type::Number)]), + class( + 23, + "Mixed", + None, + vec![("n", Type::Number), ("other", ty.clone())], + ), + ], + ) + .unwrap_or_else(|| panic!("{label}: descriptor")); + assert!( + descriptor.windows(1).any(|window| window[0] == 11), + "{label}: a non-numeric field must keep OP_OBJECT: {descriptor:?}" + ); + assert!( + !descriptor.windows(5).any(|window| window[0] == 17 + && window.len() == 5 + && u32::from_le_bytes(window[1..5].try_into().unwrap()) == 23), + "{label}: must not claim the chain nominally: {descriptor:?}" + ); + } + } + + /// Inherited fields are part of the instance, so the numeric verdict is a + /// property of the whole chain — a numeric leaf under a string parent is + /// NOT nominal. + #[test] + fn the_nominal_verdict_is_taken_over_the_whole_inheritance_chain() { + let all_numeric = class_descriptor( + "NumLeaf", + &[ + class(24, "NumBase", None, vec![("b", Type::Number)]), + class(25, "NumLeaf", Some("NumBase"), vec![("l", Type::Number)]), + ], + ) + .expect("descriptor"); + assert!( + all_numeric.windows(1).any(|window| window[0] == 17), + "an all-numeric chain is nominal: {all_numeric:?}" + ); + let string_parent = class_descriptor( + "StrLeaf", + &[ + class(26, "StrBase", None, vec![("b", Type::String)]), + class(27, "StrLeaf", Some("StrBase"), vec![("l", Type::Number)]), + ], + ) + .expect("descriptor"); + assert!( + string_parent.windows(1).any(|window| window[0] == 11), + "a string field ANYWHERE on the chain keeps the walk: \ + {string_parent:?}" + ); + } + + /// A fieldless class has no value fact to carry, so requiring the intact + /// bit could only reject receivers the by-name walk accepts. Excluded + /// deliberately — this asserts the exclusion rather than leaving it to + /// chance. + #[test] + fn a_fieldless_class_keeps_its_plain_identity_node() { + let descriptor = class_descriptor("Marker", &[class(28, "Marker", None, Vec::new())]) + .expect("descriptor"); + assert!( + descriptor.windows(1).any(|window| window[0] == 11), + "a fieldless class stays on OP_OBJECT: {descriptor:?}" + ); + assert!( + !descriptor.windows(1).any(|window| window[0] == 17), + "and must not demand an intact bit it has no fields to justify: \ + {descriptor:?}" + ); + } + /// Inherited fields belong to the instance, so a proof that names only the /// leaf's own fields would license a parent field's declared type without /// having validated it. diff --git a/crates/perry-runtime/src/param_type_guard.rs b/crates/perry-runtime/src/param_type_guard.rs index 260b124cb6..1fe0df8457 100644 --- a/crates/perry-runtime/src/param_type_guard.rs +++ b/crates/perry-runtime/src/param_type_guard.rs @@ -72,6 +72,21 @@ const OP_STRING_LITERAL: u8 = 13; const OP_RECURSIVE_REF: u8 = 14; const OP_MAP: u8 = 15; const OP_SET: u8 = 16; +/// A class parameter proved NOMINALLY: exact class identity plus the +/// per-object typed-layout-intact bit, with no field-by-name walk. +/// +/// Emitted only when every field on the class's inheritance chain is declared +/// `number`, i.e. every one is a raw-f64 candidate. For those fields the pair +/// (class chain reaches C, intact bit set) already implies the value fact the +/// walk would establish — "slot K holds a plain double" — so walking them by +/// name re-derives what the header already states. Measured at ~326 +/// instructions per field walked, so a 3-field class pays ~1_000 per call for +/// a fact two loads can settle. +/// +/// A class with any non-`number` field keeps `OP_OBJECT`: the intact bit says +/// a string field's slot is in the pointer mask, which is NOT "it holds a +/// string", and a clone that inlines `s.length` trusts exactly that. +const OP_CLASS_NOMINAL: u8 = 17; fn read_u16(bytes: &[u8], offset: usize) -> Option { Some(u16::from_le_bytes( @@ -640,6 +655,31 @@ impl GuardState<'_> { } valid && cursor == node.len() } + OP_CLASS_NOMINAL => { + let Some(class_id) = read_u32(node, 1) else { + return false; + }; + if node.len() != 5 || class_id == 0 { + return false; + } + let Some((object, address, _)) = self.plain_object(value) else { + return false; + }; + if !crate::object::class_chain_reaches((*object).class_id, class_id) { + return false; + } + // The value half. Without it this node would claim only + // identity, and `(p as any).x = "s"` on a real instance keeps + // the class id while retiring the raw-f64 layout. + // + // Read straight off the header rather than through a helper in + // `gc/layout.rs`: that file sits one line under the 2000-line + // cap, and `plain_object` has already proved this address + // carries a readable Gc header. Fails closed if it does not. + crate::value::addr_class::try_read_gc_header(address).is_some_and(|header| { + header._reserved & crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT != 0 + }) + } OP_UNION => { let Some(count) = read_u32(node, 1).map(|value| value as usize) else { return false; @@ -806,6 +846,59 @@ mod tests { ) } + fn class_nominal_node(class_id: u32) -> Vec { + let mut body = vec![OP_CLASS_NOMINAL]; + body.extend_from_slice(&class_id.to_le_bytes()); + body + } + + /// The nominal node trades the field walk for two header facts, so both + /// have to be load-bearing. A plain object carries class id 0 and reaches + /// no class, which is also the `Object.create(C.prototype)` and + /// same-shaped-literal case: the walk would have ACCEPTED a literal whose + /// fields all happen to be numbers, and the nominal node must not. + #[test] + fn a_nominal_class_node_rejects_everything_that_is_not_that_class() { + let node = class_nominal_node(4242); + let (_, literal) = + plain_object(&[(b"x", JSValue::number(1.0)), (b"y", JSValue::number(2.0))]); + assert_eq!( + guard(literal, &one_node(&node)), + 0, + "a same-shaped plain object reaches no class id" + ); + assert_eq!(guard(JSValue::number(1.0), &one_node(&node)), 0); + assert_eq!(guard(JSValue::undefined(), &one_node(&node)), 0); + assert_eq!(guard(JSValue::null(), &one_node(&node)), 0); + assert_eq!(guard(JSValue::bool(true), &one_node(&node)), 0); + } + + /// Class id 0 means "structural" for `OP_OBJECT`, where it is a legal + /// wildcard. A nominal node has nothing BUT identity, so a 0 there would + /// be a node that accepts every object with an intact layout. Fail closed. + #[test] + fn a_nominal_node_without_a_class_id_fails_closed() { + let (_, literal) = plain_object(&[(b"x", JSValue::number(1.0))]); + assert_eq!(guard(literal, &one_node(&class_nominal_node(0))), 0); + } + + /// Truncated or over-long bodies must not read past the node. + #[test] + fn a_malformed_nominal_node_fails_closed() { + let (_, literal) = plain_object(&[(b"x", JSValue::number(1.0))]); + for body in [vec![OP_CLASS_NOMINAL], vec![OP_CLASS_NOMINAL, 1, 0], { + let mut long = class_nominal_node(7); + long.push(0); + long + }] { + assert_eq!( + guard(literal, &one_node(&body)), + 0, + "malformed nominal node accepted: {body:?}" + ); + } + } + fn object_node(class_id: u32, fields: &[(bool, &[u8], u32)]) -> Vec { let mut body = vec![OP_OBJECT]; body.extend_from_slice(&class_id.to_le_bytes()); diff --git a/test-files/test_gap_nominal_class_param_guard.ts b/test-files/test_gap_nominal_class_param_guard.ts new file mode 100644 index 0000000000..9901c02eb6 --- /dev/null +++ b/test-files/test_gap_nominal_class_param_guard.ts @@ -0,0 +1,154 @@ +// A class-typed parameter whose chain is all `number` is proved NOMINALLY: +// exact class identity plus the per-object typed-layout-intact bit, with no +// field-by-name walk. This fixture pins the receivers that must NOT take that +// fast route, by construction — each one is built so the nominal check fails +// for a different reason, and each must still produce Node-identical output +// through the generic body. + +class Vec3 { + x: number; + y: number; + z: number; + constructor(x: number, y: number, z: number) { + this.x = x; + this.y = y; + this.z = z; + } +} + +// All-number chain -> nominal. The loop keeps the clone from being dropped as +// consuming no proof (a thin `p.x + p.y` body lowers identically either way). +function sumVec3(p: Vec3): number { + let s = 0; + for (let i = 0; i < 3; i++) s += p.x * p.y + p.z + i; + return s; +} + +// One string field -> the whole chain stays on the by-name walk. +class Tagged { + n: number; + tag: string; + constructor(n: number, tag: string) { + this.n = n; + this.tag = tag; + } +} +function describeTagged(t: Tagged): number { + let s = 0; + for (let i = 0; i < 3; i++) s += t.n + t.tag.length + i; + return s; +} + +// Inherited numeric chain -> still nominal. +class Vec4 extends Vec3 { + w: number; + constructor(x: number, y: number, z: number, w: number) { + super(x, y, z); + this.w = w; + } +} + +// A numeric leaf under a string parent -> NOT nominal. +class Named { + name: string; + constructor(name: string) { + this.name = name; + } +} +class NamedCount extends Named { + count: number; + constructor(name: string, count: number) { + super(name); + this.count = count; + } +} +function countOf(n: NamedCount): number { + let s = 0; + for (let i = 0; i < 3; i++) s += n.count + n.name.length + i; + return s; +} + +// Call through a dynamic route so every call reaches the public guarded entry +// rather than being resolved statically. +const api: any = { sumVec3, describeTagged, countOf }; +function call(name: string, arg: any): number { + return api[name](arg); +} + +const out: string[] = []; +function show(label: string, value: number): void { + out.push(label + "=" + String(value)); +} + +// --- accepted: a real instance ------------------------------------------- +show("plain", call("sumVec3", new Vec3(2, 3, 4))); +show("negzero", call("sumVec3", new Vec3(-0, 3, 4))); +show("frac", call("sumVec3", new Vec3(0.5, 0.25, 1.5))); +show("nan", call("sumVec3", new Vec3(NaN, 1, 1))); +show("inf", call("sumVec3", new Vec3(Infinity, 1, 1))); +show("big", call("sumVec3", new Vec3(1e308, 10, 1))); + +// --- rejected: a SUBCLASS instance --------------------------------------- +// Extra fields the proof never named; must still compute the base's view. +show("subclass", call("sumVec3", new Vec4(2, 3, 4, 5))); +const v4 = new Vec4(1, 2, 3, 4); +show("subclass_own", call("sumVec3", v4) + v4.w); + +// --- rejected: Object.create(C.prototype) -------------------------------- +// Right prototype, never ran the constructor, so no class-allocated layout. +const created: any = Object.create(Vec3.prototype); +created.x = 2; +created.y = 3; +created.z = 4; +show("object_create", call("sumVec3", created)); +show("object_create_proto", (Object.getPrototypeOf(created) === Vec3.prototype) ? 1 : 0); +const createdEmpty: any = Object.create(Vec3.prototype); +show("object_create_empty", call("sumVec3", createdEmpty)); + +// --- rejected: a SHAPE-BROKEN instance ----------------------------------- +// A real instance whose numeric slot is overwritten with a string retires the +// typed layout. JS still defines the arithmetic, so the answer must match. +const broken: any = new Vec3(2, 3, 4); +broken.x = "5"; +show("broken_string", call("sumVec3", broken)); +const broken2: any = new Vec3(2, 3, 4); +broken2.y = null; +show("broken_null", call("sumVec3", broken2)); +const broken3: any = new Vec3(2, 3, 4); +broken3.z = undefined; +show("broken_undef", call("sumVec3", broken3)); +const broken4: any = new Vec3(2, 3, 4); +broken4.extra = 9; +show("broken_added_field", call("sumVec3", broken4)); +const broken5: any = new Vec3(2, 3, 4); +delete broken5.y; +show("broken_deleted", call("sumVec3", broken5)); + +// --- rejected: a same-shaped PLAIN OBJECT -------------------------------- +// Identical keys and values, no class id at all. +show("plain_object", call("sumVec3", { x: 2, y: 3, z: 4 })); +show("plain_object_extra", call("sumVec3", { x: 2, y: 3, z: 4, w: 5 })); +show("plain_object_order", call("sumVec3", { z: 4, y: 3, x: 2 })); + +// --- rejected: frozen / accessor receivers ------------------------------- +const frozen: any = new Vec3(2, 3, 4); +Object.freeze(frozen); +show("frozen", call("sumVec3", frozen)); +const withGetter: any = new Vec3(2, 3, 4); +Object.defineProperty(withGetter, "x", { get: () => 7, configurable: true }); +show("accessor", call("sumVec3", withGetter)); + +// --- the non-nominal (by-name walk) path still works --------------------- +show("tagged", call("describeTagged", new Tagged(5, "abc"))); +show("tagged_broken", (() => { const t: any = new Tagged(5, "abc"); t.tag = 12345; return call("describeTagged", t); })()); +show("named_count", call("countOf", new NamedCount("ab", 7))); + +// --- a receiver reused after breaking, to catch a sticky verdict --------- +const reused: any = new Vec3(2, 3, 4); +show("reused_before", call("sumVec3", reused)); +reused.x = "5"; +show("reused_after", call("sumVec3", reused)); +reused.x = 2; +show("reused_restored", call("sumVec3", reused)); + +console.log(out.join("\n")); From 33f9b2d77e9da31baddb47817a1644ea8fa1889f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 12:27:20 +0200 Subject: [PATCH 04/15] perf(codegen): stop letting a loop license a per-element guard walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `declaration_guards` refused a descriptor whose validation work grows with the input — unless the body contained a loop, on the theory that array reducers and similar consumers amortize validation over their own traversal. They do not. The walk is a SECOND full pass over the same array, and the clone's saving per element is smaller than the walk's cost per element, so the guarded arm loses at every length and loses by MORE the longer the array gets — the opposite of what amortization predicts, which is why no array length rescues the rule. Instructions per call against base1579, both arms re-run in one window (dynamic-dispatch driver, best of 3): 1600 elements Pt[] 1,539,568 -> 410,171 -73.4% string[] 604,460 -> 477,884 -20.9% 16 elements Pt[] 19,191 -> 7,624 -60.3% string[] 10,308 -> 8,737 -15.2% Controls, same window, same binaries — the identical bodies taking an unproven parameter, which never had a descriptor to lose: 1600 elements Pt[] via any 555,495 -> 551,116 -0.8% string[] 531,291 -> 529,172 -0.4% Refusing is the win: the fallback is the generic body, and a refused parameter still keeps its declared type, so it lands BELOW the `any` twin rather than at it. There is no O(1) substitute to reach for instead. A raw-f64 layout flag could settle `number[]`, but that case never had a guard to speed up — wave 1's `spec_clone_consumes_no_proof` already drops it, because an index loop over a number array lowers identically with and without the proof. The cases that still carried a walk were `string[]` and `C[]`, and no header bit claims "every element is a string". The body is no longer an input to the decision, so `declaration_guards` no longer takes one and `body_contains_loop` is deleted. That makes the old behavior unexpressible rather than merely untested. --- crates/perry-codegen/src/codegen/mod.rs | 1 - .../perry-codegen/src/codegen/param_guard.rs | 99 ++++++------------- 2 files changed, 32 insertions(+), 68 deletions(-) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index 99f55d65e7..592d956d69 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -3202,7 +3202,6 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> f.id, &module_prefix, &f.params, - &f.body, &demoted, &guard_blocked, &cross_module.type_aliases, diff --git a/crates/perry-codegen/src/codegen/param_guard.rs b/crates/perry-codegen/src/codegen/param_guard.rs index b9044f6eb3..1567fddc97 100644 --- a/crates/perry-codegen/src/codegen/param_guard.rs +++ b/crates/perry-codegen/src/codegen/param_guard.rs @@ -720,54 +720,10 @@ fn descriptor_for_type( .map(|(descriptor, _)| descriptor) } -/// A loop makes the body's own work potentially unbounded, so a collection or -/// recursive graph walk can still be amortizable. With no loop, validating an -/// unbounded input to enter a bounded body cannot win as the input grows. -/// Nested closure bodies are not part of the enclosing function's work. -fn body_contains_loop(stmts: &[perry_hir::Stmt]) -> bool { - use perry_hir::Stmt; - stmts.iter().any(|stmt| match stmt { - Stmt::While { .. } | Stmt::DoWhile { .. } | Stmt::For { .. } => true, - Stmt::If { - then_branch, - else_branch, - .. - } => { - body_contains_loop(then_branch) - || else_branch.as_deref().is_some_and(body_contains_loop) - } - Stmt::Try { - body, - catch, - finally, - } => { - body_contains_loop(body) - || catch - .as_ref() - .is_some_and(|catch| body_contains_loop(&catch.body)) - || finally.as_deref().is_some_and(body_contains_loop) - } - Stmt::Switch { cases, .. } => cases.iter().any(|case| body_contains_loop(&case.body)), - Stmt::Labeled { body, .. } => body_contains_loop(std::slice::from_ref(body.as_ref())), - Stmt::Expr(_) - | Stmt::Throw(_) - | Stmt::Return(_) - | Stmt::Let { .. } - | Stmt::Break - | Stmt::Continue - | Stmt::LabeledBreak(_) - | Stmt::LabeledContinue(_) - | Stmt::PreallocateBoxes(_) - | Stmt::PreallocateTdzBoxes(_) - | Stmt::ReleaseBoxes(_) => false, - }) -} - pub(crate) fn declaration_guards( function_id: u32, module_prefix: &str, params: &[perry_hir::Param], - body: &[perry_hir::Stmt], demoted_params: &[bool], // (#8094) Guard-only ineligibility, kept SEPARATE from `demoted_params` // because that mask also drives raw representation selection: a reference @@ -779,7 +735,6 @@ pub(crate) fn declaration_guards( classes: &HashMap, class_ids: &HashMap, ) -> Vec> { - let body_can_amortize_unbounded_walk = body_contains_loop(body); params .iter() .zip(demoted_params.iter()) @@ -801,10 +756,26 @@ pub(crate) fn declaration_guards( // graph to read one discriminant and one field. The validator was // 9.8-12% of those programs and the clone it licensed was worth // only 0.1-0.2%. Do not emit a guard whose work grows with the - // input when the guarded body itself is statically bounded. A - // loop leaves the decision unchanged: array reducers and similar - // consumers can amortize validation over their own traversal. - if !walk_is_bounded && !body_can_amortize_unbounded_walk { + // input. + // + // A loop in the body used to lift this, on the theory that array + // reducers amortize validation over their own traversal. Measured, + // they do not. The walk is a SECOND full pass over the same array, + // and the clone's saving per element is smaller than the walk's + // cost per element, so the guarded arm loses at every length — + // instructions per call against the same body taking an unproven + // parameter, both arms re-run in one window: + // + // Pt[], 16 elements 11,635 vs 9,360 +24.3% + // Pt[], 1600 elements 789,536 vs 550,881 +43.3% + // string[], 16 elements 10,210 vs 9,818 +4.0% + // string[], 1600 elements 603,351 vs 529,337 +14.0% + // + // Refusing is the win: the fallback is the generic body. Note the + // penalty GROWS with length, which is the opposite of what + // amortization would predict, and is why a longer array cannot be + // the case that rescues the rule. + if !walk_is_bounded { return None; } Some(SpecParamGuard { @@ -1404,11 +1375,7 @@ mod tests { assert_eq!(scalar_descriptor_rep(b"PGT1"), None); } - fn declaration_guard_for( - ty: Type, - body: &[perry_hir::Stmt], - aliases: &HashMap, - ) -> Option { + fn declaration_guard_for(ty: Type, aliases: &HashMap) -> Option { let params = [perry_hir::Param { id: 1, name: "value".to_string(), @@ -1422,7 +1389,6 @@ mod tests { 1, "walk_bound_test", ¶ms, - body, &[false], &[false], aliases, @@ -1444,21 +1410,21 @@ mod tests { let flat = object_alias("Flat", &[("value", Type::Number)]); let flat_aliases = HashMap::from([flat]); assert!( - declaration_guard_for(Type::Named("Flat".to_string()), &[], &flat_aliases).is_some(), + declaration_guard_for(Type::Named("Flat".to_string()), &flat_aliases).is_some(), "a fixed field walk remains eligible" ); let array = Type::Array(Box::new(Type::Number)); - assert!(declaration_guard_for(array.clone(), &[], &HashMap::new()).is_none()); + assert!(declaration_guard_for(array.clone(), &HashMap::new()).is_none()); - let loop_body = [perry_hir::Stmt::While { - condition: perry_hir::Expr::Bool(false), - body: Vec::new(), - }]; - assert!( - declaration_guard_for(array, &loop_body, &HashMap::new()).is_some(), - "a loop consumer keeps the existing structural specialization" - ); + // The refusal is now unconditional. A loop in the body used to lift + // it, on the theory that array reducers amortize validation over their + // own traversal; measurement contradicts that (the guarded arm loses + // 4-43%, by MORE the longer the array), so the body is no longer an + // input to this decision at all — `declaration_guards` does not take + // one. That makes the old behavior unexpressible rather than merely + // untested. + let _ = &array; let recursive_aliases = HashMap::from([object_alias( "Link", @@ -1468,8 +1434,7 @@ mod tests { )], )]); assert!( - declaration_guard_for(Type::Named("Link".to_string()), &[], &recursive_aliases) - .is_none(), + declaration_guard_for(Type::Named("Link".to_string()), &recursive_aliases).is_none(), "a recursive value walk is runtime-sized too" ); } From 9d8f416e84c6bb344371438dfd7aac0911169922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 06:19:01 +0200 Subject: [PATCH 05/15] perf(codegen): build a rest bundle the way an array literal is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call site packing trailing arguments into a rest or `arguments` bundle emitted `js_array_alloc` plus one `js_array_push_f64` per element. Every push re-classified the receiver, re-resolved forwarding, re-noted the slot layout and re-checked the barrier — for a three-element bundle, 857 instructions of construction for numbers and 1,839 for objects. An array literal of the same width has been built inline since #5391: one bump allocation, a header that already claims pointer-free (and raw-f64 when every element is a plain double), then N stores. A bundle is the same shape with its values already lowered, so it now uses the same emitter, extracted as `emit_array_from_lowered_values`. Rooting is unchanged and still load-bearing (#7154): every element is re-read from the group's slots before the allocation, whose slow arm collects, and the finished array is adopted into the same scope so a second bundle's allocation cannot sweep the first. Bundles wider than the inline threshold keep the push loop. Per call at a static call site: `f(1, 2, 3)` into `...xs` 857 -> 92, `f(o, o, o)` 1,839 -> 494. --- .../perry-codegen/src/expr/array_literal.rs | 531 ++++++++++-------- crates/perry-codegen/src/expr/mod.rs | 4 +- crates/perry-codegen/src/lower_call/mod.rs | 39 +- crates/perry-codegen/src/rooting/mod.rs | 9 + crates/perry-codegen/src/rooting/temp_root.rs | 9 + 5 files changed, 343 insertions(+), 249 deletions(-) diff --git a/crates/perry-codegen/src/expr/array_literal.rs b/crates/perry-codegen/src/expr/array_literal.rs index 75035b330c..e76458d916 100644 --- a/crates/perry-codegen/src/expr/array_literal.rs +++ b/crates/perry-codegen/src/expr/array_literal.rs @@ -103,275 +103,312 @@ pub(crate) fn lower_array_literal(ctx: &mut FnCtx<'_>, elements: &[Expr]) -> Res } let element_refs: Vec<&Expr> = elements.iter().collect(); rooting::with_operands_rooted(ctx, &element_refs, |ctx, vals| { - // #5391: oversized modules outline array-literal construction. The inline - // bump-alloc + N×(store + layout-note + barrier) sequence makes minified - // data-table builders huge (single 18MB functions are impractical to - // optimize). Instead spill the already-evaluated element values to - // a per-literal stack buffer and build the array in ONE runtime call. The - // buffer is hoisted to the entry block (fixed size per site; bounded total - // stack) and consumed immediately by the call, so no GC-visible window. - if crate::codegen::full_outline_ic_enabled() { - let buf = ctx.func.alloca_entry_array(DOUBLE, n); - for (i, v) in vals.iter().enumerate() { - let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &i.to_string())]); - ctx.block().store(DOUBLE, v, &slot); - } - let n_str = n.to_string(); - let arr = ctx - .block() - .call(I64, "js_array_from_values", &[(PTR, &buf), (I32, &n_str)]); - return Ok(nanbox_pointer_inline(ctx.block(), &arr)); - } + let arr = emit_array_from_lowered_values( + ctx, + vals, + &canonical_raw_f64, + &layout_notes_needed, + all_numeric_elements, + )?; + Ok(nanbox_pointer_inline(ctx.block(), &arr)) + }) +} - // Inline bump-allocator path for small literals. Size threshold matches - // `MAX_SCALAR_ARRAY_LEN` in collectors.rs so every candidate the escape - // pass rejects can still benefit from the inline alloc. - const INLINE_MAX_ELEMENTS: usize = 16; - if n <= INLINE_MAX_ELEMENTS { - // Layout constants — must match `ArrayHeader` in array.rs and - // `GcHeader` in gc.rs. Duplicated here because codegen emits raw - // byte offsets; the runtime declarations are authoritative. - const GC_HEADER_SIZE: u64 = 8; - const ARRAY_HEADER_SIZE: u64 = 8; - const ELEMENT_SIZE: u64 = 8; - const GC_TYPE_ARRAY: u64 = 1; - const GC_FLAG_ARENA: u64 = 0x02; - // PR #1146: pointer-free hint for slot-layout tracking. The - // element-store loop below only suppresses per-slot notes for - // values whose non-pointer bits are proven by expression shape. - const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; - - let total_size = GC_HEADER_SIZE + ARRAY_HEADER_SIZE + (n as u64) * ELEMENT_SIZE; - let total_size_str = total_size.to_string(); - - // Load state + compute bump check. `total_size` is always a - // multiple of 8, every prior alloc rounds offset to 8, and blocks - // start 8-aligned, so no align-up step is needed. - let state_ptr = load_inline_arena_state(ctx); - let blk = ctx.block(); - let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]); - let offset_val = blk.load(I64, &offset_field_ptr); - let aligned_off = offset_val.clone(); - let new_offset = blk.add(I64, &aligned_off, &total_size_str); - let size_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "16")]); - let size_val = blk.load(I64, &size_field_ptr); - let fits = blk.icmp_ule(I64, &new_offset, &size_val); - - let fast_idx = ctx.new_block("arrlit.fast"); - let slow_idx = ctx.new_block("arrlit.slow"); - let merge_idx = ctx.new_block("arrlit.merge"); - let fast_label = ctx.block_label(fast_idx); - let slow_label = ctx.block_label(slow_idx); - let merge_label = ctx.block_label(merge_idx); - - ctx.block().cond_br(&fits, &fast_label, &slow_label); - - // Fast path: commit the bump, compute `data + offset`. - ctx.current_block = fast_idx; - let blk = ctx.block(); - // GC_STORE_AUDIT(INIT): arena bump offset is allocator metadata, not a JS heap edge. - blk.store(I64, &new_offset, &offset_field_ptr); - let data_ptr = blk.load(PTR, &state_ptr); - let raw_fast = blk.gep(I8, &data_ptr, &[(I64, &aligned_off)]); - let fast_pred_label = blk.label.clone(); - blk.br(&merge_label); - - // Slow path: call the runtime slow-alloc (same one used by the - // inline `new` path). Returns a fresh raw pointer (inclusive of - // GcHeader space). - ctx.current_block = slow_idx; - let raw_slow = ctx.block().call( - PTR, - "js_inline_arena_slow_alloc", - &[(PTR, &state_ptr), (I64, &total_size_str), (I64, "8")], - ); - let slow_pred_label = ctx.block().label.clone(); - ctx.block().br(&merge_label); - - // Merge: phi the raw pointer and write everything. - ctx.current_block = merge_idx; - let blk = ctx.block(); - let raw = blk.phi( - PTR, - &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], - ); +/// Element count up to which an array is built inline (bump allocation plus N +/// stores) rather than through `js_array_alloc`. Matches `MAX_SCALAR_ARRAY_LEN` +/// in collectors.rs so every candidate the escape pass rejects still benefits. +pub(crate) const INLINE_ARRAY_MAX_ELEMENTS: usize = 16; - // Packed GcHeader (bits 0..7 obj_type, 8..15 gc_flags, 16..31 - // _reserved, 32..63 size). PR #1146 packs the layout-tag in the - // reserved bits so the GC sees the array as pointer-free until - // the element-store loop overrides per-slot via - // `js_gc_note_slot_layout` below. - let gc_packed: u64 = GC_TYPE_ARRAY - | (GC_FLAG_ARENA << 8) - | (GC_LAYOUT_POINTER_FREE << 16) - | (total_size << 32); - // A literal whose elements are statically numbers is usually all - // plain doubles at runtime. Then the array is born exactly as - // `js_array_mark_numeric_f64_layout` would leave it — pointer-free - // with the dense raw-f64 flag — so decide that with one signed - // compare per element and skip every per-slot note and the - // marking walk. Any NaN-boxed element (an int32 box, or a value - // whose annotation lied) takes the unchanged noted path. - let all_plain_numbers = if all_numeric_elements { - let mut all_plain: Option = None; - for (i, v) in vals.iter().enumerate() { - if canonical_raw_f64[i] { - continue; - } - let bits = blk.bitcast_double_to_i64(v); - // 0x7FF9 << 48: the lowest NaN-box tag. - let plain = blk.icmp_slt(I64, &bits, "9221401712017801216"); - all_plain = Some(match all_plain { - None => plain, - Some(acc) => blk.and(I1, &acc, &plain), - }); - } - Some(all_plain.unwrap_or_else(|| "true".to_string())) - } else { - None - }; - let header_word = match &all_plain_numbers { - Some(all_plain) => { - // GC_ARRAY_RAW_F64_LAYOUT (0x80) in `_reserved`. - let flagged = gc_packed | (0x80u64 << 16); - blk.select( - I1, - all_plain, - I64, - &flagged.to_string(), - &gc_packed.to_string(), - ) +/// Build an array from element values the caller has already lowered (and +/// rooted), returning the raw `i64` user pointer. +/// +/// Split out of [`lower_array_literal`] so the rest/`arguments` bundle at a +/// call site builds its array the same way a literal does — one inline bump +/// allocation and N stores — instead of `js_array_alloc` plus one +/// `js_array_push_f64` per element, where every push re-classifies the +/// receiver, re-notes the slot layout and re-checks the barrier (#7154's +/// accumulator shape keeps the rooting, only the construction changes). +/// +/// `canonical_raw_f64[i]` says element `i` is a plain double by construction, +/// `layout_notes_needed[i]` that it may carry a heap pointer, and +/// `all_numeric_elements` that every element is statically a number. The +/// caller owns rooting: the slow arm of the bump allocator collects, so every +/// pointer value must already live in a root the group re-reads. +pub(crate) fn emit_array_from_lowered_values( + ctx: &mut FnCtx<'_>, + vals: &[String], + canonical_raw_f64: &[bool], + layout_notes_needed: &[bool], + all_numeric_elements: bool, +) -> Result { + let n = vals.len(); + // #5391: oversized modules outline array-literal construction. The inline + // bump-alloc + N×(store + layout-note + barrier) sequence makes minified + // data-table builders huge (single 18MB functions are impractical to + // optimize). Instead spill the already-evaluated element values to + // a per-literal stack buffer and build the array in ONE runtime call. The + // buffer is hoisted to the entry block (fixed size per site; bounded total + // stack) and consumed immediately by the call, so no GC-visible window. + if crate::codegen::full_outline_ic_enabled() { + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + for (i, v) in vals.iter().enumerate() { + let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + ctx.block().store(DOUBLE, v, &slot); + } + let n_str = n.to_string(); + let arr = ctx + .block() + .call(I64, "js_array_from_values", &[(PTR, &buf), (I32, &n_str)]); + return Ok(arr); + } + + // Inline bump-allocator path for small literals. Size threshold matches + // `MAX_SCALAR_ARRAY_LEN` in collectors.rs so every candidate the escape + // pass rejects can still benefit from the inline alloc. + if n <= INLINE_ARRAY_MAX_ELEMENTS { + // Layout constants — must match `ArrayHeader` in array.rs and + // `GcHeader` in gc.rs. Duplicated here because codegen emits raw + // byte offsets; the runtime declarations are authoritative. + const GC_HEADER_SIZE: u64 = 8; + const ARRAY_HEADER_SIZE: u64 = 8; + const ELEMENT_SIZE: u64 = 8; + const GC_TYPE_ARRAY: u64 = 1; + const GC_FLAG_ARENA: u64 = 0x02; + // PR #1146: pointer-free hint for slot-layout tracking. The + // element-store loop below only suppresses per-slot notes for + // values whose non-pointer bits are proven by expression shape. + const GC_LAYOUT_POINTER_FREE: u64 = 0x4000; + + let total_size = GC_HEADER_SIZE + ARRAY_HEADER_SIZE + (n as u64) * ELEMENT_SIZE; + let total_size_str = total_size.to_string(); + + // Load state + compute bump check. `total_size` is always a + // multiple of 8, every prior alloc rounds offset to 8, and blocks + // start 8-aligned, so no align-up step is needed. + let state_ptr = load_inline_arena_state(ctx); + let blk = ctx.block(); + let offset_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "8")]); + let offset_val = blk.load(I64, &offset_field_ptr); + let aligned_off = offset_val.clone(); + let new_offset = blk.add(I64, &aligned_off, &total_size_str); + let size_field_ptr = blk.gep(I8, &state_ptr, &[(I64, "16")]); + let size_val = blk.load(I64, &size_field_ptr); + let fits = blk.icmp_ule(I64, &new_offset, &size_val); + + let fast_idx = ctx.new_block("arrlit.fast"); + let slow_idx = ctx.new_block("arrlit.slow"); + let merge_idx = ctx.new_block("arrlit.merge"); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + + ctx.block().cond_br(&fits, &fast_label, &slow_label); + + // Fast path: commit the bump, compute `data + offset`. + ctx.current_block = fast_idx; + let blk = ctx.block(); + // GC_STORE_AUDIT(INIT): arena bump offset is allocator metadata, not a JS heap edge. + blk.store(I64, &new_offset, &offset_field_ptr); + let data_ptr = blk.load(PTR, &state_ptr); + let raw_fast = blk.gep(I8, &data_ptr, &[(I64, &aligned_off)]); + let fast_pred_label = blk.label.clone(); + blk.br(&merge_label); + + // Slow path: call the runtime slow-alloc (same one used by the + // inline `new` path). Returns a fresh raw pointer (inclusive of + // GcHeader space). + ctx.current_block = slow_idx; + let raw_slow = ctx.block().call( + PTR, + "js_inline_arena_slow_alloc", + &[(PTR, &state_ptr), (I64, &total_size_str), (I64, "8")], + ); + let slow_pred_label = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + // Merge: phi the raw pointer and write everything. + ctx.current_block = merge_idx; + let blk = ctx.block(); + let raw = blk.phi( + PTR, + &[(&raw_fast, &fast_pred_label), (&raw_slow, &slow_pred_label)], + ); + + // Packed GcHeader (bits 0..7 obj_type, 8..15 gc_flags, 16..31 + // _reserved, 32..63 size). PR #1146 packs the layout-tag in the + // reserved bits so the GC sees the array as pointer-free until + // the element-store loop overrides per-slot via + // `js_gc_note_slot_layout` below. + let gc_packed: u64 = GC_TYPE_ARRAY + | (GC_FLAG_ARENA << 8) + | (GC_LAYOUT_POINTER_FREE << 16) + | (total_size << 32); + // A literal whose elements are statically numbers is usually all + // plain doubles at runtime. Then the array is born exactly as + // `js_array_mark_numeric_f64_layout` would leave it — pointer-free + // with the dense raw-f64 flag — so decide that with one signed + // compare per element and skip every per-slot note and the + // marking walk. Any NaN-boxed element (an int32 box, or a value + // whose annotation lied) takes the unchanged noted path. + let all_plain_numbers = if all_numeric_elements { + let mut all_plain: Option = None; + for (i, v) in vals.iter().enumerate() { + if canonical_raw_f64[i] { + continue; } - None => gc_packed.to_string(), - }; - // GC_STORE_AUDIT(INIT): freshly allocated array header starts pointer-free until slot notes below. - blk.store(I64, &header_word, &raw); - - // Packed ArrayHeader at raw+8 (length low 32 / capacity high 32). - let arr_header_addr = blk.gep(I8, &raw, &[(I64, "8")]); - let arr_header_packed = (n as u64) | ((n as u64) << 32); - // GC_STORE_AUDIT(INIT): freshly allocated ArrayHeader length/capacity, no child pointer. - blk.store(I64, &arr_header_packed.to_string(), &arr_header_addr); - - // User pointer = raw + GC_HEADER_SIZE. Computed before the - // element loop so the per-slot layout notes target the correct - // user-visible address. - let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); - let user_ptr_as_i64 = blk.ptrtoint(&user_ptr, I64); - - if let Some(all_plain) = all_plain_numbers { - let plain_idx = ctx.new_block("arrlit.plain_numbers"); - let noted_idx = ctx.new_block("arrlit.noted"); - let done_idx = ctx.new_block("arrlit.done"); - let plain_label = ctx.block_label(plain_idx); - let noted_label = ctx.block_label(noted_idx); - let done_label = ctx.block_label(done_idx); - ctx.block().cond_br(&all_plain, &plain_label, ¬ed_label); - - ctx.current_block = plain_idx; - { - let blk = ctx.block(); - for (i, v) in vals.iter().enumerate() { - let offset = (16 + i * 8).to_string(); - let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); - // GC_STORE_AUDIT(POINTER_FREE): every element was just - // tested to be a plain double; the header already says - // pointer-free raw-f64. - blk.store(DOUBLE, v, &elem_ptr); - } - blk.br(&done_label); + let bits = blk.bitcast_double_to_i64(v); + // 0x7FF9 << 48: the lowest NaN-box tag. + let plain = blk.icmp_slt(I64, &bits, "9221401712017801216"); + all_plain = Some(match all_plain { + None => plain, + Some(acc) => blk.and(I1, &acc, &plain), + }); + } + Some(all_plain.unwrap_or_else(|| "true".to_string())) + } else { + None + }; + let header_word = match &all_plain_numbers { + Some(all_plain) => { + // GC_ARRAY_RAW_F64_LAYOUT (0x80) in `_reserved`. + let flagged = gc_packed | (0x80u64 << 16); + blk.select( + I1, + all_plain, + I64, + &flagged.to_string(), + &gc_packed.to_string(), + ) + } + None => gc_packed.to_string(), + }; + // GC_STORE_AUDIT(INIT): freshly allocated array header starts pointer-free until slot notes below. + blk.store(I64, &header_word, &raw); + + // Packed ArrayHeader at raw+8 (length low 32 / capacity high 32). + let arr_header_addr = blk.gep(I8, &raw, &[(I64, "8")]); + let arr_header_packed = (n as u64) | ((n as u64) << 32); + // GC_STORE_AUDIT(INIT): freshly allocated ArrayHeader length/capacity, no child pointer. + blk.store(I64, &arr_header_packed.to_string(), &arr_header_addr); + + // User pointer = raw + GC_HEADER_SIZE. Computed before the + // element loop so the per-slot layout notes target the correct + // user-visible address. + let user_ptr = blk.gep(I8, &raw, &[(I64, "8")]); + let user_ptr_as_i64 = blk.ptrtoint(&user_ptr, I64); + + if let Some(all_plain) = all_plain_numbers { + let plain_idx = ctx.new_block("arrlit.plain_numbers"); + let noted_idx = ctx.new_block("arrlit.noted"); + let done_idx = ctx.new_block("arrlit.done"); + let plain_label = ctx.block_label(plain_idx); + let noted_label = ctx.block_label(noted_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&all_plain, &plain_label, ¬ed_label); + + ctx.current_block = plain_idx; + { + let blk = ctx.block(); + for (i, v) in vals.iter().enumerate() { + let offset = (16 + i * 8).to_string(); + let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); + // GC_STORE_AUDIT(POINTER_FREE): every element was just + // tested to be a plain double; the header already says + // pointer-free raw-f64. + blk.store(DOUBLE, v, &elem_ptr); } + blk.br(&done_label); + } - ctx.current_block = noted_idx; - { - let blk = ctx.block(); - for (i, v) in vals.iter().enumerate() { - let offset = (16 + i * 8).to_string(); - let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); - let slot_index = i.to_string(); - emit_jsvalue_slot_store_on_block( - blk, - &elem_ptr, - v, - &user_ptr_as_i64, - &slot_index, - layout_notes_needed[i], - &user_ptr_as_i64, - "0", - false, - ); - } - blk.call( - I32, - "js_array_mark_numeric_f64_layout", - &[(I64, &user_ptr_as_i64)], + ctx.current_block = noted_idx; + { + let blk = ctx.block(); + for (i, v) in vals.iter().enumerate() { + let offset = (16 + i * 8).to_string(); + let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); + let slot_index = i.to_string(); + emit_jsvalue_slot_store_on_block( + blk, + &elem_ptr, + v, + &user_ptr_as_i64, + &slot_index, + layout_notes_needed[i], + &user_ptr_as_i64, + "0", + false, ); - blk.br(&done_label); } - ctx.current_block = done_idx; - return Ok(nanbox_pointer_inline(ctx.block(), &user_ptr_as_i64)); - } - - // Elements at raw+16 + i*8. - let blk = ctx.block(); - for (i, v) in vals.iter().enumerate() { - let offset = (16 + i * 8).to_string(); - let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); - let slot_index = i.to_string(); - emit_jsvalue_slot_store_on_block( - blk, - &elem_ptr, - v, - &user_ptr_as_i64, - &slot_index, - layout_notes_needed[i], - &user_ptr_as_i64, - "0", - false, + blk.call( + I32, + "js_array_mark_numeric_f64_layout", + &[(I64, &user_ptr_as_i64)], ); + blk.br(&done_label); } - - return Ok(nanbox_pointer_inline(ctx.block(), &user_ptr_as_i64)); + ctx.current_block = done_idx; + return Ok(user_ptr_as_i64); } - // Fallback for N > INLINE_MAX_ELEMENTS: keep the extern call + N inline - // stores. Thin-LTO already inlines this call into user IR, so the cost - // is ~1 inlined arena bump plus some LLVM churn around the arg pack. - let cap_str = n.to_string(); - let arr = ctx - .block() - .call(I64, "js_array_alloc_literal", &[(I32, &cap_str)]); - - let arr_ptr = ctx.block().inttoptr(I64, &arr); + // Elements at raw+16 + i*8. + let blk = ctx.block(); for (i, v) in vals.iter().enumerate() { - let offset = (8 + i * 8).to_string(); - let elem_ptr = ctx.block().gep_inbounds(I8, &arr_ptr, &[(I64, &offset)]); - let elem_addr = if layout_notes_needed[i] { - ctx.block().ptrtoint(&elem_ptr, I64) - } else { - "0".to_string() - }; + let offset = (16 + i * 8).to_string(); + let elem_ptr = blk.gep_inbounds(I8, &raw, &[(I64, &offset)]); let slot_index = i.to_string(); emit_jsvalue_slot_store_on_block( - ctx.block(), + blk, &elem_ptr, v, - &arr, + &user_ptr_as_i64, &slot_index, layout_notes_needed[i], - &arr, - &elem_addr, - layout_notes_needed[i], + &user_ptr_as_i64, + "0", + false, ); } - if all_numeric_elements { - ctx.block() - .call(I32, "js_array_mark_numeric_f64_layout", &[(I64, &arr)]); - } + return Ok(user_ptr_as_i64); + } - Ok(nanbox_pointer_inline(ctx.block(), &arr)) - }) + // Fallback for N > INLINE_MAX_ELEMENTS: keep the extern call + N inline + // stores. Thin-LTO already inlines this call into user IR, so the cost + // is ~1 inlined arena bump plus some LLVM churn around the arg pack. + let cap_str = n.to_string(); + let arr = ctx + .block() + .call(I64, "js_array_alloc_literal", &[(I32, &cap_str)]); + + let arr_ptr = ctx.block().inttoptr(I64, &arr); + for (i, v) in vals.iter().enumerate() { + let offset = (8 + i * 8).to_string(); + let elem_ptr = ctx.block().gep_inbounds(I8, &arr_ptr, &[(I64, &offset)]); + let elem_addr = if layout_notes_needed[i] { + ctx.block().ptrtoint(&elem_ptr, I64) + } else { + "0".to_string() + }; + let slot_index = i.to_string(); + emit_jsvalue_slot_store_on_block( + ctx.block(), + &elem_ptr, + v, + &arr, + &slot_index, + layout_notes_needed[i], + &arr, + &elem_addr, + layout_notes_needed[i], + ); + } + + if all_numeric_elements { + ctx.block() + .call(I32, "js_array_mark_numeric_f64_layout", &[(I64, &arr)]); + } + + Ok(arr) } /// #8583 follow-up gate. Default ON; `PERRY_CONST_ARRAY_DESCRIPTOR=0/off/false` diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index de64342d1c..1a720ccd49 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -64,7 +64,9 @@ mod v8_interop; mod write_barrier; pub(crate) use crate::native_value::{materialize_js_value, materialize_js_value_without_record}; -pub(crate) use array_literal::lower_array_literal; +pub(crate) use array_literal::{ + emit_array_from_lowered_values, lower_array_literal, INLINE_ARRAY_MAX_ELEMENTS, +}; pub(crate) use buffer_access::{ access_facts_for_spec, can_lower_buffer_access_without_calls, can_lower_integer_typed_array_store_value, emit_buffer_access_pointer, diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 4874372745..c18ed35956 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -340,7 +340,44 @@ pub(crate) fn lower_rest_call_args_rooted<'a>( // exactly as the push loop is for its elements. let mut accs: Vec = Vec::with_capacity(bundles.len()); for bundle in bundles { - let cap = (args.len().saturating_sub(bundle.from) as u32).to_string(); + let count = args.len().saturating_sub(bundle.from); + // Build it the way an array literal of the same width is built: ONE + // inline bump allocation and N stores. `js_array_alloc` + one + // `js_array_push_f64` per element re-classified the receiver, + // re-noted the slot layout and re-checked the barrier on every push — + // 1,586 instructions for `f(a, b, c)` into a three-element rest. + // Rooting is unchanged: every element is re-read from the group's + // slots first (the allocator's slow arm collects), and the finished + // array is adopted into the same scope, so the next bundle's + // allocation cannot sweep it. + if count > 0 && count <= crate::expr::INLINE_ARRAY_MAX_ELEMENTS { + let rest_args = &args[bundle.from..]; + let canonical_raw_f64: Vec = rest_args + .iter() + .map(|e| crate::type_analysis::expr_produces_canonical_raw_f64(ctx, e)) + .collect(); + let layout_notes_needed: Vec = rest_args + .iter() + .map(|e| !crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, e)) + .collect(); + let all_numeric = rest_args + .iter() + .all(|e| crate::type_analysis::is_numeric_expr(ctx, e)); + let mut vals: Vec = Vec::with_capacity(count); + for i in bundle.from..group.len() { + vals.push(group.reread(ctx, i)?); + } + let arr = crate::expr::emit_array_from_lowered_values( + ctx, + &vals, + &canonical_raw_f64, + &layout_notes_needed, + all_numeric, + )?; + accs.push(group.adopt_array(ctx, &arr)); + continue; + } + let cap = (count as u32).to_string(); let acc = group.begin_array(ctx, &cap); for i in bundle.from..group.len() { // Re-read per element: the previous push allocated, so the register diff --git a/crates/perry-codegen/src/rooting/mod.rs b/crates/perry-codegen/src/rooting/mod.rs index e791b8215b..0ee73530e1 100644 --- a/crates/perry-codegen/src/rooting/mod.rs +++ b/crates/perry-codegen/src/rooting/mod.rs @@ -982,6 +982,15 @@ impl<'a> RootedGroup<'a> { AccArray(self.accs.len() - 1) } + /// Root an array this group did not allocate — the inline-constructed + /// rest bundle — so one release still drops operands and arrays together. + pub(crate) fn adopt_array(&mut self, ctx: &mut FnCtx<'_>, arr: &str) -> AccArray { + let slot = temp_root::rooted_array_adopt(ctx, arr); + self.note_slot(Some(slot.clone())); + self.accs.push(slot); + AccArray(self.accs.len() - 1) + } + /// Push one element, re-reading the array from its slot and publishing the /// possibly-reallocated pointer back into it. pub(crate) fn push_array(&mut self, ctx: &mut FnCtx<'_>, acc: AccArray, value: &str) { diff --git a/crates/perry-codegen/src/rooting/temp_root.rs b/crates/perry-codegen/src/rooting/temp_root.rs index ffee46dd9f..9b51a27078 100644 --- a/crates/perry-codegen/src/rooting/temp_root.rs +++ b/crates/perry-codegen/src/rooting/temp_root.rs @@ -285,6 +285,15 @@ pub(in crate::rooting) fn rooted_array_begin(ctx: &mut FnCtx<'_>, cap: &str) -> temp_root_push_i64(ctx, &arr) } +/// Root an array the caller has already built, in the same slot shape +/// [`rooted_array_begin`] produces. The inline construction path builds the +/// whole array before anything else can collect; what follows (another +/// bundle's allocation, the consuming call) still can, so it must be rooted +/// exactly like an accumulator. +pub(in crate::rooting) fn rooted_array_adopt(ctx: &mut FnCtx<'_>, arr: &str) -> String { + temp_root_push_i64(ctx, arr) +} + /// Read the accumulator back out of its temp-root slot. Does NOT truncate: /// callers truncate after the consuming call, so the array is still rooted /// while the consumer runs (formatting an argument list allocates). From 4464ac441eb21c7f8519e8955db909bcf60fcbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 06:23:53 +0200 Subject: [PATCH 06/15] perf(runtime): resolve the map result's header once per element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Array.prototype.map` filling a plain result array paid the ownership and forwarding proof of its own receiver three times per element: `clean_arr_ptr` inside the raw-f64 canonicalization, an `addr_class::try_read_gc_header` inside the layout-note elision check, and a third header read inside the numeric-layout note. The caller has just re-derived the live head from its root for this iteration, so one read answers all three. `fill_resolved_array_slot` keeps the protocol `note_array_slot_layout_only` runs — canonicalize under a raw-f64 layout, store, retire the numeric claim on a non-number, note the slot layout unless that note is provably a no-op, and keep the born-old remembered-set edge — and falls back to the fully re-classifying helper for any head it cannot prove from that one read (unrecognized, or forwarded). `a.map(v => v + 1)` over 16 elements: 6,265 -> 4,234 instructions. The fixture covers both this and the rest-bundle change: element kinds, holes, -0/NaN, object identity, a callback that mutates and grows its source, a result longer than the 64-element branch, frozen and subclass receivers, and `arguments`. It matches node 26.5.1 normally and under the seeded moving-GC stress (4,028 copying minors on the object path). --- .../src/array/header_gc_slots.rs | 67 +++++++++++ .../perry-runtime/src/array/iter_methods.rs | 5 +- .../test_gap_rest_bundle_and_map_fill.ts | 106 ++++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_rest_bundle_and_map_fill.ts diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index e64fd97bc6..0188d8c5fe 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -178,6 +178,73 @@ pub(crate) unsafe fn store_array_slot_resolved( value_bits } +/// Store one element into a plain array the caller is BULK-FILLING, from a +/// header resolved once. +/// +/// Same protocol as [`note_array_slot_layout_only`] — canonicalize under a +/// raw-f64 layout, write, keep the numeric-layout flags honest, note the slot +/// layout unless that note is provably a no-op, and keep the born-old +/// remembered-set edge — but a filler that has just re-derived the live head +/// from its own root already owns the ownership/forwarding proof each of those +/// steps otherwise repeats: `clean_arr_ptr` inside the canonicalization, a +/// second `addr_class::try_read_gc_header` inside the elision check, a third +/// flag read inside the numeric note. `a.map(v => v + 1)` paid all three per +/// element. +/// +/// Anything this cannot prove from that one header — an unrecognized or +/// forwarded head — falls back to the fully re-classifying helper, so the +/// conservative path stays the default rather than the exception. +/// +/// # Safety +/// +/// `arr` must be a live, forwarding-resolved `GC_TYPE_ARRAY` head re-derived +/// below the last collection point, with `index` inside its allocation. +#[inline] +pub(crate) unsafe fn fill_resolved_array_slot( + arr: *mut ArrayHeader, + index: usize, + value_bits: u64, +) { + let Some(header) = super::header::array_gc_header(arr) else { + note_array_slot_layout_only(arr, index, value_bits); + return; + }; + if (*header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 { + note_array_slot_layout_only(arr, index, value_bits); + return; + } + let flags = (*header)._reserved; + let raw_layout = crate::gc::GC_ARRAY_RAW_F64_LAYOUT | crate::gc::GC_ARRAY_RAW_F64_HOLES; + let number = super::header::value_bits_to_number(value_bits); + let value_bits = match number { + Some(n) if flags & raw_layout != 0 => n.to_bits(), + _ => value_bits, + }; + // GC_STORE_AUDIT(INIT): bulk fill of a caller-owned array; the layout note + // and born-old barrier below cover this slot exactly as the layout-only + // helper does. + std::ptr::write(array_elements_ptr(arr).add(index), value_bits); + if number.is_none() { + // A non-number retires the dense raw-f64 claim, exactly as + // `note_array_numeric_index_write` does. + super::header::clear_array_numeric_layout(arr); + } + let scalar = !crate::gc::layout_pointer_bearing_bits(value_bits); + let note_elidable = flags & SCALAR_NOTE_ELIDABLE_MASK == crate::gc::GC_LAYOUT_POINTER_FREE; + if !(scalar && note_elidable) { + crate::gc::layout_note_slot(arr as usize, index, value_bits); + } + // Born-old arrays still need the old->young edge; a scalar child skips by + // shape before the old-gen classification runs (see the note in + // `note_array_slot_layout_only`). + if !crate::gc::barrier_scalar_child_skips(value_bits) + && crate::arena::pointer_in_old_gen(arr as usize) + { + let slot = array_elements_ptr(arr).add(index) as usize; + crate::gc::runtime_write_barrier_slot(arr as usize, slot, value_bits); + } +} + #[inline] pub(crate) unsafe fn note_array_slot_layout_only( arr: *mut ArrayHeader, diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index f15edd4130..c8fa7668bb 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -406,7 +406,10 @@ pub extern "C" fn js_array_map( ptr::write(result_elements.add(i), mapped); let mapped_bits = mapped.to_bits(); if length <= 64 { - note_array_slot_layout_only(result, i, mapped_bits); + // The head was just re-derived from `result_rooted`, so the + // per-element helpers' repeated ownership/forwarding proofs + // are redundant: resolve the header once. + super::header_gc_slots::fill_resolved_array_slot(result, i, mapped_bits); } else { note_array_slot(result, i, mapped_bits); } diff --git a/test-files/test_gap_rest_bundle_and_map_fill.ts b/test-files/test_gap_rest_bundle_and_map_fill.ts new file mode 100644 index 0000000000..75e7b33290 --- /dev/null +++ b/test-files/test_gap_rest_bundle_and_map_fill.ts @@ -0,0 +1,106 @@ +// Rest/`arguments` bundles are built the way an array literal is, and +// `Array.prototype.map`'s plain-array fill resolves the result header once. +// Both keep the element-kind, hole and barrier protocol of the paths they +// replace, so this pins what a caller can observe: identity, length, element +// values and kinds, holes, and what happens when a callback mutates or grows +// the array it is filling. + +function rest(...xs: any[]): any[] { + return xs; +} +function restAfterFixed(a: number, b: number, ...xs: any[]): string { + return `${a}|${b}|${xs.length}|${xs.join(",")}`; +} +function argsObject(): any { + // eslint-disable-next-line prefer-rest-params + return arguments; +} + +// Numbers, the shape the raw-f64 layout claims. +console.log("nums", JSON.stringify(rest(1, 2, 3)), rest(1, 2, 3).length); +console.log("empty", JSON.stringify(rest()), rest().length, Array.isArray(rest())); +console.log("fixed+rest", restAfterFixed(1, 2, 3, 4, 5)); +console.log("no-rest-args", restAfterFixed(1, 2)); + +// Mixed kinds must retire the numeric claim, not store raw bits. +const obj = { tag: "o" }; +const mixed = rest(1, "two", null, undefined, true, obj, 6.5, -0, NaN, Infinity); +console.log("mixed", mixed.length, typeof mixed[1], mixed[2], mixed[3], mixed[4]); +console.log("mixed-obj-identity", mixed[5] === obj, Object.is(mixed[7], -0), mixed[8] !== mixed[8]); +console.log("mixed-json", JSON.stringify(mixed)); + +// A rest array is an ordinary, extensible, mutable array. +const r = rest(1, 2, 3); +r.push(4); +r[6] = 7; +console.log("mutable", JSON.stringify(r), r.length, 5 in r, JSON.stringify(Object.keys(r))); + +// Past the inline width (16), the older construction still applies. +const wide = rest(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18); +console.log("wide", wide.length, wide[0], wide[17], JSON.stringify(wide.slice(15))); + +// `arguments` keeps its own identity and spread behaviour. +const a = argsObject(1, 2, 3); +console.log("arguments", a.length, a[0], a[2], JSON.stringify(Array.from(a))); + +// Rest of objects: every element must survive a collection that runs while the +// bundle is still being built by the next call's arguments. +function makeTag(i: number): any { + return { i, pad: new Array(8).fill(i) }; +} +const objs = rest(makeTag(0), makeTag(1), makeTag(2), makeTag(3)); +console.log("obj-rest", objs.length, objs.map((o: any) => o.i).join(",")); + +// ---- map ---------------------------------------------------------------- + +const nums = [1, 2, 3, 4]; +console.log("map-num", JSON.stringify(nums.map((v) => v * 2))); +console.log("map-to-string", JSON.stringify(nums.map((v) => `n${v}`))); +console.log("map-to-obj", JSON.stringify(nums.map((v) => ({ v })))); +console.log("map-mixed", JSON.stringify(nums.map((v) => (v % 2 === 0 ? v : String(v))))); +console.log("map-negzero", Object.is(nums.map(() => -0)[0], -0)); +console.log("map-nan", nums.map(() => NaN).every((x) => x !== x)); + +// Holes stay holes; the callback is not called for them. +const holey = [1, , 3]; +const mappedHoley = holey.map((v) => (v as number) * 10); +console.log("map-holes", JSON.stringify(mappedHoley), 1 in mappedHoley, mappedHoley.length); + +// A callback that mutates the source, grows it, or allocates heavily. +const src = [1, 2, 3]; +const mutated = src.map((v, i) => { + if (i === 0) { + src.push(99); + src[2] = 42; + } + return v; +}); +console.log("map-mutating", JSON.stringify(mutated), JSON.stringify(src)); + +const allocating = [1, 2, 3, 4, 5, 6].map((v) => { + const junk = new Array(64).fill({ v }); + return junk.length + v; +}); +console.log("map-allocating", JSON.stringify(allocating)); + +// Longer than the map fill's 64-element resolved-header branch. +const long = new Array(80).fill(0).map((_, i) => i); +const longMapped = long.map((v) => v + 0.5); +console.log("map-long", longMapped.length, longMapped[0], longMapped[79]); +const longObjs = long.map((v) => ({ v })); +console.log("map-long-obj", longObjs.length, longObjs[79].v, typeof longObjs[0]); + +// Species and subclass results take the unchanged [[Set]] path. Only the +// VALUES are asserted: `map` on a subclass receiver does not preserve the +// subclass in perry today (`subMapped instanceof MyArr` is false where node +// says true), a pre-existing gap this fixture must not start failing on. +class MyArr extends Array {} +const sub = MyArr.from([1, 2, 3]) as any; +const subMapped = sub.map((v: number) => v + 1); +console.log("map-species", JSON.stringify(Array.from(subMapped))); + +// Frozen source, and a result read back through every element kind. +const frozen = Object.freeze([1, 2, 3]); +console.log("map-frozen-src", JSON.stringify(frozen.map((v) => v + 1))); +const kinds = [0, "s", null, undefined, true, { o: 1 }, [1]].map((v) => typeof v); +console.log("map-kinds", JSON.stringify(kinds)); From 212572a33e4fb0250b489eb00270b5e4768bc92c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 07:30:53 +0200 Subject: [PATCH 07/15] perf(runtime): gate the numeric push guard's observation on feedback being on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js_typed_feedback_numeric_array_push_guard` is called on every `a.push(v)` that takes the guarded numeric tier. It built an `Observation` — a `gc_header_for_user_addr` lookup, a length read, a `classify_array` walk over the receiver's element layout and a `stable_value_kind` — and handed it to `guard_observe`, which throws it away and returns `contract_valid` unchanged whenever typed-feedback recording is off. Recording is off by default, so that was the whole cost of the call. This is #5094's gate. Every sibling array guard already carries it (`plain_array_index_get_guard_impl`, the four packed loop guards, both index set guards, `js_typed_feedback_array_get_f64`); the push guard and two declared-but-unemitted wrappers were the last ones that did not. The gated branch returns exactly what `guard_observe` would have returned in that mode, so the observing path and the recorded feedback are untouched. Measured on the dynamic-dispatch census, best-of-5 over 200k calls, minus the zero-iteration run and the same-arity identity baseline: arrPushPop 970.3 -> 861.3 -109.0 (-11.2%) No other probe moved: arrSet +0.3, arrLen -1.4, arrSumForOf +0.3, arrSumIndex +0.8, anyArrGet -1.6, objArrFieldGet +0.7, f64Get -0.5. `typed_feedback_enabled()` is hardcoded `true` under `#[cfg(test)]`, so the runtime unit tests only ever take the observing path and cannot cover the new branch. `test_gap_numeric_push_guarded.ts` covers it end to end instead, where recording is off: it drives every receiver shape the guard declines — frozen, sealed, non-extensible, non-writable length, an index accessor, sparse, a subclass, a Proxy, a mid-program `Array.prototype` index setter — plus a growing dense array and a TypedArray/Buffer receiver, and matches node both normally and under GC stress (8 copying minors, 6720 objects moved, from-space quarantine armed, evacuation verified). Two cases assert resulting state rather than a throw, both pre-existing gaps that behave identically before this change: Perry does not throw when pushing to a non-extensible array, and `map` does not preserve a subclass receiver. --- crates/perry-runtime/src/typed_feedback.rs | 26 +++++ test-files/test_gap_numeric_push_guarded.ts | 121 ++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 test-files/test_gap_numeric_push_guarded.ts diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 4b7b5773e7..ef136ddf02 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -2548,6 +2548,14 @@ pub extern "C" fn js_typed_feedback_numeric_array_push_guard( value: f64, ) -> i32 { let raw_addr = normalize_raw_object_addr(receiver.to_bits()); + // #5094's gate, which this guard never got. With recording off (the + // default) `guard_observe` hands back `contract_valid` untouched, so the + // push index lookup, the `classify_array` walk and the observation are all + // dead work on every `a.push(v)`. Every sibling array guard already gates + // here; this one was the last hot one that did not. + if !typed_feedback_enabled() { + return numeric_array_push_guard(raw_addr as *const ArrayHeader, value) as i32; + } let push_index = match gc_header_for_user_addr(raw_addr) { Some(header) if unsafe { (*header).obj_type == crate::gc::GC_TYPE_ARRAY } => unsafe { (*(raw_addr as *const ArrayHeader)).length @@ -2793,6 +2801,18 @@ pub extern "C" fn js_typed_feedback_object_set_unboxed_f64_field( key: *const crate::StringHeader, value: f64, ) { + // #5094's gate. `object_shape` resolves the receiver's shape and `key_hash` + // hashes the key purely to fill an `Observation` that `guard_observe` + // discards while recording is off. + if !typed_feedback_enabled() { + if object_key_matches_field(obj, key, field_index) && is_plain_number_bits(value.to_bits()) + { + crate::object::js_object_set_field(obj, field_index, crate::JSValue::number(value)); + } else { + crate::object::js_object_set_field_by_name(obj, key, value); + } + return; + } let object_addr = normalize_raw_object_addr(obj as u64); let (shape_addr, class_id, gc_type) = object_shape(object_addr); let observation = Observation { @@ -2826,6 +2846,12 @@ pub extern "C" fn js_typed_feedback_object_set_unboxed_f64_field( #[no_mangle] pub extern "C" fn js_typed_feedback_observe_helper_return(site_id: u64, value: f64) -> f64 { + // #5094's gate. The contract is unconditionally valid here, so with + // recording off this wrapper is the identity function and `helper_return_facts` + // (which resolves a shape for a pointer payload) is pure dead work. + if !typed_feedback_enabled() { + return value; + } let bits = value.to_bits(); let (shape_addr, class_id, heap_type, aux, value_kind) = helper_return_facts(bits); let observation = Observation { diff --git a/test-files/test_gap_numeric_push_guarded.ts b/test-files/test_gap_numeric_push_guarded.ts new file mode 100644 index 0000000000..bfb132ae60 --- /dev/null +++ b/test-files/test_gap_numeric_push_guarded.ts @@ -0,0 +1,121 @@ +// The numeric push guard's fast arm calls a post-guard entry that re-derives +// only the receiver's iteration policy. Every receiver shape the guard does +// not admit — frozen, sealed, non-extensible, descriptor-bearing, a prototype +// index setter, a subclass, a Proxy, a Buffer/TypedArray — must still reach +// the same result as before, and a length that is not writable must not move. + +function push(a: any, v: number): number { + a.push(v); + return a.length; +} + +// Plain dense numeric array: the admitted shape. +const plain: number[] = [1, 2, 3]; +console.log("plain", push(plain, 4), JSON.stringify(plain)); +for (let i = 0; i < 40; i++) push(plain, i); +console.log("grown", plain.length, plain[43], JSON.stringify(plain.slice(0, 5))); + +// Non-numeric values retire the dense claim through the same site. +const mixed: any[] = [1, 2]; +push(mixed, 3); +mixed.push("s"); +mixed.push({ o: 1 }); +mixed.push(null); +console.log("mixed", mixed.length, JSON.stringify(mixed), typeof mixed[3]); + +// Frozen / sealed / non-extensible receivers. +const frozen = Object.freeze([1, 2, 3]) as number[]; +try { + push(frozen, 4); +} catch (e: any) { + console.log("frozen-throw", e.constructor.name); +} +console.log("frozen", frozen.length, JSON.stringify(frozen)); + +const sealed = Object.seal([1, 2, 3]) as number[]; +try { + push(sealed, 4); +} catch (e: any) { + console.log("sealed-throw", e.constructor.name); +} +console.log("sealed", sealed.length, JSON.stringify(sealed)); + +// Non-extensible: only the resulting STATE is asserted. Perry does not throw +// here where node does (a pre-existing gap, identical before this change), and +// this fixture must not start failing on it. +const noExtend = Object.preventExtensions([1, 2, 3]) as number[]; +try { + push(noExtend, 4); +} catch { + /* node throws, perry does not; both must leave the array untouched */ +} +console.log("noextend", noExtend.length, JSON.stringify(noExtend)); + +// A non-writable length must refuse the push. +const fixedLen: number[] = [1, 2, 3]; +Object.defineProperty(fixedLen, "length", { writable: false }); +try { + push(fixedLen, 4); +} catch (e: any) { + console.log("fixedlen-throw", e.constructor.name); +} +console.log("fixedlen", fixedLen.length, JSON.stringify(fixedLen)); + +// An accessor defined on an index the push would write. +const accessor: any = [1, 2, 3]; +let seen: any = null; +Object.defineProperty(accessor, 3, { + set(v: any) { + seen = v; + }, + get() { + return "acc"; + }, + configurable: true, +}); +push(accessor, 99); +console.log("accessor", seen, accessor[3], accessor.length); + +// Sparse receiver. +const sparse: any[] = [1, , 3]; +push(sparse, 4); +console.log("sparse", sparse.length, 1 in sparse, JSON.stringify(sparse)); + +// Subclass and Proxy receivers. +class MyArr extends Array {} +const sub: any = MyArr.from([1, 2, 3]); +push(sub, 4); +console.log("subclass", sub.length, sub instanceof MyArr, JSON.stringify(Array.from(sub))); + +const proxied: any = new Proxy([1, 2, 3], { + set(t: any, k: any, v: any) { + t[k] = v; + return true; + }, +}); +push(proxied, 4); +console.log("proxy", proxied.length, JSON.stringify(Array.from(proxied))); + +// A mid-program Array.prototype index setter is observable on later pushes. +const beforeSetter: number[] = [1, 2]; +push(beforeSetter, 3); +let protoSaw: any = null; +Object.defineProperty(Array.prototype, 7, { + set(v: any) { + protoSaw = v; + }, + get() { + return "proto"; + }, + configurable: true, +}); +const afterSetter: number[] = [0, 1, 2, 3, 4, 5, 6]; +push(afterSetter, 42); +console.log("proto-setter", protoSaw, afterSetter[7], afterSetter.length); +delete (Array.prototype as any)[7]; + +// Buffer / typed-array receivers route to their own paths. +const u8: any = new Uint8Array([1, 2, 3]); +console.log("u8-push-typeof", typeof u8.push); +const buf: any = Buffer.from([1, 2, 3]); +console.log("buffer-len", buf.length); From c9bf05e591cc4b5edcad4879a4e434a93198d82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 08:38:39 +0200 Subject: [PATCH 08/15] perf(codegen): read the packed loop's element base from the hoisted receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packed-numeric fast clone re-derived the element base on every iteration: reload the rooted slot through the `asm "", "=r,0"` launder, mask the handle, load `size` at `-4` and `capacity` at `+4`, shift, add, subtract — about twenty instructions to reach one `load double`. The launder is opaque to LLVM by design, so LICM could not hoist any of it even though none of it varies. The clone already publishes a pre-masked receiver handle for exactly this: the poll-refreshed receiver cache `acc_scope.hoist_receivers` installs, which the packed STORE path (`expr/index_set_packed_loop.rs`) has read through `receiver_descriptor_handle_i64` since it was added. The read path was simply never converted. It is now, and the element-base chain hangs off a plain `i64` alloca nothing in the clone writes, so LLVM hoists it into the preheader. Soundness is #9379's, not a new claim: the matcher admits no call, closure or await; reads and writes lower to bare `double` load/store on existing slots, so no growth, no realloc and no barrier; and the back-edge poll is suppressed for this clone for exactly that reason. With no safepoint the receiver cannot move and its header words cannot change for the clone's whole dynamic extent. The fact is dematerialized before the slow clone is lowered, so nothing leaks past the clone it was proved for, and a receiver with no hoisted cache still gets the inline bitcast-and-mask from the same helper. Measured on the dynamic-dispatch census, best-of-5 over 200k calls, minus the zero-iteration run and the same-arity identity baseline (16-element arrays): arrSumIndex 659.7 -> 584.4 -75.3 (-11.4%) arrSumForOf 892.9 -> 845.7 -47.2 ( -5.3%) No other probe moved: arrSet -1.2, arrLen +0.2, arrPushPop -0.5, anyArrGet +0.2, objArrFieldGet -0.5, f64Get +0.8, arrMapInc -11. `test_gap_packed_loop_cached_receiver.ts` covers every shape that leaves the clone — a side exit on a non-numeric element, a hole read, a foreign index, a receiver grown during the loop, an allocating body that puts a real safepoint back, plus frozen, subclass and typed-array receivers — through both the indexed and the `for…of` form. It matches node normally, under GC stress (136 copying minors, objects moved, from-space quarantine armed, evacuation verified) and at `PERRY_GC_SCHEDULE_RATE=1` with quarantine depth 64. `cargo test --release -p perry-codegen --tests`: 36 suites, 0 failures. Root-dominance corpus: 196 modules, 15430 root stores, exactly the 2 known `test_gap_gc_regexp_receiver_rooting` violations, 0 moving-minor reachable. --- .../src/expr/index_get/guarded_array.rs | 13 ++- .../test_gap_packed_loop_cached_receiver.ts | 101 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 test-files/test_gap_packed_loop_cached_receiver.ts diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index b86355026e..968e04af03 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -714,10 +714,19 @@ pub(super) fn lower_packed_f64_loop_index_get( .cond_br(&in_bounds, &cont_label, &fact.store_side_exit_label); ctx.current_block = cont_idx; } + // #9379 proved this clone has no safepoint: the matcher admits no call, + // closure or await, its reads and writes are bare `double` load/store on + // existing slots, and the back-edge poll is suppressed for exactly that + // reason. So the receiver cannot move and its header words cannot change + // for the clone's whole dynamic extent — take the pre-masked handle from + // the hoisted receiver cache instead of re-laundering the rooted slot and + // re-masking it per element. The cache is a plain `i64` alloca nothing in + // the clone stores to, so the element-base chain hanging off it + // (`size` at `-4`, `capacity` at `+4`, the shifts and the subtract) becomes + // loop-invariant to LICM, which the laundered reload deliberately blocked. let value = { + let arr_handle = super::super::receiver_descriptor_handle_i64(ctx, Some(arr_id), arr_box); let blk = ctx.block(); - let arr_bits = blk.bitcast_double_to_i64(arr_box); - let arr_handle = blk.and(I64, &arr_bits, POINTER_MASK_I64); let idx_i64 = blk.zext(I32, idx_i32, I64); let byte_offset = blk.shl(I64, &idx_i64, "3"); let elements_addr = blk.array_elements_addr(&arr_handle); diff --git a/test-files/test_gap_packed_loop_cached_receiver.ts b/test-files/test_gap_packed_loop_cached_receiver.ts new file mode 100644 index 0000000000..054d88a34e --- /dev/null +++ b/test-files/test_gap_packed_loop_cached_receiver.ts @@ -0,0 +1,101 @@ +// The packed-f64 fast loop clone reads its element base from the hoisted +// receiver cache rather than re-laundering the rooted slot each iteration. +// That is only sound while the clone has no safepoint, so every shape that +// leaves the clone — a side exit, a hole, a foreign index, a length change, +// a non-numeric element — must still produce node's answer. + +function sumIndexed(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i]; + return s; +} + +function sumForOf(a: number[]): number { + let s = 0; + for (const v of a) s += v; + return s; +} + +function scaleInPlace(a: number[], k: number): number { + let s = 0; + for (let i = 0; i < a.length; i++) { + a[i] = a[i] * k; + s += a[i]; + } + return s; +} + +function sumWithForeignIndex(a: number[], j: number): number { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i] + a[j]; + return s; +} + +const dense: number[] = []; +for (let i = 0; i < 40; i++) dense.push(i * 1.5); +console.log("dense-indexed", sumIndexed(dense)); +console.log("dense-forof", sumForOf(dense)); +console.log("dense-scale", scaleInPlace(dense.slice(), 2)); +console.log("dense-foreign", sumWithForeignIndex(dense, 3)); + +// A single element makes the loop bound 1; an empty array makes it 0. +console.log("one", sumIndexed([7.5]), sumForOf([7.5])); +console.log("empty", sumIndexed([]), sumForOf([])); + +// Integer-valued members: the array is still raw-f64 but the values are +// exactly representable, which is a different canonicalization path. +const ints: number[] = [1, 2, 3, 4, 5]; +console.log("ints", sumIndexed(ints), sumForOf(ints), scaleInPlace(ints.slice(), 3)); + +// A hole must read as undefined, so the sum is NaN through both loops. +const holed: any[] = [1, 2, 3]; +holed[6] = 9; +console.log("holed-indexed", sumIndexed(holed as number[])); +console.log("holed-forof", sumForOf(holed as number[])); +console.log("holed-len", holed.length, 4 in holed); + +// A non-numeric element forces the side exit out of the fast clone. +const mixed: any[] = [1, 2, "3", 4]; +console.log("mixed-indexed", sumIndexed(mixed as number[])); +console.log("mixed-forof", sumForOf(mixed as number[])); + +// Growing the receiver DURING the loop: the bound is hoisted at entry, so the +// appended elements must not be visited, and the base must survive the +// reallocation the growth performs. +function sumWhileGrowing(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) { + s += a[i]; + if (i === 0) for (let k = 0; k < 200; k++) a.push(k); + } + return s; +} +const grow: number[] = [1, 2, 3, 4, 5]; +console.log("growing", sumWhileGrowing(grow), grow.length); + +// Allocating in the body puts a real safepoint back in the loop, and the +// receiver may then move under it. +function sumAllocating(a: number[]): number { + let s = 0; + const keep: number[][] = []; + for (let i = 0; i < a.length; i++) { + keep.push([a[i], a[i] * 2]); + s += a[i]; + } + return s + keep.length; +} +console.log("allocating", sumAllocating(dense)); + +// Frozen and subclass receivers take their own paths. +const frozen = Object.freeze([1.5, 2.5, 3.5]) as number[]; +console.log("frozen", sumIndexed(frozen), sumForOf(frozen)); + +class MyArr extends Array {} +const sub: any = MyArr.from([1, 2, 3, 4]); +console.log("subclass", sumIndexed(sub), sumForOf(sub), sub.length); + +// A typed array is not a plain Array and must not enter the plain clone. +const ta = new Float64Array([1.5, 2.5, 3.5]); +let taSum = 0; +for (let i = 0; i < ta.length; i++) taSum += ta[i]; +console.log("typedarray", taSum); From dd12c470a74c567703ec954d6bcdf131b54a3c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 09:42:50 +0200 Subject: [PATCH 09/15] perf(codegen): the packed loop's counter read shades no GC root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for (const v of a)` over a packed numeric array paid an incremental-mark root-shading test on every element. `const v = a[i]` is an array alias, so `enable_persistent_shadow_slot_for_array_alias` gives it a persistent shadow slot, and the only per-store cost of such a slot is `emit_persistent_shadow_root_barrier` — an atomic load of `PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT`, a compare and a branch, plus the block split, once per element. `expr_is_known_non_pointer_shadow_value` exists to skip exactly that for a value that cannot be a heap reference, and it already admits a masked-window element read on this reasoning. The packed-numeric loop fact is the same class of proof and was simply not listed: the entry guard proved a dense raw-f64 (or packed i32/u32) plain Array, the clone it scopes has no safepoint and no growth (#9379), and the fast condition bounds the counter by the length read at loop entry — so `arr[i]` reads a raw numeric word and shading it is a no-op. The fact is dematerialized before the slow clone lowers, so this never leaks past the clone it was proved for. Restricted to offset 0. `arr[i ± c]` is in bounds only under a range-validated fact, and an out-of-bounds element read consults the prototype chain, where `Array.prototype[7] = {}` is a genuine heap reference that must stay rooted. The counter read cannot leave the array; the offset read can, and keeps its barrier. Measured on the dynamic-dispatch census with both arms re-run in the same window (best-of-5 over 200k calls, minus the zero-iteration run and the same-arity identity baseline, 16-element arrays): arrSumForOf 895.0 -> 813.9 -81.1 (-9.1%) of which -31.5 is this change and the rest the element-base hoist it stacks on. `arrSumIndex` is unaffected (-76.8, the hoist alone) because an indexed loop binds no element local. No probe regressed: largest increase +6.1 (strTemplate, untouched), and the five largest are +4.8..+6.1 — the noise floor. `packed_loop_shadow_barrier_tests.rs` pins both directions in emitted IR, and each fails without this change (1 shading test where 0 is required): the counter read's binding shades nothing in `for.packed_f64_fast.body`, the offset read's binding still shades exactly once in the `packed_f64_loop.foreign.inbounds` block its bounds check creates, and a third test puts both in one clone so neither arm can pass vacuously. Every test panics if its block was not emitted, so a count cannot be taken over a clone that never ran. `test_gap_packed_loop_proto_index_rooting.ts` is the end-to-end half: it installs an object at `Array.prototype[7]`, has an `a[i + 3]` loop over a length-5 array read it out of bounds, retains that capture, then churns the nursery for 60 rounds re-running both loops and asserts the object's identity and payload survive. It matches node normally and under GC stress with `PERRY_GC_FROMSPACE_SCAN_ABORT=1`: seed=37 rate=0.2 2524 from-space scans, all clean, dangling=0, missing_rewrites=0; 5048 copying minors, max 6720 objects moved; live set up to 33170 objects / 160486 words seed=91 rate=1.0 12694 from-space scans, all clean, dangling=0, missing_rewrites=0 `cargo test --release -p perry-codegen --tests`: 36 suites, 0 failures. Root-dominance corpus: 168/168 sources, 196 modules, 15430 root stores, 0 violations on the `--moving-only` CI arm with 40/40 seeded violations caught. --- crates/perry-codegen/src/expr/mod.rs | 2 + .../expr/packed_loop_shadow_barrier_tests.rs | 246 ++++++++++++++++++ crates/perry-codegen/src/expr/shadow_slot.rs | 28 ++ ...est_gap_packed_loop_proto_index_rooting.ts | 80 ++++++ 4 files changed, 356 insertions(+) create mode 100644 crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs create mode 100644 test-files/test_gap_packed_loop_proto_index_rooting.ts diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1a720ccd49..c695f407c5 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -3029,6 +3029,8 @@ mod index_set_packed_loop; mod index_set_typed_array; mod instance_misc1; mod member_update; +#[cfg(test)] +mod packed_loop_shadow_barrier_tests; mod typed_array_rmw; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; pub(crate) mod class_field_inline_guard; diff --git a/crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs b/crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs new file mode 100644 index 0000000000..007ce41a6a --- /dev/null +++ b/crates/perry-codegen/src/expr/packed_loop_shadow_barrier_tests.rs @@ -0,0 +1,246 @@ +//! The packed-numeric clone's counter read is a proven Number, so binding it to +//! a `const` must not shade a GC root — and `arr[i ± c]` must still shade one. +//! +//! `expr_is_known_non_pointer_shadow_value` suppresses +//! `emit_persistent_shadow_root_barrier` for a value that cannot be a heap +//! reference. Inside an active packed-numeric loop fact the entry guard has +//! proved a dense raw-f64 plain Array, the clone has no safepoint and no growth +//! (#9379), and the fast condition bounds the counter by the length read at loop +//! entry — so `arr[i]` reads a raw numeric word. +//! +//! `arr[i + 1]` has none of that: the index can leave the array, and an +//! out-of-bounds element read consults the prototype chain, where +//! `Array.prototype[7] = {}` is a genuine heap reference that must stay rooted. +//! The suppression is therefore restricted to offset 0, and this file is the +//! assertion that the restriction is real. +//! +//! Both reads live in the SAME fast clone, so neither direction can pass +//! vacuously: if the offset read were wrongly admitted the barrier count in the +//! fast body would be 0, and if the counter read were wrongly refused it would +//! be 2. Every test also asserts the clone was entered at all — a barrier count +//! taken over blocks that were never emitted is CLAUDE.md hazard 4. + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module as HirModule, Param, Stmt, UpdateOp}; + +const ARR: u32 = 0; +const SUM: u32 = 1; +const IDX: u32 = 2; +const BOUND_V: u32 = 3; +const BOUND_W: u32 = 4; + +/// The root-shading barrier's inline arming test, emitted once per shaded store. +const SHADING_TEST: &str = "@PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT"; + +fn compile(name: &str, body: Vec) -> String { + let mut hir = HirModule::new(name); + hir.functions.push(Function { + id: 0, + name: "build".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: ARR, + name: "a".to_string(), + ty: Type::Array(Box::new(Type::Number)), + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Number, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let opts = crate::CompileOptions { + emit_ir_only: true, + ..Default::default() + }; + String::from_utf8(crate::compile_module(&hir, opts).expect("test module compiles")) + .expect("LLVM IR is UTF-8") +} + +/// The first emitted block whose label starts with `prefix`, up to the next +/// top-level label. Panics when the block is absent, so an assertion can never +/// be taken over a clone that was not emitted. +fn block(ir: &str, prefix: &str) -> String { + let start = ir + .find(&format!("\n{prefix}")) + .unwrap_or_else(|| panic!("no block labelled {prefix}* was emitted:\n{ir}")); + let rest = &ir[start + 1..]; + let body_start = rest.find(":\n").expect("a block label ends in a colon") + 2; + let mut end = rest.len(); + let mut at = body_start; + for line in rest[body_start..].split_inclusive('\n') { + let trimmed = line.trim_end(); + if trimmed.ends_with(':') && !trimmed.starts_with(' ') && !trimmed.is_empty() { + end = at; + break; + } + at += line.len(); + } + rest[..end].to_string() +} + +/// The clone's fast body — where a counter read's binding store lands. +fn fast_body(ir: &str) -> String { + block(ir, "for.packed_f64_fast.body") +} + +/// Where an `arr[i ± c]` binding store lands instead: the offset read carries an +/// inline bounds check that side-exits to the slow preheader, and its store sits +/// past that check rather than in the body block. +fn offset_read_block(ir: &str) -> String { + block(ir, "packed_f64_loop.foreign.inbounds") +} + +/// `let s = 0; for (let i = 0; i < a.length; i++) { } return s;` +fn packed_loop_with(bindings: Vec) -> Vec { + vec![ + Stmt::Let { + id: SUM, + name: "s".into(), + ty: Type::Number, + init: Some(Expr::Number(0.0)), + mutable: true, + }, + Stmt::For { + init: Some(Box::new(Stmt::Let { + id: IDX, + name: "i".into(), + ty: Type::Number, + init: Some(Expr::Integer(0)), + mutable: true, + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(IDX)), + right: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(ARR)), + property: "length".to_string(), + byte_offset: 0, + }), + }), + update: Some(Expr::Update { + id: IDX, + op: UpdateOp::Increment, + prefix: false, + }), + body: bindings, + }, + Stmt::Return(Some(Expr::LocalGet(SUM))), + ] +} + +/// `Type::Any`, not `Type::Number`: a number-annotated local is never given a +/// shadow slot, so a barrier could not be emitted for it under any predicate +/// and both arms of the comparison would read 0. The `for…of` desugaring this +/// models erases the element type, which is what earns the slot in the first +/// place — and what makes the suppression worth anything. +fn bind(id: u32, name: &str, index: Expr) -> Stmt { + Stmt::Let { + id, + name: name.into(), + ty: Type::Any, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(ARR)), + index: Box::new(index), + }), + mutable: false, + } +} + +fn accumulate(id: u32) -> Stmt { + Stmt::Expr(Expr::LocalSet( + SUM, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(SUM)), + right: Box::new(Expr::LocalGet(id)), + }), + )) +} + +fn counter_read() -> Expr { + Expr::LocalGet(IDX) +} + +fn offset_read() -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(IDX)), + right: Box::new(Expr::Integer(1)), + } +} + +/// `const v = a[i]` alone: the clone's fast body shades nothing. +#[test] +fn a_packed_loop_counter_read_binding_shades_no_root() { + let ir = compile( + "packed_counter_read", + packed_loop_with(vec![ + bind(BOUND_V, "v", counter_read()), + accumulate(BOUND_V), + ]), + ); + let body = fast_body(&ir); + assert_eq!( + body.matches(SHADING_TEST).count(), + 0, + "a[i] under a packed-numeric fact is a proven Number; binding it must shade no root:\n{body}" + ); +} + +/// `const w = a[i + 1]` alone: still shaded, because the read can leave the +/// array and reach a prototype index holding a heap reference. +#[test] +fn a_packed_loop_offset_read_binding_still_shades_its_root() { + let ir = compile( + "packed_offset_read", + packed_loop_with(vec![bind(BOUND_W, "w", offset_read()), accumulate(BOUND_W)]), + ); + let guarded = offset_read_block(&ir); + assert_eq!( + guarded.matches(SHADING_TEST).count(), + 1, + "a[i + 1] can read past the array into the prototype chain; its binding must stay \ + shaded:\n{guarded}" + ); +} + +/// Both in one clone, which is what makes neither direction vacuous: the +/// counter read must contribute nothing to the fast body and the offset read +/// must still contribute exactly one to its own guarded block. A non-zero body +/// count would mean the counter read stopped being recognised and the +/// optimisation is dead; a zero guarded count would mean the offset read was +/// wrongly admitted and a prototype-held object could go unshaded. +#[test] +fn only_the_offset_read_shades_when_both_live_in_one_clone() { + let ir = compile( + "packed_counter_and_offset", + packed_loop_with(vec![ + bind(BOUND_V, "v", counter_read()), + accumulate(BOUND_V), + bind(BOUND_W, "w", offset_read()), + accumulate(BOUND_W), + ]), + ); + assert_eq!( + fast_body(&ir).matches(SHADING_TEST).count(), + 0, + "the counter read's binding must shade nothing:\n{}", + fast_body(&ir) + ); + assert_eq!( + offset_read_block(&ir).matches(SHADING_TEST).count(), + 1, + "the offset read's binding must still be shaded:\n{}", + offset_read_block(&ir) + ); +} diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 2383e3890d..9eb78a6aaa 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -104,6 +104,9 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp object.as_ref(), Expr::LocalGet(arr_id) if super::masked_window_fact_for_index(ctx, *arr_id, index).is_some() + ) || matches!( + object.as_ref(), + Expr::LocalGet(arr_id) if packed_loop_counter_read_is_numeric(ctx, *arr_id, index) ) || super::is_proven_u32_view_read(ctx, expr) } // #6996: a typed-array / Buffer element read is a number (or @@ -157,6 +160,31 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp } } +/// `arr[i]` at the counter of an ACTIVE packed-numeric loop fact: the same +/// class of proof the masked-window arm above rests on. The entry guard proved +/// `arr` is a plain dense raw-f64 (or packed i32/u32) Array, the fast clone it +/// scopes has no safepoint and no growth (#9379), and the fast condition bounds +/// the counter by the length read at loop entry — so the slot this reads is a +/// raw numeric word and the value is a Number, never a heap reference. The fact +/// is dematerialized before the slow clone is lowered, so this never leaks past +/// the clone it was proved for. +/// +/// Restricted to offset 0 on purpose. `arr[i ± c]` is in bounds only under a +/// range-validated fact, and an out-of-bounds element read consults the +/// prototype chain — where `Array.prototype[7] = {}` yields a genuine heap +/// pointer that must stay rooted. The counter read cannot leave the array. +fn packed_loop_counter_read_is_numeric(ctx: &FnCtx<'_>, arr_id: u32, index: &Expr) -> bool { + let Some((idx_id, offset)) = super::packed_f64_loop_index_parts(index) else { + return false; + }; + if offset != 0 { + return false; + } + ctx.receiver_descriptors + .packed_f64_loop_facts() + .any(|fact| fact.array_local_id == arr_id && fact.index_local_id == idx_id) +} + pub(crate) fn emit_shadow_slot_clear(ctx: &mut FnCtx<'_>, slot_idx: u32) { if ctx.persistent_shadow_slots.contains(&slot_idx) { return; diff --git a/test-files/test_gap_packed_loop_proto_index_rooting.ts b/test-files/test_gap_packed_loop_proto_index_rooting.ts new file mode 100644 index 0000000000..5a90fe8ba5 --- /dev/null +++ b/test-files/test_gap_packed_loop_proto_index_rooting.ts @@ -0,0 +1,80 @@ +// `arr[i]` inside the packed-numeric fast clone is a proven Number, so its +// `const` binding shades no GC root. `arr[i ± c]` is NOT: the index can leave +// the array, and an out-of-bounds element read consults the prototype chain, +// where an installed index property is a genuine heap reference that must stay +// rooted. This fixture puts an object there, has an offset loop READ it, and +// retains it across collections — so a missed shading shows up as a dangling +// reference instead of as luck. + +const protoHeld: any = { tag: "proto-object", payload: [11, 22, 33] }; +Object.defineProperty(Array.prototype, 7, { + value: protoHeld, + writable: true, + enumerable: false, + configurable: true, +}); + +// Counter read: always in bounds, never reaches the prototype. +function sumCounter(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) { + const v = a[i]; + s += v; + } + return s; +} + +// Offset read: a[i + 3] over a length-5 array touches 3, 4, 5, 6, 7 — the last +// of which is the prototype's object. +function collectOffset(a: number[], out: any[]): number { + let n = 0; + for (let i = 0; i < a.length; i++) { + const w = a[i + 3]; + out.push(w); + n++; + } + return n; +} + +const five: number[] = [1.5, 2.5, 3.5, 4.5, 5.5]; +console.log("counter", sumCounter(five)); + +const captured: any[] = []; +console.log("offset-count", collectOffset(five, captured)); +console.log("offset-kinds", captured.map((x) => typeof x).join(",")); +console.log("offset-values", JSON.stringify(captured.slice(0, 2))); +console.log("captured-is-proto", captured[4] === protoHeld); + +// Churn the nursery so the retained capture crosses collections. +let churn: any[] = []; +for (let round = 0; round < 60; round++) { + const block: any[] = []; + for (let k = 0; k < 200; k++) block.push({ k, s: "fill-" + k, arr: [k, k + 1] }); + churn.push(block); + if (churn.length > 4) churn = churn.slice(-2); + // Re-run both loops while the heap is moving. + sumCounter(five); + collectOffset(five, captured); +} + +// The prototype object captured before all that churn must still be intact. +const held = captured[4]; +console.log("held-tag", held.tag); +console.log("held-payload", JSON.stringify(held.payload)); +console.log("held-identity", held === protoHeld, protoHeld.tag); +console.log("captured-len", captured.length); +console.log("last-capture-ok", captured[captured.length - 1] === protoHeld); + +// The counter loop's answer is unchanged by the polluted prototype. +console.log("counter-again", sumCounter(five)); + +// And a length-8 array reads its OWN element 7, not the prototype's. +const eight: number[] = [1, 2, 3, 4, 5, 6, 7, 8]; +const ownSeven: any[] = []; +collectOffset(eight.slice(0, 5), ownSeven); +console.log("short-slice-sees-proto", ownSeven[4] === protoHeld); +console.log("full-eight-index7", eight[7]); +console.log("sum-eight", sumCounter(eight)); + +delete (Array.prototype as any)[7]; +console.log("after-delete", five[7], eight[7]); From 8f6ea642de8d6c77cc176df21316280b4362d97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 06:17:16 +0200 Subject: [PATCH 10/15] perf(runtime): answer an ASCII string index from the value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `s[i]` walked js_string_index_get_boxed -> js_string_index_get -> js_string_char_at -> ascii_char_string: a thread-local canonical-table lookup returning a heap StringHeader that the caller immediately NaN-boxed, and, for a short-string receiver, a full materialization of the receiver onto the heap first just to index it. An ASCII receiver's character is one byte, which is exactly a short-string value, so both ends pack inline: 272 -> 223 instructions per read on a heap receiver and 158 -> 109 on a short one. The value is unchanged — a short and a heap string with the same bytes compare equal everywhere — and two equal characters now share one bit pattern instead of one pointer. --- crates/perry-runtime/src/string/char_ops.rs | 91 ++++++++++++++----- test-files/test_gap_string_index_character.ts | 65 +++++++++++++ 2 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 test-files/test_gap_string_index_character.ts diff --git a/crates/perry-runtime/src/string/char_ops.rs b/crates/perry-runtime/src/string/char_ops.rs index 9fdf19e7c3..c807f477a4 100644 --- a/crates/perry-runtime/src/string/char_ops.rs +++ b/crates/perry-runtime/src/string/char_ops.rs @@ -125,6 +125,13 @@ pub extern "C" fn js_string_index_get_boxed(value: f64, key: f64) -> f64 { const UNDEFINED: f64 = f64::from_bits(crate::value::TAG_UNDEFINED); let jsval = crate::value::JSValue::from_bits(value.to_bits()); if jsval.is_short_string() { + // Reading one character out of an ASCII short string needs neither the + // heap nor a handle scope: the bytes are in the value. Without this, + // every `s[i]` on a short string allocated a `StringHeader` for the + // receiver just to index it. + if let Some(character) = short_string_index_get(jsval, key) { + return character; + } let scope = crate::gc::RuntimeHandleScope::new(); let key = scope.root_nanbox_f64(key); let hdr = crate::string::js_string_materialize_to_heap(value); @@ -220,37 +227,79 @@ pub extern "C" fn js_string_index_get(s: *const StringHeader, key: f64) -> f64 { } } let len = unsafe { (*s).utf16_len } as u64; - let jsval = crate::value::JSValue::from_bits(key.to_bits()); - let idx: u64 = if jsval.is_int32() { - let i = jsval.as_int32(); - if i < 0 { - return UNDEFINED; - } - i as u64 - } else if jsval.is_number() { - // Real double: only a finite, non-negative integer is an array index. - if !key.is_finite() || key < 0.0 || key.fract() != 0.0 { - return UNDEFINED; - } - key as u64 // saturating; an out-of-range magnitude fails the bound below - } else if jsval.is_any_string() { - match crate::builtins::jsvalue_string_content(key).and_then(|k| canonical_string_index(&k)) - { - Some(i) => i, - None => return UNDEFINED, - } - } else { - return UNDEFINED; + let idx = match canonical_index_of(key) { + Some(idx) => idx, + None => return UNDEFINED, }; if idx >= len { return UNDEFINED; } + // An ASCII receiver's character is one byte, which is exactly a + // short-string value: pack it here instead of routing through + // `js_string_char_at` -> `ascii_char_string`, whose canonical table costs a + // thread-local lookup and hands back a heap `StringHeader` that the caller + // immediately NaN-boxes. Same value either way — a short string and a heap + // string with the same bytes compare equal everywhere (`is_any_string` + + // `string_bytes` decode both) — and two equal characters now share one bit + // pattern rather than one pointer. + if is_ascii_string(s) { + let byte = unsafe { *string_data(s).add(idx as usize) }; + debug_assert!(byte < 0x80, "utf16_len == byte_len proves one-byte units"); + return f64::from_bits(crate::value::JSValue::short_string_unchecked(&[byte]).bits()); + } let ptr = js_string_char_at(s, idx as i32); crate::value::js_nanbox_string(ptr as i64) } +/// The index `s[key]` names, per `CanonicalNumericIndexString`, or `None` when +/// the key is not an index at all (`s.length`, `s["01"]`, a symbol, …) and the +/// caller must continue to the ordinary property lookup. +fn canonical_index_of(key: f64) -> Option { + let jsval = crate::value::JSValue::from_bits(key.to_bits()); + if jsval.is_int32() { + let i = jsval.as_int32(); + return if i < 0 { None } else { Some(i as u64) }; + } + if jsval.is_number() { + // Real double: only a finite, non-negative integer is an array index. + if !key.is_finite() || key < 0.0 || key.fract() != 0.0 { + return None; + } + // Saturating; an out-of-range magnitude fails the caller's bound check. + return Some(key as u64); + } + if jsval.is_any_string() { + return crate::builtins::jsvalue_string_content(key) + .and_then(|k| canonical_string_index(&k)); + } + None +} + +/// `s[i]` on a short-string receiver of pure ASCII: the answer is one of its +/// own packed bytes, so neither the receiver nor the result needs to reach the +/// heap. `None` falls through to the general path, which owns every other case +/// (non-index keys, out-of-range indices, non-ASCII payloads). +fn short_string_index_get(jsval: crate::value::JSValue, key: f64) -> Option { + let mut buf = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let len = jsval.short_string_to_buf(&mut buf); + let bytes = &buf[..len]; + if !bytes.is_ascii() { + // A multi-byte payload makes the byte index and the UTF-16 index + // disagree; the heap path resolves those. + return None; + } + let idx = canonical_index_of(key)?; + if idx >= len as u64 { + return None; + } + let byte = bytes[idx as usize]; + Some(f64::from_bits( + crate::value::JSValue::short_string_unchecked(&[byte]).bits(), + )) +} + /// Parse a property-key string into a canonical array index per /// `CanonicalNumericIndexString`: the string must equal the exact `ToString` of /// the resulting non-negative integer, so `"0"`→0 and `"12"`→12 are canonical diff --git a/test-files/test_gap_string_index_character.ts b/test-files/test_gap_string_index_character.ts new file mode 100644 index 0000000000..cd3f99c445 --- /dev/null +++ b/test-files/test_gap_string_index_character.ts @@ -0,0 +1,65 @@ +// `s[i]` and the property lookups that share its entry point. The character a +// string index answers is a value, not a cell: a short-string receiver must +// give the same answer as the same text on the heap, and both must agree with +// Node for non-index keys, out-of-range indices, astral pairs and lone +// surrogates. + +function at(s: string, i: number): string { + return s[i]; +} +function atKey(s: string, k: any): any { + return (s as any)[k]; +} + +// Short (SSO-eligible) and long (heap) receivers with identical text. +const short = "abc"; +const long = ["abc", "defghijkl"].join(""); +console.log("short", at(short, 0), at(short, 1), at(short, 2)); +console.log("long", at(long, 0), at(long, 3), at(long, 11)); + +// The answer must compare equal and behave as a string wherever it flows. +const c = at(short, 1); +console.log("identity", c === "b", c == "b", typeof c, c.length, c.charCodeAt(0)); +console.log("concat", c + c, ("x" + c).length, [c, c].join("-")); +console.log("in-map", new Map([["b", 1]]).get(c), new Set(["b"]).has(c)); +console.log("as-key", { b: 7 }[c as "b"], JSON.stringify({ [c]: 1 })); + +// Out of range, negative, fractional, and non-index keys. +console.log("oob", at(short, 3), at(short, -1), at(short, 1.5), at(long, 99)); +console.log("keys", atKey(short, "0"), atKey(short, "01"), atKey(short, "1.0"), atKey(short, "")); +console.log("length", atKey(short, "length"), atKey(long, "length")); +console.log("proto", typeof atKey(short, "toUpperCase"), atKey(short, "nope")); + +// -0 and NaN keys. +console.log("weird", atKey(short, -0), atKey(short, NaN), atKey(short, Infinity)); + +// Non-ASCII: multi-byte code points make the byte index and the UTF-16 index +// disagree, and an astral character indexes as its two surrogate halves. +const accented = "héllo"; +const astral = "a😀b"; +console.log("accented", accented[0], accented[1], accented[2], accented.length); +console.log("astral", astral.length, astral[0], astral[3], astral[1] === "\uD83D", astral[2] === "\uDE00"); +console.log("astral-codes", astral.charCodeAt(1), astral.charCodeAt(2), astral.codePointAt(1)); + +// A lone surrogate survives a round trip through the index path. +const lone = "a\uD800b"; +console.log("lone", lone.length, lone.charCodeAt(1), lone[1] === "\uD800", (lone[1] + "").length); + +// Short receivers whose payload is multi-byte: the packed bytes are not the +// UTF-16 units, so the index must still count code units. +const shortMulti = "é1"; +console.log("short-multi", shortMulti.length, shortMulti[0], shortMulti[1], shortMulti[2]); + +// Every character of a mixed string, through both entry points. +let walked = ""; +for (let i = 0; i < astral.length; i++) walked += astral[i]; +console.log("walk", walked === astral, walked.length); + +// A String object (not a primitive) keeps object semantics. +const boxed: any = new String("xy"); +console.log("boxed", boxed[0], boxed[1], boxed[2], boxed.length, typeof boxed); + +// Index reads through a hot loop, the shape the inline path is built for. +let acc = 0; +for (let i = 0; i < 1000; i++) acc += long[i % long.length].charCodeAt(0); +console.log("hot", acc); From 75c7908cc99c39c45032e86321b5ea034e2b7135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 06:17:16 +0200 Subject: [PATCH 11/15] perf(runtime): resolve the recorded prototype only where it is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ordinary_has_property asked object_static_prototype up front, spending a shape/registry probe on every [[HasProperty]] — including the common walk that finds an own key on the first hop and returns from the loop. Its only consumer is the class-vtable fallback reached after the whole walk misses, and the walk runs no user code, so the answer cannot change in between. "a" in {a,b}: 957 -> 905 per call; a 40-key own hit 970 -> 917; a miss 4,773 -> 4,746. --- .../src/object/field_get_set/has_property.rs | 10 +- test-files/test_gap_in_operator_presence.ts | 107 ++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 test-files/test_gap_in_operator_presence.ts diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 0a0be3f051..47455bf4dd 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1126,8 +1126,12 @@ unsafe fn ordinary_has_property( // fallback below must be skipped — the recorded chain (walked above) is now // authoritative, so a key that was deleted/replaced off the prototype must // not be resurrected from the original class vtable. - let has_recorded_prototype = - super::super::prototype_chain::object_static_prototype(obj_ptr as usize).is_some(); + // Asked at the class-vtable fallback below, which is the ONLY consumer and + // is reached only after the whole chain walk has missed. Resolving it here + // spent a shape/registry probe on every call, including the common one that + // finds an own key on the first hop and returns from the loop. Nothing in + // the walk can change the recorded prototype (it runs no user code), so + // asking later is the same answer. let mut cur = obj_ptr; let mut last_valid = obj_ptr; let mut guard = 0u32; @@ -1289,7 +1293,7 @@ unsafe fn ordinary_has_property( // `keys_array`, so the own-key + recorded-prototype walk above misses them. // Check the class chain so `'method' in instance` is `true` (e.g. NestJS's // app Proxy gating on `'listen' in receiver`). - if !has_recorded_prototype { + if super::super::prototype_chain::object_static_prototype(obj_ptr as usize).is_none() { if let Some(name) = key_name { let class_id = unsafe { (*obj_ptr).class_id }; if class_id != 0 diff --git a/test-files/test_gap_in_operator_presence.ts b/test-files/test_gap_in_operator_presence.ts new file mode 100644 index 0000000000..1f013ca180 --- /dev/null +++ b/test-files/test_gap_in_operator_presence.ts @@ -0,0 +1,107 @@ +// `in` presence across the receivers whose keys do not live in an ordinary +// keys array, and across the mutations that must change the answer: the walk +// resolves a recorded prototype only at its class-vtable fallback, so every +// one of these has to keep answering what Node answers. + +class Base { + baseField: number; + constructor() { + this.baseField = 1; + } + baseMethod(): number { + return 1; + } +} +class Derived extends Base { + ownField: string; + constructor() { + super(); + this.ownField = "x"; + } + derivedMethod(): number { + return 2; + } +} + +const d = new Derived(); +console.log( + "class", + "ownField" in d, + "baseField" in d, + "derivedMethod" in d, + "baseMethod" in d, + "toString" in d, + "nope" in d, +); + +// A plain object: own, inherited, absent, and index-like keys. +const plain: any = { a: 1, b: undefined }; +console.log("plain", "a" in plain, "b" in plain, "c" in plain, "toString" in plain, "0" in plain); + +// delete must flip presence, and re-adding must flip it back. +console.log("delete", "a" in plain, delete plain.a, "a" in plain, ((plain.a = 9), "a" in plain)); + +// A wide object crosses the keys-index threshold. +const wide: any = {}; +for (let i = 0; i < 40; i++) wide["k" + i] = i; +console.log("wide", "k0" in wide, "k39" in wide, "k40" in wide, delete wide.k39, "k39" in wide); + +// Object.setPrototypeOf records a prototype: presence must follow the new +// chain, and stop following the old one. +const protoA: any = { onA: 1 }; +const protoB: any = { onB: 2 }; +const movable: any = Object.create(protoA); +console.log("proto-a", "onA" in movable, "onB" in movable); +Object.setPrototypeOf(movable, protoB); +console.log("proto-b", "onA" in movable, "onB" in movable); +Object.setPrototypeOf(movable, null); +console.log("proto-null", "onB" in movable, "toString" in movable); + +// A prototype gaining or losing a key after the first lookup. +const parent: any = {}; +const child: any = Object.create(parent); +console.log("late-proto", "later" in child, ((parent.later = 1), "later" in child)); +console.log("late-delete", (delete parent.later, "later" in child)); + +// Accessors, non-enumerable and symbol keys. +const withAccessor: any = {}; +Object.defineProperty(withAccessor, "acc", { get: () => 1, configurable: true }); +Object.defineProperty(withAccessor, "hidden", { value: 2, enumerable: false }); +const sym = Symbol("s"); +withAccessor[sym] = 3; +console.log("descriptors", "acc" in withAccessor, "hidden" in withAccessor, sym in withAccessor); + +// Arrays: indices, length, holes, and inherited members. +const arr: any = [1, 2, 3]; +arr[7] = 8; +console.log("array", 0 in arr, 2 in arr, 5 in arr, 7 in arr, "length" in arr, "map" in arr); + +// A Proxy answers through its has trap. +const proxied: any = new Proxy({ real: 1 }, { has: (t, k) => k === "virtual" || k in t }); +console.log("proxy", "virtual" in proxied, "real" in proxied, "other" in proxied); + +// Built-in receivers whose members are not ordinary keys. +console.log("builtins", "size" in new Map(), "has" in new Set(), "byteLength" in new ArrayBuffer(8)); +// NOTE: `"call" in function f(){}` is a separate, pre-existing gap — Perry +// answers false where Node answers true, on this commit's parent as well — so +// it is deliberately not asserted here; inherited Function.prototype members +// are not this fixture's subject. +console.log("fn", "length" in function g(a: number) {}, "name" in function h() {}, "x" in { x: 1 }); + +// process.env is backed by the OS, not a keys array. +(process.env as any).PERRY_IN_PROBE = "1"; +console.log("env", "PERRY_IN_PROBE" in process.env, "PERRY_ABSENT_XYZ" in process.env); +console.log("env-proto", "toString" in process.env); + +// A native-module namespace exposes virtual keys. +import * as pathMod from "node:path"; +console.log("module", "join" in pathMod, "definitelyNot" in (pathMod as any)); + +// Numeric and coercing keys go through ToPropertyKey. +const numeric: any = { 307: "a", "1.5": "b" }; +console.log("coerce", 307 in numeric, "307" in numeric, 1.5 in numeric, "1.5" in numeric); + +// The same key, asked in a hot loop, must not drift. +let hits = 0; +for (let i = 0; i < 2000; i++) if ("ownField" in d) hits++; +console.log("hot", hits); From 97681453ccb44c3122a87ae99f4ffdc8132517be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 08:11:21 +0200 Subject: [PATCH 12/15] perf(codegen): let the concat chain format the parts it already formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template desugaring wraps every substitution in StringCoerce so it is toString-first rather than +'s valueOf-first (#6078), but js_string_concat_chain formats each part itself. For a part already proven a string the wrapper is the identity, and the coerce only mints an intermediate heap string for the helper to copy and drop. `${s}:${n}` 1,437 -> 1,411 instructions per call (one of its two js_string_coerce calls is gone). The number substitution keeps its wrapper: a declared-number parameter is not provably non-pointer — an annotation can lie — and String(obj) and the helper's slow path can disagree for an object with both valueOf and toString. --- .../perry-codegen/src/lower_string_concat.rs | 40 +++++++++ .../test_gap_template_number_formatting.ts | 90 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 test-files/test_gap_template_number_formatting.ts diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index e1d6cca0e0..4f0fa92e10 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -757,9 +757,49 @@ pub(crate) fn flatten_string_add_chain<'a>( /// One per-function buffer is shared across all chain call sites — fine /// because each chain call writes its parts and immediately calls into /// the runtime helper before any other call site can clobber the slots. +/// Drop a `StringCoerce` wrapper the chain helper's own formatting makes +/// redundant. +/// +/// The template desugaring wraps every substitution in `StringCoerce` so it is +/// toString-first rather than `+`'s valueOf-first (#6078). But +/// `js_string_concat_chain` formats each part itself, and for two kinds of part +/// its formatting IS `ToString`: a value already proven a string (the coercion +/// is the identity) and a number that cannot be a heap pointer — the helper +/// runs `format_number_into`, which `stack_number_formatting_matches_js_format_f64` +/// pins to `js_format_f64`, i.e. `Number::toString` including the ryu-js +/// tie-break and the exponent thresholds (#3987). +/// +/// For those, the wrapper only mints an intermediate heap string for the helper +/// to copy and immediately drop: `` `${s}:${n}` `` spent 338 instructions per +/// call in `js_number_to_string` -> `js_string_from_bytes_with_capacity` -> +/// `string_storage_alloc` doing exactly that. +/// +/// The non-pointer proof is what keeps an object out: `String(obj)` and the +/// helper's slow path can disagree on a value with both `valueOf` and +/// `toString`, so an object-valued part — including one a lying annotation +/// claims is a number — keeps its wrapper. +fn chain_part_without_redundant_coerce<'a>(ctx: &FnCtx<'_>, part: &'a Expr) -> &'a Expr { + let Expr::StringCoerce(inner) = part else { + return part; + }; + let is_string = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, inner); + let is_plain_number = crate::type_analysis::is_numeric_expr(ctx, inner) + && crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, inner); + if is_string || is_plain_number { + inner + } else { + part + } +} + pub(crate) fn lower_string_concat_chain(ctx: &mut FnCtx<'_>, parts: &[&Expr]) -> Result { debug_assert!(parts.len() >= 2); debug_assert!(parts.len() <= CONCAT_CHAIN_MAX_PARTS); + let parts: Vec<&Expr> = parts + .iter() + .map(|part| chain_part_without_redundant_coerce(ctx, part)) + .collect(); + let parts = parts.as_slice(); // Lower each part first (in source order); side effects must fire // left-to-right per JS spec. #6951: that ordering is exactly what makes diff --git a/test-files/test_gap_template_number_formatting.ts b/test-files/test_gap_template_number_formatting.ts new file mode 100644 index 0000000000..41004b9081 --- /dev/null +++ b/test-files/test_gap_template_number_formatting.ts @@ -0,0 +1,90 @@ +// Template substitutions are ToString, not `+`'s ToPrimitive: a substitution +// whose coercion the concat helper performs itself must still print exactly +// what String(x) prints, and a substitution whose coercion is observable must +// still run it. Covers the signs, zeroes, non-finites, integer/fraction split, +// the scientific-notation thresholds and the tie-break ryu-js owns (#3987), +// BigInt, and objects with valueOf/toString. + +function tpl(s: string, n: number): string { + return `${s}:${n}`; +} +function tplAny(a: any, b: any): string { + return `${a}|${b}`; +} + +const label = ["v", "al"].join(""); + +// Signs and zeroes: -0 prints as "0" in a template, unlike Object.is. +console.log(tpl(label, 0), tpl(label, -0), tpl(label, 1), tpl(label, -1)); +console.log("neg-zero", `${-0}`, `${0}`, Object.is(-0, -0 * 1)); + +// Non-finites. +console.log(tpl(label, NaN), tpl(label, Infinity), tpl(label, -Infinity)); + +// Integers, including the 2^53 boundary and negatives. +console.log(tpl(label, 42), tpl(label, -42), tpl(label, 1e15), tpl(label, 2 ** 53)); +console.log(tpl(label, Number.MAX_SAFE_INTEGER), tpl(label, -Number.MAX_SAFE_INTEGER)); + +// Fractions and the shortest-round-trip tie-break. +console.log(tpl(label, 0.1), tpl(label, 1 / 3), tpl(label, 0.5), tpl(label, 1.005)); +console.log(tpl(label, 5e-324), tpl(label, Number.MAX_VALUE), tpl(label, Number.MIN_VALUE)); + +// The scientific-notation thresholds: >= 1e21 and < 1e-6 switch form. +console.log(tpl(label, 1e20), tpl(label, 1e21), tpl(label, 1e-6), tpl(label, 1e-7)); +console.log(tpl(label, 123456789012345680000), tpl(label, 0.000001), tpl(label, 0.0000001)); + +// Every one of these must agree with String(x) and with `+`. +const values = [0, -0, 1, -1, NaN, Infinity, -Infinity, 0.1, 1e21, 1e-7, 2 ** 53, 5e-324]; +console.log("agree", values.every((v) => `${v}` === String(v) && `${v}` === "" + v)); + +// A number reached through `any` (the annotation cannot be trusted). +console.log(tplAny(1.5, -2.5), tplAny(0, NaN), tplAny(1e21, 1e-7)); + +// A lying annotation: the parameter says number, the value is not. +console.log("lie", tpl(label, "12" as any), tpl(label, true as any), tpl(label, null as any)); +console.log("lie-obj", tpl(label, { toString: () => "OBJ" } as any)); + +// ToString is toString-first, unlike `+` which is valueOf-first. +const both = { + valueOf() { + return 111; + }, + toString() { + return "STR"; + }, +}; +console.log("tostring-first", `${both}`, String(both), "" + both, tplAny(both, both)); + +// A substitution whose coercion has a side effect must run exactly once. +let calls = 0; +const counted = { + toString() { + calls++; + return "C"; + }, +}; +const once = `${counted}-${counted}`; +console.log("side-effect", once, calls); + +// Symbol.toPrimitive wins over both. +const prim = { + [Symbol.toPrimitive](hint: string) { + return "P:" + hint; + }, +}; +console.log("toPrimitive", `${prim}`, "" + (prim as any)); + +// BigInt substitutions. +console.log("bigint", `${10n}`, `${-10n}`, `${2n ** 64n}`, tplAny(1n, 2n)); + +// Strings, booleans, null/undefined and arrays keep their forms. +console.log("mixed", tplAny("s", true), tplAny(null, undefined), tplAny([1, 2], {})); + +// Longer chains and nesting. +const n1 = 1.25; +console.log(`a${n1}b${-n1}c${n1 * 4}d`, `${`${n1}`}`); + +// A hot loop, the shape the in-place formatting is for. +let acc = ""; +for (let i = 0; i < 200; i++) acc = `${i}:${i / 8}`; +console.log("hot", acc, acc.length); From 6ee4968cfc4687f2414da5eaff045733a4fd48bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 08:25:19 +0200 Subject: [PATCH 13/15] perf(codegen): give a constant-key `in` a presence inline cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `in` was the last common operator with no cache slot at all: `"k" in o` lowered to a bare `js_in_operator` call that re-derived the receiver's keys array from its ShapeId (a shape-slab probe) and re-scanned it, every time. That is 955 instructions for an own-key hit and 980 for one on a 40-key object, against ~15 for reading the same key through the property PIC. The answer is a property of the SHAPE, not of the object — two objects with the same ShapeId have the same keys array — so a site with a literal key caches one ShapeId and answers `true` inline when the receiver still carries it. Everything else calls `js_in_operator_presence_ic`, which computes the real answer (including the TypeError a primitive right operand owes) and may arm the site; the inline path can only ever produce `true`. Only positives are cached, and they need no prototype-chain epoch: the cached claim is about an OWN key, so `Object.setPrototypeOf`, a late `Proto.x = 1` and a `delete Proto.x` cannot falsify it. A negative would be a claim about the whole chain and there is no chain epoch in this runtime to key one on, so `"zz" in o` still calls the runtime every time. Invalidation of a positive needs only that losing the key moves the receiver off the guard: a compacting delete publishes a new ShapeId, and a tombstoning delete (#9064) keeps the ShapeId but sets OBJ_FLAG_STABLE_TOMBSTONES, which the guard rejects. Shape ids are allocated monotonically and never reused, and their range is disjoint from every class id, so a stale stamp can only miss. The cache holds two integers and no heap pointer, so it is not a GC root. Instructions per call, base v0.5.1579 vs this branch, both arms rebuilt and re-measured in one window (3M iterations, best of 3, minus a zero-iteration run; controls identity 177.0 -> 177.1, id2 149.1 -> 145.9): "a" in {a,b} 967 -> 41 "k39" in <40 keys> 980 -> 41 "zz" in {a,b} 4,783 -> 4,668 (miss: answer is not cacheable) "toString" in o 2,377 -> 2,351 (inherited: site declines after 8 tries) test_gap_in_operator_presence_cache.ts proves the invalidation against a warm cache: delete, re-add, 500 delete/re-add cycles, a prototype swapped for another and for null, a key appearing and disappearing on the prototype, an own key deleted so the prototype shows through, eight shapes through one site, descriptors, accessors, a Proxy `has` trap that answers false for a key the target has, and a delete performed inside the hot loop. It matches Node under `PERRY_GC_FROMSPACE_SCAN_ABORT=1` with seed 37: 3,932 copying minors, 3,932 clean from-space scans, dangling=0, missing_rewrites=0. --- .../perry-codegen/src/expr/in_presence_ic.rs | 130 ++++++++++++ .../src/expr/logical_collections.rs | 12 ++ crates/perry-codegen/src/expr/mod.rs | 1 + .../src/runtime_decls/strings.rs | 1 + .../perry-runtime/src/object/field_get_set.rs | 2 + .../object/field_get_set/has_property_ic.rs | 194 ++++++++++++++++++ .../test_gap_in_operator_presence_cache.ts | 185 +++++++++++++++++ 7 files changed, 525 insertions(+) create mode 100644 crates/perry-codegen/src/expr/in_presence_ic.rs create mode 100644 crates/perry-runtime/src/object/field_get_set/has_property_ic.rs create mode 100644 test-files/test_gap_in_operator_presence_cache.ts diff --git a/crates/perry-codegen/src/expr/in_presence_ic.rs b/crates/perry-codegen/src/expr/in_presence_ic.rs new file mode 100644 index 0000000000..0e2beb523f --- /dev/null +++ b/crates/perry-codegen/src/expr/in_presence_ic.rs @@ -0,0 +1,130 @@ +//! Presence inline cache for `"k" in o` with a constant key. +//! +//! `in` was the last common operator lowering with no cache slot at all: every +//! `"k" in o` called `js_in_operator`, which re-derived the receiver's keys +//! array from its ShapeId (a shape-slab probe) and re-scanned it — ~950 +//! instructions for a hit on a plain object, against ~15 for a property read +//! of the same key through the property PIC. +//! +//! The answer is a property of the shape, not of the object, so the site +//! caches one ShapeId and the guard below answers `true` when the receiver +//! still carries it. See `perry-runtime`'s `has_property_ic` module for what +//! the cached claim means, why only positives are cached (a negative is a +//! claim about the whole prototype chain, and there is no chain epoch to key +//! one on), and why every way of losing the key moves the receiver off this +//! guard. +//! +//! The inline path can only produce `true`. Anything the guard cannot settle — +//! a primitive receiver (which must still throw), a non-object heap value, a +//! descriptor-bearing or tombstoned object, a different shape, an unprimed +//! site — takes the same `js_in_operator` semantics through the priming entry. + +use crate::nanbox::{POINTER_MASK_I64, POINTER_TAG_TOP16_I64, TAG_TRUE_I64}; +use crate::types::{DOUBLE, I1, I16, I32, I64, I8, PTR}; + +use super::FnCtx; + +/// Runtime `GC_TYPE_OBJECT`. +const GC_TYPE_OBJECT: &str = "2"; +/// Runtime `GC_FLAG_FORWARDED` (0x80). +const GC_FLAG_FORWARDED: &str = "128"; +/// `OBJ_FLAG_STABLE_TOMBSTONES | OBJ_FLAG_HAS_DESCRIPTORS` (0x400 | 0x800). +/// +/// Both are read from the `_reserved` half-word the other inline guards +/// already load. A tombstoned receiver keeps its ShapeId across a `delete` +/// (#9064), so rejecting the bit is what makes the cached positive safe; a +/// descriptor-bearing one answers `in` from the accessor side table, which the +/// shape does not describe. +const IN_PIC_BLOCKING_FLAGS: &str = "3072"; + +/// Emit the presence guard for one `"k" in o` site, returning the NaN-boxed +/// result. `key_box` is a constant string: the cache records a ShapeId only, +/// so the key it stands for must be fixed at this site. +pub(crate) fn lower_in_presence_ic(ctx: &mut FnCtx<'_>, obj_box: &str, key_box: &str) -> String { + let site_id = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let cache_name = super::inline_cache_global_name(ctx, site_id); + ctx.ic_globals.push(cache_name.clone()); + + let guard_idx = ctx.new_block("in.pic.guard"); + let hit_idx = ctx.new_block("in.pic.hit"); + let miss_idx = ctx.new_block("in.pic.miss"); + let merge_idx = ctx.new_block("in.pic.merge"); + let guard_label = ctx.block_label(guard_idx); + let hit_label = ctx.block_label(hit_idx); + let miss_label = ctx.block_label(miss_idx); + let merge_label = ctx.block_label(merge_idx); + + // #9708: the cache sits behind a pointer slot that stays null until the + // site's first prime. The guard block reads word 0 through the loaded + // pointer, so the non-null test joins the receiver predicate here rather + // than costing a branch of its own. + let ic_slot = super::emit_inline_cache_slot(ctx, &cache_name); + let cache_ref = ic_slot.cache.clone(); + let cache_slot_ref = ic_slot.slot_ref.clone(); + + // Branch before the first header load: a primitive, a forged non-pointer + // bit pattern and a handle-band id must never be dereferenced here. They + // all take the miss, where `js_in_operator`'s own classification decides + // between an answer and the TypeError ECMA-262 13.10.1 step 5 requires. + let obj_bits = ctx.block().bitcast_double_to_i64(obj_box); + let obj_raw = ctx.block().and(I64, &obj_bits, POINTER_MASK_I64); + let obj_tag = ctx.block().lshr(I64, &obj_bits, "48"); + let is_pointer = ctx.block().icmp_eq(I64, &obj_tag, POINTER_TAG_TOP16_I64); + let above_handles = ctx.block().icmp_ugt(I64, &obj_raw, "1048575"); + let eligible = ctx.block().and(I1, &is_pointer, &above_handles); + let eligible = ctx.block().and(I1, &eligible, &ic_slot.present); + ctx.block().cond_br(&eligible, &guard_label, &miss_label); + + ctx.current_block = guard_idx; + let gc_type_addr = ctx.block().sub(I64, &obj_raw, "8"); + let gc_type_ptr = ctx.block().inttoptr(I64, &gc_type_addr); + let gc_type = ctx.block().load(I8, &gc_type_ptr); + let is_object = ctx.block().icmp_eq(I8, &gc_type, GC_TYPE_OBJECT); + let gc_flags_addr = ctx.block().sub(I64, &obj_raw, "7"); + let gc_flags_ptr = ctx.block().inttoptr(I64, &gc_flags_addr); + let gc_flags = ctx.block().load(I8, &gc_flags_ptr); + let forwarded = ctx.block().and(I8, &gc_flags, GC_FLAG_FORWARDED); + let not_forwarded = ctx.block().icmp_eq(I8, &forwarded, "0"); + let reserved_addr = ctx.block().sub(I64, &obj_raw, "6"); + let reserved_ptr = ctx.block().inttoptr(I64, &reserved_addr); + let reserved = ctx.block().load(I16, &reserved_ptr); + let blocked = ctx.block().and(I16, &reserved, IN_PIC_BLOCKING_FLAGS); + let ordinary = ctx.block().icmp_eq(I16, &blocked, "0"); + // ObjectHeader offset 4: the runtime ShapeId once the object is stamped, + // else its `parent_class_id`. ShapeIds occupy 0x8000_0000..0xC000_0000, + // disjoint from every class id, and are never reused — so an armed word + // can only be matched by an object carrying that exact shape, and a stale + // one can only miss. + let shape_addr = ctx.block().add(I64, &obj_raw, "4"); + let shape_ptr = ctx.block().inttoptr(I64, &shape_addr); + let shape_id = ctx.block().load(I32, &shape_ptr); + let shape_word = ctx.block().zext(I32, &shape_id, I64); + let cached_shape_ptr = ctx.block().gep(I64, &cache_ref, &[(I64, "0")]); + let cached_shape = ctx.block().load(I64, &cached_shape_ptr); + // An unarmed cache reads 0, which no stamped shape word can equal, so the + // "is this site armed?" question needs no test of its own. + let shape_matches = ctx.block().icmp_eq(I64, &shape_word, &cached_shape); + let present = ctx.block().and(I1, &is_object, ¬_forwarded); + let present = ctx.block().and(I1, &present, &ordinary); + let present = ctx.block().and(I1, &present, &shape_matches); + ctx.block().cond_br(&present, &hit_label, &miss_label); + + ctx.current_block = hit_idx; + let hit_value = ctx.block().bitcast_i64_to_double(TAG_TRUE_I64); + let hit_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = miss_idx; + let miss_value = ctx.block().call( + DOUBLE, + "js_in_operator_presence_ic", + &[(DOUBLE, obj_box), (DOUBLE, key_box), (PTR, &cache_slot_ref)], + ); + let miss_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(DOUBLE, &[(&hit_value, &hit_end), (&miss_value, &miss_end)]) +} diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index f35a5f0e43..d5777e3d26 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -1212,7 +1212,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // first enforces ECMA-262 13.10.1 step 5: a non-Object right operand // (`"x" in 5`, `... in null`, `... in Symbol()`, …) throws a TypeError. Expr::In { property, object } => { + // A literal key gets a presence inline cache (`in_presence_ic`). + // The cache records a ShapeId and nothing else, so the key it + // stands for has to be fixed at the site: a dynamic key keeps the + // bare call. `full_outline_ic_enabled` is the size mode that asks + // every IC to stay out of the emitted function. + let constant_key = matches!(&**property, Expr::String(_)) + && !crate::codegen::full_outline_ic_enabled(); rooting::with_operands_rooted(ctx, &[property, object], |ctx, vals| { + if constant_key { + return Ok(super::in_presence_ic::lower_in_presence_ic( + ctx, &vals[1], &vals[0], + )); + } Ok(ctx.block().call( DOUBLE, "js_in_operator", diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index c695f407c5..778f93e792 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -47,6 +47,7 @@ mod class_method_arguments_object_tests; mod conforming_layout_note_tests; mod helpers; mod i32_fast_path; +mod in_presence_ic; mod index; mod nanbox_inline; mod native_memory; diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index be02718575..a73cd55079 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -455,6 +455,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_map_from_iterable", I64, &[DOUBLE]); module.declare_function("js_object_has_property", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_in_operator", DOUBLE, &[DOUBLE, DOUBLE]); + module.declare_function("js_in_operator_presence_ic", DOUBLE, &[DOUBLE, DOUBLE, PTR]); module.declare_function( "js_private_brand_check", DOUBLE, diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 9ff187b390..f6bbc8b8a7 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -213,6 +213,7 @@ mod get_field_by_name_async; mod get_field_by_name_probe_tests; mod get_field_by_name_tail; mod has_property; +mod has_property_ic; mod ic_miss; #[cfg(test)] #[path = "field_get_set/ic_miss_array_length_tests.rs"] @@ -291,6 +292,7 @@ pub(crate) use has_property::{ wide_key_index_lookup, wide_key_index_note_hit, WIDE_KEY_INDEX_MIN_KEYS, }; pub use has_property::{js_in_operator, js_object_has_property}; +pub use has_property_ic::js_in_operator_presence_ic; pub(crate) use ic_miss::{ bind_primitive_proto_method_static, cannot_be_private_member_name, current_private_lexical_brand_value, is_array_method_value_name, diff --git a/crates/perry-runtime/src/object/field_get_set/has_property_ic.rs b/crates/perry-runtime/src/object/field_get_set/has_property_ic.rs new file mode 100644 index 0000000000..7451702d33 --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/has_property_ic.rs @@ -0,0 +1,194 @@ +//! Presence inline cache for `"k" in o` with a **constant** key. +//! +//! `in` has no cache slot today: every `"k" in o` is a bare `js_in_operator` +//! call that re-derives the receiver's keys array from its ShapeId (a shape +//! slab probe) and re-scans it, ~950 instructions for a hit on a plain object. +//! The answer it recomputes is a property of the *shape*, not of the object: +//! two objects with the same ShapeId have the same keys array, so "shape S has +//! own key K" is stable for as long as S is stamped on the receiver. +//! +//! So the site caches exactly that — one ShapeId — and the emitted guard +//! answers `true` when the receiver still carries it. Everything else calls +//! [`js_in_operator_presence_ic`], which computes the real answer and may arm +//! the site. +//! +//! # Why only positives, and why no prototype epoch is needed +//! +//! The cached claim is about an **own** key, so it does not mention the +//! prototype chain: `Object.setPrototypeOf`, a late `Proto.x = 1`, a +//! `delete Proto.x` — none of them can make an own key stop existing, so none +//! of them can invalidate a positive. A *negative* would be a claim about the +//! whole chain, and there is no prototype-chain epoch in this runtime to key +//! one on (`prototype_chain` records per-object replacements; nothing counts +//! chain mutations globally), so negatives are not cached at all — `"zz" in o` +//! keeps calling the runtime every time. +//! +//! # What invalidates a positive +//! +//! Only losing the key, and every way of losing it moves the receiver off the +//! cached ShapeId or off the guard: +//! +//! * A compacting `delete` rebuilds the keys array and publishes a new +//! ShapeId — the stamp at header offset 4 no longer matches. +//! * A tombstoning `delete` (#9064) deliberately KEEPS the ShapeId and leaves +//! `TAG_HOLE` in the slot, but it sets `OBJ_FLAG_STABLE_TOMBSTONES` on the +//! receiver in the same step; the emitted guard rejects that bit, so such a +//! receiver never takes the inline answer again. +//! * Adding a key is a transition to a different ShapeId, which can only turn +//! a hit into a miss (`true` stays `true` for the cached key anyway). +//! * A moved receiver reads its own header, so evacuation is invisible here; +//! a forwarded one is rejected by the guard's `GC_FLAG_FORWARDED` test. +//! +//! ShapeIds are allocated monotonically from a process-global counter and are +//! never reused (`shapes::SHAPE_ID_NEXT`), so a stale stamp can only miss. The +//! id range (`0x8000_0000..0xC000_0000`) is disjoint from every class id, so +//! the guard's compare against a header word that still holds +//! `parent_class_id` — an object that was never shape-stamped — cannot alias +//! an armed id either. +//! +//! The cache holds two integers and never a pointer, so it is not a GC root +//! (contrast the caches enumerated by `scripts/gc_runtime_root_holders.py`). + +use super::*; + +/// The words the emitted site reads. Word 0 is the only one the inline guard +/// touches; the arena hands out zeroed memory, and `shape == 0` is "unarmed". +#[repr(C)] +pub struct InPresenceCache { + /// Armed ShapeId, widened to the guard's compare width. 0 until the site + /// primes. + shape: u64, + /// Arming attempts spent at this site. Bounds the work a site that can + /// never arm (an inherited hit, a proxy receiver) or one that thrashes + /// between shapes pays on every call. + attempts: u64, +} + +/// Arming attempts a site gets before it stops trying. A monomorphic own-key +/// site spends exactly one; a site whose `true` comes from the prototype chain +/// spends this many and then costs one load and one compare per call forever. +const IN_PRESENCE_ATTEMPT_BUDGET: u64 = 8; + +/// `"k" in o` for a site that owns a presence-cache slot. +/// +/// Answers exactly what [`js_in_operator`] answers — including its TypeError +/// on a non-object right operand — and then, for a `true` that came from an +/// own string key on an ordinary receiver, records the receiver's ShapeId so +/// the site's inline guard can answer the next one without a call. +/// +/// # Safety +/// `slot` is the address of the site's `@perry_ic_N` global (or null): a live, +/// pointer-sized location holding null or a cache from the IC arena. +#[no_mangle] +pub unsafe extern "C" fn js_in_operator_presence_ic( + obj: f64, + key: f64, + slot: *mut *mut InPresenceCache, +) -> f64 { + const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; + let answer = js_in_operator(obj, key); + if answer.to_bits() != TAG_TRUE { + // Absent, or a `false` from a trap: nothing positive to record, and a + // negative is not cacheable (see the module header). + return answer; + } + let cache = crate::object::pic_slot_resolve(slot); + if cache.is_null() { + return answer; + } + if (*cache).attempts >= IN_PRESENCE_ATTEMPT_BUDGET { + return answer; + } + (*cache).attempts += 1; + if let Some(shape) = armable_own_key_shape(obj, key) { + (*cache).shape = u64::from(shape); + } + answer +} + +/// The receiver's ShapeId, when a site may answer `true` for `key` from it +/// alone: an ordinary, shape-stamped, descriptor-free, tombstone-free heap +/// object whose keys array holds `key` as an own entry. +/// +/// Every rejection here is a receiver whose `in` answer is decided by +/// something the ShapeId does not capture — a proxy trap, a native-module +/// dispatch table, a RegExp expando, an accessor side table, an internal +/// runtime key, or a hole left by a tombstoning delete. +unsafe fn armable_own_key_shape(obj: f64, key: f64) -> Option { + let obj_val = JSValue::from_bits(obj.to_bits()); + let key_val = JSValue::from_bits(key.to_bits()); + if !obj_val.is_pointer() || !key_val.is_any_string() { + return None; + } + let addr = (obj_val.bits() & crate::value::POINTER_MASK) as usize; + let header = crate::value::addr_class::try_read_gc_header(addr)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + || header._reserved + & (crate::gc::OBJ_FLAG_HAS_DESCRIPTORS | crate::gc::OBJ_FLAG_STABLE_TOMBSTONES) + != 0 + { + return None; + } + // RegExp cells are OBJECT-typed but answer `in` through the exotic expando + // registry, exactly as `js_object_has_property`'s own fast path documents. + if super::super::exotic_expando::exotic_expando_kind(addr).is_some() { + return None; + } + let obj_ptr = addr as *const ObjectHeader; + // Native-module namespaces (console, fs, …) expose VIRTUAL keys that never + // live in `keys_array`, and their answer is the vtable's, not the shape's. + if (*obj_ptr).class_id == NATIVE_MODULE_CLASS_ID { + return None; + } + let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let key_bytes = crate::string::js_string_key_bytes(key_val, &mut sso)?; + // A compiler-private storage key is invisible to [[HasProperty]] even + // though it sits in `keys_array`. + if super::is_internal_runtime_key_bytes(key_bytes) { + return None; + } + let shape = super::super::shapes::object_shape_stamp(obj_ptr); + if shape == 0 { + return None; + } + let keys = crate::object::object_keys_array(obj_ptr); + match crate::value::addr_class::try_read_gc_header(keys as usize) { + Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => {} + _ => return None, + } + let key_count = crate::array::js_array_length(keys); + super::super::keys_lookup::keys_find_slot_by_bytes(keys, key_count, key_bytes)?; + Some(shape) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The emitted guard compares a zero-extended `i32` header word against + /// word 0, so an armed id must be unrepresentable as a class id — else an + /// object that was never shape-stamped could alias one. + #[test] + fn armed_ids_cannot_alias_a_class_id() { + assert!(!super::super::super::shapes::is_shape_id(0)); + assert!(!super::super::super::shapes::is_shape_id(1)); + assert!(!super::super::super::shapes::is_shape_id(0x7FFF_FFFF)); + assert!(!super::super::super::shapes::is_shape_id(0xFFFF_0000)); + assert!(super::super::super::shapes::is_shape_id(0x8000_0000)); + } + + /// Word 0 is what the emitted guard loads; word 1 must not move under it. + #[test] + fn cache_layout_matches_the_emitted_guard() { + assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::offset_of!(InPresenceCache, shape), 0); + assert_eq!(std::mem::offset_of!(InPresenceCache, attempts), 8); + } + + /// A site that can never arm must stop paying for the attempt. + #[test] + fn the_attempt_budget_is_small_and_nonzero() { + assert!(IN_PRESENCE_ATTEMPT_BUDGET > 0 && IN_PRESENCE_ATTEMPT_BUDGET <= 16); + } +} diff --git a/test-files/test_gap_in_operator_presence_cache.ts b/test-files/test_gap_in_operator_presence_cache.ts new file mode 100644 index 0000000000..3b60def830 --- /dev/null +++ b/test-files/test_gap_in_operator_presence_cache.ts @@ -0,0 +1,185 @@ +// A warm `"k" in o` site caches "this shape has own key k". Every way of +// losing the key — a compacting delete, a tombstoning delete, a re-add, a +// shape change — must be visible at the very next evaluation of the SAME +// site, and a prototype mutation must be visible even though the cached claim +// is only about own keys. Each helper below is one call site, deliberately +// reused so the invalidation is proven against a cache that is already warm. + +function hasA(o: any): boolean { + return "a" in o; +} +function hasP(o: any): boolean { + return "p" in o; +} +function hasToString(o: any): boolean { + return "toString" in o; +} + +// ---- warm, then delete through the same site ------------------------------- +const warm: any = { a: 1, b: 2 }; +let seen = 0; +for (let i = 0; i < 2000; i++) if (hasA(warm)) seen++; +console.log("warm", seen, hasA(warm)); +delete warm.a; +console.log("deleted", hasA(warm), "a" in warm, warm.a); +warm.a = 9; +console.log("re-added", hasA(warm), warm.a); + +// ---- delete and re-add repeatedly through the warm site --------------------- +const churn: any = { a: 1, b: 2, c: 3 }; +let churnTrue = 0; +let churnFalse = 0; +for (let i = 0; i < 500; i++) { + if (hasA(churn)) churnTrue++; + delete churn.a; + if (hasA(churn)) churnFalse++; + churn.a = i; +} +console.log("churn", churnTrue, churnFalse, hasA(churn)); + +// ---- a warm site, then the prototype moves under the receiver --------------- +const protoA = { p: 1 }; +const protoB = { q: 2 }; +const moving: any = Object.create(protoA); +moving.own = 1; +let protoSeen = 0; +for (let i = 0; i < 2000; i++) if (hasP(moving)) protoSeen++; +console.log("proto-warm", protoSeen, hasP(moving)); +Object.setPrototypeOf(moving, protoB); +console.log("proto-swapped", hasP(moving), "q" in moving); +Object.setPrototypeOf(moving, null); +console.log("proto-null", hasP(moving), hasToString(moving), "own" in moving); +Object.setPrototypeOf(moving, protoA); +console.log("proto-restored", hasP(moving)); + +// ---- the key appears and disappears ON the prototype after a warm hit ------- +const lateProto: any = {}; +const lateChild: any = Object.create(lateProto); +lateChild.own = 1; +let lateSeen = 0; +for (let i = 0; i < 2000; i++) if (hasP(lateChild)) lateSeen++; +console.log("late-before", lateSeen, hasP(lateChild)); +lateProto.p = 7; +console.log("late-added", hasP(lateChild)); +delete lateProto.p; +console.log("late-removed", hasP(lateChild)); + +// ---- an own key that shadows, then is deleted so the proto shows through ---- +const shadowProto: any = { a: "proto" }; +const shadow: any = Object.create(shadowProto); +shadow.a = "own"; +let shadowSeen = 0; +for (let i = 0; i < 2000; i++) if (hasA(shadow)) shadowSeen++; +console.log("shadow-warm", shadowSeen, shadow.a); +delete shadow.a; +console.log("shadow-deleted", hasA(shadow), shadow.a); +delete shadowProto.a; +console.log("shadow-proto-deleted", hasA(shadow), shadow.a); + +// ---- one site, many shapes ------------------------------------------------- +const shapes: any[] = [ + { a: 1 }, + { a: 1, b: 2 }, + { b: 2 }, + { x: 0, y: 0, a: 3 }, + Object.create({ a: "inherited" }), + { get a() { + return 1; + } }, + Object.freeze({ a: 1 }), + Object.seal({ a: 1, z: 2 }), +]; +let polyCount = 0; +for (let i = 0; i < 2000; i++) if (hasA(shapes[i % shapes.length])) polyCount++; +console.log("poly", polyCount, shapes.map((s) => hasA(s)).join(",")); + +// ---- descriptors and non-enumerables --------------------------------------- +const desc: any = {}; +Object.defineProperty(desc, "a", { value: 1, enumerable: false, configurable: true }); +let descSeen = 0; +for (let i = 0; i < 1000; i++) if (hasA(desc)) descSeen++; +console.log("descriptor", descSeen, hasA(desc)); +delete desc.a; +console.log("descriptor-deleted", hasA(desc)); + +const accessor: any = {}; +Object.defineProperty(accessor, "a", { get: () => 1, configurable: true }); +let accSeen = 0; +for (let i = 0; i < 1000; i++) if (hasA(accessor)) accSeen++; +console.log("accessor", accSeen, hasA(accessor)); +delete accessor.a; +console.log("accessor-deleted", hasA(accessor)); + +// ---- a class instance, and a method that is not an own key ----------------- +class Point { + a = 1; + b = 2; + moveIt(): number { + return this.a; + } +} +const pt: any = new Point(); +let ptSeen = 0; +for (let i = 0; i < 2000; i++) if (hasA(pt)) ptSeen++; +console.log("class", ptSeen, hasA(pt), "moveIt" in pt, "nope" in pt); +delete pt.a; +console.log("class-deleted", hasA(pt), "b" in pt); + +// ---- a wide object, across the keys-index threshold ------------------------- +const wide: any = {}; +for (let i = 0; i < 40; i++) wide["k" + i] = i; +wide.a = "wide"; +let wideSeen = 0; +for (let i = 0; i < 2000; i++) if (hasA(wide)) wideSeen++; +console.log("wide", wideSeen, hasA(wide), "k39" in wide, "k40" in wide); +delete wide.a; +console.log("wide-deleted", hasA(wide), "k39" in wide); + +// ---- non-object receivers through the same warm site ----------------------- +const proxy: any = new Proxy({ a: 1 }, { + has(t, k) { + return k === "a" ? false : k in t; + }, +}); +let proxySeen = 0; +for (let i = 0; i < 1000; i++) if (hasA(proxy)) proxySeen++; +console.log("proxy", proxySeen, hasA(proxy), "b" in proxy); + +const arr: any = [1, 2, 3]; +(arr as any).a = 1; +console.log("array", hasA(arr), "0" in arr, "3" in arr, "length" in arr); +console.log("builtins", hasA(new Map()), hasA(new Set()), hasA(/re/), "lastIndex" in /re/); +console.log("wrapper", hasA(new String("xy")), "0" in new String("xy")); + +// ---- a primitive right operand still throws -------------------------------- +let threw = ""; +try { + hasA(5 as any); +} catch (e) { + threw = (e as Error).constructor.name; +} +console.log("primitive", threw); +let threwNull = ""; +try { + hasA(null as any); +} catch (e) { + threwNull = (e as Error).constructor.name; +} +console.log("null", threwNull); + +// ---- hot loop where the delete happens INSIDE the loop ---------------------- +const inLoop: any = { a: 1, b: 2 }; +let hits = 0; +for (let i = 0; i < 1000; i++) { + if (hasA(inLoop)) hits++; + if (i === 500) delete inLoop.a; +} +console.log("in-loop", hits, hasA(inLoop)); + +// ---- and one where the object itself is replaced each iteration ------------- +let fresh = 0; +for (let i = 0; i < 1000; i++) { + const o: any = i % 2 === 0 ? { a: i } : { b: i }; + if (hasA(o)) fresh++; +} +console.log("fresh", fresh); From 84258c0aa44d016708a80d2e6a977bd1af36695a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 11:10:55 +0200 Subject: [PATCH 14/15] perf(runtime): latch the instanceof prototype-override escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `o instanceof C` answers a miss by falling through a ladder of built-in probes. Two steps into that ladder sat this pair: let candidate_proto = class_decl_prototype_object(cur); let target_proto = class_decl_prototype_object(class_id); if !candidate_proto.is_null() && !target_proto.is_null() && object_has_user_prototype_override(candidate_proto) && ... Both are class-registry reads — thread-local + RwLock + map — and both ran eagerly, on every call that reached the ladder, which is every MISS. They exist for one case: `util.inherits(Derived, Base)`, which re-points a prototype without creating an extends edge between the constructors. The question they set up to ask, `object_has_user_prototype_override`, is cheap: two dependent loads off the receiver's meta record. The expensive half was only there to find an object to ask it about. `OBJECT_META_FLAG_USER_PROTO_OVERRIDE` is set at exactly one site, so a process-wide latch stored just before it answers for every receiver at once. A program that never re-points a prototype — which is nearly all of them — now pays one acquire load instead of two registry probes. Set, never cleared, and published before the flag it guards (the discipline `OBJECT_PROTOTYPES_- NONEMPTY` above it already uses), so it is conservative in the safe direction: a false positive costs a probe pair, a false negative is impossible. Instructions per call, base v0.5.1579 vs this branch, both arms rebuilt with identical flags and measured in one window (3M iterations, best of 3, minus a zero-iteration run; control `idle` 11.0 -> 11.0): a instanceof B (miss) 669 -> 498 c3 instanceof B (miss, 4 deep) 1,062 -> 891 a instanceof A (hit) 74 -> 74 c3 instanceof A (hit, 4 deep) 270 -> 270 map/error/plain-object misses flat No class-id band test is involved, which is the better outcome: the "is this a user class id" question is not on the path at all. (For the record, it would have been sound — all 87 reserved class-id constants fall inside the two documented bands, 4 in 0x7FFF_FF00..=0x7FFF_FFFF and 83 at or above 0xFFFF_0000, while user ids are a dense sequence from 1.) test_gap_instanceof_miss_ladder.ts covers the latch's own hazard — a `setPrototypeOf` performed AFTER the sites are hot, and `util.inherits` with a method resolved through the linked prototype — plus `Symbol.hasInstance` in both its static-method and defineProperty forms, a Proxy, a bound constructor, Map/Error/Promise/Array/Function/Object, subclasses of Map and Error, a 4-level chain, structurally identical twins, null-prototype receivers, primitives, and a non-callable right operand. Matches Node under `PERRY_GC_FROMSPACE_SCAN_ABORT=1` with seed 37: 2,182 copying minors, 4,364 clean from-space scans, dangling=0, missing_rewrites=0. The fixture deliberately does not assert five behaviours it found to diverge from Node on this commit's PARENT; each carries a comment saying so, and they are reported separately rather than fixed here. --- crates/perry-runtime/src/object/instanceof.rs | 40 ++-- .../src/object/prototype_chain.rs | 31 ++++ test-files/test_gap_instanceof_miss_ladder.ts | 173 ++++++++++++++++++ 3 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 test-files/test_gap_instanceof_miss_ladder.ts diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index a688ed8f8e..6030dd9601 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -1122,19 +1122,33 @@ pub extern "C" fn js_instanceof(value: f64, class_id: u32) -> f64 { // prototype chain contains BaseClass.prototype. Only pay // for the spec prototype walk when the candidate class's // declaration prototype has a user-selected parent. - let candidate_proto = super::class_registry::class_decl_prototype_object(cur); - let target_proto = super::class_registry::class_decl_prototype_object(class_id); - if !candidate_proto.is_null() - && !target_proto.is_null() - && super::prototype_chain::object_has_user_prototype_override( - candidate_proto as usize, - ) - && ordinary_has_instance_prototype_walk( - value, - super::class_constructor_ref_value(class_id), - ) - { - return true_val; + // The two `class_decl_prototype_object` probes are class + // registry reads (TLS + RwLock + map, ~130 instructions + // each) and they ran EAGERLY on every call that got this + // far — which is every MISS, the path this whole ladder + // exists to answer `false` on. They exist only to ask a + // question whose answer is `false` for every receiver in a + // process that never re-points an object's prototype, and + // the latch answers that for the whole process in one + // load. Set, never cleared, and published before the flag + // it guards, so it can only ever be conservatively true. + if super::prototype_chain::any_user_prototype_override() { + let candidate_proto = + super::class_registry::class_decl_prototype_object(cur); + let target_proto = + super::class_registry::class_decl_prototype_object(class_id); + if !candidate_proto.is_null() + && !target_proto.is_null() + && super::prototype_chain::object_has_user_prototype_override( + candidate_proto as usize, + ) + && ordinary_has_instance_prototype_walk( + value, + super::class_constructor_ref_value(class_id), + ) + { + return true_val; + } } } } diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index d5469546d4..986570bb45 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -132,6 +132,34 @@ pub(crate) fn test_resolution_stack_enter_and_forget(owner: usize) -> bool { /// an object — the overwhelmingly common case. static OBJECT_PROTOTYPES_NONEMPTY: AtomicBool = AtomicBool::new(false); +/// Latched true by the first `OBJECT_META_FLAG_USER_PROTO_OVERRIDE` a receiver +/// is ever given — i.e. the first `Object.setPrototypeOf` / `util.inherits` +/// that re-points a live object's `[[Prototype]]` away from its class default. +/// +/// The flag lives on the receiver's meta record, so asking "does this object +/// have one?" costs two dependent loads — but only after the caller has +/// already found the object. `instanceof`'s `util.inherits` escape hatch has +/// to look up TWO class declaration prototypes through the class registry +/// before it can ask, and that pair of registry probes was the single largest +/// cost of a `o instanceof C` MISS (~130 instructions each, on a path whose +/// whole budget was 669). This latch answers for the entire process in one +/// relaxed-acquire load. +/// +/// Conservative by construction: it is set, never cleared, and it is stored +/// BEFORE the flag it guards (same discipline as [`OBJECT_PROTOTYPES_NONEMPTY`] +/// above), so any reader that could observe the flag already observes the +/// latch. A false positive costs a probe pair; a false negative is impossible. +static USER_PROTO_OVERRIDE_EVER: AtomicBool = AtomicBool::new(false); + +/// Has any object in this process ever been given a user `[[Prototype]]` +/// override? A `false` proves `object_has_user_prototype_override` would +/// answer `false` for every receiver, so a caller may skip whatever work it +/// would need to do to ask. +#[inline] +pub(crate) fn any_user_prototype_override() -> bool { + USER_PROTO_OVERRIDE_EVER.load(Ordering::Acquire) +} + fn get_object_prototypes() -> &'static Mutex> { OBJECT_PROTOTYPES.get_or_init(|| Mutex::new(HashMap::new())) } @@ -258,6 +286,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: (*meta).flags |= crate::object::OBJECT_META_FLAG_PROTO_DIVERGED; } if user_override { + // Latch BEFORE the flag: a reader that observes the flag must + // already observe the latch (see `USER_PROTO_OVERRIDE_EVER`). + USER_PROTO_OVERRIDE_EVER.store(true, Ordering::Release); (*meta).flags |= crate::object::OBJECT_META_FLAG_USER_PROTO_OVERRIDE; } if link_kind == PrototypeLinkKind::ClassEvaluation { diff --git a/test-files/test_gap_instanceof_miss_ladder.ts b/test-files/test_gap_instanceof_miss_ladder.ts new file mode 100644 index 0000000000..275f86fe60 --- /dev/null +++ b/test-files/test_gap_instanceof_miss_ladder.ts @@ -0,0 +1,173 @@ +// `instanceof` answers a miss by falling through a ladder of built-in probes. +// Two of those steps are class-registry reads that exist only for the +// `util.inherits` / `Object.setPrototypeOf` case, and they are now behind a +// process-wide latch. The latch is set the first time any object is given a +// user [[Prototype]] override, so the case that must be proven is a program +// that runs `instanceof` HOT first and re-points a prototype afterwards: the +// answer has to change at the next evaluation of the same site. + +import { inherits } from "node:util"; + +class A { + x = 1; +} +class B { + y = 2; +} +class C1 extends A { + z = 3; +} +class C2 extends C1 { + w = 4; +} +class C3 extends C2 { + v = 5; +} + +function isA(o: any): boolean { + return o instanceof A; +} +function isB(o: any): boolean { + return o instanceof B; +} + +const a: any = new A(); +const c3: any = new C3(); + +// ---- warm the miss and the hit through one site each ------------------------ +let hits = 0; +let misses = 0; +for (let i = 0; i < 2000; i++) { + if (isA(a)) hits++; + if (isB(a)) misses++; +} +console.log("warm", hits, misses); +console.log("chain", isA(c3), isB(c3), c3 instanceof C1, c3 instanceof C2, c3 instanceof C3); + +// ---- the latch case: re-point a prototype AFTER the sites are hot ----------- +function Base(this: any) {} +(Base as any).prototype.hello = function () { + return "hi"; +}; +function Derived(this: any) {} +inherits(Derived as any, Base as any); +const d: any = new (Derived as any)(); +console.log("inherits", d instanceof (Derived as any), d instanceof (Base as any)); +console.log("inherits-method", typeof d.hello, d.hello()); + +const late: any = new A(); +console.log("late-before", isA(late), isB(late)); +Object.setPrototypeOf(late, B.prototype); +// Only the POSITIVE half is asserted. `isA(late)` after the swap is a +// separate, pre-existing divergence — Perry answers true where Node answers +// false, because the class-id chain walk matches on the instance's original +// class id before the recorded prototype is ever consulted, and it does so on +// this commit's parent as well. Acquiring the new brand is what this fixture +// is here to prove, and that part matches. +console.log("late-after-b", isB(late)); +Object.setPrototypeOf(late, A.prototype); +console.log("late-restored", isA(late), isB(late)); +// The hot sites must agree with a fresh evaluation. +let lateHits = 0; +for (let i = 0; i < 2000; i++) if (isA(late)) lateHits++; +console.log("late-rewarm", lateHits, late instanceof A, late instanceof B); + +// ---- Symbol.hasInstance on a user class, positive and negative -------------- +class Even { + static [Symbol.hasInstance](v: any): boolean { + return typeof v === "number" && v % 2 === 0; + } +} +console.log("hasInstance", 4 instanceof Even, 5 instanceof Even, ({} as any) instanceof Even); +let evenCount = 0; +for (let i = 0; i < 2000; i++) if (i instanceof Even) evenCount++; +console.log("hasInstance-hot", evenCount); + +// The defineProperty form (zod 4's shape). +class Tagged {} +Object.defineProperty(Tagged, Symbol.hasInstance, { + value: (v: any) => v !== null && typeof v === "object" && "tag" in v, +}); +console.log("hasInstance-defineProperty", { tag: 1 } instanceof Tagged, {} instanceof Tagged); + +// ---- a Proxy, including a getPrototypeOf trap ------------------------------ +const plainProxy: any = new Proxy(new A(), {}); +console.log("proxy-plain", plainProxy instanceof A, plainProxy instanceof B); +// A `getPrototypeOf` trap is NOT asserted: Perry unwraps the proxy to its +// target and walks the target's class chain, so it answers `true false` where +// Node answers `false true` — pre-existing on this commit's parent, and a +// different subsystem from this ladder. A Proxy on the RIGHT of `instanceof` +// is not exercised at all: it SIGSEGVs on the parent commit (see this +// fixture's companion note in the wave report), so a fixture that used one +// could never be green enough to detect a regression here. +const trapped: any = new Proxy(new A(), { + getPrototypeOf() { + return B.prototype; + }, +}); +console.log("proxy-trap-runs", typeof trapped, Object.getPrototypeOf(trapped) === B.prototype); +const callableProxy: any = new Proxy(A, {}); +console.log("proxy-construct", new callableProxy() instanceof A); + +// ---- a bound constructor --------------------------------------------------- +// Only the middle case is asserted. `a instanceof A.bind(null)` is `true` in +// Node (a bound function's [[HasInstance]] delegates to its target) and +// `false` in Perry, on this commit's parent too — a bound-function gap, not a +// ladder one. +const BoundA: any = A.bind(null); +console.log("bound-construct", new BoundA() instanceof A); + +// ---- built-ins, positive and miss ------------------------------------------ +const m = new Map(); +const e = new Error("x"); +const p = Promise.resolve(1); +const arr = [1, 2]; +console.log("map", m instanceof Map, e instanceof Map, a instanceof Map); +console.log("error", e instanceof Error, m instanceof Error, a instanceof Error); +console.log("promise", p instanceof Promise, m instanceof Promise); +console.log("array", arr instanceof Array, m instanceof Array, arr instanceof Object); +console.log("function", isA instanceof Function, A instanceof Function, a instanceof Function); +console.log("object", a instanceof Object, m instanceof Object, e instanceof Object); + +// ---- a subclass of a built-in ---------------------------------------------- +class MyMap extends Map {} +const mm: any = new MyMap(); +console.log("submap", mm instanceof MyMap, mm instanceof Map, m instanceof MyMap); +class MyErr extends Error {} +const me: any = new MyErr("y"); +console.log("suberr", me instanceof MyErr, me instanceof Error, e instanceof MyErr); + +// ---- the closest thing to cross-realm that is expressible here -------------- +// Two structurally identical classes are still distinct brands. +class Twin1 { + k = 1; +} +class Twin2 { + k = 1; +} +console.log("twins", new Twin1() instanceof Twin1, new Twin1() instanceof Twin2); +// An object whose prototype is a plain object literal is no class's instance. +const bare: any = Object.create({ k: 1 }); +console.log("bare", bare instanceof A, bare instanceof Object, bare instanceof Twin1); +// `Object.create(null) instanceof Object` is `false` in Node and `true` in +// Perry on this commit's parent — the null-prototype receiver reaches the +// Object arm anyway. Not asserted; recorded so the next reader knows it was +// looked at rather than missed. +console.log("nullproto-proto", Object.getPrototypeOf(Object.create(null))); + +// ---- primitives and non-objects on the left -------------------------------- +console.log("prims", 1 instanceof A, "s" instanceof A, null instanceof A, undefined instanceof A); + +// ---- a non-callable right operand still throws ------------------------------ +let threw = ""; +try { + console.log(({} as any) instanceof ({} as any)); +} catch (err) { + threw = (err as Error).constructor.name; +} +console.log("noncallable", threw); + +// ---- a hot miss loop, the shape the ladder is paid on ----------------------- +let missCount = 0; +for (let i = 0; i < 5000; i++) if (!isB(c3)) missCount++; +console.log("hot-miss", missCount); From c54f34ae198a7e18344093c011fe319073fdc618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 16 Sep 2026 14:35:01 +0200 Subject: [PATCH 15/15] docs: changelog fragment for #10378 --- .../10378-hit-path-instruction-wave2.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 changelog.d/10378-hit-path-instruction-wave2.md diff --git a/changelog.d/10378-hit-path-instruction-wave2.md b/changelog.d/10378-hit-path-instruction-wave2.md new file mode 100644 index 0000000000..3de764a7b4 --- /dev/null +++ b/changelog.d/10378-hit-path-instruction-wave2.md @@ -0,0 +1,54 @@ +Second wave of the executed-instruction campaign (after #10295), aimed at the +three areas that audit deferred: parameter guards, per-element array work, and +key/string lookups. Across a 97-probe set called through a dynamic namespace +lookup, the summed per-call cost falls 20,308 -> 16,996 (-16.3%) with no probe +regressing beyond the noise floor. + +A constant-key `in` now caches a presence answer on the receiver's ShapeId: +955 -> 30 instructions. Only positive answers about an OWN key are cached, +because that is the only claim no prototype mutation can falsify -- a negative +would be a statement about the whole chain, and there is no epoch to key one +on. Every way of losing the key either moves the ShapeId or raises +OBJ_FLAG_STABLE_TOMBSTONES, which the guard rejects. + +Parameter guards stop walking descriptors nothing consumes. A clone consumes +one fact per parameter -- the declared type -- and never the descriptor's +field nodes, so an all-number class parameter is proved nominally from the +class id plus the typed-layout-intact bit, and the rule that let a loop in the +body license an unbounded per-element walk is gone. A 1,600-element `Pt[]` +parameter costs 1,146,528 instructions per call before and 21,553 after; a +`string[]` of the same length 213,233 -> 88,468; a class parameter with eight +number fields 3,194 -> 1,306. One non-`number` field on the chain puts the +whole chain back on the walk (control: 1,828 -> 1,844), because the intact bit +is a raw-f64 claim and says nothing about what a pointer slot holds. + +Rest bundles are built the way array literals are -- one inline bump +allocation and N stores instead of `js_array_alloc` plus a per-element +`js_array_push_f64` that re-classified the receiver every time: `f(1, 2, 3)` +909 -> 85, `f(o, o, o)` 1,655 -> 486, with bundles wider than 16 left on the +old path. `map` resolves its result header once per element rather than three +times, keeping the full protocol (canonicalize, retire the numeric claim, +layout note, remembered-set edge): `a.map(v => v + 1)` over 16 elements +6,068 -> 4,028. The packed loop stops re-deriving its element base per element +and its counter read shades no GC root, restricted to offset 0 because +`arr[i +/- c]` can leave the array and reach the prototype chain. + +Smaller runtime paths: an ASCII string index answers from the short-string +value instead of a four-call chain ending in a thread-local table (172 -> 123); +`[[HasProperty]]` resolves the recorded prototype only where it is read; +the concat chain formats number parts in place instead of building an +intermediate heap string; `instanceof`'s `util.inherits` escape hatch becomes +a process-wide latch instead of two registry probes per miss (miss 1,199 -> +1,081, hit unchanged); and a subclass `pop` flushes store plans only when it +actually retires a proof. + +One change carries no measured win and its commit message says so: skipping +the re-registration of an unchanged class parent edge removes a process-global +prop_plan epoch bump and a CLASS_REGISTRY write lock from the outlined +allocation entry, but every allocation loop that could be built takes the +inline allocator instead, which never calls register_class. + +Three spec divergences found while measuring are filed, not fixed: #10364 +(`instanceof` against a Proxy right-hand side segfaults), #10365 (four +divergences from the spec's prototype walk), #10366 (`in` does not reach +`Function.prototype`). All reproduce on unmodified main.