← blog7 min read

Four JDKs, One Tape: Zulu 24 Through 27

The same compiled Asian-option tape, replayed on Azul Zulu 24, 25, 26 and 27, on cpu-jit (plain bytecode) and simd (jdk.incubator.vector fp64) — five runs each, median plotted, price checked bit-for-bit across all forty runs. cpu-jit is flat across every JDK, as plain bytecode should be. simd on Zulu 25 replays 15% faster than on Zulu 24, on the same machine, same seed, same jar — and Zulu 26 and 27 hold that gain without extending it. Measured against Zulu 26 rather than 24, Zulu 27 is flat on both engines: for this heavy-computation workload, the newest release adds no speedup of its own.

benchmarksjvmjitsimdvector-apimonte-carloautodiff
Infographic titled 'Four JDKs, one tape'. Two linear-scale bar panels of million paths per second on a 252-fixing Asian call, value plus Greeks, 2 million scenarios, 16 threads. Left, cpu-jit, plain bytecode: Zulu 24 1.69, Zulu 25 1.72, Zulu 26 1.70, Zulu 27 1.67, all four bars nearly the same length. Right, simd, jdk.incubator.vector fp64: Zulu 24 3.47, Zulu 25 4.01 highlighted in a darker shade, Zulu 26 3.89, Zulu 27 3.81. A grey box: JEP 508 re-baselines the Vector API for the JDK 25 release train, moving native math functions onto the Foreign Function and Memory API instead of hand-written C++ intrinsics; two JDK 25 Vector API bug fixes checked, JDK-8350748 and JDK-8343685, do not touch this tape's code path, which uses only unmasked fromArray and intoArray. A teal banner: cpu-jit is flat across all four JDKs, plain bytecode is plain bytecode; simd is not — Zulu 25 replays the vectorized tape fifteen percent faster than Zulu 24 on the same machine, same seed, same jar, and Zulu 26 and 27 hold that gain but do not extend it. Footer: Ryzen 7 8845HS, 16 threads, Zulu 24.32, 25.30, 26.32, 27.28, each JDK built and ran its own bytecode, median of 5 runs.

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

  1. Each JDK compiles and runs its own bytecode. -Dmaven.compiler.release=24/25/26/27 per build, JAVA_HOME switched to the matching Zulu install, full mvn clean compile each 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.
  2. 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."
  3. Default settings, nothing hand-tuned. AadOptions defaults threads to one per available processor when unset — 16 on referential machine — so every number below is what mvn exec:java -Dengine=cpu-jit gives you with zero flags, not a hand-picked thread count.
  4. Five runs, separate JVM processes, median reported. Each run is a fresh java invocation (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

JDKmedian, M paths/srange (5 runs)
Zulu 24.32 (24.0.2)1.691.65 – 1.71
Zulu 25.30 (25.0.1)1.721.70 – 1.75
Zulu 26.32 (26.0.2)1.701.64 – 1.72
Zulu 27.28 (27+35)1.671.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

JDKmedian, M paths/srange (5 runs)vs. Zulu 24
Zulu 24.32 (24.0.2)3.473.44 – 3.80*
Zulu 25.30 (25.0.1)4.013.91 – 4.05+15.5%
Zulu 26.32 (26.0.2)3.893.75 – 4.02+12.1%
Zulu 27.28 (27+35)3.813.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-8350748checkMaskFromIndexSize was not being force-inlined, so masked fromArray/intoArray/fromMemorySegment calls allocated a VectorMask object 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. VectorReplayF64 doesn't use them — every fromArray/intoArray call in it is the plain, unmasked, fixed-width form (ld(v, a + p) in the source, which is DoubleVector.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 (VPointerMemPointer), which is what turns an ordinary scalar Java loop into SIMD instructions without the programmer calling the Vector API. VectorReplayF64 never goes through SuperWord — it calls jdk.incubator.vector types 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.

Run it yourself

# 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

Swap JAVA_HOME and re-run mvn clean compile -Dmaven.compiler.release=<N> between JDKs to reproduce the table above on your own hardware.


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.


Questions or corrections? open an issue