Skip to content

Trim the fixed cost of a host script entry - #4338

Open
sad75 wants to merge 1 commit into
sebastienros:mainfrom
sad75:perf/engine-entry-allocations
Open

sad75 wants to merge 1 commit into
sebastienros:mainfrom
sad75:perf/engine-entry-allocations

Conversation

@sad75

@sad75 sad75 commented Oct 22, 2026

Copy link
Copy Markdown

Summary

Cut the per-call overhead of Execute/Evaluate for small scripts.

Details

Problem

For a small script on a warm engine, the cost of Evaluate is dominated by the work around the script, not the script itself. engine.Evaluate("a + b") allocates 312 B per call on main; 248 B of it is avoidable, and so is part of the time. Four steps run unconditionally:

# Where What it does for nothing
1 ExecuteForCompletion Allocates a closure and a Func<T> delegate (120 B) on every call, to pass four locals to ScriptEvaluation.
2 ScriptEvaluation Calls Debugger.OnBeforeEvaluate. Reading Debugger creates a DebugHandler on every engine, even when nothing uses debugging.
3 RunAvailableContinuations Takes the host-call claim and walks the drain after every script, even when there is no job, timer, Atomics.waitAsync waiter or rejection.
4 GlobalDeclarationInstantiation Allocates two Lists and a HashSet (128 B) before checking whether the script declares anything.

Fix

  1. ExecuteForCompletion passes its state in a readonly record struct ScriptEvaluationState to a static callback, through the existing ExecuteWithConstraints<TState, TResult> overload. No allocation.
  2. BeforeEvaluate is raised only if (_debugger is not null || _isDebugMode). A host can only subscribe by reading engine.Debugger, which creates the handler, so subscribers are still notified; debug mode still creates it at script entry.
  3. RunAvailableContinuations returns first if IsContinuationDrainNoOp() (new, in Engine.Pump.cs): no queued job and no task lane, no Atomics.waitAsync deadline, no pending rejection notification and, on net8.0+, _webApi is null. Otherwise the drain runs as before.
  4. GlobalDeclarationInstantiation returns right after MarkScriptEvaluated when there is no function, var or lexical declaration, and no Annex B block function that could bind (sloppy mode only).

Engines with web-API engine state (timers, scheduler, idle callbacks) never take shortcut 3. Module evaluation (Engine.Modules.cs) still reads Debugger directly; left out of scope.

Linked issue

None.

Test plan

  • Added or updated unit tests in Jint.Tests
  • Ran dotnet test --configuration Release locally
  • For ECMAScript spec changes: ran Jint.Tests.Test262 — not a spec change
  • For interop changes — not applicable
  • For perf changes: included before/after numbers from Jint.Benchmark

New tests, one per behaviour a shortcut must keep:

  • EngineTests.BeforeEvaluateFiresForAHandlerSubscribedAfterEarlierEvaluations
  • EngineTests.ADrainAfterAnIdleDrainStillRunsNewlyQueuedJobs
  • PromiseRejectionNotificationTests.ARejectionFromAScriptThatQueuesNoJobIsReportedOnce
  • AnnexBBlockFunctionTests.AScriptWhoseOnlyDeclarationIsABlockFunctionStillGetsTheGlobalAlias (source and Prepared<Script>)
  • AnnexBBlockFunctionTests.AStrictScriptWhoseOnlyDeclarationIsABlockFunctionGetsNoGlobalAlias (directive and Options.Strict)

Jint.Tests (39,239 passed) and Jint.Tests.PublicInterface (11,085 passed) are green on net472, net8.0 and net10.0.

Benchmark

New TinyScriptEntryBenchmark: three tiny scripts on a warm engine, plus a FunctionBody control row (Invoke) that none of the changes reach. measure-paired.ps1, 8 alternating rounds, baseline main at 4fd6bb9:

Row Time Δ (median, 95% CI) Allocated: main → PR
Expression (a + b) −29.3 % [−30.4, −27.6] 312 B → 64 B
Comparison (o.x > a && b !== 0) −19.0 % [−24.9, −16.8] 312 B → 64 B
Declaration (var c = a + b; c) −13.3 % [−18.4, −10.2] 400 B → 280 B
FunctionBody (control) +1.7 % [−0.9, +4.4], no change 112 B → 112 B

Fixes 1 and 4 account for the bytes (120 B + 128 B; Declaration declares a var, so it only gets fix 1). Fixes 2 and 3 save time only on a warm engine; fix 2 also saves one DebugHandler per engine, which this benchmark does not measure.

Run on GitHub-hosted ubuntu-latest (AMD EPYC 7763, 2 vCPU), BenchmarkDotNet 0.15.8, .NET 10.0.13. The benchmark file was copied into the baseline tree and BenchmarkDotNet's build timeout raised in both trees; nothing else in the harness changed. The SunSpider/Dromaeo gate was not run: these changes only touch per-entry setup, which a long script crosses once. Happy to run it if wanted.

Breaking change?

No. All changes are internal.

Execute and Evaluate of a small script paid for work it never used:

- ExecuteForCompletion passes its state to a static callback in a struct,
  so the entry allocates neither a closure nor a delegate.
- The debugger handler is only touched when it already exists or debug
  mode is on, so evaluating a script no longer creates it on an engine
  that never asked for it.
- RunAvailableContinuations skips its host-call claim when the drain
  provably has nothing to do: no queued job or task lane, no
  Atomics.waitAsync deadline, no pending rejection notification and, on
  net8.0+, no web-API engine state.
- GlobalDeclarationInstantiation returns early for a script that declares
  nothing, keeping the Annex B block-function case.

TinyScriptEntryBenchmark measures the entry cost, with a function-call
control row that none of these changes reach.
@sad75
sad75 marked this pull request as ready for review October 22, 2026 11:54

This branch has not been deployed

No deployments
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