Skip to content
Merged
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
10 changes: 10 additions & 0 deletions changelog.d/8674-array-forwarding-path-compression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
### Performance

- Compress validated multi-hop array-growth forwarding chains at their retained
head. Generated indexed-access guards can now heal stale aliases after repeated
capacity growth with their existing one-hop path instead of entering generic
indexed lookup on every element access. On the full `codehz/ecs` suite, 11
alternating Mac mini pairs reduced the 10k read-only query by 81.94% and the
accumulation query by 76.59%, with 11/11 wins and every semantic oracle passing.
Direct Node comparisons still leave 6.282x and 3.127x gaps respectively, so
this change does not claim Node parity.
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.
9 changes: 9 additions & 0 deletions changelog.d/8676-packed-method-shape-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
category: Performance
title: Pack monomorphic method shape guards
---

Monomorphic direct-method guards now validate the contiguous GC, class, and
ShapeId header fields with two packed loads while retaining prototype
invalidation, pointer-range checks, ShapeId validation, and the generic
fallback. This reduces repeated dispatch proof work in hot method-call loops.
18 changes: 11 additions & 7 deletions crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,17 +731,21 @@ fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() {
probe.contains("method_direct.inline_deref")
&& probe.contains("getelementptr i8, ptr")
&& probe.contains("i64 -8")
&& probe.contains("i64 -7")
&& probe.contains("i64 -6")
&& probe.contains("and i16")
&& probe.contains(", 2048")
&& probe.contains("icmp ne i32")
&& probe.contains("i64 4")
&& probe.contains("and i32")
&& probe.contains(", 134250751")
&& probe.contains("icmp eq i32")
&& probe.contains(", 2")
&& probe.contains("load i64, ptr")
&& probe.contains("zext i32")
&& probe.contains("shl i64")
&& probe.contains(", 32")
&& probe.contains("or i64")
&& probe.contains("icmp eq i64")
&& probe.contains("add i32")
&& probe.contains(", -2147483648")
&& probe.contains("icmp ult i32")
&& probe.contains(", 1073741824"),
"the header block must check the GC type, forwarding flag, own-descriptor bit, nonzero class id, ShapeId domain, and live ShapeId:\n{probe}"
"the packed header block must check the GC type, forwarding flag, own-descriptor bit, exact class/ShapeId pair, and ShapeId domain:\n{probe}"
);
}

Expand Down
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
109 changes: 78 additions & 31 deletions crates/perry-codegen/src/lower_call/method_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ use crate::expr::{
};
use crate::nanbox::double_literal;
use crate::native_value::LoweredValue;
use crate::types::{DOUBLE, I1, I16, I32, I64, I8};
use crate::types::{DOUBLE, I1, I32, I64, I8};

const POINTER_TAG_HI16: &str = "32765"; // 0x7FFD
const GC_TYPE_OBJECT: &str = "2";
const GC_FLAG_FORWARDED_I8: &str = "-128"; // 0x80 as i8
const OBJ_FLAG_HAS_DESCRIPTORS_I16: &str = "2048"; // 0x0800
// The first four bytes before an ObjectHeader are, in little-endian order,
// `gtype: u8`, `flags: u8`, and `reserved: u16`. One masked i32 load can
// therefore prove the three fields the direct-method contract consumes:
//
// gtype == GC_TYPE_OBJECT
// flags & GC_FLAG_FORWARDED == 0
// reserved & OBJ_FLAG_HAS_DESCRIPTORS == 0
//
// Mask: 0x0800_0000 (descriptor bit) | 0x0000_8000 (forwarded bit) |
// 0x0000_00ff (the complete gtype byte).
const GC_OBJECT_METHOD_GUARD_MASK_I32: &str = "134250751"; // 0x0800_80ff
const SHAPE_ID_BASE_NEG_I32: &str = "-2147483648"; // subtract 0x8000_0000
const SHAPE_ID_RANGE_LEN: &str = "1073741824"; // 0x4000_0000

Expand Down Expand Up @@ -77,44 +86,82 @@ fn emit_inline_direct_method_shape_guard(
let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64);
let obj_ptr = blk.inttoptr(I64, &recv_handle);

let gtype_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]);
let gtype = blk.load(I8, &gtype_ptr);
let gtype_ok = blk.icmp_eq(I8, &gtype, GC_TYPE_OBJECT);
let gc_header_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]);
let gc_header = blk.load(I32, &gc_header_ptr);
let guarded_gc_bits = blk.and(I32, &gc_header, GC_OBJECT_METHOD_GUARD_MASK_I32);
let gc_header_ok = blk.icmp_eq(I32, &guarded_gc_bits, GC_TYPE_OBJECT);

