perf(codegen): construct field-only objects without calling a constructor - #7884
Conversation
…ctor An object literal with a closed shape is lowered to `new __AnonShape_<hash>(v, w)` against a synthesized class, and — like every own-constructor class — routed through the shared standalone `<Class>_constructor` symbol. Inside that symbol `this` is an opaque parameter, so every `this.f = p` emits the full class-field precheck: a volatile latch load, seven header loads, nine compares and a two-block diamond, per field, per object. On `churn_alloc`'s 20M-allocation loop that is ~45% of the program. Every one of those conditions is a compile-time constant the CALLER wrote three instructions earlier. `InstanceAlloc::typed_layout_baked` (#7834) certifies the whole set — GC_TYPE_OBJECT, not-forwarded, OBJECT_TYPE_REGULAR, the class id, the field count, the keys-array pointer, no descriptors, not-frozen, and GC_OBJ_TYPED_LAYOUT_INTACT — because the inline bump allocator stamped them into its packed header constant. So for a class whose entire constructor is a run of `this.<field> = <parameter>` stores, store the fields at the `new` site and skip the call. Two things stay runtime, but once per 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 call, so no field is stored before the decision is made. The bits written are identical to the boxed path's: a JS number's NaN box IS its double bits, and the finite test rejects every NaN-box tag (INT32-boxed integers included). That is also why no js_array_numeric_value_to_raw_f64 canonicalization is needed here. Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2
📝 WalkthroughWalkthroughAdds constructor-free construction for eligible field-only classes. Codegen analyzes constructor assignments, emits guarded direct numeric stores, preserves constructor fallback behavior, and adds IR tests for eligible and pointer-bearing classes. ChangesConstructor-free construction
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant new_lowering
participant prologue_store_plan
participant allocated_object
participant standalone_constructor
new_lowering->>prologue_store_plan: analyze constructor
prologue_store_plan-->>new_lowering: return store plan or None
new_lowering->>allocated_object: allocate object and check guard
alt eligible and guard passes
new_lowering->>allocated_object: write finite numeric fields
else unsupported or guard fails
new_lowering->>standalone_constructor: call constructor
standalone_constructor-->>new_lowering: return constructor result
end
new_lowering->>new_lowering: apply return-override semantics
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs`:
- Around line 151-159: Update the constructor eligibility check in the
direct-store planner around ctor.params and lowered_arg_count to reject
generated __perry_cap_* capture parameters, even when argument counts match.
Preserve the fast path only for constructors without capture-carrying
parameters, allowing capture-bearing constructors to fall back through
call_local_constructor_symbol and its initialization/writeback protocol; add a
fallback test covering capture-carrying construction.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b52b3775-529a-4133-8f77-4e6b4db5467f
📒 Files selected for processing (6)
changelog.d/7882-ctor-free-construction.mdcrates/perry-codegen/src/lower_call/ctor_prologue_store_tests.rscrates/perry-codegen/src/lower_call/ctor_prologue_stores.rscrates/perry-codegen/src/lower_call/mod.rscrates/perry-codegen/src/lower_call/new.rscrates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reject capture-carrying constructors from the direct-store plan.
Line 157 accepts generated __perry_cap_* parameters when appended arguments make the counts equal. The plan can then bypass call_local_constructor_symbol, while the shared path in new.rs still performs capture writeback from this.__perry_cap_* fields. The fast arm does not initialize that capture state, so it can write unset values back to outer captured locals.
Reject generated capture parameters in this planner, or reproduce the complete capture initialization protocol in the fast arm. Add a capture-carrying fallback test.
Proposed conservative fix
let ctor = class.constructor.as_ref()?;
+if ctor
+ .params
+ .iter()
+ .any(|param| param.name.starts_with("__perry_cap_"))
+{
+ return None;
+}
if !ctor.params.iter().all(|p| {Based on learnings, capture-carrying construction needs explicit provenance safeguards; argument count alone is not sufficient.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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; | |
| } | |
| let ctor = class.constructor.as_ref()?; | |
| if ctor | |
| .params | |
| .iter() | |
| .any(|param| param.name.starts_with("__perry_cap_")) | |
| { | |
| return None; | |
| } | |
| 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; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs` around lines 151
- 159, Update the constructor eligibility check in the direct-store planner
around ctor.params and lowered_arg_count to reject generated __perry_cap_*
capture parameters, even when argument counts match. Preserve the fast path only
for constructors without capture-carrying parameters, allowing capture-bearing
constructors to fall back through call_local_constructor_symbol and its
initialization/writeback protocol; add a fallback test covering capture-carrying
construction.
Source: Learnings
What
For a class whose entire constructor is a run of
this.<field> = <parameter>stores,store the fields at the
newsite and skip the constructor call.Why this is the allocation band's shared cost
An object literal with a closed shape is not lowered as a literal. HIR mints an anon-shape
class (
lower/context.rs::mint_anon_shape_class) and rewrites the site tonew __AnonShape_<hash>(v, w);lower_new'sforce_ctor_callthen routes that — likeevery own-constructor class — through the shared standalone
<Class>_constructorsymbol.So
{ v, w }andclass Node { constructor(v, w) { this.v = v; this.w = w } }compile tothe same thing: a bump allocation whose header is a compile-time constant, followed by a
call into a symbol where
thisis an opaque parameter.Being opaque is the cost. Every
this.f = pinside that symbol emits the full class-fieldprecheck (
expr/class_field_inline_guard.rs) — a volatile load of the policy latch, sevenheader loads, nine compares and a two-block diamond — per field, per object. On
churn_alloc's 20 M-allocation loop that is ~45% of the program, and the corpus's ownsize-vs-writes controls agree that the cost is stores, not bytes:
churn_alloc(2 fields) 0.2415 s →
churn_alloc4(4 fields, identical object bytes) 0.3708 s →churn_alloc80.6137 s.Where the proof comes from
Almost all of it from one bit:
InstanceAlloc::typed_layout_baked(#7834). It is set onlyon the inline-bump arm of
new_alloc.rsand only whenlayout_pointer_free_at_allocationholds, so it certifies that this very site wrote, as compile-time constants, every
condition the per-field precheck tests:
GcHeader.obj_type == GC_TYPE_OBJECTgc_packedconstantgc_flagsis exactlyGC_FLAG_ARENAobject_type == OBJECT_TYPE_REGULARObjectHeaderword constantclass_id == <this class>cidis this site's classfield_count > slotfield_countis the class's own count; every slot indexes a declared fieldkeys_array == @perry_class_keys_<C>_reservedis the constantGC_LAYOUT_POINTER_FREE | INTACTNothing can invalidate any of it in between: the instance has not escaped, and nothing
between the allocation and the stores is a call.
Two conditions are not static, and both are still emitted — once for the whole
construction rather than once per field:
PERRY_CLASS_FIELD_INLINE_GUARD_DISABLEDlatch, honoured so the escape hatchstays real on this path;
to the constructor call, which is the unchanged path — so no field is 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 — they share the all-ones exponent). That is also why this needs no
js_array_numeric_value_to_raw_f64canonicalization: the only inputs that helper rewritesare exactly the ones the 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 — no write barrier andno per-slot layout note are due. Same reasoning and same audit tag as the #5093 loop-clone
store.
Deliberately narrow
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 isanything other than the full run covering every declared field exactly once. Partial
coverage would leave a declared raw-f64 slot holding the allocator's
undefinedfill underan INTACT header, which is precisely the state
layout_pointer_free_at_allocationexiststo prevent.
Validation
Correctness — complete.
node --experimental-strip-types.PERRY_GC_PROTECT_FROMSPACE=1 …DEPTH=800+PERRY_GC_VERIFY_EVACUATION=1;PERRY_GC_FORCE_EVACUATE=1;PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0)→ 44/44 byte-identical, exit 0.
iso_miss→checksum 437840 misses 0, plain and under the instruments, with theinstrument verified live (50
[gc-fromspace-protect] retired_set=lines).Noted honestly:
p_iso_misscompiles byte-identically on both arms, so as evidenceabout this change it is vacuous — the 11 × 4 sweep above is the real stress.
cargo test --release -p perry-codegen --lib: 896 passed, 0 failed (includes the twonew IR-census tests).
cargo fmt --all -- --checkclean;scripts/check_file_size.shexit 0.The tests are sabotage-proven, not merely present.
prologue_store_planforced to returnNoneeverywhere (the "optimisation silently does nothing" failure)a_prologue_only_ctor_stores_its_fields_at_the_new_siteFAILS; the pointer-bearing control still passesleft: 1, right: 2cmpover the corpus — codegen-only, samePERRY_RUNTIME_DIRon both arms(verified: the fix build relinked
perrywhile leavinglibperry_runtime.aandlibperry_stdlib.auntouched):11 identical / 11 differ, and the 11 that differ are exactly the programs containing a
qualifying all-number closed-shape construction —
churn,churn_alloc,churn_alloc4,churn_alloc8,churn_read,push_cls,retain,retain1,retain4,retain_wide,retain_wide1. The other 11 (shapes,pipeline,asyncpipe,interp,iso_miss,push_num,cycles,deeplist,tree,tree_wide,fib40) are provably unchangedand need no timing. Every predicted refusal held:
cycles/deeplist(pointer field plus athis.x = nullliteral RHS),tree/tree_wide(ALL_POINTERS),shapes(heritage plustag: string),asyncpipe(string-bearing literals).Timing — absolute seconds, quiet M1 mini, best-of-5, interleaved, exit-checked
Window opened at load 2.04 / 0 foreign processes and closed at 2.25 / 0 →
VERDICT: window stayed quiet — numbers usable. Every cell exit 0 on all three arms, andall 22 programs were verified byte-identical to node before timing.
9ca8b4f71cmp-identical)churn_allocandpush_clsnow allocate faster than node.Confirmed by a second, fully independent sweep (separate lock window, also
VERDICT: usable, load 2.15 → 2.03). The two runs agree to within 0.4 percentage pointson every one of the 22 cells: churn −40.5 / −40.7, churn_alloc −50.9 / −50.8,
push_cls −50.1 / −49.9, churn_alloc4 −59.8 / −59.9, churn_alloc8 −63.6 / −63.6,
retain_wide −16.9 / −17.3, and every unaffected program within ±0.6% in both. Raw data in
gc-handoff/m0810/results_alloc2_run{1,2}.json.ns per allocation
churn_allocperforms 20 000 × 1 000 = 20 M allocations, so ns/alloc = seconds × 50:9ca8b4f71For context on the trajectory: #7834 took this from 18.6 → 12.0 against node's 7.1.
Why the 4-field and 8-field controls move MOST
churn_alloc4(−59.8%) andchurn_alloc8(−63.6%) are the corpus's size-vs-writescontrols, and they move further than the 2-field case because the removed cost is
per field: one precheck tower each. That is the change's own falsification test — if the
win came from removing the call rather than the guards, the 2-field and 8-field cases
would have moved by the same absolute amount, and they do not (0.121 s vs 0.390 s).
The
retainfamily moves less (−5.6% to −16.9%) for a reason that is visible inPERRY_GC_TRACE: those programs run at 999–1000‰ young survival and spend 62% of theirtime in the promotion walk, so the allocation path is a minority of their runtime. This PR
does not target them; the movement is a side effect of the same qualifying shape.
Summary by CodeRabbit
New Features
Bug Fixes
Tests