From d691c52070434bf5ca336773465fc16783631ef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 10:05:40 +0200 Subject: [PATCH] perf(hir): constant-fold adjacent ASCII string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two string literals next to each other are a compile-time constant, but nothing downstream could fold them: the pair lowers to a runtime concat call, and LLVM — where numeric literal folding happens — cannot see through it. Measured on the mini: "id-" + "x" cost 8.11 ns per evaluation and "a" + "b" + "c" cost 19.71 (the cost scales with the number of literals), against 0.49 and 0.52 in node; a single string literal is 1.25 and 3 + 4 folds to the empty-loop baseline. Fold in the HIR Add arm when both operands are string literals: "id-" + "x" 8.11 -> 1.25 ns (= single-literal baseline) "a" + "b" + "c" 19.71 -> 1.56 ns Left-associativity folds whole chains for free: "a" + "b" + "c" parses as ("a" + "b") + "c", so the inner pair is already String("ab") when the outer one runs. ASCII-only, deliberately. A non-ASCII pair needs the runtime's WTF-8 rules — canonicalize_surrogate_pairs merges a high/low surrogate pair ACROSS the join into one astral code point (#6728), and utf16_len / isWellFormed depend on that — so those keep the runtime path rather than have the compiler restate the rules. Differential vs node (folded and unfolded pairs, empty strings, mixed literal/variable, number/boolean/null/undefined coercions, accented and CJK pairs, an astral surrogate pair, a lone surrogate, escapes and quotes inside literals, charCodeAt/slice/indexOf/repeat on the result, computed property keys, the in operator, object indexing, switch discriminant, template literals, array join): identical to a build without the fold, and identical to node except one pre-existing lone-surrogate print that main produces too. Claude-Session: https://claude.ai/code/session_01F1dt1jfzK2cheMZyus6y6p --- .../perry-hir/src/lower/lower_expr/arm_bin.rs | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/perry-hir/src/lower/lower_expr/arm_bin.rs b/crates/perry-hir/src/lower/lower_expr/arm_bin.rs index 91a662fd1f..738e4a9918 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_bin.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_bin.rs @@ -221,11 +221,38 @@ pub(crate) fn lower_bin_expr(ctx: &mut LoweringContext, bin: &ast::BinExpr) -> R match bin.op { // Arithmetic - ast::BinaryOp::Add => Ok(Expr::Binary { - op: BinaryOp::Add, - left, - right, - }), + ast::BinaryOp::Add => { + // Two adjacent string literals are a compile-time constant, but + // nothing downstream could fold them: the pair lowers to a + // runtime concat call, which LLVM cannot see through (numeric + // literal folding happens there and is why `3 + 4` costs the + // empty loop while `"a" + "b"` cost 8.1 ns/iteration and + // `"a" + "b" + "c"` cost 19.7 — versus 0.5 in node). + // + // Left-associativity makes this fold the whole chain: `"a" + "b" + // + "c"` parses as `("a" + "b") + "c"`, and the inner pair has + // already been folded to `String("ab")` by the time this runs. + // + // ASCII-only, deliberately. A non-ASCII pair would need the + // runtime's WTF-8 rules — `canonicalize_surrogate_pairs` merges a + // high/low surrogate pair across the join into one astral code + // point (#6728), and `utf16_len` / `isWellFormed` depend on that. + // Rather than restate those rules here, non-ASCII literals keep + // the runtime path they have today. + if let (Expr::String(l), Expr::String(r)) = (left.as_ref(), right.as_ref()) { + if l.is_ascii() && r.is_ascii() { + let mut folded = String::with_capacity(l.len() + r.len()); + folded.push_str(l); + folded.push_str(r); + return Ok(Expr::String(folded)); + } + } + Ok(Expr::Binary { + op: BinaryOp::Add, + left, + right, + }) + } ast::BinaryOp::Sub => Ok(Expr::Binary { op: BinaryOp::Sub, left,