Skip to content

Let the cheapest builtin calls run frameless - #2980

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/fastcall-tier1
Aug 13, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/fastcall-tier1

Conversation

@lahma

@lahma lahma commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Draft until its benchmark gate lands — the release measurement block runs the new FastCallLaneBenchmarks rows plus the wide tables against this.

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 Leaf with Any guards" was unimplementable: the generator refuses Leaf for a plain JsValue parameter, and FastCallGuard.Any cannot un-refuse it because Any is the absence of a declaration. The new spelling FastCallGuard.Unguarded is declaration-only — the generator lowers it to Any in the emitted shape, byte-identical to an unguarded position (snapshot-pinned), zero runtime cost.

Shipped:

  • Number.isFinite/isInteger/isNaN/isSafeInteger — type test first, no ToNumber, 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 — the charAt/substring guard shape. The IsRegExp unreachability claim for startsWith-family was verified in source, not trusted: IsRegExp answers false for a non-ObjectInstance before any @@match read, and a guard-passing JsString is never one; a String.prototype[Symbol.match] cannot change that because the lookup would be on the argument, which is a primitive.
  • Global isNaN/isFinite — Number | String | Undefined (Symbol/BigInt TypeErrors and object valueOf excluded by the guard). The scouting note's registration worry was refuted: these are generated dispatchers, never ClrFunction, no identity twin, no length change.
  • Array.isArray — the note called it marginal; the old comment said Leaf was impossible. "Not a revoked proxy" is indeed inexpressible, but "one of our own arrays" is exact: InternalTypes.Array + the sealed IsArray() override make LeafArg0 = Array a 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 ToNumber probe inserted) and the LeafCallGuard assert 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, name or attribute moves. The one inherent observable — a guard-passing call no longer charges LimitRecursion — 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 for isArray, 38 warm-vs-cold agreement rows, and a warmed indexOf handed a throwing toString still showing at indexOf in error.stack.

Release build 0 warnings; test262 byte-identical to the cf3c048de baseline (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 that indexOf/includes/isNaN/isArray appear inside the suite scripts themselves, so small improvements there are results, not drift.

🤖 Generated with Claude Code

@lahma
lahma force-pushed the perf/fastcall-tier1 branch from a4c988c to e74510c Compare August 12, 2026 06:49
@lahma

lahma commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark gate — PASS

Two 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.

Row Δ (median of 2 pairs)
StringAt_Guarded −19% (pair 1) / consistent pair 2
StringIncludes_Guarded −12.0%
NumberIsInteger_Object −11.7%
StringStartsWith_Guarded −10.8%
IsNaN_String −10.5%
NumberIsInteger_Number −9.6%
ArrayIsArray_Array −7.3%
IsNaN_Number −3.0%
controls: StringIncludes/Substring/StringAt _Object +1.4% to +3.9% (envelope ≈ +5%)

Honest context: the class's pre-existing rows showed base-side spreads up to 26% between the two pairs (CharCodeAt_Guarded), which is the known sub-200 ns scatter — the designed rows' candidate spreads are 0.1–2.9%, and their direction agrees in both orderings, which is what the two-pair rule requires. Machine idle, serial, default job, node reuse disabled.

@lahma
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
lahma enabled auto-merge (squash) August 13, 2026 16:25
@lahma
lahma force-pushed the perf/fastcall-tier1 branch from e74510c to eb6ce66 Compare August 13, 2026 16:27
@lahma
lahma merged commit 7b56c83 into sebastienros:main Aug 13, 2026
5 checks passed
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>
@lahma
lahma deleted the perf/fastcall-tier1 branch August 20, 2026 19:47
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