Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions changelog.d/6909-repsel-p3a-canonical-str-locals.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 13 additions & 1 deletion crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
14 changes: 13 additions & 1 deletion crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
28 changes: 26 additions & 2 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
140 changes: 140 additions & 0 deletions crates/perry-codegen/src/expr/compare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String> {
match expr {
Expr::Compare { op, left, right } => {
Expand Down Expand Up @@ -292,6 +346,52 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// 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,
Expand Down Expand Up @@ -332,6 +432,46 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// 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,
Expand Down
22 changes: 19 additions & 3 deletions crates/perry-codegen/src/expr/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -774,6 +775,21 @@ pub(crate) struct FnCtx<'a> {
/// `repsel_context_allows_canonical_i32` is false.
pub repsel_closure_ref_locals: std::collections::HashSet<u32>,

/// 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<u32>,

/// 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
Expand Down
Loading
Loading