Skip to content

Unboxed lane for relational tests against slot-stored numbers#2574

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:ws4-unboxed-loop-test
Jul 4, 2026
Merged

Unboxed lane for relational tests against slot-stored numbers#2574
lahma merged 1 commit into
sebastienros:mainfrom
lahma:ws4-unboxed-loop-test

Conversation

@lahma

@lahma lahma commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

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 canonical i < limit test: the slot-stored counter ping-pongs between its unboxed update (i++ stores a raw double) and its loop test (which re-materializes a JsNumber), 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, no JsBoolean round-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-aware SlotLocationCache walk, 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)

Benchmark before after Δ
LoopDispatch EmptyLoop (100k iters) 4.849 ms / 2.74 MB 3.057 ms / 2.5 KB −37% / −99.9% alloc
LoopDispatch ComparisonOnly 7.143 ms 4.593 ms −35.7%
LoopDispatch CounterAdd / StringAppend 5.695 / 6.192 ms 3.785 / 4.473 ms −33.5% / −27.8%
DoubleAccumulator / LargeIntCounter 5.82 / 5.79 ms / 2.74 MB 3.92 / 4.00 ms / 2.9 KB ~−32%, Gen0 eliminated
AccumulatorWithCallArg / MixedArithmetic 31.4 / 17.3 ms 28.1 / 15.8 ms −10.6% / −8.9%
EvalExecution: EvalHot / NewFunctionHot / PlainFunction 923 / 962 / 962 µs 707 / 734 / 733 µs ≈−24%
dromaeo-3d-cube driver 35.70 ms/iter 33.48 ms/iter −6.2%
stopwatch driver (canary) 172.8 ms/iter 174.9 ms/iter +1.2%, within the day's spread

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

  • New RelationalComparisonTests pin 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

@lahma
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
lahma force-pushed the ws4-unboxed-loop-test branch from 5a50765 to 4527ad1 Compare July 4, 2026 18:17
@lahma
lahma merged commit cae25e1 into sebastienros:main Jul 4, 2026
4 checks passed
@lahma
lahma deleted the ws4-unboxed-loop-test branch July 4, 2026 18:32
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>
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