Skip to content

perf(rhino): share sealed standard scope across evaluations - #54

Merged
rapatao merged 5 commits into
mainfrom
perf/rhino-shared-scope
Aug 31, 2026
Merged

perf(rhino): share sealed standard scope across evaluations#54
rapatao merged 5 commits into
mainfrom
perf/rhino-shared-scope

Conversation

@rapatao

@rapatao rapatao commented Aug 31, 2026

Copy link
Copy Markdown
Owner

RhinoEvaluator.call rebuilt the entire JavaScript standard library on every evaluate via initSafeStandardObjects(), then threw it away. Identical work every call.

The standard objects are now built once per evaluator instance and sealed. Each evaluation gets a cheap child scope with those objects as its prototype, and the input is injected there. This is the usual Rhino shared-scope pattern.

private val sharedScope: ScriptableObject =
contextFactory.call { context -> context.initSafeStandardObjects(null, true) }

val scope = context.newObject(sharedScope) as ScriptableObject
scope.prototype = sharedScope
scope.parentScope = null

No new constructor parameter and no ThreadLocal. Unlike the GraalJS context reuse in #52, this loses no isolation, so it is not opt-in.

Results

./gradlew :rhino-evaluator:bench -PbenchIterations=2000, 147-expression suite:

ops/s avg per iteration p50
before 44,712 3.29ms 3.14ms
after 352,564 417us 340us

7.9x, stable between 344,000 and 353,000 across three runs. Rhino moves from ~12x the Kotlin engine's cost to ~1.6x, ahead of GraalJS in either mode.

Isolating a single rule in a warmed loop, scope setup drops from 13.00us to 0.17us (~63% of the call to ~14%).

Isolation

The child scope keeps all three properties the risk analysis called out:

  • Bindings: input lands on the child, which is discarded after the evaluation
  • Threads: the standard objects are sealed and only read; each thread mutates only its child
  • Rule side effects: parentScope = null makes the child the top of the scope chain, so a global assigned by a rule dies with the evaluation

Two implementation details worth knowing. ScriptableObject.getTopScopeValue walks the prototype chain, so the ClassCache and library scope associated with the shared scope stay reachable from the child; sharing the ClassCache also means TypedInjector's reflection results now cache across evaluations. And the shared scope object itself must not be sealed with sealObject(), only its builtins, because initSafeStandardObjects registers LazilyLoadedCtor entries that still write themselves onto the scope on first access.

Documentation corrections

The README claim that interpretedMode = false is "about 10x slower" no longer holds. Compiled mode is unchanged, but the interpreted path got ~10x faster, so the gap is now about 114x (37.7ms vs 0.33ms per iteration). Corrected in both places it appeared.

The "Where the time goes" table was re-measured for all engines with a single harness. Its one evaluate call column had been under-warmed for the cheap engines, and updating only the Rhino row would have made Rhino appear faster than the Kotlin engine on a single rule, contradicting the suite table. The GraalJS row moves from 122.4us to 133.7us; that reflects the per-call context close added in #52, not this change.

Tests

RhinoSharedScopeTest covers stale bindings between evaluations, rule-defined globals not leaking, sealed builtins rejecting Array.prototype.foo = 1, and 8-thread concurrent evaluation of the full case set.

./gradlew build green, 177 tests.

Added engine comparison table, internals explanations, trade-offs, and
performance data. Added bench Gradle tasks.
Introduces `reuseContextPerThread` flag to keep one context per thread instead of building and closing per evaluation. Context construction dominated runtime; reusing it removes that cost. Maintains thread-safety and input isolation by injecting a fresh object per call. Adds benchmark comparison, comprehensive docs on trade-offs, and concurrent evaluation tests.
…ntext

* origin/main:
  docs: add benchmark comparison and engine guide (#52)

# Conflicts:
#	README.md
#	graaljs-evaluator/build.gradle
Build JavaScript standard objects once per evaluator instance and seal them.
Each evaluation runs in a cheap child scope with those sealed objects as its
prototype, so input data is never shared between evaluations and standard
objects are never mutated. Gives ~7.4x speedup while keeping evaluate safe to
call concurrently and maintaining input isolation through scope hierarchy.
@rapatao
rapatao marked this pull request as ready for review August 31, 2026 17:36
Base automatically changed from perf/graaljs-reuse-context to main August 31, 2026 17:38
@rapatao
rapatao merged commit dd3529e into main Aug 31, 2026
4 checks passed
@rapatao
rapatao deleted the perf/rhino-shared-scope branch August 31, 2026 18:19
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