Оптимизация обработки изображений на C++ с использованием SIMD. Медианный фильтр

  • Tutorial

Введение


Ранее во вступительной статье я поднимал список проблем, с которыми придется столкнуться разработчику, если он захочет оптимизировать оптимизацию обработки изображения при помощи SIMD инструкций. Теперь пришло время на конкретном примере показать, как указанные выше проблемы можно решить. Я долго думал, какой алгоритм выбрать для первого примера, и решил остановиться на медианной фильтрации. Медианная фильтрация является эффективным способом подавления шумов, которые неизбежно появляются на цифровых камерах в условиях малого освещения сцены. Алгоритм этот достаточно ресурсоемок – так например, при обработке серого изображения медианным фильтром 3х3 требуется порядка 50 операций на одну точку изображения. Но в тоже время он оперирует только с 8-битными числами и ему для работы требуется сравнительно не много входных данных. Эти обстоятельства делают алгоритм достаточно простым для SIMD оптимизации и в тоже время позволяют получить из нее весьма существенное ускорение.

image


Для справки напоминаю суть алгоритма:
  • Для каждой точки исходного изображения берется некоторая окрестность (в нашем случае 3x3).
  • Точки данной окрестности сортируются по возрастанию яркости.
  • Средняя точка (5-я для фильтра 3х3) отсортированной окрестности записывается в итоговое изображение.

Для первого примера, я решил упростить задачу и рассмотреть случай серого изображения (8 бит на точку) без учета граничных эффектов — для этого для фильтрации бралась центральная часть несколько большего изображения. Конечно, в реальных задачах граничные точки изображения следует рассчитывать по особому алгоритму, и порой реализация этих особых алгоритмов может занимать большую часть кода, но мы остановимся на этих вопросах в другой раз. Кроме того, по аналогичным причинам предполагается, что ширина изображения кратна 32.

Скалярные версии алгоритма


1-я скалярная версия

В этой начальной реализации задача была решена в лоб: окрестность для каждой точки исходного изображения копировалась во вспомогательный массив, который затем сортировался при помощи функции std::sort().

inline void Load(const uint8_t * src, int a[3])
{
    a[0] = src[-1];         
    a[1] = src[0];              
    a[2] = src[1];
}

inline void Load(const uint8_t * src, size_t stride, int a[9])
{
    Load(src - stride, a + 0);
    Load(src, a + 3);
    Load(src + stride, a + 6);
}

inline void Sort(int a[9])
{
    std::sort(a, a + 9);
}

void MedianFilter(const uint8_t * src, size_t srcStride, size_t width, size_t height, uint8_t * dst, size_t dstStride)
{
    int a[9];
    for(size_t y = 0; y < height; ++y)
    {
        for(size_t x = 0; x < width; ++x)
        {
            Load(src + x, srcStride, a);
            Sort(a);
            dst[x] = (uint8_t)a[4];
        }
        src += srcStride;
        dst += dstStride; 
    }
}


2-я скалярная версия

У предыдущего метода, впрочем, сразу видно узкое место – это стандартная функции сортировки. Она предназначена для сортировки массива произвольного размера, в ней осуществляется множество внутренних проверок, поэтому она, скорее всего, не будет оптимальным решением для данной задачи, где массив мал и имеет фиксированный размер. Кроме того, нам совсем не обязательно полностью сортировать массив, достаточно, что бы в 4-м элементе было требуемое нами значение. Потому мы можем применить метод сортировки «сетью» (так же известен, как метод битонической сортировки):

inline void Sort(int & a, int & b) // сортирует пару чисел
{
    if(a > b)
    {
        int t = a;
        a = b;
        b = t;
    }
}

inline void Sort(int a[9]) //частично сортирует весь массив
{
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[1]); Sort(a[3], a[4]); Sort(a[6], a[7]);
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[3]); Sort(a[5], a[8]); Sort(a[4], a[7]);
    Sort(a[3], a[6]); Sort(a[1], a[4]); Sort(a[2], a[5]); 
    Sort(a[4], a[7]); Sort(a[4], a[2]); Sort(a[6], a[4]);
    Sort(a[4], a[2]);
}


3-я скалярная версия

