Узнайте, как нативный код лучше влияет на производительность вашего приложения

Производительность является характеристикой большинства программных продуктов, но есть несколько программ, которые более чувствительны к производительности, чем другие. Я работаю над приложением Android Camera, и моя команда уделяет большое внимание производительности.

[Google обнаружил, что] создание страницы с 10 результатами заняло 0,4 секунды. Страница с 30 результатами заняла 0,9 секунды. Задержка в полсекунды привела к падению трафика на 20%. Задержка в полсекунды убила удовлетворенность пользователей.

Мне не нужно преувеличивать важность performance для моего менеджера или для вас, но я наткнулся на этот фрагмент, который усиливает конструкцию производительность — это функция — полезно знать факты.

Если вы пишете приложения, которые обрабатывают большие изображения, снятые камерой, или уже существующее изображение на устройстве, вам нужно быть особенно осторожным. В наши дни камеры на телефонах легко оснащаются сенсорами с высоким разрешением. Легко найти 13-мегапиксельные (мегапиксельные), 24-мегапиксельные, 48-мегапиксельные или даже 108-мегапиксельные камеры, которые сейчас поставляются на устройствах Android.

Давайте посмотрим на 13-мегапиксельное изображение. Он имеет 13 000 000 пикселей. Если вы хотите сделать только одно простое вычисление на изображении, скажем, увеличить экспозицию изображения, т.е. для каждого пикселя.

image(x, y) = std::clamp(alpha * image(x, y) + beta, 0, 255);

Вам нужно сделать это 13 миллионов раз.

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

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

Это очень простая статья, демонстрирующая, как выполнять обработку изображений с помощью собственного кода в Android. Я также покажу на примере, что производительность очень простого и неоптимизированного кода C++ очень близка к достаточно оптимизированному коду Java для той же постановки задачи. Если вы ищете «Быстрая обработка изображений с помощью Java Native Interface или JNI в Android» — я думаю, вы попали по адресу, эта статья поможет вам в этом.

Пример постановки задачи: преобразование YUV в RGB

Постановка задачи заключается в преобразовании 8-мегапиксельного (3264x2448) изображения в определенный формат под названием YUV_420_888, который имеет один планарный Y канал и два полуплоскостных субдискретизированных UV канала в формат ARGB_8888, который обычно поддерживается с Bitmap в Android. Подробнее о формате YUV можно прочитать в Википедии. Кроме того, приведенные ниже статьи содержат лучшее описание постановки задачи.

Причина, по которой я выбрал это в качестве формулировки проблемы, заключается в том, что YUV_420_888 является одним из наиболее распространенных форматов OUTPUT, поддерживаемых API-интерфейсами Android Camera, а изображения обычно используются как Bitmap в Android, что делает это довольно распространенной формулировкой проблемы для решения.

Я экспериментировал с производительностью различных фреймворков или технологий, чтобы понять производительность обработки изображений в Android, взяв это за постановку проблемы. Вот несколько примеров того же с использованием других методов, которые я тестировал:

Я планирую написать целую серию статей, объясняющих эффективность различных подходов. Вот цифры для подходов, опубликованных до сих пор:

Обработка изображений с помощью собственного кода

Если бы мы работали непосредственно в родном пространстве, базовая скелетная программа выглядела бы так:

Функция принимает значение канала y, u и v для определенного пикселя и возвращает соответствующее значение RGBA_8888.

Программы для Android по умолчанию написаны на языке Java или Kotlin, но инструментальные цепочки Android поставляются с родным комплектом разработки под названием Android NDK, который позволяет вам реализовывать разделы вашего приложения с использованием таких языков, как C и C++. Документация Android описывает, что NDK полезен для двух сценариев:

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

Повторно используйте свои собственные или библиотеки C или C++ других разработчиков.

В следующих нескольких разделах я попытаюсь вкратце объяснить, как преобразовать Java-изображение в формате YUV_420_888 в Java-объект Bitmap, используя интеграцию с собственным кодом.

Итак, в основном мы должны заполнить эту скелетную функцию Java:

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

JNI

JNI расшифровывается как Java Native Interface. Он определяет способ взаимодействия байтового кода, скомпилированного из кода Java или Kotlin, с собственным кодом, написанным на C или C++. Как следует из названия, это помогает нам связать код Java с собственным кодом.

