Native applications with GraalVM

published: ⏰ 8

A quick note on building native apps using GraalVM. The main goal was to speed up app startup and reduce memory consumption, but it turns out that this process is not without its problems.

GraalVm is a multi-language virtual machine that supports Java and other programming languages. One of its key features is the ability to compile Java applications into native code, which significantly speeds up their launch and reduces memory consumption.

Installing GraalVM

First, you need to install the GraalVM JDK. You can download it yourself, but I use SDKMAN! to install and switch between different JDK versions. Here is an example of downloading via curl:

Shell
curl -s "https://get.sdkman.io" | bash

After installing SDKMAN!, install GraalVM. Testing and verification were performed on version 21.0.6-graal:

When the installer asks whether to make the installed JDK the main one, answer - Yes. That’s all.

Building a native application

To build a native application, you will need a plugin for Maven or Gradle. I almost always use Maven, so the example will be with it. I think the usage with Gradle is similar.

There is no point in describing all possible parameters, they are configured for the project and can be found in the documentation for the plugin and for GraalVM on the official website. The example below shows the minimum parameters required for assembly. The following configuration must be added to the pom.xml file, in the <build> section (or better yet, create a separate profile):

XML

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <configuration>
                <archive>
                    <manifest>
                        <mainClass>
                            com.test.Main <!--(( 1 ))-->
                        </mainClass>
                        <addClasspath>true</addClasspath>
                    </manifest>
                </archive>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.graalvm.buildtools</groupId>
            <artifactId>native-maven-plugin</artifactId>
            <version>0.10.6</version>
            <extensions>true</extensions>
            <configuration>
                <buildArgs>
                    <buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
                    <buildArg>--gc=G1</buildArg>
                    <buildArg>-H:+StrictImageHeap</buildArg>
                    <buildArg>-march=native</buildArg>
                    <buildArg>--enable-sbom</buildArg>
                    <buildArg>-O3</buildArg> <!--(( 2 ))-->
                </buildArgs>
            </configuration>
            <executions>
                <execution>
                    <id>build-native</id>
                    <goals>
                        <goal>compile-no-fork</goal>
                    </goals>
                    <phase>package</phase>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>
  1. The name of the main class.
  2. The compiler optimization level, where -O3 means the maximum optimization level. It is better to use -Ob for checking the assembly, so as not to waste time.

When you run the package task, the native application will be compiled in the target directory (the name of the compiled application will match the name of the artifact). After that, you can run it.

Actually, this is where the problems start. For example, if reflection or Java SPI is used, then various errors will appear during startup, such as:

  • does not have a no-arg default constructor.
  • java.base@21.0.6/java.util.ServiceLoader.fail(ServiceLoader.java:586)
  • and other things that are at first glance unclear.

To correct these errors, you will have to additionally describe the metadata:

Tracing agent

Describing metadata manually is quite tedious. Fortunately, GraalVM has a tracing agent (Native Image Tracing Agent) to automatically collect this data.

It can be used separately, separately, by specifying the following line in the application launch parameters:

Shell
-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image

As a result, the following files will appear in the META-INF/native-image directory:

  • jni-config.json
  • predefined-classes-config.json
  • proxy-config.json
  • reflect-config.json
  • resource-config.json
  • serialization-config.json

The second way is to add a separate task to <build>:

XML

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <version>3.0.0</version>
    <executions>
        <execution>
            <id>java-agent</id>
            <goals>
                <goal>exec</goal>
            </goals>
            <phase>package</phase> <!--(( 1 ))-->
            <configuration>
                <executable>java</executable>
                <arguments>
                    <argument>-classpath</argument>
                    <classpath/>
                    <argument>com.test.Main</argument>
                </arguments>
            </configuration>
        </execution>
    </executions>
</plugin>
  1. This specifies the build phase at which the application will be launched.
Warning

It is important that all parts of the code are executed when the application is launched so that the agent can catch them.

