Unboxed lane for relational tests against slot-stored numbers#2574
Merged
Conversation
lahma
enabled auto-merge (squash)
July 4, 2026 18:12
Decomposing a fully optimized function-local loop (new LoopDispatchBenchmarks) showed the for-statement machinery dominating at ~48 ns/iter: the slot-stored counter ping-pongs between its unboxed update (stores a raw double) and its loop test (re-materializes a JsNumber), and past the interned-int range every test read and update allocates (~27 B/iter). Relational comparisons (<, >, <=, >=) whose left side resolves to a slot-stored number and whose right side is a numeric literal now compare raw doubles in both boolean and value positions - no operand materialization, no JsBoolean round-trip in test position, and the counter stays unboxed for its whole lifetime. IEEE double comparisons reproduce the abstract relational operator for all four forms including NaN (undefined coerced to false). The lane resolves through the shadow-aware SlotLocationCache walk and bails to the materialized path for operator overloading, suspendable frames, const/TDZ and non-number bindings. Same-window stash A/B: LoopDispatch EmptyLoop -37% (48.5 -> 30.6 ns/iter) with allocation 2.74 MB -> 2.5 KB per op, ComparisonOnly -35.7%, DoubleAccumulator/LargeIntCounter ~-32% with Gen0 eliminated, EvalExecution hot rows ~-24% (the optimized-loop floor drops 60 -> 46 ns/iter), 3d-cube driver -6.2%, stopwatch canary within noise. New RelationalComparisonTests pin NaN, non-numeric transitions, const, +/-0 and block-shadowed bindings through the cached-eval walk. Jint.Tests 3195/3133, PublicInterface, CommonScripts, Test262 99260/0, all-TFM build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lahma
force-pushed
the
ws4-unboxed-loop-test
branch
from
July 4, 2026 18:17
5a50765 to
4527ad1
Compare
This was referenced Jul 4, 2026
This was referenced Jul 6, 2026
This was referenced Jul 8, 2026
lahma
added a commit
that referenced
this pull request
Jul 9, 2026
===, !==, == and != now take the same per-node numeric lane the relational operators gained in #2574/#2578: when both operands resolve to slot-stored numbers (or a numeric constant), the comparison runs on raw doubles without materializing either side. Both operands are runtime-proven Numbers, so the IEEE compare is spec-exact for all four operators (IsLooselyEqual defers to IsStrictlyEqual for same-type operands; NaN == NaN is false, +0 == -0 is true); any non-number operand declines to the generic coercing path before any side effect. The dominant win is allocation: a loop test like `i === 50000` was re-boxing the counter into a JsNumber on every iteration once it exceeded the small-int cache. LoopDispatchBenchmarks A/B (default job, adjacent runs): | row | base | lanes | |-----------------|---------------------|---------------------| | StrictEqualTest | 5.967 ms / 2,808 KB | 3.710 ms / 3.26 KB | | LooseEqualTest | 5.523 ms / 2,808 KB | 3.777 ms / 3.27 KB | -38% / -32% time, -99.9% allocation (Gen0 eliminated). Untouched rows byte-identical alloc; time deltas were sign-mixed and traced to window drift via an A/B/A re-run on identical bits. New LoopDispatch rows (StrictEqualTest/LooseEqualTest/ModuloEqualTest) pin the shapes; ModuloEqualTest (the stopwatch.js if-chain shape) is deliberately not covered yet - its left side is a modulo expression, not an identifier. EqualityLaneTests pin NaN/signed-zero semantics, mid-loop type-change decline, BigInt/mixed-type bail and value positions. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Jul 9, 2026
…ache (#2603) * Add unboxed slot-number lane to equality operators ===, !==, == and != now take the same per-node numeric lane the relational operators gained in #2574/#2578: when both operands resolve to slot-stored numbers (or a numeric constant), the comparison runs on raw doubles without materializing either side. Both operands are runtime-proven Numbers, so the IEEE compare is spec-exact for all four operators (IsLooselyEqual defers to IsStrictlyEqual for same-type operands; NaN == NaN is false, +0 == -0 is true); any non-number operand declines to the generic coercing path before any side effect. The dominant win is allocation: a loop test like `i === 50000` was re-boxing the counter into a JsNumber on every iteration once it exceeded the small-int cache. LoopDispatchBenchmarks A/B (default job, adjacent runs): | row | base | lanes | |-----------------|---------------------|---------------------| | StrictEqualTest | 5.967 ms / 2,808 KB | 3.710 ms / 3.26 KB | | LooseEqualTest | 5.523 ms / 2,808 KB | 3.777 ms / 3.27 KB | -38% / -32% time, -99.9% allocation (Gen0 eliminated). Untouched rows byte-identical alloc; time deltas were sign-mixed and traced to window drift via an A/B/A re-run on identical bits. New LoopDispatch rows (StrictEqualTest/LooseEqualTest/ModuloEqualTest) pin the shapes; ModuloEqualTest (the stopwatch.js if-chain shape) is deliberately not covered yet - its left side is a modulo expression, not an identifier. EqualityLaneTests pin NaN/signed-zero semantics, mid-loop type-change decline, BigInt/mixed-type bail and value positions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve comparison-lane operands from the validated global-descriptor cache The numeric comparison lane (relational since #2574/#2578, equality since the previous commit) could only read slot-stored operands, so it never fired on top-level code: script-level vars are global-object properties, not slots - the exact shape of stopwatch.js and every benchmark that loops at script top level. On a slot miss the lane now falls back to the identifier's validated global-descriptor cache (engine identity + shape version + lexical version, populated by the regular GetValue slow path). The probe is pure - cache validation plus a field read - so declines stay unobservable, and the value type is re-checked on every read because in-place global writes bump no version. The null pre-check keeps the cost for non-global misses to one field test; the helper is out of line so lane misses don't grow the inlined probe. A/B vs the equality-lanes base (default job, adjacent runs): - GlobalAccessBenchmarks.GlobalUpdateLoop 89.9 -> 80.9 ms (-10.1%) - top-level loop counters' tests take the lane now - stopwatch driver (+-0.7% instrument) 175.7 -> 171.5 ms/iter (-2.4%); StopwatchBenchmark classic prepared -1.0%, modern Execute -2.4% - allocations byte-identical on every row - EmptyLoop/LocalVarLoop single-launch upticks resolved as process-mode noise via --launchCount 5 (medians 3.044/39.72 ms, at or below base) Tests pin global-binding lane semantics: NaN/signed zero through globals, accessor redefinition invalidating via version bump, and in-place value-type changes declining per read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Jul 9, 2026
…eads (#2604) * Serve comparison-lane operands from the validated global-descriptor cache The numeric comparison lane (relational since #2574/#2578, equality since the previous commit) could only read slot-stored operands, so it never fired on top-level code: script-level vars are global-object properties, not slots - the exact shape of stopwatch.js and every benchmark that loops at script top level. On a slot miss the lane now falls back to the identifier's validated global-descriptor cache (engine identity + shape version + lexical version, populated by the regular GetValue slow path). The probe is pure - cache validation plus a field read - so declines stay unobservable, and the value type is re-checked on every read because in-place global writes bump no version. The null pre-check keeps the cost for non-global misses to one field test; the helper is out of line so lane misses don't grow the inlined probe. A/B vs the equality-lanes base (default job, adjacent runs): - GlobalAccessBenchmarks.GlobalUpdateLoop 89.9 -> 80.9 ms (-10.1%) - top-level loop counters' tests take the lane now - stopwatch driver (+-0.7% instrument) 175.7 -> 171.5 ms/iter (-2.4%); StopwatchBenchmark classic prepared -1.0%, modern Execute -2.4% - allocations byte-identical on every row - EmptyLoop/LocalVarLoop single-launch upticks resolved as process-mode noise via --launchCount 5 (medians 3.044/39.72 ms, at or below base) Tests pin global-binding lane semantics: NaN/signed zero through globals, accessor redefinition invalidating via version bump, and in-place value-type changes declining per read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fuse the modulo-vs-constant equality test into a single unboxed lane `identifier % numericConstant ==/===/!=/!== numericConstant` - the stopwatch.js if-chain shape, executed ~820k times per benchmark op - is now evaluated as one fused lane on the equality expressions: the identifier is read through the same slot/global arms as the comparison lane and the whole test runs on raw doubles, skipping the modulo node's dispatch, operand materialization and result boxing. C# double % implements Number::remainder exactly, and the only integer-vs-double remainder divergence (-0 vs +0 results) is erased by the equality consumer (IEEE == treats them equal), so the fused result is spec-exact for any proven-Number operand: NaN dividends, % 0 and NaN constants compare false (true negated), v % ±Infinity == v. Non-number operands decline to the generic coercing path pre-effect. A/B (default job, adjacent runs, vs the global-arm base): - LoopDispatchBenchmarks.ModuloEqualTest 8.05 -> 5.56 ms (-31%), allocation 2,808 KB -> 3.3 KB (Gen0 eliminated) - stopwatch driver 167.9 -> 152.6 ms/iter (-9.1%); StopwatchBenchmark classic Execute -9.0%, classic prepared -10.5%, allocations flat Tests pin sign/zero behavior across negative dividends, NaN / % 0 / Infinity specials, the coercing decline, and the global-binding shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Admit immutable bindings to the comparison-lane operand reads TryGetNumberSlot requires binding.Mutable so that its read-modify-write callers may pair it with SetNumberSlot unchecked - but the comparison, equality and fused-modulo lanes reused it for pure reads, inheriting the write-side constraint. Consequence: no lane ever fired on a `const` operand, and stopwatch-modern's entire `const z` if-chain (plus every `const` bound/limit in loop tests anywhere) declined per evaluation - turning the fused-modulo commit into a small regression on that row (driver +2.0%). The lanes now read through TryGetNumberSlotForRead, a read-only sibling without the Mutable gate. TDZ stays intact: an uninitialized binding has neither an unboxed number nor a reference value, so it declines and the generic path raises the ReferenceError. Writers keep the Mutable-gated form (compound assignment on a const must still throw through the generic path). A/B vs the global-arm base, both fused-modulo commits applied (default job, adjacent runs): | StopwatchBenchmark | base | fused+const | |----------------------|----------|-------------| | classic Execute | 158.6 ms | 141.6 ms (-10.7%) | | classic Prepared | 152.9 ms | 137.5 ms (-10.1%) | | modern Execute | 173.7 ms | 153.0 ms (-11.9%) | | modern Prepared | 169.7 ms | 149.8 ms (-11.7%) | Drivers: stopwatch-modern 186.3 -> 167.7 ms/iter (-10.0%, was +2.0% before this commit), classic 167.1 -> 153.0 (-8.4%). Allocations flat. Tests pin the const shapes through all three lane forms and the TDZ ReferenceError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 10, 2026
This was referenced Jul 20, 2026
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.
Decomposing the per-iteration cost of a fully-optimized function-local loop (new
LoopDispatchBenchmarks) showed the for-statement machinery itself — not the body — dominating at ~48 ns/iter, with two structural taxes on the canonicali < limittest: the slot-stored counter ping-pongs between its unboxed update (i++stores a raw double) and its loop test (which re-materializes aJsNumber), and once the counter passes the interned-int range every test read plus update allocates (~27 B/iter — 2.74 MB per 100k-iteration loop).What this does
Relational comparisons (
<,>,<=,>=) whose left side is an identifier resolving to a slot-stored number and whose right side is a numeric literal now compare raw doubles in both boolean (loop/if test) and value positions — no operand materialization, noJsBooleanround-trip in test position, and the counter stays unboxed for its whole lifetime. IEEE double comparisons reproduce the abstract relational operator exactly for all four forms including NaN (spec result undefined, coerced to false). The lane resolves through the shadow-awareSlotLocationCachewalk, bails to the materialized path for operator overloading, suspendable frames, const/TDZ/non-number bindings, and anything not slot-stored.Measurements (same-window stash A/B, BenchmarkDotNet default job)
Together with #2565/#2566 this takes the fully-optimized loop floor from 72 → 46 ns/iter, and loops whose counters exceed the interned-int range stop allocating entirely.
Gates
RelationalComparisonTestspin the corners: NaN in test/value positions, non-numeric transitions, const bindings, ±0, and block-shadowed bindings through the cached-eval walk.Jint.Tests,PublicInterface,CommonScripts, Test262 99,260 passed / 0 failed, all-TFM build.🤖 Generated with Claude Code