Skip to content

Hand a call site's arguments to an interpreted callee in registers - #2874

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf/register-args-in-registers
Jul 31, 2026
Merged

lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf/register-args-in-registers

Conversation

@lahma

@lahma lahma commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

A call site whose arity is a build-time constant evaluates its arguments straight into locals and hands them to an interpreted callee in registers, instead of renting a JsValue[] from JsValueArrayPool, filling it, and reading it back out in FunctionDeclarationInstantiation.

No public API change. JsCallArguments is still JsValue[] at every boundary; ScriptFunction is sealed, so the whole lane is internal. The array path and the register path share one body (ScriptFunction.CallCore<TArgs>, generic over an IArgumentSource struct so the JIT specialises each and At inlines), which is what keeps them from drifting apart.

Bounded before it was built: the argument array costs ~21 ns of a ~250 ns interpreted call, and a separate ablation showed ~18 ns of that is the array itself and only ~3 ns is ArgumentListEvaluation bookkeeping — so the bookkeeping half was dropped rather than implemented.

Wide comparison — SunSpider + Dromaeo, 38 rows

Both orderings (baseline-first and treatment-first), fixed harness on both sides, idle machine. Each row is judged against its own build-to-build variance (baseline r1 vs baseline r2), not a flat threshold — several rows in this suite swing more on identical code than any real effect here.

18 wins · 0 regressions · 20 within noise.

row r1 r2 mean own var
controlflow-recursive −16.56% −14.66% −15.61% 0.4%
math-spectral-norm −11.78% −15.37% −13.57% 6.2%
crypto-sha1 −7.08% −12.46% −9.77% 5.1%
dromaeo-object-string −16.26% −0.76% −8.51% 3.6%
string-unpack-code −7.08% −8.84% −7.96% 3.7%
access-binary-trees −8.94% −6.41% −7.68% 2.8%
crypto-md5 −7.66% −7.68% −7.67% 0.5%
bitops-3bit-bits-in-byte −8.90% −6.31% −7.60% 0.2%
math-cordic −6.60% −4.13% −5.37% 0.3%
date-format-tofte −3.65% −5.16% −4.41% 1.7%
bitops-nsieve-bits −1.97% −4.02% −2.99% 2.0%
bitops-bits-in-byte −4.06% −1.42% −2.74% 1.0%
3d-raytrace −1.70% −3.26% −2.48% 1.3%
date-format-xparb −0.84% −3.09% −1.96% 1.2%
string-base64 −2.89% −0.48% −1.69% 0.8%
dromaeo-3d-cube −2.06% −0.86% −1.46% 0.3%
3d-cube −1.69% −0.92% −1.30% 0.5%
access-fannkuch −1.77% −0.64% −1.20% 0.3%

The remaining 20 rows land within their own variance and are reported as noise, in both directions: string-tagcloud (−5.96% against 11.2% own variance), string-validate-input, dromaeo-string-base64 ×2, math-partial-sums, access-nsieve, string-fasta, dromaeo-core-eval ×2, crypto-aes, dromaeo-object-regexp ×2, bitops-bitwise-and, dromaeo-3d-cube/False, access-nbody, 3d-morph, dromaeo-object-array ×2, dromaeo-object-string/False, regexp-dna.

Targeted rows — including what regresses

row mean own var allocation
ClosureCallBenchmarks.ParamLocalCall −26.44% 1.0% win
RecursionBenchmark.Fib −17.83% 10.5% win 1060.23 → 181.68 KB
MethodCallBenchmark.FreeFunctionCall −16.88% 0.4% win
RecursionBenchmark.Tak −11.92% 0.5% win 334.36 → 31.95 KB
RecursionBenchmark.DeepSum −8.89% 5.7% win 197.3 → 136.6 MB
MethodCallBenchmark.UserPrototypeMethod +3.86% 0.7% regression unchanged
MethodCallBenchmark.ArrayPushPop +3.76% 0.7% regression unchanged
ClosureCallBenchmarks.SloppyEmptyClosureCall +2.44% 2.3% regression, marginal unchanged

