Skip to content

Store function-local numbers unboxed in environment slot bindings#2499

Merged
lahma merged 8 commits into
sebastienros:mainfrom
lahma:perf/unboxed-number-bindings
Jun 7, 2026
Merged

Store function-local numbers unboxed in environment slot bindings#2499
lahma merged 8 commits into
sebastienros:mainfrom
lahma:perf/unboxed-number-bindings

Conversation

@lahma

@lahma lahma commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Numeric read-modify-write statements on function-local bindings (s += 0.25;, i++; and friends) currently allocate a JsNumber per write once values leave the interned-int cache — double accumulators and large counters allocate on every loop iteration. This PR stores such values as raw doubles inside the environment slot and skips materialization entirely when the statement result is unobservable.

The first two commits fix pre-existing NullReferenceExceptions (instead of the spec-required ReferenceError) for compound assignment / update expressions on uninitialized (TDZ) bindings, e.g. { x += 1; let x; } — found by this work's test coverage and continuing the theme of #2497.

Design

Unboxed slot storageBinding packs its bools into a flags byte and gains a raw-double field. A slot binding can now be in a third state: unboxed number. Reads keep their existing instruction stream — the unboxed check nests inside the pre-existing null/TDZ branch — and a materializing read converts through JsNumber.Create with write-back caching, so repeated reads observe one instance and read-many patterns never cost more than the one allocation per write the boxed representation already paid. TryGetNumberSlot carries the same (uint) bounds guard as the identifier read path, so a stale cache falls back instead of throwing.

Discard lane — Normal-completion values of statements are spec-observable only at script/eval/module top level. Completion-value observability is an explicit per-list mode (Observable for top-level script/eval/module lists, NotObservable for function bodies, Inherit for nested blocks and switch-case consequents), maintained by JintStatementList with save/restore on EvaluationContext.CompletionValuesObservable. Expression statements in unobservable frames evaluate through a new JintExpression.EvaluateAndDiscard virtual whose default matches GetValue.

The lane is gated by a HasDiscardFastPath capability virtual rather than hardcoded type lists: update and compound assignment expressions override it, and JintSequenceExpression forwards the discard to its operands so comma-separated for-loop updates (for (...; ...; i++, j--)) reach the fast path too. Everything else keeps its exact current call sequence; generators/async functions always use the materializing path because suspended values flow through completions.

Producers — discarded i++/i-- and numeric compound assignments compute on raw doubles and store unboxed. The fast path engages only when every full-semantics trigger is absent (identifier target, no operator overloading, no generator/async frame, slot-stored mutable number binding, non-logical operator). The right-hand side runs exactly once; non-number results complete through ComputeCompound, the per-operator computation extracted from EvaluateInternal and shared by both lanes. IEEE 754 double division/remainder match the ECMAScript algorithms exactly, so the raw arms need no special cases.

Slot-location caching — the per-node cache (environment + slot index) used by the identifier read fast path and both producers now lives in a single SlotLocationCache struct. The chain walk validates the cached env by reference equality with an engine-identity gate, refuses to skip over an ObjectEnvironment (a with object can gain a shadowing property at any time — the check sits after the equality test so hop-0 hits pay nothing), and permanently disables itself for nodes whose binding can never be slot-stored (global/object/dictionary environments) so non-qualifying code doesn't pay a second environment walk.

Bugs found and fixed along the way

  • Dynamic with-statement shadowing: the previous per-node slot caches (and the identifier read cache that already ships in main) walked the env chain by reference equality only, so a with object that gained a shadowing property after the cache was populated kept being bypassed — reads returned the stale local, and the new write fast paths updated the local slot instead of the with-object property (with (obj) { i++; }). Fixed for reads, updates and compound assignments via the shared cache; covered by regression tests.
  • Dead ToInt32(JsValue) fast path: its range guard read doubleVal >= -(double) int.MinValue (i.e. >= 2147483648) combined with <= int.MaxValue — unsatisfiable — so every non-Integer bitwise/shift operand engine-wide took DoubleToInt32Slow. It now delegates to a corrected double-based ToInt32; a ToUint32(double) twin keeps raw-double shift counts off the JsValue path.

Benchmarks

Paired back-to-back runs on the same machine (BenchmarkDotNet defaults, [MemoryDiagnoser]), final branch head. New FunctionLocalNumberLoopBenchmark covers the targeted patterns, which the existing suite under-covers because its loops live at script top level where bindings are global-object properties:

Benchmark Base PR Time Allocated
DoubleAccumulator 9.21 ms / 5.48 MB 5.68 ms / 2.74 MB −38% −50%
LargeIntCounter 8.66 ms / 5.48 MB 5.66 ms / 2.74 MB −35% −50%
AccumulatorWithCallArg 37.59 ms / 12.04 MB 35.08 ms / 9.30 MB −7% −23%
MixedArithmetic 26.78 ms / 18.0 MB 18.73 ms / 11.9 MB −30% −34%

Regression watch (same protocol):

Benchmark Base PR Time Allocated
ForBencmark.ForVar 79.48 µs / 3.35 KB 79.17 µs / 3.37 KB −0.4% +0.6%¹
ForBencmark.ForLet 77.47 µs / 3.51 KB 77.76 µs / 3.53 KB +0.4% +0.6%¹
ObjectAccessBenchmark 136.4 ms / 60.43 MB 137.0 ms / 60.43 MB +0.4% ±0
MathHotPath Warm_AbsTightLoop 206.9 ns / 49 B 215.4 ns / 49 B +4.1%² ±0
MathHotPath Warm_MaxTightLoop 196.2 ns / 33 B 201.5 ns / 33 B +2.7%² ±0
Stopwatch classic (prepared) 217.4 ms / 26.5 MB 211.8 ms / 26.5 MB −2.6% ±0
Stopwatch modern (prepared) 247.8 ms / 26.71 MB 252.1 ms / 26.72 MB +1.7%³ ±0
Dromaeo Cube (classic, prepared) 13.69 ms / 5.24 MB 14.26 ms / 5.47 MB +4.2%³ +4.4%⁴
Dromaeo Cube (modern, prepared) 14.57 ms / 5.23 MB 14.73 ms / 5.46 MB +1.1% +4.4%⁴
Dromaeo ObjectString (4 cases) 150–188 ms / 1272 MB 152–167 ms / 1272 MB −12%…+5%³ −0.7 MB

¹ node-size growth (the slot-location cache on two expression types), visible only because this benchmark re-parses per execution.
² these two top-level 1000-iteration tight loops bounce ±3–4% between runs of identical code on this machine; the deltas sit inside that band (earlier pairs measured +1.7…+2.2%). The mechanical overhead is the for-update discard detour (~2 ns/iteration).
³ noisy classes: stopwatch-modern's own base varies ±6.5% across runs and ObjectString carries ±7–14 ms StdDev in both builds; the controlled micro equivalents of their hot shapes (ForLet, Stopwatch classic) show no regression. Unprepared Cube cases are bimodal (≈15.3 ↔ ≈18.5 ms) in both builds across process restarts and cannot resolve small deltas.
⁴ slot Binding grew 16 → 24 bytes; env-churn-heavy code shows it as allocated bytes while time stays within noise.

Validation

  • Jint.Tests green on net10.0 and net472, including new UnboxedBindingTests (-0/NaN/Infinity through materialization, int32 boundary widening, TDZ/const errors, sloppy-mode semantics, single rhs evaluation, closure visibility, generator/async fallback, observable-context materialization, dynamic with shadowing for reads/updates/compound assignments, comma-sequence updates, switch bodies) and CompletionValueTests (eval/top-level completion-value semantics test262 does not cover)
  • Jint.Tests.PublicInterface green (no public API change)
  • test262: 99,208 passed, 0 failed (no change)

🤖 Generated with Claude Code

lahma and others added 5 commits June 7, 2026 11:12
…d binding

The identifier fast path stored the null TDZ sentinel as the original left
value and computed with it, surfacing a NullReferenceException instead of
the spec-required ReferenceError for code like `{ x += 1; let x; }`.
Detect the uninitialized binding and take the Reference-based path, which
produces the proper error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndings

Same bug class as the previous commit: UpdateIdentifier read the null TDZ
sentinel and dereferenced it, surfacing a NullReferenceException instead of
the spec-required ReferenceError for code like `{ y++; let y; }`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Binding gains a raw-double representation alongside the materialized
JsValue: flags are packed into a byte and an UnboxedNumber flag marks
slots whose value lives in a double field instead of a heap JsNumber.

Reads stay on their existing instruction stream: the unboxed check nests
inside the pre-existing null/TDZ branch (HasReferenceValue == today's
IsInitialized), and a materializing read converts through JsNumber.Create
with write-back caching so repeated reads observe a single instance and
read-many patterns never cost more than the one allocation per write that
the boxed representation already paid.

TryGetNumberSlot/SetNumberSlot/FindSlotIndex expose the raw representation
to the numeric read-modify-write fast paths added in a follow-up commit;
nothing produces unboxed state yet, so this commit is behavior-neutral.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lane

