Skip to content

Close perf regressions vs v4.8.0: recursion pool + JsString primitive read alloc#2450

Merged
lahma merged 3 commits into
sebastienros:mainfrom
lahma:perf-recursion-and-string-primitive
May 3, 2026
Merged

Close perf regressions vs v4.8.0: recursion pool + JsString primitive read alloc#2450
lahma merged 3 commits into
sebastienros:mainfrom
lahma:perf-recursion-and-string-primitive

Conversation

@lahma

@lahma lahma commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Three small regression fixes uncovered by a SunSpider/Dromaeo/Stopwatch sweep of main against tag v4.8.0.

Details

1. Skip FunctionEnvironment pool for direct-recursive functions

The pool added by #2413 is a single slot per JintFunctionDefinition.State guarded by Interlocked.Exchange. For tight self-recursion (ack/fib/tak), only the topmost frame can ever be cached — every recursive call beyond that allocates anyway, so the 4 atomic ops per call become pure overhead.

Detect direct named self-call at AST analysis (SelfCallAstVisitor walks the function body for CallExpression with callee Identifier matching the function's own name) and gate the pool path on a new IsDirectRecursive flag in JintEnvironment.NewFunctionEnvironment and ScriptFunction.Call.

SunSpider controlflow-recursive: 63.56 ms → 62.28 ms (separate isolated run measured 60.79 ms; v4.8.0 baseline 58.71 ms). Trade-off: allocations on direct-recursive functions return to fresh-per-call, since the pool is intentionally bypassed.

2. Avoid StringInstance alloc per primitive property read

PR #2412's inline-cache fast path added baseValue.GetV(...) for non-ObjectInstance receivers. For JsString primitives this routes through TypeConverter.ToObject, which calls intrinsics.String.Construct(jsString) and allocates a fresh StringInstance per read. Hot path: any for (var i = 0; i < s.length; i++) re-reads s.length every iteration.

Mirror Engine.TryHandleStringValue's primitive-aware lookup inline: length is intrinsic, everything else lives on String.prototype and accepts the primitive as this without wrapping.

Bench main branch Δ
Dromaeo StringBase64 (avg of 4 cases) ~6.12 MB ~3.62 MB -40.8% (matches v4.8.0 byte-for-byte)
SunSpider string-base64 8.83 MB 5.07 MB -42.6% (matches v4.8.0 byte-for-byte)

Knock-on alloc reductions of 4-14% on crypto-md5/sha1, string-fasta, string-validate-input, date-format-xparb, Dromaeo ObjectRegExp. Time wins of 4-10% on Dromaeo ObjectRegExp and ObjectString.

3. _properties!.AddDangerous(...) for hand-rolled additions in source-gen prototypes

Mirrors the pattern already used in GlobalObject.Properties.cs: after CreateProperties_Generated(), _properties is the HybridDictionary wrapper around the generator-built dict; AddDangerous skips both duplicate-key probing and SetOwnProperty's validation pipeline.

Four prototypes converted: StringPrototype (trimLeft/trimRight aliases), DatePrototype (toGMTString), RegExpPrototype (exec — hand-registered to keep HasDefaultRegExpExec's reference identity), IntrinsicTypedArrayPrototype (toString alias to Array.prototype.toString). Each gets [JsObject(ExtraCapacity = N)] so the additions don't trigger a dict resize. Symbol-key additions stay as SetOwnProperty because they go to the separate _symbols dict.

Headline numbers (full BDN run, defaults, Ryzen 9 5950X / .NET 10)

35 of 54 measured cases improved vs main; no regression on the previously-large wins from main (string-tagcloud still 10× faster than v4.8.0; Stopwatch alloc still ~46% lower).

v4.8.0 main branch
SunSpider string-base64 time 51.66 ms 45.76 ms 44.32 ms
SunSpider string-base64 alloc 5.07 MB 8.83 MB 5.07 MB
Dromaeo StringBase64 F,F alloc 3.67 MB 6.16 MB 3.66 MB
Dromaeo ObjectRegExp F,T time 96.99 ms 99.90 ms 89.61 ms
SunSpider controlflow-recursive time 58.71 ms 63.56 ms 62.28 ms

Linked issue

Refs the perf comparison surfaced in this PR's commits.

Test plan

  • dotnet test --configuration Release Jint.Tests/Jint.Tests.csproj — 2842/2889 pass on net472/net10.0
  • dotnet test --configuration Release Jint.Tests.CommonScripts/Jint.Tests.CommonScripts.csproj — 28/28 pass
  • Full SunSpider + Dromaeo + Stopwatch benchmark sweep with BenchmarkDotNet defaults — 35/54 cases improved vs main, no regression on the previously-validated main wins
  • Jint.Tests.Test262 — not run locally; CI will catch any spec divergence

Breaking change?

No. All changes are internal to interpreter/runtime fast paths; observable behavior is unchanged. The Fix 1 case I considered (parser-side OnNode removal, which would have been a behavior change for Function.prototype.toString() on non-prepared scripts) was withdrawn after the apparent regression turned out to be measurement variance.

🤖 Generated with Claude Code

@lahma
lahma force-pushed the perf-recursion-and-string-primitive branch from 98eef8d to 9d48bf5 Compare May 3, 2026 12:05
@lahma

lahma commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a fix for the linux-ARM/macOS test failure: HexZeroAsArrayIndexShouldWork exercises t['0'] (computed indexed access where the property literal is the string '0'), which set _determinedProperty to JsString("0") and then entered my new fast path. The fast path's prototype lookup for the JsString case returned Undefined because String.prototype has no '0' own-property — it required the full ToObjectStringInstance.GetOwnProperty path which parses the numeric-string property and returns the indexed char.

The minimal-correct fix is to only special-case length (the actual hot loop signal from base64) and let everything else fall through to GetV. v4.8.0 also allocated a wrapper for t['0'] via Engine.GetValue's ToObject path, so we're not regressing anything by deferring to it for non-length cases. StringBase64 allocations are still 3.66/3.58 MB (matches v4.8.0 byte-for-byte) — the targeted optimization is preserved.

lahma and others added 3 commits May 3, 2026 15:11
The pool added by sebastienros#2413 has a single slot per JintFunctionDefinition.State
guarded by Interlocked.Exchange. For tight self-recursion (ack/fib/tak), only
the topmost frame can ever be cached — every recursive call beyond that
allocates anyway, so the 4 atomic ops per call become pure overhead.

Detect direct named self-call at AST analysis (SelfCallAstVisitor walks the
function body for CallExpression with callee Identifier matching the
function's own name) and gate the pool path on the new IsDirectRecursive
flag in JintEnvironment.NewFunctionEnvironment and ScriptFunction.Call.

SunSpider controlflow-recursive: 63.56 ms -> 62.28 ms on a noisy
measurement (separate isolated run measured 60.79 ms; v4.8.0 baseline
58.71 ms). Trade-off: allocations on the affected functions return to
fresh-per-call, since the pool is intentionally bypassed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR sebastienros#2412's inline-cache fast path added `baseValue.GetV(...)` for
non-ObjectInstance receivers. For JsString primitives this routes through
TypeConverter.ToObject, which calls intrinsics.String.Construct(jsString)
and allocates a fresh StringInstance per read. Hot path: any
`for (var i = 0; i < s.length; i++)` re-reads `s.length` every iteration —
the slot the script picks for the loop bound doesn't help.

Mirror Engine.TryHandleStringValue's primitive-aware lookup inline:
length is intrinsic, everything else lives on String.prototype and accepts
the primitive as `this` without wrapping.

Dromaeo StringBase64 allocations: ~6.12 MB -> ~3.62 MB across all 4 cases
(byte-for-byte match with v4.8.0). SunSpider string-base64: 8.83 MB ->
5.07 MB. Knock-on alloc reductions of 4-14% on crypto-md5/sha1,
string-fasta, string-validate-input, date-format-xparb, ObjectRegExp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the pattern in GlobalObject.Properties.cs: after
CreateProperties_Generated() returns, _properties is the HybridDictionary
wrapper around the generator-built StringDictionarySlim. AddDangerous
skips both duplicate-key probing and SetOwnProperty's validation pipeline.

For the four prototypes that add known-new keys after the generator runs —
StringPrototype (trimLeft/trimRight aliases), DatePrototype (toGMTString
alias), RegExpPrototype (exec — hand-registered to keep
HasDefaultRegExpExec's reference identity), IntrinsicTypedArrayPrototype
(toString alias to Array.prototype.toString) — switch to
_properties!.AddDangerous(...) and presize the dict via
[JsObject(ExtraCapacity = N)] so the additions don't trigger a resize.

Symbol-key additions stay as SetOwnProperty since they go to the separate
_symbols dict (DictionarySlim) which has no AddDangerous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the perf-recursion-and-string-primitive branch from 9d48bf5 to 9c6ec94 Compare May 3, 2026 12:12
@lahma
lahma enabled auto-merge (squash) May 3, 2026 12:22
@lahma
lahma merged commit 7a566b6 into sebastienros:main May 3, 2026
4 checks passed
@lahma
lahma deleted the perf-recursion-and-string-primitive branch May 3, 2026 12:22
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