Хотя первый метод и получился значительно быстрее нулевого (см. результат тестирования ниже), но у него осталось одно узкое место – в этом методе очень много условных переходов. Как известно, современные процессоры очень их не любят, так как имеют конвейерную архитектуру и возможность внеочередного исполнения нескольких команд за один такт. И для первого, и для второго условные переходы крайне нежелательны, так как процессор должен останавливаться и ждать результатов вычислений, чтобы узнать по какой ветке программы ему следует идти дальше. К счастью сортировку двух 8 битных значений вполне можно реализовать и без условных переходов:

inline void Sort(int & a, int & b)
{
    int d = a - b;
    int m = ~(d >> 8);
    b += d&m;
    a -= d&m;
}


Итогом этих предварительных оптимизаций скалярно кода, будет следующая версия медианного фильтра:

inline void Load(const uint8_t * src, int a[3])
{
    a[0] = src[-1];         
    a[1] = src[0];              
    a[2] = src[1];
}

inline void Load(const uint8_t * src, size_t stride, int a[9])
{
    Load(src - stride, a + 0);
    Load(src, a + 3);
    Load(src + stride, a + 6);
}

inline void Sort(int & a, int & b)
{
    int d = a - b;
    int m = ~(d >> 8);
    b += d&m;
    a -= d&m;
}

inline void Sort(int a[9]) //частично сортирует весь массив
{
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[1]); Sort(a[3], a[4]); Sort(a[6], a[7]);
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[3]); Sort(a[5], a[8]); Sort(a[4], a[7]);
    Sort(a[3], a[6]); Sort(a[1], a[4]); Sort(a[2], a[5]); 
    Sort(a[4], a[7]); Sort(a[4], a[2]); Sort(a[6], a[4]);
    Sort(a[4], a[2]);
}

void MedianFilter(const uint8_t * src, size_t srcStride, size_t width, size_t height, uint8_t * dst, size_t dstStride)
{
    int a[9];
    for(size_t y = 0; y < height; ++y)
    {
        for(size_t x = 0; x < width; ++x)
        {
            Load(src + x, srcStride, a);
            Sort(a);
            dst[x] = (uint8_t)a[4];
        }
        src += srcStride;
        dst += dstStride; 
    }
}


Данная версия уже значительно (где-то в 5-6 раз) опережает по скорости наш первоначальный вариант алгоритма. И именно основываясь на нем мы будем осуществлять SIMD оптимизацию алгоритма, а также осуществлять сравнение по скорости работы скалярной и векторной версии алгоритма.

SIMD версии алгоритма


Для иллюстрации оптимизации алгоритма медианной фильтрации при помощи SIMD, я задействую два набора инструкций SSE2 и AVX2, упустив расширение MMX, которое в настоящее время устарело и имеет больше исторический интерес.К счастью, для того, чтобы задействовать SIMD инструкции, совсем не обязательно использовать ассемблер. Большинство современных С++ компиляторов имеют поддержку intrinsics (встроенных функций, с помощью которых можно задействовать всевозможные расширения процессоров). Программирование с использованием intrinsics практически не отличается от программирования на чистом С. Intrinsic функции преобразуются компилятором напрямую в инструкции процессора, хотя при этом работа непосредственно с регистрами процессора остается скрытой от программиста. В большинстве случаев программа с использованием intrinsics не уступает в быстродействии программе, написанной на ассемблере.

SSE2 версия

Целочисленные команды SSE2 определены в заголовочном файле < emmintrin.h >. В качестве базового типа выступает __m128i — 128 битный вектор, который в зависимости от контекста может интерпретироваться как набор 2-х 64 битных, 4-х 32 битных, 8-х 16 битных, 16-х 8 битных знаковых или беззнаковых чисел. Как видно, поддерживают не только векторные арифметические операции, но также векторные логические операции, а также векторные операции загрузки и выгрузки данных. Ниже приведен оптимизации медианного фильтра при помощи SSE2 инструкций. Код, как мне кажется, довольно нагляден.

#include < emmintrin.h >

