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.

benchmarksjvmjitsimdvector-apimonte-carloautodiff
Original benchmark figure. 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.

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

  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 the benchmark system — 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-8350748 — checkMaskFromIndexSize 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 (VPointer → MemPointer), 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.

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.


Questions or corrections? open an issue