Four JDKs, One Tape: Zulu 24 Through 27
Benchmark the same Asian option tape on Azul Zulu JDK 24 through 27 and compare JVM bytecode and SIMD replay performance.
Updated 21 October 2026. This is a historical comparison of the original engine build across four JDKs. Current Quant and jax4j require Java 25 or newer; the JDK 24 row is not a current compatibility claim. SIMD is an optional module.
Every other article on this site asks "which backend, which library, which
language." This one asks a question that never came up: does the JDK itself
matter? NablaTensor's pom.xml targets Java 25 and says so in a comment —
but Azul ships Zulu builds of 24, 25, 26 and a same-day 27 release. So: build the engine four times, once per
JDK, each compiling to its own bytecode level and running on its own
runtime, and replay the same tape on cpu-jit and simd.
The honest expectation going in was "no difference" — cpu-jit is
deliberately plain bytecode with no intrinsics to gain or lose, and simd
sits on jdk.incubator.vector, which has been re-incubated every six months
since JDK 16 with mostly API surface changes, not raw throughput ones. One of
those two expectations turned out to be wrong.
The rules
- Each JDK compiles and runs its own bytecode.
-Dmaven.compiler.release=24/25/26/27per build,JAVA_HOMEswitched to the matching Zulu install, fullmvn clean compileeach time. No running JDK 24-era bytecode on a JDK 27 JVM to save a rebuild — that tests JVM warm-up, not the JDK's own toolchain. - The same tape, the same seed, every time. 252-fixing arithmetic Asian
call,
S = K = 100,σ = 20%,r = 3%,T = 1, 2,000,000 scenarios, seed 42, value plus five Greeks (adjoint). The reported price (5.301676) and delta matched bit-for-bit across all forty runs — a useful sanity check that "faster" never meant "different code path." - Default settings, nothing hand-tuned.
AadOptionsdefaultsthreadsto one per available processor when unset — 16 on the benchmark system — so every number below is whatmvn exec:java -Dengine=cpu-jitgives you with zero flags, not a hand-picked thread count. - Five runs, separate JVM processes, median reported. Each run is a
fresh
javainvocation (one internal warm-up call inside the process, then the timed one) so that JIT state never carries over between runs — the point is per-process steady state, not amortizing compilation cost across a long-lived benchmark harness.
cpu-jit: flat, as it should be
| JDK | median, M paths/s | range (5 runs) |
|---|---|---|
| Zulu 24.32 (24.0.2) | 1.69 | 1.65 – 1.71 |
| Zulu 25.30 (25.0.1) | 1.72 | 1.70 – 1.75 |
| Zulu 26.32 (26.0.2) | 1.70 | 1.64 – 1.72 |
| Zulu 27.28 (27+35) | 1.67 | 1.63 – 1.68 |
Every number in that table is within the same 3–4% band that separates the
five runs within a single JDK. cpu-jit compiles the tape to plain
bytecode with no incubator module and no native dependency — there is
nothing in it for a newer JDK to specifically accelerate, and the numbers
say exactly that. This table is the control group.
simd: Zulu 25 is a real step, not noise
| JDK | median, M paths/s | range (5 runs) | vs. Zulu 24 |
|---|---|---|---|
| Zulu 24.32 (24.0.2) | 3.47 | 3.44 – 3.80* | — |
| Zulu 25.30 (25.0.1) | 4.01 | 3.91 – 4.05 | +15.5% |
| Zulu 26.32 (26.0.2) | 3.89 | 3.75 – 4.02 | +12.1% |
| Zulu 27.28 (27+35) | 3.81 | 3.64 – 4.10 | +10.0% |
* One of Zulu 24's five runs (1.24 M paths/s) is excluded from the range
shown; it's included in the median calculation for fairness, but it's worth
naming: only Zulu 24 produced a run that looked like it hadn't finished
warming up the vectorized code path by the time the timed call started — the
other three JDKs never did this, in ten runs each. We don't have a profiler
trace to explain why, so take it as an observation, not a conclusion: Zulu
24's simd path may be slower to reach steady state, not just slower once
there.
The shape that matters: Zulu 25 is the fastest of the four, Zulu 26 and 27 hold most of that gain but don't extend it, and the 26→27 difference (3.89 → 3.81) is inside the run-to-run noise band, not a second trend. Whatever changed, it changed once, between 24 and 25, and then stopped changing.
JDK 27, on its own, moves nothing
Compare 27 to 26 instead of to 24 and the jump disappears. cpu-jit goes
from 1.70 to 1.67 M paths/s; simd goes from 3.89 to 3.81. Both are drops,
and both are smaller than the 3–4% run-to-run spread this article uses
elsewhere to separate signal from noise — not a regression, but not a gain
either. For a heavy-computation workload like this 2-million-scenario
adjoint Monte-Carlo tape, JDK 27 itself buys nothing on either engine. The
15% jump belongs to JDK 25; JDK 26 and 27 inherited it and stopped there.
What we checked on the JDK side
The instinct is to credit jdk.incubator.vector directly, so we went
looking at what OpenJDK's own release engineering says changed in it.
JEP 508: Vector API (Tenth Incubator) is
JDK 25's re-incubation. It lists one API change (VectorShuffle gained
MemorySegment access) and two implementation changes: Float16
arithmetic is now auto-vectorized on supporting x64 CPUs, and — the one that
matters here — the Vector API's native math functions (exp, log,
sqrt, …) now link through the Foreign Function & Memory API instead of
calling hand-written C++ code compiled into HotSpot. VectorReplayF64
calls exactly those two: EXP for the GBM drift step, SQRT where the tape
needs it. A different code path to the same native math library is a
plausible source of a double-digit percentage change, and it lines up with
the JDK version where the jump actually happens.
We also checked the two JDK 25 Vector API changes with published, quantified performance numbers, to see if either explained it directly:
- JDK-8350748 —
checkMaskFromIndexSizewas not being force-inlined, so maskedfromArray/intoArray/fromMemorySegmentcalls allocated aVectorMaskobject that C2 should have compiled away. The fix is real (reported up to 14x on masked-array microbenchmarks on AArch64 SVE2) but it only fires on the masked overloads of those methods.VectorReplayF64doesn't use them — everyfromArray/intoArraycall in it is the plain, unmasked, fixed-width form (ld(v, a + p)in the source, which isDoubleVector.fromArray(SPECIES, array, index)with no mask argument). This fix isn't the explanation for this tape. - JDK-8343685 — a
refactor of C2's SuperWord auto-vectorizer (
VPointer→MemPointer), which is what turns an ordinary scalar Java loop into SIMD instructions without the programmer calling the Vector API.VectorReplayF64never goes through SuperWord — it callsjdk.incubator.vectortypes explicitly, which C2 compiles through its Vector API intrinsics instead. Also not the explanation for this tape.
So the honest position is: JEP 508's native-math-via-FFM change is the most
plausible documented cause we found, because it's the one implementation
change in the JDK 25 Vector API re-incubation that both (a) touches
operations this tape actually calls and (b) ships in exactly the version
where the number moves. It is not a confirmed attribution — that would need
a JFR profile or an -XX:+PrintCompilation/perfasm diff between two JVMs
doing the same run, which is a follow-up, not something four mvn clean compile invocations can establish on their own. We'd rather publish the
measured number with a named, checked, and ultimately inconclusive
investigation than dress up a guess as a finding.
What this doesn't measure
This is one tape (a 252-step Asian call), one machine, one seed, at the
default 16-thread setting. It doesn't say anything about cpu, vulkan,
rocm, or cuda on newer JDKs — those weren't touched, on purpose (see rule
5). It doesn't isolate which native math call inside EXP/SQRT benefits
from the FFM linkage, or by how much each contributes versus general
JIT-level changes elsewhere in the same release. And a same-machine,
same-session comparison like this one can't rule out every possible
confound — thermal state, background load — though the tight within-JDK
run-to-run spread (3–4%) relative to the 24→25 jump (15%) is the reason we're
comfortable calling the jump real rather than noise.
Reproduce the historical comparison
The archived JdkSpeedBench harness remains in the old repository. Use an explicit checkout rather than its current migration notices:
git clone https://github.com/nablatensor-dev/nablatensor.git
cd nablatensor
git checkout 26006a665b074d4e231c8dfa405467afa90bc595
mvn -q -DskipTests install
# cpu-jit — works with plain exec:java, no extra flags
mvn -q -o -pl nablatensor-examples exec:java \
-Dexec.mainClass=com.nablatensor.bench.JdkSpeedBench \
-Dengine=cpu-jit -Dscenarios=2000000
# simd — needs the incubator module on the JVM's own command line,
# which exec:java can't pass through, so build the classpath and run java directly
mvn -q -o -pl nablatensor-examples dependency:build-classpath \
-Dmdep.outputFile=/tmp/cp.txt
java --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED \
-Dengine=simd -Dscenarios=2000000 \
-cp "nablatensor-examples/target/classes:$(cat /tmp/cp.txt)" \
com.nablatensor.bench.JdkSpeedBench
These commands use the original NablaTensor checkout and its JdkSpeedBench,
not the current Quant module layout. For the historical comparison, swap
JAVA_HOME and re-run mvn clean compile -Dmaven.compiler.release=<N>
between JDKs.
For new applications, use Java 25 or newer. Current cpu-jit ships in
jax4j-core; SIMD additionally needs jax4j-simd and the incubator module
flag. Do not lower the current release to Java 24 to reproduce an old row.
Ryzen 7 8845HS, 16 threads · Zulu OpenJDK 24.32.13 (24.0.2), 25.30.17 (25.0.1), 26.32.13 (26.0.2), 27.28.101 (27) · Apache Maven 3.9.12 · each JDK built and ran its own bytecode; the classes were never shared across JDK versions.
