Skip to content

Ask once per loop, not once per element, how to call a built-in's callback - #2876

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf/register-args-callbacks-v2
Aug 1, 2026
Merged

Ask once per loop, not once per element, how to call a built-in's callback#2876
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf/register-args-callbacks-v2

Conversation

@lahma

@lahma lahma commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

A built-in that invokes a user callback per element — filter, map, forEach, every, some, reduce, the typed-array equivalents, Map/Set forEach — rents a JsValue[3] once, rewrites its slots per element, and dispatches through ICallable.Call. The decision of how to call the callback is remade on every element even though the callback cannot change between them.

CallbackInvoker asks once, before the loop: is this a plain interpreted function whose instantiation is the fixed-slot fast path? If so every element goes to ScriptFunction.CallFromRegisters (added in #2874) and no argument array is materialised at all. Otherwise it keeps exactly today's rented array and ICallable.Call. Every input to that verdict is fixed for the callback's lifetime — JintFunctionDefinition.State is computed once on the immutable AST node, _isClassConstructor is set at class definition, Engine._isDebugMode is readonly — so a callback that mutates the collection mid-iteration, throws, or is a revoked proxy cannot invalidate it.

Results

Paired A/B against merged main, both orderings, idle machine. Each row is judged against its own build-to-build variance (baseline r1 vs r2), with an absolute floor of 1% — several rows here have 0.1–0.4% variance, where "exceeds variance" alone would promote meaningless deltas.

row r1 r2 mean own var
ArrayCallbackBenchmark.ReduceToObject −11.63% −9.79% −10.71% 2.9%
ArrayFilterBenchmark.Filter/100 −8.49% −8.98% −8.73% 1.9%
ArrayCallbackBenchmark.EveryHit −8.15% −9.04% −8.59% 0.4%
ArrayFilterBenchmark.Filter/10000 −9.64% −5.87% −7.76% 0.1%
ArrayCallbackBenchmark.ForEachSum −8.47% −6.76% −7.61% 4.0%
ArrayCallbackBenchmark.MapFilterReduceChain −6.61% −8.61% −7.61% 0.2%
ArrayCallbackBenchmark.ReduceSum −5.83% −4.60% −5.21% 1.0%
ArrayFindSearchBenchmark.IndexOf_Hit_Mid −6.57% −0.68% −3.62% 0.1%

Sort rows land within noise (SortWithComparer_1K −0.52%, SortReverseSorted_1K +0.11%, SortAlreadySorted_1K −0.30%, SortRandom_10K −0.70%). The one row I would flag: SortRandom_100 +1.38%, whose two reps disagree six-fold (+2.37% / +0.40%) on the smallest benchmark in the set — reported rather than dismissed, but I do not think it is a signal.

The sort comparers deliberately do NOT use the invoker

The first version of this change converted ArrayComparer and TypedArrayComparer too. Measured, both orderings: SortWithComparer_1K +4.27% (own variance 2.3%) and SortReverseSorted_1K +2.10% (1.3%).

The second row is the diagnostic. It uses the default comparison — compare is null, so no CallbackInvoker is ever constructed and none is ever called — and it regressed anyway. That rules out dispatch and identifies the object: these comparers are classes dereferenced once per comparison across an n log n sort, and embedding the invoker by value grew each one by its eight fields. Both are back on a plain JsValue[2], with the reasoning recorded in a comment so it does not get "cleaned up" later.

The per-element callbacks are unaffected by that concern — the invoker is a local built once per built-in call, not a field on an object walked per comparison.

Also

The diff removes ~250 lines net and collapses seven copies of the // args is rented from the pool whose factory allocates new JsValue[3]… covariance note into one place, so the rent/return invariant lives in a single type rather than at twenty call sites.

IteratorProtocol.Execute is deliberately not converted: its argument count is a constructor parameter rather than a build-time constant, and the rented array is handed to an abstract ProcessItem(JsValue[], JsValue) whose overrides mostly ignore it. Routing it would mean changing the protocol's signature for one real callback.

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 · dotnet build -c Release 0 warnings.

Test262 is the real gate here — array callbacks are densely covered, including callbacks that mutate the array mid-iteration, throw, or are revoked proxies.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK

lahma and others added 2 commits July 31, 2026 15:33
…lback

Array.prototype.map, forEach, filter, every, reduce and their typed-array,
Map, Set and Array.from siblings all rent one JsValue[] outside the loop,
rewrite its slots per element and invoke through ICallable.Call. For an
interpreted callback the callee then reads those same slots straight back
out into its fixed parameter slots, so the array is a round trip that the
register lane can skip entirely.

CallbackInvoker is where the question is asked. The callback does not change
across elements, so neither can the answer: State is computed once and cached
on the immutable AST node, _isClassConstructor is decided when a class is
defined, and Engine._isDebugMode is readonly. Building the invoker resolves
the whole gate once, before the loop, and every element after that is one
null check against the resolved target — never a re-probe.

The two lanes share one shape rather than one loop body being written twice.
Rent() hands back an invoker that took the register lane and rented nothing,
or one holding the pooled array the site would have rented anyway; Call()
either fills registers or writes the leading slots and dispatches through
ICallable exactly as before. Create() is the same for a caller that outlives
a single built-in call — a sort comparer, which is the highest-frequency
callback here at n log n invocations — and allocates its array only if the
register lane declined.

Callbacks in this family almost all end in the collection itself, the same
value on every call, so the factory takes it separately: the array lane keeps
its single hoisted store and the register lane passes it from a field, which
is why converting a site adds no per-element work to the path it did not take.

Equivalence is structural rather than argued. ScriptFunction.Call already
routes a SupportsRegisterCall callee into CallCore with an ArrayArguments, so
for exactly the callees this lane accepts the conversion swaps the argument
source and changes nothing else — not the call-stack frame (built-in callback
invocation pushes none either way), not thisArg, not Arguments.At semantics
for a callback declaring more parameters than the site supplies. Sites that
deliberately leak their rented array on an early return still do; sites with
a try/finally still return it there.

IteratorProtocol.Execute is left alone: its argument count is a constructor
parameter and the rented array is handed to an abstract ProcessItem that three
of its four overrides ignore, so routing it would mean changing the protocol's
signature for one real callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ArrayComparer and TypedArrayComparer are dereferenced once per comparison of an
n log n sort, and holding a CallbackInvoker by value grew each comparer object
by the invoker's eight fields. Measured against merged main, both A/B orderings:
SortWithComparer_1K +4.27% (own variance 2.3%) and SortReverseSorted_1K +2.10%
(1.3%) -- the latter uses the DEFAULT comparison and never calls the invoker at
all, which is what identifies the object size rather than the dispatch as the
cost. The per-element callbacks keep the invoker and keep their wins
(Filter/10000 -12.9%, MapFilterReduceChain -11.7%, ForEachSum -8.6%).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK
@lahma
lahma merged commit d5eaa6f into sebastienros:main Aug 1, 2026
5 checks passed
@lahma
lahma deleted the perf/register-args-callbacks-v2 branch August 1, 2026 04:15
lahma added a commit that referenced this pull request Aug 1, 2026
…or reviver (#2885)

* Add benchmark rows for the callbacks JSON and replaceAll invoke per key

JSON.stringify's replacer, JSON.parse's reviver and replaceAll's functional
replacer are all invoked once per key/match, but no benchmark row passed one,
so nothing measured what those loops cost.

JsonJsBenchmark gains StringifyRecordsWithReplacer and ParseRecordsWithReviver
over the fixture it already builds; the existing StringifyRecords/ParseRecords
rows are their controls, passing no callback over the same graph.

StringReplaceBenchmark is new: ReplaceAllFunction invokes an interpreted
callback once per match over ~8k matches, and ReplaceAllPattern is its control
— a "$"-bearing string replacement, which defeats the string.Replace
short-circuit and so walks the same match loop without a callback in it.

One engine per row, built in [GlobalSetup] and warmed with that row's script
only, per the IsolatedScript rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK

* Ask once per document, not once per key, how to call a JSON replacer or reviver

Extends the CallbackInvoker hoist from #2876 to the two JSON callbacks that are
invoked once per key of the graph, and whose dispatch verdict is loop-invariant
for the same reason it is on the array callbacks: it depends only on the
callback, and JintFunctionDefinition.State, _isClassConstructor and
Engine._isDebugMode are all fixed for the callback's lifetime.

JsonSerializer built the callable afresh for every key of the graph —
(ICallable) _replacerFunction.AsObject() — and handed it a params array.  The
invoker is built in SetupReplacer, where the replacer is established, and lives
for the whole Serialize call; Create() rather than Rent(), because the recursive
walk can exit by exception from any depth so there is nowhere to hand a pooled
array back reliably.  _replacerFunction is replaced by a bool for the two hot
"is there a replacer" gates, and the invoker field is declared last: no per-key
path reads it.

JsonInstance's reviver is built once in Parse and threaded through
InternalizeJSONProperty by `in`, so the recursion passes a pointer instead of
copying the struct down every level.  Its arity is always three — the
json-parse-with-source context object is constructed and passed unconditionally,
only its "source" property is conditional — and all three arguments vary per
key, so nothing is hoisted into the invoker as a fixed last argument.  One
invoker serves the whole recursion safely: a level fills and invokes only after
every child level has finished doing so.

JsonJsBenchmark, default job, both A/B orderings on an idle machine:

| row                          | delta  |
| ---------------------------- | ------ |
| StringifyRecordsWithReplacer | -28.5% |
| ParseRecordsWithReviver      |  -1.7% |
| StringifyRecords (control)   |  -0.6% |
| ParseRecords (control)       |  -2.8% |

The replacer is the win.  The reviver row is inside its own run-to-run variance
and is kept on mechanism rather than on the clock: it drops the per-key
JsValue[3] that ICallable.Call's params array allocated (and on the register
lane there is no array at all), and the row does not move because a
parse-with-source reviver spends its per-key budget on the context object it
constructs, not on the call.  Its whole cost is one `in` parameter in place of
an ICallable one.  Both no-callback controls stayed flat, which is what says the
per-key gates and the widened serializer instance did not tax a stringify or
parse that passes no callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G24ppn8iSgjo1APp3YkzTK

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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