Skip to content
Draft
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
7 changes: 7 additions & 0 deletions changelog.d/8497-string-append-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
perf(string): fuse proven `s = s + a + b + ...` accumulator chains into one
rooted runtime operation (#8497). The runtime now appends every suffix directly
when a unique accumulator has capacity, or allocates the complete result once,
instead of first materializing the suffix and then entering a separate append
envelope. On `iso_miss`, this reduces instructions retired by 7.91% and cycles
by 9.34% across five shuffled interleaved repeats; no other corpus row moves by
more than 0.41% in instructions. Observed peak RSS changes by +48 KiB (+0.15%).
20 changes: 10 additions & 10 deletions crates/perry-codegen/src/codegen/declared_string_add_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,12 @@ fn a_chain_whose_second_part_is_proven_still_folds() {
}

#[test]
fn a_self_append_chain_retains_the_accumulator_and_fuses_only_the_suffix() {
fn a_self_append_chain_fuses_the_accumulator_and_suffix() {
// `s = s + "[" + name + "]"` is the #8394 accumulator shape. Folding
// all four parts into `js_string_concat_chain` copies the growing `s`
// prefix on every iteration. The self-append lowering must instead build
// the three-part suffix once and hand it to `js_string_append`, whose
// unique-owner path grows the accumulator geometrically.
// prefix on every iteration. Building a three-part suffix first still
// creates short-lived garbage; the append-chain lowering must hand all
// four parts to one helper whose unique-owner path grows geometrically.
let value = add(
add(
add(Expr::LocalGet(1), Expr::String("[".to_string())),
Expand All @@ -359,13 +359,13 @@ fn a_self_append_chain_retains_the_accumulator_and_fuses_only_the_suffix() {
let ir = function_ir(module);

assert!(
ir.contains("call i64 @js_string_append_known_heap("),
ir.contains("call i64 @js_string_append_chain("),
"the growing prefix must reach the amortized append path:\n{ir}"
);
assert_eq!(
ir.matches("call i64 @js_string_concat_chain(").count(),
1,
"only the fixed-size suffix should use the n-way concat fold:\n{ir}"
0,
"the suffix must not be allocated before it is appended:\n{ir}"
);
}

Expand Down Expand Up @@ -423,7 +423,7 @@ fn a_self_append_chain_keeps_an_opaque_numeric_head_pair_intact() {
let ir = function_ir(module);

assert!(
!ir.contains("call i64 @js_string_append_known_heap("),
!ir.contains("call i64 @js_string_append_chain("),
"an opaque numeric-capable head pair must remain in source-tree order:\n{ir}"
);
}
Expand Down Expand Up @@ -455,8 +455,8 @@ fn a_module_global_self_append_uses_the_amortized_path_and_demotes_extractions()
let ir = function_ir(module);

assert!(
ir.contains("call i64 @js_string_append_known_heap("),
"a module root is binding storage and can retain the unique string owner:\n{ir}"
ir.contains("call i64 @js_string_append_chain("),
"a module root is binding storage and can retain the unique string owner across the fused chain:\n{ir}"
);
assert!(
ir.contains("call void @js_string_addref_if_heap_string("),
Expand Down
11 changes: 2 additions & 9 deletions crates/perry-codegen/src/expr/literals_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use perry_hir::{BinaryOp, Expr, UpdateOp};

use crate::lower_string_concat::{
can_lower_string_self_append, flatten_string_add_chain, lower_string_self_append,
lower_string_self_append_chain,
};
use crate::nanbox::double_literal;
use crate::native_value::MaterializationReason;
Expand Down Expand Up @@ -575,15 +576,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
None
};
if let Some(parts) = accumulator_parts {
let mut suffix = parts[1].clone();
for part in &parts[2..] {
suffix = Expr::Binary {
op: BinaryOp::Add,
left: Box::new(suffix),
right: Box::new((*part).clone()),
};
}
let v = lower_string_self_append(ctx, *id, &suffix)?;
let v = lower_string_self_append_chain(ctx, *id, &parts[1..])?;
emit_shadow_slot_update_for_expr(ctx, *id, &v, value);
super::record_native_arena_owner_assignment(ctx, *id, value.as_ref());
return Ok(v);
Expand Down
64 changes: 64 additions & 0 deletions crates/perry-codegen/src/lower_string_concat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,46 @@ pub(crate) fn lower_string_self_append(
lower_tag_dispatched_str_self_append(ctx, rhs, &target)
}

/// Lower `str = str + a + b + ...` without allocating the `a + b + ...`
/// suffix first. The first array element is an owner read of `str`, so it
/// retains the unique-string bit; the runtime either grows that value in
/// place or allocates the complete result once.
pub(crate) fn lower_string_self_append_chain(
ctx: &mut FnCtx<'_>,
local_id: u32,
suffix_parts: &[&Expr],
) -> Result<String> {
debug_assert!(suffix_parts.len() >= 2);
debug_assert!(suffix_parts.len() < CONCAT_CHAIN_MAX_PARTS);

let target = StringAppendTarget::for_local(ctx, local_id)
.ok_or_else(|| anyhow!("string self-append chain: local {} not in scope", local_id))?;
let lhs = target.load(ctx)?;
let lhs_collects = suffix_parts
.iter()
.any(|part| operand_may_collect(ctx, part));

with_rooted_group(ctx, suffix_parts.len() + 1, |ctx, group| {
let lhs_root = group.adopt_emitted(ctx, Repr::Boxed, &lhs, lhs_collects);
let mut suffix_roots = Vec::with_capacity(suffix_parts.len());
for (index, part) in suffix_parts.iter().enumerate() {
let later_collects = suffix_parts[index + 1..]
.iter()
.any(|later| operand_may_collect(ctx, later));
suffix_roots.push(group.lower(ctx, part, later_collects)?);
}

let mut values = Vec::with_capacity(suffix_parts.len() + 1);
values.push(group.reread_emitted(ctx, lhs_root));
for root in suffix_roots {
values.push(group.reread(ctx, root)?);
}
let result = emit_string_append_chain(ctx, &values);
target.store(ctx, &result)?;
Ok(result)
})
}

/// 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
Expand Down Expand Up @@ -752,3 +792,27 @@ pub(crate) fn emit_string_concat_chain(ctx: &mut FnCtx<'_>, parts: &[String]) ->
);
nanbox_string_inline(blk, &result_handle)
}

/// Emit the shared parts buffer for an accumulator chain. `parts[0]` is the
/// binding's owner read rather than an ordinary `LocalGet`, which is what lets
/// the runtime preserve unique ownership across loop iterations.
fn emit_string_append_chain(ctx: &mut FnCtx<'_>, parts: &[String]) -> String {
debug_assert!(parts.len() >= 3);
debug_assert!(parts.len() <= CONCAT_CHAIN_MAX_PARTS);

let n = parts.len();
let buf_reg = ctx.func.alloca_entry_array(DOUBLE, CONCAT_CHAIN_MAX_PARTS);
let blk = ctx.block();
for (i, val) in parts.iter().enumerate() {
let slot = blk.gep(DOUBLE, &buf_reg, &[(I64, &i.to_string())]);
blk.store(DOUBLE, val, &slot);
}
let base_i64 = blk.next_reg();
blk.emit_raw(format!("{} = ptrtoint ptr {} to i64", base_i64, buf_reg));
let result_handle = blk.call(
I64,
"js_string_append_chain",
&[(I64, &base_i64), (I32, &n.to_string())],
);
nanbox_string_inline(blk, &result_handle)
}
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
// second arg is the count. Returns a raw string handle.
// (`crates/perry-runtime/src/string.rs::js_string_concat_chain`)
module.declare_function("js_string_concat_chain", I64, &[I64, I32]);
// Self-append variant of the N-way chain. The first part is the binding's
// current owner value; the runtime may extend it in place when unique and
// otherwise writes the complete result in one allocation.
module.declare_function("js_string_append_chain", I64, &[I64, I32]);

// In-place append for the `x = x + y` pattern. When `x` has
// refcount=1 (unique owner), the runtime mutates in-place and
Expand Down
142 changes: 142 additions & 0 deletions crates/perry-runtime/src/string/concat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,148 @@ pub extern "C" fn js_string_concat_chain(parts: *const f64, n: i32) -> *mut Stri
}
}

/// N-way concat for `s = s + a + b + ...`, where `parts[0]` is an owner read
/// of `s`. An all-heap-string chain can copy the suffix pieces straight into a
/// unique accumulator, or allocate the complete result once when it cannot.
/// Other value shapes retain the ordinary concat-chain semantics.
#[no_mangle]
pub extern "C" fn js_string_append_chain(parts: *const f64, n: i32) -> *mut StringHeader {
let n = (n as usize).min(CONCAT_CHAIN_MAX_PARTS);
if n < 2 || parts.is_null() {
return js_string_concat_chain(parts, n as i32);
}
if n <= 4 {
append_chain_all_heap_strings::<4>(parts, n)
} else if n <= 8 {
append_chain_all_heap_strings::<8>(parts, n)
} else {
append_chain_all_heap_strings::<CONCAT_CHAIN_MAX_PARTS>(parts, n)
}
}

/// All-heap-string fast path for [`js_string_append_chain`]. Falling back to
/// `js_string_concat_chain` preserves dynamic/SSO coercion and the `s + s`
/// overlap case without adding those branches to the hot copy loop.
fn append_chain_all_heap_strings<const MAX_PARTS: usize>(
parts: *const f64,
n: usize,
) -> *mut StringHeader {
let mut piece_ptrs: [*const StringHeader; MAX_PARTS] = [std::ptr::null(); MAX_PARTS];
let mut piece_lens: [u32; MAX_PARTS] = [0; MAX_PARTS];
let mut total_blen = 0u32;
let mut total_u16 = 0u32;
let mut piece_flags = 0u32;

for i in 0..n {
let bits = unsafe { *parts.add(i) }.to_bits();
if bits >> 48 != 0x7FFF {
return js_string_concat_chain(parts, n as i32);
}
let piece = (bits & 0x0000_FFFF_FFFF_FFFF) as *const StringHeader;
if !is_valid_string_ptr(piece) || (i > 0 && piece == piece_ptrs[0]) {
return js_string_concat_chain(parts, n as i32);
}
let blen = unsafe { (*piece).byte_len };
piece_ptrs[i] = piece;
piece_lens[i] = blen;
total_blen = total_blen.saturating_add(blen);
total_u16 = total_u16.saturating_add(unsafe { (*piece).utf16_len });
piece_flags |= unsafe { (*piece).flags };
}

let dest = piece_ptrs[0] as *mut StringHeader;
let dest_blen = piece_lens[0];
let suffix_blen = total_blen.saturating_sub(dest_blen);
if suffix_blen == 0 {
return dest;
}

unsafe {
if (*dest).refcount == 1 && total_blen <= (*dest).capacity {
let mut cursor = (string_data(dest) as *mut u8).add(dest_blen as usize);
for i in 1..n {
let len = piece_lens[i] as usize;
ptr::copy_nonoverlapping(string_data(piece_ptrs[i]), cursor, len);
cursor = cursor.add(len);
}
(*dest).byte_len = total_blen;
(*dest).utf16_len = total_u16;
(*dest).flags |= piece_flags;
return if piece_flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 {
canonicalize_surrogate_pairs(dest)
} else {
dest
};
}
}

// An empty one-frame accumulator keeps exact capacity: no RSS-for-speed
// reserve. Once a non-empty accumulator grows, match js_string_append's
// existing geometric capacity so later iterations remain amortized.
let capacity = if dest_blen == 0 {
total_blen
} else {
total_blen.saturating_mul(2).max(32)
};

if let Some((result, cursor)) = string_storage_alloc_no_collect(capacity) {
return unsafe {
init_string_header(result, total_u16, total_blen, capacity, 1, piece_flags);
copy_heap_chain(&piece_ptrs, &piece_lens, n, cursor);
if piece_flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 {
canonicalize_surrogate_pairs(result)
} else {
result
}
};
}

let scope = crate::gc::RuntimeHandleScope::new();
let mut handles = [None; MAX_PARTS];
for i in 0..n {
handles[i] = Some(scope.root_string_ptr(piece_ptrs[i]));
}
let (result, mut cursor) = string_storage_alloc(capacity);
unsafe {
init_string_header(result, total_u16, total_blen, capacity, 1, piece_flags);
for i in 0..n {
let len = piece_lens[i] as usize;
if len == 0 {
continue;
}
handles[i]
.expect("append-chain string handle")
.with_const_ptr::<StringHeader, _>(|piece| {
ptr::copy_nonoverlapping(string_data(piece), cursor, len);
});
cursor = cursor.add(len);
}
if piece_flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 {
canonicalize_surrogate_pairs(result)
} else {
result
}
}
}

unsafe fn copy_heap_chain<const MAX_PARTS: usize>(
piece_ptrs: &[*const StringHeader; MAX_PARTS],
piece_lens: &[u32; MAX_PARTS],
n: usize,
mut cursor: *mut u8,
) {
for i in 0..n {
let len = piece_lens[i] as usize;
if len == 0 {
continue;
}
unsafe {
ptr::copy_nonoverlapping(string_data(piece_ptrs[i]), cursor, len);
cursor = cursor.add(len);
}
}
}

#[cfg(test)]
thread_local! {
/// #7912 counter: how many chains took the unrooted fast path below. A gate
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-runtime/src/string/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ pub(crate) use compare::{
js_string_key_bytes, js_string_key_matches, js_string_key_matches_bytes, utf16_cmp_bytes,
};
pub use concat::{
js_string_add_value, js_string_concat, js_string_concat_box, js_string_concat_chain,
js_string_concat_value, js_value_add_string, js_value_concat_string,
js_string_add_value, js_string_append_chain, js_string_concat, js_string_concat_box,
js_string_concat_chain, js_string_concat_value, js_value_add_string, js_value_concat_string,
};
pub(crate) use format::fix_exponent_format;
pub(crate) use format::js_format_f64;
Expand Down
Loading
Loading