diff --git a/changelog.d/7882-ctor-free-construction.md b/changelog.d/7882-ctor-free-construction.md new file mode 100644 index 0000000000..ca8827c5e9 --- /dev/null +++ b/changelog.d/7882-ctor-free-construction.md @@ -0,0 +1,42 @@ +### perf(codegen): construct field-only objects without calling a constructor + +An object literal with a closed shape is not lowered as a literal. HIR mints an +anon-shape class for it and rewrites the site to `new __AnonShape_(v, w)`, +and `lower_new` routes that — like every own-constructor class — through the +shared standalone `_constructor` symbol. So `{ v, w }` and +`class Node { constructor(v, w) { this.v = v; this.w = w } }` compile to the same +thing: a bump allocation whose header is a compile-time constant, followed by a +call into a symbol where `this` is an **opaque parameter**. + +Being opaque is the cost. Every `this.f = p` inside that symbol emits the full +class-field precheck — a volatile load of the policy latch, seven header loads, +nine compares and a two-block diamond — per field, per object. And every one of +those conditions is a constant the *caller* wrote three instructions earlier: +`typed_layout_baked` (#7834) certifies `GC_TYPE_OBJECT`, not-forwarded, +`OBJECT_TYPE_REGULAR`, the class id, the field count, the keys-array pointer, no +per-object descriptors, not-frozen, and `GC_OBJ_TYPED_LAYOUT_INTACT` — all +stamped into the packed header the inline bump allocator emits. + +So for a class whose entire constructor is a run of `this. = ` +stores, the call is avoidable: store the fields at the `new` site and skip it. +Two things are still decided at runtime, but **once for the construction instead +of once per field** — the sticky `PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED` latch, +and whether every value is a plain finite number. A single non-number sends the +whole construction to the unchanged constructor call, so no field is ever stored +before the decision is made. + +The bits stored are identical to what the boxed path would write: a JS number's +NaN box *is* its double bits, and the finite test rejects every NaN-box tag +(INT32-boxed integers included, since they share the all-ones exponent). That is +also why no `js_array_numeric_value_to_raw_f64` canonicalization is needed — the +only inputs that helper rewrites are exactly the ones the finite test rejects. + +Deliberately narrow, and every refusal is a thing the constructor symbol does +that this path does not reproduce: any heritage, any accessor, decorators, +computed members, initialized/private/computed-key fields, non-plain parameters, +an argument count that is not exactly the parameter count (a capture-carrying +constructor appends `__perry_cap_*` arguments), or a body that is anything other +than the full run covering every declared field exactly once. Partial coverage +would leave a declared raw-f64 slot holding the allocator's `undefined` fill +under an INTACT header, which is precisely the state +`layout_pointer_free_at_allocation` exists to prevent. diff --git a/crates/perry-codegen/src/lower_call/ctor_prologue_store_tests.rs b/crates/perry-codegen/src/lower_call/ctor_prologue_store_tests.rs new file mode 100644 index 0000000000..9ed1b2373d --- /dev/null +++ b/crates/perry-codegen/src/lower_call/ctor_prologue_store_tests.rs @@ -0,0 +1,76 @@ +//! Constructor-free construction — IR-census tests for +//! [`super::ctor_prologue_stores`]. +//! +//! These are the "assert the subject was live" kind (CLAUDE.md). The change is +//! invisible to every behavioural test: a program whose predicate silently +//! answers `false` everywhere still compiles, still prints the right answer, and +//! is merely as slow as it was before. `js_gc_declare_typed_shape_layout` was +//! 30% of `churn_alloc` and nothing but a profile said so — the same shape of +//! mistake is available here, so the positive test asserts the fast arm exists +//! AND that it carries the stores, and the negatives assert it is absent for +//! each shape that must keep the call. +//! +//! The module reuses the `#7834` bake tests' fixtures verbatim +//! (`typed_shape_bake_tests`), because the qualifying population is a subset of +//! that ticket's: `typed_layout_baked` is the first thing +//! `prologue_store_plan` tests. + +use super::typed_shape_bake_tests::{emit, loop_new_module}; +use perry_hir::types::Type; +use perry_hir::Expr; + +/// The label the fast arm's basic block carries. +const FAST_BLOCK: &str = "ctor_prologue.fast"; + +/// How many `store double` instructions appear inside the first +/// `ctor_prologue.fast` block — the arm is a straight line, so counting to its +/// terminator is exact. +fn fast_arm_double_stores(ir: &str) -> usize { + let Some(start) = ir.find(FAST_BLOCK) else { + return 0; + }; + let body = &ir[start..]; + let end = body.find("\n br ").unwrap_or(body.len()); + body[..end].matches("store double").count() +} + +/// `class Pair { a: number; b: number }` constructed in a loop: the whole +/// constructor is `this.a = a; this.b = b`, the allocation baked its header, so +/// the two stores land here and the call is on the cold arm. +#[test] +fn a_prologue_only_ctor_stores_its_fields_at_the_new_site() { + let ir = emit(&loop_new_module("Pair", Type::Number, Expr::Integer(2))); + assert!( + ir.contains(FAST_BLOCK), + "no constructor-free arm was emitted for a two-`number` class whose \ + whole constructor is `this.a = a; this.b = b` — the predicate answered \ + `false` and every construction still pays the call plus two full \ + class-field prechecks:\n{ir}" + ); + assert_eq!( + fast_arm_double_stores(&ir), + 2, + "the constructor-free arm exists but does not store both fields — an \ + arm that stores fewer fields than the constructor did is a WRONG \ + ANSWER, not a slow one:\n{ir}" + ); +} + +/// The control. `class Link { a: number; b: Link | null }` has a non-empty +/// pointer mask, so `typed_layout_baked` is false, so none of the header +/// constants this change reads as proof were written — and the arm must not be +/// emitted at all. +#[test] +fn a_pointer_bearing_shape_keeps_the_constructor_call() { + let ir = emit(&loop_new_module( + "Link", + Type::Union(vec![Type::Named("Link".to_string()), Type::Null]), + Expr::Null, + )); + assert!( + !ir.contains(FAST_BLOCK), + "a pointer-bearing shape took the constructor-free arm. Its header is \ + GC_LAYOUT_SIDE_MASK with no INTACT bit, so the precheck conditions \ + this arm skips are NOT statically true for it:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs b/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs new file mode 100644 index 0000000000..1dc43445f2 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs @@ -0,0 +1,234 @@ +//! Constructor-free construction for a class whose entire constructor is a run +//! of `this. = ` stores. +//! +//! ## What this is for +//! +//! An object literal with a closed shape is not lowered as a literal. HIR mints +//! an anon-shape class for it (`lower/context.rs::mint_anon_shape_class`) and +//! rewrites the site to `new __AnonShape_(v, w)`, and `lower_new` then +//! routes that through the shared standalone `_constructor` symbol +//! (`new.rs`'s `force_ctor_call`, on by default since inlining the body at every +//! site cost more in IR than it saved). So `{ v, w }` and +//! `class Node { constructor(v, w) { this.v = v; this.w = w } }` compile to the +//! same thing: a bump allocation whose header is a compile-time constant, +//! followed by a call into a symbol where `this` is an **opaque parameter**. +//! +//! Being opaque is the whole cost. Every `this.f = p` inside that symbol emits +//! the full class-field precheck (`expr/class_field_inline_guard.rs`): a +//! volatile load of the policy latch, seven header loads, nine compares, and a +//! two-block diamond — per field, per object. Measured on `churn_alloc`'s +//! 20 M-allocation loop that is ~45% of the program +//! (`gc-handoff/ALLOC-NOTES.md` §6), and the corpus's own size-vs-writes +//! controls agree: `churn_alloc` (2 fields) 0.2405 s → `churn_alloc4` +//! (4 fields, **identical object bytes**) 0.3707 s → `churn_alloc8` 0.6143 s. +//! The step is field *stores*, not bytes. +//! +//! Every one of those conditions is statically true three instructions earlier, +//! at the allocation. This module recognizes that case and stores the fields +//! straight into their packed slots at the `new` site, with no call and no +//! per-field guard. +//! +//! ## Where the proof comes from +//! +//! Almost all of it from one bit: `InstanceAlloc::typed_layout_baked` (#7834). +//! It is set only on the inline-bump arm of `new_alloc.rs`, and only when +//! `layout_pointer_free_at_allocation` holds, so it certifies that the +//! allocation wrote these header constants itself: +//! +//! | precheck condition | why it holds | +//! |---|---| +//! | `GcHeader.obj_type == GC_TYPE_OBJECT` | low byte of the packed `gc_packed` constant | +//! | not forwarded | `gc_flags` is exactly `GC_FLAG_ARENA` | +//! | `object_type == OBJECT_TYPE_REGULAR` | first `ObjectHeader` word constant | +//! | `class_id == ` | same word, `cid` is this site's class | +//! | `field_count > slot` | `field_count` is the class's own field count, and every slot in the plan indexes a declared field | +//! | `keys_array == @perry_class_keys_` | the header store loads the same global the precheck compares against | +//! | no per-object descriptors | `_reserved` is the constant `GC_LAYOUT_POINTER_FREE \| INTACT` | +//! | not frozen | same constant | +//! | typed layout INTACT | same constant — this is exactly what #7834 baked | +//! +//! Nothing can invalidate any of it in between: the instance has not escaped, +//! and the arguments were lowered *before* the allocation (they are re-read from +//! their roots by `refresh_rooted_args`, so a collection during the allocation +//! is already handled). +//! +//! Two conditions are **not** static, and both are still emitted — once for the +//! whole construction rather than once per field: +//! +//! * **The policy latch** `PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED`. It is +//! sticky 0 → 1 and flips when a prototype-level accessor/descriptor install +//! or typed-feedback tracing arms. Honouring it keeps the escape hatch real — +//! a knob whose off-state is not on the path it claims to gate is the failure +//! mode `CLAUDE.md`'s kill-policy section is about. +//! * **The values.** A raw slot may hold only a plain finite double. The +//! conjunction of the per-value finite tests decides the whole construction, +//! so a single non-number sends *all* fields to the constructor call, which is +//! the unchanged path. That is why this is a diamond and not a per-field +//! side exit: no field is stored before the decision is made. +//! +//! Because the finite test rejects every NaN-box tag (they all share the +//! all-ones exponent, including INT32-boxed integers), the bits stored here are +//! bit-identical to what the boxed path would have written — a JS number's NaN +//! box *is* its double bits. So this is correct for a slot the reader treats as +//! raw f64 and for one it treats as a NaN-boxed `JsValue` alike, and it needs no +//! `js_array_numeric_value_to_raw_f64` canonicalization (the only inputs that +//! helper rewrites are exactly the ones the finite test rejects). +//! +//! GC: a plain finite double is provably not a heap pointer, the shape is +//! `GC_LAYOUT_POINTER_FREE`, and the instance is a fresh nursery object — so no +//! write barrier and no per-slot layout note are due. Same reasoning, same +//! audit tag, as the #5093 loop-clone store in `expr/property_set.rs`. + +use crate::expr::FnCtx; + +/// One `this. = ` store the plan will emit directly. +pub(super) struct PrologueStore { + /// Packed inline-slot index, from `class_field_global_index`. + pub(super) slot: u32, + /// Index into the `new` site's lowered argument vector. + pub(super) arg_index: usize, +} + +/// Can `new (…)` skip its constructor call and store the fields +/// inline? `Some(plan)` = yes, and `plan` is every store to emit, in source +/// order; `None` = emit the unchanged call. +/// +/// Deliberately narrow. Everything it refuses is refused because the standalone +/// constructor symbol would have done something this path does not reproduce: +/// +/// * **Heritage of any kind** — `super(…)` runs another constructor body. +/// * **Decorators, computed members, private/initialized/computed-key fields** — +/// the field-init phase then contains user expressions. +/// * **Any accessor** — `this.f = v` would have to dispatch to a setter. (The +/// per-field `class_field_global_index` lookup rejects an accessor anywhere in +/// the chain as well; both checks are kept so the refusal does not depend on +/// which one happens to run first.) +/// * **Non-plain constructor parameters** (default / rest / decorated / +/// `arguments`) — the call site's argument packing is then not positional. +/// * **Fewer arguments than parameters, or extra ones** — a capture-carrying +/// constructor appends `__perry_cap_*` arguments, and a short call leaves +/// parameters `undefined`. Requiring exact positional correspondence is what +/// makes `lowered_args[i]` the value of parameter `i`. +/// * **A body that is anything other than the full run** — every statement must +/// be `this. = `, and the assigned set must cover *every* declared +/// field exactly once. Partial coverage would leave a declared raw-f64 slot +/// holding the allocator's `undefined` fill under an INTACT header, which is +/// precisely the state `layout_pointer_free_at_allocation` exists to prevent. +/// +/// A literal or a pure operator tree on the right-hand side is admissible to +/// `field_init::ctor_prologue_param_assigned_fields` but NOT here: those values +/// have no corresponding entry in the call site's argument vector. Widening to +/// them means lowering the expression at the `new` site, which is a different +/// change. +pub(super) fn prologue_store_plan( + ctx: &FnCtx<'_>, + class_name: &str, + class: &perry_hir::Class, + lowered_arg_count: usize, + typed_layout_baked: bool, +) -> Option> { + if !typed_layout_baked { + return None; + } + if class.extends.is_some() + || class.extends_name.is_some() + || class.native_extends.is_some() + || class.extends_expr.is_some() + || class.heritage_lexically_shadowed + || !class.decorators.is_empty() + || !class.getters.is_empty() + || !class.setters.is_empty() + || !class.computed_members.is_empty() + || class.alloc_width_hint != 0 + { + return None; + } + if !class.fields.iter().all(|f| { + f.init.is_none() && f.key_expr.is_none() && f.decorators.is_empty() && !f.is_private + }) { + return None; + } + let ctor = class.constructor.as_ref()?; + if !ctor.params.iter().all(|p| { + p.default.is_none() && !p.is_rest && p.decorators.is_empty() && p.arguments_object.is_none() + }) { + return None; + } + if lowered_arg_count != ctor.params.len() || ctor.params.is_empty() { + return None; + } + if ctor.body.len() != class.fields.len() || class.fields.is_empty() { + return None; + } + let param_slot: std::collections::HashMap = ctor + .params + .iter() + .enumerate() + .map(|(i, p)| (p.id, i)) + .collect(); + if param_slot.len() != ctor.params.len() { + return None; + } + + let mut plan: Vec = Vec::with_capacity(ctor.body.len()); + let mut assigned: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for stmt in &ctor.body { + let (property, param_id) = param_store(stmt)?; + if !assigned.insert(property) { + return None; + } + let arg_index = *param_slot.get(¶m_id)?; + // A compiled setter owns the name — never store into the slot behind it. + if ctx + .methods + .contains_key(&(class.name.clone(), format!("__set_{}", property))) + { + return None; + } + let slot = crate::type_analysis::class_field_global_index(ctx, class_name, property)?; + plan.push(PrologueStore { slot, arg_index }); + } + // Every declared field written, exactly once — see the doc comment. + if !class + .fields + .iter() + .all(|f| assigned.contains(f.name.as_str())) + { + return None; + } + Some(plan) +} + +/// `Some((field, param_id))` when `stmt` is exactly `this. = `. +/// +/// Both HIR spellings, for the same reason `field_init::prologue_assigned_field` +/// matches both: `Expr::PropertySet` is what the anon-shape lowering +/// synthesizes, `Expr::PutValueSet` is what user source produces. Matching one +/// of them covers exactly half the population — #7512 is the bug that shape +/// caused. +fn param_store(stmt: &perry_hir::Stmt) -> Option<(&str, u32)> { + use perry_hir::{Expr, Stmt}; + match stmt { + Stmt::Expr(Expr::PropertySet { + object, + property, + value, + }) if matches!(object.as_ref(), Expr::This) => match value.as_ref() { + Expr::LocalGet(id) => Some((property.as_str(), *id)), + _ => None, + }, + Stmt::Expr(Expr::PutValueSet { + target, + key, + value, + receiver, + strict: _, + }) if matches!(target.as_ref(), Expr::This) && matches!(receiver.as_ref(), Expr::This) => { + match (key.as_ref(), value.as_ref()) { + (Expr::String(property), Expr::LocalGet(id)) => Some((property.as_str(), *id)), + _ => None, + } + } + _ => None, + } +} diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index d32ecbfa3f..d32ceeaac0 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -44,6 +44,9 @@ mod console_promise; /// repaired (#7649) — see the module header for why these assert on IR. #[cfg(test)] mod console_rooting_tests; +#[cfg(test)] +mod ctor_prologue_store_tests; +mod ctor_prologue_stores; mod dataview_intrinsic; mod early_branches; mod event_target; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 3ff62aa12c..15214f1bce 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -533,7 +533,8 @@ fn lower_new_impl_inner<'a>( // // Before the instance root's push, so the handle this names is the one the // allocator returned: nothing between here and there can collect. - emit_typed_shape_layout_declare(ctx, class_name, &obj_handle, alloc.typed_layout_baked); + let typed_layout_baked = alloc.typed_layout_baked; + emit_typed_shape_layout_declare(ctx, class_name, &obj_handle, typed_layout_baked); let instance = { let protected = construction_runs_user_code(ctx, class_name); Instance { @@ -628,6 +629,89 @@ fn lower_new_impl_inner<'a>( } else { None }; + // Constructor-free construction: when the whole constructor body is a + // run of `this. = ` stores into the shape the inline bump + // allocator just baked, store the fields here and skip the call. See + // `ctor_prologue_stores` for the proof — the short version is that every + // condition the per-field precheck tests is a compile-time constant this + // very site wrote three instructions ago, so the only things left to + // decide at runtime are the policy latch and whether the values are + // plain finite numbers, and both are decided ONCE for the construction + // instead of once per field. + // + // Emitted only when `saved_new_target` is absent: a `new.target`-reading + // chain needs the runtime cell the call path sets, and a class whose + // body is nothing but field stores cannot read it anyway. + // `local_constructor_symbol_exists` is re-tested because this arm is + // also reached by the recursion guard and the capture-alias redirect, + // neither of which requires it — and the diamond's slow arm IS the call, + // so emitting it when the call cannot be emitted would leave the fast + // arm branching into a block nothing terminates. + let prologue_plan = + if saved_new_target.is_none() && local_constructor_symbol_exists(ctx, class) { + super::ctor_prologue_stores::prologue_store_plan( + ctx, + class_name, + class, + lowered_args.len(), + typed_layout_baked, + ) + } else { + None + }; + // `(merge block, fast-arm predecessor label)` when the diamond was + // emitted; the slow arm is the current block from here on. + let mut prologue_merge: Option<(usize, String)> = None; + if let Some(plan) = prologue_plan.as_ref() { + let fast_idx = ctx.new_block("ctor_prologue.fast"); + let slow_idx = ctx.new_block("ctor_prologue.slow"); + let merge_idx = ctx.new_block("ctor_prologue.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); + { + let blk = ctx.block(); + // The sticky policy latch, volatile for the same reason every + // other reader loads it volatile: the runtime flips it 0 -> 1 + // mid-execution and LLVM must not hoist a stale 0 across it. + let flag = + blk.load_volatile(crate::types::I8, "@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"); + let mut acc = blk.icmp_eq(crate::types::I8, &flag, "0"); + for store in plan { + let bits = blk.bitcast_double_to_i64(&lowered_args[store.arg_index]); + let finite = + crate::expr::class_field_inline_guard::emit_plain_finite_number_check( + blk, &bits, + ); + acc = blk.and(crate::types::I1, &acc, &finite); + } + blk.cond_br(&acc, &fast_label, &slow_label); + } + ctx.current_block = fast_idx; + { + // arm64_32 watchOS: the fields region starts at + // `size_of::()` past the user pointer (24 on + // 64-bit, 20 on ILP32) — same derivation as every other packed + // slot access. + let header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, &obj_handle); + let fields_base = blk.gep(crate::types::I8, &obj_ptr, &[(I64, &header_skip)]); + for store in plan { + let field_ptr = + blk.gep(DOUBLE, &fields_base, &[(I64, &store.slot.to_string())]); + // GC_STORE_AUDIT(POINTER_FREE): the finite check above + // proved every value is a genuine unboxed double, never a + // heap pointer, and the shape is GC_LAYOUT_POINTER_FREE — + // no edge, no write barrier, no layout note. + blk.store(DOUBLE, &lowered_args[store.arg_index], &field_ptr); + } + blk.br(&merge_label); + } + prologue_merge = Some((merge_idx, ctx.block_label(fast_idx))); + ctx.current_block = slow_idx; + } if let Some(ctor_ret) = call_local_constructor_symbol( ctx, class, @@ -638,6 +722,23 @@ fn lower_new_impl_inner<'a>( if let Some(save) = &saved_new_target { crate::rooting::new_target_restore(ctx, save); } + // Rejoin the constructor-free arm. The phi is over the CONSTRUCTOR'S + // RETURN VALUE, not the instance: the fast arm ran no constructor, + // which is `undefined` — the same thing an ordinary ctor body + // returns — so `emit_ctor_return_override` below yields the instance + // on both arms without this path having to reason about it. + let ctor_ret = match prologue_merge.take() { + Some((merge_idx, fast_pred)) => { + let slow_pred = ctx.block().label.clone(); + let merge_label = ctx.block_label(merge_idx); + ctx.block().br(&merge_label); + ctx.current_block = merge_idx; + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + ctx.block() + .phi(DOUBLE, &[(&undef, &fast_pred), (&ctor_ret, &slow_pred)]) + } + None => ctor_ret, + }; // #7154: the constructor body has run, so every register holding // the instance is potentially pre-move. Re-read it from its root // before anything else touches it — `emit_typed_shape_layout_init` diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index 8d29b40781..80f6cf4961 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -220,7 +220,7 @@ fn two_field_class(name: &str, f1_ty: Type) -> Class { /// bump allocator, and only the inline bump has a packed header constant to /// fold the layout into. An outlined `js_object_alloc_class_inline_keys` site /// keeps the runtime declare, by design. -fn loop_new_module(name: &str, f1_ty: Type, second: Expr) -> Module { +pub(super) fn loop_new_module(name: &str, f1_ty: Type, second: Expr) -> Module { let mut m = Module::new("typed_shape_bake.ts"); m.classes = vec![two_field_class(name, f1_ty)]; m.init = vec![Stmt::For { @@ -307,7 +307,7 @@ fn loop_new_module(name: &str, f1_ty: Type, second: Expr) -> Module { m } -fn emit(m: &Module) -> String { +pub(super) fn emit(m: &Module) -> String { String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") }