Skip to content

perf(class): a subclassed hierarchy paid a by-name hash store per field per construction (shapes 0.183 -> 0.146; isolated probe 1.81x) - #7861

Merged
proggeramlug merged 9 commits into
mainfrom
perf/class-field-subclass-closure
Aug 11, 2026
Merged

perf(class): a subclassed hierarchy paid a by-name hash store per field per construction (shapes 0.183 -> 0.146; isolated probe 1.81x)#7861
proggeramlug merged 9 commits into
mainfrom
perf/class-field-subclass-closure

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

A subclassed class hierarchy paid a by-name hash store for every field assignment in every constructor on its chain. gc-handoff/apps/shapes.ts issued 528 000 js_put_value_set calls per run; it now issues 48 000.

shapes 0.1829 → 0.1463 s (−20.0%) on the quiet mini, best-of-5, exit-checked, VERDICT CLEAN (load 1.69 at both ends). A standalone probe isolating just the defect goes 0.3613 → 0.2000 s (1.81×).

Two defects, found in that order; the second was only visible once the first was fixed.

1. The class-field shape guard bet on the DECLARED class

expr/class_field_inline_guard.rs's inline precheck — and the runtime class_field_fast_contract behind it — compared the receiver's class_id and keys_array against the declared class of the expression. One pair, exact match.

Inside a base class's own constructor that bet is not unreliable, it is guaranteed wrong: this in Node2D's constructor is only ever reached through super(...) from a subclass, so both compares fail on every single this.x = x. Same for every inherited read — Node2D's get originDist reading this.x missed 100% of the time.

class_field_subclass_arms() collects the base's transitive subclass closure and the emitter turns the single equality into a disjunction over it. This is the field-side counterpart of the dispatch widening in lower_call/property_get/dynamic_dispatch.rs (#7800).

Soundness does not rest on the layout algorithm's root→leaf field ordering: the slot index and the raw-f64 candidacy are re-derived per candidate subclass, so a shadowing re-declaration or an accessor on the subclass chain drops that arm. Capped at 8 arms. A class with no eligible subclass emits byte-identical IR to before — deliberately, so the corpus-wide cmp stays a usable no-regression instrument.

Instrumented with temporary per-precondition counters: the runtime get guard goes from being entered on every inherited read to never being entered at all (get_guard_calls=0). The widening is taken, not merely emitted.

2. #7512, one level up: no subclass instance ever got an at-allocation typed shape

Fixing (1) left the store side almost unmoved, and the counters said why:

contract_notobj=0 contract_cid=0 contract_keys=0 contract_fieldcount=0
set_frozen=0 set_notplain=0
contract_rawf64=144000        <-- the side table says the slot is not raw-f64

typed_shape::class_layout_declarable_at_allocation consults ctor_prologue_param_assigned_fields, which returns the empty set the moment a class has extends. Empty prologue ⇒ no js_gc_declare_typed_shape_layout at the allocation site ⇒ GC_OBJ_TYPED_LAYOUT_INTACT stays clear for the whole construction ⇒ every raw-f64 field store in every constructor on the chain misses its guard and falls back to js_put_value_set.

That is exactly #7512's mechanism — "declaring the fields number is what makes the class slower: more type information selects a representation whose guard the construction path has made unsatisfiable" — fixed for a standalone class and never extended past it.

It is not a base-class-only tax. Node2D extends nothing, yet its own this.x = x misses too, because the eligibility question is asked of the allocated class. Four TypeScript probes isolate it: a monomorphic class and a hand-flattened two-field class take the fast path on 100% of constructor stores; adding a single extends — even a fieldless one — puts every store on the chain onto the by-name path.

chain_prologue_assigned_fields() answers the same question for a whole chain and distinguishes disqualified from qualified but assigns nothing. The old single-set API conflated those, which is precisely what made a chain unanalysable a class at a time (a fieldless Marker extends Shape is the second case and is fine).

The extra obligations heritage brings:

  • A leading super(...) is skipped rather than truncating the prologue at statement 0 — but only when every argument is This-free, so the parent constructor cannot be handed the half-built instance.
  • Every statement after a class's prologue run must be a Stmt::Expr with no this anywhere in it. A non-leaf constructor's trailing statements run before the leaf writes its own fields, so a this.w read in Shape's body would see a raw-f64-masked slot still holding undefined's NaN-box bits and yield NaN instead of undefined. (Shape.made = Shape.made + 1 is the motivating admission.)
  • Every raw-f64 field anywhere on the chain must be prologue-assigned by its own class, or the declaration is refused.