After the agent has completed its work, we rebuild the project and the main errors should disappear.

Performance comparison

And finally about performance. For tests I used tekBlueprint.infra with calculation of metrics for a year, with different duration of tests: seconds, minutes, hours.

The results are actually expected, the native application starts up many times faster. Therefore, for cases when you need to quickly and frequently start applications – it is a good fit. The usual JVM loses time on startup and initial " warm-up" (I think it doesn’t even have time to reach JIT).

JIT – Just-In-Time compilation. A compiler that optimizes code during program execution. In Java, this is HotSpot.

But if the calculations take a long time (for example, a calculation per year in seconds), then the difference will be in the opposite direction. The “hot spots” of the executed code are optimized by JIT, and the native application remains at the same level.

Simulation parametersJITNativeNative vs JIT
8760 probes, each HOUR during 1 YEAR534.2 ms ± 12.5 ms36.5 ms ± 1.1 ms14.63 ± 0.54
525600 probes, each MINUTE during 1 YEAR1.560 s ± 0.067 s1.109 s ± 0.028 s1.41 ± 0.07
31536000 probes, each SECOND during 1 YEAR55.400 s ± 0.228 s64.362 s … 64.388 s-1.16 ± 0.00

Simulation by hours for a year

Shell
Benchmark 1: ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
  Time (mean ± σ):     534.2 ms ±  12.5 ms    [User: 1537.5 ms, System: 174.8 ms]
  Range (min … max):   519.1 ms … 556.7 ms    10 runs
 
Benchmark 2: ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
  Time (mean ± σ):      36.5 ms ±   1.1 ms    [User: 24.8 ms, System: 11.8 ms]
  Range (min … max):    34.2 ms …  39.9 ms    78 runs
 
Summary
  ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml ran
   14.63 ± 0.54 times faster than ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml

Simulation by minutes per year

Shell
Benchmark 1: ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
  Time (mean ± σ):      1.560 s ±  0.067 s    [User: 4.469 s, System: 0.313 s]
  Range (min … max):    1.495 s …  1.701 s    10 runs
 
Benchmark 2: ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
  Time (mean ± σ):      1.109 s ±  0.028 s    [User: 1.080 s, System: 0.288 s]
  Range (min … max):    1.079 s …  1.162 s    10 runs
 
Summary
  ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml ran
    1.41 ± 0.07 times faster than ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml

Simulation by seconds per year

Shell
Benchmark 1: ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
  Time (mean ± σ):     55.400 s ±  0.228 s    [User: 57.790 s, System: 0.416 s]
  Range (min … max):   55.238 s … 55.561 s    2 runs
 
Benchmark 2: ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml
  Time (mean ± σ):     64.375 s ±  0.018 s    [User: 64.609 s, System: 0.498 s]
  Range (min … max):   64.362 s … 64.388 s    2 runs
 
Summary
  ./tekBlueprint.infra -w ../infra-release/distrib/workbench software solve blueprint-simple.xml ran
    1.16 ± 0.00 times faster than ./infra-cli -w ../infra-release/distrib/workbench software solve blueprint-simple.xml

Below you can see that the native application’s prepare (drawing loading, simulation context creation), merge (results collector) stages take less than a millisecond, while a regular application spends from 2ms to 36ms on this. When calculating, the situation changes in the opposite direction, the native application is slower.

Example of simulation by seconds per year (JIT):

Shell
--- SOLVING SOFTWARE ---

31536000 probes, each SECOND during 1 YEAR
         prepare: 36ms
         progress:   3.76% (1186367 / 31536000), 237273.40 per second
         progress:  13.60% (4287894 / 31536000), 620305.40 per second
         progress:  22.98% (7246069 / 31536000), 591635.00 per second
         progress:  32.03% (10101239 / 31536000), 571034.00 per second
         progress:  41.02% (12935823 / 31536000), 566916.80 per second
         progress:  50.05% (15782622 / 31536000), 569359.80 per second
         progress:  58.94% (18586421 / 31536000), 560759.80 per second
         progress:  68.03% (21453728 / 31536000), 573461.40 per second
         progress:  77.30% (24377240 / 31536000), 584702.40 per second
         progress:  86.62% (27315928 / 31536000), 587737.60 per second
         progress:  95.82% (30216793 / 31536000), 580173.00 per second
         progress: 100.00% 31535999 / 31536000
         simulation: 54299ms
         merge: 2ms

