Skip to content

Unboxed fast path for plain numeric assignment (s = a op b)#2550

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:okojo-ws5-unboxed-assign
Jun 28, 2026
Merged

Unboxed fast path for plain numeric assignment (s = a op b)#2550
lahma merged 1 commit into
sebastienros:mainfrom
lahma:okojo-ws5-unboxed-assign

Conversation

@lahma

@lahma lahma commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Compound numeric assignment (s += i) already has an unboxed slot fast path that computes on raw doubles. Plain assignment of a numeric binary expression — s = s + i, d = a - b — did not: it ran the right-hand side through the boxed binary path (JsNumber.Create) and stored a materialized JsNumber, so a function-local numeric loop allocated a transient number on most iterations.

This adds a narrow, slot-gated discard fast path for lhs = a op b where:

  • lhs is a slot-stored number binding (a function local), and
  • each operand a, b is a number-slot identifier or a numeric literal, and
  • op ∈ { +, -, /, % }.

It reads the operands as raw doubles (JintIdentifierExpression.TryReadNumberDeclarativeEnvironment.TryGetNumberSlot), computes on double, and stores unboxed (SetNumberSlot) — mirroring the existing TryCompoundUnboxed path.

Behaviour-preserving. Eligibility is decided once, eagerly, from the AST (HasDiscardFastPath), so anything that isn't this exact shape — member/element targets, non-numeric or non-slot operands, operator overloading, generators/async — keeps its exact current code path (no added per-evaluation cost). Operand reads are pure slot reads (never getters), so declining after a partial read has no observable effect.

Multiplication is intentionally excluded. The boxed binary path multiplies small integers as integers and yields +0 for 0 * negative, whereas raw-double multiplication yields the spec-correct -0; including it would change observable behaviour. For + - / % the two paths produce identical results, so this stays a pure optimization. A differential test over the integer grid [-40,40]×[-13,13] (compared with Object.is, i.e. distinguishing ±0 and NaN) reports 0 divergences for all four enabled operators.

Benchmark

New Jint.Benchmark/PlainNumericAssignBenchmark (fresh-engine, default BDN job, .NET 10, Ryzen 9 5950X, same-runtime worktree A/B):

Benchmark Baseline This PR Δ time Δ alloc
SumAssign (s = s + i) 90.9 ms / 60.7 MB 58.7 ms / 30.2 MB −35% −50%
Mixed (a = a + b; d = a - b) 160.1 ms / 90.6 MB 88.0 ms / 30.2 MB −45% −67%
DivAssign (s = s / h) 157.9 ms / 60.7 MB 137.1 ms / 60.7 MB −13% flat

(SumAssign's residual allocation is the loop condition i < N re-boxing the counter, which this change does not touch.)

Regression guards (EngineComparison Jint_ParsedScript): dromaeo-object-array (the path the earlier broad unboxed-double attempt regressed) and stopwatch show flat allocation and time within run-to-run thermal noise. Member-assignment-heavy code (object-array) takes the unchanged HasDiscardFastPath=false path, so it cannot regress structurally.

Correctness

  • Differential fast-vs-boxed: 0 divergences across + - / %.
  • dotnet test Jint.Tests: 3138 passed, 0 failed.
  • dotnet test Jint.Tests.PublicInterface: 79 passed, 0 failed.
  • Test262 (strict + sloppy): 0 new failures.

🤖 Generated with Claude Code

Compound numeric assignment (s += i) already computes on raw doubles via an unboxed slot path,
but plain assignment of a numeric binary expression (s = s + i, d = a - b) ran the right-hand
side through the boxed binary path and stored a materialized JsNumber, so a function-local
numeric loop allocated a transient number on most iterations.

Add a narrow, slot-gated discard fast path for `lhs = a op b` where lhs is a slot-stored number,
each operand is a number-slot identifier or numeric literal, and op is + - / %. Operands are read
as raw doubles (JintIdentifierExpression.TryReadNumber -> TryGetNumberSlot), computed on double,
and stored via SetNumberSlot. Eligibility is decided once, eagerly, from the AST so anything that
isn't this exact shape keeps its exact current code path (HasDiscardFastPath gates the routing);
operand reads are pure slot reads, so declining after a partial read has no observable effect.

Multiplication is intentionally excluded: the boxed path multiplies small integers as integers and
yields +0 for 0 * negative, while raw-double multiplication yields the spec-correct -0 — including
it would change observable behaviour. For + - / % the two paths are identical, so this is a pure
optimization (a differential over the integer grid, compared with Object.is, reports 0 divergences).

PlainNumericAssignBenchmark (fresh engine, default job, .NET 10, same-runtime A/B):
  SumAssign (s = s + i):        90.9 ms / 60.7 MB -> 58.7 ms / 30.2 MB  (-35% time, -50% alloc)
  Mixed (a = a + b; d = a - b): 160.1 ms / 90.6 MB -> 88.0 ms / 30.2 MB (-45% time, -67% alloc)
  DivAssign (s = s / h):        157.9 ms / 60.7 MB -> 137.1 ms / 60.7 MB (-13% time)
Guards (dromaeo-object-array, stopwatch): flat allocation, time within thermal noise; the
member-assignment path is unchanged (HasDiscardFastPath=false). Jint.Tests 3138/0, Test262 0 new failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <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