The regressions are real and I have not explained them. All three are shapes the lane cannot serve: UserPrototypeMethod and SloppyEmptyClosureCall are zero-argument calls (excluded from arming — with no arguments there is no array to avoid), and ArrayPushPop's callees are built-ins that take the pre-existing fast-call lane and return before the new guard is reached. ArrayPushPop in particular executes no new instruction and still moves, so the cost is code/field layout in JintCallExpression, not anything on its path. Three separate designs were measured trying to remove it; each relocated it rather than eliminating it. ArrayPushPop also gains occasional outliers (152/160 ms against a ~123 ms median) that I could not account for.

The trade, stated plainly: 38 real-script rows show only wins, up to −15.6%; three synthetic micro-rows measuring call shapes that gain nothing cost ~3–4%.

Allocation

Recursion holds one rented argument array live per frame, so past the pool's capacity every frame allocates. Fib −82.9%, Tak −90.4%, DeepSum −30.8%.

Also fixed here (same method, few lines)

FastCall / FastCallVariadic read _fastShape after the site's arguments were evaluated. An argument expression can re-enter the same handler node and re-cache it, so the outer dispatch could run the correct callee under another built-in's shape — a wrongly routed CallFastVariadic, or a wrongly elided call-stack frame, which is observable through error.stack. The shape is now snapshotted beside the guard, before any argument runs, and passed down. Exotic to trigger, but real, and structurally part of this change.

Verification

Jint.Tests 4660/0/4 (net10.0) · 4579/0/4 (net472) · Jint.Tests.PublicInterface 1240/0/9 · 1239/0/9 · Jint.Tests.CommonScripts 28/0 both TFMs · Jint.Tests.SourceGenerators 51/0 · Jint.Tests.Test262 99484 / 0 / 157 · JINT_HOST_CONTRACT_VERIFICATION=1 leg clean (PublicInterface 1244/0/5) · Debug build + Jint.Tests Debug clean · dotnet build -c Release 0 warnings.

All benchmark numbers were taken through the harness fixed in #2873, on both sides, so they are internally consistent but not comparable to figures published before that merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK

lahma and others added 2 commits July 31, 2026 11:23
A call whose arity is a build-time constant already knows how many values it
will produce, yet every interpreted call still routed them through a rented
JsCallArguments array so that FunctionDeclarationInstantiation could index it.
This adds a second monomorphic lane to JintCallExpression that evaluates up to
four arguments straight into locals and hands them to the callee through
ScriptFunction.CallFromRegisters, which shares its whole body with the ordinary
array-backed arm via a CallCore<TArgs> generic over IArgumentSource — so the
register form never materializes an array and the array form is step for step
what it was.

The lane is strictly additive at the call site. The built-in fast-call lane
keeps its fields, its guard chain, its inline argument evaluation and its
FastCall shape; a call that takes it returns before the new guard is ever
reached, and everything else pays one reference compare against a field that
stays null unless this site's last callee was an eligible ScriptFunction. The
argument evaluation, the reference return, the call-stack frame and the
dispatch all live in a NoInlining RegisterLaneCall, so EvaluateInternal grows
by the guard alone.

Everything static about the gate — the site's arity and spread shape, the
callee's SupportsRegisterCall, the engine's readonly _isDebugMode — is settled
once per distinct callee in ProbeRegisterCallee and recorded against a separate
_regProbedCallee slot, so a site whose callee never qualifies remembers the
rejection instead of re-probing per dispatch. Zero-argument sites deliberately
never arm: they rent nothing today, so the lane could only cost them. The two
lanes keep separate cache slots because a fixed-arity FastCallShape reports
Supported without consulting arity, which is only safe while that lane is
capped at two arguments and this one serves four.

