Skip to content

Strings: a short + chain no longer pays for a deferred representation it never takes - #3571

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3527-short-concat-eager
Sep 1, 2026
Merged

Strings: a short + chain no longer pays for a deferred representation it never takes#3571
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3527-short-concat-eager

Conversation

@lahma

@lahma lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

date-format-xparb pays +3.91% since #3386 (#3527), and the profile in that issue put the cost under AdditionChainExpression: RhpNewVariableSizeObject under the chain ×2.0, RhpNewPtrArrayFast ×3.3, allocation events +23%, while String.Concat — the cost the deferred copy was meant to displace — did not move at all. The workload allocates more under the new lane, not less.

The cutoff was never the problem

The issue proposed adding a length cutoff to the chain lane. There already is one, and it was already doing its job: JsString.MinDeferredConcatenationLength is 512 characters, and both the pairwise + (inside JsString.Concat) and the flattened chain (ConcatThree / ConcatFour / ConcatMany) test the summed operand lengths against it. xparb's chains are "2007-01-01" and "Monday, January 01, 2007 1:11:11 AM" — ten and thirty-five characters. They were on the eager side of the cutoff the entire time, and no threshold value can change that. Moving the line would only have pushed more chains onto the deferred path, which is the expensive one here.

What they paid for was the shape of the path below the line, which #3386 changed without meaning to:

before #3386 after #3386
operand coercion TypeConverter.ToString → a string TypeConverter.ToJsString → a string plus a JsString wrapper, for every operand that is not already a string
ConcatMany storage one string[] a JsString[] and a string[] built from it for string.Concat

xparb's "l, F d, Y g:i:s A" compiles to one fifteen-operand chain. Per evaluation that is a second 144-byte array plus a wrapper per numeric operand — getFullYear(), the hour expression — every byte of it dead before the result was copied out. That is the doubled variable-size allocations and the tripled pointer-array allocations the profile measured, and none of it existed before #3386.

What changed

An operand is now held in whichever form its coercion already produced: the JsString it was — whose representation has to survive, because flattening it is precisely the copy the deferred lane exists to avoid — or the plain text a non-string primitive coerced to. Both answer their length without materializing anything, and the length is all the cutoff is decided on, so holding the cheaper form until the decision is made gives nothing up.

Below the cutoff the result is then joined through a ValueStringBuilder over a cutoff-sized stack buffer — free, since the assembly carries [module: SkipLocalsInit], and it cannot grow because the cutoff bounds it — so there is no second array and no wrapper. Above the cutoff the fold through JsString.Concat is untouched.

The cutoff itself stays at 512, and its remarks now carry the arithmetic that was missing: a RopeString is 56 bytes on 64-bit and does not remove the flat allocation, it postpones it, so a result read once costs 56 bytes and an indirection more than copying it would have. The node earns them back only across iterations. Which is why the line sits far above the point where a node is merely affordable — and why what the path below the line allocates matters more than where the line is.

Every lane that can reach the deferred representation

JsString.Concat is the only producer of RopeString, and it has exactly two callers.

lane verdict
ApplyAdditionToPrimitives — the pairwise +, and the pairwise fold a numeric chain falls back to covered. Same 512 cutoff as before. Two operands that are already strings now take a branch that coerces nothing at all; the mixed pair (n + "x") coerces to text and builds the wrapper only on the branch that goes on to defer.
AdditionChainExpression — the flattened a + b + c […], both the direct path and the resumed one, which share the same three joins covered, as described above.
+=JintAssignmentExpression exempt. Builds JsString.ConcatenatedString, the StringBuilder-backed mutable accumulator. Never calls JsString.Concat, never produces a RopeString. #3386 did not touch it and neither does this.
String.prototype.concat exempt, for the same reason: EnsureCapacity / Append on ConcatenatedString.
JsString.CreateSlicedSlicedString out of scope. A different deferred representation (a zero-copy view) with its own cutoff and retention budget, not introduced by #3386 and not implicated by the profile.

Semantics

