diff --git a/changelog.d/8729-symbol-identity-equality.md b/changelog.d/8729-symbol-identity-equality.md new file mode 100644 index 0000000000..30bdbefd4c --- /dev/null +++ b/changelog.d/8729-symbol-identity-equality.md @@ -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. diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 905945538e..c62b1bc6bf 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -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 @@ -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], diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 50cb97dd32..3435d161a3 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -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 @@ -676,6 +700,34 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); 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 diff --git a/crates/perry-codegen/src/expr/compare_tests.rs b/crates/perry-codegen/src/expr/compare_tests.rs index 453197b951..08afb63337 100644 --- a/crates/perry-codegen/src/expr/compare_tests.rs +++ b/crates/perry-codegen/src/expr/compare_tests.rs @@ -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: diff --git a/crates/perry-codegen/src/type_analysis/refine.rs b/crates/perry-codegen/src/type_analysis/refine.rs index e2000a5541..ced0d38b38 100644 --- a/crates/perry-codegen/src/type_analysis/refine.rs +++ b/crates/perry-codegen/src/type_analysis/refine.rs @@ -233,6 +233,11 @@ pub(crate) fn proven_type_from_init(ctx: &FnCtx<'_>, init: &Expr) -> Option { 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(), diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 9e9c3a4ee2..4eb69517c2 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -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", diff --git a/test-files/test_symbol_identity_equality.ts b/test-files/test_symbol_identity_equality.ts new file mode 100644 index 0000000000..0d2a137972 --- /dev/null +++ b/test-files/test_symbol_identity_equality.ts @@ -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));