Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions Jint.Benchmark/PlainNumericAssignBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using BenchmarkDotNet.Attributes;
using Jint.Native;

namespace Jint.Benchmark;

/// <summary>
/// Function-local plain numeric assignment `lhs = a op b` (NOT compound `+=`, which already has an
/// unboxed slot path). The accumulator `s = s + i` is the micro-loop-sum shape: every iteration's
/// result is an uncached double that otherwise materializes a JsNumber.
/// </summary>
[MemoryDiagnoser]
[HideColumns("Error", "StdDev", "Median", "Gen0", "Gen1", "Gen2")]
public class PlainNumericAssignBenchmark
{
private Engine _engine = null!;
private Prepared<Script> _sumAssign;
private Prepared<Script> _divAssign;
private Prepared<Script> _mixed;

[GlobalSetup]
public void Setup()
{
_sumAssign = Engine.PrepareScript("""
function f() { var s = 0.5; for (var i = 0; i < 1000000; i++) { s = s + i; } return s; }
f();
""");

_divAssign = Engine.PrepareScript("""
function f() { var s = 1e30, h = 1.0000001; for (var i = 0; i < 1000000; i++) { s = s / h; if (s < 1) s = 1e30; } return s; }
f();
""");

// lhs distinct from rhs operands: d = a - b with a moving accumulator
_mixed = Engine.PrepareScript("""
function f() { var a = 0.0, b = 0.25, d = 0.0; for (var i = 0; i < 1000000; i++) { a = a + b; d = a - b; } return d; }
f();
""");

_engine = new Engine();
_engine.Evaluate(_sumAssign);
_engine.Evaluate(_divAssign);
_engine.Evaluate(_mixed);
}

[Benchmark] public JsValue SumAssign() => _engine.Evaluate(_sumAssign);
[Benchmark] public JsValue DivAssign() => _engine.Evaluate(_divAssign);
[Benchmark] public JsValue Mixed() => _engine.Evaluate(_mixed);
}
174 changes: 174 additions & 0 deletions Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -525,10 +525,52 @@ internal sealed class SimpleAssignmentExpression : JintExpression
private bool _leftIsCoverParenthesized;
private bool _initialized;

// Unboxed numeric assignment fast path (WS-5): `lhs = a op b` where lhs is a number-slot
// identifier and a, b are each a number-slot identifier or a numeric literal, for op in
// {+,-,*,/,%}. Eligibility is decided once in Initialize; everything else falls through.
private bool _numericFastPath;
private Operator _numericOperator;
private SlotLocationCache _lhsSlotCache;
private JintIdentifierExpression? _numericLeftId; // null => use _numericLeftLiteral
private double _numericLeftLiteral;
private JintIdentifierExpression? _numericRightId; // null => use _numericRightLiteral
private double _numericRightLiteral;

// Whether the assignment has the structural shape `identifier = (id|lit) op (id|lit)` for an
// arithmetic op. Computed eagerly (from the AST, before the lazy Initialize) so HasDiscardFastPath
// only routes this shape through EvaluateAndDiscard; every other assignment keeps its exact path.
private readonly bool _structurallyNumeric;

public SimpleAssignmentExpression(AssignmentExpression expression) : base(expression)
{
_structurallyNumeric = IsNumericAssignmentShape(expression);
}

private static bool IsNumericAssignmentShape(AssignmentExpression expression)
{
if (expression.Left is not Identifier || expression.Right is not NonLogicalBinaryExpression binary)
{
return false;
}

// Multiplication is intentionally excluded: the boxed binary path computes 0 * negative as
// integers and yields +0, whereas raw-double multiplication yields the spec-correct -0, so a
// double-based fast path would change observable behaviour. Addition/subtraction/division/
// remainder produce identical results on both paths, keeping this a pure optimization.
switch (binary.Operator)
{
case Operator.Addition:
case Operator.Subtraction:
case Operator.Division:
case Operator.Remainder:
return IsIdentifierOrNumericLiteral(binary.Left) && IsIdentifierOrNumericLiteral(binary.Right);
default:
return false;
}
}

private static bool IsIdentifierOrNumericLiteral(Expression node) => node is Identifier or NumericLiteral;

private void Initialize()
{
var assignmentExpression = (AssignmentExpression) _expression;
Expand All @@ -542,6 +584,54 @@ private void Initialize()
_leftIsCoverParenthesized = _left._expression.Range.Start != assignmentExpression.Range.Start;

_right = Build(assignmentExpression.Right);

TryEnableNumericFastPath(assignmentExpression);
}

