HimeraSearchDB
Carding_EbayThief
triada
CrackerTuch
d-shop

НОВОСТИ Кроссплатформенные оптимизации OpenCV

Bonnie
Оффлайн
Регистрация
12.04.17
Сообщения
19.095
Реакции
107
Репутация
0
Библиотека OpenCV состоит из нескольких тысяч функций и алгоритмов. В этой статье мы хотим вам рассказать о том, как обеспечивается гибкость оптимизаций алгоритмов компьютерного зрения в OpenCV для разных архитектур, операционных систем и рабочих окружений.


Мы рассмотрим абстракцию parallel_for_ и механизм Universal Intrinsics и то, как переиспользовать это в вашем проекте.


Статья основана на лекции, которую команда OpenCV читала в рамках зимнего лагеря по оптимизации от Intel в 2020 году. Также доступна этой лекции.


oovxlimtjtxxsoliglsrljfcolo.jpeg



OpenCV



В этом году OpenCV исполняется 20 лет. Не исключено, что некоторые участки кода библиотеки даже старше читающих эту статью :) Но несмотря на возраст, библиотека постоянно совершенствуется как пополнением новыми алгоритмами, так и оптимизациями существующих под обновлённое железо, новые архитектуры, новые способы компиляции и развертывания.


Несколько фактов:


  • В год OpenCV скачивают более 3 миллионов раз (если учитывать только PyPI + SourceForge).


  • Библиотека написана на C++, но имеет автоматические обертки в Python, Java, JavaScript, MATLAB, PHP, Go, C#,… Большинство из них вызывают нативные библиотеки, но даже и в случае, например, JavaScript, оптимизируя код на C++, мы получаем векторизованный и параллельный код на JS (подробнее далее).


  • Модульность библиотеки позволяет разделять алгоритмы по областям применения. Модуль core является базовым, а значит, если в вашем проекте уже есть OpenCV, то вам доступны и те оптимизации, о которых рассказано в этой статье.


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


Компромисс есть и, наверное, это то состояние OpenCV на сегодняшний день, которое позволяет расширяться на новые горизонты. Так или иначе большая часть алгоритмов работает с массивами данных (многомерными в общем случае) — изображениями, масками, полями, тензорами и т.д. Базисной структурой данных является cv::Mat — обертка над массивом данных определённого типа и размерностями:


cv::Mat mat(480, 640, CV_8UC3);
int rows = mat.rows; // 480
int cols = mat.cols; // 640
int channels = mat.channels(); // 3
uint8_t* data = mat.ptr();

// or

std::vector myData(10*11);
cv::Mat myMat(10, 11, CV_32FC1, myData.data());


Давайте посмотрим пример кода оператора выделения границ с использованием cv::Mat:


// | -1 0 +1 |
// Gx = | -1 0 +1 | * A
// | -1 0 +1 |
void prewitt_x(const Mat& src, Mat& dst) {
CV_Assert(src.type() == CV_8UC1);
Mat bsrc;
copyMakeBorder(src, bsrc, 1, 1, 1, 1, BORDER_REPLICATE);
dst.create(src.size(), CV_8UC1);
for (int y = 0; y < dst.rows; ++y)
for (int x = 0; x < dst.cols; ++x) {
dst.at(y, x) = bsrc.at(y , x+2) - bsrc.at(y , x) +
bsrc.at(y+1, x+2) - bsrc.at(y+1, x) +
bsrc.at(y+2, x+2) - bsrc.at(y+2, x);
}
}


Это вариант матричного фильтра 3x3 для нахождения граней на изображении. Чтобы не усложнять код обработкой граничных случаев, а акцентировать внимание на оптимизации, добавим на исходное изображение по 1 пикселю на каждую из сторон.


При разрешении 1920x1080 алгоритм показывал около 6.13 миллисекунд на тестовом компьютере при подготовке доклада.


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

parallel_for_



OpenCV-шный parallel_for_ — это абстракция, способная во время компиляции разворачиваться в конкретный параллельный цикл в зависимости от того, под какую OS происходит компиляция и какие библиотеки для параллелизма доступны. Вот полный список вариантов:

  • Intel Threading Building Blocks (TBB)
  • OpenMP
  • Apple GCD
  • Windows RT concurrency
  • Windows concurrency
  • Pthreads


Для нашего примера — распараллелим по строчкам, что обычно и используется:


parallel_for_(Range(0, src.rows), [&](const Range& range) {
for (int y = range.start; y < range.end; ++y)
for (int x = 0; x < dst.cols; ++x) {
dst.at(y, x) = bsrc.at(y , x+2) - bsrc.at(y , x) +
bsrc.at(y+1, x+2) - bsrc.at(y+1, x) +
bsrc.at(y+2, x+2) - bsrc.at(y+2, x);
}
});


Для тестовой машины на том же разрешении даёт 2.20ms (x2.78). Больше информации и примеров использования можно найти в или по исходному коду.

Universal Intrinsics



Универсальные интринсики — встроенный в OpenCV механизм векторизации кода. Задумка заключается в том, чтобы предоставить единый интерфейс для наиболее общих инструкций разных платформ. Для использования в OpenCV, необходимо лишь подключить заголовочный файл:


#include core/hal/intrin.hpp>


Тогда векторизованный код может выглядеть таким образом:


void process(int* data, int len) {
const cv::v_int32x4 twos = cv::v_setall_s32(2);
int i = 0;
for (; i code>


В примере, каждый элемент массива умножается на 2. Отметим важные моменты:
Двойки представлены в векторном виде, поскольку операции, в которых участвовали бы вектор и скаляр, не определены — их можно интерпретировать как операцию над первым элементом, так и над всеми.
Для операций над целочисленными векторами перегружены операторы *, +, -. Для чисел с плавающей точкой, также перегружен оператор деления.


Поскольку в зависимости от типа данных и особенностей железа более оптимальным может быть своя ширина вектора, доступны также обобщенные vx_load и соответствующие векторные типы без суффиксов: v_uint8, v_int32 и так далее. Ширину вектора можно узнать из константы nlanes для своего типа, например v_uint8::nlanes.


В зависимости от способа сборки и возможностей исполняемой машины, универсальные интринсики раскрываются в следующие наборы инструкций:

  • AVX / SSE (x86)
  • NEON (ARM)
  • VSX (PowerPC)
  • MSA (MIPS)
  • WASM (JavaScript)


Таким образом, одна реализация с помощью универсальных интринсиков позволяет сгенерировать оптимизированный код под все поддерживаемые платформы. Универсальность заключается в том, что для разных платформ одни и те же операции выражаются различными инструкциями или набором инструкций. OpenCV же предоставляет API скрывающий эти особенности.


С точки зрения оптимизации в масштабах библиотеки, выделяются два независимых этапа — написание алгоритмов с использованием универсальных интринсиков и добавление новых бэкендов для самого механизма. То есть если алгоритм уже был написан на универсальных интринсиках, введение оптимизаций для новой платформы в большинстве случаев обеспечивает улучшение производительности. Огромную роль в освоении новых платформ играют сторонние контрибьюторы, которые так или иначе заинтересованы в использовании OpenCV в своих конфигурациях. Например, VSX ( ), MSA и WASM ( ).


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


Взглянем на наш алгоритм с использованием Universal Intrinsics:


parallel_for_(Range(0, src.rows), [&](const Range& range) {
for (int y = range.start; y < range.end; ++y) {
const uint8_t* psrc0 = bsrc.ptr(y);
const uint8_t* psrc1 = bsrc.ptr(y + 1);
const uint8_t* psrc2 = bsrc.ptr(y + 2);
uint8_t* pdst = dst.ptr(y);
int x = 0;
for (; x code>


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


Обратите внимание на то, что на случай, если ширина изображения не делится на размерность вектора нацело, существует обработка хвоста. Кстати, когда мы экспериментировали с языком программирования Halide — подметили интересный подход к обработке хвоста путём смещения вектора с перекрытием:


* * * * * *

* * * * * *

* * * * * *


Это, с одной стороны, избавляет от написания похожих циклов-хвостов, с другой — не всегда применимо, например если алгоритм работает in-place и повторная обработка элементов недопустима.


Такой код на изображении 1920x1080 работает 0.2ms, что в 23.5 раз быстрее изначальных 6.13 ms.


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

Код с использованием нативных интринсиков

for (int y = range.start; y < range.end; ++y) {
const uint8_t* psrc0 = bsrc.ptr(y);
const uint8_t* psrc1 = bsrc.ptr(y + 1);
const uint8_t* psrc2 = bsrc.ptr(y + 2);
uint8_t* pdst = dst.ptr(y);
int x = 0;
#if CV_AVX512_SKX
if (CV_CPU_HAS_SUPPORT_AVX512_SKX) {
for (; x code>

Практика



В качестве практического задания студентам предлагалось реализовать перекрестный оператор и выполнить как минимум два раунда оптимизации (с использованием parallel_for_ и универсальных интринсиков):


input: cv::Mat (single channel, uint8_t)
output: cv::Mat (single channel, int32_t)

out = (Gx)^2 + (Gy)^2, where

| +1 0 | | 0 +1 |
Gx = | 0 -1 | * A, Gy = | -1 0 | * A


Базовый проект с практикой доступен по ссылке:


А также слайды:


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