Skip to content

Speed up JS script evaluation - #69

Merged
rubensworks merged 6 commits into
master-1.21-ltsfrom
claude/js-script-eval-perf-s6dy59
Aug 31, 2026
Merged

Speed up JS script evaluation#69
rubensworks merged 6 commits into
master-1.21-ltsfrom
claude/js-script-eval-perf-s6dy59

Conversation

@rubensworks

@rubensworks rubensworks commented Aug 26, 2026

Copy link
Copy Markdown
Member

Optimizes the JavaScript script evaluation pipeline, based on a new benchmark and JFR profiling. Each optimization is a separate commit with its own measurement.

Benchmarking

The existing BenchmarkValueTranslators only covers value translation in isolation, so this PR first adds BenchmarkScriptEvaluation, which covers the paths scripts actually go through at runtime: context creation, script instantiation, reading a script member value, calling a JS function as an Integrated Dynamics operator, calling an Integrated Dynamics operator from JS, and unwrapping values that were translated to Graal before. Both benchmarks run from test (./gradlew benchmark benchmarkScriptEvaluation).

The benchmark harness also had to be made trustworthy first: it now warms up before measuring, measures with System.nanoTime, and reports the fastest of several rounds. The previous millisecond-precision single-shot measurements had ~50% run-to-run variance, enough to hide or invent any of the changes below.

Profiling with JFR showed that translation cost is dominated by HostToGuestRootNode.execute and the thread-local context enter/leave around it — i.e. the number of Value API calls crossing the host boundary, not the Java-side work. Every optimization below reduces that number.

Commits

Lazily populate idContext.ops in script contexts — every created context eagerly translated all 276 global interact operators, which took longer than the rest of script instantiation combined, while many scripts use only a handful of them or none. ops is now a self-replacing lazy getter, so after the first access it is a plain data property again and repeated idContext.ops.x accesses stay as fast as before. Exposing ops as a host proxy object instead would have made every access cross the host boundary, which measured ~9% slower per operator call.

Don't overwrite the exports binding when translating an NBT end tag — a correctness fix rather than an optimization: this path evaluated exports = { 'nbt_end': true }, which assigns to the global exports binding and silently discarded whatever the script itself had exported. It now uses a proxy object, which also removes a JS parse+eval from that path.

Unwrap Graal proxies with instanceof instead of ClassCastException — the translators cast the proxy and caught the ClassCastException to detect a mismatch, so translating any proxy threw and caught up to seven exceptions before reaching its own translator. Proxy-unwrapNbt 2.67us → 1.10us (-59%).

Dispatch object value translators on their member key — each of the six object translators materialized the value's member keys to compare against its own single key. Translators now report that key (IValueTranslator#getGraalValueMemberKey, defaulting to null), so the registry materializes the set once. FromGraal-nbt 10.63us → 7.56us (-29%), FromGraal-item 5.10us → 3.73us (-27%). This one regresses Proxy-unwrapItem 0.59us → 1.17us, since the object translators used to detect their own proxies before looking at member keys — the next commit more than makes up for it.

Resolve value translators for round-tripped Graal proxies directly — proxies wrapping an Integrated Dynamics value now report their value type through IValueProxy, so their translator is found in two calls instead of a scan. Proxy-unwrapItem 1.28us → 0.54us (-58%), Operator-callJsFromId-item 1.71us → 1.03us (-40%), Proxy-unwrapNbt 0.93us → 0.56us (-39%).

Net result

Median of 3 alternating runs of the whole branch against its base:

benchmark before after change
Script-instantiate 474us 206us -56%
Proxy-unwrapNbt 2.10us 0.54us -74%
FromGraal-nbt 11.6us 8.07us -31%
Context-createPopulated 449us 319us -29%
FromGraal-item 5.35us 4.03us -25%
Operator-callJsFromId-item 1.05us 0.91us -13%
Proxy-unwrapItem 0.62us 0.56us -10%
Script-instantiate-useOps 423us 448us +6%

Instantiating a script that does use idContext.ops is the one regression: it now pays for the lazy getter on top of building the ops object. That is a one-time ~25us cost per instantiation, against ~270us saved for scripts that don't touch ops.

Caveats on the numbers, so they aren't read as more precise than they are: absolute timings shift by ~20% between measurement sessions on this machine, so only the large relative changes above should be taken as solid. The sub-microsecond rows (Operator-callJsFromId-*, Script-memberValue, Operator-roundtrip-int) have measured anywhere between -5% and -25% across sessions; they improve, but I would not put a specific figure on them. The primitive FromGraal-*/ToGraal-* benchmarks run below ~100ns per operation and move by more than ±100% between runs of identical code, so no conclusions are drawn from those at all.

Remaining cost in FromGraal-item is mostly ValueObjectTypeItemStack.deserialize running the vanilla ItemStack codec, which is outside this repo.

Testing

  • ./gradlew build passes (50 unit tests, including two new ones covering the lazy ops resolution and the exports fix).
  • ./gradlew runGameTestServer passes (21/21).
  • Every commit compiles on its own, so the history stays bisectable.

🤖 Generated with Claude Code

https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63

@rubensworks
rubensworks force-pushed the claude/js-script-eval-perf-s6dy59 branch from 5200f98 to f9dc0de Compare August 26, 2026 19:58
@coveralls