inline void Load(const uint8_t * src, __m128i a[3])
{
    a[0] = _mm_loadu_si128((__m128i*)(src - 1)); //загрузка 128 битного вектора по невыровненному по 16 битной границе адресу
    a[1] = _mm_loadu_si128((__m128i*)(src));
    a[2] = _mm_loadu_si128((__m128i*)(src + 1));
}

inline void Load(const uint8_t * src, size_t stride, __m128i a[9])
{
    Load(src - stride, a + 0);
    Load(src, a + 3);
    Load(src + stride, a + 6);
}

inline void Sort(__m128i& a, __m128i& b)
{
    __m128i t = a;
    a = _mm_min_epu8(t, b); //нахождение минимума 2-х 8 битных беззнаковых чисел для каждого из 16 значений вектора
    b = _mm_max_epu8(t, b); //нахождение максимума 2-х 8 битных беззнаковых чисел для каждого из 16 значений вектора
}

inline void Sort(__m128i a[9]) //частично сортирует весь массив
{
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[1]); Sort(a[3], a[4]); Sort(a[6], a[7]);
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[3]); Sort(a[5], a[8]); Sort(a[4], a[7]);
    Sort(a[3], a[6]); Sort(a[1], a[4]); Sort(a[2], a[5]); 
    Sort(a[4], a[7]); Sort(a[4], a[2]); Sort(a[6], a[4]);
    Sort(a[4], a[2]);
}

void MedianFilter(const uint8_t * src, size_t srcStride, size_t width, size_t height, uint8_t * dst, size_t dstStride)
{
    __m128i a[9];
    for(size_t y = 0; y < height; ++y)
    {
        for(size_t x = 0; x < width;  x += sizeof(__m128i))
        {
            Load(src + x, srcStride, a);
            Sort(a);
            _mm_storeu_si128((__m128i*)(dst + x), a[4]); //сохранение 128 битного вектора по невыровненному по 16 битной границе адресу
        }
        src += srcStride;
        dst += dstStride; 
    }
}


AVX2 версия


Целочисленные команды AVX2 определены в заголовочном файле < immintrin.h >. В качестве базового типа выступает __m256i — 256 битный вектор, который в зависимости от контекста может интерпретироваться как набор 4-х 64 битных, 8-х 32 битных, 16-х 16 битных, 32-х 8 битных знаковых или беззнаковых чисел. Хотя набор инструкций AVX2 во многом повторяет набор инструкций SSE2 (с учетом удвоившейся ширины вектора), но он также содержит и достаточно много инструкций, у которых нет аналогов в SSE2. Ниже приведена оптимизация медианного фильтра при помощи AVX2 инструкций.

#include < immintrin.h >

inline void Load(const uint8_t * src, __m256i a[3])
{
    a[0] = _mm256_loadu_si256((__m128i*)(src - 1)); //загрузка 256 битного вектора по невыровненному по 32 битной границе адресу
    a[1] = _mm256_loadu_si256((__m128i*)(src));
    a[2] = _mm256_loadu_si256((__m128i*)(src + 1));
}

inline void Load(const uint8_t * src, size_t stride, __m256i a[9])
{
    Load(src - stride, a + 0);
    Load(src, a + 3);
    Load(src + stride, a + 6);
}

inline void Sort(__m256i& a, __m256i& b)
{
    __m256i t = a;
    a = _mm256_min_epu8(t, b); //нахождение минимума 2-х 8 битных беззнаковых чисел для каждого из 32 значений вектора
    b = _mm256_max_epu8(t, b); //нахождение максимума 2-х 8 битных беззнаковых чисел для каждого из 32 значений вектора
}

inline void Sort(__m256i a[9]) //частично сортирует весь массив
{
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[1]); Sort(a[3], a[4]); Sort(a[6], a[7]);
    Sort(a[1], a[2]); Sort(a[4], a[5]); Sort(a[7], a[8]); 
    Sort(a[0], a[3]); Sort(a[5], a[8]); Sort(a[4], a[7]);
    Sort(a[3], a[6]); Sort(a[1], a[4]); Sort(a[2], a[5]); 
    Sort(a[4], a[7]); Sort(a[4], a[2]); Sort(a[6], a[4]);
    Sort(a[4], a[2]);
}

