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
12 changes: 12 additions & 0 deletions changelog.d/8729-symbol-identity-equality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Made strict equality against a proven `Symbol()` or `Symbol.for()` value use
direct identity instead of the generic JavaScript equality helper. The proof
comes only from a stable constructor initializer, never from an erased
TypeScript `symbol` annotation; reassigned bindings and loose equality retain
their semantic runtime paths.

This removes two generic `js_eq` calls from codehz/ecs's 10k-entity
accumulation loop. On an Apple M1 Mac mini, 11 alternating process pairs
measured the row at 0.195845 ms versus 0.615553 ms on the parent change, a
68.193% median paired improvement with 11/11 wins. The full ECS suite remained
7/7 with checksum 50005000, and a forced verified-GC Symbol stress run recorded
87 copying minors and 11,470 copied objects with Node-identical output.
34 changes: 34 additions & 0 deletions crates/perry-codegen/src/codegen/module_globals_emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ fn module_global_runtime_type(
Expr::String(_) | Expr::WtfString(_) | Expr::I18nString { .. } | Expr::TypeOf(_) => {
Some(Type::String)
}
Expr::SymbolNew(_) | Expr::SymbolFor(_) => Some(Type::Symbol),
// Compiler-owned allocation HIR establishes these runtime classes
// independently of the erased binding annotation. Keep module-global
// facts aligned with `proven_type_from_init`; otherwise a value that
Expand Down Expand Up @@ -75,6 +76,39 @@ fn module_global_runtime_type(
}
}

#[cfg(test)]
mod runtime_type_tests {
use super::module_global_runtime_type;
use perry_hir::types::Type;
use perry_hir::Expr;

#[test]
fn symbol_constructors_are_module_global_runtime_proofs() {
assert_eq!(
module_global_runtime_type(&Expr::SymbolNew(None), true),
Some(Type::Symbol)
);
assert_eq!(
module_global_runtime_type(
&Expr::SymbolFor(Box::new(Expr::String("shared".to_string()))),
true,
),
Some(Type::Symbol)
);
}

#[test]
fn an_object_initializer_cannot_inherit_a_symbol_annotation_as_proof() {
assert_eq!(
module_global_runtime_type(
&Expr::Object(vec![("x".to_string(), Expr::Number(1.0))]),
true,
),
None
);
}
}

