The Baseline Was the Bug

Every comparison in a benchmark gets read against the baseline row, and nobody audits the baseline. It's the control. Its whole job is to be boring: plain JVM, platform threads, no tricks, the number everything else is a percentage of.

In this benchmark the boring row was the one lying. jvm-synth-unlimited on micronaut/imperative-synth — "JVM, platform threads" in every report — was dispatching requests on virtual threads. So was the native tier sitting next to it, on twice the heap. The famous 8x native speedup was measuring both mistakes at once.

The platform-thread baseline was running virtual threads#

Same scenario, two runs. The published one from 3 July, and the one from 1 September, after the harness learned to record how each run dispatches:

rundispatch_threadsrps
20260703-162220, jvm-synth-unlimitedfield absent256.4
20260703-162220, jvm-vthreads-synth-unlimitedfield absent255.2
20260901-174554, jvm-synth-unlimitedplatform104.5
20260901-174554, jvm-vthreads-synth-unlimitedvirtual276.8

Look at the July pair. The "platform" row and the "virtual" row are 1.2 rps apart. Two rows, two names, one dispatch model, printed twice. Then September pins the platform row down for real and it drops to 104.5, while the virtual row lands at 276.8 — within 8% of July's "platform" number.

The baseline did not lose 59% of its throughput between July and September. The July row was never platform.

The July config.txt shows why nobody could have told from the artifact:

$ cat 20260703-162220/jvm-synth-unlimited/config.txt
label=jvm-synth-unlimited
branch=micronaut/imperative-synth
compose_file=docker-compose-java.yml:docker-compose-synth.yml
mem_limit=0
db_pool=20
java_tool_options=
aot_enabled=unknown
runtime=jvm

An empty java_tool_options. Nothing displaces Micronaut's dispatch executor, so Micronaut picks its default, and on a Loom-capable JDK that default is virtual. The September run of the same scenario says what it did, in a line the harness wrote:

$ /usr/bin/grep -E "^(java_tool_options|dispatch_threads)=" 20260901-174554/jvm-synth-unlimited/config.txt
java_tool_options=-Dapp.fanout.max-threads=200 -Dmicronaut.executors.blocking.number-of-threads=200 -Dapp.fanout.virtual=false -Dmicronaut.executors.blocking.type=FIXED -Dmicronaut.netty.event-loops.default.num-threads=16 -Dmicronaut.server.netty.worker.threads=16
dispatch_threads=platform

Nobody types dispatch_threads. The harness derives it from the flags it actually passed: blocking.type=FIXED present, so platform. It arrived on 2026-08-24, in the same commits that turned virtual-versus-platform threading into a real toggle, where every lever that gets equalized also leaves a trace in the run it touched. A label can say whatever the scenario name says. A derived field can only say what the inputs did.

Why "turn off virtual threads" never reached dispatch#

Nobody forgot a flag in July. There is no shared flag to forget. Each framework hides the switch somewhere different, and none of the three is a runtime -D you can trust.

Micronaut has no framework switch at all. thread-selection: BLOCKING routes dispatch through DefaultExecutorSelector, which picks the bean named blocking. That bean resolves to virtual.isPresent() ? virtual : io, and the only gate on virtual is a plain JDK Loom check (LoomSupport$LoomCondition). On a modern JDK it's present, so dispatch is virtual and no property says otherwise. The only route to platform threads is to displace the bean: micronaut.executors.blocking.type=FIXED plus number-of-threads=N. Tuning micronaut.executors.io.*, the pool the name suggests, does nothing on the dispatch path. Micronaut's executor naming has misled this series before.

Spring froze the switch at build time. -Dspring.threads.virtual.enabled=false is a real property gating a real @Conditional. But process-aot evaluates conditionals and writes the outcome into the generated bean graph. The AOT build had already registered the Tomcat virtual-thread customizer; the runtime flag shows up after the decision and changes nothing. A @Value moved onto a @Bean method parameter is resolved at runtime under AOT, so that is the way back in.

Quarkus makes it a build-time property on purpose. -Dquarkus.virtual-threads.enabled=false works at build. At runtime it is silently ignored, so the no-virtual-threads tier needs its own image.

Three frameworks, three hiding places, zero runtime knobs that reach the dispatch path. That divergence is exactly what a benchmark launders into a "framework comparison" when no row has to say what it ran on.

Native's 8x was virtual dispatch on twice the heap#

The native tier had the same disease, compounded. Native binaries ignore JAVA_TOOL_OPTIONS — the earlier native-heap post found that the hard way — so every per-tier lever the JVM rows received through that variable never reached the binary. The native rows ran Micronaut's default dispatch, virtual. And they ran on a heap twice the JVM row's: at the 256 MB tier, native_max_heap=128m against the JVM row's -Xmx64m.

That's where the headline came from:

scenariodispatch_threadsmax heaprps
native-synth-256mbvirtual128m261.2
jvm-synth-256mbplatform64m32.6

8.0x. Measured honestly, labeled honestly in September, and still not a comparison of runtimes. It compares a virtual-thread binary with room to breathe against a platform-thread JVM squeezed into 64 MB of heap with a p95 near ten seconds. Two variables changed. The runtime was a third, and the smallest.

Proving the native binary parsed the matched flags#

The fix is a matched tier, native-platform-synth-*, that hands the binary its JVM twin's exact levers. SubstrateVM ignores JAVA_TOOL_OPTIONS but parses its own argv, so the JVM row's -D flags go in as arguments, along with its -Xms, -Xss and heap cap.

The trap with a native binary is never knowing whether a flag was honored or eaten. So the binary — built with GraalVM CE 25.0.2 — got a control:

  • -Xms, -Xss, -Xmx: accepted.
  • -D: honored.
  • -XX:Nonexistent: rejected, Could not find option.

The last line carries the other two. A binary that rejects a bogus option by name is parsing the namespace, not discarding it, so its silence on a real flag means acceptance. The same binary also rejects -XX:+UseSerialGC — CE ships only the serial collector, so there is nothing to select. GC stays runtime identity, which leaves one honest asterisk below: the unlimited JVM rows run the JVM's default G1, and no flag can give a CE native image the same.

Matched, throughput ties until memory runs out — RSS never does#

Equalize dispatch and the heap cap and the 8x evaporates. Every number here is from the six Micronaut branches of run 20260901-174554 — a deliberate slice. The full 18-branch run finished afterwards; this piece is about one correction and what it did to one framework's rows, and three frameworks' worth of tables would bury it. RSS is the last prom.csv scrape of each run.

workload / tierJVM rpsnative rpsJVM RSSnative RSS
imperative-synth 256m32.632.6236 MiB49 MiB
imperative-synth unlimited104.5104.4475 MiB102 MiB
more-io 256m56.362.8238 MiB46 MiB
more-io 512m185.2185.5348 MiB91 MiB
more-io unlimited185.3184.7435 MiB119 MiB

Throughput agrees to within 0.6 rps on four of the five rows — about 0.3% at most. The fifth is the tightest fan-out tier, where the JVM runs out of room first: 56.3 rps against native's 62.8. Wherever both runtimes have headroom, native does not make the same code serve more requests. It serves them on a fifth to just over a quarter of the resident memory: 236 MiB down to 49, 475 down to 102. That is the runtime difference, isolated at last. No JIT, no metaspace, no class-loading machinery sitting resident.

What native buys is the floor.

(The 256m imperative-synth row ties on throughput — 32.6 rps each, p95s of 9.9 and 8.8 seconds — but not on outcome: the JVM failed 1,705 of 21,275 requests, 8.0%, and native failed none.)

At the tight tier, the RSS gap becomes dropped requests#

The floor stops being a footnote once memory gets tight. On the 28-call fan-out — imperative-synth-more-io, where one request fans out to up to 28 mock APIs — both rows at 256 MB, both platform dispatch:

  • JVM: 2,847 of 36,461 requests failed. 7.8%. p95 4.4 s.
  • Native: 0 of 40,798 failed. p95 4.2 s.

Native is saturated too — a 4.2-second p95 is no victory lap. But it finishes every request it takes, and the JVM sheds one in thirteen.

The 7.8% comes from http_req_failed in k6-summary.json. The Error rate: line in k6.txt prints 9.603% for the same run, because it folds latency-SLO misses in with real failures. That conflation caught me mid-run, and it still made it into the notes this post was drafted from as "9.6% real failures." The real failure rate is the smaller number.

On the fan-out, dispatch decides whether memory matters#

Zoom out across the fan-out rows and dispatch model is the lever that sets everything else. Every virtual-dispatch row — JVM or native, 128 MB to unlimited — lands between 221.5 and 222.9 rps. The work is waiting on upstream calls, and a parked virtual thread costs next to nothing, so the memory cap never shows up in the number.

Platform dispatch is the opposite: 185 rps with headroom, 56.3 on the JVM at 256 MB, 62.8 native. The JVM row loses 70% of its throughput to a cap that virtual dispatch doesn't notice.

So on this workload the dispatch model is the biggest variable in the Micronaut matrix — bigger than JVM versus native, which ties within 0.6 rps everywhere except the tightest fan-out tier, and the thing that decides whether the memory tier matters at all. It was also the one variable no row had a field for. The July baseline said "platform" because the scenario was called jvm, and 256.4 rps of virtual-thread throughput sat under that name until the harness had to compute the word itself.

Filed under: java, micronaut, graalvm, native-image, virtual-threads, benchmarks, jvm

Sources#

  • Run artifacts, benchmark-results/micronaut/<branch>/20260901-174554/<scenario>/ and …/20260703-162220/… in the benchmark repo: config.txt (dispatch_threads, java_tool_options, native_extra_args, native_max_heap), k6.txt (Requests/sec (avg), Error rate:, p95), k6-summary.json (http_req_failed), prom.csv (rss_bytes). Internal, unpublished.
  • Project upgrade plan, items 1.2 and 1.41 — native heap cap via -XX:MaxHeapSize, and the GraalVM CE 25.0.2 argv checks (-Xms/-Xss/-Xmx accepted, -D honored, -XX:Nonexistent and -XX:+UseSerialGC rejected with Could not find option). Internal.
  • Framework dispatch mechanisms (Micronaut DefaultExecutorSelector / LoomSupport$LoomCondition, Spring process-aot conditional freezing, Quarkus build-time quarkus.virtual-threads.enabled) — verified against the frameworks' bytecode and config in the project, per the research notes. Internal.