Нативные приложения с GraalVM

опубликовано: ⏰ 9

Небольшая заметка о сборке нативных приложений с использованием GraalVM. Основная цель была ускорить запуск приложения и сократить потребление памяти, но оказалось, что этот процесс не без проблем.

GraalVm – это многоязыковая виртуальная машина, которая поддерживает Java и другие языки программирования. Одной из её ключевых особенностей является возможность компиляции Java-приложений в нативный код, что значительно ускоряет их запуск и снижает потребление памяти.

Установка GraalVM

Для начала нужно установить JDK GraalVM. Её можно скачать самому, но я использую SDKMAN! для установки и переключения между различными версиями JDK. Ниже пример скачивания через curl:

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

После установки SDKMAN! устанавливаем GraalVM. Тестирование и проверка выполнялась на версии 21.0.6-graal:

Когда установщик спросит, сделать или нет установленную JDK основной, ответьте - Yes. На этом всё.

Сборка нативного приложения

Для сборки нативного приложения потребуется плагин для Maven или Gradle. Практически всегда использую Maven, поэтому пример будет с ним. Думаю, с Gradle использование схожее.

Все возможные параметры описывать нет смысла, настраивается под проект, и их можно найти в документации к плагину и к GraalVM на официальном сайте. Пример ниже приведён с минимально необходимыми параметрами для сборки. В файл pom.xml нужно добавить следующую конфигурацию в секцию <build> (а лучше сделать отдельный профиль):

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. Имя основного класса.
  2. Уровень оптимизации компилятора, где -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) для автоматического сбора этих данных.

Его можно использовать отдельно, отдельно, указав в параметрах запуска приложения следующую строку:

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

В результате в каталоге META-INF/native-image появятся файлы:

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

Второй способ – это добавить отдельную задачу в <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. Здесь указывается фаза сборки, на которой будет запускаться приложение.
Предупреждение

Важно, чтобы при запуске приложения были выполнены все части кода, чтобы агент смог их отловить.

После того как отработал агент, пересобираем проект и основные ошибки должны пропасть.

Сравнение производительности

И наконец-то про производительность. Для тестов использовал tekBlueprint.infra с расчётом метрик на год, с разной длительностью проб: секунды, минуты, часы.

Результаты ожидаемые на самом деле, нативное приложение в разы быстрее запускается. Поэтому для вариантов, когда нужно быстро и часто запускать приложения – подходит хорошо. Обычная JVM теряет время на старт и начальный “прогрев” (думаю, до JIT даже не успевает дойти).

JIT – Just-In-Time компиляция. Компилятор, который оптимизирует код в процессе выполнения программы. В Java это HotSpot.

Но, если расчёты выполняются долго (например, по секундам расчёт на год), то разница уже будет в обратную сторону. “Горячие участки” выполняемого кода оптимизирует JIT, а нативное приложение остаётся на том же уровне.

Параметры расчётовJITNativeNative 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

Расчёт по часам на год

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

Расчёт по минутам на год

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

Расчёт по секундам на год

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

Ниже видно, что у нативного приложения этапы prepare (загрузка чертежа, создание контекста симуляции), merge (коллектор результатов) занимают меньше миллисекунды, в то время как обычное приложение тратит на это от 2 ms до 36 ms. При расчётах ситуация меняется в обратную сторону, уже нативное приложение медленнее.

Пример расчёта по секундам на год (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

Пример расчёта по секундам на год (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

Использование памяти

С памятью результаты немного неожиданные. В теории, нативное приложение должно занимать меньше оперативной памяти. Но в моём случае нативное занимало на 26% больше памяти, чем обычное.

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

Возможно, это какие-то накладные расходы на сам нативный образ. Плюс, в приложении активно используется шаблон 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 компиляция может быть более эффективной.

Graalvm Java Performance