The Baseline Was the Bug
Contents
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:
| run | dispatch_threads | rps |
|---|---|---|
20260703-162220, jvm-synth-unlimited | field absent | 256.4 |
20260703-162220, jvm-vthreads-synth-unlimited | field absent | 255.2 |
20260901-174554, jvm-synth-unlimited | platform | 104.5 |
20260901-174554, jvm-vthreads-synth-unlimited | virtual | 276.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:
| scenario | dispatch_threads | max heap | rps |
|---|---|---|---|
native-synth-256mb | virtual | 128m | 261.2 |
jvm-synth-256mb | platform | 64m | 32.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 / tier | JVM rps | native rps | JVM RSS | native RSS |
|---|---|---|---|---|
| imperative-synth 256m | 32.6 | 32.6 | 236 MiB | 49 MiB |
| imperative-synth unlimited | 104.5 | 104.4 | 475 MiB | 102 MiB |
| more-io 256m | 56.3 | 62.8 | 238 MiB | 46 MiB |
| more-io 512m | 185.2 | 185.5 | 348 MiB | 91 MiB |
| more-io unlimited | 185.3 | 184.7 | 435 MiB | 119 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
Read next#
- 2492, 2495, 2508 — the ORM-to-plain-JDBC swap that equalized the data layer before any of these rows were run.
- The Throughput Was Always 115 — the 28-API fan-out workload, and the first time its ceiling turned out to be something other than the framework.
- -Xmx Won't Save Your Native Image — why
JAVA_TOOL_OPTIONSnever reaches a native binary, which is how the native tier got its extra heap. - Eighteen Requests for One Redirect — the next part: one stub
302, four HTTP client stacks, four different request bills.
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/-Xmxaccepted,-Dhonored,-XX:Nonexistentand-XX:+UseSerialGCrejected withCould not find option). Internal. - Framework dispatch mechanisms (Micronaut
DefaultExecutorSelector/LoomSupport$LoomCondition, Springprocess-aotconditional freezing, Quarkus build-timequarkus.virtual-threads.enabled) — verified against the frameworks' bytecode and config in the project, per the research notes. Internal.