Skip to content

Perf #857: promote append-only string locals to a StringBuilder slot#870

Merged
nickna merged 1 commit into
mainfrom
wrk/issue-857-string-accumulator
Jun 21, 2026
Merged

Perf #857: promote append-only string locals to a StringBuilder slot#870
nickna merged 1 commit into
mainfrom
wrk/issue-857-string-accumulator

Conversation

@nickna

@nickna nickna commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Part of #856 (perf epic). Closes the largest remaining compiled-vs-Node gap: strings.

Problem

A string local built by repeated s = s + str lowered to String.Concat(string,string) per iteration, copying the whole accumulator each time — O(n²). Re-measured warm (content-forced), this was the dominant remaining gap to Node:

strings @10k (warm) Compiled (before) Node Slowdown
concat-only 18.0 ms 0.05 ms 337×
full benchmark 17.3 ms 0.12 ms 149×

Scaling (5k/10k/20k/40k = 6.5/17.8/74/351 ms) confirmed O(n²) vs Node's O(n) cons-strings — the gap grows unbounded (1340× @40k). StringConcatOptimizer only flattens intra-expression chains; loop accumulation was unhandled.

Fix

Promote a provably non-escaping string local with a string-literal initializer, used only via s = s + str/s += str (statement position), s.length, and s.charCodeAt(i), to a concrete StringBuilder slot:

  • append → amortized-O(1) Append
  • .lengthget_Length, charCodeAt(i) → the [int] indexer (both UTF-16 code units = JS semantics), OOB → NaN
  • no materialization needed for these uses

O(n²) → O(n): bundled strings.ts @10k 17.3 ms → 0.265 ms (65×), now ~2.3× Node. IL verifies.

Design notes

  • New StringAccumulatorPromotionAnalyzer enforces the conservative escape rule (any return/arg-pass/index/other-method/comparison/reassignment/non-string-append/capture, or an append used as a value, disqualifies).
  • Append must be in statement positions = s + E evaluates to the new string, which a StringBuilder slot can't produce without an O(n) ToString; as a statement the result is discarded.
  • Candidacy is keyed per function scope, NOT whole-program-per-lexeme like ArrayLocalPromotionAnalyzer — a clean s must not be poisoned by an unrelated escaping s in another bundled module (e.g. perf_hooks's const s = findMark(...)). Cross-scope references are captures, caught by IsVariableCaptured. ArrayLocalPromotionAnalyzer has the same latent bug (dodged by uncommon names) — a follow-up should port this per-scope keying there.
  • Use-site fast paths key off the slot's CLR type, so they never misfire for a captured/object local. StringBuilder is pure BCL — no standalone-DLL dependency introduced.
  • Phase 2 (materialize-on-escape for return s/pass/index via a cached companion slot) is deferred. Plan: docs/plans/issue-857-string-accumulator-stringbuilder.md.

Tests

  • New StringAccumulatorPromotionTests.cs — 16 cases (both modes): positive append/length/charCodeAt, OOB→NaN, the per-scope name-collision case, and fallback-correctness for return/reassign/capture/non-string-append.
  • Full dotnet test: 13967 passed, 0 real regressions. The only failures are pre-existing flaky network tests (pass in isolation) and the two documented stale Test262 baselines (drift is Array.isArray/proxy, present in interpreter mode too → unrelated to this compile-only change).
  • SharpTS.TypeScriptConformance is type-checker-only and unaffected by this IL-emitter change.

In compiled mode, a string local built by repeated `s = s + str` lowered to
`String.Concat(string,string)` per iteration, copying the whole accumulator each
time — O(n²). The strings benchmark was the largest remaining gap to Node (~149×
warm @10k; concat-only 337×, growing unbounded vs Node's O(n) cons-strings).

Promote a provably non-escaping `string` local with a string-literal initializer,
used ONLY via `s = s + str`/`s += str` (statement position), `s.length`, and
`s.charCodeAt(i)`, to a concrete StringBuilder slot: append → amortized-O(1)
Append, length → get_Length, charCodeAt → the [int] indexer (both UTF-16 code
units, identical to JS), out-of-range charCodeAt → NaN. No materialization for
these uses. Turns O(n²) into O(n): bundled strings@10k 17.3ms → 0.265ms (65×),
now ~2.3× Node. IL verifies.

New StringAccumulatorPromotionAnalyzer enforces the conservative escape rule
(any return, arg-pass, index, other method/property, comparison, reassignment,
non-string append, capture, or an append used as a value disqualifies). Append
must be in statement position because `s = s + E` evaluates to the new string,
which a StringBuilder slot cannot produce without an O(n) ToString — as a
statement the result is discarded.

Candidacy is keyed PER FUNCTION SCOPE, not whole-program-per-lexeme like
ArrayLocalPromotionAnalyzer: a clean `s` in one function must not be poisoned by
an unrelated escaping `s` in another module (e.g. perf_hooks's `const s`) in a
bundle. Cross-scope references are captures, caught by the IsVariableCaptured
guard. (ArrayLocalPromotionAnalyzer has the same latent bug, dodged by uncommon
names — a follow-up should port this per-scope keying there.)

Phase 2 (materialize-on-escape for `return s`/pass/index via a cached companion
slot) is deferred. Standalone-DLL constraint preserved (StringBuilder is pure
BCL). Use-site fast paths key off the slot's CLR type, so they never misfire for
a captured/object local. Plan: docs/plans/issue-857-string-accumulator-stringbuilder.md

Green on dotnet test (16 new StringAccumulatorPromotionTests, both modes) except
the two pre-existing stale/flaky Test262 baselines.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant