From ed9d86bce3c5206d25af5d9b1c963ed8dbe353bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 03:29:09 +0200 Subject: [PATCH 1/2] perf(codegen): share numeric guards across dynamic add trees --- crates/perry-codegen/src/expr/binary.rs | 27 +++++- .../src/expr/dynamic_add_tree_tests.rs | 92 +++++++++++++++++++ crates/perry-codegen/src/expr/mod.rs | 2 + 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index c289ae6c08..27e897139c 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -303,6 +303,24 @@ fn add_tree_leaves<'a>(expr: &'a Expr, out: &mut Vec<&'a Expr>) { } } +/// Whether a fully-dynamic `+` tree is worth one shared numeric guard. +/// +/// A three-or-more-leaf tree is the common accumulator shape +/// `sum += row.x + row.y`: lowering each node independently otherwise pays +/// the dynamic add helper twice even when every runtime value is a number. At +/// two leaves the guard merely moves the helper behind a branch; starting at +/// three it can replace two or more helper calls with one shared tag check. +/// +/// Do not require a static numeric hint here. The important accumulator case +/// is often a captured local plus fields read from interface-shaped objects, +/// so every leaf is `Any` to codegen. The guard itself is the runtime proof; +/// its cold arm preserves the original tree and exact dynamic `+` semantics. +fn dynamic_add_tree_benefits_shared_guard(expr: &Expr) -> bool { + let mut leaves = Vec::new(); + add_tree_leaves(expr, &mut leaves); + leaves.len() >= 3 +} + /// Rebuild the `+` tree over already-lowered leaf values, node for node, so the /// original associativity survives. `fast` picks the inline `fadd`; otherwise /// every node goes through the spec-`+` helper. @@ -889,9 +907,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { || crate::type_analysis::expr_produces_canonical_raw_f64(ctx, left)) && (right_bool || crate::type_analysis::expr_produces_canonical_raw_f64(ctx, right)); - if !(both_numeric || boolean_numeric_add) - || add_operands_have_pod_materialization_hazard(ctx, left, right) - { + let materialization_hazard = + add_operands_have_pod_materialization_hazard(ctx, left, right); + if !(both_numeric || boolean_numeric_add) || materialization_hazard { + if dynamic_add_tree_benefits_shared_guard(expr) && !materialization_hazard { + return lower_guarded_numeric_add(ctx, expr); + } return lower_rooted_dynamic_binary( ctx, "js_dynamic_string_or_number_add", diff --git a/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs b/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs new file mode 100644 index 0000000000..8f0d50322b --- /dev/null +++ b/crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs @@ -0,0 +1,92 @@ +//! IR coverage for the shared numeric guard on fully dynamic `+` trees. + +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Stmt}; + +use crate::temp_root_coverage::main_ir_for as ir_for; + +const A: u32 = 1; +const B: u32 = 2; +const C: u32 = 3; +const RESULT: u32 = 4; + +fn any_local(id: u32, name: &str, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(init), + } +} + +fn add(left: Expr, right: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(left), + right: Box::new(right), + } +} + +fn dynamic_locals() -> Vec { + vec![ + any_local(A, "a", Expr::Undefined), + any_local(B, "b", Expr::Undefined), + any_local(C, "c", Expr::Undefined), + ] +} + +fn result(expr: Expr) -> Stmt { + Stmt::Let { + id: RESULT, + name: "result".to_string(), + ty: Type::Any, + mutable: false, + init: Some(expr), + } +} + +#[test] +fn three_leaf_dynamic_add_tree_uses_one_shared_guard() { + let mut body = dynamic_locals(); + body.push(result(add( + Expr::LocalGet(A), + add(Expr::LocalGet(B), Expr::LocalGet(C)), + ))); + let ir = ir_for("three_leaf_dynamic_add_tree", body); + + assert_eq!( + ir.matches("\nguarded_add.numeric.").count(), + 1, + "the tree should have one shared numeric block:\n{ir}" + ); + assert_eq!( + ir.matches("fadd double").count(), + 2, + "the fast arm must preserve both additions:\n{ir}" + ); + assert_eq!( + ir.matches("call double @js_dynamic_string_or_number_add(") + .count(), + 2, + "the cold arm must preserve both dynamic additions:\n{ir}" + ); +} + +#[test] +fn two_leaf_dynamic_add_stays_on_direct_dispatch() { + let mut body = dynamic_locals(); + body.push(result(add(Expr::LocalGet(A), Expr::LocalGet(B)))); + let ir = ir_for("two_leaf_dynamic_add", body); + + assert!( + !ir.contains("guarded_add.numeric") && !ir.contains("fadd double"), + "a single dynamic add should not pay for a separate guard diamond:\n{ir}" + ); + assert_eq!( + ir.matches("call double @js_dynamic_string_or_number_add(") + .count(), + 1, + "a single dynamic add should keep direct dispatch:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 95a6640830..1df50da00a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2158,6 +2158,8 @@ mod compare; mod compare_tests; mod conditional; mod dyn_extern_i18n; +#[cfg(test)] +mod dynamic_add_tree_tests; mod env_clones; mod fs_await; mod index_get; From 5c1d55e38ff10e8f199389c18642e16d72c9fb33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 03:34:22 +0200 Subject: [PATCH 2/2] docs(changelog): note shared dynamic add guard --- changelog.d/8675-dynamic-add-tree-guard.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/8675-dynamic-add-tree-guard.md diff --git a/changelog.d/8675-dynamic-add-tree-guard.md b/changelog.d/8675-dynamic-add-tree-guard.md new file mode 100644 index 0000000000..e6aac3b950 --- /dev/null +++ b/changelog.d/8675-dynamic-add-tree-guard.md @@ -0,0 +1,9 @@ +### Performance + +- Lower dynamic `+` trees with three or more leaves behind one shared numeric + guard. Number-only executions use native additions, while the cold arm keeps + the original tree, evaluation order, associativity, and complete dynamic + string/BigInt/Symbol/object-coercion behavior. On the `codehz/ecs` 10k + accumulation query, 11 alternating Mac mini pairs reduced median time by + 4.11%, with 11/11 wins and every semantic oracle passing. The read-only + query was neutral, and this change does not claim Node parity.