How We Beat HotSpot Performance (By Cheating, But Not Like That)
The Starting Point
Client VMs are a different beast. The joke around here is that I built ParparVM in two weeks and Steve spent the next three years fixing bugs. When I built it, throughput wasn't a priority at all. I was aiming for simplicity, reliability, and consistency.
What actually matters for client performance is startup time, memory footprint, low latency, and fast native access. 90% of client code time should be spent in rendering and IO, and in Codename One those paths are handcrafted native code on each platform, already tuned to the metal.
Occasionally a customer would complain about performance, we'd add an optimization for that specific case, and we'd move along. We profiled common use cases, they were fine, and we never treated VM throughput as a problem. Comparing against HotSpot wasn't even on the table. You can't out-optimize a JIT, and we didn't run on the same platforms anyway.
Now that we have desktop ports, people are porting heavier workloads and running direct comparisons on the same hardware. I assumed we were 2x or 3x slower than warmed HotSpot, ignoring startup. I was really, really off.
Measuring it took some surgery: ParparVM normally exists to run Codename One, so we hacked it to run standalone, without the framework, and the suite measures the VM alone rather than the framework on top of it. The reference is OpenJDK 25 with full warmup, and warmed is the fair way to measure it: Java 25 ships ahead of time class loading and method profiles, so a deployed app can reach that warmed state quickly rather than earning it over thousands of iterations.
Here's the starting point on an Apple M2, identical checksums on both runtimes:
| Benchmark | What it stresses | ParparVM vs Java 25 |
|---|---|---|
| hashMapChurn | HashMap put/get with autoboxing | 36.2x slower |
| stringBuilding | StringBuilder append and hash | 22.7x slower |
| objectAllocation | Allocation and GC churn | 19.6x slower |
| recursion | Deep call chains (fib) | 7.6x slower |
| arraySequential | Fill and reduce | 3.7x slower |
| quicksort | Compare, swap, recurse | 1.8x slower |
| longArithmetic | 64-bit ALU chain | 1.5x slower |
| intArithmetic | 32-bit ALU chain | 1.3x slower |
| mathTranscendental | sqrt/sin/cos | 1.1x slower |
| arrayRandom | Cache-miss gather | 1.0x |
Geomean: 4.21x slower. This was really bad.
The Final Results
Spoiler: after everything below, this is where the same suite landed.
| Benchmark | Final ratio | Benchmark | Final ratio |
|---|---|---|---|
| stringBuilding | 0.67x | arrayRandom | 0.96x |
| arraySequential | 0.82x | intArithmetic | 1.07x |
| quicksort | 0.92x | longArithmetic | 1.12x |
| hashMapChurn | 0.95x | objectAllocation | 1.19x |
| mathTranscendental | 0.96x | recursion | 1.60x |
Geomean: 1.00x. Below 1.0 means we beat warmed HotSpot C2. Same Java source, same checksums, best of 5 interleaved runs.
The interleaving isn't decoration: early on, sequential A-then-B timing on the M2 carried a 10 to 15% thermal bias that made one change look 1.5x faster when it was worth 3%, so the harness alternates the two VMs within a run.
One methodology note before you ask: the comparison runs on macOS because HotSpot doesn't run on iOS. The same generated C ships to every Apple target, so the codegen wins carry over to devices where the JIT can't follow. Lower is better and 1.0 is HotSpot; everything left of intArithmetic finishes ahead of it. The starting ratios wouldn't fit on this chart. hashMapChurn began at 36.
The Memory Story
Speed is only half the report, and for client apps it's the less important half. RAM is where this release moves the most:
| Metric | master | this PR | Java 25 |
|---|---|---|---|
| Binary size (bench app) | 434 KB | 451 KB (+3.8%) | n/a |
| Memory floor (no-op app) | 2.2 MB | 2.4 MB | ~40 MB |
| Peak memory (allocation churn) | 1.4 to 2.1 GB | 290 to 390 MB | 508 MB |
Master's gigabyte peaks were a real GC bug this work exposed: the allocated-since-last-collection counter was a 32-bit int counting bytes. Workloads that allocate gigabytes per cycle wrapped it negative, the "am I allocating fast?" check answered no, and the collector slept through the storm while dead pages piled up.
With the trigger fixed, heavy churn peaks below the JVM on the same workload, and the idle floor stays where a phone wants it: 2.4 MB. Sit with that pair for a second, because it's the whole thesis of this VM. Trading blows with warmed HotSpot on speed while holding a 2.4 MB floor against its ~40 MB.
How Did We "Cheat"?
The one advantage we have over HotSpot is that we're not Java. Not really. We bill ourselves as a tool that lets Java developers ship their Java apps to mobile devices. See what we did there? You write Java code, so it's a Java app. But we're not really Java in some major ways, and that gives us freedom HotSpot doesn't have.
We compile a closed world. There's no dynamic class loading, so we know every class that will ever exist. There's no reflection (Class.forName() exists in a limited form, and that's roughly the extent of it), so a method nobody calls is a method we can delete, and a virtual call with exactly one reachable implementation is a direct call. The API is smaller, so there's less legacy behavior to preserve. And we can play fast and loose with some nuanced VM behaviors that HotSpot must keep exactly (more on Integer identity below).
Without this cheating we would have lost every benchmark in the group. HotSpot carries a quarter century of engineering, and we wouldn't be able to come close playing "fair". Even with these structural advantages, we had to spit blood to get to a competitive point.
What The C Compiler Actually Sees
ParparVM translates bytecode to C and lets clang optimize it. So the whole game is: how much does the generated C look like the C a human would write? The answer, at the start, was "not at all".
Every Java method pushed a GC visible frame of type tagged slots and routed every intermediate value through it:
/* BEFORE: one heap-visible frame per call, every value tagged and in memory */
JAVA_LONG fib ( CODENAME_ONE_THREAD_STATE , JAVA_INT n ) {
stack = pushFrameOnThreadStack ( threadStateData , locals = 2 , stack = 4 );
memset ( stack , 0 , 6 * sizeof ( elementStruct )); /* on EVERY call */
locals [ 0 ]. type = CN1_TYPE_INT ;
locals [ 0 ]. data . i = n ;
( * SP ). type = CN1_TYPE_INT ;
( * SP ). data . i = 2 ;
SP ++ ;
if ( locals [ 0 ]. data . i < SP [ - 1 ]. data . i ) ... /* compare via memory */
releaseForReturn ( threadStateData , ...);
}
That frame is how the GC finds live objects. It's also why clang couldn't keep anything in a register.
Methods the translator can now prove safe (no try/catch, no synchronization, object roots covered by the native stack scan) compile to the C you'd write by hand:
/* AFTER: locals are C locals, so they become registers */
JAVA_LONG fib ( CODENAME_ONE_THREAD_STATE , JAVA_INT n ) {
JAVA_INT ilocals_0_ = n ;
CN1_FRAMELESS_SOE_GUARD ( 0 ); /* stack-overflow check, nothing else */
if ( ilocals_0_ < 2 ) return ilocals_0_ ;
return fib ( threadStateData , ilocals_0_ - 1 ) + fib ( threadStateData , ilocals_0_ - 2 );
}
Between this and its follow-ups, recursion went from 7.6x to 1.6x. More importantly, frameless codegen feeds every other optimization, because once values live in registers the rest of clang's optimizer wakes up.
Why We Still Lose Recursion
The remaining 1.6x on recursion is HotSpot's speculative inlining, and we accepted it. A JIT watches the program run, notices that fib calls fib, inlines it into itself several levels deep, and keeps a deoptimization escape hatch in case its bet goes wrong. An ahead-of-time compiler doesn't get to gamble. It has to emit code that's correct for every possible execution, so a real call remains a real call.
This is the structural advantage of a JIT and no amount of hand tuning closes it. If your workload is deep recursive call chains, HotSpot is simply the better machine for it.
The Bounds Check That Poisoned Every Loop
Java requires an index check on every array access. The check itself is cheap. What killed us was the shape of the failure path. Our bounds check helper threw the exception and then returned a dummy value, so the "failed" path rejoined the loop. That means every loop iteration contained a reachable function call, and clang must assume a call can modify any memory. So it reloaded the array pointer and the array length from memory on every single pass:
/* BEFORE: while (a[i] < pivot) i++;-- quicksort's scan loop */
label_scan :
if ( cn1_array_element_int ( ts , locals [ 0 ]. data . o , i ) >= pivot ) goto done ;
/* on bounds failure: throwException(...); return 0; ...and REJOIN.
A call is reachable on every iteration, so clang reloads array->data
AND array->length every pass: 3 loads per element. */
i ++ ;
goto label_scan ;
The fix is to make the failure path diverge. Throw and return from the method, the same way the stack overflow guard works. Now no cycle of the loop contains a call, and clang hoists the loads out of the loop:
/* AFTER: the throw path leaves the function; the loop is load/compare/branch */
label_scan : {
JAVA_OBJECT a = locals [ 0 ]. data . o ;
JAVA_INT idx = i ;
CN1_ARRAY_CHECK_DIVERGE ( a , idx , ); /* null/oob -> throw; return */
if ((( JAVA_ARRAY_INT * )( * ( JAVA_ARRAY ) a ). data )[ idx ] >= pivot ) goto done ;
}
i ++ ;
goto label_scan ;
We measured the check itself against a pure C control at identical flags: raw C runs the sort in 91ms, C with diverging bounds checks in 98ms. So the safety Java mandates costs about 8% when the surrounding code is right. With the fix, the benchmark sort dropped from 216ms to 164ms, vs HotSpot's 197ms. Quicksort ended at 0.92x, below HotSpot, while keeping every check.
Where HotSpot Beat Our Hand-Tuned C
On intArithmetic and longArithmetic we wrote plain C controls, no VM, no GC, just the loop, compiled with the same clang flags. The generated ParparVM code ran at exact parity with the hand written C: 94.0ms vs 93.7ms, and 59.7ms vs 59.3ms. Zero VM overhead.
HotSpot still won, 1.07x and 1.12x. The residual is C2 reassociating the loop-carried dependency chain better than clang schedules it. On a tight arithmetic loop, HotSpot C2 generates better machine code than clang -O3 given identical semantics. The JIT sees the actual hot loop and its actual dependency graph and optimizes exactly that.
We declared those benchmarks done, because when the gap between you and HotSpot is the same as the gap between clang and HotSpot, the VM is no longer the story.
There's one footnote worth stealing for any C-generating project: Java integer semantics require -fwrapv -fno-strict-aliasing. Without -fwrapv, clang -O3 provably miscompiles overflowing accumulation loops. Our checksum gate caught it, off by exactly 2^32 per overflow.
Memory: Two Philosophies
The worst starting numbers, the 20x to 36x ones, weren't about code generation at all. They were about memory, and to understand them you need to see how differently the two VMs think about it.
HotSpot asks the OS for one large block up front and manages everything inside it. That buys enormous speed. Allocation is a pointer bump into a thread-local slab, and because HotSpot owns the entire address range, a single address comparison can tell it which region or generation an object belongs to. Owning the addresses simplifies reasoning across the whole VM: barriers, card tables, generation checks, all of it gets cheaper when "where is this object?" is arithmetic.
ParparVM went the other way: we allocate through the OS's own malloc, calloc, and free. For years that looked like the slow, naive choice, and parts of it were slow. We keep it anyway, because it buys three things a client VM cares about more than benchmark points:
- Direct native access. Native code and the GPU see our objects as ordinary memory, no pinning, no copying at a heap boundary. That's what makes fast SIMD and rendering paths practical.
- Real tooling. Native profilers and leak detectors (Instruments, Valgrind, and friends) understand our memory, because
Comments
No comments yet. Start the discussion.