void MedianFilter(const uint8_t * src, size_t srcStride, size_t width, size_t height, uint8_t * dst, size_t dstStride)
{
    __m256i a[9];
    for(size_t y = 0; y < height; ++y)
    {
        for(size_t x = 0; x < width;  x += sizeof(__m256i))
        {
            Load(src + x, srcStride, a);
            Sort(a);
            _mm256_storeu_si256((__m256i*)(dst + x), a[4]); //сохранение 256 битного вектора по невыровненному по 32 битной границе адресу
        }
        src += srcStride;
        dst += dstStride; 
    }
}


Примечание: Для получения максимального ускорения от оптимизации код, который содержит AVX2 инструкции, должен быть собран с следующими опцией компилятора: (/arch:AVX для Visual Studio 2012, -march=core-avx2 для GCC). Кроме того, очень желательно, чтобы в коде не было чередования AVX2 и SSE2 инструкций, так как переключение режима работы процессора в этом случае будет отрицательно сказывается на общей производительности. Исходя из выше сказанного, целесообразно располагать AVX2 и SSE2 версии алгоритмов в разных ".cpp" файлах.

Тестирование


Тестирование производилось на серых изображениях размером 2 MB (1920 x 1080). Для повышения точности измерения времени, тесты прогонялись несколько раз. Время выполнения получали делением общего времени исполнения тестов на количество прогонов. Количество прогонов выбиралось таким образом, что общее время исполнения было не меньше 1 секунды, что должно обеспечить два знака точности при измерении времени исполнения алгоритмов. Алгоритмы были скомпилированы с максимальной оптимизацией под 64 bit Windows 7 на Microsoft Visual Studio 2012 и запущены на процессоре iCore-7 4770 (3.4 GHz).

Время выполнения, мс Относительное ускорение
Лучший скалярный SSE2 AVX2 Лучший скалярный / SSE2 Лучший скалярный / AVX2 SSE2 / AVX2
24.814 0.565 0.424 43.920 58.566 1.333


Заключение