Normal-completion values of statements are spec-observable only at
script/eval/module top level; inside function bodies only Return/Throw
completions surface. EvaluationContext.CompletionValuesObservable tracks
this (maintained by JintStatementList with save/restore: FunctionBody
lists clear it, top-level lists set it, blocks inherit), and expression
statements in unobservable frames evaluate through a new
JintExpression.EvaluateAndDiscard virtual whose default matches GetValue.

The discard lane is gated by node type at both call sites (expression
statements and for-statement updates): only update and compound
assignment expressions override it, so every other statement keeps its
exact current call sequence. Generators and async functions always use
the materializing path because suspended values flow through completions.

CompletionValueTests pin the observable cases test262 does not cover:
eval('1;2;3') inside functions, indirect eval, trailing expression values,
loops/blocks/switch/try at top level, generator/async results, class
static blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ents

i++ / s += <expr> statements on function-local number bindings now compute
on raw doubles and store unboxed via the discard lane, eliminating the
per-write JsNumber allocation for values outside the interned-int cache
(double accumulators, large counters).

The fast path engages only when every full-semantics trigger is absent:
identifier target, no operator overloading, no generator/async frame, a
slot-stored mutable binding currently holding a number, and (for compound
forms) a non-logical operator. The slot location is cached per node with
the same validity reasoning as JintIdentifierExpression's slot-binding
cache, and nodes whose binding can never be slot-stored (global/object/
dictionary environments) permanently stop attempting, so non-qualifying
code does not pay a second environment walk.

The right-hand side runs exactly once; non-number results complete
through ComputeCompound, the per-operator computation extracted from
EvaluateInternal and shared by both lanes. IEEE 754 double division and
remainder match the ECMAScript algorithms exactly, so the raw-double arms
need no special cases; bitwise forms use a new double-based
TypeConverter.ToInt32 with a correct range fast path.

FunctionLocalNumberLoopBenchmark covers the patterns this targets
(accumulators, large counters, mixed kernels, write-then-call-arg);
UnboxedBindingTests pin exact semantics: -0/NaN/Infinity through
materialization, int32 boundary widening, TDZ/const errors, single rhs
evaluation, closure visibility, generator/async fallback and
observable-context materialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma lahma force-pushed the perf/unboxed-number-bindings branch from d96685c to c84c9bc Compare June 7, 2026 08:17
lahma and others added 3 commits June 7, 2026 12:48
ToInt32(JsValue)'s range guard read `doubleVal >= -(double) int.MinValue`,
i.e. >= 2147483648, combined with <= int.MaxValue — unsatisfiable — so every
non-Integer operand of the bitwise/shift operators took DoubleToInt32Slow.
Delegate to the corrected double-based ToInt32 instead.

ToUint32(double) mirrors ToInt32(double) so raw-double callers do not need
to route shift counts back through the JsValue overload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The slot-location caches walked the environment chain by reference equality
only, skipping intermediate ObjectEnvironments. A `with` object that gains a
property with the cached name after the cache was populated must shadow the
slot binding, but the cached walk kept reading - and with the new discard
fast paths, writing - the function-local slot:

  function f() { var i = 0;
    for (var k = 0; k < 3; k++) {
      var obj = (k === 1) ? { i: 100 } : {};
      with (obj) { i++; }            // k==1 must update obj.i
    } return i; }                    // was 3 (obj.i untouched), now 2

The identifier read path had the same pre-existing gap (mis-reads instead of
mis-writes); the chain walk now refuses to skip over an ObjectEnvironment in
all of them, placed after the reference-equality test so hop-0 hits pay
nothing.

The cache itself (fields, bounded walk, populate/poison policy) was hand
written in three drifting copies; it now lives in one SlotLocationCache
struct used by the update and compound-assignment fast paths, with the
identifier path sharing FindSlotIndex for population. TryGetNumberSlot gains
the same (uint) bounds guard as the identifier read so a stale cache falls
back instead of throwing, and the discard-lane shift arms use the raw double
shift count instead of reaching back through the JsValue overload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch-case consequents are built as statement lists with a null statement,
which the completion-value logic treated as top-level, forcing values
observable and disabling expression-statement elision for everything inside
a switch nested in a function. Observability is now an explicit enum on the
statement list (Observable / NotObservable / Inherit) and switch consequents
inherit the enclosing frame's mode.

The discard gating also no longer hardcodes node types at the call sites:
JintExpression.HasDiscardFastPath is the capability flag, overridden by the
update/compound-assignment fast paths, and JintSequenceExpression forwards
the discard to its operands so comma-separated for-loop updates
(`for (...; ...; i++, j--)`) reach the fast path too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma lahma merged commit 3ca07cd into sebastienros:main Jun 7, 2026
7 of 8 checks passed
@lahma lahma deleted the perf/unboxed-number-bindings branch June 7, 2026 10:04
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