Вы когда-нибудь задумывались, насколько grpc быстрый. Да, в сети, ему равных нет. Если вы гоняете маленькие сообщения, которые надо быстро доставить, то лучше grpc попросту не найти ( найти, но по мнению автору protobuf остается движком сериализации, поддерживающим большее кол-во языков ). Но насколько он хорош? Сможет ли он к примеру сравнится просто с нативными вызовами?

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

Постановка задачи

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

К примеру, если вы пишете ndk приложения, то почти со 100% вероятностью вам понадобится использовать этот подход, чтобы просто смаппить данные из jvm в классические C++ классы или структуры. И да, этот маппинг не для слабонервных.

JNIEXPORT jint JNICALL
Java_com_example_engine_JniEngine_cmd(JNIEnv *env, jobject, jobject jCmd) {
    struct some_cmd cmd{};

    jfieldID idField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "id", "I");
    jfieldID nameField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "name", "Ljava/lang/String;");

    cmd.id = env->GetIntField(jCmd, idField);
    
    jstring jName = (jstring) env->GetObjectField(jCmd, nameField);
    const char *name = jName != NULL ? env->GetStringUTFChars(jName, NULL) : NULL;
    cmd.name = std::string(name ?: "");
    if (name != NULL) env->ReleaseStringUTFChars(jName, name);
}

Примерно так нужно маппить данные из jvm в нативной библиотеке ndk. И как сразу много проблем случается, если к примеру кто-то решит перенести модельку SomeCmd из одного пакета в другой. Ну или переименовать поле или метод.

Каждый такой маппинг может использовать рефлексию один или несколько раз.

jfieldID idField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "id", "I");
jfieldID nameField = env->GetFieldID(env->FindClass("com/example/model/SomeCmd"), "name", "Ljava/lang/String;");

Это достаточно затратный процесс, в особенности, если полей и методов, которые мы ищем, много. Кажется простая сериализация моделей в какой-нибудь бинарный формат ускорит этот процесс, да и еще избавит нас от необходимости чистить ресурсы.

Просто Proto

Выбрав путь воина, тестировать планируем все на своем компьютере. А так как монструозных плагинов, поддерживающих и cmake и java, немного, автор может упомянуть только AGP, и он нам не подходит из-за специфичности платформы, то выбираем свой путь. Выполняем сборку проекта из нескольких плагинов для cmake, java, а также protobuf.

plugins {
    application
    id("com.github.gmazzo.buildconfig") version ("3.1.0")
    id("io.github.tomtzook.gradle-cmake") version ("1.2.2")
    id("com.google.protobuf") version ("0.9.3")
}

Используем классическую реализацию protobuf без grpc и kroto плагинов. Сам тест при этом ничего делает, только пересылает простую модельку в нативную библиотеку и обратно. Модельку сделали вложенной и из различных типов данных, чтобы приблизиться к реальному использованию.

syntax = "proto3";

option java_package = "com.github.klee0kai.proto";

message SomeCmdModel {
  int32 id = 1;
  int64 count = 2;
  float value = 3;
  double valueD = 4;
  string name = 5;
  repeated MetaModel meta = 6;
}

Для cmake проекта дополнительно нужно установить библиотеки protoc, к примеру для ubuntu.

apt-get install libprotobuf-dev protobuf-compiler

Генерируем модели C++ из proto файлов и подтягиваем библиотеку в CMakeLists.txt.

find_package(Protobuf REQUIRED)
include_directories(${Protobuf_INCLUDE_DIRS})
include_directories(${CMAKE_CURRENT_BINARY_DIR})
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS ./../proto/jni.proto)

target_link_libraries(myapplication ${Protobuf_LIBRARIES})

target_include_directories(
        myapplication
        PUBLIC
        ${JNI_INCLUDE_DIRS}
        ${PROTO_HDRS}

        ./../../../build/generated/sources/headers/java/main
)

Сами хедер файлы генерируются protobuf плагином в gradle, а работа с protobuf в C++ будет уже работать за счет, найденной в cmake, библиотеки. Проверяйте версии используемых библиотек и там и там, так как найденная в cmake библиотека protobuf может не переварить сгенерированные хедер файлы либо не поддержать различные опции, как например lite.