Как видно из результата тестов, ускорение алгоритма медианной фильтрации от использования SIMD инструкций на современных процессорах может достигать от 40 до 60 раз. Как мне кажется это достаточная величина, чтобы заморачиваться оптимизацией (если конечно в вашей программе важна скорость исполнения). В данной публикации я постарался максимально упростить используемый код.
Так за рамками остались вопросы, связанные выравниванием данных, с обработкой граничных точек, а также многое другое.
Данные вопросы я постараюсь раскрыть в следующих статьях. Если читателя интересует, как будет выглядеть боевой код, реализующий данную функциональность, то он сможет найти его здесь.
Share post
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 28

    +3
    Не сортировать, а находить сразу медиану не пробовали? e-maxx.ru/algo/kth_order_statistics
      0
      Здесь не тот случай. Так как размер массива достаточно мал и фиксирован, то любой обобщенный алгоритм будет проигрывать специализированному, так как содержит много ненужных проверок. Кроме в вашем варианте полно условных переходов, которые убьют производительность.
      Если вы посмотрите внимательно, то увидите, что я и так применяю для нахождения среднего элемента частичную сортировку.
      0
      За счет чего получилось ускорение 40-60 раз, если за операцию обрабатывается 16 или 32 точки. Разве это не будет теоретическим пределом ускорения?
        +1
        Не будет, в векторной версии есть замечательные быстрые инструкции mm256_min_*, mm256_max_*
          0
          Как-то странно, сделать инструкцию (причем очень полезную) в векторном варианте, но не сделать в скалярном.
            0
            В скалярном варианте есть conditional move, которые тоже позволяют избавиться от условных переходов, хоть и не так красиво конечно.
        0
        В бенчмарке не хватает версии на OpenCL.
          +1
          На всякий случай, вдруг кто не знает — в стандартной библиотеке C++ есть функция std::nth_element для частичной сортировки массива. (Конечно, оне не для этого конкретного случая)
            0
            Спасибо за статью!
            Интересно было бы реализацию для фильтра с произвольным радиусом. Понятно, что оптимальные сети сортировки для маленьких радиусов известны и могут быть легко реализованы по-отдельности, а что делать в SIMD с большими областями?
              0
              Ну и насчет переключения режима работы процессора — есть такая инструкция, как vzeroupper, которая позволяет этого гигантского штрафа от переключения избежать. Конечно, писать вмеремешку SSE2 и AVX код всё равно не стоит, но и ужасных >100 циклов вы не потеряете.
              0
              Алгоритмы были скомпилированы с максимальной оптимизацией

              Значит результаты 1 и 2 — с автовекторизацией? Или для них был выключен векторизатор?
                0
                Здесь использовался MSVC, и он видимо не очень справился с оптимизацией. На GCC результаты будут гораздо лучше.
                  +5
                  Я провёл стравнение на разных компиляторах и разных типах данных (автор почему-то выбрал int для промежуточных данных, что мешает автоматической векторизации). Методика аналогична авторской. Также добывил nth_element и вариант и испозованием std::min/max (T t = a; a = std::min(t, b); b = std::max(t, b);)

                  1   std::sort
                  2   std::nth_element
                  3   if
                  4   std::min std::max
                  5   bits
                  6   sse2
                  
                  Компилятор  T           1       2       3       4       5       6
                  GCC native  int         260     305      11.3    11.3   18      2.82
                              uint16_t    -       -         5.3     5.3    7.7    2.82
                              uint8_t     -       -         2.79    2.82  -       2.83
                  GCC         int         -       -        21      21.2   17.7    2.79
                              uint16_t    -       -         8.36    8.56   7.95   2.8
                              uint8_t     -       -         2.85    2.85  -       2.8
                  clang       int         234     309     155      40.9   82.2    2.92
                              uint16_t    -       -       155      40.9   79.5    2.92
                              uint8_t     -       -       155     184     -       2.92
                  
                  CXXFLAGS: -O3 -std=c++11
                  
                  GCC:     gcc version 4.8.1 (Ubuntu/Linaro 4.8.1-10ubuntu9)
                  native:  -march=native
                  clang:   Ubuntu clang version 3.4-1ubuntu1 (trunk) (based on LLVM 3.4)
                  
                  CPU:     Core2 Duo P8600 2.4 GHz
                  OS:      Ubuntu 13.10 64-bit
                  


                  Выводы:
                  — Результаты очень сильно зависят от компилятора
                  — GCC способен векторизовывать такие вычисления
                  — Способность к векторизации сильно зависит от типа данных. Не следует использовать слишком длинные типы данных.
                  — Наилучшие результаты получаются при T=uint8_t с ключом -march=native. В этом случае автоматическая векторизация даёт ту же производительность, что и ручная.
                  — GCC для кода с if даёт код примерно той-же производительности, что и для битовых манипуляций. Условные переходы он заменяет на условные операции (cmov)
                  — clang c такими опциями с векторизацией справиться пока не способен
                  — В данной задаче nth_element медленнее чем sort, и оба они на порядки медленнее по сранению с другими методами на GCC (но не на clang).
                    0
                    Спасибо за такой развернутый комментарий. Честно говоря, я не знал что уже GCC дорос до автовекторизации подобных выражений. Буду экспереметировать и курить доки.

                    Тем не менее, пример, который я привел — скорее исключение, чем правило. Обычно в алгоритме обработки изображения присутствует необходимость паковки и распаковки векторов, обработка краевых значений, маскирования и другие преобразования, которые не очень способствуют автовекторизации. Будет ли она работать в таком случае?
                      0
                      P.S. Проверил. Пока автовекторизация в большинстве перечисленных случаев не работает. Возможно в будущем это изменится.
                        +1
                        Осталось проверить эти примеры на ICC :)
                          +1
                          Для автовекторизации лучше использовать Intel C/C++ Compiler, его автовекторизатор на порядок продвинутей MSVS/gcc/clang.
                            0
                            Я тоже так думал. Но сегодня я специально прверил и ICC:

                            ICC         int         -       -       132     240     66.7    2.88
                                        uint16_t    -       -       156     260     74.4    2.79
                                        uint8_t     -       -       158     268       -     2.97
                            ICC native  int         -       -       132     111     66.7    2.8
                                        uint16_t    -       -       156     109     74.5    2.8
                                        uint8_t     -       -       158     115       -     2.8
                            
                            icc: icc version 14.0.1 (gcc version 4.8.0 compatibility)
                            

                            То есть ICC с векторизацией не справился.

                            Вот что он выдал с ключом -vec-report2 (для программы с if):
                            scalar-if.cpp(46): (col. 9) remark: loop was not vectorized: existence of vector dependence
                            scalar-if.cpp(44): (col. 5) remark: loop was not vectorized: not inner loop
                            


                            а вот что GCC c ключом -ftree-vectorizer-verbose=1:
                            Analyzing loop at scalar-if.cpp:44
                            
                            Analyzing loop at scalar-if.cpp:46
                            
                            Vectorizing loop at scalar-if.cpp:46
                            
                            scalar-if.cpp:46: note: create runtime check for data references MEM[(const uint8_t *)_28 + -1B] and *_17
                            scalar-if.cpp:46: note: create runtime check for data references *_28 and *_17
                            scalar-if.cpp:46: note: create runtime check for data references MEM[(const uint8_t *)_28 + 1B] and *_17
                            scalar-if.cpp:46: note: create runtime check for data references MEM[(const uint8_t *)_13 + -1B] and *_17
                            scalar-if.cpp:46: note: create runtime check for data references *_13 and *_17
                            scalar-if.cpp:46: note: create runtime check for data references MEM[(const uint8_t *)_13 + 1B] and *_17
                            scalar-if.cpp:46: note: create runtime check for data references MEM[(const uint8_t *)_41 + -1B] and *_17
                            scalar-if.cpp:46: note: create runtime check for data references *_41 and *_17
                            scalar-if.cpp:46: note: create runtime check for data references MEM[(const uint8_t *)_41 + 1B] and *_17
                            scalar-if.cpp:46: note: created 9 versioning for alias checks.
                            
                            scalar-if.cpp:46: note: === vect_do_peeling_for_loop_bound ===Setting upper bound of nb iterations for epilogue loop to 14
                            
                            scalar-if.cpp:46: note: LOOP VECTORIZED.
                            scalar-if.cpp:41: note: vectorized 1 loops in function.
                            


                            То есть ICC увиел какие-то зависимости и успокоился, а GCC вставил рантайм проверки (и видимо создал несколько вариантов цикла?)
                              +1
                              #pragma ivdep
                              перед внутренним циклом спасёт отца русской демократии :)
                                0
                                Она ускоряет в полтора раза примерно, но всё равно сильно медленнее чем GCC.
                    +1
                    В конце прошлого века помнится, реализовывал медианный фильтр на MMX. Положил кучу времени и сил, а результат по скорости был ненамного лучше того, что получалось на процессоре. Сейчас, конечно, прогресс ушёл далеко вперёд — на современных инструкциях работать куда как приятнее и легче. Ещё несколько лет назад понадобился мне медианный фильтр с очень большим ядром — 15х15 и больше, до 31х31. Наиболее оптимальным оказался алгоритм Хуанга. Ну или вот ещё — Median Filtering in Constant Time.
                      0
                      Реализовывал медианный фильтр по алгоритму из последней вашей ссылки. Да он позволяет обрабатывать изображения с одинаковой скоростью не зависимо от размера окна. Но эта постоянная скорость все же очень низкая (около секунды для HD разрешения, на сколько я помню). В области где я работаю (видеоаналитика), такая скорость неприемлема. Да и не требуются для нас медианные фильтры большого радиуса. максимум 5x5.
                        0
                        Да, он имеет выигрыш только на больших ядрах (где-то начиная от 9x9 и выше), кроме того довольно чувствителен к сильным перепадам яркости, поскольку ему по гистограмме слишком много бегать придётся.
                      +1
                      Добавили бы сравнение с Intel IPP, очень интересно. А то ручные низкоуровневые оптимизации — очень спорная и опасная вещь.
                        0
                        Вот статья есть про супер быстрый способ.

                        siggraph2006.pdf
                          0
                          Классный пост! сделал бы еще сборку для линукса — цены бы не было)
                            0
                            Simd вроде как без проблем собирается под Linux.
                              0
                              но в проекте нет makefile, а самому писать не охота)

                          Only users with full accounts can post comments. Log in, please.