Unchanged, deliberately and checkably. Every path produces the same characters through the same JsString.Create, so the same shared instances are returned for the empty and single-character results, and the same representation decision is taken on the same numbers — typeof, .length, indexing, equality and hashing all see exactly what they saw. Coercion order is preserved (left operand before right; ToString of a symbol still throws from the same side), and the length guard still runs on the summed lengths before anything is built. No public API, no observable behaviour change, so no docs/v5-migration.md entry.

Failing first

AShortChainDoesNotPayForTheDeferredRepresentation measures bytes per evaluation of an eleven-operand short chain as a delta of GC.GetAllocatedBytesForCurrentThread, which says what a profile says without depending on what else the machine is doing. Measured against this branch and against main with only the three runtime files reverted:

framework main this branch
net10.0 576 B 272 B −52.8%
net8.0 576 B 272 B −52.8%
net472 712 B 296 B −58.4%

The ceiling is 400 B — 35% above the largest after-figure, 30% below the smallest before-figure — so it fails on every framework against unfixed code, and neither side is close enough to the line to be fragile.

The asymptotic guarantee #3386 exists for is pinned as before by AccumulatingWithPlusIsNoLongerQuadratic (allocation ratio at 4,000 vs 8,000 iterations under 3.0, plus a 16 MB absolute ceiling — the old shape allocated 640 MB), now extended with the two five-operand chain shapes that reach ConcatMany: s = s + chunk + chunk + chunk + chunk and s = chunk + chunk + chunk + chunk + s. Both stay linear.

Also added: the mixed coerced/string chain at every arity that has its own join (3, 4, 5, 6, 11) asserted character-for-character, and the two-operand mixed pair asserted on both sides of the cutoff — flat below it, RopeString above it in both leanings.

Verification

  • dotnet build -c Release — clean, 0 warnings, all five target frameworks.
  • Jint.Tests — 11,625 passed on net10.0 and net8.0, 8,245 on net472, 0 failed.
  • Jint.Tests.PublicInterface — 3,355 / 3,345 / 2,722 passed on net10.0 / net8.0 / net472, 0 failed.
  • Jint.Tests.CommonScripts — 28/28 on net10.0 and net472. These are the SunSpider scripts themselves, date-format-xparb included, run for correctness.
  • Jint.Tests.SourceGenerators — 71/71.
  • Jint.Tests.Test262 — 102,537 passed, 0 failed, 151 skipped of 102,688. Exactly the control.

Benchmarks — not run

Nothing here was measured with BenchmarkDotNet; this machine runs agents concurrently. The paired gates belong to whoever runs them, and these are the predictions the change should be judged against:

  1. date-format-xparb, string-tagcloud, crypto-md5 (SunSpider) — should recover. These are the three rows SunSpider: date-format-xparb pays +3.9% and bitops-3bit +1.8% for the deferred-copy string representation #3527 records as having paid, and all three are built from short concatenations; xparb is the one with a measured mechanism and should move most.
  2. ObjectRegExp (Dromaeo) — must keep Strings: a long + defers its copy, so s = s + x is linear like s += x #3386's −8.9% to −11.5%. Nothing on the deferring path changed: the cutoff, the fold and RopeString are all as they were, and a chain that crosses 512 characters takes the branch it took yesterday. A regression here would mean the operand-form change moved the cutoff decision, which is the one thing it is built not to do.
  3. StringConcatLargeBenchmark's Assign* lanes — flat. They accumulate past the cutoff within their first iterations and are deferred from then on.
  4. ChainSmallThree / ChainSmallSix — should improve slightly, being exactly the eager-chain shape this touches.
  5. The += rows (AppendSmallChunks, AppendLargeChunks, BuildLargeThenScan) — must be flat. Not one line on their path changed.

The other half of #3527

bitops-3bit-bits-in-byte — the +1.81% row — does not reproduce, and needs no code. The re-profile in the analysis comment found the candidate capture had fewer total samples than base (150,057 vs 152,123) with no function moved beyond ±0.3%; the row contains no string work in its loop, and the original figure was measured with a virus scanner active. The issue's own framing allows "not reproducible" as an outcome for that half, which is why this closes the issue rather than leaving it open on that row.

Fixes #3527.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

…on it never takes

