From b2636e3dfbfec7099fa89eba5f3b2b0b8b40af0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 11:58:58 +0200 Subject: [PATCH 1/2] perf(hir): widen the property-array hoist to aliases, captures and nested loops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three shapes the first cut refused, each for a reason that dissolves on inspection. Mac mini, ns/op, the same binary switched with PERRY_LOOP_PROPERTY_HOIST, node v26.5.1 for reference: receiver captured in an arrow 20.35 -> 0.50 (node 0.54) const alias of the receiver 20.49 -> 0.47 (node 0.58) outer loop with a nested loop 20.58 -> 0.47 (node 0.55) loop with an early return 21.59 -> 3.59 (node 0.57) All four beat or match node except the last, which improves 6x and is left short by something else: an inner loop containing `return` does not appear to reach the packed-array admission, so it keeps generic indexing even once the property lookup is gone. That is a codegen-side limit, not a hoist one, and it is the next thing to look at. A `const` alias inherits the data-field proof, because neither name can ever be rebound and both therefore denote the object the literal created. That one rule also reaches receivers read inside a closure at no extra cost: the capture keeps the same LocalId, so the existing rewrite already matches. An alias of a `let` is not admitted — only const bindings ever enter the registry — and there is a test for it, since following a mutable source is exactly how this rule would turn unsound. Nested loops are the shape the pass exists for (`m.rows[i]` outside, the row inside), and refusing them was pure conservatism: a nested loop is safe on the same terms as any other statement, so the scan recurses instead. `return` and `throw` are likewise fine — the hoisted Let is evaluated before the loop either way, and leaving early only skips reads. Every arm the scan admits is also handled by the rewriter, or the read it vouched for would silently keep its per-iteration lookup. size(1) .text: 10974932 -> 10973396, i.e. -1536 bytes (-0.014%); the wider the pass reaches the more per-iteration lookups it deletes, so it keeps removing code. Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p --- .../perry-hir/src/destructuring/var_decl.rs | 21 +++- .../src/lower/property_array_hoist.rs | 104 ++++++++++++++++++ .../perry/tests/loop_property_array_hoist.rs | 84 ++++++++++++++ 3 files changed, 207 insertions(+), 2 deletions(-) diff --git a/crates/perry-hir/src/destructuring/var_decl.rs b/crates/perry-hir/src/destructuring/var_decl.rs index f23a8e9c6d..072b1b5e5e 100644 --- a/crates/perry-hir/src/destructuring/var_decl.rs +++ b/crates/perry-hir/src/destructuring/var_decl.rs @@ -333,12 +333,29 @@ pub(crate) fn lower_var_decl_with_destructuring( // every property of `x` is a data field rather than an accessor — // `is_closed_shape` rejects getters and setters — and the // loop-invariant property hoist refuses to fire without it. + // + // A `const` alias of such a binding inherits the proof: neither + // name can ever be rebound, so both always denote the object the + // literal created. This is what lets the hoist reach receivers + // read through an alias, including one captured by a closure — + // the capture keeps the same `LocalId`, so no extra plumbing is + // needed once the alias is recorded. if !mutable { - if let Some(Expr::New { class_name, .. }) = init.as_ref() { - if class_name.starts_with("__AnonShape_") { + match init.as_ref() { + Some(Expr::New { class_name, .. }) + if class_name.starts_with("__AnonShape_") => + { ctx.closed_shape_literal_locals .insert(id, class_name.clone()); } + Some(Expr::LocalGet(source)) => { + if let Some(class_name) = + ctx.closed_shape_literal_locals.get(source).cloned() + { + ctx.closed_shape_literal_locals.insert(id, class_name); + } + } + _ => {} } } result.push(Stmt::Let { diff --git a/crates/perry-hir/src/lower/property_array_hoist.rs b/crates/perry-hir/src/lower/property_array_hoist.rs index c818a29b2d..705715cd7e 100644 --- a/crates/perry-hir/src/lower/property_array_hoist.rs +++ b/crates/perry-hir/src/lower/property_array_hoist.rs @@ -200,7 +200,40 @@ fn stmt_is_hoist_safe(stmt: &Stmt, recv_id: u32) -> bool { .as_ref() .is_none_or(|b| b.iter().all(|s| stmt_is_hoist_safe(s, recv_id))) } + // Nested loops are the common case this pass exists for — `m.rows[i]` + // in an outer loop with an inner loop over the row — so recurse rather + // than refuse. A nested loop is safe on exactly the same terms: it may + // not rebind the receiver and may not call anything. + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + expr_is_hoist_safe(condition, recv_id) + && body.iter().all(|s| stmt_is_hoist_safe(s, recv_id)) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_ref() + .is_none_or(|s| stmt_is_hoist_safe(s, recv_id)) + && condition + .as_ref() + .is_none_or(|e| expr_is_hoist_safe(e, recv_id)) + && update + .as_ref() + .is_none_or(|e| expr_is_hoist_safe(e, recv_id)) + && body.iter().all(|s| stmt_is_hoist_safe(s, recv_id)) + } + Stmt::Labeled { body, .. } => stmt_is_hoist_safe(body, recv_id), + // Leaving the loop early is fine: the hoisted `Let` is evaluated + // before the loop either way, and the property read it replaces was + // never reached on this path. + Stmt::Return(value) => value + .as_ref() + .is_none_or(|e| expr_is_hoist_safe(e, recv_id)), + Stmt::Throw(value) => expr_is_hoist_safe(value, recv_id), Stmt::Break | Stmt::Continue => true, + Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => true, _ => false, } } @@ -265,6 +298,31 @@ fn stmt_reads_property(stmt: &Stmt, recv_id: u32, property: &str) -> bool { .as_ref() .is_some_and(|b| b.iter().any(|s| stmt_reads_property(s, recv_id, property))) } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + expr_reads_property(condition, recv_id, property) + || body.iter().any(|s| stmt_reads_property(s, recv_id, property)) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_ref() + .is_some_and(|s| stmt_reads_property(s, recv_id, property)) + || condition + .as_ref() + .is_some_and(|e| expr_reads_property(e, recv_id, property)) + || update + .as_ref() + .is_some_and(|e| expr_reads_property(e, recv_id, property)) + || body.iter().any(|s| stmt_reads_property(s, recv_id, property)) + } + Stmt::Labeled { body, .. } => stmt_reads_property(body, recv_id, property), + Stmt::Return(value) => value + .as_ref() + .is_some_and(|e| expr_reads_property(e, recv_id, property)), + Stmt::Throw(value) => expr_reads_property(value, recv_id, property), _ => false, } } @@ -345,10 +403,56 @@ fn rewrite_stmt(stmt: &Stmt, recv_id: u32, property: &str, hoist_id: u32) -> Stm .collect() }), }, + // These mirror the arms `stmt_is_hoist_safe` admits. Anything it + // admits must be rewritten here too, or the read it vouched for keeps + // the per-iteration lookup. + Stmt::While { condition, body } => Stmt::While { + condition: rewrite_expr(condition, recv_id, property, hoist_id), + body: rewrite_block(body, recv_id, property, hoist_id), + }, + Stmt::DoWhile { body, condition } => Stmt::DoWhile { + body: rewrite_block(body, recv_id, property, hoist_id), + condition: rewrite_expr(condition, recv_id, property, hoist_id), + }, + Stmt::For { + init, + condition, + update, + body, + } => Stmt::For { + init: init + .as_ref() + .map(|s| Box::new(rewrite_stmt(s, recv_id, property, hoist_id))), + condition: condition + .as_ref() + .map(|e| rewrite_expr(e, recv_id, property, hoist_id)), + update: update + .as_ref() + .map(|e| rewrite_expr(e, recv_id, property, hoist_id)), + body: rewrite_block(body, recv_id, property, hoist_id), + }, + Stmt::Labeled { label, body } => Stmt::Labeled { + label: label.clone(), + body: Box::new(rewrite_stmt(body, recv_id, property, hoist_id)), + }, + Stmt::Return(value) => Stmt::Return( + value + .as_ref() + .map(|e| rewrite_expr(e, recv_id, property, hoist_id)), + ), + Stmt::Throw(value) => { + Stmt::Throw(rewrite_expr(value, recv_id, property, hoist_id)) + } other => other.clone(), } } +fn rewrite_block(body: &[Stmt], recv_id: u32, property: &str, hoist_id: u32) -> Vec { + body.iter() + .map(|s| rewrite_stmt(s, recv_id, property, hoist_id)) + .collect() +} + fn rewrite_expr(expr: &Expr, recv_id: u32, property: &str, hoist_id: u32) -> Expr { if is_target_property(expr, recv_id, property) { return Expr::LocalGet(hoist_id); diff --git a/crates/perry/tests/loop_property_array_hoist.rs b/crates/perry/tests/loop_property_array_hoist.rs index 9a14dce21d..c63846c671 100644 --- a/crates/perry/tests/loop_property_array_hoist.rs +++ b/crates/perry/tests/loop_property_array_hoist.rs @@ -204,3 +204,87 @@ fn handles_nested_loops_and_string_elements() { "10 abc 0", ); } + +#[test] +fn hoists_through_a_const_alias_and_a_capture() { + // `const h = holder` cannot be rebound and neither can `holder`, so the + // alias inherits the data-field proof. The closure capture keeps the same + // LocalId, so the same rewrite reaches a receiver read inside an arrow. + assert_same_with_and_without_hoist( + "alias and capture", + r#" + const holder = { arr: [1, 2, 3, 4], n: 4 }; + function aliased(): number { + const h = holder; + let s = 0; + for (let i = 0; i < h.arr.length; i++) s += h.arr[i]; + return s; + } + function captured(): number { + const h = holder; + const f = (): number => { + let s = 0; + for (let i = 0; i < h.arr.length; i++) s += h.arr[i]; + return s; + }; + return f(); + } + console.log(aliased() + " " + captured()); + "#, + "10 10", + ); +} + +#[test] +fn refuses_an_alias_of_a_reassignable_binding() { + // `let base` is not in the registry, so the alias inherits nothing and the + // loop keeps its per-iteration lookup. Pinned because the alias rule would + // be unsound if it ever followed a mutable source. + assert_same_with_and_without_hoist( + "mutable source", + r#" + let base: any = { get arr() { return [1, 2]; } }; + const h = base; + let s = 0; + for (let i = 0; i < h.arr.length; i++) s += h.arr[i]; + console.log(s); + "#, + "3", + ); +} + +#[test] +fn hoists_an_outer_loop_containing_a_nested_loop() { + assert_same_with_and_without_hoist( + "nested loop hoisted", + r#" + const g = { cells: [1, 2, 3, 4], n: 4 }; + let s = 0; + for (let r = 0; r < 3; r++) { + for (let i = 0; i < g.cells.length; i++) { + for (let k = 0; k < 2; k++) s += g.cells[i]; + } + } + console.log(s); + "#, + "60", + ); +} + +#[test] +fn hoists_a_loop_that_returns_early() { + assert_same_with_and_without_hoist( + "early return", + r#" + const h = { arr: [3, 7, 11, 15], n: 4 }; + function firstOver(limit: number): number { + for (let i = 0; i < h.arr.length; i++) { + if (h.arr[i] > limit) return h.arr[i]; + } + return -1; + } + console.log(firstOver(8) + " " + firstOver(100)); + "#, + "11 -1", + ); +} From ce8ef5326fbf021591b14bbbf48a2edad2e84465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 12:43:25 +0200 Subject: [PATCH 2/2] style: rustfmt --- .../perry-hir/src/lower/property_array_hoist.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/perry-hir/src/lower/property_array_hoist.rs b/crates/perry-hir/src/lower/property_array_hoist.rs index 705715cd7e..c0a9a9e1ca 100644 --- a/crates/perry-hir/src/lower/property_array_hoist.rs +++ b/crates/perry-hir/src/lower/property_array_hoist.rs @@ -214,8 +214,7 @@ fn stmt_is_hoist_safe(stmt: &Stmt, recv_id: u32) -> bool { update, body, } => { - init.as_ref() - .is_none_or(|s| stmt_is_hoist_safe(s, recv_id)) + init.as_ref().is_none_or(|s| stmt_is_hoist_safe(s, recv_id)) && condition .as_ref() .is_none_or(|e| expr_is_hoist_safe(e, recv_id)) @@ -300,7 +299,9 @@ fn stmt_reads_property(stmt: &Stmt, recv_id: u32, property: &str) -> bool { } Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { expr_reads_property(condition, recv_id, property) - || body.iter().any(|s| stmt_reads_property(s, recv_id, property)) + || body + .iter() + .any(|s| stmt_reads_property(s, recv_id, property)) } Stmt::For { init, @@ -316,7 +317,9 @@ fn stmt_reads_property(stmt: &Stmt, recv_id: u32, property: &str) -> bool { || update .as_ref() .is_some_and(|e| expr_reads_property(e, recv_id, property)) - || body.iter().any(|s| stmt_reads_property(s, recv_id, property)) + || body + .iter() + .any(|s| stmt_reads_property(s, recv_id, property)) } Stmt::Labeled { body, .. } => stmt_reads_property(body, recv_id, property), Stmt::Return(value) => value @@ -440,9 +443,7 @@ fn rewrite_stmt(stmt: &Stmt, recv_id: u32, property: &str, hoist_id: u32) -> Stm .as_ref() .map(|e| rewrite_expr(e, recv_id, property, hoist_id)), ), - Stmt::Throw(value) => { - Stmt::Throw(rewrite_expr(value, recv_id, property, hoist_id)) - } + Stmt::Throw(value) => Stmt::Throw(rewrite_expr(value, recv_id, property, hoist_id)), other => other.clone(), } }