Skip to content

Let Map and Set lookups run frameless - #2984

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

Let Map and Set lookups run frameless#2984
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/fastcall-map-set

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 rows, MapSetLookupBenchmark, and the wide tables against this.

Map.prototype.get/has/set/delete and Set.prototype.has/add/delete claim Leaf, so a warm call site with a real Map/Set receiver skips the built-in's call-stack frame. All seven already had the register half of the lane; map.get(k) is a hot path in real embeddings.

A bit, not a type test. InternalTypes.Map/Set bits, aliased by FastCallGuard.Map/Set, keep Satisfies at one mask test for passing and failing receivers. The Date precedent (spare bit + is JsDate type test) was rejected deliberately: two more type-tested kinds lengthen the fallthrough tail every failing guard of every kind pays. Both classes are public sealed and the bit is set in the single constructor every instance — subclasses included — reaches, so the bit is exactly is JsMap/is JsSet and a host cannot forge it. Bit headroom is now documented; one free int bit remains.

One bit each, not a shared collection bit — verified that Map.prototype.get.call(aSet, k) throws today, so a shared bit would have made it throw frameless; pinned both by a wrong-brand theory and by (Map & Set) == Empty in the guard/flags agreement test.

A second declaration-only guard rode along. A Map key is only hashed and compared (SameValueZero), never converted, so no key reaches user code — but the generator blocks Leaf for a plain JsValue parameter and Any is also the "undeclared" sentinel. FastCallGuard.AnyValue expresses the author's claim; the generator resolves it to Any so no shape ever carries it, with a Debug.Assert making sure. Without it the guard would have been String | Number, sending the object keys a Map exists for back to the framed path.

Mutating leaves verified safe: frame elision skips only push/recursion-charge/pop; no version counter or iteration state hangs off the frame. Pinned by a test that mutates through warmed frameless sites and reads back via size, forEach, a live iterator and spread. Honest residual, documented in-code: a wrapped CLR key runs the host's Equals/GetHashCode — host code, identical on the framed path.

Declined, with reasons in-code: WeakMap/WeakSet (set/add TypeError on a non-weakly-holdable argument no guard expresses; two more bits on a colder path); a wrong-receiver benchmark row (every guard failure is a call that throws, so the row would time exception construction — replaced by WeakMapHas_Framed/WeakSetHas_Framed as same-shape framed controls); getOrInsert (not even FastCall today; own change).

Leaf audit by falsification: dropping only the receiver guard turns the wrong-brand theories red with the LeafCallGuard assert firing from the frameless branch; a temporary in-body probe proved the frameless branch is entered for guarded receivers including subclass instances. Both reverted; Debug legs green (5179/5098), verification legs green, test262 byte-identical to baseline including the same 8 pre-existing load-flake timeouts.

Flagged for someone to chase, unrelated to this change: three full-suite runs died with a test-host crash that --blame-crash pins on Atomics_waitAsync_bigint no-spurious-wakeup-on-exchange.js (a $262.agent worker test); 6/6 clean filtered repeats afterwards, and a concurrent test262 run from another worktree was contaminating the blame directory — environmental here, but a pre-existing flake.

🤖 Generated with Claude Code

@lahma

lahma commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark gate — PASS

Single clean pair vs the merge-base (node reuse disabled, idle machine). The verdict rests on the millisecond-scale MapSetLookupBenchmark rows — far above the ns-scatter floor — plus the deterministic allocation column:

Row base cand Δ
MapGetHit 16.23 ms 14.08 ms −13.3%
MapSetDeleteChurn 31.21 ms 28.09 ms −10.0%
MemoizePattern 19.24 ms 17.74 ms −7.8%
SetHasMixed / MapGetMixed / MapHasMixed −2.0% / −1.6% / −1.4%
new ns rows: MapSet_StringKey / SetAdd_Churn / SetHas_ObjectValue / SetHas_NumberValue / MapDelete_Churn / MapGet_Miss −13.1% / −12.4% / −11.9% / −9.3% / −6.7% / −4.9%
framed controls: WeakMapHas_Framed / WeakSetHas_Framed +0.2% / −3.4%

Allocation byte-identical on every row of the class and its neighbours.

One unresolved ns-scale cell, disclosed: MapGet_ObjectKey read +11.7% in this pair — but untouched neighbour rows in the same class swung up to +20% (Substring_Guarded, which read −15% in a different session's pair), so a single ns-scale reading cannot be attributed. The ms-scale MapGetMixed row, which is half object keys, improved −1.6%, which bounds any real object-key effect to well under the single-row reading.

@lahma
lahma marked this pull request as ready for review August 12, 2026 17:08
Map.prototype's get/has/set/delete and Set.prototype's has/add/delete already
took the fast-call lane's register half. They could not take its frameless half,
because Leaf needs every route to user code or a JavaScript error closed off by
a guard the runtime can check per call, and neither of the two routes these
methods have was expressible.

The receiver route now is. JsMap and JsSet take an InternalTypes bit each, set
in the constructor every instance reaches -- including the
OrdinaryCreateFromConstructor path a `class MyMap extends Map` takes -- so the
bit means exactly what AssertMapInstance asks. A bit rather than a type test
like Date's, because a keyed-collection guard is asked on a hot lookup and
putting it in Satisfies' type-test tail would have lengthened that tail for
every kind, including the ones that merely fail. One bit each rather than a
shared "keyed collection" one, so Map.prototype.get.call(aSet, k) still reaches
its TypeError with its frame.

The argument route needed a word the guard vocabulary did not have. A Map key is
hashed and compared with SameValueZero and never converted, so unlike a
String.prototype argument no key can reach a user valueOf -- but a plain JsValue
parameter blocks Leaf by design, and FastCallGuard.Any cannot double as the
author's claim of no hazard, being also what the generator refuses to infer.
FastCallGuard.AnyValue is that claim: declaration-only, resolved to Any by the
generator so no shape ever carries it, and it is what lets these stay leaf for
the object keys a Map exists for rather than only for strings and numbers.

WeakMap and WeakSet decline. Their set/add raise a TypeError for a key that
cannot be held weakly, which is a property of the argument no guard expresses,
so a receiver bit would have bought only half the family -- and spent the
InternalTypes headroom doing it. WeakMapPrototype.Has says so.

The declining path is pinned rather than benchmarked: a keyed collection's only
guard is the exact condition its body throws for, so every call that fails it is
a call that was going to throw, and a loop of those would time exception
construction. The new FastCallLaneBenchmarks rows use WeakMap/WeakSet has as the
same-shape never-leaf control instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma force-pushed the perf/fastcall-map-set branch from 6f20737 to 1a5f28f Compare August 12, 2026 18:49
@lahma

lahma commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased over #2975 (proper tail calls). The rebase was a real conflict, not a textual one: PTC took InternalTypes bit 1 << 26 for TailCall — the bit this PR had assigned to Map. Map/Set moved to 1 << 27/1 << 28, consuming the last free flag bit (the headroom comment now says so), and the renumbering propagated through all three coordinated copies: the runtime enum, the source generator's mirror — whose JINT023 drift diagnostic caught the mismatch exactly as designed — and the generator test fixture, caught by its Verify snapshot.

Full post-rebase gate: solution green on both TFMs (5,525/5,443 Jint.Tests), generator snapshots 52/52, and test262 at the new post-PTC baseline: 99,779 passed / 0 failed / 122 skipped#2975 un-parked the 35 tail-call exclusions and they pass.

@lahma
lahma merged commit db8c0fc into sebastienros:main Aug 13, 2026
5 checks passed
@lahma
lahma deleted the perf/fastcall-map-set branch August 13, 2026 16:12
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>
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