private void TryEnableNumericFastPath(AssignmentExpression assignmentExpression)
{
if (_leftIdentifier is null || _evalOrArguments || _leftIsCoverParenthesized
|| assignmentExpression.Right is not NonLogicalBinaryExpression binary)
{
return;
}

switch (binary.Operator)
{
case Operator.Addition:
case Operator.Subtraction:
case Operator.Division:
case Operator.Remainder:
break;
default:
return;
}

if (!TryClassifyNumericOperand(binary.Left, out _numericLeftId, out _numericLeftLiteral)
|| !TryClassifyNumericOperand(binary.Right, out _numericRightId, out _numericRightLiteral))
{
return;
}

_numericOperator = binary.Operator;
_numericFastPath = true;
}

private static bool TryClassifyNumericOperand(Expression node, out JintIdentifierExpression? id, out double literal)
{
id = null;
literal = 0;
switch (node)
{
case Identifier identifier:
id = new JintIdentifierExpression(identifier);
return true;
case NumericLiteral numeric:
literal = numeric.Value;
return true;
default:
return false;
}
}

protected override object EvaluateInternal(EvaluationContext context)
Expand All @@ -560,6 +650,90 @@ protected override object EvaluateInternal(EvaluationContext context)
return completion ?? SetValue(context);
}

internal override bool HasDiscardFastPath => _structurallyNumeric;

internal override void EvaluateAndDiscard(EvaluationContext context)
{
if (!_initialized)
{
Initialize();
_initialized = true;
}

if (_numericFastPath && TryAssignNumeric(context))
{
return;
}

EvaluateInternal(context);
}

/// <summary>
/// Discard-mode fast path: a plain assignment whose left side is a slot-stored number and whose
/// right side is `a op b` over number-slot/literal operands computes on raw doubles and stores
/// unboxed, with no JsNumber materialization. Operand reads are pure slot reads (never getters),
/// so declining after a partial read has no observable effect; anything not matching the exact
/// shape — operator overloading, generators/async, non-number operands, non-slot bindings — falls
/// back to the boxed path before storing.
/// </summary>
private bool TryAssignNumeric(EvaluationContext context)
{
var engine = context.Engine;
if (context.OperatorOverloadingAllowed || engine.ExecutionContext.Suspendable is not null)
{
return false;
}

// The left side is overwritten, but SetNumberSlot requires a mutable, initialized number
// slot; TryGetNumberSlot confirms exactly that (and that it is slot-stored at all).
if (!_lhsSlotCache.TryResolve(engine, engine.ExecutionContext.LexicalEnvironment, _leftIdentifier!.Identifier, out var lhsEnv, out var lhsSlot)
|| !lhsEnv.TryGetNumberSlot(lhsSlot, out _))
{
return false;
}

if (!TryReadNumericOperand(context, _numericLeftId, _numericLeftLiteral, out var left)
|| !TryReadNumericOperand(context, _numericRightId, _numericRightLiteral, out var right))
{
return false;
}

double result;
switch (_numericOperator)
{
case Operator.Addition:
result = left + right;
break;
case Operator.Subtraction:
result = left - right;
break;
case Operator.Division:
// IEEE 754 division matches the ECMAScript algorithm for all special cases
result = left / right;
break;
case Operator.Remainder:
// IEEE 754 remainder (fmod) matches the ECMAScript algorithm for all special cases
result = left % right;
break;
default:
return false;
}

lhsEnv.SetNumberSlot(lhsSlot, result);
return true;
}

private static bool TryReadNumericOperand(EvaluationContext context, JintIdentifierExpression? id, double literal, out double value)
{
if (id is null)
{
value = literal;
return true;
}

return id.TryReadNumber(context, out value);
}

// https://262.ecma-international.org/5.1/#sec-11.13.1
private JsValue SetValue(EvaluationContext context)
{
Expand Down
21 changes: 21 additions & 0 deletions Jint/Runtime/Interpreter/Expressions/JintIdentifierExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,29 @@ public JintIdentifierExpression(Identifier expression) : base(expression)
?? new Environment.BindingName(expression.Name);
}

// Separate slot-location cache for the unboxed numeric read (WS-5); kept apart from the
// GetValue caches above so it never perturbs the materializing read path.
private SlotLocationCache _numberSlotCache;

public Environment.BindingName Identifier => _identifier;

/// <summary>
/// Reads this identifier as a raw double when it resolves to a slot-stored number binding,
/// without materializing a JsNumber. Returns false for everything else (binding not slot-stored,
/// not a number, uninitialized) so the caller falls back to the boxed path before any side effect.
/// </summary>
internal bool TryReadNumber(EvaluationContext context, out double value)
{
var engine = context.Engine;
if (_numberSlotCache.TryResolve(engine, engine.ExecutionContext.LexicalEnvironment, _identifier, out var slotEnv, out var slotIndex))
{
return slotEnv.TryGetNumberSlot(slotIndex, out value);
}

value = 0;
return false;
}

public bool HasEvalOrArguments
{
get
Expand Down