Так как в нашем проекте использованы несколько самостоятельных плагинов, никак не связанных между собой, немного корректируем последовательность выполнения задач в gradle. Сборку cmake будем выполнять после java - это нужно, чтобы сгенерировались jni хедер файлы для C++, а также сгенерировались хедер файлы proto моделек, которые в нашем случае генерируются через gradle.

tasks.clean.dependsOn(tasks.cmakeClean)
tasks.classes.dependsOn(tasks.cmakeBuild)
tasks.cmakeBuild.dependsOn(tasks.compileJava)

Сам проект, кстати, написан на java. Jni интеграция для kotlin выглядит сложнее. Так, например, многие kotlin типы в jni не доступны по своим именам классов:kotlin.Int представляется либо через примитивный тип int, либо через его объектное представление java.lang.Integer. Kotlin не генерирует хедер файлы для jni, что в java поддерживается по умолчанию. Вдобавок, kotlin может исказить итоговые имена полей и методов.

Собрав все плагины воедино, получаем следующую последовательность сборки проекта.

:term:run
\--- :term:classes
     +--- :term:cmakeBuild
     |    +--- :term:compileJava
     |    |    +--- :term:generateBuildConfig
     |    |    \--- :term:generateProto
     |    |         +--- :term:extractIncludeProto
     |    |         \--- :term:extractProto
     |    \--- :term:myapplication_linux-amd64_runGeneratorUnix_Makefiles
     |         \--- :term:cmakemyapplication_linux-amd64
     +--- :term:compileJava
     |    +--- ***
     \--- :term:processResources
          \--- :term:extractProto
               \--- ***

Осталось собрать и прогнать тест.

./gradlew run

Сухой остаток

Прогнав тест получаем результат

> Task :term:run
test jni reflection test...........
Test time 1.272
test on indexed jni reflection...........
Test time 0.594
test proto serialize...........
Test time 1.034

Protobuf получился быстрее рефлексии, однако не является лучшим решением для работы с jni. Самым оптимальным получилось решение использовать рефлексию с предварительным индексированием классов и методов - что заняло по итогу около 0.594 с. работы для 100к операции копирования и пересылки моделек в jni библиотеку и обратно.

