CrucibleVM alpha

Open-source profile-guided optimization for native-image.

CrucibleVM is a fork of GraalVM Community Edition. Build your program once so that it records how it runs, run it on a workload that looks like production, and build it again with what it recorded. The second build knows which branches go which way, which methods the time is spent in, and where calls actually land — and compiles accordingly.

Linux x86-64, 547 MB, a GraalVM JDK 25 with native-image and the CrucibleVM scripts; it needs gcc, the C library headers, zlib headers and python3. Release notes and checksum.

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.

  1. 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
  2. Run it on a representative workload

    It writes crucible-profile.json when it exits. The recording image runs about twice as slow as normal.

    ./app-recording <the usual arguments>
  3. 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.

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, /fruits100,060105,2005% more
Spring PetClinic, /vets (JSON)36,80046,50026% more
Spring PetClinic, /5,8906,55011% more
Samples seconds, lower is better
GameOfLife (Oracle's own example)3.392.9114% faster
ArrayBenchOracle's image depends on how long a run it was recorded on: on 25.3 a short recording gave it 0.37 s1.190.3174% faster
BranchBench17% lost to control flow duplication, new in 25.4; on 25.3 it was 16% ahead0.550.54about level
JsonBench1.561.3911% faster
BenchPGO9% lost to control flow duplication0.370.3214% 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.

Boot and first response time, throughput and memory after the first request for Quarkus and Spring Boot, on the JVM and as native images from Oracle GraalVM and CrucibleVM with and without a profile

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-mnemonicsabout the same29% faster
scrabbleThe collector now prefetches while it copies, worth 20% here335 MB against 17423% faster
scala-doku82 MB against 13223% 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 904see note12 to 16% faster
future-genetic, finagle-chirperfinagle-chirper 125 MB against 258; future-genetic about the samesee note8 to 10% faster
reactors841 MB against 1,0937 to 13% faster
mnemonics202 MB against 3536 to 10% faster
finagle-http91 MB against 1155% faster
scala-kmeansabout the same2% 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 MB114 MB against 1533 to 4% slower
scala-stm-bench73.4% ahead with smaller objectsabout the sameup 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

ProcessorIntel Xeon E-2236, 6 cores and 12 threads, 3.4 GHz (4.8 GHz turbo), 12 MB of L3 cache
Memory32 GB
SystemRocky Linux 10.2, kernel 6.12, x86-64; builds and runs inside a rootless Podman container of Rocky Linux 10
LimitsRenaissance 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 sideOracle GraalVM 25.0.4 (25.0.4+7-LTS-jvmci-b01), --pgo-instrument then --pgo, which builds at -O3
CrucibleVM's sidecrucible/main on GraalVM Community Edition 25.4.4.1.1 with labs JDK 25, -O3, defaults
Collector and heapThe 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
MethodRenaissance 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.

Found a security vulnerability? Do not open a public issue; report it privately, as SECURITY.md says.