Also fixes a latent re-entrancy bug in the built-in lane: FastCall and
FastCallVariadic read _fastShape after the arguments had been evaluated, so an
argument expression re-entering the same node with a different callee could
dispatch under another built-in's shape — wrong Variadic routing, or a wrongly
elided frame corrupting error.stack. The shape is now snapshotted beside the
guard and passed down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK
FastCallShape is a small readonly record struct, so handing it to FastCall by
readonly reference makes the callee dereference a stack local twice where it
previously read two fields off `this`. By value the JIT can promote it into
registers, and on a built-in-heavy workload (a.push/a.pop, 6 alternating reps)
the median moved from +2.12% to +1.69% against the same baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK
@lahma
lahma enabled auto-merge (squash) July 31, 2026 12:04
@lahma
lahma merged commit 0af0a25 into sebastienros:main Jul 31, 2026
9 of 10 checks passed
@lahma
lahma deleted the perf/register-args-in-registers branch July 31, 2026 12:32
legrab added a commit to legrab/pocok that referenced this pull request Aug 18, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.15.3 to
4.16.0.

<details>
<summary>Release notes</summary>

_Sourced from [Jint's
releases](https://github.com/sebastienros/jint/releases)._

## 4.16.0

Jint 4.16.0 is a **correctness- and reliability-focused release**:
alongside asynchronous module loading, proper tail calls and four new
iterator built-ins, a pre-tag review swept the whole engine and fixed
what it found — including long-standing defects that predate this cycle.
**No option defaults changed.** Behaviour changes to note up front:
`JSON.stringify` and other machine-readable output now format
invariantly under every host culture — under Swedish or Finnish locales
on .NET 8+ it used to emit a Unicode minus sign no JSON parser accepts;
`JSON.parse` now rejects trailing commas as the grammar requires; bare
identifiers at global scope resolve through the global's prototype chain
per spec; `IModuleLoader.Resolve` is consulted once per (referrer,
specifier) pair, so a loader using it as a per-import access-control
checkpoint should move the check to `LoadModule`; and an inconsistent
sort comparator now finishes with an implementation-defined order on
every target framework instead of hanging (net462/netstandard) or
throwing a CLR exception at script (net8+).

### Highlights

**Proper tail calls (#​2975).** Strict-mode calls in tail position reuse
their frame, so `"use strict"` tail recursion runs in constant stack —
the first ES2015 PTC implementation among the .NET engines.

**Asynchronous module loading (#​2872).** `IAsyncModuleLoader` and the
`AsyncModuleLoader` template let a host fetch module source over I/O
without blocking a thread; `Engine.Modules.StartImport` returns an
operation a game loop drives via `ProcessTasks()`, and `ImportAsync`
awaits without holding a thread. The spec's load phase now exists as
written, a warm-cache async loader keeps the blocking `Import` fully
synchronous, and the blocking drain wakes on a work-arrived signal
instead of polling. A module served over a transport keeps its whole url
as `Module.Location` so its own relative imports resolve, a deferred
namespace evaluates its module instead of exposing uninitialized
bindings, and an import abandoned by a global snapshot restore reports
itself faulted instead of polling forever.

**The process no longer dies for recoverable reasons.**
`Options.LimitRecursion` used to kill the host process for most useful
limits — the constraint fired, and the unwind itself overflowed the
stack; exception filters now let it unwind ~7× deeper. The new opt-in
`Options.Constraints.StackOverflowGuard` converts unbounded recursion —
reachable through eighteen distinct routes, `new`, accessors, coercions
and Proxy traps included — from a process kill into a catchable
`RangeError`, exempting strict tail calls, which grow no stack. And a
family of CLR exceptions that escaped `engine.Evaluate` past every
script `catch` are now proper JavaScript errors or correct results:
sorting with an inconsistent comparator, destructuring with a
function-valued default (`const { onChange = () => {} } = opts`),
`toLocaleString` outside `DateTime`'s range, typed-array
`defineProperty` without a value, `DataView` reads at 2³¹,
`String.replace` `$'` with a lying exec, and the first instant of year
10000.

**New built-ins.** `Iterator.prototype.join`, `chunks`, `windows` and
`includes`; `take`/`drop` now throw `RangeError` for a finite limit
above 2^53−1 per the updated proposals.
`Intl.Locale.prototype.getCollations` reports CLDR-cited collation data
that `Intl.Collator` accepts in full, a malformed `collation` option is
a `RangeError`, and `Intl.supportedValuesOf("collation")` derives from
the same lists so the three can never drift.

**Conformance, from a review that ran what the suite does not.** Two of
the fixed defects had test262 coverage only under the never-generated
`staging/` directory, and several had none at all: `parseInt` strips the
sign before testing for a hex prefix, so `parseInt("-0x10")` is −16; a
suspended `finally` no longer swallows a pending `break`/`continue`; a
Proxy (or exotic host object) as the global's prototype answers bare
identifiers through its `get` trap; `Date.prototype.toISOString` emits
the spec's six-digit expanded year and round-trips through `Date.parse`
in every spelling including year 0; iterator helpers close their
receiver exactly once and only when the spec says so, and carry their
own `@@​toStringTag`; `Map`/`Set` `size` is the prototype accessor the
spec defines rather than a phantom own property; a Proxy's
`defineProperty` trap receives the partial descriptor the caller wrote;
a string's `@@​iterator` is read once, with the primitive as receiver;
`Array.prototype.join` re-asks the array when a side effect fills a hole
mid-join; a direct eval reaches the enclosing function's `arguments` in
both modes; and `Temporal.Now` drops the methods the proposal removed.

**Embedder surface.** `OperationDeadlineConstraint` bounds a whole
multi-entry host operation; `ScriptPreparationOptions.StaticAnalysis`
trades prepare-time analysis for per-engine materialization on shared
graphs; `ModuleFactory.LocationOf` exposes the module-naming rule a host
must match; `Engine.Advanced.HostDefined` carries per-request state on a
pooled engine; the CLR exception behind an interop error is reachable
through `JintException.TryGetClrException` with opt-in
`ChainClrExceptions()`, and a host method's own `TargetException` is no
longer mistaken for a receiver mismatch; and a recursion-limit failure
propagates out of a module load instead of becoming a catchable
rejection.

**Performance, gated.** Against v4.15.3 on idle hardware, medians of
three paired runs: `controlflow-recursive` **−15.6% time and −40.4%
allocation** (proper tail calls), `bitops-3bit-bits-in-byte` −8.9%,
`math-spectral-norm` −7.3%, `crypto-sha1` −6.9%, `3d-raytrace` −5.9%,
`math-cordic` −5.8%, with a broad −1–4% tail across the call- and
string-heavy rows; no row moved outside its own measured cross-run
envelope in the other direction, and allocation is flat within ±0.2%
suite-wide. Warmed `parseInt` call sites take the frameless fast-call
lane (−13% on the parse loop), joined by the `Number` predicates,
`String.prototype.indexOf`/`startsWith`/`endsWith`/`includes`/`at`/`substr`,
global `isNaN`/`isFinite` and `Array.isArray` (−3% to −19%) and the
`Map`/`Set` method family (`map.get` hit loop −13%); existence questions
on a wrapped dictionary answer from `ContainsKey`, taking `in` −33% with
−98% allocation and `Object.keys` −37%; resolving an inherited global no
longer allocates per miss (−99.99% on the read loop) and a global
created through an inherited write keeps the in-place store; JSON
replacer/reviver eligibility is decided once per document, built-in
callback dispatch once per loop, a call site's arguments reach an
interpreted callee in registers, and function-local `let`/`const` live
in fixed slots.

**Breaking changes.**
`Int32Extensions`/`Int64Extensions`/`DoubleExtensions` — polyfill hosts
that leaked into the public API — are now internal; on
net462/netstandard2.0, code with `using Jint;` may have bound span
`Parse`/`TryParse` members through them. `JsonParser` rejects trailing
commas. `Number.parseInt.length`/`Number.parseFloat.length` report their
spec values. Post-construction mutation of an `Options` instance no
longer reaches an already-built engine, and `Options.Configure`
callbacks work again. `UnwrapIfPromise` reports a cancelled engine as
`ExecutionCanceledException` instead of a timeout. Time-zone matching is
ASCII-case-insensitive per ECMA-402.

On the [engine comparison
benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md),
Jint 4.16.0 is the fastest engine outright on 5 of 12 scripts — leading
`dromaeo-object-regexp-modern` over native V8 by 1.25× — in a
statistical tie for first on `interop-collection-traversal`, the fastest
managed engine on 10 of 12, the fastest interpreter on all 12, and
8.6×–11.2× ahead of ClearScript (native V8) on every interop row while
allocating 3.9×–12.4× less than the nearest managed competitor.

## What's Changed
* Run the repository's own host tests under Release-mode contract
verification by @​lahma in
sebastienros/jint#2866
* Update test262 suite and implement Iterator.prototype.join by @​lahma
in sebastienros/jint#2867
* Stop a closing iterator from swallowing the error that closed it by
@​lahma in sebastienros/jint#2868
* Give every benchmark row its own engine by @​lahma in
sebastienros/jint#2873
* Hand a call site's arguments to an interpreted callee in registers by
@​lahma in sebastienros/jint#2874
* Ask once per loop, not once per element, how to call a built-in's
callback by @​lahma in sebastienros/jint#2876
* Say which spec document to read for a feature by @​lahma in
sebastienros/jint#2882
* Run PR CI on every pull request, not only those targeting main by
@​lahma in sebastienros/jint#2883
* Update test262 suite and adopt the new take/drop RangeError by @​lahma
in sebastienros/jint#2877
* Implement Iterator Chunking by @​lahma in
sebastienros/jint#2878
* Say to write against the modern BCL and polyfill downwards by @​lahma
in sebastienros/jint#2884
* Implement Iterator Includes by @​lahma in
sebastienros/jint#2879
* Ask once per document, not once per key, how to call a JSON replacer
or reviver by @​lahma in sebastienros/jint#2885
* Use double.IsFinite instead of hand-rolled NaN and infinity pairs by
@​lahma in sebastienros/jint#2880
* Bump the testing group with 1 update by @​dependabot[bot] in
sebastienros/jint#2889
* Stop charging closures for per-engine lazy globals by @​lahma in
sebastienros/jint#2890
* Bump the analyzers group with 1 update by @​dependabot[bot] in
sebastienros/jint#2888
* Make the lazy-global re-arm on restore a contract instead of an
accident by @​lahma in sebastienros/jint#2892
* Let the amortized constraint cadence span top-level entries into the
engine by @​lahma in sebastienros/jint#2886
* Let a function's own let/const live in its fixed slots by @​lahma in
sebastienros/jint#2887
* Keep a host function in the engine's own realm after a second realm
exists by @​lahma in sebastienros/jint#2893
* Make an engine's principal realm [[HostDefined]] reachable by @​lahma
in sebastienros/jint#2891
* Carry a labelled break/continue target on the completion record by
@​lahma in sebastienros/jint#2894
* Make Array.prototype.sort stable on every target framework by @​lahma
in sebastienros/jint#2898
* Stop toSorted and %TypedArray%.sort hanging on an inconsistent
comparator by @​lahma in sebastienros/jint#2899
* Delete the evaluation context's dead completion channel by @​lahma in
sebastienros/jint#2895
* Stop shipping the numeric polyfill hosts as public API by @​lahma in
sebastienros/jint#2901
 ... (truncated)

Commits viewable in [compare
view](sebastienros/jint@v4.15.3...v4.16.0).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.15.3&new-version=4.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
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