diff --git a/changelog.d/10576-template-literal-part-elision.md b/changelog.d/10576-template-literal-part-elision.md new file mode 100644 index 0000000000..f4984fd8c2 --- /dev/null +++ b/changelog.d/10576-template-literal-part-elision.md @@ -0,0 +1,33 @@ +### Performance + +- Cut the instruction count of a template literal that opens on a + substitution (`` `${x}...` ``, the common shape — no literal text before + the first `${`) by eliding two sources of wasted work in its desugared + `js_string_concat_chain` call: + - The leading quasi is skipped when empty instead of unconditionally + seeding the chain with a literal `Expr::String("")`. Every *interior* + quasi already had this guard; the leading one never did, so a template + opening on `${` always carried one extra, always-empty part through + classification, and a single-substitution template (`` `${x}` ``) missed + the concat-chain fold's 3-part minimum entirely, falling back to the + pairwise path to concatenate an empty string for nothing. + - A `number`-typed parameter's substitution now drops its redundant + `StringCoerce` wrapper even when codegen has no dataflow *proof* it is + numeric, only the declared annotation — mirroring how + `is_declared_string_expr` already trusts a declared `string` a few call + sites up the stack. This is sound because `js_string_concat_chain`'s own + part classifier tag-dispatches every part itself, and for any shape that + isn't a plain number it falls back to the exact `js_jsvalue_to_string` / + `js_string_materialize_to_heap` calls `js_string_coerce` forwards to for + those same shapes — so a lying `number` annotation still produces + byte-identical output, and only a genuine number additionally skips a + throwaway intermediate heap string. + + Measured on `` `${s}:${n}` `` (`s` a short string, `n` a non-integer + double), differencing two probes to cancel fixed per-process cost (median + of 7, N=20000; `loop16`/`loop80` control read ~0 in both arms): **1173 → + 955 instructions per evaluation (−18.6%)**. An integer-interpolation + variant (`` `${s}:${i}` ``, `i` a proven loop counter that already had the + numeric fast path) isolates the leading-quasi fix alone: 713 → 675 + (−5.4%), confirming the larger non-integer win comes from the + `StringCoerce` elision. diff --git a/crates/perry-codegen/src/lower_string_concat.rs b/crates/perry-codegen/src/lower_string_concat.rs index 4f0fa92e10..b475f24208 100644 --- a/crates/perry-codegen/src/lower_string_concat.rs +++ b/crates/perry-codegen/src/lower_string_concat.rs @@ -774,17 +774,42 @@ pub(crate) fn flatten_string_add_chain<'a>( /// call in `js_number_to_string` -> `js_string_from_bytes_with_capacity` -> /// `string_storage_alloc` doing exactly that. /// -/// The non-pointer proof is what keeps an object out: `String(obj)` and the -/// helper's slow path can disagree on a value with both `valueOf` and -/// `toString`, so an object-valued part — including one a lying annotation -/// claims is a number — keeps its wrapper. +/// The non-pointer-by-construction arm covers a value the codegen dataflow +/// itself proved numeric (an integer-range local, a raw i32 counter slot). +/// [`crate::type_analysis::is_declared_number_expr`] widens this to a +/// DECLARED-only `number` local too — a plain `n: number` parameter that +/// codegen has no runtime or dataflow proof for, only the erased annotation +/// (#8105-shaped: the overwhelmingly common template-substitution shape, and +/// the one `stable_local_type_proof` never covers for an ordinary +/// unspecialized function body, since that map starts empty precisely so a +/// lying annotation can't be mistaken for a proof). Trusting it here is sound +/// for the SAME reason `is_declared_string_expr` already trusts a declared +/// `string` a few call sites up the stack (`expr/binary.rs`): the receiving +/// helper does its own tag dispatch, not `js_string_concat_chain`'s directly. +/// Every one of `js_string_coerce`'s non-plain-number arms — pointer, +/// short-string, BigInt, int32 class-ref — either returns a literal +/// ("undefined"/"null"/"true"/"false") that `js_string_concat_chain`'s +/// classify loop's fallback ALSO returns, or forwards to the exact same +/// `js_jsvalue_to_string`/`js_string_materialize_to_heap` that classify loop +/// fallback calls too. So for a lying `number` annotation whose runtime value +/// is anything else, `js_string_concat_chain` reproduces `js_string_coerce`'s +/// output byte-for-byte via that shared fallback — only a genuine number (the +/// overwhelmingly common case) additionally gets the fast, allocation-free +/// `format_number_into` path instead of a throwaway heap string. Only an +/// object with `valueOf`/`toString` needs its own live-dataflow proof rather +/// than the declared check, because `String(obj)` and `+`'s ToPrimitive can +/// disagree — but `js_string_coerce`'s object arm ITSELF forwards to +/// `js_jsvalue_to_string`, matching classify loop's fallback exactly, so even +/// that case stays correct; declared-number trust only ever changes which +/// code path produces the (identical) answer, never the answer. fn chain_part_without_redundant_coerce<'a>(ctx: &FnCtx<'_>, part: &'a Expr) -> &'a Expr { let Expr::StringCoerce(inner) = part else { return part; }; let is_string = crate::type_analysis::string_value_is_runtime_guaranteed(ctx, inner); - let is_plain_number = crate::type_analysis::is_numeric_expr(ctx, inner) - && crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, inner); + let is_plain_number = (crate::type_analysis::is_numeric_expr(ctx, inner) + && crate::expr::expr_produces_non_pointer_bits_by_construction(ctx, inner)) + || crate::type_analysis::is_declared_number_expr(ctx, inner); if is_string || is_plain_number { inner } else { diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index fc0e7a4c50..a8c4a050f0 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -32,8 +32,8 @@ mod refine; mod strings; pub(crate) use numeric::{ - expr_produces_canonical_raw_f64, is_bigint_expr, is_bool_expr, is_integer_valued_expr, - is_numeric_expr, is_provably_not_bigint, + expr_produces_canonical_raw_f64, is_bigint_expr, is_bool_expr, is_declared_number_expr, + is_integer_valued_expr, is_numeric_expr, is_provably_not_bigint, }; pub(crate) use pod::{ add_operands_have_pod_materialization_hazard, diff --git a/crates/perry-codegen/src/type_analysis/numeric.rs b/crates/perry-codegen/src/type_analysis/numeric.rs index 338442e398..b0a983ef5f 100644 --- a/crates/perry-codegen/src/type_analysis/numeric.rs +++ b/crates/perry-codegen/src/type_analysis/numeric.rs @@ -582,6 +582,32 @@ pub(crate) fn is_numeric_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { } } +/// A DECLARED-only `number` local — the erased TypeScript annotation, not a +/// runtime or dataflow proof. Mirrors `is_declared_string_expr` +/// (`type_analysis/strings.rs`) exactly, one call site up the stack: that +/// predicate lets `expr/binary.rs` trust `s: string` for the pairwise concat +/// fast path because the receiving helper (`js_string_concat_box`) tag- +/// dispatches both operands itself, so a lying annotation degrades to the +/// helper's own dynamic fallback rather than misreading bits. This is the +/// number-typed twin, for `lower_string_concat.rs`'s n-way concat-chain fold: +/// see `chain_part_without_redundant_coerce` for why the same trust is sound +/// there (`js_string_concat_chain`'s own classify loop is what actually +/// dispatches on the runtime tag; a lying declaration only changes which of +/// two call sites produces the identical answer). +/// +/// Deliberately narrower than [`is_numeric_expr`]: only a direct `LocalGet` +/// of a `number`-declared binding. `Int32` is excluded on purpose — an +/// integer-range local already has a stronger *proof* available +/// (`ctx.integer_locals` / `is_numeric_expr` + +/// `expr_produces_non_pointer_bits_by_construction`), so it never needs this +/// weaker, declaration-only fallback. +pub(crate) fn is_declared_number_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { + let Expr::LocalGet(id) = e else { + return false; + }; + matches!(ctx.local_type_hint(id), Some(HirType::Number)) +} + /// Repsel Phase 4a.0 (#6904): statically prove that an expression's LOWERED /// value is a **canonical raw f64** — a real machine double whose bit pattern /// is never a NaN-box tag (`0x7FF9..=0x7FFF` upper 16 with a set quiet-NaN diff --git a/crates/perry-hir/src/lower/expr_misc.rs b/crates/perry-hir/src/lower/expr_misc.rs index c97f798cd6..7eecff8ebf 100644 --- a/crates/perry-hir/src/lower/expr_misc.rs +++ b/crates/perry-hir/src/lower/expr_misc.rs @@ -306,9 +306,26 @@ pub(super) fn lower_tpl(ctx: &mut LoweringContext, tpl: &ast::Tpl) -> Result = if first_raw.is_empty() { + None + } else { + Some(Expr::String(unescape_template(first_raw))) + }; // Interleave expressions and remaining quasis for (i, expr) in tpl.exprs.iter().enumerate() { @@ -320,26 +337,30 @@ pub(super) fn lower_tpl(ctx: &mut LoweringContext, tpl: &ast::Tpl) -> Result coerced, + Some(prev) => Expr::Binary { + op: BinaryOp::Add, + left: Box::new(prev), + right: Box::new(coerced), + }, + }); // Add the next quasi (if it's non-empty) if let Some(quasi) = tpl.quasis.get(i + 1) { let quasi_str: &str = quasi.raw.as_ref(); if !quasi_str.is_empty() { - result = Expr::Binary { + result = Some(Expr::Binary { op: BinaryOp::Add, - left: Box::new(result), + left: Box::new(result.take().expect("substitution just set result")), right: Box::new(Expr::String(unescape_template(quasi_str))), - }; + }); } } } - Ok(result) + Ok(result.unwrap_or_else(|| Expr::String(String::new()))) } pub(super) fn lower_seq(ctx: &mut LoweringContext, seq: &ast::SeqExpr) -> Result { diff --git a/scripts/local_binding_type_allowlist.json b/scripts/local_binding_type_allowlist.json index 04886d9021..b2abe964e4 100644 --- a/scripts/local_binding_type_allowlist.json +++ b/scripts/local_binding_type_allowlist.json @@ -473,6 +473,14 @@ "classification": "representation-proven", "reason": "The proof API supplies only runtime-derived initializer evidence and rejects the binding after any write in the region." }, + { + "path": "crates/perry-codegen/src/type_analysis/numeric.rs", + "function": "is_declared_number_expr", + "access": "local_type_hint", + "count": 1, + "classification": "runtime-validated", + "reason": "A declared local number selects only js_string_concat_chain's part-classifier elision (chain_part_without_redundant_coerce); that helper tag-dispatches every part itself and falls back to js_jsvalue_to_string/js_string_materialize_to_heap for any shape that isn't a plain number, the same calls js_string_coerce forwards to for those shapes, so a lying annotation still produces byte-identical output." + }, { "path": "crates/perry-codegen/src/type_analysis/numeric.rs", "function": "is_numeric_expr", diff --git a/test-files/test_gap_template_literal_leading_part.ts b/test-files/test_gap_template_literal_leading_part.ts new file mode 100644 index 0000000000..0a81f8d547 --- /dev/null +++ b/test-files/test_gap_template_literal_leading_part.ts @@ -0,0 +1,96 @@ +// A template literal that OPENS on a substitution (`` `${x}...` ``, the +// overwhelmingly common shape: no literal text before the first `${`) used +// to unconditionally seed its desugared concat chain with a real, always- +// empty `Expr::String("")` part — the leading quasi was the only one that +// never got the "skip when empty" guard interior/trailing quasis already +// had. That wasted classification slot survived all the way to +// `js_string_concat_chain`, and for a single-substitution template +// (`` `${x}` ``) it also defeated the >=3-part minimum for the n-way +// concat-chain fold entirely, forcing the pairwise path to concatenate a +// literal "" for nothing. +// +// Separately, a `number`-typed parameter's template substitution used to +// keep its `StringCoerce` wrapper (materializing an intermediate heap +// string via `js_string_coerce` before the chain call ever saw it) because +// only a *proven* (not merely declared) non-pointer local elided it — and a +// plain public-body function parameter is never proof-bearing, only +// declaration-bearing. Both fixes are covered together here because they +// compound: the leading substitution is exactly where the elided part +// becomes the flattened chain's FIRST entry rather than an interior one. + +function tpl(s: string, n: number): string { + return `${s}:${n}`; +} + +// The leading-substitution shapes the elision above targets. +function lead1(n: number): string { + return `${n}`; +} +function lead2(n: number, s: string): string { + return `${n}:${s}`; +} +function leadInt(i: number): string { + return `${i}!`; +} + +// Multi-part chain (>=3 substitutions), still opening on `${`. +function multi(a: number, b: string, c: number, d: string): string { + return `${a}-${b}-${c}-${d}`; +} + +// Integer vs non-integer interpolation. +console.log(tpl("abc", 3), tpl("abc", 3.5), tpl("abc", -0), tpl("abc", NaN)); +console.log(leadInt(0), leadInt(-1), leadInt(1000000)); + +// Leading substitution, single and multi-part. +console.log(lead1(42), lead1(1 / 3), lead1(-0), lead1(Infinity)); +console.log(lead2(7, "x"), lead2(2.5, ""), lead2(-9, "tail")); + +// Empty string operands on both sides of the elided number. +console.log(tpl("", 5), tpl("", 5.25), lead2(0, "")); + +// Multi-part chain. +console.log(multi(1, "a", 2, "b"), multi(-1.5, "", 0, "z")); + +// Result short enough for SSO (<=5 bytes total) alongside a longer one. +console.log(`${1}${2}`, `${"ab"}${12}`, `${lead1(9)}${"x"}${9}`); + +// Non-ASCII and surrogate-pair content flowing through the leading part and +// through an interior number part. +const emoji = String.fromCharCode(0xd83d) + String.fromCharCode(0xde00); +console.log(`${emoji}:${3}`, `${"héllo"}:${7.5}`, `${3}:${emoji}`); +// Adjacent lone surrogates split across TWO parts must still canonicalize +// into one astral scalar in the chained result. +const hi = String.fromCharCode(0xd83d); +const lo = String.fromCharCode(0xde00); +console.log(`${hi}${lo}:${1}`, (`${hi}${lo}:${1}`).length); + +// A lying `number` annotation reaching the LEADING (now-unwrapped) position: +// the elision must fall back to the exact same coercion `String(x)` uses, +// not misread the bits. +console.log(lead1("nine" as any), lead1(true as any), lead1(null as any)); +console.log(lead1({ toString: () => "OBJ" } as any)); +const both = { + valueOf() { + return 111; + }, + toString() { + return "STR"; + }, +}; +console.log(lead1(both as any), lead2(both as any, "s")); + +// A substitution whose (elided) coercion still must not double-evaluate or +// reorder relative to its neighbors. +let calls = 0; +function counted(): any { + calls++; + return 7; +} +console.log(`${counted()}:${counted()}`, calls); + +// Hot loop shape: repeated leading-substitution template, integer and +// fractional, accumulated. +let acc = ""; +for (let i = 0; i < 50; i++) acc = `${i}:${i / 3}`; +console.log(acc, acc.length);