Нативные приложения с GraalVM
опубликовано: ⏰ 9Небольшая заметка о сборке нативных приложений с использованием GraalVM. Основная цель была ускорить запуск приложения и сократить потребление памяти, но оказалось, что этот процесс не без проблем.
GraalVm – это многоязыковая виртуальная машина, которая поддерживает Java и другие языки программирования. Одной из её ключевых особенностей является возможность компиляции Java-приложений в нативный код, что значительно ускоряет их запуск и снижает потребление памяти.
Установка GraalVM
Для начала нужно установить JDK GraalVM. Её можно скачать самому, но я использую SDKMAN! для
установки и переключения между различными версиями JDK. Ниже пример скачивания через curl:
curl -s "https://get.sdkman.io" | bashПосле установки SDKMAN! устанавливаем GraalVM. Тестирование и проверка выполнялась на версии 21.0.6-graal:

Когда установщик спросит, сделать или нет установленную JDK основной, ответьте - Yes. На этом всё.
Сборка нативного приложения
Для сборки нативного приложения потребуется плагин для Maven или Gradle. Практически всегда использую Maven, поэтому пример будет с ним. Думаю, с Gradle использование схожее.
Все возможные параметры описывать нет смысла, настраивается под проект, и их можно найти в документации к плагину и к
GraalVM на официальном сайте. Пример ниже приведён с минимально необходимыми параметрами для сборки. В файл pom.xml
нужно добавить следующую конфигурацию в секцию <build> (а лучше сделать отдельный профиль):
<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>- Имя основного класса.
- Уровень оптимизации компилятора, где
-O3означает максимальный уровень оптимизации. Для проверки сборки лучше использовать-Ob, чтобы не тратить время.
При запуске задачи package будет скомпилировано нативное приложение в каталоге target (имя скомпилированного
приложения будет совпадать с именем артефакта). После этого можно запускать.
Собственно, с этого момента и начинаются проблемы. Например, если используется рефлексия или Java SPI, то посыпятся разного вида ошибки при запуске, вроде:
does not have a no-arg default constructor.java.base@21.0.6/java.util.ServiceLoader.fail(ServiceLoader.java:586)- и прочие малопонятные на первый взгляд вещи.
Чтобы исправить эти ошибки, придутся дополнительно описывать метаданные:
Агент трассировки
Заниматься описанием метаданных вручную – занятие довольно утомительное. К счастью, у GraalVM есть агент трассировки (Native Image Tracing Agent) для автоматического сбора этих данных.
Его можно использовать отдельно, отдельно, указав в параметрах запуска приложения следующую строку:
-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-imageВ результате в каталоге META-INF/native-image появятся файлы:
jni-config.jsonpredefined-classes-config.jsonproxy-config.jsonreflect-config.jsonresource-config.jsonserialization-config.json
Второй способ – это добавить отдельную задачу в <build>:
<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>- Здесь указывается фаза сборки, на которой будет запускаться приложение.
Важно, чтобы при запуске приложения были выполнены все части кода, чтобы агент смог их отловить.
После того как отработал агент, пересобираем проект и основные ошибки должны пропасть.
Сравнение производительности
И наконец-то про производительность. Для тестов использовал tekBlueprint.infra с расчётом метрик на год, с разной
длительностью проб: секунды, минуты, часы.
Результаты ожидаемые на самом деле, нативное приложение в разы быстрее запускается. Поэтому для вариантов, когда нужно быстро и часто запускать приложения – подходит хорошо. Обычная JVM теряет время на старт и начальный “прогрев” (думаю, до JIT даже не успевает дойти).
JIT – Just-In-Time компиляция. Компилятор, который оптимизирует код в процессе выполнения программы. В Java это HotSpot.
Но, если расчёты выполняются долго (например, по секундам расчёт на год), то разница уже будет в обратную сторону. “Горячие участки” выполняемого кода оптимизирует JIT, а нативное приложение остаётся на том же уровне.
| Параметры расчётов | JIT | Native | Native vs JIT |
|---|---|---|---|
| 8760 probes, each HOUR during 1 YEAR | 534.2 ms ± 12.5 ms | 36.5 ms ± 1.1 ms | 14.63 ± 0.54 |
| 525600 probes, each MINUTE during 1 YEAR | 1.560 s ± 0.067 s | 1.109 s ± 0.028 s | 1.41 ± 0.07 |
| 31536000 probes, each SECOND during 1 YEAR | 55.400 s ± 0.228 s | 64.362 s … 64.388 s | -1.16 ± 0.00 |
Расчёт по часам на год
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Расчёт по минутам на год
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Расчёт по секундам на год
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Ниже видно, что у нативного приложения этапы prepare (загрузка чертежа, создание контекста симуляции),
merge (коллектор результатов) занимают меньше миллисекунды, в то время как обычное приложение тратит на это от 2 ms до
36 ms. При расчётах ситуация меняется в обратную сторону, уже нативное приложение медленнее.
Пример расчёта по секундам на год (JIT):
--- 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Пример расчёта по секундам на год (Native):
--- 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Использование памяти
С памятью результаты немного неожиданные. В теории, нативное приложение должно занимать меньше оперативной памяти. Но в моём случае нативное занимало на 26% больше памяти, чем обычное.
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 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Возможно, это какие-то накладные расходы на сам нативный образ. Плюс, в приложении активно используется шаблон Object Pool, поэтому новые объекты редко создаются, чтобы не нагружать GC.
Судя по сайту Quarkus (Quarkus offers unequaled performance) всё должно быть наоборот в плане использования памяти.
Генерация классов в рантайме
Есть ещё одна проблема с приложениями, которые генерируют динамические классы в рантайме. Например, это скриптовые языки или какие-нибудь шаблонизаторы. В таких случаях компилятор ничего не знает об этих файлах, да и не может знать, так как они создаются пользователем.
С этим уже не стал разбираться. tekBlueprint.infra проще использовать в режиме отслеживания изменений, без постоянного
перезапуска. Тут нативное как раз оказывается хуже по скорости.
Заключение
Возможно, скорость выполнения нативных версий приложения можно поднять, используя Profile-Guided Optimization. Судя по описанию, он как раз выполняет схожий анализ, который выполняет JIT и может использовать эту статистику для AOT. Но он недоступен в “GraalVM Community Edition”, поэтому поверить не было возможности.
AOT – Ahead-of-Time компиляция. Предварительная компиляция программы в машинный код до запуска программы.
По итогу, выглядит так, что компиляция в нативные приложения очень хороша для приложений-функций, когда необходимо быстро выполнить какую-то работу и завершится (аналог, AWS Lambda, OpenStack Lambda). Но если это долгоживущее приложение, которое постоянно работает, то JIT компиляция может быть более эффективной.