coveralls commented Aug 26, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 33327925285

Coverage increased (+0.4%) to 46.243%

Details

  • Coverage increased (+0.4%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • 44 coverage regressions across 8 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

44 previously-covered lines in 8 files lost coverage.

File Lines Losing Coverage Coverage
org/cyclops/integratedscripting/evaluate/translation/translator/ValueTranslatorOperator.java 13 59.65%
org/cyclops/integratedscripting/evaluate/translation/translator/ValueTranslatorObjectAdapter.java 9 65.08%
org/cyclops/integratedscripting/evaluate/ScriptHelpers.java 5 88.16%
org/cyclops/integratedscripting/evaluate/translation/translator/NbtCompoundTagProxyObject.java 5 64.71%
org/cyclops/integratedscripting/evaluate/translation/translator/ValueObjectProxyObject.java 5 79.49%
org/cyclops/integratedscripting/evaluate/translation/ValueTranslatorRegistry.java 3 89.16%
org/cyclops/integratedscripting/evaluate/translation/translator/OperatorProxyExecutable.java 2 86.67%
org/cyclops/integratedscripting/evaluate/translation/translator/ValueTranslatorNbt.java 2 92.96%

Coverage Stats

Coverage Status
Relevant Lines: 2574
Covered Lines: 1309
Line Coverage: 50.85%
Relevant Branches: 860
Covered Branches: 279
Branch Coverage: 32.44%
Branches in Coverage %: Yes
Coverage Strength: 22.74 hits per line

💛 - Coveralls

claude added 6 commits August 30, 2026 18:12
The existing benchmark only covers value translation in isolation.
This adds a second benchmark that covers the paths that scripts actually
go through at runtime: context creation, script instantiation, reading a
script member value, calling a JS function as an Integrated Dynamics
operator, calling an Integrated Dynamics operator from JS, and unwrapping
values that were translated to Graal before.

The benchmark harness now also warms up before measuring, measures with
nanosecond precision, and reports the fastest of several rounds, as the
previous millisecond-precision single-shot measurements were too noisy to
tell improvements apart from run-to-run variance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63
Every created script context eagerly translated all 276 global interact
operators into idContext.ops, which took longer than the rest of the
script instantiation combined, while many scripts use only a handful of
them, or none at all.

The ops object is now defined as a self-replacing lazy getter, so that it
is only built once a script actually accesses it. After the first access
it is a plain data property again, so repeated idContext.ops accesses stay
as fast as before. Exposing ops as a host proxy object instead would have
made every access cross the host boundary, which measured ~9% slower per
operator call.

  Script-instantiate         388us -> 197us (-49%)
  Context-createPopulated    425us -> 259us (-39%)

Instantiating a script that does use idContext.ops becomes ~8% slower, as
it now pays for the lazy getter on top of building the ops object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63
Translating an NBT end tag to Graal evaluated
"exports = { 'nbt_end': true }", which assigns to the global exports
binding, and thereby silently discarded whatever the script itself had
exported.

It now uses a proxy object with the same single member, which as a side
effect also removes a JS parse and evaluation from that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63
The translators that unwrap values which were translated to Graal before
cast the proxy and caught the resulting ClassCastException to detect a
mismatch. Since the translators are tried in order, translating any proxy
threw and caught up to seven exceptions before reaching its own
translator, each with the stack trace fill-in that entails.

  Proxy-unwrapNbt    2.67us -> 1.10us (-59%)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63
Determining which translator handles a Graal value scanned all
translators in order, and each of the six object translators materialized
the value's member keys to compare them against its own single key. For a
plain object that meant six member key sets and six host boundary
crossings before the NBT translator was even reached.

Translators can now report the single member key they dispatch on, so the
registry materializes the member key set once and matches all of them
against it. The translator list and their keys are snapshotted on
registration, so dispatching doesn't repeat the lookups either.

Median of 3 paired runs:

  FromGraal-nbt     10.63us -> 7.56us (-29%)
  FromGraal-item     5.10us -> 3.73us (-27%)

This does regress values that were translated to Graal before, since the
object translators used to detect their own proxies before looking at any
member keys:

  Proxy-unwrapItem   0.59us -> 1.17us (+99%)

The next commit more than makes up for that by resolving such values from
their proxy directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63
Values that a script passes back unchanged are Graal proxies wrapping an
Integrated Dynamics value, so their translator is known up front, but they
still went through the full translator scan to find it.

Graal proxies wrapping such a value now report their value type through
IValueProxy, which lets the registry look their translator up in two calls
on the Graal value instead of scanning.

Median of 3 paired runs:

  Proxy-unwrapItem             1.28us -> 0.54us (-58%)
  Operator-callJsFromId-item   1.71us -> 1.03us (-40%)
  Proxy-unwrapNbt              0.93us -> 0.56us (-39%)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63
@rubensworks
rubensworks force-pushed the claude/js-script-eval-perf-s6dy59 branch from f9dc0de to 57102d9 Compare August 30, 2026 18:23
@rubensworks
rubensworks merged commit 6fa2c8a into master-1.21-lts Aug 31, 2026
23 checks passed
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.

3 participants