Skip to content

perf(codegen,gc): declare a class's typed-shape layout at allocation, not after its constructor (#7510 item 1, #7512) - #7532

Merged
proggeramlug merged 3 commits into
mainfrom
perf/7510-construction-layout
Aug 6, 2026
Merged

proggeramlug merged 3 commits into
mainfrom
perf/7510-construction-layout

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

#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_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 — neither the inline and i16 %r, 4096 test nor class_field_fast_contract's layout_typed_raw_f64_slot_for_user. Every one fell back to js_put_value_set.

Declaring the fields number was what made the class slower. More type information selected a representation whose guard the construction path had made unsatisfiable.

The one-line reorder does not work, and that is the whole design problem: init_typed_shape_layout validates that each raw-f64 slot already holds a plain double, and a fresh slot holds TAG_UNDEFINED, whose 0x7FFC tag sits inside layout_raw_f64_bits' reject range. An early call downgrades every instance it touches. (test_validating_install_refuses_a_freshly_allocated_instance pins this as executable documentation — if that predicate ever accepts undefined, 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, where typed_shape::class_layout_declarable_at_allocation discharges it with two conditions:

  1. Every raw-f64 field is assigned by the constructor prologue from a plain parameter — perf(codegen): elide provably-dead per-store bookkeeping on class-field stores #7486's 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. A LocalGet of a plain parameter cannot throw, allocate, or observe this, 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.
  2. The pointer mask is empty, so the declared state is POINTER_FREE — byte-identical to what layout_init_pointer_free already 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 install SIDE_MASK over 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 through layout_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:

bench wall speedup
push_cls — new Node(v, w) ×20M 2.360 → 1.520 s 1.553×
push_cls_read — same, plus reading both fields back 2.410 → 1.550 s 1.555×

The 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_read compile 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 as Any, not Number. Any is 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 to origin/main (22 pre-existing; the two ext_registry entries that appeared once did not reproduce in four further full-suite runs — a shared-registry test-order flake).
  • New 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).
  • New 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.
  • Adversarial end-to-end: 20,000 instances constructed with heap strings in a number-declared field, collected hard — all 20,000 readable, typeof string, matching node exactly, under default and under PERRY_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 matches node.
  • 25 class/object-heavy test-files/ programs: byte-identical output across both arms, and identical again under GC zeal + from-space protection.
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean — new.rs was 19 lines under the 2000-line cap and this change would have pushed it to 2042, hence the lower_call/typed_shape_init split.

What's left on #7510

Item 3 (layout_note_slot compiling away on a store that still matches the canonical mask, ~6% of churn_alloc) is untouched, and the ticket's headline acceptance (≥1.5× on churn_alloc, gc::layout under 8%) still is not met — churn_alloc is the literal path, which as above needs the type-propagation fix, not this one. #7510 stays open.

Summary by CodeRabbit

  • Performance

    • Improved handling of numeric fields in newly created objects, enabling more efficient storage and potentially reducing garbage-collection overhead.
    • Benchmark results show improvements in applicable scenarios, with no generated-object changes for other tested workloads.
  • Reliability

    • Added safeguards to preserve conservative storage and tracing when field values or layouts do not match expectations.
    • Expanded coverage for valid, invalid, contradictory, overlapping, and mismatched layout cases.
  • Documentation

    • Added changelog documentation describing the new typed-layout behavior and its limitations.

Loading
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