Skip to content

perf(graaljs): opt-in per-thread context reuse, plus benchmark tooling and engine guide - #53

Merged
rapatao merged 3 commits into
mainfrom
perf/graaljs-reuse-context
Aug 31, 2026
Merged

perf(graaljs): opt-in per-thread context reuse, plus benchmark tooling and engine guide#53
rapatao merged 3 commits into
mainfrom
perf/graaljs-reuse-context

Conversation

@rapatao

@rapatao rapatao commented Aug 31, 2026

Copy link
Copy Markdown
Owner

What

Two related changes to the GraalJS evaluator and the docs around it.

Benchmark tooling and engine guide (edb7589)

Adds a bench task to each evaluator module that replays the 147 expressions from
TestData against its engine, and documents the three engines in the README:
how each one works, what it is best for, its trade-offs, and where its time goes.

Opt-in context reuse (6413b0c)

GraalJSEvaluator.call built a new polyglot Context for every evaluate call and
never closed it. Context construction was about 88% of a single-rule evaluation, and
the contexts were left to the garbage collector rather than released deterministically.

  • The per-call context is now built with use { }, so it is closed on every path.
  • New reuseContextPerThread flag (default false, so existing behaviour is unchanged).
    When enabled, each thread keeps one Context in a ThreadLocal. A polyglot Context
    rejects concurrent access, so one context per thread keeps evaluate safe to call
    concurrently without a lock that would serialise evaluations.
  • Input data no longer lands on the global object. Each evaluation gets a fresh JavaScript
    object bound to a single global (__ruleset_input__) that is replaced wholesale, and
    operators evaluate inside with (__ruleset_input__) { ... }. Binding isolation is
    structural in both modes instead of depending on a fresh context.
  • An operator can re-enter evaluate through EvalContext.engine(). In the reused mode
    the nested call would otherwise leave its own input in place for the rest of the outer
    evaluation, so the enclosing input scope is saved and restored.

Numbers

2000 iterations of the 147-expression suite, Apple M3 Pro, Amazon Corretto 21.

mode ops/s p50
before 9,750 14.90ms
default (now closed per call) 9,391 15.57ms
reuseContextPerThread = true 185,000 to 294,000 415us to 613us

Reuse is roughly 25x. Its spread across runs is wide: with the context cost gone, an
iteration is short enough that this timing loop measures JIT and GC noise as much as the
engine. Closing the context and the namespace indirection cost the default path about 4%,
and buy deterministic release plus isolation that holds under reuse.

Behaviour notes

  • Default mode is unchanged apart from the context now being closed. A Value returned out
    of call is dead once it returns; evaluate returns a Boolean, so nothing in-tree is
    affected.
  • parseParameters keeps its signature but now receives the per-evaluation scope object
    rather than the global bindings. Subclasses that override it are unaffected in behaviour.
  • Reused mode does not isolate globals a rule writes itself (globalThis.x = 1, redefining
    a builtin); those persist for later evaluations on the same thread. Per-thread contexts
    are not closed either, so it suits a bounded pool. Both are documented in the README.

Verification

  • ./gradlew check is green: detekt, every module's tests, and the kover 90% branch and
    instruction gates.
  • GraalJSReusedContextEvaluatorTest runs the full case suite in reused mode (175 tests)
    plus targeted tests for stale bindings between evaluations, nested evaluation through
    engine(), and 8-thread concurrent evaluation.
  • The nested-evaluation test was confirmed to fail with the save/restore removed, so it
    guards the behaviour rather than passing incidentally.
  • ./gradlew :graaljs-evaluator:bench -PbenchIterations=2000 [-PbenchReuse=true] for the
    figures above.

Rhino's equivalent scope-per-call cost is untouched and left as separate work.

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
@rapatao
rapatao marked this pull request as ready for review August 31, 2026 15:57
@rapatao
rapatao merged commit 2084bfa into main Aug 31, 2026
4 checks passed
@rapatao
rapatao deleted the perf/graaljs-reuse-context branch August 31, 2026 17:38
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