Skip to content

class: later evaluations of a class declaration are fresh classes; first evaluation stays at shared cost (Closes #11759) - #11840

Merged
proggeramlug merged 4 commits into
mainfrom
fix/class-fresh-evaluations
Oct 3, 2026
Merged

proggeramlug merged 4 commits into
mainfrom
fix/class-fresh-evaluations

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #11759 with option (c′), owner decision 64: the first evaluation of a class declaration keeps the shared class, and the 2nd and later evaluations create a fresh class, as node does (function f(){ class K {} return K } f() !== f()).

Mechanism

  • Each repeatable declaration gets one module-private word holding the shared class after its first evaluation.
  • An evaluation costs one load, compare and branch. Where run_once proves a single run, there is no word and no check.
  • Subclasses in the same body keep a static parent and follow the evaluated parent on later evaluations.
  • Later evaluations don't leak into the first: statics, parent and captures stay separate, and instanceof and .constructor follow the real chain.
  • The esbuild __esm wrapper still emits the check, because run_once can't prove it runs once without a pattern table. The class correctly stays shared at runtime.

First-evaluation cost kept at main's level

  • A loop over a repeatable class tests the first evaluation once and is compiled in two copies.
  • After const c = new C(), the rest of the body tests once and is compiled twice, bounded to 400 HIR nodes.
  • Instances keep their template's field proofs, so t += c.x stays a plain add.
  • Allocation elision is fixed for functions containing fresh class evaluations, which also helps fresh class expressions.
Per iteration (instructions) main this PR
whole iteration 215 216
new C() + field read 35 35
method call on an instance built before the loop 7 7
static call / static field read 82 / 30 84 / 31 (accepted per decision 68.1)

Verification

  • Full gap A/B vs main: 0 pass→fail; 3 new tests go fail→pass.
  • Moving GC: 20/20.
  • hir 880/0, transform 198/0, codegen 2473/0, runtime 4850/0.
  • fmt, wasm ABI and gc_effects pass.
  • Zod +0.02% and tsc +0.04%, full collections equal; .text tsc −37.7 KB.

Not addressed here: later evaluations still run #11780's fresh runtime path, about 8× a shared class. That's a separate lane.

Ralph Küpper added 4 commits October 3, 2026 23:40
…classes

A class declaration evaluated more than once returned the same class object
on every evaluation (`function f() { class K {} return K } f() === f()`), so
its prototype, statics and instanceof were shared across evaluations. Owner
decision 64, option (c′): the first evaluation keeps the shared class and
every later evaluation creates a fresh class object.

- HIR: a function-body or block declaration that `lower::run_once` cannot
  prove runs once, and that has no private names, computed keys or runtime
  heritage, lowers to `ClassExprFresh { shared_first_evaluation }`. Its
  captures, including the self-binding, use the guarded class environment.
  `new C()` and `C.<static field>` through its binding are guarded on the
  first evaluation (`ClassIsFirstEvaluation`) and keep their static forms
  there. A subclass in the same body shares its first evaluation only while
  the parent's binding holds the parent's first evaluation. The
  template-keyed capture snapshot belongs to the first evaluation.
- Codegen: a module-private word per template holds the shared class once
  the first evaluation has handed it out. An evaluation costs one load,
  compare and branch; a run-once declaration emits nothing. The first
  evaluation marks the class function object.
- Runtime: a class function object that is its declaration's first
  evaluation is a class of its own. Later evaluations' static writes are not
  mirrored into it, it does not stand in for the template, its static parent
  is kept, and instanceof and `constructor` follow the actual chain.
- Transform: the exact-receiver inliner recognizes the guarded `new`.
  Monomorph's default padding reaches `ClassEnvStamp`.

Closes #11759
#11759 (c′) guarded `new C()` and `C.<static field>` on the declaration's
first evaluation at every use, so a hot loop paid the test per iteration and
the guarded `new` lost scalar replacement.

- Codegen: a `for`/`while`/`do` loop holding first-evaluation guards on a
  binding it cannot rebind (not boxed, not a module global, never reassigned
  in the body, not rebound by the loop) tests the binding once before the
  loop and lowers the loop twice: the first-evaluation copy holds only the
  static forms, the later-evaluation copy only the by-value forms. A loop
  that defines a closure or class is left alone.
- Escape analysis: a `let` bound to the guarded `new` is a scalar-replacement
  candidate (replaced only where the versioned copy lowers it as a plain
  `new`). A class declaration's evaluation and the first-evaluation test are
  walked by their operands instead of the catch-all that escaped every
  candidate of the body.
- HIR: `C.<static method>(args)` through a repeatable declaration's binding is
  guarded like a static field read, keeping the static call on the first
  evaluation.

Closes #11759
#11759 (c′) left two first-evaluation costs behind the loop versioning:
`t += c.x` kept `t` boxed, because one local has one representation for the
whole function and the later-evaluation copy's `c.x` had no number proof; and
an instance built before a loop (`const c0 = new C()`) kept its per-use test
and dispatched its method calls through the inline cache.

- Ptr<Shape> proof: a local bound to a repeatable declaration's guarded `new`
  is proven by every rule except the exact class object, and recorded as a
  template-lineage fact. Every evaluation of one template runs the same
  constructor chain, initializers and methods on the same arguments, so which
  fields hold a Number is a template fact: it feeds the Number-by-construction
  proof and the canonical-f64 predicate for reads of those fields, in both
  loop copies. It never licenses a bare load or a direct call.
- Codegen: the rest of a function body after `const c = new C()` through such
  a binding tests the first evaluation once and is lowered twice, like a
  versioned loop, when the binding cannot be rebound, the rest defines no
  closure or class and holds at most 400 HIR nodes. In the first-evaluation
  copy (and in a versioned loop's) the instance is the shared class's, which
  is the lineage fact's missing exactness: its field reads and method calls
  take the Ptr<Shape> forms.

  The rest is versioned only when it gains from it: it uses the instance,
  or tests the binding outside a loop (a loop versions itself). A class
  with captures, whose first-evaluation `new` is stamped with its
  evaluation, versions the same way but keeps the guarded instance forms.

Closes #11759
@proggeramlug
proggeramlug merged commit 833a826 into main Oct 3, 2026
24 checks passed
@proggeramlug
proggeramlug deleted the fix/class-fresh-evaluations branch October 3, 2026 21:41
@coderabbitai

coderabbitai Bot commented Oct 3, 2026

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7ea0a541-05a4-44ec-a84e-1b7a0b98c6f3
📥 Commits

Reviewing files that changed from the base of the PR and between 5b06d69 and 7f090f7.

📒 Files selected for processing (60)
  • changelog.d/PENDING-class-fresh-evaluations.md
  • crates/perry-abi/src/lib.rs
  • crates/perry-codegen/src/codegen/artifacts.rs
  • crates/perry-codegen/src/codegen/fresh_class_templates.rs
  • crates/perry-codegen/src/collectors/escape_check.rs
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/collectors/ptr_shape.rs
  • crates/perry-codegen/src/collectors/ptr_shape_entry.rs
  • crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs
  • crates/perry-codegen/src/collectors/refs.rs
  • crates/perry-codegen/src/expr/class_first_evaluation.rs
  • crates/perry-codegen/src/expr/dispatch.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/stmt/class_first_loop.rs
  • crates/perry-codegen/src/stmt/mod.rs
  • crates/perry-codegen/src/type_analysis/numeric.rs
  • crates/perry-codegen/src/type_analysis/refine.rs
  • crates/perry-hir/src/analysis/value_types.rs
  • crates/perry-hir/src/analysis/value_types_tests.rs
  • crates/perry-hir/src/ir/expr.rs
  • crates/perry-hir/src/lower/context_new.rs
  • crates/perry-hir/src/lower/expr_function.rs
  • crates/perry-hir/src/lower/lower_expr.rs
  • crates/perry-hir/src/lower/lower_expr/arm_class.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower/shared_mutable_capture.rs
  • crates/perry-hir/src/lower/tests/capture_stash.rs
  • crates/perry-hir/src/lower/tests/class_decl_self_binding.rs
  • crates/perry-hir/src/lower/tests/class_expr_naming_and_shadowing.rs
  • crates/perry-hir/src/lower/tests/fresh_class_extends_renamed.rs
  • crates/perry-hir/src/lower/tests/issue_11157_class_decl_self_statics.rs
  • crates/perry-hir/src/lower_decl/block.rs
  • crates/perry-hir/src/lower_decl/body_stmt.rs
  • crates/perry-hir/src/lower_decl/class_decl.rs
  • crates/perry-hir/src/lower_decl/class_decl/decl_self_binding.rs
  • crates/perry-hir/src/lower_decl/mod.rs
  • crates/perry-hir/src/monomorph/defaults.rs
  • crates/perry-hir/src/monomorph/tests.rs
  • crates/perry-hir/src/stable_hash/expr.rs
  • crates/perry-hir/src/walker/expr_mut.rs
  • crates/perry-hir/src/walker/expr_ref.rs
  • crates/perry-hir/tests/class_self_new_shadowing.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/parent_static.rs
  • crates/perry-runtime/src/object/class_registry/state.rs
  • crates/perry-runtime/src/object/class_value.rs
  • crates/perry-runtime/src/object/field_get_set/class_object_props.rs
  • crates/perry-runtime/src/object/field_get_set/class_object_template.rs
  • crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs
  • crates/perry-runtime/src/object/instanceof/dynamic_dispatch.rs
  • crates/perry-transform/src/inline/call_inliner.rs
  • crates/perry-transform/src/inline/exact_receivers.rs
  • crates/perry-transform/src/inline/factory_specialize.rs
  • crates/perry-transform/src/inline/substitute.rs
  • crates/perry/tests/issue_11759_class_first_evaluation.rs
  • test-files/test_gap_11759_class_decl_evaluations_gc.ts
  • test-files/test_gap_11759_class_decl_first_evaluation_versions.ts
  • test-files/test_gap_11759_class_decl_fresh_per_evaluation.ts
  • test-files/test_gap_11759_class_decl_loop_extends_wrapper.ts
 _________________________________________________
< My whiskers twitch when I detect a memory leak. >
 -------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 pushed a commit that referenced this pull request Oct 3, 2026
…its prototype link in separate captures

#11840 put the evaluation state in capture 1 of the class function object
and #11843 put the prototype link there too; the merge left conflict
markers in class_value.rs and main does not compile. The evaluation state
keeps capture 1 (codegen writes it at CLASS_EVALUATION_STATE_CAPTURE); the
prototype link moves to capture 2, and the object is minted with three
captures. Const asserts keep the two slots apart.
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.

Repeated evaluation of a non-capturing class declaration returns the same class object

1 participant