Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions changelog.d/11840-class-fresh-evaluations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
**A class declaration evaluated more than once now creates a fresh class on every evaluation after the first, as Node does** (#11759).

`function f() { class K {} return K }` returned the same class on every call, so `f() === f()` was `true`, a property written to one evaluation's `prototype` showed up on the other's instances, `instanceof` answered `true` across evaluations, and every evaluation shared the same static fields. The same happened for a class declared in a module-level loop body. Classes that capture variables, have private elements, or have a computed key or a runtime `extends` value were already fresh per evaluation.

The fix follows owner decision 64, option (c′). The first evaluation of a declaration is still the shared class, so code that runs once keeps the static class and its fast paths: bundle wrappers (esbuild `__esm`/`__commonJS` bodies, which only the runtime can see running once), functions called once, and module top level. The second and later evaluations each create a fresh class object, built from #11780's template shapes.

- **The evaluation check.** Whether the first evaluation has happened is a fact of the class template: a module-private word per template (`@perry_class_first_eval.<template>`) that holds the shared class once the first evaluation has handed it out. An evaluation does one load, one compare and one branch. No word and no check is emitted where `lower::run_once` proves the declaration runs once: module top level outside loops, IIFE bodies, and functions called once from such a position.
- **The class's own name inside a repeatable declaration.** It resolves to that evaluation. The members read it from a guarded class environment, the scheme fresh class expressions use, so first-evaluation instances carry no capture fields.
- **Static forms where the binding holds the first evaluation.** `new C(...)`, `C.<static field>` and `C.<static method>(...)` through the binding are guarded on the first evaluation and take the static form there: a static `new`, a direct static-field load and a static call. The exact-receiver inliner treats the guarded `new` as constructing `C`; every evaluation runs C's constructor and carries C's declaration-time method table.
- **Loops test once.** A `for`, `while` or `do` loop holding such 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 is lowered twice: the first-evaluation copy holds only the static forms, so `const c = new C()` in it is scalar-replaced as a single evaluation's would be, and the later-evaluation copy holds only the by-value forms. A loop that defines a closure or a class keeps its per-use tests. Escape analysis now walks a class evaluation and the first-evaluation test by their operands instead of escaping every candidate in the body.
- **Field proofs come from the template.** A local bound to the guarded `new` (`const c = new C()`) gets the `Ptr<Shape>` proof of every rule except the exact class object, recorded as a template-lineage fact: every evaluation of one template runs the same constructor chain, field initializers and methods on the same arguments, so which fields hold a Number is a fact of the template. It feeds the Number-by-construction proof and the canonical-number test for reads of those fields in both copies of a versioned loop, so `t += c.x` keeps `t` a Number. It never licenses a bare field load or a direct method call by itself.
- **The rest of a body tests once.** The statements after `const c = new C()` in a function body test the first evaluation once and are lowered twice when the binding cannot be rebound, they define no closure or class, hold at most 400 HIR nodes, and either use `c` or test the binding outside a loop. In the first-evaluation copy (and in a versioned loop's) `c` is an instance of the shared class, which is the lineage fact's missing exactness: its field reads and method calls take the `Ptr<Shape>` forms.
- **Subclasses in the same body.** `class D extends L`, where `L` is such a declaration in the same body, is one too. D's template extends L's template, and D shares its first evaluation only while L's binding holds L's first evaluation. Every other evaluation of D pins the evaluated `L` it extends.
- **Captured values.** The template-keyed capture snapshot (`RegisterClassCaptures`, read when the shared class is constructed by value) and its body-end refreshes belong to the first evaluation only. A later evaluation carries its own capture array.
- **Runtime.** A class function object has a second INT32 capture slot that records whether it is its declaration's first evaluation. Generated code sets it when the first evaluation hands the shared class out; the slot index and value are in `perry-abi`. From then on the shared class is a class of its own:
- a later evaluation's static writes are no longer mirrored into it (#6530);
- it no longer stands in for the template in `CLASS_OBJECT_VALUES`;
- its static parent is no longer replaced by a later evaluation's parent;
- `instanceof` against either evaluation, and `instance.constructor` of a later evaluation's instance, follow the instance's actual prototype chain.
- **Fixed on the way.** `monomorph`'s default-argument padding now reaches a `new` inside `ClassEnvStamp`. It also missed fresh class expressions' guarded `new K()`, which left captured values in omitted constructor parameters.

**Validation**

- **New gap tests.** `test_gap_11759_class_decl_fresh_per_evaluation`, `test_gap_11759_class_decl_loop_extends_wrapper` and `test_gap_11759_class_decl_evaluations_gc` cover identity, prototype isolation, statics, `delete`, captures, self-reference, instance fields, a module-top loop, `extends` of an evaluated class, a module-level parent, an esbuild `__esm` wrapper, a CommonJS factory a three-level chain with `super` calls and inherited statics over three evaluations, a subclass declared in a nested function, and 400 evaluations under GC pressure.
- All three match Node 26.5.1. The GC test also matches under `PERRY_GC_MOVING_LOOP_POLLS`, `PERRY_GC_FORCE_EVACUATE`, `PERRY_GC_POISON_FROMSPACE` and a 64 KB allocation schedule.
- All three fail on main and on a sabotaged build whose every evaluation is the shared class.
- `test_gap_11759_class_decl_first_evaluation_versions` runs the versioned loops and body tails through a first and two later evaluations: numeric and string fields, captured values, methods, `instanceof`, statics, and a binding the tail rebinds. It matches Node on main and on this change, and under the same five GC settings on this change.
- **IR test.** `crates/perry/tests/issue_11759_class_first_evaluation.rs` checks that a repeatable declaration emits the check and fresh later evaluations, that a module-top loop body is repeatable, that a loop over the class is versioned once and a loop rebinding it is not, that an accumulator over its field adds as a Number in both copies, that the rest of a body after `const c = new C()` is versioned and a rest that defines a closure is not, that an IIFE, a module-top block and a function called once emit neither, and that the esbuild wrapper keeps its one class.
- **Suites.** The full gap suite against main 2026ecfe6: no test goes from PASS to anything else, and the only real differences are the three new tests passing (rows that failed to build under load on one arm were rerun; the 16 HTTP/2 and WebSocket tests fail on both). Runtime: 4850/0 (`RUST_TEST_THREADS=1`, release). `perry-hir` 880/0, `perry-transform` 198/0, `perry-codegen` 2473/0, the IR test 9/9, `cargo fmt --check` clean. `gc_call_effects --check` (linux-x86_64) shows no drift from this change, and `--check-wasm-abi` is current.
- **Cost.** Instruction counts against main 2026ecfe6.
- tsc and Zod (n=5): Zod +0.02% instructions, 0/0 full collections, RSS 55.5/55.3 MB; tsc +0.04%, 82/82 full collections, RSS 296.3/296.7 MB. `.text` is tsc −37.7 KB and Zod +3.7 KB. Neither program has a versioned loop or body tail (tsc has 4 repeatable declarations, Zod none), so the versioning adds no code to either.
- A body that runs once but cannot be proven to, with a hot loop over its class (`new` + method + static field + static call): 215 → 216 instructions per iteration.
- `new C()` + field read into an accumulator: 35 → 35;
- method call on an instance built before the loop: 7 → 7;
- static field read: 30 → 31; static call: 82 → 84. The loop's IR is the same as main's; the function holds both copies of the loop, and the register allocator rematerializes two constants in the hot one.
- A later evaluation pays #11780's fresh-class path, which this change does not touch: evaluation + `new` + call is 30.8k instructions against 3.8k for the shared class (node: 13.5k); `new` + call on an existing later evaluation is 9.0k against 4.2k; a static method adds ~650 per evaluation. Most of an evaluation is its prototype, built at the first `new` (about 30%: the object, one function object per method, and two template-cell lookups through the class registry lock), linking the instance to it (about 24%: interning the per-evaluation instance shape and marking the prototype), the class object (10%) and the minor collections all of that allocation causes (11%).
- Of the repo files that evaluate such a declaration more than once:
- `test_gap_10490_implicit_this_scope_rooting` (500 evaluations): +0.8%;
- `test_gap_9466_shadowed_class_identity` (3 evaluations): +10.7% of a 6.3M-instruction run.
7 changes: 7 additions & 0 deletions crates/perry-abi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ pub const STATIC_CALL_MEMO_C_OFFSET: usize = 8;
pub const STATIC_CALL_MEMO_OWNER_OFFSET: usize = 16;
pub const STATIC_CALL_MEMO_VALUE_OFFSET: usize = 24;

/// #11759 (c′): the capture slot of a class function object (an INT32) that
/// says whether the object is its declaration's first evaluation, and the
/// value generated code stores there when the first evaluation hands the
/// shared class out (`INT32_TAG | 1`; the slot is born `INT32_TAG | 0`).
pub const CLASS_EVALUATION_STATE_CAPTURE: usize = 1;
pub const CLASS_FIRST_EVALUATION_STATE: u64 = 0x7FFE_0000_0000_0001;

/// `gc::GC_TYPE_CLOSURE`: the GcHeader type byte (at payload - 8) that makes a
/// cell a function object. The kind is this byte, never a payload magic.
pub const GC_TYPE_CLOSURE: u8 = 4;
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/codegen/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,11 @@ pub(super) fn emit_module_artifacts(
);
progress.checkpoint("string pool and registration initializer");

crate::expr::class_first_evaluation::emit_flag_globals(
llmod,
&super::fresh_class_templates::shared_first_templates(hir),
);

super::namespace_value_getters::emit(llmod, module_prefix, cross_module);

Ok(())
Expand Down
67 changes: 56 additions & 11 deletions crates/perry-codegen/src/codegen/fresh_class_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,31 +29,76 @@ pub(crate) fn template_cell_words(class: Option<&perry_hir::Class>) -> usize {
/// Every `ClassExprFresh` template named anywhere in `hir`: module init,
/// function bodies, class members and nested closures.
pub(crate) fn fresh_class_templates(hir: &perry_hir::Module) -> HashSet<String> {
fn visit_expr(e: &Expr, out: &mut HashSet<String>) {
if let Expr::ClassExprFresh { template, .. } = e {
templates_where(hir, |_| true)
}

/// The templates whose first evaluation is the shared class (#11759 (c′),
/// `ClassExprFresh { shared_first_evaluation: Some(_) }`) or that a
/// `ClassIsFirstEvaluation` names, in a stable order.
pub(crate) fn shared_first_templates(hir: &perry_hir::Module) -> Vec<String> {
let mut out: Vec<String> = collect(hir, &mut |e, out| match e {
Expr::ClassExprFresh {
template,
shared_first_evaluation: Some(_),
..
}
| Expr::ClassIsFirstEvaluation { template, .. } => {
out.insert(template.clone());
}
_ => {}
})
.into_iter()
.collect();
out.sort();
out
}

fn templates_where(hir: &perry_hir::Module, wanted: fn(&Expr) -> bool) -> HashSet<String> {
let visit_expr = &mut |e: &Expr, out: &mut HashSet<String>| {
if let Expr::ClassExprFresh { template, .. } = e {
if wanted(e) {
out.insert(template.clone());
}
}
};
collect(hir, visit_expr)
}

fn collect(
hir: &perry_hir::Module,
on_expr: &mut dyn FnMut(&Expr, &mut HashSet<String>),
) -> HashSet<String> {
fn visit_expr(
e: &Expr,
out: &mut HashSet<String>,
on_expr: &mut dyn FnMut(&Expr, &mut HashSet<String>),
) {
on_expr(e, out);
if let Expr::Closure { body, .. } = e {
visit_body(body, out);
visit_body(body, out, on_expr);
}
perry_hir::walker::walk_expr_children(e, &mut |c| visit_expr(c, out));
perry_hir::walker::walk_expr_children(e, &mut |c| visit_expr(c, out, on_expr));
}
fn visit_body(body: &[Stmt], out: &mut HashSet<String>) {
fn visit_body(
body: &[Stmt],
out: &mut HashSet<String>,
on_expr: &mut dyn FnMut(&Expr, &mut HashSet<String>),
) {
for s in body {
perry_hir::walker::stmt_any_expr(s, &mut |e| {
visit_expr(e, out);
visit_expr(e, out, on_expr);
false
});
}
}
let mut out = HashSet::new();
visit_body(&hir.init, &mut out);
visit_body(&hir.init, &mut out, on_expr);
for f in &hir.functions {
visit_body(&f.body, &mut out);
visit_body(&f.body, &mut out, on_expr);
}
for class in &hir.classes {
if let Some(ctor) = &class.constructor {
visit_body(&ctor.body, &mut out);
visit_body(&ctor.body, &mut out, on_expr);
}
for f in class
.methods
Expand All @@ -62,11 +107,11 @@ pub(crate) fn fresh_class_templates(hir: &perry_hir::Module) -> HashSet<String>
.chain(class.getters.iter().map(|(_, f)| f))
.chain(class.setters.iter().map(|(_, f)| f))
{
visit_body(&f.body, &mut out);
visit_body(&f.body, &mut out, on_expr);
}
for field in class.fields.iter().chain(class.static_fields.iter()) {
if let Some(init) = &field.init {
visit_expr(init, &mut out);
visit_expr(init, &mut out, on_expr);
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-codegen/src/collectors/escape_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ pub fn find_new_candidates(
} if !boxed_vars.contains(id) && !module_globals.contains_key(id) => {
candidates.insert(*id, class_name.clone());
}
// #11759 (c′): `new C()` guarded on a class declaration's first
// evaluation. Its first-evaluation branch is the static `new`; a
// loop versioned on the guard (`stmt/class_first_loop.rs`) lowers
// that branch alone, which scalar replacement then sees. Where the
// guard stays, the init is not a `New` and is never replaced.
Stmt::Let {
id,
init:
Some(Expr::Conditional {
condition,
then_expr,
..
}),
..
} if matches!(condition.as_ref(), Expr::ClassIsFirstEvaluation { .. })
&& !boxed_vars.contains(id)
&& !module_globals.contains_key(id) =>
{
if let Expr::New { class_name, .. } = then_expr.as_ref() {
candidates.insert(*id, class_name.clone());
}
}
Stmt::If {
then_branch,
else_branch,
Expand Down Expand Up @@ -1159,6 +1181,14 @@ pub fn check_escapes_in_expr(
// arm (around line 4949). The `ErrorNew(None)` here was dead —
// removed.
| Expr::BigInt(_) => {}
// #11759 (c′): a class declaration's evaluation and its
// first-evaluation test hold candidates only in their operand
// expressions (captured values, static initializers, the binding).
Expr::ClassExprFresh { .. } | Expr::ClassIsFirstEvaluation { .. } => {
perry_hir::walker::walk_expr_children(e, &mut |child| {
check_escapes_in_expr(child, candidates, classes, escaped)
});
}
// Catch-all: conservatively mark any candidate referenced in an
// unrecognized expression as escaped. This is safe — just misses
// the optimization for patterns we haven't enumerated.
Expand Down
Loading
Loading