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
18 changes: 18 additions & 0 deletions changelog.d/8885-ecs-followup-gc-bookkeeping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
Removed most of the remaining GC-bookkeeping and dispatch overhead on the ECS
command path (follow-up to #8872). General compiler/runtime mechanisms:
branded `number & {…}` intersections and generic/imported type aliases now
lower to their primitive (they were `Any`, so every entity id was dynamic);
inline plain-double fast paths for dynamic compares and truthiness;
beta-reduction of called arrow-literal locals left behind by inlining;
`Map.get` heals stale array-growth forwarding stubs in place; a header-bit
fast lane in `clean_arr_ptr` and every strict array helper; a process-global
address sketch, an object/closure per-object-mask threshold of eight slots, a
death prune for the per-object layout tables and a live-young-record count
that together let the inline allocator skip the stale-layout probe; the
strict store lane, `pop` and `length =` resolve the header once; the
dirty-page and `typeof` caches move to hot TLS; the string-demote tag test
and plain closure-capture reads are inlined; a validated-parent write-barrier
entry; and `length = 0` keeps an all-pointer array all-pointer. On the
upstream `codehz/ecs` "5k entities: 3 commands each + sync" row the compiled
benchmark went from 7.30 ms/op to ~4.7 ms/op (−36%, paired runs on an idle
Mac mini; Node 26.5.1 is 1.76 ms/op).
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,7 @@ pub(super) fn compile_closure(
declared_only_numeric_locals: std::collections::HashSet::new(),
shadow_slot_clears_after_stmt,
arena_state_slot: None,
arena_state_lazy: false,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down
14 changes: 14 additions & 0 deletions crates/perry-codegen/src/codegen/declared_string_add_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,20 @@ fn assigning_one_local_to_another_demotes_a_possible_string_alias() {
ir.contains("call void @js_string_addref_if_heap_string("),
"assignment aliases need the same demote as declaration aliases:\n{ir}"
);
// The helper's tag test is hoisted into IR: the call sits behind an
// inline `STRING_TAG` compare, so a numeric copy never leaves the
// function. Pin the compare AND the call — the demote must still reach
// the runtime for a real heap string.
let tag_at = ir
.find("icmp ne i64 %")
.expect("inline STRING_TAG compare before the demote call");
let call_at = ir
.find("call void @js_string_addref_if_heap_string(")
.expect("demote call");
assert!(
ir.contains(", 9223090561878065152") && tag_at < call_at,
"the demote call must be guarded by an inline 0x7FFF_0000_0000_0000 tag compare:\n{ir}"
);
}

// ------------------------------------------------------- untouched tiers
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ pub(super) fn compile_module_entry(
declared_only_numeric_locals: std::collections::HashSet::new(),
shadow_slot_clears_after_stmt: main_shadow_slot_clears_after_stmt,
arena_state_slot: None,
arena_state_lazy: false,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down Expand Up @@ -1588,6 +1589,7 @@ pub(super) fn compile_module_entry(
declared_only_numeric_locals: std::collections::HashSet::new(),
shadow_slot_clears_after_stmt: init_shadow_slot_clears_after_stmt,
arena_state_slot: None,
arena_state_lazy: false,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,7 @@ pub(super) fn compile_function(
shadow_slots_bound: bound_param_slots,
temp_roots: crate::rooting::TempRootPool::default(),
arena_state_slot,
arena_state_lazy: false,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ pub(super) fn compile_method(
declared_only_numeric_locals: std::collections::HashSet::new(),
shadow_slot_clears_after_stmt,
arena_state_slot: None,
arena_state_lazy: false,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down Expand Up @@ -1662,6 +1663,7 @@ pub(super) fn compile_static_method(
declared_only_numeric_locals: std::collections::HashSet::new(),
shadow_slot_clears_after_stmt,
arena_state_slot: None,
arena_state_lazy: false,
class_keys_slots: HashMap::new(),
class_shape_slots: HashMap::new(),
class_header_images: HashMap::new(),
Expand Down
22 changes: 21 additions & 1 deletion crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,26 @@ fn unproven_bitset_index_still_has_raw_number_truthiness() {
);
}

/// `fcmp one` has two sources. The constructive-proof shortcut this test guards
/// emits it unguarded; the dynamic truthiness lowering also emits one, but only
/// inside its own `truthy.num` block — after the bit test that has already proved
/// the value is a plain untagged non-NaN double, where it is exactly correct.
///
/// So the claim is "no *unguarded* numeric truthiness", not "no `fcmp one`".
/// Mirrors `type_analysis::numeric::tests::fcmp_one_only_under_the_plain_number_guard`.
fn fcmp_one_outside_the_plain_number_guard(body: &str) -> bool {
let mut label = String::new();
for line in body.lines() {
let trimmed = line.trim_start();
if !line.starts_with(' ') && trimmed.ends_with(':') {
label = trimmed.trim_end_matches(':').to_string();
} else if trimmed.contains("fcmp one") && !label.starts_with("truthy.num") {
return true;
}
}
false
}

/// A generic bitwise method is not enough. Keep using total truthiness unless
/// the full Number-or-throw bitset tree matched structurally.
#[test]
Expand All @@ -837,7 +857,7 @@ fn noncanonical_bitwise_method_does_not_gain_raw_number_truthiness() {
"a noncanonical bitwise return bypassed total JavaScript truthiness:\n{probe}"
);
assert!(
!probe.contains("fcmp one double"),
!fcmp_one_outside_the_plain_number_guard(&probe),
"an arbitrary bitwise return was mistaken for the canonical bitset test:\n{probe}"
);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-codegen/src/expr/barrier_stem_census_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ pub(super) const VERIFIED_BARRIER_STEMS: &[(&str, StemKind)] = &[
("put.pic", StemKind::PointerTestedStore),
];

const BARRIER_CALL: &str = "call void @js_write_barrier_slot(";
const BARRIER_CALL: &str = "call void @js_write_barrier_slot";
const INCREMENTAL_GLOBAL: &str = "@PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT";

// ---------------------------------------------------------------------------
Expand Down
137 changes: 115 additions & 22 deletions crates/perry-codegen/src/expr/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,85 @@ fn lower_string_literal_strict_eq(
///
/// Returns an i64 holding `TAG_TRUE`/`TAG_FALSE` (or `js_eq`'s own tagged
/// boolean), i.e. the same value the bare call produced.
/// Quiet-NaN prefix (`0x7FF8_0000_0000_0000`) shared by every Perry NaN-box
/// tag and by the canonical NaN itself.
const QNAN_PREFIX_I64: &str = "9221120237041090560";

/// `(bits & 0x7FF8…) != 0x7FF8…`: the operand is an ordinary IEEE double —
/// finite, ±Infinity, or a signaling-NaN pattern no Perry encoding occupies.
/// Every NaN-box tag (top-16 `0x7FF9`..=`0x7FFF`, sign-clear) and the quiet
/// NaN carry the prefix, so one mask+compare separates "plain number" from
/// "tagged or NaN" without decoding either side. Two plain numbers answer
/// every relational and (strict or loose) equality operator with the raw
/// `fcmp`; the helper keeps NaN, so the unordered edge never reaches the
/// inline predicate.
fn emit_is_plain_double(ctx: &mut FnCtx<'_>, bits: &str) -> String {
let blk = ctx.block();
let masked = blk.and(I64, bits, QNAN_PREFIX_I64);
blk.icmp_ne(I64, &masked, QNAN_PREFIX_I64)
}

/// Dynamic-operand comparison with an inline plain-number fast path.
///
/// When both NaN-boxed operands are ordinary doubles the result is
/// `select(fcmp <pred> l, r, TAG_TRUE, TAG_FALSE)`; every other shape —
/// strings, BigInt, objects with `valueOf`/`toString`, null/undefined/boolean
/// coercions, NaN — takes `helper`, which owns the full ECMAScript semantics.
/// `helper_takes_bits` selects the `(i64, i64) -> i64` helper ABI
/// (`js_eq`, `js_loose_eq`) over the `(double, double) -> double` one
/// (`js_rel_*`). Returns the NaN-boxed boolean as i64 bits.
fn lower_dynamic_compare_bits(
ctx: &mut FnCtx<'_>,
l: &str,
r: &str,
pred: &str,
helper: &str,
helper_takes_bits: bool,
) -> String {
let l_bits = ctx.block().bitcast_double_to_i64(l);
let r_bits = ctx.block().bitcast_double_to_i64(r);
let l_plain = emit_is_plain_double(ctx, &l_bits);
let r_plain = emit_is_plain_double(ctx, &r_bits);
let both_plain = ctx.block().and(I1, &l_plain, &r_plain);

let fast_idx = ctx.new_block("dyncmp.num");
let slow_idx = ctx.new_block("dyncmp.slow");
let merge_idx = ctx.new_block("dyncmp.merge");
let fast_l = ctx.block_label(fast_idx);
let slow_l = ctx.block_label(slow_idx);
let merge_l = ctx.block_label(merge_idx);
ctx.block().cond_br(&both_plain, &fast_l, &slow_l);

ctx.current_block = fast_idx;
let bit = ctx.block().fcmp(pred, l, r);
let fast_res = ctx.block().select(
I1,
&bit,
I64,
crate::nanbox::TAG_TRUE_I64,
crate::nanbox::TAG_FALSE_I64,
);
let fast_pred = ctx.block().label.clone();
ctx.block().br(&merge_l);

ctx.current_block = slow_idx;
let slow_res = if helper_takes_bits {
ctx.block()
.call(I64, helper, &[(I64, &l_bits), (I64, &r_bits)])
} else {
let boxed = ctx
.block()
.call(DOUBLE, helper, &[(DOUBLE, l), (DOUBLE, r)]);
ctx.block().bitcast_double_to_i64(&boxed)
};
let slow_pred = ctx.block().label.clone();
ctx.block().br(&merge_l);

ctx.current_block = merge_idx;
ctx.block()
.phi(I64, &[(&fast_res, &fast_pred), (&slow_res, &slow_pred)])
}

fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String {
let l_bits = ctx.block().bitcast_double_to_i64(l);
let r_bits = ctx.block().bitcast_double_to_i64(r);
Expand Down Expand Up @@ -411,10 +490,26 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String {
let same_ok = ctx.block().or(I1, &tagged, &not_nan);
ctx.block().cond_br(&same_ok, &true_l, &slow_l);

// Different bits: only a same-tag pair whose encoding is canonical is
// decidable here. Pointer pairs need the runtime's allocation registries
// before either payload can safely be treated as a GC allocation.
// Different bits: two plain numbers are decided by `fcmp` (only `+0`/`-0`
// differ in bits yet compare equal); otherwise only a same-tag pair whose
// encoding is canonical is decidable here. Pointer pairs need the
// runtime's allocation registries before either payload can safely be
// treated as a GC allocation.
ctx.current_block = diff_idx;
let num_idx = ctx.new_block("anyeq.num");
let tag_idx = ctx.new_block("anyeq.tag");
let num_l = ctx.block_label(num_idx);
let tag_l = ctx.block_label(tag_idx);
let l_plain = emit_is_plain_double(ctx, &l_bits);
let r_plain = emit_is_plain_double(ctx, &r_bits);
let both_plain = ctx.block().and(I1, &l_plain, &r_plain);
ctx.block().cond_br(&both_plain, &num_l, &tag_l);

ctx.current_block = num_idx;
let num_eq = ctx.block().fcmp("oeq", l, r);
ctx.block().cond_br(&num_eq, &true_l, &false_l);

ctx.current_block = tag_idx;
let l_tag = ctx.block().lshr(I64, &l_bits, "48");
let r_tag = ctx.block().lshr(I64, &r_bits, "48");
let l_sso = ctx
Expand Down Expand Up @@ -1271,10 +1366,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// call. Loose `==`'s cross-type coercions are not
// bit-decidable, so it keeps the bare call.
let result_bits = if matches!(op, CompareOp::LooseEq | CompareOp::LooseNe) {
let blk = ctx.block();
let l_bits = blk.bitcast_double_to_i64(&l);
let r_bits = blk.bitcast_double_to_i64(&r);
blk.call(I64, "js_loose_eq", &[(I64, &l_bits), (I64, &r_bits)])
lower_dynamic_compare_bits(ctx, &l, &r, "oeq", "js_loose_eq", true)
} else {
lower_strict_eq_inline_any(ctx, &l, &r)
};
Expand Down Expand Up @@ -1448,11 +1540,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// "1"==1, false==0, etc.). Strict === already handled
// above by the typed fast paths.
if matches!(op, CompareOp::LooseEq | CompareOp::LooseNe) {
let blk = ctx.block();
let l_bits = blk.bitcast_double_to_i64(&l);
let r_bits = blk.bitcast_double_to_i64(&r);
let result_bits =
blk.call(I64, "js_loose_eq", &[(I64, &l_bits), (I64, &r_bits)]);
lower_dynamic_compare_bits(ctx, &l, &r, "oeq", "js_loose_eq", true);
let blk = ctx.block();
if matches!(op, CompareOp::LooseNe) {
let cmp = blk.icmp_eq(I64, &result_bits, crate::nanbox::TAG_TRUE_I64);
let inv = blk.xor(crate::types::I1, &cmp, "true");
Expand Down Expand Up @@ -1489,13 +1579,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
return Ok(lower_strict_eq_against_number(ctx, *op, &l, &r));
}
if is_relational_op && !both_numeric {
let fname = match op {
CompareOp::Lt => "js_rel_lt",
CompareOp::Le => "js_rel_le",
CompareOp::Gt => "js_rel_gt",
CompareOp::Ge => "js_rel_ge",
let (pred, fname) = match op {
CompareOp::Lt => ("olt", "js_rel_lt"),
CompareOp::Le => ("ole", "js_rel_le"),
CompareOp::Gt => ("ogt", "js_rel_gt"),
CompareOp::Ge => ("oge", "js_rel_ge"),
_ => unreachable!(),
};
// #8876: one operand statically proven numeric takes the
// specialized inline form first.
if exactly_one_numeric {
return Ok(lower_relational_against_number(
ctx,
Expand All @@ -1506,9 +1598,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
fname,
));
}
let blk = ctx.block();
let res = blk.call(DOUBLE, fname, &[(DOUBLE, &l), (DOUBLE, &r)]);
return Ok(res);
// #8885: neither is statically proven, but two plain numbers
// are the overwhelmingly common dynamic shape (erased ids,
// PIC-loaded fields); they take the raw `fcmp` inline and
// everything else keeps the helper.
let bits = lower_dynamic_compare_bits(ctx, &l, &r, pred, fname, false);
return Ok(ctx.block().bitcast_i64_to_double(&bits));
}
// Strict ===/!== where the operands are NOT both certainly
// numeric must NOT fall to the bare fcmp tail: a declared
Expand All @@ -1518,10 +1613,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// js_eq answers correctly for every runtime shape, including
// the honest number-vs-object case (#3576 probe family).
if matches!(op, CompareOp::Eq | CompareOp::Ne) && !both_numeric {
let result_bits = lower_dynamic_compare_bits(ctx, &l, &r, "oeq", "js_eq", true);
let blk = ctx.block();
let l_bits = blk.bitcast_double_to_i64(&l);
let r_bits = blk.bitcast_double_to_i64(&r);
let result_bits = blk.call(I64, "js_eq", &[(I64, &l_bits), (I64, &r_bits)]);
if matches!(op, CompareOp::Ne) {
let cmp = blk.icmp_eq(I64, &result_bits, crate::nanbox::TAG_TRUE_I64);
let inv = blk.xor(crate::types::I1, &cmp, "true");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -978,7 +978,7 @@ fn growing_array_store_uses_the_reallocated_head_for_its_barrier() {
.join("\n");
let barrier = realloc_body
.lines()
.find(|line| line.contains("@js_write_barrier_slot("))
.find(|line| line.contains("@js_write_barrier_slot"))
.unwrap_or_else(|| panic!("realloc path lost its write barrier:\n{realloc_body}"));
assert!(
barrier.contains(&format!("i64 {new_head}")),
Expand Down
30 changes: 30 additions & 0 deletions crates/perry-codegen/src/expr/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,36 @@ pub(crate) fn class_field_store_layout_note_is_conforming(
/// there would leave a refcount==1 string aliased from the heap for a later
/// in-place `+=` to rewrite underneath the stored slot — silent corruption
/// with no crash to trace it back from.
/// `js_string_addref_if_heap_string(v)` with the helper's own tag test hoisted
/// into IR: the call is taken only when `v` carries `STRING_TAG`.
///
/// The helper is deliberately applied to every local-sourced copy (#7846: a
/// declared numeric/object type is not proof the value is not a string), so
/// on a numeric-heavy path — the ids and records an ECS `world.set` copies
/// around — every one of those calls returned after its first compare. The
/// compare is four instructions inline; the call stays exactly as it was for
/// a real heap string, so the aliasing contract (#7846, #8432) is unchanged.
pub(crate) fn emit_string_addref_if_heap_string(ctx: &mut FnCtx<'_>, value: &str) {
const TAG_MASK_I64: &str = "-281474976710656"; // 0xFFFF_0000_0000_0000
const STRING_TAG_I64: &str = "9223090561878065152"; // 0x7FFF_0000_0000_0000
let call_idx = ctx.new_block("str_addref.call");
let done_idx = ctx.new_block("str_addref.done");
let call_label = ctx.block_label(call_idx);
let done_label = ctx.block_label(done_idx);
{
let blk = ctx.block();
let bits = blk.bitcast_double_to_i64(value);
let tag = blk.and(I64, &bits, TAG_MASK_I64);
let not_string = blk.icmp_ne(I64, &tag, STRING_TAG_I64);
blk.cond_br(&not_string, &done_label, &call_label);
}
ctx.current_block = call_idx;
ctx.block()
.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value)]);
ctx.block().br(&done_label);
ctx.current_block = done_idx;
}

pub(crate) fn class_field_store_needs_string_addref(ctx: &FnCtx<'_>, value: &Expr) -> bool {
store_needs_string_addref(ctx, value)
}
Expand Down
Loading
Loading