The expression scan uses perry_hir::walker::walk_expr_children, which is exhaustive and drift-checked against its _mut twin. The statement side is a deliberate whitelist — the HIR has no shared statement walker, and a missed variant there would be a silent wrong answer rather than a missed optimization.

The field-init dead-undefined-write elision consumes the same chain set exactly when the chain form is what authorized the declaration. The two must agree: with the raw-f64 mask live from birth, a field-init undefined write into one of those slots fails layout_raw_f64_bits and downgrades the descriptor on the spot, which would make the declaration worthless.

Validation

Corpus cmp, both arms against the SAME runtime archives, output basename held constant: 18 of 19 byte-identical, only shapes differs. All 19 outputs match node byte-for-byte with exit 0. Because 18 binaries are identical machine code, their ±0.6 ms timing spread on the mini is the in-run noise floor, and no ceiling is approached.

bench base new
shapes 0.1829 0.1463
all 18 others within ±0.6 ms of base

gc-handoff/apps/iso_miss.ts prints checksum 437840 misses 0.

Semantics. A probe covering the shapes CLAUDE.md flags as weak — fieldless subclass, indirect subclass, an un-assigned number field, a string field, and a post-construction d.x = "str" downgrade — is byte-identical to node, including Object.keys order (x,y,unset,w — the elision does not drop a slot) and JSON.stringify output.

Unit tests. 20/20 in lower_call::field_init::tests, 8 of them new, including the two soundness refusals (a trailing statement mentioning this; a super() argument mentioning this) and a pin that the single-class predicate is unchanged.

Gap suite. The full run flagged 6 rows. Every one of them was re-run through the harness with the base 0321c6554 compiler and reproduces identically — two parity_fail (byte-identical output under both compilers) and four SIGABRT with the same there is no reactor running, must be called from the context of a Tokio 1.x runtime panic in perry-ext-http/src/server/server.rs:911. A further 10 rows moved node_fail -> parity_fail, which is an oracle-classification change on a contended host, not a behaviour change: node here is v26.5.1 matching .node-version, and it still refuses test_gap_derived_param_props with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX while both arms print that test's documented expected output exactly. Zero gap regressions are attributable to this change.

cargo fmt --check, scripts/check_file_size.sh, scripts/addr_class_inventory.py and scripts/gc_runtime_root_holders.py are all clean.

What this does NOT reach

The campaign target was shapes ≤ 0.075 s. This gets to 0.1463. Re-profiling the fixed binary shows why, and the remainder is not in this lever:

family before after
by-name property access 40.7% 2.7%
GC layout side tables 34.5% 33.2%
GC collection proper 27.4% 33.9%
TLS (_tlv_get_addr) 9.4% 7.1%

shapes is no longer a property-access problem — that family is spent. It is now ~74% GC bookkeeping: layout_forget_object is the #2 leaf (6.4%) reached from ArenaSweepObjectsState::reclaim_dead_object and js_array_alloc; per_object_slot_mask (4.9%) from HeapChildSlotIterator::new during the trace; layout_addr_filter_add (3.0%) still 100% from layout_transfer during evacuation. That is the open per-object-layout workstream, shared with churn/tree/retain.

Note for #7854

#7854 adds the inline precheck to the strict boxed class-field store arm and touches the same two files. The two are complementary and the conflict is mechanical: emit_class_field_inline_precheck gained a trailing subclass_arms: &[ClassFieldSubclassArm] parameter, and that new call site should pass class_field_subclass_arms(ctx, &class_name, property, field_index, false) so boxed stores get the widening too.

Summary by CodeRabbit

  • Performance Improvements

    • Improved performance when constructing and accessing class fields across inheritance chains.
    • Reduced runtime checks and unnecessary property-update operations.
    • Extended optimized field layouts to eligible subclass constructor chains.
  • Bug Fixes

    • Improved handling of inherited fields, raw numeric fields, and constructor initialization.
    • Preserved safe fallback behavior for unsupported inheritance patterns.
  • Tests

    • Added regression coverage for valid and invalid constructor-chain scenarios.

