Three builds, one of them shipped
One script does it all; the steps it takes are below, for doing them by hand. Every option is a -H: option behind -H:+UnlockExperimentalVMOptions.
In one command
# from the release archive; in a source tree, crucible/pgo.sh
tar xzf cruciblevm-0.4.0-linux-x86_64.tar.gz
cruciblevm/crucible/pgo.sh --name app --run '$APP <your workload arguments>' -- -cp app.jar com.example.Main
It builds an image that records, runs your workload on it, builds an image that samples call stacks, runs the workload again, and builds ./app, the image to ship. In --run, $APP is the image to run; the workload has to let it exit normally, so stop a service with SIGTERM (build it with --install-exit-handlers). --no-samples stops after the counted profile. For Spring PetClinic the whole command takes about a quarter of an hour.
-
Build an image that records
source crucible/env.sh && cd substratevm mx native-image -O3 -cp app.jar -o app-recording \ -H:+UnlockExperimentalVMOptions -H:+CrucibleInstrument com.example.Main -
Run it on a representative workload
It writes
crucible-profile.jsonwhen it exits. The recording image runs about twice as slow as normal../app-recording <the usual arguments> -
Build the image you ship
mx native-image -O3 -cp app.jar -o app \ -H:+UnlockExperimentalVMOptions -H:CrucibleProfile=crucible-profile.json com.example.Main
The second build says what it made of the profile, and warns when the profile does not fit the program. Profiles from several runs merge with merge-profiles.py; sampled call stacks from JFR can be added with jfr-to-samples.py; and a profile recorded by Oracle GraalVM (.iprof) converts with iprof-to-crucible.py.
What it does
The profile work reaches the compiler through extension points upstream already has, with a few lines changed upstream where one was missing, so rebasing onto a new GraalVM stays simple. The collector and System.arraycopy changes are changes to upstream's own files.
-
The class library's profile
A build without a profile gives the library's branches the probabilities other programs recorded for them, from a profile that comes with the builder.
-
Branch probabilities
Counted in every place a method is inlined, and applied before inlining, so layout, inlining and the loop optimizations all see them.
-
Receiver types
Counted at every virtual call in every calling context. The inliner tests for the common receivers first and inlines their methods.
-
Loop range splitting
A hot loop whose checks always go one way runs its middle part without them — which also lets the vectorizer take it.
-
Cold code for size
Methods the run never reached don't inline and are compiled as
-Oswould: images 7 to 12% smaller at the same speed. -
Code layout
The code section is ordered by how often each method ran.
-
Hot methods at
-O3Below
-O3, hot methods still get its inliner, unrolling and vectorization —-O3speed in an-O2-sized image on the samples. -
The collector, too
GC branches are profiled like the program's, and the serial collector promotes and copies the way a profiled program wants.
-
Young collection on several threads
New and opt-in: built with
-H:+ParallelYoungCollection, the serial collector scans its young generation on up to nine threads. Programs that collect on several threads of their own gain most. -
Smaller objects
The recording lists the types the run locked; the others it used drop their eight-byte monitor slot. They still lock correctly, only slowly, so record a workload that locks what production locks.
-
Faster
System.arraycopyCopies go to
memmovewith one card mark per copy, instead of a scalar loop with a barrier per element.
How it compares
Profile-guided binary from each compiler, Oracle GraalVM 25.0.4 against CrucibleVM on GraalVM Community Edition 25.4.4.1.1, same machine, -O3, every binary checked for identical output; without a profile, each builder at its default level with nothing asked for. Every workload is listed, including the ones where CrucibleVM loses. What it was measured on is below; one machine is one machine, so please run your own.
- Renaissance, fourteen benchmarks, from sampled stacks
- 0.871 of Oracle's time
- With smaller objects; 0.881 without them in the same session
- The same, from a counted profile
- 0.913 of Oracle's time
- The plain recording, no second sampled run, before smaller objects
- Behind Oracle
- 1 of fourteen
- rx-scrabble, by 1%, on 0.4.0 as released (2026-10-30). Over three sessions before smaller objects, scala-stm-bench7 was behind too, by up to 2%
- With the young collection on several threads
- 0.824 of Oracle's time
- On by default since 0.4.0, measured on 0.4.0 as released, on 8 processors; 0.858 with the serial collector in an earlier session
- Without a profile, at the default level
- 0.917 of Oracle's time
- A plain build, nothing asked for, against Oracle's; ahead on thirteen, level on rx-scrabble; images 17% larger
Below 1 means less time than Oracle's binary: 0.871 is 13% less. It is the geometric mean, over the benchmarks, of CrucibleVM's time divided by Oracle's.
Services and samples
| Workload | Oracle GraalVM | CrucibleVM | Result |
|---|---|---|---|
| Services requests a second, higher is better | |||
Quarkus REST/JSON quickstart, /fruits | 100,060 | 105,200 | 5% more |
Spring PetClinic, /vets (JSON) | 36,800 | 46,500 | 26% more |
Spring PetClinic, / | 5,890 | 6,550 | 11% more |
| Samples seconds, lower is better | |||
| GameOfLife (Oracle's own example) | 3.39 | 2.91 | 14% faster |
| ArrayBenchOracle's image depends on how long a run it was recorded on: on 25.3 a short recording gave it 0.37 s | 1.19 | 0.31 | 74% faster |
| BranchBench17% lost to control flow duplication, new in 25.4; on 25.3 it was 16% ahead | 0.55 | 0.54 | about level |
| JsonBench | 1.56 | 1.39 | 11% faster |
| BenchPGO9% lost to control flow duplication | 0.37 | 0.32 | 14% faster |
The services are built and recorded with crucible/pgo.sh under the load they are timed with. The samples are built from counted recordings, medians of eleven runs.
Quarkus and Spring Boot Quarkus's own benchmark, with each builder's profile
The applications, settings and chart of Quarkus's spring-quarkus-perf-comparison, out of the box, with four native images of each application: Oracle GraalVM without a profile and with --pgo, CrucibleVM without a profile and with crucible/pgo.sh. Both profiles are recorded under the load the images are timed with, one minute of /fruits at 100 connections. The two JVM bars, on Temurin 25, are there as in Quarkus's chart.
With a profile, Spring Boot serves 31% more requests a second built by CrucibleVM than by Oracle GraalVM, 5,838 against 4,458, and 32% more without one, 4,028 against 3,046. Quarkus is level without a profile, 9,440 against 9,427, and 3% behind with one, 14,550 against 15,018. Boot and first response are level: 24 ms for both on Quarkus with a profile, 110 ms on Spring Boot. Memory after the first request is level on Quarkus with a profile, 45 MB, 4% higher on Spring Boot, 119 MB against 114, and 11 to 12% higher on both without a profile. The chart does not show memory under load: there CrucibleVM's Spring Boot image peaks at 372 MB against Oracle's 194, both under -Xmx512m, and its Quarkus image at 87 MB against 151.
Measured on the machine below the way Quarkus's lab measures, from its main.yml: its time-to-first-request script; resident memory from pmap 20 s after the start and after the first request; requests a second over 30 s at 100 connections after 2 minutes of warm-up and a 30 s pause; a fresh database for every measurement, Quarkus's PostgreSQL 17 image and settings; three rounds, the runtimes in turn, averaged as Quarkus's charts are. Different from Quarkus's lab: oha in place of Hyperfoil; the application on two cores (four threads), the database on two and the load generator on two, where the lab gives each its own cores of a 64-thread Xeon; the first round's boot time left out, since the image was not yet in the file cache and a native image took 4 to 10 times longer. Quarkus 3.39.4, Spring Boot 4.1.1, -Xmx512m -Xms512m for every runtime and -XX:+UseNUMA on the JVM, CrucibleVM at f3dc403398e, serial collector. These numbers are not comparable with Quarkus's published ones. The chart is drawn by Quarkus's generator from quarkusio/benchmarks, with the eight native runtimes added.
Renaissance from sampled stacks, three sessions of five rounds
These sessions were before smaller objects. With them: akka-uct 5.4% faster, scala-stm-bench7 4.8%, scala-kmeans, scala-doku, mnemonics, scrabble, reactors and rx-scrabble 1.4 to 2.2%, four within 1%, and philosophers 3% slower.
| Benchmark | Peak memory, CrucibleVM against Oracle | Time against Oracle |
|---|---|---|
| par-mnemonics | about the same | 29% faster |
| scrabbleThe collector now prefetches while it copies, worth 20% here | 335 MB against 174 | 23% faster |
| scala-doku | 82 MB against 132 | 23% faster |
philosophers, fj-kmeans, akka-uctfj-kmeans 179 MB against 530; philosophers about the same; akka-uct 2,460 MB against 931, but with -Xmx700m it runs as fast in 723 MB, and with the young collection on several threads it peaks at 1,131 MB against 904 | see note | 12 to 16% faster |
| future-genetic, finagle-chirperfinagle-chirper 125 MB against 258; future-genetic about the same | see note | 8 to 10% faster |
| reactors | 841 MB against 1,093 | 7 to 13% faster |
| mnemonics | 202 MB against 353 | 6 to 10% faster |
| finagle-http | 91 MB against 115 | 5% faster |
| scala-kmeans | about the same | 2% faster |
| rx-scrabble2% behind with smaller objects. About half is young collections inside the timed iterations: the collector keeps eden at about 71 MB where an iteration allocates over 100 MB | 114 MB against 153 | 3 to 4% slower |
| scala-stm-bench73.4% ahead with smaller objects | about the same | up to 2% slower |
The README gives each benchmark's result as a percentage or a range over the three sessions, and groups some benchmarks that fell in the same range; this table keeps its grouping. Memory is three rounds, with each collector choosing its heap: CrucibleVM peaks lower on seven of the fourteen, about the same on five, higher on two.
Where it is behind
On rx-scrabble both binaries run the same instructions outside the collector; about half the gap is young collections inside the timed iterations, 49 over forty iterations against Oracle's 6. scala-stm-bench7, which was behind for what the profile buys Oracle's compiler, is ahead since its objects got smaller.
Without a profile
A build nobody profiled, at the default level with nothing asked for, is what most builds are. Until 2026-10-12 CrucibleVM's ran at 1.16 of the time of Oracle's over the fourteen, scala-kmeans at twice it: a change in upstream GraalVM from August 2026 turns the inliner's exploration to its minimum at -O2, and every budget the inliner has is scaled by it, down to nothing. Oracle's 25.0.4 predates the change. The exploration is at its middle now and the default level has the -O3 typical graph size: 0.938 of Oracle's time over the fourteen, 0.917 with the library's profile below, ahead on thirteen and level on rx-scrabble. Spring PetClinic serves 13% and 17% more requests a second than before, Quarkus 16%, which puts three of the four pages ahead of Oracle's binary and PetClinic's front page 11% behind it. The cost is size: images 17% larger than Oracle's on Renaissance, 20% on PetClinic and 27% on Quarkus, and a build a third longer than before, still a tenth shorter than Oracle's. A smaller setting, -H:TypicalGraphSize=450, gives Oracle's image size and Oracle's speed on everything but Scala collections code, where scala-kmeans stays at 1.76. philosophers is the one benchmark that ran faster with the inliner starved, by 12%, and stays 10% ahead of Oracle.
A profile of the class library
Much of what a program runs is the JDK, and a branch of HashMap or of a stream goes much the same way whoever calls it. Since 2026-10-13 a build without a profile takes the probabilities of the library's branches from what other programs recorded for them, a profile of the library that comes with the builder; -H:-CrucibleLibraryProfile turns it off, and a build with a profile of its own does not use it. Over the fourteen at the default level it takes CrucibleVM from 0.938 to 0.917 of Oracle's time, 4,900 to 6,100 branches an image, with the branches that leave a loop left alone: the two services gain 3 to 5%, philosophers 6%, and future-genetic and scala-doku lose under 1%. At -O3 without a profile, 1.009 to 0.973. Measured earlier on the branch, from the Renaissance recordings alone, on programs of another kind: JsonBench 18% faster, Spring PetClinic 8.2% and 3.1% more requests a second on its two pages, the Quarkus quickstart 4.3% and 4.0%. A third of the way to a profile of its own on PetClinic's page and a tenth on Quarkus: a help for a build nobody profiled, not a replacement for profiling.
Collecting on several threads
Native Image in the community edition has one collector, and it collects on one thread; Oracle's G1, which needs a library only Oracle GraalVM ships, collects on several. Measured against Oracle's serial collector, G1 was 0.57 of its time on akka-uct, 0.78 on reactors and 0.81 on scala-stm-bench7, and lost most of that on one thread: the gain is the threads. Since 2026-10-16, CrucibleVM built with -H:+ParallelYoungCollection copies the young generation on up to nine threads; -XX:ParallelYoungCollectionThreads sets how many. With a profile, against Oracle's binary, in one session of three rounds on 8 processors: 0.827 over the fourteen, where the serial collector is at 0.858; scala-stm-bench7 0.837 against 0.991, akka-uct 0.657 against 0.744 with its peak memory down from 1,831 MB to 1,131, reactors 0.797 against 0.866, scrabble 0.607 against 0.645; none more than 1% slower than on the serial collector, and peak memory the same or lower; fj-kmeans, at 213 MB against 192 in that session, is level since a fix on 2026-10-17. The services are level with the serial collector: they spend little time collecting. It passes the native unit tests, with heap verification after every collection too, and more than 2,000 repeated benchmark runs; two akka-uct runs hung, one on each collector, and the cause is akka's own: akka/akka-core#33019, a mailbox submitted to the fork-join pool again while another worker is still taking it, and the benchmark hangs the same way on HotSpot. It is on by default since 0.4.0, counts a container's processor limit, and stays on one thread on one processor; build with -H:-ParallelYoungCollection to keep it on one thread.
What 25.4 changed
Control flow duplication, new in 25.4, stays on with a profile: the fourteen gain 0.7% from it, while BranchBench loses 17% and BenchPGO 9%. The collector now prefetches what the objects it copies point to once a collection copies more than 1 MB, and types the run used and never locked drop their monitor slot.
Image size
Last measured on 25.3: Oracle's builder cut its code area by 44% with a profile on the two services, CrucibleVM by 21–22%. Its service images were still larger: Quarkus 44.2 MB against 38.9, PetClinic 161.2 MB against 146.4.
What it was measured on
| Processor | Intel Xeon E-2236, 6 cores and 12 threads, 3.4 GHz (4.8 GHz turbo), 12 MB of L3 cache |
| Memory | 32 GB |
| System | Rocky Linux 10.2, kernel 6.12, x86-64; builds and runs inside a rootless Podman container of Rocky Linux 10 |
| Limits | Renaissance and the samples: 6 processors and 14 GB. The services: 8 processors and 24 GB, the service on four processors and the load generator (oha, 16 connections) on four others |
| Oracle's side | Oracle GraalVM 25.0.4 (25.0.4+7-LTS-jvmci-b01), --pgo-instrument then --pgo, which builds at -O3 |
| CrucibleVM's side | crucible/main on GraalVM Community Edition 25.4.4.1.1 with labs JDK 25, -O3, defaults |
| Collector and heap | The serial collector in both, each choosing its own heap unless a row says otherwise; the young collection on several threads where it is named, measured with 8 processors and 24 GB |
| Method | Renaissance 0.16.1, each benchmark as a standalone image: the median iteration after the first third of 10 to 20, five rounds with the binaries taken in turn, and the median of the rounds. Samples: wall time, median of eleven runs in turn. Services: requests a second over 20 s after 10 s of warm-up, median of three rounds |
Only Linux on x86-64 has been built and measured. Two builds of the same source can differ by 2 to 5% on some of these benchmarks, philosophers most of all, so a difference of that size on one of them is not a result.
Run your own
These are one machine's numbers on one set of programs, and yours will differ. Build your program both ways, crucible/pgo.sh here and --pgo with Oracle GraalVM, give both the same workload, check that they print the same, and time them in turn. crucible/samples/bench.sh times a sample with a profile against the same build without one.
Tell us what you find
Faster, slower or wrong, with the machine, the two versions and the commands, in an issue. A program where CrucibleVM loses is the most useful thing you can send.
One machine. The earlier GraalVM 25.3 results, every number behind these, and everything that was tried and did not work are in the README, the roadmap and docs/issues/.
Download it, or build it from source
CrucibleVM 0.4.0 for Linux x86-64 is a tar.gz that needs neither mx nor the source tree: unpack it and use cruciblevm/bin/native-image and cruciblevm/crucible/pgo.sh. It was built on Rocky Linux 10; 0.1.0 was checked on Ubuntu 24.04 with only gcc libc6-dev zlib1g-dev python3 installed, 0.2.0 added the parallel young collector and two run-time options for the serial collector's memory, 0.3.0 collects the old generation once it holds twice what the last complete collection kept, which brings Spring PetClinic under load from 311 MB to 240 to 280, and 0.4.0 collects the young generation on several threads by default. To build it yourself you need mx and a labs JDK 25; crucible/env.sh sets both up, and the first build takes 20 to 60 minutes.
git clone https://github.com/SiteNetSoft/crucible.git && cd crucible
source crucible/env.sh
cd substratevm && mx build
mx crucible-e2e # records, rebuilds and checks that the profile was applied
Get involved
CrucibleVM has been measured on a handful of benchmarks. What it needs most is more programs: try it on yours, with crucible/pgo.sh, and say what happened. A wrong answer, a crash, a slower binary and a faster one are all worth reporting.
-
Report wrong output
The program behaves differently built with a profile than without. This is the report that matters most; the guide says how to narrow it down, and a small program that shows it is worth more than any description.
-
Share a result
Faster or slower than without a profile, or than Oracle GraalVM's binary. Slower is as welcome as faster: the results above include where CrucibleVM loses, and yours should too.
-
Ask a question
How to record a service, why a build applied little of its profile, or whether something is a problem at all.
-
Send a change
Code, a benchmark or documentation. A change to what the compiler does comes with what it was measured to do, slower included. Commits are signed off under the Developer Certificate of Origin,
git commit -s; there is no agreement to sign.
Found a security vulnerability? Do not open a public issue; report it privately, as SECURITY.md says.