Если вы в конечном итоге будете больше работать с JNI, я бы порекомендовал прочитать советы Android по JNI — Советы JNI.

Начало работы с NDK и JNI

Чтобы избежать избыточности, я бы порекомендовал проверить и попробовать следующие статьи, чтобы настроить свое первое приложение для Android на основе JNI. Эти статьи также помогут вам настроить необходимые наборы инструментов для компиляции приложений Android вместе с NDK и собственным кодом.

Дайте мне знать в комментариях, если эти статьи недостаточно ясны. Перейдите к следующему разделу, если вы успешно создали и запустили приложение JNI или уже знакомы с конструкциями.

Собственный код для преобразования YUV в Bitmap

Создайте библиотеку и исходный файл yuv2rgb.h/cc в app/src/main/cpp.

Заголовочный файл

Исходный файл

В идеале для этого также следует написать модульный тест, но это выходит за рамки данной статьи. Вы можете прочитать больше о добавлении Native Tests в документации Android.

Далее мы напишем слой JNI, чтобы связать его со скелетом Java, с которого мы начали.

Интеграция Java + JNI

Теперь вам нужен файл JNI для подключения библиотеки Java к собственной библиотеке. Давайте добавим yuv2rgb-jni.cc в app/src/main/cpp.

Кроме того, предположим, что наша скелетная функция Java находится в пакете com.example.myproject и в статическом классе с именем YuvConvertor, ваш файл JNI должен выглядеть так.

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

И, наконец, вызовите это из библиотеки Java

Вот несколько важных моментов, на которые следует обратить внимание:

  • Используя API-интерфейсы NDK, мы можем напрямую получить доступ к содержимому ByteBuffer в нативном коде, который одновременно очень полезен и опасен, если не обрабатывается правильно.
  • Существуют NDK API для Bitmap, которые можно напрямую использовать в этом случае, что может помочь уменьшить дополнительное выделение памяти, мы можем напрямую обновлять память Bitmap из собственного кода.

Таким образом, у вас есть код Java, вызывающий JNI, который извлекает указатель на входные и выходные данные в собственном формате и передает их в собственную библиотеку для обработки. Нативная библиотека представляет собой довольно распространенный код C++ и может использоваться и за пределами Android.

Ваш make-файл (в данном случае он должен быть app/src/main/cpp/CMakeLists.txt) должен быть правильно настроен для поддержки сборки нативного кода с помощью Android APK. Для этого примера он должен иметь по крайней мере эти определения.

Производительность

При рассмотрении производительности имейте в виду, что это довольно простая форма кода C++, в ней явно не используются преимущества многопоточности или наборов инструкций SIMD, которые могут работать на устройствах Android. В лучшем случае некоторая часть кода оптимизируется компилятором (например, основной цикл for может автоматически векторизоваться). Код был скомпилирован с флагом оптимизации -O3.

Для изображения 8MP (3264x2448) этот код занимает около 76.4ms на том же эталонном устройстве.

Здесь вы можете видеть, что производительность этой версии нативного кода в 1,4 раза ниже, чем у очень оптимизированного многопоточного Java-кода, что не является плохой новостью. Стартовый Java-код потребовал 353 ms для запуска того же алгоритма (хотя, вероятно, у него есть причины, связанные с тем, что сложный ByteBuffer не дает прямого доступа к массиву — подробнее). Чтобы узнать больше о написании более оптимизированного нативного кода или воспользоваться преимуществами компилятора для языковых конструкций, я бы рекомендовал прочитать эту статью — Руководство компилятора C++ по автоматической векторизации кода

Заключительные примечания

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

В этой же серии экспериментов, используя многопоточность, NEON API (SIMD) и некоторый ассемблерный код, я смог снизить задержку до 12.1 ms, что является победителем в этой серии (спойлер). Однако его довольно сложно как писать, так и поддерживать, и решение на основе Halide, которое требует около 28ms для одной и той же постановки задачи, является даже идеальным решением с точки зрения производительности, обслуживания и простоты написания.

Я напишу об обоих из них в моем следующем наборе статей, спасибо за чтение. Следите за обновлениями!!

Want to Connect With the Author?
This article was originally published at https://blog.minhazav.dev.