perf(codegen,gc): declare a class's typed-shape layout at allocation, not after its constructor (#7510 item 1, #7512) - #7532
Conversation
|
Warning Review limit reached
Next review available in: 25 seconds You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (15)
📝 WalkthroughWalkthroughTyped-shape layouts can now be declared immediately after class-instance allocation when constructor analysis proves safe raw- ChangesTyped-shape allocation declaration
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant NewLowering
participant TypedShapeInit
participant RuntimeLayout
participant Constructor
NewLowering->>TypedShapeInit: analyze constructor field assignments
NewLowering->>RuntimeLayout: declare typed shape after allocation
RuntimeLayout-->>NewLowering: install raw-f64 layout
NewLowering->>Constructor: execute constructor stores
Constructor->>RuntimeLayout: downgrade layout if a contradictory store occurs
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 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
🧹 Nitpick comments (3)
crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs (1)
107-131: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueOptional: the raw
child_headerpointer is held across a store and a trace.
child_headeris a plain Rust local. Rust stack locals are not conservatively scanned, so this pointer is neither a root nor a pin. The read at line 126 is only valid becausejs_object_set_fieldand the manual mark/trace helpers do not evacuate in this harness. If the downgrade path later gains an allocating step, this test reads freed memory instead of failing cleanly.Re-deriving the header from
childafter the store would keep the test robust without changing what it proves.🛡️ Proposed adjustment
let child = crate::string::js_string_from_bytes(b"contradiction".as_ptr(), 13); - let child_header = unsafe { header_from_user_ptr(child as *mut u8) }; crate::object::js_object_set_field(obj, 0, crate::value::JSValue::string_ptr(child)); + let child_header = unsafe { header_from_user_ptr(child as *mut u8) };🤖 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-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs` around lines 107 - 131, Remove the long-lived child_header local from the test and derive the child’s header from child immediately before the final GC_FLAG_MARKED assertion. Keep the existing store, marking, and tracing sequence unchanged, so the assertion reads the post-trace header without retaining a potentially stale pointer across operations.crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs (2)
107-108: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueFix the broken sentence in this doc comment.
The sentence has an unmatched
(and no predicate after "lowers to".📝 Proposed wording
-/// `this.<name> = <param id>` in the shape user source lowers to -/// (`PutValueSet`, not the synthesized `PropertySet` — see `#7512`). +/// `this.<name> = <param id>` in the shape user source lowers to +/// `PutValueSet`, not the synthesized `PropertySet` — see `#7512`.🤖 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/tests/typed_shape_declared_at_allocation.rs` around lines 107 - 108, The doc comment describing `this.<name> = <param id>` has an unmatched parenthesis and an incomplete “lowers to” phrase. Rewrite the sentence so it clearly states that the syntax lowers to `PutValueSet`, not the synthesized `PropertySet`, with balanced punctuation.
246-254: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding in-crate coverage for the eligibility predicate.
class_layout_declarable_at_allocationispub(crate), so its only coverage is this integration suite plus full IR compilation. A unit test module insidecrates/perry-codegen/src/typed_shape.rswould pin the predicate directly and would run under the ordinarycargo testjob. Keep the IR-level tests for the emission and ordering claims.Based on the coding guideline "Do not rely exclusively on integration tests under
crates/*/tests/*.rsfor PR coverage ... place acceptance coverage in tests visible tocargo-testwhere practical".🤖 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/tests/typed_shape_declared_at_allocation.rs` around lines 246 - 254, Add an in-crate unit-test module in typed_shape.rs covering the class_layout_declarable_at_allocation predicate directly, including its eligibility behavior. Keep declarable_class_declares_its_layout_at_the_allocation_site and the existing IR-level tests unchanged for emission and ordering coverage.Source: Coding guidelines
🤖 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-runtime/src/gc/layout.rs`:
- Around line 946-948: In crates/perry-runtime/src/gc/layout.rs lines 946-948,
update js_gc_declare_typed_shape_layout to reject a non-empty pointer_mask when
proof is TypedShapeProof::FreshlyAllocated, and narrow the documentation to
cover only the POINTER_FREE case. In
crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs lines
168-192, add a neighboring test using non-overlapping raw 0b01 and pointer 0b10
masks, asserting the object does not become INTACT.
---
Nitpick comments:
In `@crates/perry-codegen/tests/typed_shape_declared_at_allocation.rs`:
- Around line 107-108: The doc comment describing `this.<name> = <param id>` has
an unmatched parenthesis and an incomplete “lowers to” phrase. Rewrite the
sentence so it clearly states that the syntax lowers to `PutValueSet`, not the
synthesized `PropertySet`, with balanced punctuation.
- Around line 246-254: Add an in-crate unit-test module in typed_shape.rs
covering the class_layout_declarable_at_allocation predicate directly, including
its eligibility behavior. Keep
declarable_class_declares_its_layout_at_the_allocation_site and the existing
IR-level tests unchanged for emission and ordering coverage.
In `@crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs`:
- Around line 107-131: Remove the long-lived child_header local from the test
and derive the child’s header from child immediately before the final
GC_FLAG_MARKED assertion. Keep the existing store, marking, and tracing sequence
unchanged, so the assertion reads the post-trace header without retaining a
potentially stale pointer across operations.
🪄 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: 28068c61-6c0c-42da-a6c5-7ca8aae5cb43
📒 Files selected for processing (13)
changelog.d/7532-typed-shape-declared-at-allocation.mdcrates/perry-codegen/src/gc_call_effects.rscrates/perry-codegen/src/lower_call/field_init.rscrates/perry-codegen/src/lower_call/mod.rscrates/perry-codegen/src/lower_call/new.rscrates/perry-codegen/src/lower_call/typed_shape_init.rscrates/perry-codegen/src/root_reload.rscrates/perry-codegen/src/runtime_decls/arrays.rscrates/perry-codegen/src/typed_shape.rscrates/perry-codegen/tests/typed_shape_declared_at_allocation.rscrates/perry-runtime/src/gc/layout.rscrates/perry-runtime/src/gc/tests/layout_trace.rscrates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs
| /// The *collector's* half needs no proof at all: `TAG_UNDEFINED` is a | ||
| /// non-pointer in every slot, which is consistent with both the | ||
| /// `POINTER_FREE` and the `SIDE_MASK` state this installs. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
The declaring entry point does not enforce the empty-pointer-mask precondition, and no test pins it. Codegen guarantees an empty pointer mask by rejecting every pointer-bearing field, but js_gc_declare_typed_shape_layout is an extern "C" symbol that accepts any non-overlapping mask and installs SIDE_MASK over slots that still hold the allocator's fill.
crates/perry-runtime/src/gc/layout.rs#L946-L948: reject a non-emptypointer_maskwhenproof == TypedShapeProof::FreshlyAllocated, and narrow this doc so it claims only thePOINTER_FREEcase.crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs#L168-L192: add a test next totest_declaring_install_rejects_overlapping_masksthat passes a non-overlapping, non-empty pointer mask (for example raw0b01, pointer0b10) and asserts the object does not become INTACT.
📍 Affects 2 files
crates/perry-runtime/src/gc/layout.rs#L946-L948(this comment)crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs#L168-L192
🤖 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-runtime/src/gc/layout.rs` around lines 946 - 948, In
crates/perry-runtime/src/gc/layout.rs lines 946-948, update
js_gc_declare_typed_shape_layout to reject a non-empty pointer_mask when proof
is TypedShapeProof::FreshlyAllocated, and narrow the documentation to cover only
the POINTER_FREE case. In
crates/perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs lines
168-192, add a neighboring test using non-overlapping raw 0b01 and pointer 0b10
masks, asserting the object does not become INTACT.
… not after its constructor js_gc_init_typed_shape_layout was emitted AFTER the constructor call, so no raw-f64 class-field store inside a constructor could pass its GC_OBJ_TYPED_LAYOUT_INTACT guard and every one fell back to js_put_value_set. Declaring the fields `number` was therefore what made the class slower: more type information selected a representation whose guard the construction path had made unsatisfiable (#7512's residual, folded into #7510 item 1). Moving the existing call earlier does not work — it validates that each raw-f64 slot holds a plain double, and a fresh slot holds TAG_UNDEFINED, whose 0x7FFC tag is inside layout_raw_f64_bits' reject range, so an early call downgrades every instance. js_gc_declare_typed_shape_layout skips that validation and codegen carries the proof instead: 1. every raw-f64 field is assigned by the constructor prologue from a plain parameter (#7486's predicate), so no read can observe one before its first write; and 2. the pointer mask is empty, so the declared state is POINTER_FREE — byte-identical to what layout_init_pointer_free already sets at birth, leaving the collector's view unchanged. Nothing rests on the values being numbers: a contradicting store is rejected by the store guard, falls back to the boxed setter, and downgrades the descriptor exactly as a post-install contradiction always has. push_cls 1.54x, push_cls_read 1.53x. The collector result is strictly better: promotion 210,488 -> 64 bytes, copied 0.0042 -> 0.0036 GB, peak RSS 29.6 -> 24.1 MB, cycles unchanged — the class instance now behaves exactly like the equivalent object literal. Every other benchmark compiles to byte-identical objects across the two arms.
231d4d4 to
29b42ed
Compare
…it as Any
A declaration in a `for` initializer registered `Type::Any` — the annotation
was discarded and the initializer was never inferred from. The cost is not the
loop variable but everything computed from it: `base + j` infers Any once `j`
is Any, so an object literal in the loop body mints an anon-shape class whose
fields are all Any.
Any is pointer-bearing, so `{v: number, w: number}` was handed to the collector
as TWO TRACED POINTER SLOTS with an empty raw-f64 mask — no POINTER_FREE, no
raw-f64 store path, and #7532's declare-at-allocation gate refused the shape.
For-init declarators now route through the same `infer_decl_type` the ordinary
let/const path uses. `var` is left alone: it is function-scoped and hoisted, so
its assignment story differs.
churn_alloc 1.60s -> 1.33s (1.20x), unmodified source.
…it as Any (#7547) (#7552) * perf(hir): type a for-initializer declaration instead of registering it as Any A declaration in a `for` initializer registered `Type::Any` — the annotation was discarded and the initializer was never inferred from. The cost is not the loop variable but everything computed from it: `base + j` infers Any once `j` is Any, so an object literal in the loop body mints an anon-shape class whose fields are all Any. Any is pointer-bearing, so `{v: number, w: number}` was handed to the collector as TWO TRACED POINTER SLOTS with an empty raw-f64 mask — no POINTER_FREE, no raw-f64 store path, and #7532's declare-at-allocation gate refused the shape. For-init declarators now route through the same `infer_decl_type` the ordinary let/const path uses. `var` is left alone: it is function-scoped and hoisted, so its assignment story differs. churn_alloc 1.60s -> 1.33s (1.20x), unmodified source. * docs(changelog): #7547 for-init local types * chore(changelog): rename to the PR number * chore: bump version to 0.5.1314 --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
…oops The outlined per-`new`-site allocator has been the default since [#bloat]: it collapses ~145 lines of per-class-constant IR per site into one js_object_alloc_class_inline_keys call. The size half of that decision still holds — measured ~268 bytes of machine code per site, +214,656 over an 800-site program. The SPEED half has inverted. The comment reads '~17% faster on an 8M-allocation loop'; today the outlined form is 1.81x SLOWER on churn_alloc and 1.78x on push_cls. Nothing about the inline bump changed — everything around the allocation got cheaper (#7474 #7486 #7487 #7501 #7525 #7532 #7535 #7536 #7552), so the surviving FFI call and the thread-local resolutions it performs now dominate what its code bloat costs. Those resolutions cannot be made cheaper on Darwin: Mach-O has no local-exec TLS model, and building the runtime with -Ztls-model=local-exec leaves the blr through the TLV descriptor byte-identical (measured 1.02x). Only their count can be reduced. So the choice becomes per site rather than global. A `new` inside a loop takes the inline bump; everything else keeps the outlined call and adds nothing to binary size. Loop membership reuses the existing loop_targets stack — switch frames push an empty continue label, every loop pushes a real one, the same discriminator Stmt::Continue already relies on. Measured: churn_alloc 1.81x, push_cls 1.81x, churn 1.56x — the full unconditional-inline ceiling. Size +0 bytes for 800 sites none of which are in loops; equal to all-inline when every site is. tree is -1.4%.
#7510 item 1 (construction), which absorbed the residual half of #7512. Item 2 (death) shipped in #7525; item 3 (slot notes) is untouched.
The defect
js_gc_init_typed_shape_layoutwas emitted after the constructor call, so no raw-f64 class-field store inside a constructor could pass itsGC_OBJ_TYPED_LAYOUT_INTACTguard — neither the inlineand i16 %r, 4096test norclass_field_fast_contract'slayout_typed_raw_f64_slot_for_user. Every one fell back tojs_put_value_set.The one-line reorder does not work, and that is the whole design problem:
init_typed_shape_layoutvalidates that each raw-f64 slot already holds a plain double, and a fresh slot holdsTAG_UNDEFINED, whose0x7FFCtag sits insidelayout_raw_f64_bits' reject range. An early call downgrades every instance it touches. (test_validating_install_refuses_a_freshly_allocated_instancepins this as executable documentation — if that predicate ever acceptsundefined, the test fails and the whole split becomes removable.)The change
A second runtime entry point,
js_gc_declare_typed_shape_layout, skips slot validation. That moves the burden of proof to codegen, wheretyped_shape::class_layout_declarable_at_allocationdischarges it with two conditions:ctor_prologue_param_assigned_fields, non-empty only for a class with no heritage, no field initializers or computed keys, no decorators, plain parameters, and no setter shadowing an assigned field. ALocalGetof a plain parameter cannot throw, allocate, or observethis, so nothing can read a raw-f64 slot between the declaration and its first write. Every, not some — one field assigned later would still be exposed.POINTER_FREE— byte-identical to whatlayout_init_pointer_freealready sets on every fresh instance. The only delta emitted is the intact bit and the shape-shared descriptor install; the collector's view at birth is unchanged. Classes with pointer fields would installSIDE_MASKover the allocator's fill: sound on the pre-filling allocation path, but it would rest on that pre-fill, so they are out of scope.One predicate drives both emitters — the declaration is emitted iff the post-constructor install is suppressed — so they cannot drift into double-installing or into leaving an instance with no descriptor.
Nothing rests on the values actually being numbers. A constructor that stores a string into a
number-declared field is rejected by the store guard (is_plain_number_bits, and the inline path's finite-exponent test), falls back to the boxed setter, and downgrades the descriptor throughlayout_note_slot— the same path any post-install contradiction has always taken.Measurements
Pinned quiet host (load 2.0, wall == user), interleaved, best-of-7:
push_cls—new Node(v, w)×20Mpush_cls_read— same, plus reading both fields backThe collector result is strictly better, not merely equal.
push_cls: promotion 210,488 → 64 bytes, copied 0.0042 → 0.0036 GB, peak RSS 29.6 → 24.1 MB, cycles unchanged at 105. Those are the numbers the equivalent object literal already had (churn: 0.0036 GB, 64 B, 24.2 MB) — #7512's anomaly closed on the memory axis as well as the time one.churn,churn_alloc,push_num,tree,deeplist,churn_readcompile to byte-identical generated objects across the two arms, so their ±2% is host noise by construction. (Compared as object bytes — the object cache key hashes codegen env and compiler identity per #6394, so filenames always differ and cannot answer this.)Object literals do not qualify — and the reason is a finding
HIR rewrites a closed-shape literal to
new __AnonShape_<hash>(…), whose synthesized constructor is a qualifying prologue. But the minted class's field types come out asAny, notNumber.Anyis pointer-bearing, so the gate refuses — and there was no raw-f64 store path to unlock anyway.The corollary is worth its own ticket:
{v: number, w: number}is currently declared to the collector as two pointer slots. The literal path's remaining construction cost is a type-propagation gap, not an ordering one, and it is a different lever from this ticket's.Testing
cargo test -p perry-runtime— 1767 pass, 0 fail.cargo test -p perry-codegen— failure set identical toorigin/main(22 pre-existing; the twoext_registryentries that appeared once did not reproduce in four further full-suite runs — a shared-registry test-order flake).perry-codegen/tests/typed_shape_declared_at_allocation.rs(9 tests): the declaration dominates the constructor call, replaces the post-constructor install, and carries a raw-f64 mask with a null pointer mask — plus six negatives (non-prologue number field, pointer field, untyped field, all-boolean class, no constructor).perry-runtime/src/gc/tests/layout_trace/declared_at_allocation.rs(5 tests): the validating install still refuses a fresh instance, the declaring one accepts it, both still reject a slot-count mismatch and overlapping masks, and a contradicting store both evicts the descriptor and leaves the object conservatively scanned so its string child is still traced.number-declared field, collected hard — all 20,000 readable,typeofstring, matchingnodeexactly, under default and underPERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_VERIFY_EVACUATION=1. A read-before-write probe confirms the gate refuses both shapes that would expose a pre-write slot (the declare symbol is not even linked) and matchesnode.test-files/programs: byte-identical output across both arms, and identical again under GC zeal + from-space protection.cargo fmt --all -- --checkclean;scripts/check_file_size.shclean —new.rswas 19 lines under the 2000-line cap and this change would have pushed it to 2042, hence thelower_call/typed_shape_initsplit.What's left on #7510
Item 3 (
layout_note_slotcompiling away on a store that still matches the canonical mask, ~6% ofchurn_alloc) is untouched, and the ticket's headline acceptance (≥1.5× onchurn_alloc,gc::layoutunder 8%) still is not met —churn_allocis the literal path, which as above needs the type-propagation fix, not this one. #7510 stays open.Summary by CodeRabbit
Performance
Reliability
Documentation