JNI, часть 3: производительность Java/JNI/NDK

Всем привет! Меня зовут Роман Аймалетдинов, я разрабатываю клиентское приложение Ситимобил. Продолжаю свою серию статей по JNI, так как технология используется редко, но иногда она бывает очень полезной (или просто интересной). В этот раз я покажу замеры производительности, достаточно тривиальные, но отображающие суть. И если вы не знакомы с JNI, но тема интересна, то советую ознакомиться с первой и второй частью этой серии статей.

a80e104a31feea1d8601e242e11c1dd2.jpg

Характеристики машины

  • ЦП: AMD Ryzen 9 5900X (auto boost 3,2–4,9 ГГц).

  • ОЗУ: 16 Гб, 3000 МГц, DDR4.

  • Диск: SSD Samsung 970 evo.

  • Windows 10 19042.1466.

Напишем тест на Java

Будем писать его в известном нам из предыдущих частей классе — AwesomeLib.java. Прошу не удивляться простоте теста и подсчёта: высчитывать медиану и пропускать результаты после холодного старта, пока ЦП не разогреется, я не стал, не вижу в этом смысла в рамках конкретного исследования.

Для начала я заполняю массив числами Фибоначчи, затем присваиваю элементам массива результат деления текущего элемента на предыдущий. Это самое простое, что боло придумано в 2017 году. Да, я решил написать статью только сейчас, а тесты с JNI были проведены в 2017-м в рамках любопытства интерна :)

public double runJavaLongAlgorithm(int size) {
    double[] arr = new double[size];

    for (int i = 0; i < size; i++) {
        if (i == 0 || i == 1)
            arr[i] = 1;
        else
            arr[i] = arr[i - 1] + arr[i - 2];
    }

    for (int i = 1; i < size; i++)
        arr[i] /= arr[i - 1];
 }

Теперь тот же код на C++

В известном из первой статьи классе AwesomeLib.java добавим метод

public native double runNativeLongAlgorithm(int size);

Затем в сгенерированный файл nativelib_AwesomeLib.h. Если непонятно, откуда появился .h-файл, то обратитесь к первой статье.

/*
 * Class:     nativelib_AwesomeLib
 * Method:    runNativeLongAlgorithm
 * Signature: (I)D
 */
JNIEXPORT jdouble JNICALL Java_nativelib_AwesomeLib_runNativeLongAlgorithm
  (JNIEnv *, jobject, jint);

Обновляем наш AwesomeLib.cpp:

JNIEXPORT jdouble JNICALL Java_nativelib_AwesomeLib_runNativeLongAlgorithm(
    JNIEnv * env,
    jobject obj,
    jint size
) {
    double *arr = new double[size];

    for(int i = 0; i < size; i++) {
        if (i == 0 || i == 1)
            arr[i] = 1;
        else
            arr[i] = arr[i - 1] + arr[i - 2];
    }

    for(int i = 1; i < size; i++)
        arr[i] /= arr[i - 1];
}

Создаем .dll консольными командами (описано в первой статье), и осталось только вызвать наши методы в Main.java. Поясню: я заполняю два листа временем выполнения микробенчмарка. Нахожу среднее в массиве из 100 запусков и вывожу в консоль.

public class Main {

    private static final int JAVA_VS_NATIVE_ARR_SIZE = 20_000;

    public static void main(String[] args) {
        AwesomeLib nativeLib = new AwesomeLib();

        ArrayList javaList = new ArrayList<>(101);
        ArrayList nativeList = new ArrayList<>(101);

        for(int i = 0; i < 100; i++) {
            long t1 = System.currentTimeMillis();
            nativeLib.runJavaLongAlgorithm(JAVA_VS_NATIVE_ARR_SIZE);
            long t2 = System.currentTimeMillis();
            javaList.add((double)(t2 - t1) / 1000);
        }

        double javaAvg = javaList
                .stream()
                .mapToDouble(d -> d)
                .average()
                .orElse(0.0);
        System.out.println("Java код выполнился в среднем за: " + javaAvg);

        for(int i = 0; i < 100; i++) {
            long t1 = System.currentTimeMillis();
            nativeLib.runNativeLongAlgorithm(JAVA_VS_NATIVE_ARR_SIZE);
            long t2 = System.currentTimeMillis();
            nativeList.add((double)(t2 - t1) / 1000);
        }

        double nativeAvg = nativeList
                .stream()
                .mapToDouble(d -> d)
                .average()
                .orElse(0.0);
        System.out.println("Native код выполнился в среднем за: " + nativeAvg);
    }
}

Результаты:

Как видно, нативный код отработал в среднем медленнее, чем аналогичный код на Java.

