From 13053ae4a2ce1787ab2f7ce5dc1f339f347c3ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 13:40:41 +0200 Subject: [PATCH 1/5] =?UTF-8?q?perf(object):=20#6812=20w16=20=E2=80=94=20p?= =?UTF-8?q?er-site=20anon=20classes=20for=20{}=20+=20learned=20width=20in?= =?UTF-8?q?=20compiled=20new?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty object literals lower to a unique 0-field anon-shape class per source site (content-addressed on source path + byte offset), giving builder-pattern objects a non-zero class_id: the runtime's learned inline sizing can attribute overflow growth to the site and right-size later allocations, and the static-key write PIC gate admits the objects. js_object_alloc_class_inline_keys (the compiled-new allocation path) now consumes learned_inline_field_count and stores the widened field_count, matching the dynamic-construct path's capacity semantics. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-hir/src/lower/context.rs | 26 ++++++++++++++++++++++- crates/perry-hir/src/lower/expr_object.rs | 17 +++++++++++++++ crates/perry-runtime/src/object/alloc.rs | 14 ++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index db28174b38..6b31bb46f0 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -944,7 +944,31 @@ impl LoweringContext { shape_key.push_str(tag(ty)); shape_key.push(','); } - + self.mint_anon_shape_class(shape_key, field_shapes) + } + + /// #6812 (w16): mint a UNIQUE per-source-site 0-field anon-shape class + /// for an EMPTY object literal (`{}`). Unlike + /// [`Self::synthesize_anon_shape_class`], the dedup key is the SITE + /// (source path + byte offset), not the field shape — every `{}` + /// occurrence gets its own class_id. That gives builder-pattern objects + /// a learnable identity: the runtime's learned inline sizing + /// (`note_learned_inline_fields` / `learned_inline_field_count`, keyed + /// by class_id) can attribute overflow growth to this site and + /// right-size later allocations so all fields land inline, and the + /// static-key write PIC's `class_id != 0` gate admits the objects. An + /// empty literal that never grows allocates exactly as before (learned + /// width 0 keeps the INLINE_SLOT_FLOOR minimum). + pub(crate) fn synthesize_empty_site_class(&mut self, byte_offset: u32) -> String { + let site_key = format!("@empty-site:{}:{}", self.source_file_path, byte_offset); + self.mint_anon_shape_class(site_key, &[]) + } + + fn mint_anon_shape_class( + &mut self, + shape_key: String, + field_shapes: &[(String, Type)], + ) -> String { // Field names in source order, so a call-site can recover a config // object's keys after the literal is lowered to `New { class_name }`. let field_names: Vec = field_shapes.iter().map(|(name, _)| name.clone()).collect(); diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index d51ef51f19..155dafd3d2 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -649,6 +649,23 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R } true } + // #6812 (w16): `{}` — the builder-pattern seed — lowers to `new + // __AnonShape_()`, a unique 0-field shape-only class per + // source site, instead of the legacy class-0 empty object. See + // `synthesize_empty_site_class` for why: the runtime's learned inline + // right-sizing and the static-key write PIC both key on a non-zero + // class_id, so class-0 builder objects could never leave the overflow + // side-table slow path. + if obj.props.is_empty() { + let class_name = ctx.synthesize_empty_site_class(obj.span.lo.0); + return Ok(Expr::New { + class_name, + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }); + } if is_closed_shape(obj) { let mut fields: Vec<(String, Type, Expr)> = Vec::new(); let mut bail = false; diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index d1ca3e1abb..5e2a68bf69 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -249,7 +249,17 @@ pub extern "C" fn js_object_alloc_class_inline_keys( register_class(class_id, parent_class_id); } let header_size = std::mem::size_of::(); - let alloc_field_count = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); + // #6812 (w16): honor the learned high-water width for this class so + // builder-pattern instances allocate their true field count inline + // instead of spilling writes to the overflow side-table. The stored + // field_count must be the widened count too — read/write paths derive + // alloc_limit as max(field_count, INLINE_SLOT_FLOOR) — mirroring the + // dynamic-construct path, which already passes + // `learned_inline_field_count` as the field count (capacity semantics; + // enumeration follows keys_array, not field_count). + let learned = crate::object::learned_inline_field_count(class_id) as usize; + let logical_field_count = std::cmp::max(field_count as usize, learned); + let alloc_field_count = std::cmp::max(logical_field_count, crate::object::INLINE_SLOT_FLOOR); let fields_size = alloc_field_count * std::mem::size_of::(); let total_size = header_size + fields_size; @@ -259,7 +269,7 @@ pub extern "C" fn js_object_alloc_class_inline_keys( (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; + (*ptr).field_count = logical_field_count as u32; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); set_object_keys_array(ptr, keys_array); From 83b1f5c48f624a1cbb014b026016cc4d960a7bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 14:10:13 +0200 Subject: [PATCH 2/5] =?UTF-8?q?perf(hir/codegen):=20#6812=20w16=20?= =?UTF-8?q?=E2=80=94=20compile-time=20width=20hint=20for=20const-bounded?= =?UTF-8?q?=20dynamic-key=20builders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A '{} then for (let k = C0; k < C1; k++) o[key] = v' builder has a statically provable final width even though its keys are computed. Learned sizing alone right-sizes only instances AFTER the first overflow, leaving instance #1 permanently under-sized — and as element 0 of the array built at the site it vetoes the whole-loop clone guard for every hot loop over that array. The scan (builder_fold::empty_builder_width_hints) records the proven width per empty-literal site; the per-site anon-shape class carries it as Class::alloc_width_hint (stable-hashed), and compiled new allocates max(fields, hint) inline slots. Capacity only — keys/enumeration unchanged. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-codegen/src/codegen/mod.rs | 3 + .../src/collectors/class_accessors.rs | 1 + .../src/collectors/scalar_method_dispatch.rs | 1 + .../src/collectors/this_as_value.rs | 1 + .../src/lower_call/buffer_intrinsic.rs | 1 + crates/perry-codegen/src/lower_call/new.rs | 10 + .../perry-codegen/src/type_analysis_tests.rs | 2 + .../perry-codegen/tests/class_keys_gc_root.rs | 1 + .../tests/constructor_recursion.rs | 1 + .../tests/native_proof_buffer_views.rs | 1 + .../tests/native_proof_regressions.rs | 1 + .../tests/private_guard_declaring_class.rs | 1 + .../tests/static_symbol_hygiene.rs | 1 + crates/perry-codegen/tests/typed_feedback.rs | 1 + .../tests/typed_shape_descriptor.rs | 1 + .../src/analysis/value_types_tests.rs | 5 + crates/perry-hir/src/ir/decl.rs | 10 + crates/perry-hir/src/lower/builder_fold.rs | 344 +++++++++++++++++- crates/perry-hir/src/lower/context.rs | 15 +- crates/perry-hir/src/lower/expr_object.rs | 7 +- crates/perry-hir/src/lower/lower_module_fn.rs | 4 + .../perry-hir/src/lower/lowering_context.rs | 5 + .../src/lower/module_decl/namespace.rs | 2 + crates/perry-hir/src/lower_decl/class_decl.rs | 2 + crates/perry-hir/src/monomorph/specialize.rs | 1 + crates/perry-hir/src/stable_hash/decls.rs | 2 + crates/perry-hir/src/stable_hash/tests.rs | 1 + .../perry-transform/src/async_to_generator.rs | 1 + crates/perry-transform/src/deforest/tests.rs | 3 + .../perry-transform/src/generator/id_scan.rs | 1 + crates/perry-transform/src/inline/mod.rs | 1 + 31 files changed, 415 insertions(+), 16 deletions(-) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index f926feaf85..3357053443 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -422,6 +422,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let stub = perry_hir::Class { id: 0, // imported — no local ClassId name: effective_name.to_string(), + // #6812: width hints don't cross module metadata; imported stubs + // fall back to runtime learned sizing. + alloc_width_hint: 0, type_params: Vec::new(), extends: None, extends_name: ic.parent_name.clone(), diff --git a/crates/perry-codegen/src/collectors/class_accessors.rs b/crates/perry-codegen/src/collectors/class_accessors.rs index ab1b40b811..bc6db26a22 100644 --- a/crates/perry-codegen/src/collectors/class_accessors.rs +++ b/crates/perry-codegen/src/collectors/class_accessors.rs @@ -109,6 +109,7 @@ mod tests { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, static_accessor_names: Vec::new(), static_accessor_fn_ids: Vec::new(), } diff --git a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs index ced40005c3..cb7de9842d 100644 --- a/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs +++ b/crates/perry-codegen/src/collectors/scalar_method_dispatch.rs @@ -465,6 +465,7 @@ mod tests { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), } } diff --git a/crates/perry-codegen/src/collectors/this_as_value.rs b/crates/perry-codegen/src/collectors/this_as_value.rs index 6c9c59b831..b893bb9958 100644 --- a/crates/perry-codegen/src/collectors/this_as_value.rs +++ b/crates/perry-codegen/src/collectors/this_as_value.rs @@ -536,6 +536,7 @@ mod tests { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, static_accessor_names: Vec::new(), static_accessor_fn_ids: Vec::new(), } diff --git a/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs b/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs index 767a874f08..d1519cd237 100644 --- a/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs +++ b/crates/perry-codegen/src/lower_call/buffer_intrinsic.rs @@ -569,6 +569,7 @@ mod shadow_scan_tests { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 03afe736ed..bc5f58dc18 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -360,6 +360,16 @@ fn lower_new_impl( if let Some(&authoritative) = ctx.class_field_counts.get(class_name) { field_count = authoritative; } + // #6812 (w16): a per-site empty-literal anon-shape class may carry a + // compile-time proven builder width. Allocate that many inline slots so + // the FIRST instance of the site is as wide as the runtime-learned + // resizes make every later one — a lone under-sized first instance + // permanently vetoes whole-loop clone eligibility for arrays built at + // the site. Capacity only: the keys array stays authoritative for + // enumeration, and the runtime treats header field_count as alloc_limit. + if class.alloc_width_hint > field_count { + field_count = class.alloc_width_hint; + } // Allocate the object with the per-class id and (if applicable) // parent class id, so the runtime registers the inheritance diff --git a/crates/perry-codegen/src/type_analysis_tests.rs b/crates/perry-codegen/src/type_analysis_tests.rs index 4d18b6d635..d10cd1609b 100644 --- a/crates/perry-codegen/src/type_analysis_tests.rs +++ b/crates/perry-codegen/src/type_analysis_tests.rs @@ -214,6 +214,7 @@ fn hir_inferred_types_reuse_codegen_contextual_class_facts() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; let widget = perry_hir::Class { @@ -284,6 +285,7 @@ fn hir_inferred_types_reuse_codegen_contextual_class_facts() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; let classes = HashMap::from([("Base".to_string(), &base), ("Widget".to_string(), &widget)]); diff --git a/crates/perry-codegen/tests/class_keys_gc_root.rs b/crates/perry-codegen/tests/class_keys_gc_root.rs index 0ed75d5505..f794081b57 100644 --- a/crates/perry-codegen/tests/class_keys_gc_root.rs +++ b/crates/perry-codegen/tests/class_keys_gc_root.rs @@ -120,6 +120,7 @@ fn module_with_declared_field_class() -> Module { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }], interfaces: Vec::new(), diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index eb372fffe1..95dc743645 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -122,6 +122,7 @@ fn module_with_recursive_constructor_return() -> Module { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, }], interfaces: Vec::new(), type_aliases: Vec::new(), diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index 58463077c1..82be52bbcc 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -254,6 +254,7 @@ fn class(id: u32, name: &str, fields: Vec) -> Class { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 3c7d75359d..ef61d9b2df 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -258,6 +258,7 @@ fn class(id: u32, name: &str, fields: Vec) -> Class { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-codegen/tests/private_guard_declaring_class.rs b/crates/perry-codegen/tests/private_guard_declaring_class.rs index f232523a06..656755537a 100644 --- a/crates/perry-codegen/tests/private_guard_declaring_class.rs +++ b/crates/perry-codegen/tests/private_guard_declaring_class.rs @@ -55,6 +55,7 @@ fn class(id: u32, name: &str) -> Class { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-codegen/tests/static_symbol_hygiene.rs b/crates/perry-codegen/tests/static_symbol_hygiene.rs index 3c93cfd121..d921ee59d8 100644 --- a/crates/perry-codegen/tests/static_symbol_hygiene.rs +++ b/crates/perry-codegen/tests/static_symbol_hygiene.rs @@ -117,6 +117,7 @@ fn class_with_static(id: u32, value: f64) -> Class { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index a0587c317a..1d93f68b8e 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -134,6 +134,7 @@ fn class(id: u32, name: &str, fields: Vec) -> Class { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index d0902cbac5..671a235b34 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -91,6 +91,7 @@ fn class(id: u32, name: &str, fields: Vec) -> Class { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-hir/src/analysis/value_types_tests.rs b/crates/perry-hir/src/analysis/value_types_tests.rs index e12ac78bce..fe59ca7f40 100644 --- a/crates/perry-hir/src/analysis/value_types_tests.rs +++ b/crates/perry-hir/src/analysis/value_types_tests.rs @@ -622,6 +622,7 @@ fn seeds_contextual_class_and_enum_facts_from_module() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }); @@ -694,6 +695,7 @@ fn infers_named_class_and_interface_property_facts() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }); module.classes.push(Class { @@ -718,6 +720,7 @@ fn infers_named_class_and_interface_property_facts() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }); module.interfaces.push(Interface { @@ -1661,6 +1664,7 @@ fn resolves_this_and_super_in_class_context() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }); module.classes.push(Class { @@ -1685,6 +1689,7 @@ fn resolves_this_and_super_in_class_context() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }); diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index 7cf559c3f9..43a74bdc69 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -258,6 +258,16 @@ pub struct Class { /// inside a turbopack factory threw at module init). Codegen /// (`init_static_fields_*`) skips module-init static init for these. pub is_nested: bool, + /// #6812 (w16): minimum inline slot count to allocate for instances, + /// beyond `fields.len()`. Set only on per-site empty-literal anon-shape + /// classes when the lowering can prove the builder's final width (e.g. a + /// `{}` declarator followed by a constant-bounded single-write build + /// loop), so even the FIRST instance allocates every field inline + /// instead of spilling to the overflow side-table and permanently + /// poisoning whole-loop clone eligibility for arrays built at that + /// site. Pure capacity: does not add fields, keys, or enumeration + /// entries. 0 = no hint. + pub alloc_width_hint: u32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/perry-hir/src/lower/builder_fold.rs b/crates/perry-hir/src/lower/builder_fold.rs index deb0d9cbeb..7b3d89e7ba 100644 --- a/crates/perry-hir/src/lower/builder_fold.rs +++ b/crates/perry-hir/src/lower/builder_fold.rs @@ -90,9 +90,7 @@ fn scan_stmt(s: &ast::Stmt) -> bool { match s { ast::Stmt::Block(b) => stmts_have_candidate(&b.stmts), ast::Stmt::If(i) => { - scan_expr(&i.test) - || scan_stmt(&i.cons) - || i.alt.as_deref().is_some_and(scan_stmt) + scan_expr(&i.test) || scan_stmt(&i.cons) || i.alt.as_deref().is_some_and(scan_stmt) } ast::Stmt::While(w) => scan_expr(&w.test) || scan_stmt(&w.body), ast::Stmt::DoWhile(d) => scan_stmt(&d.body) || scan_expr(&d.test), @@ -115,8 +113,7 @@ fn scan_stmt(s: &ast::Stmt) -> bool { .is_some_and(|f| stmts_have_candidate(&f.stmts)) } ast::Stmt::Switch(sw) => { - scan_expr(&sw.discriminant) - || sw.cases.iter().any(|c| stmts_have_candidate(&c.cons)) + scan_expr(&sw.discriminant) || sw.cases.iter().any(|c| stmts_have_candidate(&c.cons)) } ast::Stmt::Decl(d) => scan_decl(d), ast::Stmt::Expr(es) => scan_expr(&es.expr), @@ -209,13 +206,7 @@ fn scan_expr(e: &ast::Expr) -> bool { matches!(&c.callee, ast::Callee::Expr(e) if scan_expr(e)) || c.args.iter().any(|a| scan_expr(&a.expr)) } - E::New(n) => { - scan_expr(&n.callee) - || n.args - .iter() - .flatten() - .any(|a| scan_expr(&a.expr)) - } + E::New(n) => scan_expr(&n.callee) || n.args.iter().flatten().any(|a| scan_expr(&a.expr)), E::Seq(s) => s.exprs.iter().any(|e| scan_expr(e)), E::Tpl(t) => t.exprs.iter().any(|e| scan_expr(e)), E::Paren(p) => scan_expr(&p.expr), @@ -743,3 +734,332 @@ fn walk_expr(e: &mut ast::Expr, changed: &mut bool) { _ => {} } } + +// --------------------------------------------------------------------------- +// #6812 (w16): compile-time builder WIDTH scan. +// +// `fold_builder_sequences` above handles STATIC-key builders. A builder +// whose keys are computed (`for (let k = 0; k < 24; k++) o["p" + k] = v;`) +// cannot be folded into a literal, but when the build loop is +// constant-bounded its FINAL width is still statically known. That width +// matters beyond the learned resize: the runtime learns a site's width only +// when the FIRST instance overflows, so that instance stays under-sized +// forever — and as element 0 of the array built at the site it vetoes the +// whole-loop clone guard ("first receiver target slot is out of bounds") +// for every hot loop over that array. A width hint right-sizes instance #1 +// so the site's instances are uniform from the first allocation. +// +// The hint is pure allocation capacity: over-counting (e.g. duplicate keys +// across iterations) wastes slots but can never change semantics, so the +// VALUE side of the assignments is deliberately unconstrained. + +/// Scan for `const o = {};` immediately followed by a constant-bounded +/// build loop writing only to `o`. Returns `span.lo` of each empty object +/// literal → proven final width (writes per iteration × trip count). +pub(crate) fn empty_builder_width_hints( + module: &ast::Module, +) -> std::collections::HashMap { + let mut hints = std::collections::HashMap::new(); + for w in module.body.windows(2) { + if let (ast::ModuleItem::Stmt(a), ast::ModuleItem::Stmt(b)) = (&w[0], &w[1]) { + note_hint_pair(a, b, &mut hints); + } + } + for item in &module.body { + if let ast::ModuleItem::Stmt(s) = item { + hint_walk_stmt(s, &mut hints); + } + } + hints +} + +fn hint_scan_stmts(stmts: &[ast::Stmt], hints: &mut std::collections::HashMap) { + for w in stmts.windows(2) { + note_hint_pair(&w[0], &w[1], hints); + } + for s in stmts { + hint_walk_stmt(s, hints); + } +} + +fn hint_walk_stmt(s: &ast::Stmt, hints: &mut std::collections::HashMap) { + match s { + ast::Stmt::Block(b) => hint_scan_stmts(&b.stmts, hints), + ast::Stmt::If(i) => { + hint_walk_stmt(&i.cons, hints); + if let Some(alt) = &i.alt { + hint_walk_stmt(alt, hints); + } + } + ast::Stmt::While(w) => hint_walk_stmt(&w.body, hints), + ast::Stmt::DoWhile(w) => hint_walk_stmt(&w.body, hints), + ast::Stmt::For(f) => hint_walk_stmt(&f.body, hints), + ast::Stmt::ForIn(f) => hint_walk_stmt(&f.body, hints), + ast::Stmt::ForOf(f) => hint_walk_stmt(&f.body, hints), + ast::Stmt::Labeled(l) => hint_walk_stmt(&l.body, hints), + ast::Stmt::Try(t) => { + hint_scan_stmts(&t.block.stmts, hints); + if let Some(h) = &t.handler { + hint_scan_stmts(&h.body.stmts, hints); + } + if let Some(f) = &t.finalizer { + hint_scan_stmts(&f.stmts, hints); + } + } + ast::Stmt::Switch(sw) => { + for case in &sw.cases { + hint_scan_stmts(&case.cons, hints); + } + } + ast::Stmt::Decl(d) => hint_walk_hint_decl(d, hints), + ast::Stmt::Expr(e) => hint_walk_expr(&e.expr, hints), + ast::Stmt::Return(r) => { + if let Some(arg) = &r.arg { + hint_walk_expr(arg, hints); + } + } + _ => {} + } +} + +fn hint_walk_hint_decl(d: &ast::Decl, hints: &mut std::collections::HashMap) { + match d { + ast::Decl::Fn(f) => { + if let Some(body) = &f.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::Decl::Var(v) => { + for decl in &v.decls { + if let Some(init) = &decl.init { + hint_walk_expr(init, hints); + } + } + } + ast::Decl::Class(c) => hint_walk_class(&c.class, hints), + _ => {} + } +} + +fn hint_walk_class(class: &ast::Class, hints: &mut std::collections::HashMap) { + for member in &class.body { + match member { + ast::ClassMember::Method(m) => { + if let Some(body) = &m.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::ClassMember::PrivateMethod(m) => { + if let Some(body) = &m.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::ClassMember::Constructor(c) => { + if let Some(body) = &c.body { + hint_scan_stmts(&body.stmts, hints); + } + } + ast::ClassMember::ClassProp(p) => { + if let Some(v) = &p.value { + hint_walk_expr(v, hints); + } + } + ast::ClassMember::PrivateProp(p) => { + if let Some(v) = &p.value { + hint_walk_expr(v, hints); + } + } + _ => {} + } + } +} + +fn hint_walk_expr(e: &ast::Expr, hints: &mut std::collections::HashMap) { + use ast::Expr as E; + match e { + E::Fn(f) => { + if let Some(body) = &f.function.body { + hint_scan_stmts(&body.stmts, hints); + } + } + E::Arrow(a) => match &*a.body { + ast::BlockStmtOrExpr::BlockStmt(b) => hint_scan_stmts(&b.stmts, hints), + ast::BlockStmtOrExpr::Expr(x) => hint_walk_expr(x, hints), + }, + E::Class(c) => hint_walk_class(&c.class, hints), + E::Paren(p) => hint_walk_expr(&p.expr, hints), + E::Seq(s) => { + for x in &s.exprs { + hint_walk_expr(x, hints); + } + } + E::Cond(c) => { + hint_walk_expr(&c.test, hints); + hint_walk_expr(&c.cons, hints); + hint_walk_expr(&c.alt, hints); + } + E::Bin(b) => { + hint_walk_expr(&b.left, hints); + hint_walk_expr(&b.right, hints); + } + E::Unary(u) => hint_walk_expr(&u.arg, hints), + E::Assign(a) => hint_walk_expr(&a.right, hints), + E::Await(a) => hint_walk_expr(&a.arg, hints), + E::Call(c) => { + for arg in &c.args { + hint_walk_expr(&arg.expr, hints); + } + } + E::New(n) => { + if let Some(args) = &n.args { + for arg in args { + hint_walk_expr(&arg.expr, hints); + } + } + } + E::Array(arr) => { + for el in arr.elems.iter().flatten() { + hint_walk_expr(&el.expr, hints); + } + } + E::Object(o) => { + for prop in &o.props { + if let ast::PropOrSpread::Prop(p) = prop { + if let ast::Prop::KeyValue(kv) = p.as_ref() { + hint_walk_expr(&kv.value, hints); + } + } + } + } + E::Tpl(t) => { + for x in &t.exprs { + hint_walk_expr(x, hints); + } + } + E::Member(m) => hint_walk_expr(&m.obj, hints), + _ => {} + } +} + +/// Cap mirroring the runtime's `LEARNED_INLINE_MAX_FIELDS`: hints past this +/// stop paying for themselves and a pathological constant loop must not +/// inflate every instance. +const WIDTH_HINT_MAX: u32 = 64; + +fn note_hint_pair(a: &ast::Stmt, b: &ast::Stmt, hints: &mut std::collections::HashMap) { + let (Some(name), Some(props)) = decl_object_binding(a) else { + return; + }; + if !props.is_empty() { + return; + } + let ast::Stmt::Decl(ast::Decl::Var(var)) = a else { + return; + }; + let Some(init) = &var.decls[0].init else { + return; + }; + let ast::Expr::Object(obj) = &**init else { + return; + }; + let Some(width) = const_build_loop_width(b, &name) else { + return; + }; + if width == 0 || width > WIDTH_HINT_MAX { + return; + } + hints.insert(obj.span.lo.0, width); +} + +/// `for (let k = C0; k < C1; k++) body` where every body statement is a +/// plain `name.x = value` / `name[expr] = value` assignment. Returns writes +/// per iteration × trip count. Values and key expressions are arbitrary — +/// the width is capacity only. +fn const_build_loop_width(s: &ast::Stmt, name: &str) -> Option { + let ast::Stmt::For(f) = s else { + return None; + }; + let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init else { + return None; + }; + if vd.decls.len() != 1 { + return None; + } + let d0 = &vd.decls[0]; + let ast::Pat::Ident(kb) = &d0.name else { + return None; + }; + let counter = kb.id.sym.as_ref(); + let c0 = width_int_lit(d0.init.as_deref()?)?; + let ast::Expr::Bin(cmp) = f.test.as_deref()? else { + return None; + }; + if cmp.op != ast::BinaryOp::Lt { + return None; + } + let ast::Expr::Ident(ci) = &*cmp.left else { + return None; + }; + if ci.sym.as_ref() != counter { + return None; + } + let c1 = width_int_lit(&cmp.right)?; + match f.update.as_deref()? { + ast::Expr::Update(u) if u.op == ast::UpdateOp::PlusPlus => { + let ast::Expr::Ident(ui) = &*u.arg else { + return None; + }; + if ui.sym.as_ref() != counter { + return None; + } + } + _ => return None, + } + if c1 <= c0 { + return None; + } + let trips = u32::try_from(c1 - c0).ok()?; + let body: &[ast::Stmt] = match &*f.body { + ast::Stmt::Block(bs) => &bs.stmts, + other => std::slice::from_ref(other), + }; + if body.is_empty() || body.len() > 4 { + return None; + } + let mut writes = 0u32; + for stmt in body { + let ast::Stmt::Expr(es) = stmt else { + return None; + }; + let ast::Expr::Assign(assign) = &*es.expr else { + return None; + }; + if assign.op != ast::AssignOp::Assign { + return None; + } + let ast::AssignTarget::Simple(ast::SimpleAssignTarget::Member(m)) = &assign.left else { + return None; + }; + let ast::Expr::Ident(oi) = &*m.obj else { + return None; + }; + if oi.sym.as_ref() != name { + return None; + } + if matches!(&m.prop, ast::MemberProp::PrivateName(_)) { + return None; + } + writes += 1; + } + trips.checked_mul(writes) +} + +fn width_int_lit(e: &ast::Expr) -> Option { + let ast::Expr::Lit(ast::Lit::Num(n)) = e else { + return None; + }; + if n.value.fract() != 0.0 || !(0.0..=1_000_000_000.0).contains(&n.value) { + return None; + } + Some(n.value as i64) +} diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 6b31bb46f0..06cab8b7e4 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -80,6 +80,7 @@ impl LoweringContext { object_super_home_stack: Vec::new(), extern_func_types: Vec::new(), source_file_path, + empty_site_width_hints: std::collections::HashMap::new(), exportable_object_vars: HashSet::new(), pending_functions: Vec::new(), closure_display_names: HashMap::new(), @@ -944,7 +945,7 @@ impl LoweringContext { shape_key.push_str(tag(ty)); shape_key.push(','); } - self.mint_anon_shape_class(shape_key, field_shapes) + self.mint_anon_shape_class(shape_key, field_shapes, 0) } /// #6812 (w16): mint a UNIQUE per-source-site 0-field anon-shape class @@ -959,15 +960,22 @@ impl LoweringContext { /// static-key write PIC's `class_id != 0` gate admits the objects. An /// empty literal that never grows allocates exactly as before (learned /// width 0 keeps the INLINE_SLOT_FLOOR minimum). - pub(crate) fn synthesize_empty_site_class(&mut self, byte_offset: u32) -> String { + /// `width_hint` (#6812 w16): compile-time proven builder width — see + /// [`crate::Class::alloc_width_hint`]. 0 = rely on runtime learning only. + pub(crate) fn synthesize_empty_site_class( + &mut self, + byte_offset: u32, + width_hint: u32, + ) -> String { let site_key = format!("@empty-site:{}:{}", self.source_file_path, byte_offset); - self.mint_anon_shape_class(site_key, &[]) + self.mint_anon_shape_class(site_key, &[], width_hint) } fn mint_anon_shape_class( &mut self, shape_key: String, field_shapes: &[(String, Type)], + alloc_width_hint: u32, ) -> String { // Field names in source order, so a call-site can recover a config // object's keys after the literal is lowered to `New { class_name }`. @@ -1091,6 +1099,7 @@ impl LoweringContext { // Synthetic anon-shape class; no static fields, so static-init // timing is irrelevant. is_nested: false, + alloc_width_hint, }); self.anon_shape_classes diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index 155dafd3d2..a879a7bf6f 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -657,7 +657,12 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R // class_id, so class-0 builder objects could never leave the overflow // side-table slow path. if obj.props.is_empty() { - let class_name = ctx.synthesize_empty_site_class(obj.span.lo.0); + let width_hint = ctx + .empty_site_width_hints + .get(&obj.span.lo.0) + .copied() + .unwrap_or(0); + let class_name = ctx.synthesize_empty_site_class(obj.span.lo.0, width_hint); return Ok(Expr::New { class_name, args: Vec::new(), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 51aa7a6331..735162be26 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -419,6 +419,10 @@ pub fn lower_module_full( let folded = super::builder_fold::fold_builder_sequences(ast_module); let ast_module = folded.as_ref().unwrap_or(ast_module); let mut ctx = LoweringContext::with_class_id_start(source_file_path, start_class_id); + // #6812 (w16): scan the module lowering actually consumes (post-fold) for + // constant-bounded dynamic-key builder widths; `lower_object` attaches + // them to the per-site empty-literal classes as alloc_width_hint. + ctx.empty_site_width_hints = super::builder_fold::empty_builder_width_hints(ast_module); ctx.resolved_types = resolved_types; ctx.is_entry_module = is_entry_module; ctx.is_external_module = is_external_module; diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index fdb645a9d2..91fa1143ea 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -291,6 +291,11 @@ pub struct LoweringContext { pub(crate) extern_func_types: Vec<(String, Vec, Type)>, /// Source file path (for import.meta.url) pub(crate) source_file_path: String, + /// #6812 (w16): span.lo of empty object literals whose builder width the + /// pre-lowering scan proved (`builder_fold::empty_builder_width_hints`) + /// → final width. Consumed by `lower_object`'s empty-literal branch to + /// set `Class::alloc_width_hint` on the per-site anon-shape class. + pub(crate) empty_site_width_hints: std::collections::HashMap, /// Variables that hold closures or other values needing cross-module export globals /// (arrow functions, object literals, call expressions, arrays, new expressions) // #854: initialized in `new` but not yet read on this lowering path. diff --git a/crates/perry-hir/src/lower/module_decl/namespace.rs b/crates/perry-hir/src/lower/module_decl/namespace.rs index 964fa663b0..fced20a019 100644 --- a/crates/perry-hir/src/lower/module_decl/namespace.rs +++ b/crates/perry-hir/src/lower/module_decl/namespace.rs @@ -109,6 +109,7 @@ pub(crate) fn lower_namespace_as_class( is_exported, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, }); } }; @@ -407,5 +408,6 @@ pub(crate) fn lower_namespace_as_class( is_exported, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, }) } diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index e8d075ac7e..44f9a2d8e4 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -1303,6 +1303,7 @@ pub fn lower_class_decl( // Declared inside a function body / non-module block → its static-field // initializers must run on class evaluation, not at module init. is_nested: ctx.scope_depth > 0 || ctx.inside_block_scope > 0, + alloc_width_hint: 0, }) } @@ -1909,5 +1910,6 @@ pub fn lower_class_from_ast( // Declared inside a function body / non-module block → its static-field // initializers must run on class evaluation, not at module init. is_nested: ctx.scope_depth > 0 || ctx.inside_block_scope > 0, + alloc_width_hint: 0, }) } diff --git a/crates/perry-hir/src/monomorph/specialize.rs b/crates/perry-hir/src/monomorph/specialize.rs index 73da48e31a..2699f54940 100644 --- a/crates/perry-hir/src/monomorph/specialize.rs +++ b/crates/perry-hir/src/monomorph/specialize.rs @@ -267,5 +267,6 @@ pub fn specialize_class(class: &Class, type_args: &[Type], new_id: ClassId) -> C is_exported: class.is_exported, aliases: class.aliases.clone(), is_nested: class.is_nested, + alloc_width_hint: class.alloc_width_hint, } } diff --git a/crates/perry-hir/src/stable_hash/decls.rs b/crates/perry-hir/src/stable_hash/decls.rs index 1468ed78f8..3cb9869f58 100644 --- a/crates/perry-hir/src/stable_hash/decls.rs +++ b/crates/perry-hir/src/stable_hash/decls.rs @@ -33,6 +33,7 @@ impl SH for Class { is_exported, aliases, is_nested, + alloc_width_hint, } = self; id.hash(h); name.hash(h); @@ -56,6 +57,7 @@ impl SH for Class { is_exported.hash(h); aliases.hash(h); is_nested.hash(h); + alloc_width_hint.hash(h); } } diff --git a/crates/perry-hir/src/stable_hash/tests.rs b/crates/perry-hir/src/stable_hash/tests.rs index 0c144e5f78..8d597751aa 100644 --- a/crates/perry-hir/src/stable_hash/tests.rs +++ b/crates/perry-hir/src/stable_hash/tests.rs @@ -281,6 +281,7 @@ fn module_metadata_affects_hash() { is_exported: false, aliases: vec![], is_nested: false, + alloc_width_hint: 0, }); assert_ne!(base_hash, hash_module(&m_class)); diff --git a/crates/perry-transform/src/async_to_generator.rs b/crates/perry-transform/src/async_to_generator.rs index c74bb16e56..7d94ba7860 100644 --- a/crates/perry-transform/src/async_to_generator.rs +++ b/crates/perry-transform/src/async_to_generator.rs @@ -1867,6 +1867,7 @@ mod computed_and_field_async_tests { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-transform/src/deforest/tests.rs b/crates/perry-transform/src/deforest/tests.rs index dbed00caf3..2ec5cf0686 100644 --- a/crates/perry-transform/src/deforest/tests.rs +++ b/crates/perry-transform/src/deforest/tests.rs @@ -352,6 +352,7 @@ fn deforests_producer_called_from_class_method() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; @@ -470,6 +471,7 @@ fn rejects_deforest_when_class_method_uses_super() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; @@ -560,6 +562,7 @@ fn still_deforests_when_method_has_no_super() { decorators: Vec::new(), is_exported: false, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; diff --git a/crates/perry-transform/src/generator/id_scan.rs b/crates/perry-transform/src/generator/id_scan.rs index 081d5a1274..7281032561 100644 --- a/crates/perry-transform/src/generator/id_scan.rs +++ b/crates/perry-transform/src/generator/id_scan.rs @@ -629,6 +629,7 @@ mod tests { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 4534f033ec..6098b58062 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -700,6 +700,7 @@ mod tests { is_exported: false, aliases: Vec::new(), is_nested: false, + alloc_width_hint: 0, } } From 31fb81f44697ba6833846c240b99b13df23b1fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 15:57:20 +0200 Subject: [PATCH 3/5] docs: #6812 w16 changelog fragment + matrix row update Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- changelog.d/6812-w16-builder-identity.md | 1 + docs/object-write-matrix.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/6812-w16-builder-identity.md diff --git a/changelog.d/6812-w16-builder-identity.md b/changelog.d/6812-w16-builder-identity.md new file mode 100644 index 0000000000..0281401bf2 --- /dev/null +++ b/changelog.d/6812-w16-builder-identity.md @@ -0,0 +1 @@ +perf(object): #6812 w16 — builder-pattern objects get a learnable per-site identity. Empty object literals lower to a unique 0-field anon-shape class per source site (content-addressed on file + offset), so the runtime's learned inline sizing and the static-key write PIC admit `{}`-built objects; the compiled-`new` allocator consumes the learned high-water width. A constant-bounded dynamic-key build loop additionally proves its width at compile time (`Class::alloc_width_hint`), right-sizing even the first instance so arrays of builder objects stay uniform and whole-loop clone eligible. w16 (overflow-slot writes): 173× slower than node → beats node. diff --git a/docs/object-write-matrix.md b/docs/object-write-matrix.md index c64dd678b4..a4fa6cd78f 100644 --- a/docs/object-write-matrix.md +++ b/docs/object-write-matrix.md @@ -49,7 +49,7 @@ Ratio = perry/node median (fill from measurement; `<1` = beating node). | w12_arb_dynkey | rotating keys from array | generic | 84 | 18 | 4.7 | GAP: GC-safe dynamic-key cache | | w13_int_key | `o[7]` on plain object | generic numeric-as-property | 160 | 13 | 12.3 | GAP | | w15_append_build | fresh `{}` + 6 assigns (builder) | *(pre-#6829 baseline)* generic transitions; `class_id==0` blocks PIC | 1489 → 196 (#6829) | 8 | 186 → 25 | #6829 folds builders into literals; residual tracked below | -| w16_overflow_slot | writes past inline capacity | runtime (PIC bounds reject) | 4150 | 23 | **180** | TOP GAP — wide objects | +| w16_overflow_slot | writes past inline capacity | *(pre-#6812-w16 baseline)* runtime (PIC bounds reject) → whole-loop clone | 4150 → 3 | 23 | 180 → **0.10** | BEATS node — `{}` per-site classes + learned width + compile-time width hint make builder arrays uniform and clone-eligible | | w17_alloc_rhs | allocating RHS (`"s"+i`) | NOT PIC (safepoint-free rule) | 26 | 18 | 1.4 | close; revisit only with receiver-reload design | | w18_class_inst | class instances via `any` | PIC | 6 | 12 | **0.50** | BEATS node (best row) | From bb292cbf84dd32ea25dafb47a2445f668bf783ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 16:22:21 +0200 Subject: [PATCH 4/5] test(codegen): repair two stale e2e assertions (pre-existing on main) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduce at origin/main and at f6fb9a48a~1 — broken before the whole #6812 line, surfaced now because this PR touches the test files and e2e-scoped selects suites by changed paths. - native_owned_uint8array_get_fallback: codegen migrated the disposed-view fallback to js_uint8array_index_get_value, which validates against the typed-array kind registry before dereferencing (the memory-safety property the test pins); assert the new helper. - typed_feedback_trace_dump_runs_before_entry_return: the entry epilogue gained a second exit path returning the dynamic js_process_pending_exit_code result, so rfind("ret i32 0") no longer finds the last return. Assert the stronger real invariant: every entry return is immediately preceded by the trace dump. Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- .../tests/native_proof_buffer_views.rs | 11 ++++++-- crates/perry-codegen/tests/typed_feedback.rs | 26 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index 82be52bbcc..6c827940cb 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -1555,9 +1555,16 @@ fn native_owned_uint8array_get_fallback_uses_uint8array_helper() { })), ], ); + // Codegen migrated the disposed-view fallback from `js_uint8array_get` + // to `js_uint8array_index_get_value` (#6088-era JS-value getter), which + // validates the address against the typed-array kind registry before any + // dereference and returns undefined for dead views — the memory-safety + // property this test exists to pin. The suite was not CI-selected when + // that landed, so the old helper name went stale here. assert!( - ir.contains("call i32 @js_uint8array_get"), - "disposed native Uint8Array fallback should call js_uint8array_get:\n{ir}" + ir.contains("call double @js_uint8array_index_get_value"), + "disposed native Uint8Array fallback should call the registry-validating \ + js_uint8array_index_get_value:\n{ir}" ); assert!( !ir.contains("call i32 @js_buffer_get"), diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 1d93f68b8e..3f648f30fe 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -218,11 +218,27 @@ fn typed_feedback_trace_dump_runs_before_entry_return() { )); assert!(ir.contains("declare void @js_typed_feedback_maybe_dump_trace()")); - let dump_pos = ir - .rfind("call void @js_typed_feedback_maybe_dump_trace()") - .expect("entry should call typed-feedback trace dump"); - let ret_pos = ir.rfind("ret i32 0").expect("entry should return i32 0"); - assert!(dump_pos < ret_pos); + // The entry epilogue now has TWO exit paths: the host-return path + // (`ret i32 0`) and the event-loop exit path, which returns the dynamic + // `js_process_pending_exit_code` result (`ret i32 %rN`). The old + // rfind(dump) < rfind("ret i32 0") comparison broke the day the second + // path appeared — its dump call sits after the literal `ret i32 0`. The + // real invariant is stronger: EVERY entry return must be immediately + // preceded by the trace dump, so no exit path can skip the dump. + let mut ret_count = 0; + let mut search_from = 0; + while let Some(rel) = ir[search_from..].find("ret i32") { + let ret_pos = search_from + rel; + ret_count += 1; + let preceding = &ir[ret_pos.saturating_sub(200)..ret_pos]; + assert!( + preceding.contains("call void @js_typed_feedback_maybe_dump_trace()"), + "entry return at byte {ret_pos} is not preceded by the typed-feedback \ + trace dump:\n...{preceding}" + ); + search_from = ret_pos + 1; + } + assert!(ret_count >= 1, "entry should return i32"); } #[test] From 9af1e262c4736f47b17e270cbb8c6a8fc323bd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 25 Jul 2026 16:26:52 +0200 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20CodeRabbit=20round=201=20=E2=80=94?= =?UTF-8?q?=20width-hint=20scan=20covers=20exported=20decls;=20missing=20C?= =?UTF-8?q?lass=20field=20in=20perry=20test=20cfg;=20one-source=20w16=20nu?= =?UTF-8?q?mbers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - empty_builder_width_hints now descends ModuleDecl items (export fn/class/ const, export default) and pairs 'export const o = {}' with its build loop — exported builder helpers are the most common real-world shape. - perry crate test-cfg Class literals gained alloc_width_hint (CI cargo-test E0063; local checks missed the CLI crate's tests). - w16 doc row and changelog now cite one measured lineage (pre-fix 4163/24 = 173x, post-fix 3/29 = 0.10, 2026-07-25 sweeps). Claude-Session: https://claude.ai/code/session_01QJ5mwMDPc63tNLAFPdthAG --- crates/perry-hir/src/lower/builder_fold.rs | 84 ++++++++++++++++---- crates/perry/src/commands/compile/helpers.rs | 2 + docs/object-write-matrix.md | 2 +- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/crates/perry-hir/src/lower/builder_fold.rs b/crates/perry-hir/src/lower/builder_fold.rs index 7b3d89e7ba..0b2d1dd3f5 100644 --- a/crates/perry-hir/src/lower/builder_fold.rs +++ b/crates/perry-hir/src/lower/builder_fold.rs @@ -760,19 +760,71 @@ pub(crate) fn empty_builder_width_hints( module: &ast::Module, ) -> std::collections::HashMap { let mut hints = std::collections::HashMap::new(); + // Pair `const o = {}` (plain or `export const`) with a following build + // loop; the loop itself is always a plain Stmt. for w in module.body.windows(2) { - if let (ast::ModuleItem::Stmt(a), ast::ModuleItem::Stmt(b)) = (&w[0], &w[1]) { - note_hint_pair(a, b, &mut hints); + let ast::ModuleItem::Stmt(b) = &w[1] else { + continue; + }; + if let Some((name, span_lo)) = item_empty_object_decl(&w[0]) { + note_hint_for_site(&name, span_lo, b, &mut hints); } } for item in &module.body { - if let ast::ModuleItem::Stmt(s) = item { - hint_walk_stmt(s, &mut hints); + match item { + ast::ModuleItem::Stmt(s) => hint_walk_stmt(s, &mut hints), + // Exported declarations are ModuleDecls, not Stmts — and + // `export function buildX() { const o = {}; ... }` is the most + // common real-world builder shape. + ast::ModuleItem::ModuleDecl(md) => match md { + ast::ModuleDecl::ExportDecl(e) => hint_walk_hint_decl(&e.decl, &mut hints), + ast::ModuleDecl::ExportDefaultDecl(d) => match &d.decl { + ast::DefaultDecl::Fn(f) => { + if let Some(body) = &f.function.body { + hint_scan_stmts(&body.stmts, &mut hints); + } + } + ast::DefaultDecl::Class(c) => hint_walk_class(&c.class, &mut hints), + ast::DefaultDecl::TsInterfaceDecl(_) => {} + }, + ast::ModuleDecl::ExportDefaultExpr(e) => hint_walk_expr(&e.expr, &mut hints), + _ => {} + }, } } hints } +/// `const/let name = {}` from a plain statement or an `export const`. +/// Returns the binding name and the empty literal's span.lo. +fn item_empty_object_decl(item: &ast::ModuleItem) -> Option<(String, u32)> { + match item { + ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(v))) => empty_object_decl(v), + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => match &e.decl { + ast::Decl::Var(v) => empty_object_decl(v), + _ => None, + }, + _ => None, + } +} + +fn empty_object_decl(var: &ast::VarDecl) -> Option<(String, u32)> { + if var.decls.len() != 1 { + return None; + } + let d = &var.decls[0]; + let ast::Pat::Ident(bi) = &d.name else { + return None; + }; + let ast::Expr::Object(obj) = d.init.as_deref()? else { + return None; + }; + if !obj.props.is_empty() { + return None; + } + Some((bi.id.sym.to_string(), obj.span.lo.0)) +} + fn hint_scan_stmts(stmts: &[ast::Stmt], hints: &mut std::collections::HashMap) { for w in stmts.windows(2) { note_hint_pair(&w[0], &w[1], hints); @@ -947,28 +999,28 @@ fn hint_walk_expr(e: &ast::Expr, hints: &mut std::collections::HashMap const WIDTH_HINT_MAX: u32 = 64; fn note_hint_pair(a: &ast::Stmt, b: &ast::Stmt, hints: &mut std::collections::HashMap) { - let (Some(name), Some(props)) = decl_object_binding(a) else { - return; - }; - if !props.is_empty() { - return; - } let ast::Stmt::Decl(ast::Decl::Var(var)) = a else { return; }; - let Some(init) = &var.decls[0].init else { - return; - }; - let ast::Expr::Object(obj) = &**init else { + let Some((name, span_lo)) = empty_object_decl(var) else { return; }; - let Some(width) = const_build_loop_width(b, &name) else { + note_hint_for_site(&name, span_lo, b, hints); +} + +fn note_hint_for_site( + name: &str, + literal_span_lo: u32, + build_loop: &ast::Stmt, + hints: &mut std::collections::HashMap, +) { + let Some(width) = const_build_loop_width(build_loop, name) else { return; }; if width == 0 || width > WIDTH_HINT_MAX { return; } - hints.insert(obj.span.lo.0, width); + hints.insert(literal_span_lo, width); } /// `for (let k = C0; k < C1; k++) body` where every body statement is a diff --git a/crates/perry/src/commands/compile/helpers.rs b/crates/perry/src/commands/compile/helpers.rs index 380b3732bc..fd75538639 100644 --- a/crates/perry/src/commands/compile/helpers.rs +++ b/crates/perry/src/commands/compile/helpers.rs @@ -267,6 +267,7 @@ mod tests { decorators: Vec::new(), is_exported: true, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; let project_root = PathBuf::from("/repo"); @@ -319,6 +320,7 @@ mod tests { decorators: Vec::new(), is_exported: true, is_nested: false, + alloc_width_hint: 0, aliases: Vec::new(), }; let project_root = PathBuf::from("/repo"); diff --git a/docs/object-write-matrix.md b/docs/object-write-matrix.md index a4fa6cd78f..9ac867c88e 100644 --- a/docs/object-write-matrix.md +++ b/docs/object-write-matrix.md @@ -49,7 +49,7 @@ Ratio = perry/node median (fill from measurement; `<1` = beating node). | w12_arb_dynkey | rotating keys from array | generic | 84 | 18 | 4.7 | GAP: GC-safe dynamic-key cache | | w13_int_key | `o[7]` on plain object | generic numeric-as-property | 160 | 13 | 12.3 | GAP | | w15_append_build | fresh `{}` + 6 assigns (builder) | *(pre-#6829 baseline)* generic transitions; `class_id==0` blocks PIC | 1489 → 196 (#6829) | 8 | 186 → 25 | #6829 folds builders into literals; residual tracked below | -| w16_overflow_slot | writes past inline capacity | *(pre-#6812-w16 baseline)* runtime (PIC bounds reject) → whole-loop clone | 4150 → 3 | 23 | 180 → **0.10** | BEATS node — `{}` per-site classes + learned width + compile-time width hint make builder arrays uniform and clone-eligible | +| w16_overflow_slot | writes past inline capacity | *(pre-#6812-w16 baseline)* runtime (PIC bounds reject) → whole-loop clone | 4163 → 3 | 29 | 173 → **0.10** | BEATS node — `{}` per-site classes + learned width + compile-time width hint make builder arrays uniform and clone-eligible. Each ratio vs its own run's node baseline (2026-07-25 sweeps: pre-fix 4163/24, post-fix 3/29) | | w17_alloc_rhs | allocating RHS (`"s"+i`) | NOT PIC (safepoint-free rule) | 26 | 18 | 1.4 | close; revisit only with receiver-reload design | | w18_class_inst | class instances via `any` | PIC | 6 | 12 | **0.50** | BEATS node (best row) |