fn module_shadows_shared_array_buffer_intrinsic(
hir: &HirModule,
imported_classes: &[ImportedClass],
Expand Down
52 changes: 52 additions & 0 deletions crates/perry-codegen/src/expr/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,30 @@ use crate::types::{DOUBLE, I1, I32, I64, I8};

use super::{unbox_str_handle, unbox_to_i64, FnCtx};

/// True only when compiler-owned initializer provenance establishes that this
/// expression currently contains a Symbol identity.
///
/// This deliberately does not consult an erased TypeScript `symbol`
/// annotation: `const s: symbol = value as any` is legal source and may hold a
/// moving object at runtime. Fresh `Symbol()` values use system `gc_malloc`
/// storage (reclaimable but non-moving), while `Symbol.for()` values are
/// process-lifetime `Box` allocations. Therefore a proven Symbol can equal
/// another JS value iff their NaN-boxed pointer bits are identical.
fn is_proven_symbol_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool {
match expr {
Expr::SymbolNew(_) | Expr::SymbolFor(_) => true,
Expr::LocalGet(id) => {
matches!(ctx.stable_local_type_proof(id), Some(HirType::Symbol))
|| (!ctx.reassigned_locals.contains(id)
&& matches!(
ctx.module_global_proven_types.get(id),
Some(HirType::Symbol)
))
}
_ => false,
}
}

/// Repsel Phase 3a shared dispatch for the canonical-Str compare arms:
/// lower both operands' bits, branch on "both heap `STRING_TAG`", call
/// `heap_fn(handle, handle)` on the hot arm and `boxed_fn(box, box)` on the
Expand Down Expand Up @@ -676,6 +700,34 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
);
return Ok(blk.bitcast_i64_to_double(&tagged));
}
// Symbol identity is raw pointer identity when at least one
// operand is proven from a Symbol constructor. Unlike arrays,
// Symbols never relocate through grow forwarding, and unlike
// GC-arena objects their system allocation is never evacuated.
// Thus different bits are decisively unequal without entering
// `js_eq`'s tracked-GC forwarding classifier. This is the hot
// sentinel shape `value === MISSING_COMPONENT` in codehz/ecs.
// STRICT only: loose equality still has coercion/throw rules.
let either_proven_symbol =
is_proven_symbol_expr(ctx, left) || is_proven_symbol_expr(ctx, right);
if either_proven_symbol && matches!(op, CompareOp::Eq | CompareOp::Ne) {
let blk = ctx.block();
let l_bits = blk.bitcast_double_to_i64(&l);
let r_bits = blk.bitcast_double_to_i64(&r);
let bit = if matches!(op, CompareOp::Ne) {
blk.icmp_ne(I64, &l_bits, &r_bits)
} else {
blk.icmp_eq(I64, &l_bits, &r_bits)
};
let tagged = blk.select(
I1,
&bit,
I64,
crate::nanbox::TAG_TRUE_I64,
crate::nanbox::TAG_FALSE_I64,
);
return Ok(blk.bitcast_i64_to_double(&tagged));
}
// Boolean equality fast path: NaN-tagged TAG_TRUE/FALSE
// bits don't compare correctly with fcmp. For
// ===/!== where EITHER side is statically boolean, compare
Expand Down
163 changes: 163 additions & 0 deletions crates/perry-codegen/src/expr/compare_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,169 @@ fn cmp_ir(name: &str, op: CompareOp, lhs: Expr, rhs: Expr) -> String {
const JS_EQ_CALL: &str = "call i64 @js_eq(";
const JS_LOOSE_EQ_CALL: &str = "call i64 @js_loose_eq(";

#[test]
fn strict_eq_against_a_proven_symbol_is_raw_identity() {
let ir = ir_for(
"streq_proven_symbol",
vec![
Stmt::Let {
id: X,
name: "value".to_string(),
ty: Type::Any,
mutable: true,
init: Some(Expr::Undefined),
},
Stmt::Let {
id: Y,
name: "sentinel".to_string(),
// The proof must come from the initializer, not this erased
// declaration, so keep the source type deliberately broad.
ty: Type::Any,
mutable: false,
init: Some(Expr::SymbolNew(None)),
},
Stmt::Let {
id: R,
name: "r".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::Compare {
op: CompareOp::Eq,
left: Box::new(Expr::LocalGet(X)),
right: Box::new(Expr::LocalGet(Y)),
}),
},
],
);
assert!(
!ir.contains(JS_EQ_CALL),
"proven Symbol identity fell through to js_eq:\n{ir}"
);
assert!(
ir.contains("icmp eq i64"),
"proven Symbol identity did not become a raw-bit compare:\n{ir}"
);
}

#[test]
fn loose_eq_against_a_proven_symbol_keeps_the_coercing_helper() {
let ir = ir_for(
"looseeq_proven_symbol",
vec![
Stmt::Let {
id: X,
name: "value".to_string(),
ty: Type::Any,
mutable: true,
init: Some(Expr::Undefined),
},
Stmt::Let {
id: Y,
name: "sentinel".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::SymbolNew(None)),
},
Stmt::Let {
id: R,
name: "r".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::Compare {
op: CompareOp::LooseEq,
left: Box::new(Expr::LocalGet(X)),
right: Box::new(Expr::LocalGet(Y)),
}),
},
],
);
assert!(ir.contains(JS_LOOSE_EQ_CALL), "{ir}");
}

#[test]
fn a_symbol_annotation_without_symbol_provenance_keeps_js_eq() {
let ir = ir_for(
"streq_lying_symbol_annotation",
vec![
Stmt::Let {
id: X,
name: "value".to_string(),
ty: Type::Any,
mutable: true,
init: Some(Expr::Undefined),
},
Stmt::Let {
id: Y,
name: "not_really_a_symbol".to_string(),
ty: Type::Symbol,
mutable: false,
init: Some(Expr::Object(vec![("x".to_string(), Expr::Number(1.0))])),
},
Stmt::Let {
id: R,
name: "r".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::Compare {
op: CompareOp::Eq,
left: Box::new(Expr::LocalGet(X)),
right: Box::new(Expr::LocalGet(Y)),
}),
},
],
);
assert!(
ir.contains(JS_EQ_CALL),
"an erased Symbol annotation was mistaken for runtime proof:\n{ir}"
);
}

