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. 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 c6d97a2c3c..1567fddc97 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()?); @@ -677,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 @@ -736,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()) @@ -758,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 { @@ -1361,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(), @@ -1379,7 +1389,6 @@ mod tests { 1, "walk_bound_test", ¶ms, - body, &[false], &[false], aliases, @@ -1401,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", @@ -1425,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" ); } @@ -1708,6 +1716,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-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/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/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/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 de64342d1c..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; @@ -64,7 +65,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, @@ -3027,6 +3030,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/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/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/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). 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/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/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/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/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.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/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/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/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/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/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_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, +); 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); 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); 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); 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")); 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); 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); 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]); 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)); 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); 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, +); 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);