proggeramlug added a commit that referenced this pull request Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: abb0aa28-8a40-41b0-9aae-4d0e45b07786

📥 Commits

Reviewing files that changed from the base of the PR and between bf98134 and 991bae3.

📒 Files selected for processing (10)
  • changelog.d/7861-class-field-subclass-chain.md
  • crates/perry-codegen/src/expr/class_field_inline_guard.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/property_get.rs
  • crates/perry-codegen/src/expr/property_get/helpers.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/lower_call/field_init.rs
  • crates/perry-codegen/src/lower_call/field_init/tests.rs
  • crates/perry-codegen/src/lower_call/typed_shape_init.rs
  • crates/perry-codegen/src/typed_shape.rs

📝 Walkthrough

Walkthrough

Class-field inline guards now support compatible subclasses with bounded arms. Constructor analysis and typed-shape allocation now validate inherited chains, constructor prologues, raw-f64 assignments, and field-initializer elision.

Changes

Subclass-aware class-field guards

Layer / File(s) Summary
Guard-arm discovery and precheck contract
crates/perry-codegen/src/expr/class_field_inline_guard.rs, crates/perry-codegen/src/expr/mod.rs
The code discovers compatible transitive subclasses, limits widening to eight arms, and validates class IDs, keys, and field counts.
Get and set path integration
crates/perry-codegen/src/expr/property_get.rs, crates/perry-codegen/src/expr/property_get/helpers.rs, crates/perry-codegen/src/expr/property_set.rs
Class-field get and set paths pass subclass arms for boxed and raw-f64 fields while preserving fallback behavior.

Constructor-chain typed layouts

Layer / File(s) Summary
Inheritance-chain prologue analysis
crates/perry-codegen/src/lower_call/field_init.rs
The code resolves complete class chains, validates super() ordering and this usage, and collects per-class assignments.
Allocation layout and field-init integration
crates/perry-codegen/src/lower_call/field_init.rs, crates/perry-codegen/src/lower_call/typed_shape_init.rs, crates/perry-codegen/src/typed_shape.rs
Allocation-time layout checks and field-initializer filtering now use validated chain results and raw-f64 assignment requirements.
Constructor-chain regression coverage
crates/perry-codegen/src/lower_call/field_init/tests.rs, changelog.d/7861-class-field-subclass-chain.md
Tests cover valid and rejected constructor chains, and the changelog records validation and performance results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClassFieldAccess
  participant ClassFieldSubclassArms
  participant InlinePrecheck
  participant ReceiverShape
  ClassFieldAccess->>ClassFieldSubclassArms: Discover compatible subclass arms
  ClassFieldSubclassArms->>InlinePrecheck: Pass bounded arm list
  InlinePrecheck->>ReceiverShape: Match class ID, keys, and field count
  ReceiverShape-->>ClassFieldAccess: Use fast path or fallback
Loading
sequenceDiagram
  participant FieldInitializer
  participant ConstructorChainAnalysis
  participant TypedShapeAllocation
  FieldInitializer->>ConstructorChainAnalysis: Resolve and validate inheritance chain
  ConstructorChainAnalysis->>TypedShapeAllocation: Provide per-class assignments
  TypedShapeAllocation-->>FieldInitializer: Return allocation-layout eligibility
Loading

Possibly related PRs

  • PerryTS/perry#6823: Earlier bounded polymorphic inline-cache handling extended here to class-field guards.
  • PerryTS/perry#7532: Earlier typed-shape-at-allocation logic extended here to inherited constructor chains.
  • PerryTS/perry#7686: Related typed pointer-field store and constructor-prologue eligibility changes.

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/class-field-subclass-closure

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 added a commit that referenced this pull request Aug 11, 2026
@proggeramlug
proggeramlug force-pushed the perf/class-field-subclass-closure branch from 3f3169c to bcd176a Compare August 11, 2026 15:35
@proggeramlug
proggeramlug force-pushed the perf/class-field-subclass-closure branch from bcd176a to 991bae3 Compare August 11, 2026 16:31
@proggeramlug
proggeramlug merged commit 468d66e into main Aug 11, 2026
0 of 19 checks passed
@proggeramlug
proggeramlug deleted the perf/class-field-subclass-closure branch August 11, 2026 16:32
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