> Task :Main.main()
Java код выполнился в среднем за: 0.00768
Native код выполнился в среднем за: 0.00808
BUILD SUCCESSFUL in 1s
3 actionable tasks: 1 executed, 2 up-to-date
19:49:25: Task execution finished 'Main.main()'.

Так, ну, возможно, вызовы JNI имеют транспортный налог, и поэтому возникает задержка. C++ наверняка быстрее, это только обращения к нативу медленные! Давайте усложним код до сложности N2. Аналогичный код и на Java, и на C++, теперь он будет выполняться значительно медленнее, поэтому количество заполнений массива уменьшим до 50 000:

public void runJavaLongAlgorithm(int size) {
    double[] arr = new double[size];

    for (int i = 0; i < size; i++) {
        if (i == 0 || i == 1)
            arr[i] = 1;
        else
            arr[i] = arr[i - 1] + arr[i - 2];
    }

    for (int i = 1; i < size; i++) {
        arr[i] /= arr[i - 1];

        for (int k = 0; k < size; k++) {
            arr[i] *= arr[i];
        }
    }
}

Запускаем:

> Task :Main.main()
Java код выполнился в среднем за: 0.24600
Native код выполнился в среднем за: 1.05392
BUILD SUCCESSFUL in 2m 10s
3 actionable tasks: 3 executed
20:07:03: Task execution finished 'Main.main()'.

JNI действительно проигрывает Java. Причём разница растёт с увеличением сложности выполняемого алгоритма.

Именно так это и работает. Получается, что некоторые сайты говорят не совсем чистую правду? Возможно и так, а возможно, что этот инструмент для профессионального разработчика на C/C++, который лучше JVM умеет в управление памятью и в такую магию, в которую я, простой мобильный разработчик, сейчас вникать не хочу. Но общий концепт ясен, JNI, скорее всего, не добавит вашему проекту производительности, но может создать очень много сложностей.

IBM написали очень хорошую статью про производительность JNI, с которой я тягаться конечно же, не собирался, поэтому просто оставлю её тут.

А что насчёт Android?

Какое-то время я был уверен, что JNI/NDK — это выход для Android, если необходимо выполнить какое-либо тяжёлое вычисление. Я был уверен, что на C/C++ будет быстрее. А уверен в этом я был потому, что в 2017 году, будучи интерном в одном automotive-проекте, видя, как везде вовсю используется native-код, я написал мобильное приложение, в которое включил тот же самый код, который я вам представил в статье, и увидел значительную разницу.

C++ был быстрее, чем Java-код.

Благо я положил приложение в свой репозиторий и забыл на четыре года. Теперь я его откопал, сдул пыль, обновил зависимости, чтобы проект запустился, чуточку актуализировал, конвертировав в Kotlin, и показываю вам. Результат меня удивил. C++ проигрывает JVM на моём Galaxy S20, однако я достал ещё и старое устройство и запустил на нём. Теперь всё сошлось.

Тест на Android

На старом устройстве C++ действительно выиграл в производительности. Это не единичный тест, а стабильная ситуация, на старых устройствах нативный код отрабатывает со значительным приростом производительности. А вот на новом устройстве/ОС уже доминирует JVM. Разбираться, почему так случилось, мне не захотелось, но если есть предположения, буду рад их видеть в комментариях.

image-loader.svg

Выводы

Теперь вы знаете, какова ситуация с производительностью, однако это очень простые примеры. В приведенных мною примерах производительность на desktop-машине у Java была выше, чем у нативного решения. А в мобильном сегменте Java проигрывает на старых устройствах и одерживает убедительную победу на новых. Таким образом, в 2022 году использование JNI/NDK в новых проектах очень спорное. Так или иначе, сейчас всё ещё много проектов использует эти технологии, и мне пару-тройку раз писали рекрутёры, потому что у меня в CV скромно написано: JNI/NDK (Entry). Значит, спрос есть, просто мой кругозор очень мал, и, как говорится, чем больше мы знаем, тем больше мы понимаем, что не знаем ничего.

Абсолютно точно JNI/NDK полезен, если:

  • Вам нужно получить доступ к скрытой нативной библиотеке.

  • Нужно использовать существующую библиотеку на C++ вместо переписывания.

  • Вы бог С++ и напишете более производительный код, чем на Java/Kotlin.

  • Вы знаете что-то, чего не знаю я.

Абсолютно точно JNI/NDK не нужен, если:

Возможно, мне будет не лень и я напишу статью про истинную причину сильного увеличения производительности Java/Kotlin по сравнению с C++, если докопаюсь до истины, но на текущий момент это всё.

© Habrahabr.ru