Speed up JS script evaluation - #69
Merged
Merged
Conversation
rubensworks
force-pushed
the
claude/js-script-eval-perf-s6dy59
branch
from
August 26, 2026 19:58
5200f98 to
f9dc0de
Compare
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
force-pushed
the
claude/js-script-eval-perf-s6dy59
branch
from
August 30, 2026 18:23
f9dc0de to
57102d9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
BenchmarkValueTranslatorsonly covers value translation in isolation, so this PR first addsBenchmarkScriptEvaluation, 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 fromtest(./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.executeand the thread-local context enter/leave around it — i.e. the number ofValueAPI 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.opsis now a self-replacing lazy getter, so after the first access it is a plain data property again and repeatedidContext.ops.xaccesses stay as fast as before. Exposingopsas 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 evaluatedexports = { 'nbt_end': true }, which assigns to the globalexportsbinding 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 theClassCastExceptionto detect a mismatch, so translating any proxy threw and caught up to seven exceptions before reaching its own translator.Proxy-unwrapNbt2.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 tonull), so the registry materializes the set once.FromGraal-nbt10.63us → 7.56us (-29%),FromGraal-item5.10us → 3.73us (-27%). This one regressesProxy-unwrapItem0.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 throughIValueProxy, so their translator is found in two calls instead of a scan.Proxy-unwrapItem1.28us → 0.54us (-58%),Operator-callJsFromId-item1.71us → 1.03us (-40%),Proxy-unwrapNbt0.93us → 0.56us (-39%).Net result
Median of 3 alternating runs of the whole branch against its base:
Script-instantiateProxy-unwrapNbtFromGraal-nbtContext-createPopulatedFromGraal-itemOperator-callJsFromId-itemProxy-unwrapItemScript-instantiate-useOpsInstantiating a script that does use
idContext.opsis 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 touchops.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 primitiveFromGraal-*/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-itemis mostlyValueObjectTypeItemStack.deserializerunning the vanillaItemStackcodec, which is outside this repo.Testing
./gradlew buildpasses (50 unit tests, including two new ones covering the lazyopsresolution and theexportsfix)../gradlew runGameTestServerpasses (21/21).🤖 Generated with Claude Code
https://claude.ai/code/session_014MtPuLF94baQNqedrqSY63