Так как автор не провел замеры скорости работы нативной работы java, то предположим, что проиндексированная рефлексия является наиболее ее приближенным вариантом. Потери при использовании protobuf будут x2 от нативной работы, а при рефлексии - около x2.2. Подробнее о работе смотри на github.

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


  1. ris58h
    16.09.2023 12:34
    +9

    Читается тяжело, если честно.

    Смысл задачи я, вроде, уловил, но если ключевая цель статьи "сравнить", то почему так мало внимания уделено непосредственно сравнению?

    Прогнав тест получаем результат

    Какой тест? Что тестируем? Как тестируем? Что за "предварительное индексирование"?

    Открыл ваш тест на GitHub и вижу там какие-то слушатели, вывод в консоль и прочее не относящееся к непосредственно сериализации. Вы что померить хотели? Почему JMH не использовали?

    https://habr.com/ru/articles/349914/


    1. klee0kai Автор
      16.09.2023 12:34
      +1

      Спасибо за замечание, думал прикрутить какие-нибудь замеры, к примеру hugo. Про jmh не слышал и сейчас могу отнестись скептически, так как понял, что он ломает работу с JIT и с GC, что может исказить реальное представление о скорости работы программы, но на будущее спасибо, обязательно гляну.

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

      Открыл ваш тест на GitHub и вижу там какие-то слушатели, вывод в консоль и прочее не относящееся к непосредственно сериализации

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

      Про раскрытие темы, спасибо за замечание


      1. ris58h
        16.09.2023 12:34
        +5

        Прикрутив "какие-нибудь замеры" вы получите какие-нибудь результаты. Что только потом с ними делать? Создатели JMH на бенчмарках собаку съели. https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html

        что конкретнее вы хотели увидеть?

        Я, в тестах которые замеряют скорость сериализации, хотел бы видеть только её. Во-первых, потому что другие операции могут занять достаточно много времени. Во-вторых, это усложняет тест. Как со стороны оценить что ваши тесты правильные?


        1. klee0kai Автор
          16.09.2023 12:34
          +1

          Замеры рефлексии и сериализации отдельно уже проведены. Автор пожелал провести сравнительные замеры механизмов взаимодействия с jni библиотекой, что отображено в шапке статьи.

          По бечмарку думаю можно докрутить отключение JIT оптимизации в тесте, а также оптимизацию в cmake проекте. Однако и без бечмарков и отключений оптимизаций можно делать правильные выводы в работе, если погрешности этой самой работы не ломают конечный вывод. Думаю можно дополнительно высчитывать мат. ожидание и дисперсию по результатом. Прикрутим все в работе.


  1. ivankudryavtsev
    16.09.2023 12:34

    теперь джун стал умнее

    Спорное утверждение, честно говоря.


  1. sergey-gornostaev
    16.09.2023 12:34
    +2

    Да, в сети, ему равных нет.

    Аккуратнее надо с такими громкими заявлениями, есть и другие быстрые транспорты. Например Aeron может обеспечить отправку и приём миллиона сообщений в секунду с задержкой не более 100 микросекунд. Сможете с gRPC повторить?


    1. iamkisly
      16.09.2023 12:34
      +1

      Не знаю почему автор сделал акцент именно на скорости. Все-таки gRPC это про контракты, собственно RPC, и широкую поддержку сообществом. В этом его преимущество.

      Кстати, посмотрел семплы aeron, создалось впечатление что это больше про RUDP транспорт, чем законченный RPC.


      1. breninsul
        16.09.2023 12:34

        Хм, а чем openapi+ rest + json в этом плане хуже?


        1. klee0kai Автор
          16.09.2023 12:34

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

          Спасибо@sergey-gornostaevза замечание, да в действительно заявление получилось не к месту громким.

          Дополнительно хотел бы уточнить, что целей выбрать законченный RPC фреймворк - не было, и также можно было бы рассмотреть и другие доступные движки сериализации


        1. dph
          16.09.2023 12:34

          Почти всем )
          REST как идеология для service-service взаимодействия не так удобна, как RPC
          Работа с json медленнее, чем с protobuf

          У подходов json-over-http основные плюсы в человекочитаемости сообщений, большой гибкости при версионировании, отсутствием кодогенерации и большим удобством библиотек. Но если требуется и производительность и удобство - то приходится искать какие-то сложные решения, так как и grpc и json не очень подходят (


          1. breninsul
            16.09.2023 12:34

            честно говоря, учитывая реализацию гугловских библиотек для работы с proto и ее порнографические билдеры, о удобстве речь не идет.

            Ну и отсутствие null доставляет боль или вовсе приводит к багам, если программист не знает/пропустил этот нюанс.

            По скорости согласен. Но так ли это важно в большинстве случаев?

            Человекочитаемость и есть уберфича)

            Кодогенерация и вовсе глобальное зло.


            1. dph
              16.09.2023 12:34

              Угу, потому я и говорю, что нормального протокола до сих пор нет. У протобафа и grpc плохо с реализациями, у json-over-http с производительностью. Какой-нибудь упрощенный bson с эффективной десериализацией поверх сокетов был бы нормальным решением, если бы был популярным и поддерживался всякими проксями и мешами, но увы.


      1. qw1
        16.09.2023 12:34
        +1

        Не знаю почему автор сделал акцент именно на скорости

        Потому что он сделал занятный велосипед, прикрутив protobuf (как я понял, gRPC тут вообще не при чём) для обмена между java и C++, и нужно оценить его жизнеспособность: много ли на нём накладных расходов. Если много, то ну его нафиг, иначе пусть живёт.


  1. qw1
    16.09.2023 12:34

    Отлично. Узнал из этой статьи, что в C++ завезли null-coalescing operator, правда, только для GCC и Clang


    const char* name1 = name ?: "";