Skip to content

perf(codegen): construct field-only objects without calling a constructor - #7884

Merged
proggeramlug merged 1 commit into
mainfrom
perf/ctor-free-construction
Aug 11, 2026
Merged

perf(codegen): construct field-only objects without calling a constructor#7884
proggeramlug merged 1 commit into
mainfrom
perf/ctor-free-construction

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

For a class whose entire constructor is a run of this.<field> = <parameter> stores,
store the fields at the new site 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 to
new __AnonShape_<hash>(v, w); lower_new's force_ctor_call then routes that — like
every own-constructor class — through the shared standalone <Class>_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 (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. On
churn_alloc's 20 M-allocation loop that is ~45% of the program, and the corpus's own
size-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_alloc8 0.6137 s.

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 this very site wrote, as compile-time constants, every
condition the per-field precheck tests:

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 == <this class> same word, cid is this site's class
field_count > slot field_count is the class's own count; every slot indexes a declared field
keys_array == @perry_class_keys_<C> 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 — exactly what #7834 baked

Nothing 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
:

  • the sticky PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED latch, honoured so the escape hatch
    stays real on this path;
  • the conjunction of per-value plain-finite tests. A single non-number sends all fields
    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_f64 canonicalization: the only inputs that helper rewrites
are 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 and
no 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 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.

Validation

Correctness — complete.

  • All 22 corpus programs built with the fix arm: exit 0 and byte-identical to
    node --experimental-strip-types.
  • GC stress: the 11 programs the change actually alters × 4 configurations (plain;
    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.
  • Canary iso_misschecksum 437840 misses 0, plain and under the instruments, with the
    instrument verified live (50 [gc-fromspace-protect] retired_set= lines).
    Noted honestly: p_iso_miss compiles byte-identically on both arms, so as evidence
    about 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 two
    new IR-census tests).
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh exit 0.

The tests are sabotage-proven, not merely present.

sabotage result
prologue_store_plan forced to return None everywhere (the "optimisation silently does nothing" failure) a_prologue_only_ctor_stores_its_fields_at_the_new_site FAILS; the pointer-bearing control still passes
fast arm stores only the FIRST field (the wrong-answer failure) same test FAILS with left: 1, right: 2

cmp over the corpus — codegen-only, same PERRY_RUNTIME_DIR on both arms
(verified: the fix build relinked perry while leaving libperry_runtime.a and
libperry_stdlib.a untouched):

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 unchanged
and need no timing. Every predicted refusal held: cycles/deeplist (pointer field plus a
this.x = null literal RHS), tree/tree_wide (ALL_POINTERS), shapes (heritage plus
tag: 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, and
all 22 programs were verified byte-identical to node before timing.

bench base 9ca8b4f71 fix delta node fix/node
churn_alloc 0.2400 0.1179 −50.9% 0.1407 0.84
push_cls 0.2349 0.1172 −50.1% 0.1379 0.85
churn 0.2881 0.1714 −40.5% 0.1655 1.04
churn_alloc8 0.6135 0.2234 −63.6% 0.1947 1.15
churn_alloc4 0.3701 0.1488 −59.8% 0.1549 0.96
retain_wide 0.3684 0.3060 −16.9% 0.1498 2.04
retain_wide1 0.1312 0.1107 −15.6% 0.0882 1.26
retain4 0.2810 0.2516 −10.5% 0.1338 1.88
retain 0.2646 0.2464 −6.9% 0.1303 1.89
retain1 0.1080 0.1020 −5.6% 0.0840 1.21
churn_read 0.0226 0.0224 −0.9% 0.0843 0.27
shapes / pipeline / asyncpipe / interp / iso_miss / push_num / cycles / deeplist / tree / tree_wide / fib40 ±0.6% (binaries cmp-identical)

churn_alloc and push_cls now 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 points
on 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_alloc performs 20 000 × 1 000 = 20 M allocations, so ns/alloc = seconds × 50:

arm ns/alloc
base 9ca8b4f71 12.0
node 26.5.1 7.0
this PR 5.9

For 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%) and churn_alloc8 (−63.6%) are the corpus's size-vs-writes
controls, 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 retain family moves less (−5.6% to −16.9%) for a reason that is visible in
PERRY_GC_TRACE: those programs run at 999–1000‰ young survival and spend 62% of their
time 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

    • Eligible numeric-only objects can now be created through a faster construction path.
    • Standard construction behavior is preserved for unsupported cases, including objects with references or complex initialization logic.
  • Bug Fixes

    • Preserved constructor return behavior and numeric field layout semantics across both optimized and standard construction paths.
  • Tests

    • Added coverage validating optimized field initialization and fallback behavior.

…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
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Constructor-free construction

Layer / File(s) Summary
Constructor prologue eligibility analysis
crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs
Builds ordered field-slot and argument mappings for constructors that meet the layout, inheritance, field, parameter, and assignment constraints.
Guarded fast-path lowering
crates/perry-codegen/src/lower_call/new.rs
Checks the runtime latch and finite numeric arguments, stores eligible values directly, and falls back to the standalone constructor.
IR validation and test wiring
crates/perry-codegen/src/lower_call/ctor_prologue_store_tests.rs, crates/perry-codegen/src/lower_call/mod.rs, crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs, changelog.d/7882-ctor-free-construction.md
Adds positive and negative IR-census tests, registers test modules, exposes test helpers, and documents supported and unsupported construction shapes.

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
Loading

Possibly related PRs

  • PerryTS/perry#7486: Modifies constructor prologue analysis and direct parameter-to-field store handling.
  • PerryTS/perry#7515: Extends constructor-prologue recognition for this.field = parameter assignments.
  • PerryTS/perry#7686: Expands typed stores and constructor-prologue allocation handling for additional field types and initializer forms.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main code-generation optimization.
Description check ✅ Passed The description clearly explains the change, rationale, constraints, and extensive validation, although it does not use the template headings or checklist.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/ctor-free-construction

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug marked this pull request as ready for review August 11, 2026 19:59

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 9ca8b4f and c4fe8fe.

📒 Files selected for processing (6)
  • changelog.d/7882-ctor-free-construction.md
  • crates/perry-codegen/src/lower_call/ctor_prologue_store_tests.rs
  • crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs
  • crates/perry-codegen/src/lower_call/mod.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs

Comment on lines +151 to +159
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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

@proggeramlug
proggeramlug merged commit aa86670 into main Aug 11, 2026
9 of 53 checks passed
@proggeramlug
proggeramlug deleted the perf/ctor-free-construction branch August 11, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant