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
1 change: 1 addition & 0 deletions changelog.d/13450-jst5-string-concat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
String concatenation is cheaper: literal-aware concat folds image literals without re-reading headers, operand checks are inlined at the call site, and array push of freshly built strings uses a pointer-only gate. Allocation and GC schedules are unchanged.
165 changes: 160 additions & 5 deletions crates/perry-codegen/src/codegen/declared_string_add_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ fn a_string_literal_operand_keeps_the_fused_concat() {
add(Expr::String("item_".to_string()), Expr::LocalGet(1)),
);
assert!(
ir.contains("call double @js_string_concat_value_box("),
ir.contains("call double @js_string_concat_value_box(")
|| ir.contains("call double @js_string_concat_lit_value_box("),
"a proven string must keep the fused concat (the `_box` twin, which \
returns SSO for short results) — the guard is for claims, not for \
proofs:\n{ir}"
Expand Down Expand Up @@ -290,6 +291,13 @@ fn a_string_method_name_on_an_unproven_receiver_is_guarded() {

// -------------------------------------------------------------- chain fold

/// The N-way fold's runtime entry: the generic chain, or the literal-operand
/// entry that carries literals as constant data (JST slice 3).
fn has_nway_fold(ir: &str) -> bool {
ir.contains("call i64 @js_string_concat_chain(")
|| ir.contains("call i64 @js_string_concat_parts(")
}

#[test]
fn a_chain_whose_head_pair_is_all_declared_does_not_fold() {
// `s + t + "x"`. `js_string_concat_chain` formats EVERY part as a string,
Expand All @@ -304,7 +312,8 @@ fn a_chain_whose_head_pair_is_all_declared_does_not_fold() {
),
);
assert!(
!ir.contains("call i64 @js_string_concat_chain("),
!ir.contains("call i64 @js_string_concat_chain(")
&& !ir.contains("call i64 @js_string_concat_parts("),
"the head pair carries no proof, so the N-way fold is unsound \
here:\n{ir}"
);
Expand All @@ -323,7 +332,7 @@ fn a_chain_led_by_a_literal_still_folds() {
),
);
assert!(
ir.contains("call i64 @js_string_concat_chain("),
has_nway_fold(&ir),
"a proven string in the head pair keeps the N-way fold:\n{ir}"
);
}
Expand All @@ -339,7 +348,7 @@ fn a_chain_whose_second_part_is_proven_still_folds() {
),
);
assert!(
ir.contains("call i64 @js_string_concat_chain("),
has_nway_fold(&ir),
"`s + \",\" + t` concatenates at every node whatever `s` holds:\n{ir}"
);
}
Expand Down Expand Up @@ -375,7 +384,8 @@ fn a_self_append_chain_fuses_the_accumulator_and_suffix() {
"the growing prefix must reach the amortized append path:\n{ir}"
);
assert_eq!(
ir.matches("call i64 @js_string_concat_chain(").count(),
ir.matches("call i64 @js_string_concat_chain(").count()
+ ir.matches("call i64 @js_string_concat_parts(").count(),
0,
"the suffix must not be allocated before it is appended:\n{ir}"
);
Expand Down Expand Up @@ -759,3 +769,148 @@ fn annotation_only_numeric_assignment_keeps_string_alias_guard() {
"{ir}"
);
}

// -------------------------------------- JST slice 3: operand checks on live bits

/// Text of every block whose label starts with `prefix`.
fn blocks_named(ir: &str, prefix: &str) -> Vec<String> {
let mut blocks = Vec::new();
let mut current: Option<Vec<&str>> = None;
for line in ir.lines() {
if let Some(label) = line.strip_suffix(':') {
if !label.starts_with(char::is_whitespace) {
if let Some(done) = current.take() {
blocks.push(done.join("\n"));
}
if label.starts_with(prefix) {
current = Some(Vec::new());
}
continue;
}
}
if let Some(body) = current.as_mut() {
body.push(line);
}
}
if let Some(done) = current.take() {
blocks.push(done.join("\n"));
}
blocks
}

fn literal_chain(middle: Expr, params: Vec<Param>) -> String {
ir(
params,
add(
add(Expr::String("item-".to_string()), middle),
Expr::String(";".to_string()),
),
)
}

#[test]
fn literal_parts_get_no_symbol_check_and_the_value_part_is_tested_inline() {
// `"item-" + n + ";"` with a declared-only `n`: ToPrimitive and the Symbol
// check each have exactly one site, for the one value part, and each sits
// in a block reached only through the inline test of the live bits.
let ir = literal_chain(Expr::LocalGet(1), vec![param(1, "n", Type::Number)]);
assert!(ir.contains("call i64 @js_string_concat_parts("), "{ir}");
assert_eq!(
ir.matches("call void @js_add_throw_if_symbol(").count(),
1,
"only the value part can be a Symbol:\n{ir}"
);
assert_eq!(
ir.matches("call double @js_to_primitive_default_for_add(")
.count(),
1,
"{ir}"
);
let prim = blocks_named(&ir, "cat.prim.call");
let sym = blocks_named(&ir, "cat.sym.call");
assert_eq!(prim.len(), 1, "{ir}");
assert_eq!(sym.len(), 1, "{ir}");
assert!(
prim[0].contains("@js_to_primitive_default_for_add("),
"{}",
prim[0]
);
assert!(sym[0].contains("@js_add_throw_if_symbol("), "{}", sym[0]);
// The test is on the value's own bits: plain number, STRING_TAG, SSO tag.
assert!(
ir.contains("9223372036854775807"),
"plain-number mask missing:\n{ir}"
);
assert!(
ir.contains(", 32767") && ir.contains(", 32761"),
"string/SSO tag tests missing:\n{ir}"
);
// Literals travel as the address of their image-resident text + lengths.
assert_eq!(
ir.matches(
"ptrtoint (ptr getelementptr (i8, ptr getelementptr (i8, ptr @perry_image_literal_"
)
.count(),
2,
"both literals are passed as constant data:\n{ir}"
);
// The literals are still lowered in source position (pool interning order
// is unchanged), but nothing consumes their handle loads.
for handle in ir.lines().filter(|l| l.contains(".handle")) {
let reg = handle.trim().split(" = ").next().unwrap();
assert_eq!(
ir.matches(reg).count(),
1,
"a literal's handle load must be unused ({reg}):\n{ir}"
);
}
}

#[test]
fn an_operand_that_cannot_be_a_symbol_carries_no_check_at_all() {
// `"a" + n + 5 + "z"`: the integer is non-pointer by construction.
let ir = ir(
vec![param(1, "n", Type::Number)],
add(
add(
add(Expr::String("a".to_string()), Expr::LocalGet(1)),
Expr::Integer(5),
),
Expr::String("z".to_string()),
),
);
assert!(ir.contains("call i64 @js_string_concat_parts("), "{ir}");
assert_eq!(
ir.matches("call void @js_add_throw_if_symbol(").count(),
1,
"only `n` needs the check:\n{ir}"
);
}

