Let the cheapest builtin calls run frameless - #2980
Merged
Merged
Conversation
lahma
force-pushed
the
perf/fastcall-tier1
branch
from
August 12, 2026 06:49
a4c988c to
e74510c
Compare
Collaborator
Author
Benchmark gate — PASSTwo order-reversed pairs vs the merge-base, medians. Every designed row improves with tight candidate spreads; the object-argument controls sit inside the class's documented envelope; allocation byte-identical everywhere.
Honest context: the class's pre-existing rows showed base-side spreads up to 26% between the two pairs ( |
lahma
marked this pull request as ready for review
August 12, 2026 17:08
Number.isFinite, isInteger, isNaN and isSafeInteger are the cheapest frameless
candidates in the engine: each is a type test and nothing else. An argument that
is not already a JsNumber is answered false on the spot, the number branch does
arithmetic that raises nothing, and the receiver is never read. None of them
could claim Leaf, though, because the argument is a plain JsValue, and the
generator refuses that outright - it cannot see the body, and the reason it
refuses is that four String.prototype methods once took such a parameter and
coerced it through a user valueOf inside the frameless window.
LeafArg0/LeafArg1 are how a body vouches for a raw JsValue, but the vocabulary
had no way to spell "every value". FastCallGuard.Any cannot: Any is the absence
of a constraint, and absence is exactly what the generator refuses, so writing
it would be indistinguishable from writing nothing. FastCallGuard.Unguarded is
the missing spelling - declaration only, never a runtime precondition, lowered
to Any in the emitted shape, because "every value satisfies this" and "there is
nothing to test" are the same test and all that separates them is whether the
claim was made. The alternative, guarding those four on Number, would have been
sound and would have cost frame elision to exactly the calls they exist for:
Number.isInteger(x) is asked about a value whose type is not yet known.
Six String.prototype methods and three more built-ins follow under ordinary
guards.
* indexOf, startsWith, endsWith and includes take a String receiver, a String
needle and a Number-or-undefined position. The TypeError the last three
raise for a regular-expression argument reads like the thing that blocks
Leaf and is not: IsRegExp answers false for anything that is not an
ObjectInstance before it looks at a single property, and a JsString never is
one - not even a String.prototype[Symbol.match] could change that, because
the lookup it would answer is on the argument.
* at and substr take the guards charAt and substring already carry, for the
same reason: the index stays a raw JsValue so its coercion keeps running
after the receiver's, and the declaration names the values that coercion
cannot reach user code for.
* The global isNaN and isFinite are a single ToNumber, so the guard is the set
of values that conversion answers without asking the value anything: a
number, a string - an unparseable one is NaN, not a throw - and undefined.
Symbol and BigInt are the two primitives ToNumber raises a TypeError for and
an object reaches valueOf, so both keep the frame.
* Array.isArray is leaf only where the answer is yes. Its comment said Leaf
was impossible because IsArray on a revoked Proxy throws, and "not a revoked
proxy" is indeed not expressible - but "one of our own arrays" is, and that
settles the question outright: InternalTypes.Array is set by ArrayInstance's
constructors and nothing else, and ArrayInstance seals IsArray() to true.
The proxy fails the guard and keeps the frame its TypeError needs.
Nothing observable changes. No function's identity, length or name moves, and a
guard-passing call cannot report that its frame is gone, because a built-in that
provably runs no user code has nowhere to report it from. So the claims are
pinned where they are decided - GetFastCallShape plus IsLeafFor, per built-in
and per value - while the declining side keeps being pinned from error.stack at
a warmed site, now including the revoked proxy Array.isArray turns away and the
object Number's predicates answer without ever calling its valueOf.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma
enabled auto-merge (squash)
August 13, 2026 16:25
lahma
force-pushed
the
perf/fastcall-tier1
branch
from
August 13, 2026 16:27
e74510c to
eb6ce66
Compare
lahma
added a commit
that referenced
this pull request
Aug 13, 2026
Full re-measure of both suites (script + interop, one session, default job, idle machine: 96 script rows and 20 interop rows) on the 4.16.0 release candidate 7b56c83. Every figure in the narrative sections is recomputed from the new reports; no number is carried forward from the 4.15.0 tables. Where the table stands now: - Jint is fastest outright on 5 of 12 scripts (minimal ~345x V8's compiled lane, evaluation-modern ~80x, linq-js ~6.7x, dromaeo-core-eval-modern ~5%, dromaeo-object-regexp-modern 1.25x ahead of V8's fresh-context lane and 1.46x ahead of its compiled lane), fastest managed engine on 10 of 12, and fastest interpreter on all 12 - V8 keeps the tight-loop rows: base64 9.8x, object-string 6.6x, stopwatch 6.0x, 3d-cube 3.4x, json-parse 2.2x, plus narrow leads on object-array (1.08x) and array-stress (1.09x) - array-stress being the one script row that changes hands, out of the rank-1 tie it held at 4.15.0 - Allocation: Jint is lowest of the managed engines on 10 of 12 scripts (Okojo on object-array, NiL.JS on minimal) and on all four interop rows, 3.9x-12.4x under the nearest managed competitor there - Interop: rank 1 on string-passing, and back into a rank-1 tie with NiL.JS on collection-traversal (1,251.0 vs 1,242.7 us, 0.7% apart); rank 2 on method-calls (NiL.JS ahead) and property-access (YantraJS by 2.6%). Plain ClearScript costs 8.6x-11.2x against Jint, FastProxy 3.4x-7.0x Adds a "What changed for 4.16.0" section: proper tail calls (#2975, which measured -15.6% time and -40.4% allocation on the Jint-only controlflow-recursive row against 4.15.3), the fast-call lane's growth (#2968, #2980, #2984), the wrapped-dictionary probe lane (#2969), and the disclosed cost of the join-hole re-read (#3003, +3.4% on hole-heavy joins). The comparison against the 4.15.0 tables is stated as directional only - the two sessions ran on .NET 10.0.10 -> 10.0.11 with YantraJS 1.2.419 -> 1.2.422 in between, so no row-for-row delta is claimed - and the two stale prose claims naming 4.15.0 outside the history sections are refreshed from this session's data. Environment: AMD Ryzen 9 5950X, .NET 10.0.11 (SDK 10.0.400), BenchmarkDotNet 0.15.8, default job, otherwise idle machine. ClearScript's V8 lanes land within ~3% of the 2026-07-28 session on the identical package. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extends the leaf fast-call lane to the Tier 1 candidates from the release campaign's scouting note. Every premise was re-verified in source; one was refuted as written and fixed properly, one was refuted in the other direction.
The missing mechanism first. "Unconditional
LeafwithAnyguards" was unimplementable: the generator refusesLeaffor a plainJsValueparameter, andFastCallGuard.Anycannot un-refuse it becauseAnyis the absence of a declaration. The new spellingFastCallGuard.Unguardedis declaration-only — the generator lowers it toAnyin the emitted shape, byte-identical to an unguarded position (snapshot-pinned), zero runtime cost.Shipped:
Number.isFinite/isInteger/isNaN/isSafeInteger— type test first, noToNumber, receiver unread;Leaf+Unguarded, so even the object-argument calls take the lane (they are validation calls, which is what these functions are for).String.prototype.indexOf/startsWith/endsWith/includes/at/substr— thecharAt/substringguard shape. TheIsRegExpunreachability claim forstartsWith-family was verified in source, not trusted:IsRegExpanswers false for a non-ObjectInstancebefore any@@matchread, and a guard-passingJsStringis never one; aString.prototype[Symbol.match]cannot change that because the lookup would be on the argument, which is a primitive.isNaN/isFinite—Number | String | Undefined(Symbol/BigInt TypeErrors and objectvalueOfexcluded by the guard). The scouting note's registration worry was refuted: these are generated dispatchers, neverClrFunction, no identity twin, nolengthchange.Array.isArray— the note called it marginal; the old comment saidLeafwas impossible. "Not a revoked proxy" is indeed inexpressible, but "one of our own arrays" is exact:InternalTypes.Array+ the sealedIsArray()override makeLeafArg0 = Arraya flag test and a return, and a proxy fails the guard into the frame its TypeError needs.Leaf claims were proven by falsification, not by green Debug runs. A green Debug leg only says no assert fired; each claim was temporarily falsified (guard widened, or a
ToNumberprobe inserted) and theLeafCallGuardassert shown to fire across all four call-site shapes, then reverted — the failure transcripts are in the commit history of this PR's preparation. Final Debug legs green everywhere including a 79,489-test Debug test262 with zero leaf-audit failures.Behaviour changes: none of #2968's kind. No identity,
length,nameor attribute moves. The one inherent observable — a guard-passing call no longer chargesLimitRecursion— is invisible (such a call provably runs no user code and raises no JS error), and recursion through user code still charges, pinned by the pre-existing test.New pins include
valueOf-count-zero for the Number predicates, the revoked-proxy frame forisArray, 38 warm-vs-cold agreement rows, and a warmedindexOfhanded a throwingtoStringstill showingat indexOfinerror.stack.Release build 0 warnings; test262 byte-identical to the
cf3c048debaseline (same 8 pre-existing load-flake timeouts on this box, identical on both sides). Gate rows and expectations are listed in the PR preparation notes; >1% on any wide-table row blocks, with the caveat thatindexOf/includes/isNaN/isArrayappear inside the suite scripts themselves, so small improvements there are results, not drift.🤖 Generated with Claude Code