Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8caf8c9
refactor(runtime): one reader for "which class does this constructor …
Sep 26, 2026
a463fd5
fix(runtime,codegen): a class used as a value is its function object …
Sep 26, 2026
4fbe113
perf(codegen,runtime): class values cost a cached load; gates for the…
Sep 26, 2026
db5918c
perf(runtime): reject an ordinary function before proving a class fun…
Sep 26, 2026
2e6b145
refactor(runtime): class statics are the class function object's own …
Sep 26, 2026
6a5cd0d
fix(runtime): a class function object as a dynamic parent, a bind tar…
Sep 26, 2026
a10b3e9
perf(runtime): class probes off the hot bind and callable paths
Sep 26, 2026
888124b
chore: root-holder inventory for the class-value table and the delete…
Sep 26, 2026
1aed5f5
perf(runtime,codegen): a class function object reaches the class look…
Sep 26, 2026
31737ff
perf(runtime): class constructors are a shape fact; ordinary receiver…
Sep 26, 2026
5317821
perf(runtime): keep value_is_callable inlined (bind path)
Sep 26, 2026
c77b882
perf(runtime): class reads use the caller's key header; ordinary func…
Sep 26, 2026
9a9107c
perf(runtime): one class probe per method-value read; the verdict cac…
Sep 27, 2026
221c633
refactor(runtime): a class constructor's own [[Prototype]] is recorde…
Sep 26, 2026
ea96c7a
fix(runtime,codegen): a declared static's compiled alias follows the …
Sep 26, 2026
0c5906e
refactor(runtime): class static symbols are own symbol properties of …
Sep 26, 2026
a2c21fb
fix(runtime): a class constructor's name and length are own data of i…
Sep 27, 2026
3a24e30
chore: gate inventories after the port onto the function-own-props base
Sep 27, 2026
83dbfb7
changelog: name the fragments after PR #11609
Sep 28, 2026
a77b52e
Merge remote-tracking branch 'origin/main' into mq-11609
Sep 28, 2026
6921b7e
Merge remote-tracking branch 'origin/main' into mq-11609
Sep 28, 2026
ed76b13
wasm32: regenerate runtime_abi.tsv for the class-as-function-object s…
Sep 28, 2026
1cdb55f
Merge remote-tracking branch 'origin/main' into mq-11609
Sep 28, 2026
df94a3a
fix(codegen): a class expression's capture refresh re-reads its captu…
Sep 28, 2026
dcc7591
Merge origin/main: adopt generated gc-call-effects tables (#11565)
Sep 28, 2026
ff0eaeb
gc_effects: regenerate tables for the class function object entries
Sep 28, 2026
4eb457f
runtime: trace a class function object's statics bag from the class-v…
Sep 28, 2026
19cbd0c
runtime: move class-method binding out of native_module.rs
Sep 28, 2026
98c407d
runtime: repoint the class-method-bind holder entries; read the stati…
Sep 28, 2026
e6296cf
fix(codegen): a class capture refresh roots its capture array in one …
Sep 29, 2026
155704a
Merge main into perf-class-function-objects
Sep 29, 2026
3a51d4d
Merge branch 'main' into perf-class-function-objects
proggeramlug Sep 29, 2026
4e41a3b
Merge main into perf-class-function-objects
Sep 29, 2026
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
9 changes: 9 additions & 0 deletions changelog.d/11609-class-name-length-own-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Fixed

A class constructor's `name` and `length` are now own data properties of its
function object, as in Node: `delete C.name` makes `C.name` read the value
inherited from its prototype (`""` from `Function.prototype`, or the parent
class's name for a subclass) instead of `undefined`, `delete C.length` reads
`0`, and `Object.getOwnPropertyNames(C)` no longer lists a deleted `name` or
`length`. Reading `C.name` or `obj.constructor.name` no longer builds a new
string on every read (about 5x fewer instructions for `C.name`).
9 changes: 9 additions & 0 deletions changelog.d/11609-class-values-are-function-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### Fixed

A class used as a value is now a real function object. It used to be encoded
as the int32 number equal to its internal class id, so `1 === SomeClass` could
be true, `switch (1) { case SomeClass: }` matched, `[SomeClass, 1].indexOf(1)`
found the class, `JSON.stringify({ c: SomeClass })` printed the id and
`SomeClass instanceof Object` was `false`. Each class now has one function
object per agent, which compares, hashes, prints and reflects as it does in
Node (`[class A extends B] { statics }` in `util.inspect`).
4 changes: 1 addition & 3 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -783,9 +783,7 @@ pub(super) fn compile_closure(
// closure's synthetic this slot with the enclosing ClassRef rather
// than the old 0.0 sentinel so arrows in static fields retain the
// class constructor as their SuperProperty receiver.
let class_ref = crate::nanbox::double_literal(f64::from_bits(
crate::nanbox::INT32_TAG | class_id as u64,
));
let class_ref = crate::expr::emit_class_value(blk, class_id);
blk.store(DOUBLE, &class_ref, &slot);
} else if entry_bound_this {
// A valid non-pointer until the prologue's receiver read below
Expand Down
6 changes: 2 additions & 4 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1549,10 +1549,8 @@ pub(super) fn emit_namespace_populator(
crate::expr::nanbox_pointer_inline(blk, &handle)
}
NamespaceEntryKind::LocalClass { class_id } => {
// INT32-tagged class-id NaN-box: 0x7FFE_0000_0000_0000 |
// (class_id & 0xFFFFFFFF). Matches `Expr::ClassRef`.
let bits = crate::nanbox::INT32_TAG | (*class_id as u64 & 0xFFFF_FFFF);
crate::nanbox::double_literal(f64::from_bits(bits))
// The class's function object, as `Expr::ClassRef` lowers.
crate::expr::emit_class_value_cached(ctx, *class_id)
}
NamespaceEntryKind::ForeignFunction {
source_prefix,
Expand Down
25 changes: 18 additions & 7 deletions crates/perry-codegen/src/codegen/method_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,24 @@ pub(in crate::codegen) fn compile_static_method(

let ic_base = llmod.ic_counter;
let buffer_alias_base = llmod.buffer_alias_counter;
// The prologue's cache of this class's function object (see
// `js_static_this_resolve_class`); thread-local when workers exist, so
// each agent caches its own.
let class_value_slot = format!("@{llvm_name}__classval");
llmod.add_raw_global(format!(
"{class_value_slot} = private {}global double 0.0, align 8",
if crate::codegen::program_has_worker() {
"thread_local "
} else {
""
}
));
let lf = llmod.define_function(&llvm_name, DOUBLE, params);

// gh #6206 / #6081: same shadow-frame emission as compile_method — static
// method bodies were equally invisible to the exact-roots copying minor.
// One extra slot roots the resolved receiver: static `this` is usually
// the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a
// the class's pinned function object, but `js_static_this_resolve_class` returns a
// REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()`
// dynamic dispatch, and that object may be reachable only from this slot.
// #10663: decided before any statement is lowered.
Expand Down Expand Up @@ -88,10 +100,6 @@ pub(in crate::codegen) fn compile_static_method(
// path. (Previously `this` fell through to `js_implicit_this_get` and
// read back `undefined`.)
let class_ref_cid = class_ids.get(&class.name).copied().unwrap_or(class.id);
let class_ref_lit = {
let bits = crate::nanbox::INT32_TAG | (class_ref_cid as u64 & 0xFFFF_FFFF);
crate::nanbox::double_literal(f64::from_bits(bits))
};
let (this_slot, locals): (String, HashMap<u32, String>) = {
let blk = lf.block_mut(0).unwrap();
let this_slot = blk.alloca(DOUBLE);
Expand All @@ -103,8 +111,11 @@ pub(in crate::codegen) fn compile_static_method(
// real receiver (test262 class/elements static-private-*).
let resolved_this = blk.call(
DOUBLE,
"js_static_this_resolve",
&[(DOUBLE, &class_ref_lit)],
"js_static_this_resolve_class",
&[
(I32, &(class_ref_cid as i32).to_string()),
(PTR, &class_value_slot),
],
);
blk.store(DOUBLE, &resolved_this, &this_slot);
if crate::codegen::helpers::precise_root_analysis_enabled() {
Expand Down
3 changes: 1 addition & 2 deletions crates/perry-codegen/src/codegen/static_fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,7 @@ pub(super) fn init_static_fields_late(
// class-ref NaN-box a static method binds (see
// `compile_static_method`) for the init's duration.
let seeded_this = ctx.class_ids.get(&c.name).copied().map(|cid| {
let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF);
let class_ref_lit = crate::nanbox::double_literal(f64::from_bits(bits));
let class_ref_lit = crate::expr::emit_class_value_cached(ctx, cid);
let this_slot = ctx.func.alloca_entry(DOUBLE);
ctx.block().store(DOUBLE, &class_ref_lit, &this_slot);
ctx.this_stack.push(this_slot);
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/codegen/string_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -968,6 +968,12 @@ pub(super) fn emit_string_pool(
// #1788: static methods are emitted as `perry_static_*` (no `this`
// param). Collect them for the runtime CLASS_STATIC_METHODS table.
for sm in &class.static_methods {
// A `static { }` block is lowered to a synthetic static method the
// class's initializer calls directly. It is not a member: never
// registered, so no reflection (`Reflect.ownKeys(C)`) can see it.
if sm.name.starts_with("__perry_static_init_") {
continue;
}
let llvm_name = scoped_static_method_name(module_prefix, cid, class_name, &sm.name);
let has_rest = sm.params.last().map(|p| p.is_rest).unwrap_or(false);
// Spec `.length`: leading formal params before the first default/rest
Expand Down
3 changes: 1 addition & 2 deletions crates/perry-codegen/src/expr/arrays_finds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1449,8 +1449,7 @@ pub(crate) fn lower(
// class_ids (legacy callers checking truthiness). Refs #420.
Expr::ClassRef(name) => {
if let Some(&cid) = ctx.class_ids.get(name) {
let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF);
Ok(double_literal(f64::from_bits(bits)))
Ok(super::emit_class_value_cached(ctx, cid))
} else {
Ok(double_literal(0.0))
}
Expand Down
15 changes: 14 additions & 1 deletion crates/perry-codegen/src/expr/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ fn typeof_literal_pair<'a>(
/// storage (reclaimable but non-moving), while `Symbol.for()` values are
/// process-lifetime `Box` allocations. Therefore a proven Symbol can equal
/// another JS value iff their NaN-boxed pointer bits are identical.
/// A declared class named as a value (`Expr::ClassRef`): its pinned function
/// object (`js_class_value`).
fn is_class_value_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool {
matches!(expr, Expr::ClassRef(name) if ctx.class_ids.contains_key(name))
}

pub(crate) fn is_proven_symbol_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool {
match expr {
Expr::SymbolNew(_) | Expr::SymbolFor(_) => true,
Expand Down Expand Up @@ -1252,7 +1258,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// STRICT only: loose equality still has coercion/throw rules.
let either_proven_symbol =
is_proven_symbol_expr(ctx, left) || is_proven_symbol_expr(ctx, right);
if either_proven_symbol && matches!(op, CompareOp::Eq | CompareOp::Ne) {
// A class value is its class's pinned function object — one
// per class, never moved or forwarded — so strict identity
// against one is bit identity too.
let either_class_value =
is_class_value_expr(ctx, left) || is_class_value_expr(ctx, right);
if (either_proven_symbol || either_class_value)
&& matches!(op, CompareOp::Eq | CompareOp::Ne)
{
let blk = ctx.block();
let l_bits = blk.bitcast_double_to_i64(&l);
let r_bits = blk.bitcast_double_to_i64(&r);
Expand Down
3 changes: 1 addition & 2 deletions crates/perry-codegen/src/expr/dyn_extern_i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -888,8 +888,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
if let Some(&cid) = ctx.class_ids.get(name).filter(|_| {
!ctx.imported_vars.contains(name) && !ctx.namespace_imports.contains(name)
}) {
let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF);
return Ok(double_literal(f64::from_bits(bits)));
return Ok(super::emit_class_value_cached(ctx, cid));
}
// Issue #841: named imports from Node submodules Perry recognizes
// as runtime-backed values must win over the generic native-module
Expand Down
45 changes: 45 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,51 @@ use perry_hir::types::Type as HirType;
use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp};

use crate::block::LlBlock;

/// A class constructor as a VALUE (#11414): the class's per-agent function
/// object. `js_class_value` never collects (`gc_call_effects`) and the object
/// is pinned for the agent's life, so the result needs no root.
pub(crate) fn emit_class_value(blk: &mut LlBlock, class_id: u32) -> String {
blk.call(
DOUBLE,
"js_class_value",
&[(I32, &(class_id as i32).to_string())],
)
}

/// [`emit_class_value`] behind a per-site cache: a zero-initialised global
/// (thread-local when the program starts workers, so each agent caches its
/// own class object) holds the NaN-boxed value after the first use. The object
/// is pinned for the agent's life, so the cached bits never go stale and the
/// slot needs no root.
pub(crate) fn emit_class_value_cached(ctx: &mut FnCtx<'_>, class_id: u32) -> String {
let site = ctx.ic_site_counter;
ctx.ic_site_counter += 1;
let slot = format!("@{}_classval", inline_cache_global_name(ctx, site));
let tls = if crate::codegen::program_has_worker() {
"thread_local "
} else {
""
};
ctx.typed_parse_rodata
.push(format!("{slot} = private {tls}global double 0.0, align 8"));
let cached = ctx.block().load(DOUBLE, &slot);
let bits = ctx.block().bitcast_double_to_i64(&cached);
let empty = ctx.block().icmp_eq(I64, &bits, "0");
let from_l = ctx.block_label(ctx.current_block);
let miss_idx = ctx.new_block("classval.miss");
let join_idx = ctx.new_block("classval.join");
let miss_l = ctx.block_label(miss_idx);
let join_l = ctx.block_label(join_idx);
ctx.block().cond_br(&empty, &miss_l, &join_l);
ctx.current_block = miss_idx;
let fresh = emit_class_value(ctx.block(), class_id);
ctx.block().store(DOUBLE, &fresh, &slot);
ctx.block().br(&join_l);
ctx.current_block = join_idx;
ctx.block()
.phi(DOUBLE, &[(&cached, &from_l), (&fresh, &miss_l)])
}
use crate::codegen::AppMetadata;
use crate::collectors::NativeRegionFactGraph;
use crate::function::LlFunction;
Expand Down
3 changes: 1 addition & 2 deletions crates/perry-codegen/src/expr/property_get.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,8 +1144,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
.get(&crate::namespace_member_class_key(name, property))
.copied();
if let Some(cid) = class_cid {
let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF);
return Ok(double_literal(f64::from_bits(bits)));
return Ok(super::emit_class_value_cached(ctx, cid));
}
// Issue #680: prefer the per-namespace map so
// `random.make` and `tracer.make` resolve to their
Expand Down
101 changes: 101 additions & 0 deletions crates/perry-codegen/src/expr/slice7_rooting_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,104 @@ fn proxy_function_apply_uses_argument_validation_bridge() {
"Function.prototype.apply must validate and convert its argument list"
);
}

// ---------------------------------------------------------------------------
// expr/static_field_meta.rs — RefreshClassExprCaptures
// ---------------------------------------------------------------------------

/// A class expression's capture refresh builds its capture array with one
/// push per capture. The array is live across every capture's lowering, and a
/// capture can collect, so each push must read the array from its root, not
/// from the `js_array_alloc` register (gc-root-dominance --stale-registers
/// flagged `source=alloc -> sink=js_array_push_f64` on #11609).
#[test]
fn class_expr_capture_refresh_rereads_its_capture_array_below_each_capture() {
let ir = compile_body(
"refresh_class_expr_captures",
vec![Stmt::Expr(Expr::RefreshClassExprCaptures {
class_value: Box::new(Expr::Undefined),
captures: vec![allocating("first"), allocating("second")],
env_class: None,
})],
);
require_call_line(&ir, "js_array_alloc");
assert_operand_survives_the_window(
&ir,
"js_array_push_f64",
0,
"the capture array pushed after the first capture",
);
let pushes: Vec<usize> = ir
.lines()
.enumerate()
.filter(|(_, l)| {
l.contains("@js_array_push_f64(") && !l.trim_start().starts_with("declare")
})
.map(|(i, _)| i)
.collect();
assert_eq!(pushes.len(), 2, "one push per capture:\n{ir}");
let last = *pushes.last().unwrap();
let line = ir.lines().nth(last).unwrap();
let reg = line
.split("@js_array_push_f64(i64 ")
.nth(1)
.and_then(|rest| rest.split(',').next())
.unwrap_or_else(|| panic!("unexpected push shape: {line}"))
.to_string();
let def = require_definition_line(&ir, &reg);
let alloc = last_alloc_before(&ir, last);
assert!(
def > alloc,
"the second push reads {reg} (line {def}) from above the second capture's allocation \
(line {alloc}):\n{ir}"
);
}

fn capture_refresh(name: &str, captures: Vec<Expr>) -> String {
compile_body(
name,
vec![Stmt::Expr(Expr::RefreshClassExprCaptures {
class_value: Box::new(Expr::Undefined),
captures,
env_class: None,
})],
)
}

/// The capture array is ONE accumulator: however many captures collect, the
/// refresh holds one rooted slot, republished by each push. A slot per push
/// grew tsc's module-scope closure (hundreds of refreshes of ~75 captures
/// each) by ~370k blocks and made its compile 3-4x slower.
#[test]
fn class_expr_capture_refresh_roots_one_slot_for_any_capture_count() {
let one = capture_refresh("refresh_one", vec![allocating("a")]);
let three = capture_refresh(
"refresh_three",
vec![allocating("a"), allocating("b"), allocating("c")],
);
assert_eq!(call_count(&one, "js_array_push_f64"), 1, "{one}");
assert_eq!(call_count(&three, "js_array_push_f64"), 3, "{three}");
assert_eq!(
temp_root_slot_width(&one),
temp_root_slot_width(&three),
"three collecting captures must reuse the one capture-array slot:\n{three}"
);
}

/// Captures that cannot collect leave no collection point between two pushes,
/// so the refresh emits no root at all: the same slot width as a refresh with
/// no captures.
#[test]
fn class_expr_capture_refresh_over_inert_captures_emits_no_root() {
let none = capture_refresh("refresh_none", vec![]);
let inert = capture_refresh(
"refresh_inert",
vec![Expr::Number(1.0), Expr::Number(2.0), Expr::Undefined],
);
assert_eq!(call_count(&inert, "js_array_push_f64"), 3, "{inert}");
assert_eq!(
temp_root_slot_width(&inert),
temp_root_slot_width(&none),
"inert captures cannot collect, so the capture array needs no slot:\n{inert}"
);
}
Loading
Loading