diff --git a/changelog.d/8597-short-heap-string-equality.md b/changelog.d/8597-short-heap-string-equality.md new file mode 100644 index 0000000000..7c329eac39 --- /dev/null +++ b/changelog.d/8597-short-heap-string-equality.md @@ -0,0 +1,5 @@ +Improved equality checks between statically proven strings by comparing heap-string +lengths and up to three payload bytes inline before falling back to the full runtime +helper. This targets the short identifiers used by tree-walking interpreters: retired +instructions fell 2.41% for `interp` and 1.62% for `iso_miss`, with RSS effectively +unchanged. Generic-key comparisons retain the smaller existing dispatch. diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index ab33db7e17..339208ad2d 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -432,6 +432,119 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { ) } +/// Resolve equal-length heap strings of up to three bytes inline, and reject +/// longer pairs on a length or endpoint mismatch before using the full helper. +/// The caller has already proven both values carry `STRING_TAG`. +fn lower_short_heap_string_eq( + ctx: &mut FnCtx<'_>, + lh: &str, + rh: &str, + true_l: &str, + false_l: &str, + merge_l: &str, +) -> (String, String) { + let heap_valid_idx = ctx.new_block("streq.heap.valid"); + let heap_len_idx = ctx.new_block("streq.heap.len"); + let heap_first_idx = ctx.new_block("streq.heap.first"); + let heap_one_idx = ctx.new_block("streq.heap.one"); + let heap_last_idx = ctx.new_block("streq.heap.last"); + let heap_two_idx = ctx.new_block("streq.heap.two"); + let heap_middle_idx = ctx.new_block("streq.heap.middle"); + let heap_middle_byte_idx = ctx.new_block("streq.heap.middle.byte"); + let heap_slow_idx = ctx.new_block("streq.heap.slow"); + let heap_valid_l = ctx.block_label(heap_valid_idx); + let heap_len_l = ctx.block_label(heap_len_idx); + let heap_first_l = ctx.block_label(heap_first_idx); + let heap_one_l = ctx.block_label(heap_one_idx); + let heap_last_l = ctx.block_label(heap_last_idx); + let heap_two_l = ctx.block_label(heap_two_idx); + let heap_middle_l = ctx.block_label(heap_middle_idx); + let heap_middle_byte_l = ctx.block_label(heap_middle_byte_idx); + let heap_slow_l = ctx.block_label(heap_slow_idx); + + // Preserve `js_string_equals`' handling of deliberately forged low + // `STRING_TAG` payloads: only dereference real heap addresses here. + let lh_valid = ctx.block().icmp_ugt(I64, lh, "4095"); + let rh_valid = ctx.block().icmp_ugt(I64, rh, "4095"); + let both_valid = ctx.block().and(I1, &lh_valid, &rh_valid); + ctx.block() + .cond_br(&both_valid, &heap_valid_l, &heap_slow_l); + + ctx.current_block = heap_valid_idx; + let lp = ctx.block().inttoptr(I64, lh); + let rp = ctx.block().inttoptr(I64, rh); + let llenp = ctx + .block() + .gep_inbounds(I8, &lp, &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)]); + let rlenp = ctx + .block() + .gep_inbounds(I8, &rp, &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)]); + let llen = ctx.block().load(I32, &llenp); + let rlen = ctx.block().load(I32, &rlenp); + let same_len = ctx.block().icmp_eq(I32, &llen, &rlen); + ctx.block().cond_br(&same_len, &heap_len_l, false_l); + + ctx.current_block = heap_len_idx; + let empty = ctx.block().icmp_eq(I32, &llen, "0"); + ctx.block().cond_br(&empty, true_l, &heap_first_l); + + // A non-empty, same-length pair can be rejected on its first byte. For a + // one-byte pair that byte also settles equality. + ctx.current_block = heap_first_idx; + let data_off = STRING_HEADER_SIZE.to_string(); + let lfirstp = ctx.block().gep_inbounds(I8, &lp, &[(I64, &data_off)]); + let rfirstp = ctx.block().gep_inbounds(I8, &rp, &[(I64, &data_off)]); + let lfirst = ctx.block().load(I8, &lfirstp); + let rfirst = ctx.block().load(I8, &rfirstp); + let same_first = ctx.block().icmp_eq(I8, &lfirst, &rfirst); + ctx.block().cond_br(&same_first, &heap_one_l, false_l); + + ctx.current_block = heap_one_idx; + let one_byte = ctx.block().icmp_eq(I32, &llen, "1"); + ctx.block().cond_br(&one_byte, true_l, &heap_last_l); + + // The length checks above prove that this dynamic last-byte offset is in + // both payloads. Together with the first byte it settles two-byte strings. + ctx.current_block = heap_last_idx; + let llen64 = ctx.block().zext(I32, &llen, I64); + let last_off = ctx + .block() + .add(I64, &llen64, &(STRING_HEADER_SIZE - 1).to_string()); + let llastp = ctx.block().gep_inbounds(I8, &lp, &[(I64, &last_off)]); + let rlastp = ctx.block().gep_inbounds(I8, &rp, &[(I64, &last_off)]); + let llast = ctx.block().load(I8, &llastp); + let rlast = ctx.block().load(I8, &rlastp); + let same_last = ctx.block().icmp_eq(I8, &llast, &rlast); + ctx.block().cond_br(&same_last, &heap_two_l, false_l); + + ctx.current_block = heap_two_idx; + let two_bytes = ctx.block().icmp_eq(I32, &llen, "2"); + ctx.block().cond_br(&two_bytes, true_l, &heap_middle_l); + + // For three-byte strings the middle byte is the only byte not checked yet. + // Longer strings retain the full runtime content comparison. + ctx.current_block = heap_middle_idx; + let three_bytes = ctx.block().icmp_eq(I32, &llen, "3"); + ctx.block() + .cond_br(&three_bytes, &heap_middle_byte_l, &heap_slow_l); + + ctx.current_block = heap_middle_byte_idx; + let lmiddlep = ctx.block().gep_inbounds(I8, &lp, &[(I64, "21")]); + let rmiddlep = ctx.block().gep_inbounds(I8, &rp, &[(I64, "21")]); + let lmiddle = ctx.block().load(I8, &lmiddlep); + let rmiddle = ctx.block().load(I8, &rmiddlep); + let same_middle = ctx.block().icmp_eq(I8, &lmiddle, &rmiddle); + ctx.block().cond_br(&same_middle, true_l, false_l); + + ctx.current_block = heap_slow_idx; + let heap_res = ctx + .block() + .call(I32, "js_string_equals", &[(I64, lh), (I64, rh)]); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(merge_l); + (heap_res, heap_pred) +} + /// Inline prefix for the `===`/`!==` string arms that have **no** literal /// operand — `names[i] === name` in an environment lookup, say. /// @@ -444,13 +557,20 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { /// * both operands SSO with different bits => different content, again by /// canonicality. /// -/// The remaining arms are exactly what each caller emitted before, so this is -/// behaviour-preserving. That matters most for `legacy_unified`, whose fallback -/// keeps the `js_get_string_pointer_unified` composition — including its -/// number-coercing behaviour for operands whose `string` annotation lies. Note -/// that composition *materializes* an SSO operand onto the heap, so routing -/// SSO x SSO around it removes two allocations per comparison as well as the -/// calls. +/// When both operands are statically proven strings, heap values first compare +/// their lengths and up to three payload bytes inline. Besides rejecting every +/// different-length pair, that completely settles the short identifiers that +/// dominate environment lookup in tree-walking interpreters (`n`, `go`, +/// `fib`, ...). Longer same-length pairs still use the old helper. Generic-key +/// comparisons skip this larger prefix because their strings may be longer. +/// +/// The remaining representation arms are exactly what each caller emitted +/// before, so this is behaviour-preserving. That matters most for +/// `legacy_unified`, whose fallback keeps the +/// `js_get_string_pointer_unified` composition — including its number-coercing +/// behaviour for operands whose `string` annotation lies. Note that +/// composition *materializes* an SSO operand onto the heap, so routing SSO x +/// SSO around it removes two allocations per comparison as well as the calls. /// /// Returns an `i32` that is 1 iff the operands are `===`. fn lower_string_strict_eq_inline( @@ -458,6 +578,7 @@ fn lower_string_strict_eq_inline( l: &str, r: &str, legacy_unified: bool, + inline_short_heap: bool, ) -> String { let l_bits = ctx.block().bitcast_double_to_i64(l); let r_bits = ctx.block().bitcast_double_to_i64(r); @@ -495,11 +616,16 @@ fn lower_string_strict_eq_inline( ctx.current_block = heap_idx; let lh = ctx.block().and(I64, &l_bits, POINTER_MASK_I64); let rh = ctx.block().and(I64, &r_bits, POINTER_MASK_I64); - let heap_res = ctx - .block() - .call(I32, "js_string_equals", &[(I64, &lh), (I64, &rh)]); - let heap_pred = ctx.block().label.clone(); - ctx.block().br(&merge_l); + let (heap_res, heap_pred) = if inline_short_heap { + lower_short_heap_string_eq(ctx, &lh, &rh, &true_l, &false_l, &merge_l) + } else { + let heap_res = ctx + .block() + .call(I32, "js_string_equals", &[(I64, &lh), (I64, &rh)]); + let heap_pred = ctx.block().label.clone(); + ctx.block().br(&merge_l); + (heap_res, heap_pred) + }; ctx.current_block = sso_idx; let l_sso = ctx @@ -795,7 +921,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // only `js_string_equals`. The boxed arm is byte-for-byte // the old helper composition, so a lying annotation keeps // its existing behaviour. - let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true); + let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true, false); let blk = ctx.block(); let bit = blk.icmp_ne(I32, &i32_eq, "0"); let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { @@ -890,7 +1016,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { CompareOp::Eq | CompareOp::LooseEq | CompareOp::Ne | CompareOp::LooseNe ) { - let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, false); + let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, false, true); let blk = ctx.block(); let bit = blk.icmp_ne(I32, &i32_eq, "0"); let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { @@ -921,7 +1047,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // SSO x SSO are answered inline, which is what keeps a pair of // short runtime strings (`charAt`, `substring`) from // materializing two throwaway heap copies per comparison. - let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true); + let i32_eq = lower_string_strict_eq_inline(ctx, &l, &r, true, true); let blk = ctx.block(); let bit = blk.icmp_ne(I32, &i32_eq, "0"); let bit_final = if matches!(op, CompareOp::Ne | CompareOp::LooseNe) { diff --git a/crates/perry-codegen/src/expr/compare_tests.rs b/crates/perry-codegen/src/expr/compare_tests.rs index 489fc5d79a..453197b951 100644 --- a/crates/perry-codegen/src/expr/compare_tests.rs +++ b/crates/perry-codegen/src/expr/compare_tests.rs @@ -287,6 +287,60 @@ fn string_vs_generic_key_uses_the_identity_first_dispatch() { ir.contains("streq.tag"), "string-vs-generic comparison did not enter the identity-first dispatch:\n{ir}" ); + assert!( + !ir.contains("streq.heap.len"), + "generic-key equality paid the short-string checks without two proven strings:\n{ir}" + ); +} + +#[test] +fn string_equality_inlines_short_heap_content_checks_before_the_helper() { + let ir = ir_for( + "streq_short_heap", + vec![ + Stmt::Let { + id: X, + name: "left".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("left".to_string())), + }, + Stmt::Let { + id: Y, + name: "right".to_string(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("right".to_string())), + }, + Stmt::Let { + id: R, + name: "same".to_string(), + ty: Type::Boolean, + mutable: false, + init: Some(Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(X)), + right: Box::new(Expr::LocalGet(Y)), + }), + }, + ], + ); + + for label in [ + "streq.heap.len", + "streq.heap.first", + "streq.heap.last", + "streq.heap.middle", + ] { + assert!( + ir.contains(label), + "missing {label} short-string arm:\n{ir}" + ); + } + assert!( + ir.contains("call i32 @js_string_equals("), + "long heap strings lost their full-content fallback:\n{ir}" + ); } /// The SSO immediate is hand-built here but consumed by `perry-runtime`'s diff --git a/test-files/test_strict_eq_string_literal_inline.ts b/test-files/test_strict_eq_string_literal_inline.ts index 71a232994f..847129f5fb 100644 --- a/test-files/test_strict_eq_string_literal_inline.ts +++ b/test-files/test_strict_eq_string_literal_inline.ts @@ -107,6 +107,38 @@ console.log(describe(tree)); const dynOp: string = ["+", "-", "*"][1]; console.log(dynOp === "-", dynOp === "+", dynOp !== "-"); +// ---- non-literal heap strings (the environment-lookup shape from #8591) --- +// Keep both operands statically `string` but construct distinct heap values so +// the no-literal equality path has to compare their contents. Lengths 0..3 are +// settled inline; longer strings retain the runtime helper. +function sameString(a: string, b: string): boolean { + return a === b; +} +const heapEmptyA: string = "xy".substring(1, 1); +const heapEmptyB: string = "ab".substring(0, 0); +const heapOneA: string = "xn".substring(1); +const heapOneB: string = "ny".substring(0, 1); +const heapTwoA: string = "g" + "o"; +const heapTwoB: string = "xgoy".substring(1, 3); +const heapThreeA: string = "f" + "ib"; +const heapThreeB: string = "xfiby".substring(1, 4); +const heapLongA: string = "long" + "name"; +const heapLongB: string = "xlongnamey".substring(1, 9); +console.log( + sameString(heapEmptyA, heapEmptyB), + sameString(heapOneA, heapOneB), + sameString(heapTwoA, heapTwoB), + sameString(heapThreeA, heapThreeB), + sameString(heapLongA, heapLongB), +); +console.log( + sameString(heapOneA, "z".substring(0, 1)), + sameString(heapTwoA, "no".substring(0, 2)), + sameString(heapThreeA, "fox".substring(0, 3)), + sameString(heapThreeA, "fig".substring(0, 3)), + sameString(heapLongA, "longnames".substring(0, 9)), +); + // ---- switch/case over the same literals (a second lowering of ===) --------- function classify(s: string): number { switch (s) {