let gflags_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-7")]);
let gflags = blk.load(I8, &gflags_ptr);
let forwarded = blk.and(I8, &gflags, GC_FLAG_FORWARDED_I8);
let not_forwarded = blk.icmp_eq(I8, &forwarded, "0");
// ObjectHeader begins with adjacent `class_id: u32` and
// `shape_id: u32`. Compare them as one packed word. The expected
// ShapeId is still range-checked below, so equality proves the live
// receiver's class is non-zero and its ShapeId is in-domain without
// four separate field predicates.
let class_shape = blk.load(I64, &obj_ptr);
let expected_shape_i64 = blk.zext(I32, expected_shape_id, I64);
let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32");
let expected_class_shape = blk.or(I64, &expected_shape_high, expected_class_id);
let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape);

let reserved_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]);
let reserved = blk.load(I16, &reserved_ptr);
let descriptor_bits = blk.and(I16, &reserved, OBJ_FLAG_HAS_DESCRIPTORS_I16);
let no_own_descriptors = blk.icmp_eq(I16, &descriptor_bits, "0");

let class_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]);
let class_id = blk.load(I32, &class_ptr);
let class_valid = blk.icmp_ne(I32, &class_id, "0");
let class_ok = blk.icmp_eq(I32, &class_id, expected_class_id);

let shape_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]);
let shape_id = blk.load(I32, &shape_ptr);
// `is_shape_id` is `[0x8000_0000, 0xC000_0000)`. Subtract the base
// modulo i32 and compare with the range length, matching the runtime
// helper without a call.
let shape_id_rel = blk.add(I32, &shape_id, SHAPE_ID_BASE_NEG_I32);
// helper. Equality above transfers this proof to the live header.
let shape_id_rel = blk.add(I32, expected_shape_id, SHAPE_ID_BASE_NEG_I32);
let shape_valid = blk.icmp_ult(I32, &shape_id_rel, SHAPE_ID_RANGE_LEN);
let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id);

let mut pass = blk.and(I1, &gtype_ok, &not_forwarded);
pass = blk.and(I1, &pass, &no_own_descriptors);
pass = blk.and(I1, &pass, &class_valid);
pass = blk.and(I1, &pass, &class_ok);
pass = blk.and(I1, &pass, &shape_valid);
pass = blk.and(I1, &pass, &shape_ok);
let pass = blk.and(I1, &gc_header_ok, &class_shape_ok);
let pass = blk.and(I1, &pass, &shape_valid);
blk.cond_br(&pass, fast_label, fallback_label);
}
}

#[cfg(test)]
mod packed_guard_tests {
use super::*;

/// Anti-drift gate for the runtime fields packed into the i32 load at
/// `obj - 8`. Keep this alongside the emitter so a flag move changes a
/// failing test instead of silently weakening a generated guard.
#[test]
fn gc_header_mask_preserves_the_direct_method_contract() {
let obj_type_mask = 0x0000_00ffu32;
let forwarded = u32::from(0x80u8) << 8;
let has_descriptors = 0x0800u32 << 16;
let mask = obj_type_mask | forwarded | has_descriptors;
let expected = u32::from(2u8);

assert_eq!(GC_OBJECT_METHOD_GUARD_MASK_I32, mask.to_string());
assert_eq!(expected & mask, expected);
assert_ne!((expected | forwarded) & mask, expected);
assert_ne!((expected | has_descriptors) & mask, expected);
assert_ne!((expected ^ 1) & mask, expected);
}

/// `ObjectHeader::{class_id,parent_class_id}` are adjacent u32 fields.
/// Perry's supported native targets are little-endian, so one i64 load
/// sees class in the low word and ShapeId in the high word.
#[test]
fn class_shape_word_uses_the_supported_little_endian_layout() {
let class_id = 0x1234_5678u32;
let shape_id = 0x89ab_cdefu32;
let mut bytes = [0u8; 8];
bytes[..4].copy_from_slice(&class_id.to_le_bytes());
bytes[4..].copy_from_slice(&shape_id.to_le_bytes());
assert_eq!(
u64::from_le_bytes(bytes),
(u64::from(shape_id) << 32) | u64::from(class_id)
);

for triple in [
"aarch64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-linux-android",
"x86_64-pc-windows-msvc",
] {
assert!(!triple.starts_with("s390") && !triple.starts_with("powerpc64-"));
}
}
}

fn typed_i1_method_signature_note(reps: &[crate::codegen::TypedParamRep]) -> String {
let first = reps.first().map(|rep| rep.label()).unwrap_or("void");
if reps.len() <= 1 {
Expand Down
Loading
Loading