#[test]
fn a_reassigned_symbol_constructor_local_keeps_js_eq() {
let ir = ir_for(
"streq_reassigned_symbol_local",
vec![
Stmt::Let {
id: X,
name: "value".to_string(),
ty: Type::Any,
mutable: true,
init: Some(Expr::Undefined),
},
Stmt::Let {
id: Y,
name: "sentinel".to_string(),
ty: Type::Any,
mutable: true,
init: Some(Expr::SymbolNew(None)),
},
// Whole-region reassignment analysis must revoke the constructor
// proof even though this write appears before the comparison. The
// replacement may be a moving/forwarded object whose identity
// requires `js_eq`'s forwarding resolution.
Stmt::Expr(Expr::LocalSet(
Y,
Box::new(Expr::Object(vec![("x".to_string(), Expr::Number(1.0))])),
)),
Stmt::Let {
id: R,
name: "r".to_string(),
ty: Type::Any,
mutable: false,
init: Some(Expr::Compare {
op: CompareOp::Eq,
left: Box::new(Expr::LocalGet(X)),
right: Box::new(Expr::LocalGet(Y)),
}),
},
],
);
assert!(
ir.contains(JS_EQ_CALL),
"a reassigned Symbol constructor local retained stale provenance:\n{ir}"
);
}

/// `makeLeft() === makeRight()` has to keep the first call result alive while
/// the second call runs. Object literals give the IR test the same two
/// allocating, pointer-valued temporaries without depending on call lowering:
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/type_analysis/refine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ pub(crate) fn proven_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option<HirT
Expr::String(_) | Expr::WtfString(_) | Expr::I18nString { .. } | Expr::TypeOf(_) => {
Some(HirType::String)
}
// `Symbol()` identities are system-allocated and never relocated;
// `Symbol.for()` identities are process-lifetime `Box` allocations.
// Recording the constructor provenance (rather than trusting a
// `symbol` annotation) lets strict equality use raw identity safely.
Expr::SymbolNew(_) | Expr::SymbolFor(_) => Some(HirType::Symbol),
Expr::Array(_) | Expr::ArraySpread(_) => Some(HirType::Array(Box::new(HirType::Any))),
Expr::MapNew | Expr::MapNewFromArray(_) => Some(HirType::Generic {
base: "Map".to_string(),
Expand Down
8 changes: 8 additions & 0 deletions scripts/local_binding_type_allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@
"classification": "representation-proven",
"reason": "Array.isArray constant-folding uses only runtime-derived initializer evidence and rejects every binding written in the region."
},
{
"path": "crates/perry-codegen/src/expr/compare.rs",
"function": "is_proven_symbol_expr",
"access": "stable_local_type_proof",
"count": 1,
"classification": "representation-proven",
"reason": "Strict Symbol identity lowering accepts only constructor-derived runtime proof; the proof API rejects every binding written in the region, and fresh or registered Symbol storage is non-moving, so raw NaN-boxed pointer equality is the representation contract."
},
{
"path": "crates/perry-codegen/src/expr/array_push.rs",
"function": "guarded_numeric_add_push_candidate",
Expand Down
50 changes: 50 additions & 0 deletions test-files/test_symbol_identity_equality.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Strict equality against a Symbol whose constructor provenance is known can
// use raw identity. Keep dynamic values, module globals, fresh/registered
// Symbols, and annotation lies together so the optimization cannot silently
// widen beyond the representation contract it proved.

const MISSING: any = Symbol("missing");

function isMissing(value: any): boolean {
return value === MISSING;
}

console.log(
isMissing(MISSING),
isMissing(Symbol("missing")),
isMissing({}),
isMissing([]),
isMissing("missing"),
isMissing(undefined),
);

const fresh = Symbol();
console.log(fresh === fresh, fresh !== Symbol(), Symbol() === Symbol());

const registeredA = Symbol.for("perry-symbol-identity-equality");
const registeredB = Symbol.for("perry-symbol-identity-equality");
const registeredOther = Symbol.for("perry-symbol-identity-equality-other");
console.log(
registeredA === registeredB,
registeredA !== registeredOther,
isMissing(registeredA),
);

// TypeScript annotations are erased and therefore are not runtime evidence.
// This must keep generic object identity, including after the array grows and
// an alias may retain the pre-grow forwarding address.
let notReallyASymbol: symbol = [] as any;
const oldAlias: any = notReallyASymbol as any;
for (let i = 0; i < 128; i++) {
(notReallyASymbol as any).push(i);
}
console.log(notReallyASymbol === (oldAlias as any));

// Loose equality has coercion rules and deliberately stays on the runtime
// helper. The wrapper converts to the exact primitive Symbol.
const symbolWrapper = {
[Symbol.toPrimitive](): symbol {
return MISSING;
},
};
console.log((MISSING as any) == (symbolWrapper as any));
Loading