diff --git a/changelog.d/6909-repsel-p3a-canonical-str-locals.md b/changelog.d/6909-repsel-p3a-canonical-str-locals.md new file mode 100644 index 0000000000..04d4fbe1ca --- /dev/null +++ b/changelog.d/6909-repsel-p3a-canonical-str-locals.md @@ -0,0 +1,32 @@ +perf(codegen): representation-selection Phase 3a — canonical string locals (tagged-at-rest `Str` rep) (#6909) + +Phase 3a of `docs/representation-selection-rfc.md`: `SlotRep::Str` marks +function locals proven to hold NaN-box string bits (`STRING_TAG` heap or +`SHORT_STRING_TAG` SSO) at rest. Storage, shadow-slot GC binding, and every +alias/refcount demote stay exactly the pre-phase model (zero GC changes; SSO +stays by-value); the rep is a compile-time proof the string-op lowerings +consume to tag-dispatch inline instead of routing operands through +`js_get_string_pointer_unified` (which heap-materializes SSO and +number-coerces): + +- `s += rhs` self-append: both-heap → raw `js_string_append(lhs_h, rhs_h)` + (keeps the refcount==1 in-place path), SSO-dest with string rhs → + `js_string_concat_box`, else the exact legacy sequence (annotation lies + degrade to today's behavior). +- `.length` on statically-string receivers: SSO inline length-byte extract / + heap bare `load i32` of `utf16_len` / `js_value_length_f64` cold arm, + replacing the ~18-op GC-type-byte tower. +- `===`/`<` with a canonical-Str operand: both-heap → direct + `js_string_equals` / `js_string_compare` on raw handles, else one SSO-aware + call (`js_jsvalue_equals` / new `js_string_compare_value`). +- `charCodeAt`/`at`/`codePointAt`: proven-heap receiver → bare and-mask + handle; string-literal operands of coerce-concat unbox inline; `StringRef` + materialization inlines the `or STRING_TAG` retag (null cold arm kept). + +Structural proof on `benchmarks/app-patterns/kernels/string_concat_csv.ts`: +zero `js_get_string_pointer_unified` calls in the emitted module. Gated by +`PERRY_CANONICAL_STR_LOCALS` (default on), keyed into the object cache. Gap +test `test_gap_repsel_canonical_str_locals.ts` covers alias `+=` discipline, +SSO round-trip, lying-annotation acceptance, and non-ASCII/emoji +byte-exactness in all four flag/GC-evacuation arms; `shadow_slot_hygiene.rs` +gains a canonical-Str GC-binding + tag-dispatch structural test. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 7d080fd738..ea5a9d612a 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -754,11 +754,21 @@ pub(super) fn compile_closure( && !is_async && !cross_module.async_step_closures.contains(&func_id) && !cross_module.local_generator_funcs.contains(&func_id); - let repsel_closure_refs = if repsel_allows { + // Phase 3a: same context restrictions, independent env gate. + let repsel_str_allows = crate::expr::canonical_str_locals_enabled() + && !is_async + && !cross_module.async_step_closures.contains(&func_id) + && !cross_module.local_generator_funcs.contains(&func_id); + let repsel_closure_refs = if repsel_allows || repsel_str_allows { crate::expr::collect_closure_referenced_locals(body) } else { std::collections::HashSet::new() }; + let repsel_str_ineligible = if repsel_str_allows { + crate::expr::collect_canonical_str_ineligible_locals(body) + } else { + std::collections::HashSet::new() + }; let mut ctx = FnCtx { func: lf, @@ -869,6 +879,8 @@ pub(super) fn compile_closure( local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + repsel_context_allows_canonical_str: repsel_str_allows, + repsel_str_ineligible_locals: repsel_str_ineligible, spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 68a41ed565..0314d369ab 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -769,6 +769,8 @@ pub(super) fn compile_module_entry( // import/init machinery; the win lives in function bodies). repsel_context_allows_canonical_i32: false, repsel_closure_ref_locals: std::collections::HashSet::new(), + repsel_context_allows_canonical_str: false, + repsel_str_ineligible_locals: std::collections::HashSet::new(), spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), @@ -1384,6 +1386,8 @@ pub(super) fn compile_module_entry( // import/init machinery; the win lives in function bodies). repsel_context_allows_canonical_i32: false, repsel_closure_ref_locals: std::collections::HashSet::new(), + repsel_context_allows_canonical_str: false, + repsel_str_ineligible_locals: std::collections::HashSet::new(), spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 7f26971ebc..5476012a39 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -613,11 +613,21 @@ pub(super) fn compile_function( && !f.is_async && !f.is_generator && !f.was_plain_async; - let repsel_closure_refs = if repsel_allows { + // Phase 3a: same context restrictions, independent env gate. + let repsel_str_allows = crate::expr::canonical_str_locals_enabled() + && !f.is_async + && !f.is_generator + && !f.was_plain_async; + let repsel_closure_refs = if repsel_allows || repsel_str_allows { crate::expr::collect_closure_referenced_locals(&f.body) } else { std::collections::HashSet::new() }; + let repsel_str_ineligible = if repsel_str_allows { + crate::expr::collect_canonical_str_ineligible_locals(&f.body) + } else { + std::collections::HashSet::new() + }; let mut ctx = FnCtx { func: lf, @@ -724,6 +734,8 @@ pub(super) fn compile_function( i32_counter_slots: spec_i32_param_slots, repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + repsel_context_allows_canonical_str: repsel_str_allows, + repsel_str_ineligible_locals: repsel_str_ineligible, spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 501a0ed485..bf6cc77747 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -381,11 +381,21 @@ pub(super) fn compile_method( && !method.is_async && !method.is_generator && !method.was_plain_async; - let repsel_closure_refs = if repsel_allows { + // Phase 3a: same context restrictions, independent env gate. + let repsel_str_allows = crate::expr::canonical_str_locals_enabled() + && !method.is_async + && !method.is_generator + && !method.was_plain_async; + let repsel_closure_refs = if repsel_allows || repsel_str_allows { crate::expr::collect_closure_referenced_locals(&method.body) } else { std::collections::HashSet::new() }; + let repsel_str_ineligible = if repsel_str_allows { + crate::expr::collect_canonical_str_ineligible_locals(&method.body) + } else { + std::collections::HashSet::new() + }; let mut ctx = FnCtx { func: lf, @@ -492,6 +502,8 @@ pub(super) fn compile_method( local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + repsel_context_allows_canonical_str: repsel_str_allows, + repsel_str_ineligible_locals: repsel_str_ineligible, spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), @@ -1400,11 +1412,21 @@ pub(super) fn compile_static_method( && !f.is_async && !f.is_generator && !f.was_plain_async; - let repsel_closure_refs = if repsel_allows { + // Phase 3a: same context restrictions, independent env gate. + let repsel_str_allows = crate::expr::canonical_str_locals_enabled() + && !f.is_async + && !f.is_generator + && !f.was_plain_async; + let repsel_closure_refs = if repsel_allows || repsel_str_allows { crate::expr::collect_closure_referenced_locals(&f.body) } else { std::collections::HashSet::new() }; + let repsel_str_ineligible = if repsel_str_allows { + crate::expr::collect_canonical_str_ineligible_locals(&f.body) + } else { + std::collections::HashSet::new() + }; let mut ctx = FnCtx { func: lf, @@ -1515,6 +1537,8 @@ pub(super) fn compile_static_method( local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, repsel_closure_ref_locals: repsel_closure_refs, + repsel_context_allows_canonical_str: repsel_str_allows, + repsel_str_ineligible_locals: repsel_str_ineligible, spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 8c7a83842d..42cfa2be66 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -8,6 +8,7 @@ use anyhow::Result; use perry_hir::types::Type as HirType; use perry_hir::{CompareOp, Expr}; +use crate::nanbox::POINTER_MASK_I64; use crate::type_analysis::{ expr_may_return_boxed_value_from_raw_f64_fallback, is_bigint_expr, is_bool_expr, is_numeric_expr, is_string_expr, @@ -16,6 +17,59 @@ use crate::types::{DOUBLE, I32, I64}; use super::{lower_expr, unbox_str_handle, unbox_to_i64, FnCtx}; +/// 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 +/// mixed/SSO/lie arm, and phi the i32 result. The caller applies its own +/// predicate tail (`!= 0` select for equality, signed compare for +/// relational). +fn canonical_str_cmp_dispatch( + ctx: &mut FnCtx<'_>, + l: &str, + r: &str, + heap_fn: &str, + boxed_fn: &str, + prefix: &str, +) -> String { + let l_bits = ctx.block().bitcast_double_to_i64(l); + let r_bits = ctx.block().bitcast_double_to_i64(r); + let l_tag = ctx.block().lshr(I64, &l_bits, "48"); + let r_tag = ctx.block().lshr(I64, &r_bits, "48"); + let l_heap = ctx + .block() + .icmp_eq(I64, &l_tag, crate::nanbox::STRING_TAG_TOP16_I64); + let r_heap = ctx + .block() + .icmp_eq(I64, &r_tag, crate::nanbox::STRING_TAG_TOP16_I64); + let both_heap = ctx.block().and(crate::types::I1, &l_heap, &r_heap); + + let heap_idx = ctx.new_block(&format!("{prefix}.heap")); + let boxed_idx = ctx.new_block(&format!("{prefix}.boxed")); + let merge_idx = ctx.new_block(&format!("{prefix}.merge")); + let heap_label = ctx.block_label(heap_idx); + let boxed_label = ctx.block_label(boxed_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&both_heap, &heap_label, &boxed_label); + + ctx.current_block = heap_idx; + let l_handle = ctx.block().and(I64, &l_bits, POINTER_MASK_I64); + let r_handle = ctx.block().and(I64, &r_bits, POINTER_MASK_I64); + let res_heap = ctx + .block() + .call(I32, heap_fn, &[(I64, &l_handle), (I64, &r_handle)]); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = boxed_idx; + let res_boxed = ctx.block().call(I32, boxed_fn, &[(DOUBLE, l), (DOUBLE, r)]); + let boxed_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(I32, &[(&res_heap, &heap_pred), (&res_boxed, &boxed_pred)]) +} + pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { match expr { Expr::Compare { op, left, right } => { @@ -292,6 +346,52 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // unordered → always false). When both operands are // statically strings, dispatch through js_string_equals. let both_strings = is_string_expr(ctx, left) && is_string_expr(ctx, right); + // Representation-selection Phase 3a: when a canonical-Str local + // is an operand, tag-dispatch inline instead of paying the two + // opaque (SSO-heap-materializing) unified unbox calls: both + // proven heap → direct `js_string_equals(h, h)` on the raw + // handles; any other mix → one `js_jsvalue_equals` call, which + // content-compares heap × SSO without materializing and never + // number-coerces (a lying annotation gets exact `===` + // semantics, strictly closer to spec than the legacy path). + let canonical_str_involved = matches!( + left.as_ref(), Expr::LocalGet(id) if crate::expr::local_is_canonical_str(ctx, *id) + ) || matches!( + right.as_ref(), Expr::LocalGet(id) if crate::expr::local_is_canonical_str(ctx, *id) + ); + if both_strings + && canonical_str_involved + && matches!( + op, + CompareOp::Eq | CompareOp::LooseEq | CompareOp::Ne | CompareOp::LooseNe + ) + { + let l = lower_expr(ctx, left)?; + let r = lower_expr(ctx, right)?; + let i32_eq = canonical_str_cmp_dispatch( + ctx, + &l, + &r, + "js_string_equals", + "js_jsvalue_equals", + "streq", + ); + let blk = ctx.block(); + let bit = blk.icmp_ne(I32, &i32_eq, "0"); + let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { + blk.xor(crate::types::I1, &bit, "true") + } else { + bit + }; + let tagged_i64 = blk.select( + crate::types::I1, + &bit_final, + crate::types::I64, + crate::nanbox::TAG_TRUE_I64, + crate::nanbox::TAG_FALSE_I64, + ); + return Ok(blk.bitcast_i64_to_double(&tagged_i64)); + } if both_strings && matches!( op, @@ -332,6 +432,46 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // so dispatch through js_string_compare which returns // -1/0/1 like memcmp. Then test the result against 0 with // the right icmp predicate. + // Representation-selection Phase 3a: relational counterpart of + // the canonical-Str equality arm above — both proven heap → + // direct `js_string_compare(h, h)`; any other mix → one + // `js_string_compare_value` call (SSO-aware, no heap + // materialization, numbers coerced via their decimal string + // form exactly like the legacy unified path). + if both_strings + && canonical_str_involved + && matches!( + op, + CompareOp::Lt | CompareOp::Le | CompareOp::Gt | CompareOp::Ge + ) + { + let l = lower_expr(ctx, left)?; + let r = lower_expr(ctx, right)?; + let cmp_i32 = canonical_str_cmp_dispatch( + ctx, + &l, + &r, + "js_string_compare", + "js_string_compare_value", + "strcmp", + ); + let blk = ctx.block(); + let bit = match op { + CompareOp::Lt => blk.icmp_slt(I32, &cmp_i32, "0"), + CompareOp::Le => blk.icmp_sle(I32, &cmp_i32, "0"), + CompareOp::Gt => blk.icmp_sgt(I32, &cmp_i32, "0"), + CompareOp::Ge => blk.icmp_sge(I32, &cmp_i32, "0"), + _ => unreachable!(), + }; + let tagged_i64 = blk.select( + crate::types::I1, + &bit, + crate::types::I64, + crate::nanbox::TAG_TRUE_I64, + crate::nanbox::TAG_FALSE_I64, + ); + return Ok(blk.bitcast_i64_to_double(&tagged_i64)); + } if both_strings && matches!( op, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 79e72cf653..e73d48af9c 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -133,9 +133,10 @@ mod record_value; mod shadow_slot; mod slot_rep; pub(crate) use slot_rep::{ - canonical_i32_locals_enabled, canonical_local_i32_slot, collect_closure_referenced_locals, - load_canonical_local_boxed, note_canonical_i32_local, store_canonical_local_from_double, - SlotRep, + canonical_i32_locals_enabled, canonical_local_i32_slot, canonical_str_locals_enabled, + collect_canonical_str_ineligible_locals, collect_closure_referenced_locals, + load_canonical_local_boxed, local_is_canonical_str, local_rep_is_canonical_i32, + note_canonical_local, store_canonical_local_from_double, SlotRep, }; pub(crate) use dispatch::{lower_expr, lower_math_operand}; @@ -774,6 +775,21 @@ pub(crate) struct FnCtx<'a> { /// `repsel_context_allows_canonical_i32` is false. pub repsel_closure_ref_locals: std::collections::HashSet, + /// Representation-selection Phase 3a: whether this function context + /// permits canonical-Str selection. Mirrors + /// `repsel_context_allows_canonical_i32` (sync bodies only, no module + /// init) but gated on `PERRY_CANONICAL_STR_LOCALS` instead, so the two + /// phases can be A/B-tested independently. + pub repsel_context_allows_canonical_str: bool, + + /// Phase 3a eligibility pre-pass result + /// (`collect_canonical_str_ineligible_locals`): locals with a + /// non-string-proven reassignment, an equality compare against a + /// non-proven-string operand (the `other_side_is_any` hazard), or a + /// catch binding. Never selected canonical-Str. Empty when + /// `repsel_context_allows_canonical_str` is false. + pub repsel_str_ineligible_locals: std::collections::HashSet, + /// Representation-selection Phase 2 (`codegen/spec_abi.rs`): FuncId → /// specialization plan for functions that have an emitted specialized /// entry in this module. Direct `FuncRef` call sites consult this to diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 4413fb24c6..720fa1b01e 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -271,6 +271,80 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(ctx.block().load(DOUBLE, &slot)); } } + // Representation-selection Phase 3a: `.length` on a statically- + // string receiver (canonical-Str local, `string[]` element, + // string-returning expression). The receiver bits are freshly + // produced with no safepoint before the header read (no + // forwarding hazard — evacuation rewrites slots/returns before + // the mutator resumes), so the ~18-op generic tower below + // (GC-type byte, forwarding flag, handle-band checks) collapses + // to a 3-arm tag dispatch: SSO → inline length-byte extract + // (`lshr 40; and 0xFF`, matching `js_value_length_f64`'s SSO + // branch), heap STRING_TAG → `load i32` of `utf16_len` at + // offset 0, anything else (annotation lie, nullable-union + // receiver) → the same `js_value_length_f64` slow call the + // generic tower's slow arm uses. + { + if crate::expr::canonical_str_locals_enabled() + && is_string_expr(ctx, object) + && !is_array_expr(ctx, object) + { + let recv_box = lower_expr(ctx, object)?; + let bits = ctx.block().bitcast_double_to_i64(&recv_box); + let tag = ctx.block().lshr(I64, &bits, "48"); + let is_sso = + ctx.block() + .icmp_eq(I64, &tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); + let sso_idx = ctx.new_block("strlen.sso"); + let chk_idx = ctx.new_block("strlen.chk"); + let heap_idx = ctx.new_block("strlen.heap"); + let slow_idx = ctx.new_block("strlen.slow"); + let merge_idx = ctx.new_block("strlen.merge"); + let sso_label = ctx.block_label(sso_idx); + let chk_label = ctx.block_label(chk_idx); + let heap_label = ctx.block_label(heap_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_sso, &sso_label, &chk_label); + + ctx.current_block = sso_idx; + let len_shifted = ctx.block().lshr(I64, &bits, "40"); + let len_byte = ctx.block().and(I64, &len_shifted, "255"); + let sso_len = ctx.block().uitofp(I64, &len_byte, DOUBLE); + let sso_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = chk_idx; + let is_heap = + ctx.block() + .icmp_eq(I64, &tag, crate::nanbox::STRING_TAG_TOP16_I64); + ctx.block().cond_br(&is_heap, &heap_label, &slow_label); + + ctx.current_block = heap_idx; + let handle = ctx.block().and(I64, &bits, POINTER_MASK_I64); + let len_i32 = ctx.block().safe_load_i32_from_ptr(&handle); + let heap_len = ctx.block().uitofp(I32, &len_i32, DOUBLE); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = slow_idx; + let slow_len = + ctx.block() + .call(DOUBLE, "js_value_length_f64", &[(DOUBLE, &recv_box)]); + let slow_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + return Ok(ctx.block().phi( + DOUBLE, + &[ + (&sso_len, &sso_pred), + (&heap_len, &heap_pred), + (&slow_len, &slow_pred), + ], + )); + } + } // Issue #73: validate the receiver before the inline load. // The compile-time condition above fires for Array / String / // Named / Tuple, but TypeScript type erasure (a `Named`-typed diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 1ed8e31e66..02229ccbdc 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -86,6 +86,24 @@ pub(crate) enum SlotRep { /// materialize with `uitofp` so values above `INT32_MAX` stay observable /// as unsigned numbers. U32, + /// Representation-selection Phase 3a: canonical string local, + /// TAGGED-AT-REST. The slot (still the `ctx.locals` double alloca — the + /// bits ARE the boxed form) always holds full NaN-box string bits: + /// `STRING_TAG|ptr` for heap strings or inline `SHORT_STRING_TAG` SSO + /// bits. Unlike `I32`/`U32` this rep does NOT move storage: boxed reads + /// and writes are bit-identical to the pre-phase model, shadow-slot GC + /// binding is unchanged (the mark/evacuation path is NaN-box-driven and + /// already handles these bits), and every alias/refcount demote site + /// keeps firing. What the rep buys is a compile-time PROOF consumed by + /// the string-op lowerings (`+=` self-append, `.length`, `===`/`<`, + /// `charCodeAt`-family): they tag-dispatch inline on the slot bits and + /// call the raw string helpers directly on proven-heap handles instead + /// of routing every operand through the opaque (and SSO-heap- + /// materializing, number-coercing) `js_get_string_pointer_unified`. + /// Every specialized site keeps a fallback arm with the legacy sequence, + /// so a type-annotation lie degrades to today's behavior, never to a + /// wrong-value coercion (RFC §5.5 acceptance contract). + Str, } /// `PERRY_CANONICAL_I32_LOCALS` gate. Enabled by default; `=0`/`off`/`false` @@ -103,15 +121,32 @@ pub(crate) fn canonical_i32_locals_enabled() -> bool { }) } +/// `PERRY_CANONICAL_STR_LOCALS` gate (repsel Phase 3a). Enabled by default; +/// `=0`/`off`/`false` disables canonical-Str selection and every lowering it +/// gates (the `+=`/`length`/compare/char-scan fast arms and the inline +/// `StringRef` retag), reverting to the pre-phase IR byte-for-byte. Keyed +/// into the object cache (`object_cache.rs`). +pub(crate) fn canonical_str_locals_enabled() -> bool { + use std::sync::OnceLock; + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + !matches!( + std::env::var("PERRY_CANONICAL_STR_LOCALS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) + }) +} + fn repsel_debug_enabled() -> bool { use std::sync::OnceLock; static CACHED: OnceLock = OnceLock::new(); *CACHED.get_or_init(|| std::env::var("PERRY_REPSEL_DEBUG").as_deref() == Ok("1")) } -/// Compile-time visibility: one stderr line per local that went canonical-i32, +/// Compile-time visibility: one stderr line per local that selected a +/// canonical representation (i32/u32/Str), /// plus a process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. -pub(crate) fn note_canonical_i32_local(ctx: &FnCtx<'_>, id: u32, name: &str, rep: SlotRep) { +pub(crate) fn note_canonical_local(ctx: &FnCtx<'_>, id: u32, name: &str, rep: SlotRep) { if !repsel_debug_enabled() { return; } @@ -132,6 +167,14 @@ pub(crate) fn note_canonical_i32_local(ctx: &FnCtx<'_>, id: u32, name: &str, rep pub(crate) fn canonical_local_i32_slot(ctx: &FnCtx<'_>, id: u32) -> Option<(String, SlotRep)> { let rep = *ctx.local_slot_reps.get(&id)?; debug_assert!(!matches!(rep, SlotRep::Boxed), "Boxed rep is never stored"); + // Phase 3a: a canonical-Str local has no i32 slot — its storage is the + // ordinary `ctx.locals` double alloca (tagged-at-rest). Every i32-rep + // query site (LocalGet materialize, LocalSet store, Update, loops, + // capture write-back) must treat it as "not canonical-i32" and fall + // through to the plain boxed path, which is bit-exact for Str. + if matches!(rep, SlotRep::Str) { + return None; + } let slot = ctx .i32_counter_slots .get(&id) @@ -140,6 +183,24 @@ pub(crate) fn canonical_local_i32_slot(ctx: &FnCtx<'_>, id: u32) -> Option<(Stri Some((slot, rep)) } +/// True when `id` selected the Phase 3a canonical-Str representation: the +/// local's `ctx.locals` slot provably holds NaN-box STRING bits (heap +/// `STRING_TAG` or inline SSO) on every proven-string write, and the +/// string-op lowerings may tag-dispatch on those bits directly. +pub(crate) fn local_is_canonical_str(ctx: &FnCtx<'_>, id: u32) -> bool { + matches!(ctx.local_slot_reps.get(&id), Some(SlotRep::Str)) +} + +/// True when `id` selected canonical-i32/u32 storage (NOT Str — Str keeps +/// the plain double slot). Use instead of `local_slot_reps.contains_key` +/// wherever the follow-up action assumes an i32 slot exists. +pub(crate) fn local_rep_is_canonical_i32(ctx: &FnCtx<'_>, id: u32) -> bool { + matches!( + ctx.local_slot_reps.get(&id), + Some(SlotRep::I32 | SlotRep::U32) + ) +} + /// Materialize the boxed-double view of a canonical-i32 local at a boxed use /// site: one `sitofp` (`uitofp` for `U32`). Returns `None` for Boxed locals. pub(crate) fn load_canonical_local_boxed(ctx: &mut FnCtx<'_>, id: u32) -> Option { @@ -186,6 +247,275 @@ pub(crate) fn store_canonical_local_from_double( /// on the boxed protocol: closure capture creation snapshots the double slot, /// and the capture/writeback machinery assumes it exists. Under-approximating /// eligibility here is free. +/// Phase 3a eligibility pre-pass: locals that must NOT select the +/// canonical-Str representation, computed once per function body before +/// lowering (ctx-free — runs at `FnCtx` build time, so it works from +/// declared `Stmt::Let` types + syntax only and under-approximates freely). +/// +/// A local is marked ineligible when any of these hold: +/// +/// - **Non-string-proven write**: some `LocalSet(id, v)` where `v` is not +/// syntactically a definite string (mirrors +/// `type_analysis::strings::is_definitely_string_expr`, minus the +/// ctx-dependent arms), or any `Update` (++/--) on it. +/// - **Compare hazard** (mirrors `compare.rs`'s `other_side_is_any` +/// demote): the local appears on one side of an equality compare whose +/// other side is not itself a proven string — the static `string` type +/// may be a lie there (the NestJS `token === name` shape), so the local +/// keeps the fully generic model. +/// - **Catch binding**: `catch (e)` bindings rebind exceptional values of +/// unknown representation. +/// +/// Closure bodies are NOT walked: closure-referenced locals are excluded +/// wholesale via `collect_closure_referenced_locals` (same as Phase 1), +/// which sees explicit capture lists too. +pub(crate) fn collect_canonical_str_ineligible_locals(stmts: &[perry_hir::Stmt]) -> HashSet { + use perry_hir::{Expr, Stmt}; + + // Forward pass: ids declared `let x: string` / `let x = "lit"` — the + // set syntactic string-ness of `LocalGet` operands is judged against. + let mut declared_str: HashSet = HashSet::new(); + fn scan_declared(stmts: &[Stmt], out: &mut HashSet) { + for stmt in stmts { + match stmt { + Stmt::Let { id, ty, init, .. } => { + let ty_str = matches!( + ty, + perry_hir::types::Type::String | perry_hir::types::Type::StringLiteral(_) + ); + let init_str = init + .as_ref() + .is_some_and(|e| matches!(e, Expr::String(_) | Expr::WtfString(_))); + if ty_str || init_str { + out.insert(*id); + } + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + scan_declared(then_branch, out); + if let Some(e) = else_branch { + scan_declared(e, out); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => scan_declared(body, out), + Stmt::For { init, body, .. } => { + if let Some(i) = init { + scan_declared(std::slice::from_ref(i), out); + } + scan_declared(body, out); + } + Stmt::Labeled { body, .. } => scan_declared(std::slice::from_ref(body), out), + Stmt::Try { + body, + catch, + finally, + } => { + scan_declared(body, out); + if let Some(c) = catch { + scan_declared(&c.body, out); + } + if let Some(f) = finally { + scan_declared(f, out); + } + } + Stmt::Switch { cases, .. } => { + for c in cases { + scan_declared(&c.body, out); + } + } + Stmt::Expr(_) + | Stmt::Return(_) + | Stmt::Throw(_) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } + } + } + scan_declared(stmts, &mut declared_str); + + // Ctx-free mirror of `is_definitely_string_expr` for the write / compare + // scans. Method calls whose NAME also exists on Array/Object (`slice`, + // `concat`, `replace`, …) additionally require a syntactically-string + // RECEIVER — name-only matching would classify `arr.slice()` as a + // string write and skip the exclusion. Only the number-formatting / + // universal-ToString family (`toString`/`toFixed`/`toPrecision`/ + // `toExponential`) stays name-only, mirroring + // `is_definitely_string_expr`. A misclassification here is a missed + // exclusion, not a correctness break (every specialized lowering + // re-checks the runtime tag and falls back) — but keeping the scan + // honest keeps ineligible locals off the canonical rep. + fn syntactic_str(e: &Expr, declared: &HashSet) -> bool { + match e { + Expr::String(_) | Expr::WtfString(_) | Expr::StringCoerce(_) | Expr::TypeOf(_) => true, + Expr::LocalGet(id) => declared.contains(id), + Expr::Binary { + op: perry_hir::BinaryOp::Add, + left, + right, + } => syntactic_str(left, declared) || syntactic_str(right, declared), + Expr::Conditional { + then_expr, + else_expr, + .. + } => syntactic_str(then_expr, declared) && syntactic_str(else_expr, declared), + Expr::Call { callee, .. } => match callee.as_ref() { + Expr::PropertyGet { + object, property, .. + } => match property.as_str() { + "toString" | "toFixed" | "toPrecision" | "toExponential" => true, + "toLowerCase" | "toUpperCase" | "trim" | "trimStart" | "trimEnd" | "slice" + | "substring" | "substr" | "charAt" | "repeat" | "replace" | "replaceAll" + | "padStart" | "padEnd" | "concat" | "normalize" => { + syntactic_str(object, declared) + } + _ => false, + }, + _ => false, + }, + _ => false, + } + } + + struct Scan<'a> { + declared: &'a HashSet, + out: HashSet, + } + impl Scan<'_> { + fn expr(&mut self, e: &Expr) { + match e { + Expr::LocalSet(id, v) => { + if !syntactic_str(v, self.declared) { + self.out.insert(*id); + } + } + Expr::Update { id, .. } => { + self.out.insert(*id); + } + Expr::Compare { + op: + perry_hir::CompareOp::Eq + | perry_hir::CompareOp::Ne + | perry_hir::CompareOp::LooseEq + | perry_hir::CompareOp::LooseNe, + left, + right, + } => { + if let Expr::LocalGet(id) = left.as_ref() { + if !syntactic_str(right, self.declared) { + self.out.insert(*id); + } + } + if let Expr::LocalGet(id) = right.as_ref() { + if !syntactic_str(left, self.declared) { + self.out.insert(*id); + } + } + } + _ => {} + } + // Do not descend into closure bodies: closure-referenced locals + // are excluded wholesale by `collect_closure_referenced_locals`. + if !matches!(e, Expr::Closure { .. }) { + perry_hir::walker::walk_expr_children(e, &mut |child| self.expr(child)); + } + } + fn stmts(&mut self, stmts: &[Stmt]) { + for s in stmts { + self.stmt(s); + } + } + fn stmt(&mut self, s: &Stmt) { + match s { + Stmt::Expr(e) | Stmt::Throw(e) => self.expr(e), + Stmt::Return(Some(e)) => self.expr(e), + Stmt::Let { init: Some(e), .. } => self.expr(e), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + self.expr(condition); + self.stmts(then_branch); + if let Some(eb) = else_branch { + self.stmts(eb); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + self.expr(condition); + self.stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + self.stmt(i); + } + if let Some(c) = condition { + self.expr(c); + } + if let Some(u) = update { + self.expr(u); + } + self.stmts(body); + } + Stmt::Labeled { body, .. } => self.stmt(body), + Stmt::Try { + body, + catch, + finally, + } => { + self.stmts(body); + if let Some(c) = catch { + if let Some((catch_id, _)) = &c.param { + self.out.insert(*catch_id); + } + self.stmts(&c.body); + } + if let Some(f) = finally { + self.stmts(f); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + self.expr(discriminant); + for c in cases { + if let Some(t) = &c.test { + self.expr(t); + } + self.stmts(&c.body); + } + } + Stmt::Return(None) + | Stmt::Let { init: None, .. } + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } + } + } + let mut scan = Scan { + declared: &declared_str, + out: HashSet::new(), + }; + scan.stmts(stmts); + scan.out +} + pub(crate) fn collect_closure_referenced_locals(stmts: &[perry_hir::Stmt]) -> HashSet { let mut closures: Vec<(perry_hir::types::FuncId, perry_hir::Expr)> = Vec::new(); let mut seen: HashSet = HashSet::new(); diff --git a/crates/perry-codegen/src/lower_call/capture_writeback.rs b/crates/perry-codegen/src/lower_call/capture_writeback.rs index 111e914d50..98f0f46eb9 100644 --- a/crates/perry-codegen/src/lower_call/capture_writeback.rs +++ b/crates/perry-codegen/src/lower_call/capture_writeback.rs @@ -82,7 +82,10 @@ pub(crate) fn emit_class_capture_writeback( // Repsel Phase 1: a canonical-i32 local is in scope but has no // `ctx.locals` entry — its write-back goes through the i32 slot below. let outer_slot = ctx.locals.get(&outer_id).cloned(); - let outer_is_canonical_i32 = ctx.local_slot_reps.contains_key(&outer_id); + // Phase 3a: a canonical-Str local (`SlotRep::Str`) HAS a `ctx.locals` + // slot and takes the plain double-store write-back below — only + // i32/u32 reps route through the i32 slot. + let outer_is_canonical_i32 = crate::expr::local_rep_is_canonical_i32(ctx, outer_id); if outer_slot.is_none() && !outer_is_canonical_i32 { continue; } diff --git a/crates/perry-codegen/src/lower_string_method.rs b/crates/perry-codegen/src/lower_string_method.rs index 756b4dcf94..1ca05d4f3f 100644 --- a/crates/perry-codegen/src/lower_string_method.rs +++ b/crates/perry-codegen/src/lower_string_method.rs @@ -637,8 +637,8 @@ pub(crate) fn lower_string_method( for extra in args.iter().skip(1) { let _ = lower_expr(ctx, extra)?; } + let recv_handle = str_operand_handle_tag_dispatched(ctx, object, &recv_box); let blk = ctx.block(); - let recv_handle = unbox_str_handle(blk, &recv_box); let idx_i32 = blk.call(I32, "js_string_index_to_i32", &[(DOUBLE, &idx_d)]); // js_string_at returns a NaN-boxed string or undefined directly. Ok(blk.call( @@ -658,8 +658,8 @@ pub(crate) fn lower_string_method( for extra in args.iter().skip(1) { let _ = lower_expr(ctx, extra)?; } + let recv_handle = str_operand_handle_tag_dispatched(ctx, object, &recv_box); let blk = ctx.block(); - let recv_handle = unbox_str_handle(blk, &recv_box); let idx_i32 = blk.call(I32, "js_string_index_to_i32", &[(DOUBLE, &idx_d)]); // Returns NaN-boxed number or undefined directly. Ok(blk.call( @@ -679,8 +679,8 @@ pub(crate) fn lower_string_method( for extra in args.iter().skip(1) { let _ = lower_expr(ctx, extra)?; } + let recv_handle = str_operand_handle_tag_dispatched(ctx, object, &recv_box); let blk = ctx.block(); - let recv_handle = unbox_str_handle(blk, &recv_box); let idx_i32 = blk.call(I32, "js_string_index_to_i32", &[(DOUBLE, &idx_d)]); // js_string_char_code_at returns a plain f64 (NaN for OOB). Ok(blk.call( @@ -1257,6 +1257,13 @@ pub(crate) fn lower_string_self_append( .ok_or_else(|| anyhow!("string self-append: local {} not in scope", local_id))? .clone(); + // Representation-selection Phase 3a: canonical-Str destination — + // tag-dispatch on the slot bits inline instead of paying the two opaque + // `js_get_string_pointer_unified` calls per iteration. + if crate::expr::local_is_canonical_str(ctx, local_id) { + return lower_canonical_str_self_append(ctx, local_id, rhs, &slot); + } + // Lower the RHS first (might be a string literal, a local, or a // computed expression). For non-string RHS we'd need to coerce, but // the bench_string_ops case always uses a string literal, so for the @@ -1298,6 +1305,270 @@ pub(crate) fn lower_string_self_append( Ok(new_box) } +/// Repsel Phase 3a: is this expression PROVEN to lower to a heap-tagged +/// (`STRING_TAG`) NaN-box — never SSO bits, never a non-string? String +/// literals load the interned pool handle (`@.str.N.handle`, always a heap +/// `StringHeader` from `js_string_from_bytes`); `String(x)` routes through +/// `js_string_coerce`, which always allocates a heap header. Deliberately +/// NOT included: `Binary Add` string results — the pairwise concat lowering +/// returns `js_string_concat_box`, which assembles ≤5-byte ASCII results as +/// SSO bits. +fn proven_heap_string_operand(_ctx: &FnCtx<'_>, e: &Expr) -> bool { + match e { + Expr::String(_) | Expr::WtfString(_) | Expr::StringCoerce(_) => true, + Expr::Conditional { + then_expr, + else_expr, + .. + } => { + proven_heap_string_operand(_ctx, then_expr) + && proven_heap_string_operand(_ctx, else_expr) + } + _ => false, + } +} + +/// Repsel Phase 3a: operand → raw `StringHeader*` handle for the string +/// helpers, tag-dispatched: +/// +/// - proven heap-tagged operand (see `proven_heap_string_operand`) → inline +/// `bitcast; and POINTER_MASK` — zero calls; +/// - canonical-Str `LocalGet` → 2-arm dispatch: heap `STRING_TAG` bits → +/// bare `and POINTER_MASK` (hot arm, no call); anything else (SSO bits, +/// annotation lie) → the legacy `js_get_string_pointer_unified` (which +/// materializes SSO — cold); +/// - everything else (or flag off) → the legacy unified call, unchanged. +fn str_operand_handle_tag_dispatched(ctx: &mut FnCtx<'_>, object: &Expr, recv_box: &str) -> String { + use crate::nanbox::POINTER_MASK_I64; + if !crate::expr::canonical_str_locals_enabled() { + return unbox_str_handle(ctx.block(), recv_box); + } + if proven_heap_string_operand(ctx, object) { + let bits = ctx.block().bitcast_double_to_i64(recv_box); + return ctx.block().and(I64, &bits, POINTER_MASK_I64); + } + let canonical = matches!( + object, Expr::LocalGet(id) if crate::expr::local_is_canonical_str(ctx, *id) + ); + if !canonical { + return unbox_str_handle(ctx.block(), recv_box); + } + let bits = ctx.block().bitcast_double_to_i64(recv_box); + let tag = ctx.block().lshr(I64, &bits, "48"); + let is_heap = ctx + .block() + .icmp_eq(I64, &tag, crate::nanbox::STRING_TAG_TOP16_I64); + + let heap_idx = ctx.new_block("strrecv.heap"); + let cold_idx = ctx.new_block("strrecv.cold"); + let merge_idx = ctx.new_block("strrecv.merge"); + let heap_label = ctx.block_label(heap_idx); + let cold_label = ctx.block_label(cold_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_heap, &heap_label, &cold_label); + + ctx.current_block = heap_idx; + let h_heap = ctx.block().and(I64, &bits, POINTER_MASK_I64); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = cold_idx; + let h_cold = unbox_str_handle(ctx.block(), recv_box); + let cold_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(I64, &[(&h_heap, &heap_pred), (&h_cold, &cold_pred)]) +} + +/// Representation-selection Phase 3a: `s += rhs` for a canonical-Str +/// destination (`SlotRep::Str` — the `ctx.locals` slot provably holds +/// NaN-box string bits at rest). Replaces the two opaque +/// `js_get_string_pointer_unified` calls per iteration with an inline tag +/// dispatch on the slot bits: +/// +/// - **both heap** (`STRING_TAG` on both sides): `and POINTER_MASK` → +/// `js_string_append(h, h)` → `or STRING_TAG` — the hot accumulator-loop +/// arm; keeps the refcount==1 in-place append (every alias demote site is +/// untouched by this phase, so `let b = a` still demotes first). +/// - **both strings, SSO involved**: `js_string_concat_box(box, box)` — +/// SSO-aware pairwise concat, assembles ≤5-byte ASCII results inline and +/// never mutates in place. No per-op heap materialization of SSO bits +/// (RFC §4 "short-string values stay by-value"). +/// - **anything else** (a lying `string` annotation): the exact pre-phase +/// sequence — `js_get_string_pointer_unified` ×2 (SSO materialize + +/// number coercion included) → `js_string_append` — so acceptance +/// behavior is bit-identical to today's on non-string bits (RFC §5.5: +/// mismatches route to the legacy path, never a new coercion). +fn lower_canonical_str_self_append( + ctx: &mut FnCtx<'_>, + _local_id: u32, + rhs: &Expr, + slot: &str, +) -> Result { + use crate::nanbox::{ + POINTER_MASK_I64, SHORT_STRING_TAG_TOP16_I64 as TAG_SSO_STR, + STRING_TAG_TOP16_I64 as TAG_HEAP_STR, + }; + + if !is_string_expr(ctx, rhs) { + // Non-string rhs: mirror the legacy fallback's evaluation order + // (lhs slot load, then rhs), coerce the rhs once (heap handle + // guaranteed), then 2-arm on the destination tag only. + let lhs_box = ctx.block().load(DOUBLE, slot); + let rhs_val = lower_expr(ctx, rhs)?; + let r_handle = ctx + .block() + .call(I64, "js_jsvalue_to_string", &[(DOUBLE, &rhs_val)]); + let bits_d = ctx.block().bitcast_double_to_i64(&lhs_box); + let tag_d = ctx.block().lshr(I64, &bits_d, "48"); + let is_heap = ctx.block().icmp_eq(I64, &tag_d, TAG_HEAP_STR); + + let heap_idx = ctx.new_block("strapp.heap"); + let cold_idx = ctx.new_block("strapp.cold"); + let merge_idx = ctx.new_block("strapp.merge"); + let heap_label = ctx.block_label(heap_idx); + let cold_label = ctx.block_label(cold_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_heap, &heap_label, &cold_label); + + ctx.current_block = heap_idx; + let h_d = ctx.block().and(I64, &bits_d, POINTER_MASK_I64); + let h_heap = ctx + .block() + .call(I64, "js_string_append", &[(I64, &h_d), (I64, &r_handle)]); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = cold_idx; + let h_d2 = unbox_str_handle(ctx.block(), &lhs_box); + let h_cold = ctx + .block() + .call(I64, "js_string_append", &[(I64, &h_d2), (I64, &r_handle)]); + let cold_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let handle = ctx + .block() + .phi(I64, &[(&h_heap, &heap_pred), (&h_cold, &cold_pred)]); + let new_box = nanbox_string_inline(ctx.block(), &handle); + ctx.block().store(DOUBLE, &new_box, slot); + return Ok(new_box); + } + + // Proven-string rhs: mirror the legacy fast path's evaluation order + // (rhs first, then the lhs slot load). + // + // Arm layout — the load-bearing property is that a HEAP destination + // ALWAYS reaches `js_string_append` (whose refcount==1 in-place path is + // what makes accumulator loops amortized O(n)). Routing a heap-dest / + // SSO-rhs iteration through `js_string_concat_box` instead would copy + // the whole accumulator every time a ≤5-byte part arrives — O(n²). + // + // dest heap, rhs heap → append(h, h) (hot, no calls) + // dest heap, rhs other → append(h, unified(rhs)) (legacy-exact: + // unified materializes SSO / coerces a lie) + // dest SSO → js_string_concat_box (SSO-aware, + // nothing to mutate in place; result may stay + // SSO — no per-op heap materialization) + // dest other (lie) → unified ×2 + append (legacy-exact) + let rhs_box = lower_expr(ctx, rhs)?; + let lhs_box = ctx.block().load(DOUBLE, slot); + let bits_d = ctx.block().bitcast_double_to_i64(&lhs_box); + let bits_r = ctx.block().bitcast_double_to_i64(&rhs_box); + let tag_d = ctx.block().lshr(I64, &bits_d, "48"); + let tag_r = ctx.block().lshr(I64, &bits_r, "48"); + let d_heap = ctx.block().icmp_eq(I64, &tag_d, TAG_HEAP_STR); + + let dheap_idx = ctx.new_block("strapp.dheap"); + let heap_idx = ctx.new_block("strapp.heap"); + let rcold_idx = ctx.new_block("strapp.rcold"); + let dother_idx = ctx.new_block("strapp.dother"); + let sso_idx = ctx.new_block("strapp.sso"); + let cold_idx = ctx.new_block("strapp.cold"); + let merge_idx = ctx.new_block("strapp.merge"); + let dheap_label = ctx.block_label(dheap_idx); + let heap_label = ctx.block_label(heap_idx); + let rcold_label = ctx.block_label(rcold_idx); + let dother_label = ctx.block_label(dother_idx); + let sso_label = ctx.block_label(sso_idx); + let cold_label = ctx.block_label(cold_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&d_heap, &dheap_label, &dother_label); + + // dest heap: split on the rhs tag. + ctx.current_block = dheap_idx; + let r_heap = ctx.block().icmp_eq(I64, &tag_r, TAG_HEAP_STR); + ctx.block().cond_br(&r_heap, &heap_label, &rcold_label); + + ctx.current_block = heap_idx; + let h_d = ctx.block().and(I64, &bits_d, POINTER_MASK_I64); + let h_r = ctx.block().and(I64, &bits_r, POINTER_MASK_I64); + let h_new = ctx + .block() + .call(I64, "js_string_append", &[(I64, &h_d), (I64, &h_r)]); + let box_heap = nanbox_string_inline(ctx.block(), &h_new); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = rcold_idx; + let h_d1 = ctx.block().and(I64, &bits_d, POINTER_MASK_I64); + let r_h1 = unbox_str_handle(ctx.block(), &rhs_box); + let h_rc = ctx + .block() + .call(I64, "js_string_append", &[(I64, &h_d1), (I64, &r_h1)]); + let box_rcold = nanbox_string_inline(ctx.block(), &h_rc); + let rcold_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + // dest not heap: an SSO dest with a real-string rhs takes the SSO-aware + // pairwise concat; a lie on EITHER side keeps the exact legacy sequence + // (`js_string_concat_box` treats a non-string operand as empty, but the + // legacy unified path ToString-coerces it — `"ab" += 42` must stay + // `"ab42"`). + ctx.current_block = dother_idx; + let d_sso = ctx.block().icmp_eq(I64, &tag_d, TAG_SSO_STR); + let r_heap2 = ctx.block().icmp_eq(I64, &tag_r, TAG_HEAP_STR); + let r_sso = ctx.block().icmp_eq(I64, &tag_r, TAG_SSO_STR); + let r_str = ctx.block().or(I1, &r_heap2, &r_sso); + let take_sso = ctx.block().and(I1, &d_sso, &r_str); + ctx.block().cond_br(&take_sso, &sso_label, &cold_label); + + ctx.current_block = sso_idx; + let box_sso = ctx.block().call( + DOUBLE, + "js_string_concat_box", + &[(DOUBLE, &lhs_box), (DOUBLE, &rhs_box)], + ); + let sso_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = cold_idx; + let l_h = unbox_str_handle(ctx.block(), &lhs_box); + let r_h = unbox_str_handle(ctx.block(), &rhs_box); + let h_cold = ctx + .block() + .call(I64, "js_string_append", &[(I64, &l_h), (I64, &r_h)]); + let box_cold = nanbox_string_inline(ctx.block(), &h_cold); + let cold_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let new_box = ctx.block().phi( + DOUBLE, + &[ + (&box_heap, &heap_pred), + (&box_rcold, &rcold_pred), + (&box_sso, &sso_pred), + (&box_cold, &cold_pred), + ], + ); + ctx.block().store(DOUBLE, &new_box, slot); + Ok(new_box) +} + /// Lower `string + non_string` (or vice versa) concat with runtime /// coercion of the non-string side. The non-string operand passes through /// `js_jsvalue_to_string` which inspects its NaN tag and produces the @@ -1317,15 +1588,17 @@ pub(crate) fn lower_string_coerce_concat( ) -> Result { let l_box = lower_expr(ctx, left)?; let r_box = lower_expr(ctx, right)?; - let blk = ctx.block(); // Issue #58: fused string+value concat — when one side is a string // and the other is not, use the fused runtime call that collapses // js_jsvalue_to_string + js_string_concat into a single allocation // for number operands (the common `"item_" + i` pattern). if l_is_string && !r_is_string { - // Issue #214: SSO-safe unbox — see lower_string_concat. - let l_handle = unbox_str_handle(blk, &l_box); + // Issue #214: SSO-safe unbox; repsel Phase 3a: inline `bitcast+and` + // for proven-heap operands (string literals — the `"user_" + i` + // shape) and tag-dispatch for canonical-Str locals. + let l_handle = str_operand_handle_tag_dispatched(ctx, left, &l_box); + let blk = ctx.block(); let result_handle = blk.call( I64, "js_string_concat_value", @@ -1335,8 +1608,9 @@ pub(crate) fn lower_string_coerce_concat( } if !l_is_string && r_is_string { - // Issue #214: SSO-safe unbox — see lower_string_concat. - let r_handle = unbox_str_handle(blk, &r_box); + // Issue #214: SSO-safe unbox; repsel Phase 3a: see above. + let r_handle = str_operand_handle_tag_dispatched(ctx, right, &r_box); + let blk = ctx.block(); let result_handle = blk.call( I64, "js_value_concat_string", @@ -1347,6 +1621,7 @@ pub(crate) fn lower_string_coerce_concat( // Both non-string (shouldn't normally reach here) — fall back to // the generic path. + let blk = ctx.block(); let l_handle = blk.call(I64, "js_jsvalue_to_string", &[(DOUBLE, &l_box)]); let r_handle = blk.call(I64, "js_jsvalue_to_string", &[(DOUBLE, &r_box)]); diff --git a/crates/perry-codegen/src/nanbox.rs b/crates/perry-codegen/src/nanbox.rs index 3a692b6e24..e97f04d14f 100644 --- a/crates/perry-codegen/src/nanbox.rs +++ b/crates/perry-codegen/src/nanbox.rs @@ -52,6 +52,11 @@ pub const POINTER_MASK_I64: &str = "281474976710655"; pub const INT32_TAG_I64: &str = "9222809086901354496"; pub const STRING_TAG_I64: &str = "9223090561878065152"; pub const BIGINT_TAG_I64: &str = "9221683186994511872"; +/// Top-16-bit comparands for `lshr 48`-style tag dispatch (repsel Phase 3a +/// canonical-Str lowerings): `STRING_TAG >> 48` and `SHORT_STRING_TAG >> 48`. +/// Asserted against the u64 tags in `tag_strings_match_u64_values`. +pub const STRING_TAG_TOP16_I64: &str = "32767"; +pub const SHORT_STRING_TAG_TOP16_I64: &str = "32761"; /// Format a `u64` as a signed LLVM i64 literal (LLVM IR integer literals are signed). pub fn i64_literal(v: u64) -> String { @@ -107,6 +112,11 @@ mod tests { assert_eq!(i64_literal(STRING_TAG), STRING_TAG_I64); assert_eq!(i64_literal(BIGINT_TAG), BIGINT_TAG_I64); assert_eq!(i64_literal(STATIC_DISPATCH_TAG), "9221120237041090560"); + assert_eq!(i64_literal(STRING_TAG >> 48), STRING_TAG_TOP16_I64); + assert_eq!( + i64_literal(SHORT_STRING_TAG >> 48), + SHORT_STRING_TAG_TOP16_I64 + ); } #[test] diff --git a/crates/perry-codegen/src/native_value/materialize.rs b/crates/perry-codegen/src/native_value/materialize.rs index a285ae835a..a3e8e91cce 100644 --- a/crates/perry-codegen/src/native_value/materialize.rs +++ b/crates/perry-codegen/src/native_value/materialize.rs @@ -433,6 +433,46 @@ fn materialize_js_value_bits_to_js_value( value } +/// NaN-box a raw `StringRef` handle (i64 `StringHeader*`) as a boxed string +/// value. Repsel Phase 3a: with `PERRY_CANONICAL_STR_LOCALS` on (the +/// default), the hot non-null path is the inline `or STRING_TAG; bitcast` +/// pair (`expr/nanbox_inline.rs` shape) instead of the opaque +/// `js_nanbox_string` call; the helper's one semantic addition — a null +/// handle allocates an empty string — is preserved in a cold arm that still +/// calls it. Flag off reverts to the pre-phase unconditional call. +fn nanbox_string_ref_boxed(ctx: &mut FnCtx<'_>, handle: &str) -> String { + if !crate::expr::canonical_str_locals_enabled() { + return ctx + .block() + .call(DOUBLE, "js_nanbox_string", &[(I64, handle)]); + } + let is_null = ctx.block().icmp_eq(I64, handle, "0"); + let null_idx = ctx.new_block("strref.null"); + let tag_idx = ctx.new_block("strref.tag"); + let merge_idx = ctx.new_block("strref.merge"); + let null_label = ctx.block_label(null_idx); + let tag_label = ctx.block_label(tag_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block().cond_br(&is_null, &null_label, &tag_label); + + ctx.current_block = null_idx; + let null_box = ctx + .block() + .call(DOUBLE, "js_nanbox_string", &[(I64, handle)]); + let null_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = tag_idx; + let tagged = ctx.block().or(I64, handle, crate::nanbox::STRING_TAG_I64); + let tag_box = ctx.block().bitcast_i64_to_double(&tagged); + let tag_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + ctx.block() + .phi(DOUBLE, &[(&null_box, &null_pred), (&tag_box, &tag_pred)]) +} + pub(crate) fn materialize_js_value( ctx: &mut FnCtx<'_>, lowered: LoweredValue, @@ -512,10 +552,7 @@ pub(crate) fn materialize_js_value( } NativeRep::BufferLen => ctx.block().uitofp(I32, &lowered.value, DOUBLE), NativeRep::F32 => ctx.block().fpext(F32, &lowered.value, DOUBLE), - NativeRep::StringRef => { - ctx.block() - .call(DOUBLE, "js_nanbox_string", &[(I64, &lowered.value)]) - } + NativeRep::StringRef => nanbox_string_ref_boxed(ctx, &lowered.value), NativeRep::BufferView(_) => lowered.value.clone(), NativeRep::PodRecord { .. } => lowered.value.clone(), NativeRep::PodRecordView { .. } => lowered.value.clone(), @@ -556,10 +593,7 @@ pub(crate) fn materialize_js_value_without_record( let tagged = ctx.block().or(I64, &lowered.value, POINTER_TAG_I64); ctx.block().bitcast_i64_to_double(&tagged) } - NativeRep::StringRef => { - ctx.block() - .call(DOUBLE, "js_nanbox_string", &[(I64, &lowered.value)]) - } + NativeRep::StringRef => nanbox_string_ref_boxed(ctx, &lowered.value), NativeRep::I1 => { let bits = ctx.block().select( I1, diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 96972267ea..8a66e21947 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -663,6 +663,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_string_replace_all_string", I64, &[I64, I64, I64]); module.declare_function("js_string_equals", I32, &[I64, I64]); module.declare_function("js_string_compare", I32, &[I64, I64]); + // Repsel Phase 3a (canonical-Str locals): boxed-operand variants for the + // non-proven-heap compare arms. `js_jsvalue_equals` content-compares + // strings in any representation mix (heap × SSO) without materializing + // SSO bits to the heap, and never number-coerces (`5 === "5"` is false). + // `js_string_compare_value` is the relational (`<`/`>`) counterpart. + module.declare_function("js_jsvalue_equals", I32, &[DOUBLE, DOUBLE]); + module.declare_function("js_string_compare_value", I32, &[DOUBLE, DOUBLE]); module.declare_function("js_jsvalue_to_string_radix", I64, &[DOUBLE, DOUBLE]); module.declare_function("js_math_random", DOUBLE, &[]); // WebAssembly host runtime (issue #76). All take/return NaN-boxed diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 700ab8323d..566a41cd5e 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -1134,7 +1134,12 @@ pub(crate) fn lower_let( // falling through to the plain path would allocate a double slot that // shadows the canonical one (reads through `local_slot_reps` would see a // stale 0). Mirrors the canonical branch's init lowering exactly. - if ctx.local_slot_reps.contains_key(&id) { + // + // Phase 3a: canonical-Str locals are NOT routed here — their storage is + // the ordinary `ctx.locals` double slot, so a re-declaration must take + // exactly the pre-phase plain path below (`local_rep_is_canonical_i32` + // is false for `SlotRep::Str`). + if crate::expr::local_rep_is_canonical_i32(ctx, id) { if let Some(init_expr) = init { let i32_slots = ctx.i32_counter_slots.clone(); let flat_ca = ctx.flat_const_arrays.clone(); @@ -1226,7 +1231,7 @@ pub(crate) fn lower_let( ctx.i32_counter_slots.insert(id, i32_slot.clone()); ctx.local_slot_reps.insert(id, rep); ctx.local_types.insert(id, refined_ty.clone()); - crate::expr::note_canonical_i32_local(ctx, id, name, rep); + crate::expr::note_canonical_local(ctx, id, name, rep); if let Some(init_expr) = init { let i32_slots = ctx.i32_counter_slots.clone(); let flat_ca = ctx.flat_const_arrays.clone(); @@ -1266,6 +1271,33 @@ pub(crate) fn lower_let( return Ok(()); } + // Representation-selection Phase 3a: canonical-Str selection + // (tagged-at-rest). Unlike canonical-i32, this does NOT change storage: + // the local keeps the ordinary `ctx.locals` double slot allocated below, + // its shadow-slot GC binding, and every alias/refcount demote — the + // NaN-box string bits at rest ARE the canonical representation. The rep + // entry is a compile-time proof consumed by the string-op lowerings + // (`+=` self-append, `.length`, `===`/`<`, `charCodeAt`-family), which + // tag-dispatch on the slot bits inline instead of routing operands + // through `js_get_string_pointer_unified`. See `expr/slot_rep.rs`. + let canonical_str = ctx.repsel_context_allows_canonical_str + && matches!( + refined_ty, + perry_hir::types::Type::String | perry_hir::types::Type::StringLiteral(_) + ) + && !ctx.local_slot_reps.contains_key(&id) + && !ctx.boxed_vars.contains(&id) + && !ctx.module_globals.contains_key(&id) + && !ctx.repsel_closure_ref_locals.contains(&id) + && !ctx.repsel_str_ineligible_locals.contains(&id) + && !ctx.i32_counter_slots.contains_key(&id); + if canonical_str { + ctx.local_slot_reps.insert(id, crate::expr::SlotRep::Str); + crate::expr::note_canonical_local(ctx, id, name, crate::expr::SlotRep::Str); + // Fall through: storage, init lowering, aliasing demotes, and GC + // binding are exactly the plain path's. + } + // Slot must live in the entry block — see the boxed-var case // above. Putting allocas inside an `if` arm causes verifier // failures the moment a closure in another branch captures diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index 10b66d9159..84aa1689d2 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -839,3 +839,138 @@ fn closure_body_write_to_captured_outer_local_is_visible_to_shadow_analysis() { "boxed captured local should bind its outer shadow slot to the box slot" ); } + +// ── Representation-selection Phase 3a: canonical string locals ───────────── + +fn canonical_str_shadow_module() -> Module { + Module { + script_global_functions: Vec::new(), + references_global_this: false, + annexb_global_undefined_names: Vec::new(), + name: "canonical_str_shadow.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![Function { + id: 1, + name: "probe_str".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![ + Stmt::Let { + id: 1, + name: "acc".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("hello world!".to_string())), + }, + // acc = acc + "x" — the canonical `+=` self-append shape. + Stmt::Expr(Expr::LocalSet( + 1, + Box::new(Expr::Binary { + op: perry_hir::BinaryOp::Add, + left: Box::new(Expr::LocalGet(1)), + right: Box::new(Expr::String("x".to_string())), + }), + )), + // acc.length — the canonical `.length` tag dispatch. + Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "length".to_string(), + byte_offset: 0, + })), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }], + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + class_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + gen_param_prologue_len: std::collections::HashMap::new(), + } +} + +/// Phase 3a invariants (default flag state, `PERRY_CANONICAL_STR_LOCALS` on): +/// a canonical-Str local keeps EXACTLY the pre-phase GC protocol — same +/// double slot, same `js_shadow_slot_bind` — while the string ops tag- +/// dispatch inline. The `+=` hot arm calls `js_string_append` on raw +/// handles with no `js_get_string_pointer_unified` in it, and `.length` +/// drops the generic GC-type-byte tower for the 3-arm tag dispatch. +#[test] +fn canonical_str_local_keeps_shadow_binding_and_tag_dispatched_ops() { + let ir = + String::from_utf8(compile_module(&canonical_str_shadow_module(), empty_opts()).unwrap()) + .expect("LLVM IR should be UTF-8"); + let fn_ir = function_slice(&ir, "perry_fn_canonical_str_shadow_ts__probe_str"); + + // GC contract: the canonical-Str local still binds its shadow slot + // (tagged-at-rest bits are marked/rewritten through the same path). + assert!( + fn_ir.contains("call void @js_shadow_slot_bind"), + "canonical-Str local must keep its shadow-slot binding:\n{fn_ir}" + ); + + // `+=` selected the canonical tag-dispatched shape, and its proven-heap + // arm appends raw handles without the opaque unified unbox. Locate the + // BLOCK DEFINITIONS (lines ending in ':'), not the branch-operand label + // references. + fn block_def_offset(fn_ir: &str, prefix: &str) -> usize { + let mut offset = 0usize; + for line in fn_ir.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with(prefix) && trimmed.trim_end().ends_with(':') { + return offset; + } + offset += line.len() + 1; + } + panic!("expected a '{prefix}…:' block definition in:\n{fn_ir}"); + } + let heap_arm_start = block_def_offset(fn_ir, "strapp.heap"); + let heap_arm_end = + heap_arm_start + block_def_offset(&fn_ir[heap_arm_start + 1..], "strapp.rcold") + 1; + let heap_arm = &fn_ir[heap_arm_start..heap_arm_end]; + assert!( + heap_arm.contains("call i64 @js_string_append"), + "heap arm must call js_string_append directly:\n{heap_arm}" + ); + assert!( + !heap_arm.contains("js_get_string_pointer_unified"), + "heap arm must not route through js_get_string_pointer_unified:\n{heap_arm}" + ); + + // `.length` selected the canonical 3-arm tag dispatch, not the generic + // GC-type-byte tower. + assert!( + fn_ir.contains("strlen.heap"), + "canonical-Str .length should emit the strlen tag dispatch:\n{fn_ir}" + ); + assert!( + !fn_ir.contains("plen.check_gc"), + "canonical-Str .length must not fall into the generic receiver tower:\n{fn_ir}" + ); +} diff --git a/crates/perry-runtime/src/string/compare.rs b/crates/perry-runtime/src/string/compare.rs index f656d74434..c61a74091c 100644 --- a/crates/perry-runtime/src/string/compare.rs +++ b/crates/perry-runtime/src/string/compare.rs @@ -87,6 +87,76 @@ pub extern "C" fn js_string_equals(a: *const StringHeader, b: *const StringHeade } } +/// Repsel Phase 3a: relational comparison over NaN-boxed operands that may +/// be heap strings (`STRING_TAG`) or inline SSO values (`SHORT_STRING_TAG`) +/// in any mix — the boxed counterpart of `js_string_compare`, used by the +/// canonical-Str compare lowering's non-proven-heap arm. Decodes SSO +/// operands through a stack scratch buffer (no heap materialization). +/// +/// Semantics for non-string operands mirror the legacy +/// `js_get_string_pointer_unified` → `js_string_compare` composition this +/// arm replaces: a plain number compares by its decimal string form; every +/// other non-string value ranks like `js_string_compare`'s invalid-pointer +/// handling (invalid < any valid string; two invalids compare equal). +/// Returns -1 / 0 / 1. +#[no_mangle] +pub extern "C" fn js_string_compare_value(a: f64, b: f64) -> i32 { + // Phase 1 — ALLOCATING coercions only. `js_number_to_string` allocates, + // and an allocation can run a GC cycle that MOVES the other operand's + // heap string (evacuation); the decimal bytes are therefore copied into + // an owned `Vec` immediately, and no raw heap-string pointer may exist + // yet. Both operands' coercions complete before phase 2 takes any view. + fn number_bytes(v: f64) -> Option> { + if !crate::JSValue::from_bits(v.to_bits()).is_number() { + return None; + } + // Mirror the unified helper's number → decimal-string coercion. + let s = crate::string::js_number_to_string(v); + if !crate::string::is_valid_string_ptr(s) { + return None; + } + unsafe { + let len = (*s).byte_len; + let data = crate::string::string_data(s); + Some(std::slice::from_raw_parts(data, len as usize).to_vec()) + } + } + let a_num = number_bytes(a); + let b_num = number_bytes(b); + + // Phase 2 — NON-allocating views only (heap payload pointers, SSO + // scratch decode, or the owned number buffers). Nothing below allocates, + // so the raw `from_raw_parts` reads cannot observe a moved string. + fn view_of<'s>( + v: f64, + scratch: &'s mut [u8; crate::value::SHORT_STRING_MAX_LEN], + num_buf: &'s Option>, + ) -> Option<(*const u8, u32)> { + if let Some(view) = crate::string::str_bytes_from_jsvalue(v, scratch) { + return Some(view); + } + num_buf.as_ref().map(|buf| (buf.as_ptr(), buf.len() as u32)) + } + let mut a_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let mut b_scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let a_view = view_of(a, &mut a_scratch, &a_num); + let b_view = view_of(b, &mut b_scratch, &b_num); + match (a_view, b_view) { + (None, None) => 0, + (None, Some(_)) => -1, + (Some(_), None) => 1, + (Some((a_ptr, a_len)), Some((b_ptr, b_len))) => unsafe { + let a_bytes = std::slice::from_raw_parts(a_ptr, a_len as usize); + let b_bytes = std::slice::from_raw_parts(b_ptr, b_len as usize); + match utf16_cmp_bytes(a_bytes, b_bytes) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } + }, + } +} + /// SSO-aware key match: compare a stored-key `JSValue` (which may be a /// `STRING_TAG` heap pointer OR a `SHORT_STRING_TAG` inline SSO value) /// against an incoming heap `*const StringHeader` key. diff --git a/crates/perry-runtime/src/string/tests.rs b/crates/perry-runtime/src/string/tests.rs index b96bfa2ddb..e5cf1abe55 100644 --- a/crates/perry-runtime/src/string/tests.rs +++ b/crates/perry-runtime/src/string/tests.rs @@ -474,3 +474,54 @@ fn test_string_append_loop() { inplace_count ); } + +// ── Repsel Phase 3a: js_string_compare_value ─────────────────────────────── + +#[test] +fn string_compare_value_heap_and_sso_mixes() { + use super::compare::js_string_compare_value; + let heap = |s: &str| { + let p = js_string_from_bytes(s.as_ptr(), s.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(p).bits()) + }; + let sso = |s: &str| { + f64::from_bits( + crate::value::JSValue::try_short_string(s.as_bytes()) + .expect("<=5 bytes") + .bits(), + ) + }; + // heap × heap + assert_eq!(js_string_compare_value(heap("abc"), heap("abd")), -1); + assert_eq!(js_string_compare_value(heap("abc"), heap("abc")), 0); + // SSO × SSO + assert_eq!(js_string_compare_value(sso("ab"), sso("ac")), -1); + assert_eq!(js_string_compare_value(sso("ab"), sso("ab")), 0); + assert_eq!(js_string_compare_value(sso("b"), sso("a")), 1); + // mixed representations, equal content + assert_eq!(js_string_compare_value(sso("ok"), heap("ok")), 0); + assert_eq!(js_string_compare_value(heap("ok"), sso("oz")), -1); + // astral vs BMP: UTF-16 code-unit order, not code-point order + assert_eq!( + js_string_compare_value(heap("\u{1F600}"), heap("\u{FFFD}")), + -1 + ); + // number operand coerces via its decimal string form (legacy unified + // behavior this helper's arm replaces) — both orders and both string + // representations, exercising the "allocating coercions complete before + // any heap-payload view is taken" phase split (the number path calls + // js_number_to_string, which allocates and may move the other operand's + // heap string under evacuation). + assert_eq!(js_string_compare_value(42.0, heap("42")), 0); + assert_eq!(js_string_compare_value(heap("42"), 42.0), 0); + assert_eq!(js_string_compare_value(42.0, heap("5")), -1); + assert_eq!(js_string_compare_value(heap("5"), 42.0), 1); + assert_eq!(js_string_compare_value(42.0, sso("42")), 0); + assert_eq!(js_string_compare_value(sso("41"), 42.0), -1); + assert_eq!(js_string_compare_value(1.5, 2.5), -1); // both numbers coerce + // non-string, non-number operands rank as invalid + let undef = f64::from_bits(crate::value::JSValue::undefined().bits()); + assert_eq!(js_string_compare_value(undef, heap("x")), -1); + assert_eq!(js_string_compare_value(heap("x"), undef), 1); + assert_eq!(js_string_compare_value(undef, undef), 0); +} diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 555a0b8676..dd5a868962 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -970,6 +970,18 @@ fn compute_object_cache_key_with_env( .as_deref() .unwrap_or(""), ); + // Representation-selection Phase 3a — canonical string locals + // (tagged-at-rest): `=0`/`off`/`false` reverts the Str-gated lowerings + // (`+=` tag-dispatch, inline `.length`, direct string compares, the + // char-access receiver fast arm, and the inline StringRef retag) back to + // the pre-phase sequences, which changes the emitted IR / .o bytes — a + // warm cache must not serve an object built under the other setting. + h.field( + "env_canonical_str_locals", + env_var("PERRY_CANONICAL_STR_LOCALS") + .as_deref() + .unwrap_or(""), + ); // Representation-selection Phase 2 — specialized calling convention: // `PERRY_SPECIALIZED_ABI=0/off/false` removes the specialized entries and // their static/guarded dispatch sites; `PERRY_SPECIALIZED_ABI_MAX` diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 38e9c3e358..1e5401c67c 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -617,6 +617,8 @@ fn key_changes_with_codegen_env_vars() { "PERRY_INT_VALUED_LOCALS", // Representation-selection Phase 1: canonical unboxed i32 locals. "PERRY_CANONICAL_I32_LOCALS", + // Representation-selection Phase 3a: canonical string locals. + "PERRY_CANONICAL_STR_LOCALS", // Representation-selection Phase 2: specialized calling convention. "PERRY_SPECIALIZED_ABI", "PERRY_SPECIALIZED_ABI_MAX", diff --git a/test-files/test_gap_repsel_canonical_str_locals.ts b/test-files/test_gap_repsel_canonical_str_locals.ts new file mode 100644 index 0000000000..5974872369 --- /dev/null +++ b/test-files/test_gap_repsel_canonical_str_locals.ts @@ -0,0 +1,157 @@ +// Gap test: representation-selection Phase 3a — canonical string locals +// (tagged-at-rest Str rep). Exercises the four correctness obligations: +// 1. alias/refcount discipline: in-place `+=` must not corrupt aliases +// 2. SSO round-trip: short ASCII strings stay correct through +// `+=`/`.length`/`===` without per-op heap materialization +// 3. boxed→Str acceptance: a lying `string` annotation must degrade to +// exact JS semantics, never a wrong-value coercion +// 4. non-ASCII byte-exactness through the canonical-Str fast arms +// Run: node --experimental-strip-types test_gap_repsel_canonical_str_locals.ts +// Also run with PERRY_CANONICAL_STR_LOCALS=0 and PERRY_GC_FORCE_EVACUATE=1. + +function aliasDemote(): void { + // Obligation 1: `b` aliases `a`'s heap buffer; the demote at `let b = a` + // must force `a += "y"` to allocate fresh instead of mutating in place. + let a = "x".repeat(3); + const b = a; + a += "y"; + console.log("alias:", a, b, a.length, b.length); + + // Same discipline through a scalar-replaced array element. + let c = "z".repeat(4); + const arr = [c]; + c += "!"; + console.log("alias-arr:", c, arr[0]); + + // And through an object field. + let d = "q".repeat(4); + const o = { f: d }; + d += "?"; + console.log("alias-obj:", d, o.f); +} +aliasDemote(); + +function accumulator(): void { + // The += hot-loop shape (string_concat_csv kernel). Crosses the SSO→heap + // boundary in the first iterations and grows through several in-place + // append reallocs. + let csv = ""; + for (let i = 0; i < 50; i++) { + csv += String(i); + csv += ","; + } + console.log("acc:", csv.length, csv.slice(0, 12), csv.slice(-6)); +} +accumulator(); + +function ssoRoundTrip(): void { + // Obligation 2: short JSON-key-like strings. `id`/`ab` stay ≤5 bytes. + let k = "i"; + k += "d"; // SSO + SSO → SSO + console.log("sso:", k, k.length, k === "id", "id" === k); + let l = "ab"; + l += "cde"; // exactly 5 bytes — still SSO-representable + console.log("sso5:", l, l.length, l === "abcde", l < "abcdf", l > "abcdd"); + l += "f"; // crosses to heap + console.log("sso6:", l, l.length, l === "abcdef"); + // SSO receiver for the char-access family. + console.log("ssochar:", k.charCodeAt(0), k.charCodeAt(1), k.at(-1), k.codePointAt(0)); + // Compare a parsed (runtime-SSO) value against a literal (heap constant). + const parsed: string = JSON.parse('"ok"'); + console.log("ssoparse:", parsed === "ok", parsed.length, parsed < "oz"); +} +ssoRoundTrip(); + +function lyingAnnotation(): void { + // Obligation 3: a `string`-typed local that actually holds `undefined` + // (annotation lie). The canonical compare arm must route to the exact + // non-coercing equality helper — `undefined === "..."` is false — and + // agree with both node and the pre-phase lowering. (Number-holding lies + // are deliberately NOT asserted here: the pre-phase unified helper + // number-coerces them, a shipped divergence from node that this phase + // fixes only under the flag; a byte-exact gap test must hold in the + // flag-off arm too.) + const s: string = undefined as unknown as string; + console.log("lie-eq:", s === "boom", s !== "boom"); + + // `+=` with a lying rhs must ToString-coerce on every destination shape + // (SSO-at-rest and heap-at-rest) — the SSO arm must not swallow the rhs. + const lie: string = 42 as unknown as string; + let sso: string = JSON.parse('"ab"'); // runtime-SSO destination bits + sso += lie; + console.log("lie-append-sso:", sso, sso.length); + let heap = "abcdefgh"; // literal init → heap destination bits + heap += lie; + console.log("lie-append-heap:", heap, heap.length); + // SSO dest + SSO-ish rhs stays on the SSO-aware concat (result may stay + // SSO): both sides real strings. + let sk: string = JSON.parse('"x"'); + const sv: string = JSON.parse('"y"'); + sk += sv; + console.log("sso-sso-append:", sk, sk.length, sk === "xy"); +} +lyingAnnotation(); + +function nonAscii(): void { + // Obligation 4 (non-ASCII byte-exactness): multi-byte UTF-8 through the + // canonical `+=`/`.length`/compare arms. + let u = "é"; + u += "x"; + console.log("utf8:", u, u.length, u === "éx", u.charCodeAt(0), u.charCodeAt(1)); + let cjk = "漢"; + cjk += "字"; + console.log("cjk:", cjk, cjk.length, cjk === "漢字", cjk.codePointAt(0)); + let emoji = ""; + emoji += "\u{1F600}"; + emoji += "!"; + console.log("emoji:", emoji, emoji.length, emoji.charCodeAt(0), emoji.charCodeAt(1)); +} +nonAscii(); + +function comparesAndScan(): void { + // Route-compare shape: canonical local vs heap literal, both orders, + // equality + relational. + let method = "GET"; + method += ""; + console.log( + "route:", + method === "GET", + method !== "POST", + "GET" === method, + method < "HEAD", + method >= "GET" + ); + // char-scan shape: charCodeAt over a proven-heap accumulator result. + let payload = ""; + for (let i = 0; i < 6; i++) payload += "abz"; + let sum = 0; + for (let i = 0; i < payload.length; i++) sum += payload.charCodeAt(i); + console.log("scan:", payload.length, sum); +} +comparesAndScan(); + +function lengthShapes(): void { + // `.length` on SSO-at-rest, heap-at-rest, and lying receivers. + let short = "abc"; + short += "d"; // SSO + let long = "abc"; + long += "defgh"; // heap + console.log("len:", short.length, long.length); + // (A number-holding `string` local's `.length` is deliberately not + // asserted: the pre-phase lowering already returns 0 where node says + // undefined, and the canonical slow arm reproduces the shipped behavior.) + const und: string = undefined as unknown as string; + console.log("len-und:", und === undefined); +} +lengthShapes(); + +function templateChain(): void { + // Template-chain shape: interpolation feeding the += accumulator. + let out = ""; + const host = "example.com"; + for (let p = 80; p < 83; p++) { + out += `${host}:${p};`; + } + console.log("tmpl:", out, out.length); +} +templateChain();