Skip to content
Closed
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
9 changes: 9 additions & 0 deletions changelog.d/8675-dynamic-add-tree-guard.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 24 additions & 3 deletions crates/perry-codegen/src/expr/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -889,9 +907,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
|| 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",
Expand Down
92 changes: 92 additions & 0 deletions crates/perry-codegen/src/expr/dynamic_add_tree_tests.rs
Original file line number Diff line number Diff line change
@@ -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<Stmt> {
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}"
);
}
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading