Let Map and Set lookups run frameless - #2984
Conversation
0a7d27a to
6f20737
Compare
Benchmark gate — PASSSingle clean pair vs the merge-base (node reuse disabled, idle machine). The verdict rests on the millisecond-scale
Allocation byte-identical on every row of the class and its neighbours. One unresolved ns-scale cell, disclosed: |
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>
6f20737 to
1a5f28f
Compare
|
Rebased over #2975 (proper tail calls). The rebase was a real conflict, not a textual one: PTC took 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. |
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>
Map.prototype.get/has/set/deleteandSet.prototype.has/add/deleteclaimLeaf, 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/Setbits, aliased byFastCallGuard.Map/Set, keepSatisfiesat one mask test for passing and failing receivers. The Date precedent (spare bit +is JsDatetype test) was rejected deliberately: two more type-tested kinds lengthen the fallthrough tail every failing guard of every kind pays. Both classes arepublic sealedand the bit is set in the single constructor every instance — subclasses included — reaches, so the bit is exactlyis JsMap/is JsSetand 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) == Emptyin 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 blocksLeaffor a plainJsValueparameter andAnyis also the "undeclared" sentinel.FastCallGuard.AnyValueexpresses the author's claim; the generator resolves it toAnyso no shape ever carries it, with aDebug.Assertmaking sure. Without it the guard would have beenString | 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'sEquals/GetHashCode— host code, identical on the framed path.Declined, with reasons in-code: WeakMap/WeakSet (
set/addTypeError 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 byWeakMapHas_Framed/WeakSetHas_Framedas same-shape framed controls);getOrInsert(not evenFastCalltoday; own change).Leaf audit by falsification: dropping only the receiver guard turns the wrong-brand theories red with the
LeafCallGuardassert 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-crashpins onAtomics_waitAsync_bigintno-spurious-wakeup-on-exchange.js(a$262.agentworker 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