#[test]
fn the_symbol_and_conversion_calls_are_never_on_the_straight_line() {
// Sabotage guard: an unconditional call (the inline test dropped) puts the
// call outside every `cat.*.call` block.
let ir = literal_chain(Expr::LocalGet(1), vec![param(1, "n", Type::Any)]);
let mut in_call_block = false;
for line in ir.lines() {
if let Some(label) = line.strip_suffix(':') {
if !label.starts_with(char::is_whitespace) {
in_call_block =
label.starts_with("cat.prim.call") || label.starts_with("cat.sym.call");
continue;
}
}
if line.contains("@js_add_throw_if_symbol(")
|| line.contains("@js_to_primitive_default_for_add(")
{
if line.trim_start().starts_with("declare") {
continue;
}
assert!(
in_call_block,
"call outside its inline-test arm: {line}\n{ir}"
);
}
}
}
1 change: 1 addition & 0 deletions crates/perry-codegen/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ fn lower_module(
.unwrap_or(&module_prefix);
let mut strings = StringPool::with_prefix(module_prefix.clone());
strings.set_thread_literal_callback_prefix(thread_literal_callback_prefix.clone());
strings.set_image_literals(opts.output_type != "dylib" && opts.output_type != "staticlib");
strings.tdz_binding_names = tdz_names::collect(hir);
// #5247: install per-module source-location context for the dynamic
// call-dispatch throw path, but only under `--debug-symbols` (which sets
Expand Down
68 changes: 65 additions & 3 deletions crates/perry-codegen/src/expr/array_push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,72 @@ fn emit_dynamic_pointer_push_store(
let done_idx = ctx.new_block("apush.pointer_layout.done");
let bookkeeping_label = ctx.block_label(bookkeeping_idx);
let done_label = ctx.block_label(done_idx);
{
// A heap string is pointer-bearing exactly like an object pointer, so it
// is admitted by the same header proof; what it still owes is the shared-
// string demote, and only when the string is not already shared.
// `layout_pointer_bearing_bits` accepts a `STRING_TAG` payload iff it is
// non-zero; the `>= 0x1000` floor is what makes the refcount read below
// safe, and a string that fails it takes the full sequence.
let admit_strings = string_addref_needed;
// Created only for the string arm: an unterminated block is invalid IR.
let (fast_idx, string_addref_idx, string_demote_idx) = if admit_strings {
(
ctx.new_block("apush.pointer_layout.fast"),
ctx.new_block("apush.pointer_layout.str_addref"),
ctx.new_block("apush.pointer_layout.str_demote"),
)
} else {
(done_idx, done_idx, done_idx)
};
let fast_label = ctx.block_label(fast_idx);
let string_addref_label = ctx.block_label(string_addref_idx);
let string_demote_label = ctx.block_label(string_demote_idx);
let (is_heap_string, payload) = {
let blk = ctx.block();
let top16 = blk.lshr(I64, &value_bits, "48");
let is_object_pointer = blk.icmp_eq(I64, &top16, crate::nanbox::POINTER_TAG_TOP16_I64);
let is_heap_string = blk.icmp_eq(I64, &top16, crate::nanbox::STRING_TAG_TOP16_I64);
let payload = blk.and(I64, &value_bits, crate::nanbox::POINTER_MASK_I64);
let admitted_value = if admit_strings {
let payload_valid = blk.icmp_uge(I64, &payload, "4096");
let string_ok = blk.and(I1, &is_heap_string, &payload_valid);
blk.or(I1, &is_object_pointer, &string_ok)
} else {
is_object_pointer
};
let proof_bits = blk.and(I16, object_flags, ARRAY_PUSH_POINTER_LAYOUT_MASK_I16);
let pointer_layout = blk.icmp_eq(I16, &proof_bits, ARRAY_PUSH_POINTER_LAYOUT_EXPECT_I16);
let fast = blk.and(I1, &is_object_pointer, &pointer_layout);
blk.cond_br(&fast, &done_label, &bookkeeping_label);
let fast = blk.and(I1, &admitted_value, &pointer_layout);
if admit_strings {
blk.cond_br(&fast, &fast_label, &bookkeeping_label);
} else {
blk.cond_br(&fast, &done_label, &bookkeeping_label);
}
(is_heap_string, payload)
};

if admit_strings {
ctx.current_block = fast_idx;
ctx.block()
.cond_br(&is_heap_string, &string_addref_label, &done_label);
ctx.current_block = string_addref_idx;
{
let blk = ctx.block();
// `StringHeader.refcount` (offset 12): 0 is already shared. A fresh
// concat result is born shared, so the helper runs only for a
// uniquely-owned string that must be demoted.
let refcount_addr = blk.add(I64, &payload, "12");
let refcount_ptr = blk.inttoptr(I64, &refcount_addr);
let refcount = blk.load(I32, &refcount_ptr);
let unique = blk.icmp_ne(I32, &refcount, "0");
blk.cond_br(&unique, &string_demote_label, &done_label);
}
ctx.current_block = string_demote_idx;
{
let blk = ctx.block();
blk.call_void("js_string_addref_if_heap_string", &[(DOUBLE, value_double)]);
blk.br(&done_label);
}
}

ctx.current_block = bookkeeping_idx;
Expand Down Expand Up @@ -1873,3 +1931,7 @@ mod parent_gate_tests {
);
}
}

#[cfg(test)]
#[path = "array_push_string_gate_tests.rs"]
mod string_gate_tests;
Loading
Loading