Example of simulation by seconds per year (Native):

Shell
--- SOLVING SOFTWARE ---

31536000 probes, each SECOND during 1 YEAR
         prepare: 0ms
         progress:   3.38% (1064963 / 31536000), 212992.60 per second
         progress:  11.31% (3565270 / 31536000), 500061.40 per second
         progress:  19.17% (6043884 / 31536000), 495722.80 per second
         progress:  27.00% (8515355 / 31536000), 494294.20 per second
         progress:  34.88% (11000622 / 31536000), 497053.40 per second
         progress:  42.44% (13385213 / 31536000), 476918.20 per second
         progress:  50.04% (15781071 / 31536000), 479171.60 per second
         progress:  57.91% (18261901 / 31536000), 496166.00 per second
         progress:  65.81% (20752741 / 31536000), 498168.00 per second
         progress:  73.59% (23208860 / 31536000), 491223.80 per second
         progress:  81.25% (25621794 / 31536000), 482586.80 per second
         progress:  88.84% (28015952 / 31536000), 478831.60 per second
         progress:  96.52% (30439208 / 31536000), 484651.20 per second
         progress: 100.00% 31535999 / 31536000
         simulation: 64255ms
         merge: 0ms

Memory usage

The results with memory are a bit unexpected. In theory, a native app should take up less RAM. But in my case, the native app took up 26% more memory than the regular one.

Shell
    PID△USER       PRI  NI  VIRT   RES   SHR S  CPU% MEM%   TIME+  Command
1077262 sergey      20   0  7472  3676  3420 S   0.0  0.0  0:00.00 │  │  │  └─ bash ./tekBlueprint.infra
1077265 sergey      20   0 11.6G  510M 29644 S   0.0  1.6  0:00.00 │  │  │     └─ ./tekblueprint/infra/i
Shell
    PID△USER       PRI  NI  VIRT   RES   SHR S  CPU% MEM%   TIME+  Command
1075289 sergey      20   0 9528M  697M 42984 S   0.0  2.2  0:01.01 │  │  │  └─ ./infra-cli -w ../infra-r
1075290 sergey      20   0 9528M  697M     0 S   0.0  2.2  0:00.02 │  │  │     ├─ ./infra-cli -w ../infr

Perhaps this is some overhead for the native image itself. Plus, the application actively uses the Object Pool pattern, so new objects are rarely created so as not to overload the GC.

Judging by the Quarkus (Quarkus offers unequaled performance) website, everything should be the other way around in terms of memory usage.

Generating classes at runtime

There is another problem with applications that generate dynamic classes at runtime. For example, these are scripting languages or some template engines. In such cases, the compiler knows nothing about these files, and cannot know, since they are created by the user.

I did not bother to figure this out. tekBlueprint.infra is easier to use in change tracking mode, without constant restart. Here, the native one turns out to be worse in speed.

Conclusion

It is possible to increase the execution speed of native versions of the application using Profile-Guided Optimization. Judging by the description, it just performs a similar analysis that JIT does and can use these statistics for AOT. But it is not available in the GraalVM Community Edition, so I could not check it.

AOT – Ahead-of-Time compilation. Pre-compilation of a program into machine code before the program is run.

In summary, it looks like native compilation is great for function apps where you need to do some work quickly and then stop the app. (think AWS Lambda, OpenStack Lambda). But if it’s a long-running app that runs all the time, JIT compilation might be more efficient.

Graalvm Java Performance