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

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

  • ЦП: 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<Double> javaList = new ArrayList<>(101);
        ArrayList<Double> 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

  • Galaxy S20, Android 12

  • Digma VOX 501, Android 5.1

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

Выводы

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

UPD:
Раньше я думал, что можно оптимизировать небольшие алгоритмы вынося часть кода в натив, именно небольшие алгоритмы которые что-то считают. Особенно выгодно это казалось для android. Но проведя эти тесты я понял, что как android разработчик без углубленного знания C/C++ я вряд ли смогу написать код, который будет быстрее чем на Java, а использование native для простых алгоритмов и вовсе с большой вероятностью не даст ожидаемого результата.

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

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

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

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

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

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

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

  • Можно обойтись простой Java/Kotlin без больших жертв.

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

Комментарии (7)


  1. pomadka
    15.02.2022 14:38
    +1

    Здравствуйте! Для android ndk тоже нужно выполнять консольные команды? Есть ли отличаи от jni в командах?


    1. Evleaps Автор
      15.02.2022 14:50

      Здравствуйте! Нет, выполнять консольные команды из Android Studio не нужно, все работает из коробки. Т.е. в AS все намного проще. Советую прочитать эту статью которая рассказывает именно про AS.


  1. bfDeveloper
    15.02.2022 14:58
    +9

    Я взял ваш код на C++ и Java и сравнил без JNI, просто две разных программы. На обеих версиях, что линейной, что квадратичной, результаты идентичны для обоих языков. Разницей в 4 раза даже не пахнет.

    Debian 11, AMD Ryzen Threadripper 3970X

    openjdk 11.0.13 2021-10-19 запускал через java main.java.

    gcc version 10.2.1 g++ -O2 -o cmp_java java.cc && ./cmp_java

    В обоих случаях около 1.8 секунды при размере задачи 50_000

    И это вполне ожидаемые разультаты для числодробилки, потому что на подобном коде jit особо не уступает aot.

    Но возьмите задачу, которая требует больше разнородных действий, сильнее нагружает GC, использует системные api и увидите, что всё уже совсем не в пользу Java. Поэтому заявления, что java быстрее jni мягко говоря необоснованы.

    Не спорю, на java можно писать высокопроизводительный код, но начиная с некоторой сложности задачи, это становится слишком тяжело - мест, где можно потерять всю скорость, язык и библиотеки предоставляют массу.


    1. Evleaps Автор
      15.02.2022 15:13
      +1

      Большое спасибо за комментарий!

      Вы действительно правы! Использование native сейчас, кажется оправданным, если код действительно сложный, а человек/команда его пишущий действительно хорошо понимает тонкости языка.

      Однако существовало мнение, что можно оптимизировать небольшие алгоритмы вынося часть кода в натив, именно небольшие алгоритмы которые что-то считают. Особенно выгодно это казалось для android. Но Проведя эти тесты я понял, что как android разработчик без углубленного знания C/C++ я вряд ли смогу написать код который будет быстрее чем на Java, а использование native для простых алгоритмов и вовсе с большой вероятностью не даст ожидаемого результата.

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


      1. iShrimp
        15.02.2022 20:26
        +1

        Интересно, каков предел возможностей андроидовского ART. Насколько сложным должен быть алгоритм, чтобы он не смог его оптимизировать и пришлось переходить на С++ и интринсики? Свёртки, IIR, edge detection, median filter ...

        Существует такой замечательный сервис - godbolt.org - для сравнения эффективности статических компиляторов. Вот бы что-нибудь подобное для JIT.


  1. yarston
    15.02.2022 17:40
    +3

    Если в build.gradle добавить флаг оптимизации -O3 в строку cppFlags "-fexceptions -O3", то время выполнения с++ и java кода становится +/- одиноаковым, C++ чуть быстрее на устройстве и чуть медленнее в эмуляторе. Это не удивительно, потому что у вас последовательный проход по массиву в цикле - при этом проверки на выход за пределы массива вырезаются. А вот если паттерн доступа будет более сложный, то java код очень сильно проиграет, например, при преобразовании фурье.


  1. dbf
    16.02.2022 10:19

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

    Почему было принято такое решение? Итерации до jit могут быть значительно медленнее и оказать влияние на среднее. Значение вычисления нигде не используется, так что, теоретически, вызов функции может быть и убран компилятором. Стоит ли самому писать усреднение и наталкиваться на проблемы измерения и т.д. если есть инструменты специально для таких сравнений, и например, JMH?