sebastienros#3386 gave a long `+` a deferred copy, and SunSpider's date-format-xparb
row paid 3.9% for it. The cutoff that was supposed to keep short
concatenations out of that trade was already there and already working —
`JsString.MinDeferredConcatenationLength`, 512 characters, honoured by
both the pairwise `+` and the flattened chain — and xparb's chains are
ten to thirty-five characters, so they were on the eager side of it the
whole time. What they paid for was not the deferral. It was the shape of
the eager path.

Two things had moved onto it. The chain lane coerced every operand with
`TypeConverter.ToJsString` before the cutoff was read, which allocates a
`JsString` wrapper for every operand that is not already a string — a
number, which is most of what a formatted date is made of — and the
eager join then unwrapped every one of them again. And `ConcatMany`
built a `string[]` to hand to `string.Concat` on top of the `JsString[]`
the operands already lived in, so a chain allocated two arrays where the
pre-sebastienros#3386 lane allocated one. For xparb's fifteen-operand long-format
chain that is a second 144-byte array plus a wrapper per numeric
operand, all of it dead before the result was copied out: the doubled
variable-size allocations and the +23% allocation events the profile in
sebastienros#3527 measured under the chain node.

An operand is now carried in whichever form its coercion already
produced — the `JsString` it was, whose representation has to survive
because flattening it is the copy the deferred lane exists to avoid, or
the plain text a non-string primitive coerced to. Both answer their
length without materializing anything, which is all the cutoff is
decided on, so nothing is given up by holding the cheaper form until the
decision is made. Below the cutoff the result is then joined through a
`ValueStringBuilder` over a cutoff-sized stack buffer, which the
assembly's `SkipLocalsInit` leaves unzeroed, so there is no second array
and no wrapper. Above it the fold is unchanged.

The pairwise `+` gets the same treatment from the other end: two operands
that are already strings — a `+` between two string expressions — now
take a branch that coerces nothing at all, and the mixed pair (`n + "x"`)
coerces to text and builds the wrapper only if it goes on to defer.

`+=` and `String.prototype.concat` are untouched and exempt: both build
`JsString.ConcatenatedString`, the mutable builder, and never enter the
deferred representation at all.

Bytes per evaluation of an eleven-operand short chain, as a delta of a
thread-local allocation counter: 576 to 272 on net10.0 and net8.0, 712
to 296 on net472. `AShortChainDoesNotPayForTheDeferredRepresentation`
pins it at 400, between the two with a third of the distance on either
side; the asymptotic guarantee sebastienros#3386 exists for is pinned as before by
`AccumulatingWithPlusIsNoLongerQuadratic`, now including the five-operand
chain shapes that reach `ConcatMany`.

Fixes sebastienros#3527.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma

lahma commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Gate verdict: PASS on all three measurements (paired, quiet box, baseline = merge-base a50731039, 95% CI must exclude zero).

SunSpider, 26 rows, 8 rounds: zero regressions. string-fasta −1.18 [−2.70, −0.02] FASTER; the three rows this PR targets all lean the right way against a main that already carries the downstream workarounds: date-format-xparb −1.88, crypto-md5 −0.68, string-tagcloud +0.05. Full table in the gate log.

Dromaeo, 24 rows, 8 rounds: 23 no-change, and one borderline flag — ObjectRegExp[True,False] +1.35 [+0.05, +3.57] — whose final rounds ran while an unrelated workload held one core. Re-measured as its own 12-round pair on a fully quiet box:

row median % 95% CI sign verdict
ObjectRegExp[False,False] +1.45 [−0.48, +3.63] 8/12 no change
ObjectRegExp[False,True] −0.72 [−2.64, +1.06] 6/12 no change
ObjectRegExp[True,False] +0.53 [−1.58, +3.38] 6/12 no change
ObjectRegExp[True,True] +0.88 [−0.50, +1.30] 7/12 no change

The flag does not reproduce under clean conditions — #3386's deferred-copy wins hold, exactly as the deferring branch being byte-identical predicts. With the allocation evidence in the PR body (−52.8%/−58.4% bytes per short-chain evaluation) and every suite at its control, this merges.

@lahma
lahma merged commit ef706d7 into sebastienros:main Sep 1, 2026
7 checks passed
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.

SunSpider: date-format-xparb pays +3.9% and bitops-3bit +1.8% for the deferred-copy string representation

1 participant