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


> Часть 0
> Часть 1, общие оптимизации


В прошлый раз мы получили ускорение в среднем в 2,5 раза без изменения подхода. В этот раз я покажу, как применять SIMD-подход и получить ускорение еще в 3,5 раза. Конечно, применение SIMD для обработки графики не является ноу-хау, можно даже сказать, что SIMD был придуман для этого. Но на практике очень мало разработчиков используют его даже в задачах обработки изображений. Например, довольно известные и распространенные библиотеки ImageMagick и LibGD написаны без использования SIMD. Отчасти так происходит потому, что SIMD-подход объективно сложнее и не кроссплатформенный, а отчасти потому, что по нему мало информации. Довольно просто найти азы, но мало детальных материалов и разбора реальных задач. От этого на Stack Overflow очень много вопросов буквально о каждой мелочи: как загрузить данные, как распаковать, запаковать. Видно, что всем приходится набивать шишки самостоятельно.


Что же такое SIMD


Смело пропускайте этот раздел, если уже знакомы с основами. SIMD означает singe instruction, multiple data (одна команда, множество данных). Такой подход позволяет объединить несколько одинаковых операций в одну. Набор данных, над которым выполняется операция, называется вектор.



В большинстве случаев на современных процессорах SIMD-инструкции выполняются за столько же тактов, за сколько их скалярные аналоги. Т.е. теоретически при переходе на SIMD можно рассчитывать на ускорение в 2, 4, 8, 16 и даже 32 раза, в зависимости от используемых типов данных и наборов команд. На практике выходит по-разному. Во-первых, даже в векторизованном коде часть кода остается скалярным. Во-вторых, часто для векторных операций данные нужно подготовить: распаковать и запаковать. Как правило, запаковка и распаковка данных — это самое сложное при написании SIMD-кода. В-третьих, SIMD-инструкции не являются точной копией обычных: для каких-то операций есть специфичные инструкции, хорошо решающие стоящую задачу, а для других задач нужных инструкций не оказывается. Например для нахождения минимального и максимального значений есть отдельные SIMD-инструкции, работающие без условных переходов. А вот целочисленного векторного деления в процессорах x86 просто нет.


В векторные регистры помещается больше значений. Как много — зависит от типа данных и размера регистра. SSE-регистры 128-битные. Если, например, мы работаем с 32-битными целыми, то в один SSE-регистр поместится четыре значения. В основном доступны следующие тип данных:


  • Целые числа по 8 бит (со знаком или без)
  • Целые числа по 16 бит (со знаком или без)
  • Целые числа по 32 бита (со знаком или без)
  • Целые числа по 64 бита (со знаком или без)
  • Числа с плавающей точкой одинарной точности, 32 бита
  • Числа с плавающей точкой двойной точности, 64 бита

Все векторные регистры одинаковые и не знают, какого типа данные в них находятся. Интерпретация зависит только от инструкции, которая работает с регистром. Соответственно, большинство инструкций имеют разновидности для работы с данными разных типов. Инструкции могут принимать данные одного типа, а выдавать другого. Если вы знакомы с ассемблером, это чем-то напоминает то, как работают обычные регистры: вы можете записать что-то в 32-битный регистр eax, а потом работать с его 16-битной частью ax.


Выбор подходящего расширения команд


Первые SIMD-инструкции появились в процессоре Intel Pentium MMX. Собственно MMX — это и есть название расширения команд. Этот набор был настолько важным, что Intel вынесла его в название процессора. С помощью MMX я когда-то давно писал простенькие алгоритмы вроде смешивания двух изображений или суперсемплинга. Писал на Дельфи, но чтобы использовать MMX, приходилось спускаться на уровень ниже и делать вставки на ассемблере.


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


Чаще всего каждому интринсику соответствует одна конкретная инструкция процессора. Т.е. написанный код получается очень эффективный и близкий к железу. Но при этом для написания кода вы пользуетесь знакомым и относительно безопасным синтаксисом Си. Вы как обычно подключаете заголовочный файл, в котором определены функции-интринсики, как обычно объявляете переменные, используя специальные типы данных, и как обычно вызываете функции. Словом, пишите обычный код. Небольшое неудобство только в том, что с SIMD-типами данных нельзя использовать математические операции, для всех вычислений нужно использовать интринсики. Грубо говоря, нельзя написать ss0 + ss1, можно только add_float(ss0, ss1) (название функции выдумано).


SIMD-расширений много. В основном наличие в процессоре более нового расширения означает наличие всех предшественников. По хронологии появления расширения располагаются в следующем порядке:


MMX, SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, AVX, AVX2, AVX-512


Как видите, список внушительный. Не на каждой машине есть каждое расширение. Найти живой x86 процессор без MMX можно разве что в музее. SSE2 — обязательное расширение для 64-битных процессоров, т.е. в наши дни тоже есть практически везде. Поддержку SSE4.2 можно найти в любом процессоре, начиная с архитектуры Nehalem, т.е. с 2008 года. А вот AVX2 есть только у не бюджетных процессоров Intel, начиная с ядра Haswell, т.е. с 2013 года, а у AMD они появились в процессорах Ryzen, выпущенных в 2017 году. AVX-512 пока доступны только в серверных процессорах Intel Xeon и Xeon Phi.


От выбора набора инструкций, зависит производительность и сложность написания кода, а также поддержка процессоров. Иногда разработчики делают несколько реализаций своего кода под разные наборы инструкций. Я выбрал два: SSE4.2 и AVX2. Я рассуждал так: SSE4.2 — базовый набор, который должен быть у всех, кого хоть сколько волнует производительность и не стоит мучаться, реализовывая все, например, на SSE2. А AVX2 для тех, кто не ленится менять железо хотя бы раз в 3 года. Что бы вы не выбрали для своей реализации, со временем ваш выбор будет становиться только более верным, потому что количество процессоров на рынке с выбранным набором инструкций будет только увеличиваться.


SSE4-реализация


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


#include <emmintrin.h>
#include <mmintrin.h>
#include <smmintrin.h>

Кроме того, нужно указать флаг компилятора -msse4. Если речь идет о сборке питоновского модуля (как у нас), то можно добавить этот флаг прямо из командной строки, чтобы не усложнять сейчас сборку:


$ CC="ccache cc -msse4" python ./setup.py develop

Наиболее полный справочник по интринсикам можно найти по ссылке Intel Intrinsics Guide. Там есть хороший поиск и фильтрация, в описании интринсиков указаны соответствующие им инструкции, псевдокод инструкций и даже время выполнения в тактах на процессорах Intel последних поколений. Как справочник, это уникальная вещь. Но формат справочника не позволяет составить общую картину, как что должно происходить.


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


for (xx = 0; xx < imOut->xsize; xx++) {
    ss0 = 0.5;
    ss1 = 0.5;
    ss2 = 0.5;
    for (y = ymin; y < ymax; y++) {
        ss0 = ss0 + (UINT8) imIn->image[y][xx*4+0] * k[y-ymin];
        ss1 = ss1 + (UINT8) imIn->image[y][xx*4+1] * k[y-ymin];
        ss2 = ss2 + (UINT8) imIn->image[y][xx*4+2] * k[y-ymin];
    }
    imOut->image[yy][xx*4+0] = clip8(ss0);
    imOut->image[yy][xx*4+1] = clip8(ss1);
    imOut->image[yy][xx*4+2] = clip8(ss2);
}

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


void
ImagingResampleHorizontalConvolution8u(
    UINT32 *lineOut, UINT32 *lineIn,
    int xsize, int *xbounds, float *kk, int kmax
);

void
ImagingResampleVerticalConvolution8u(
    UINT32 *lineOut, Imaging imIn,
    int ymin, int ymax, float *k
);

Если вы помните, в предыдущей части я сделал три частных случая для двух, трех и четырех каналов в изображении. Это было нужно, чтобы избавиться от внутреннего цикла по каналам и в то же время не выполнять лишних вычислений для каналов, которых в изображении нет. В SIMD-версии мы не будем делить реализации по каналам: все вычисления всегда будут производиться над четырьмя каналами. Каждый пиксель будет представлен четырьмя 32-битными числами с плавающей точкой и будет занимать ровно один SSE-регистр. Да, для трехканальных изображений четверить операций будет выполняться вхолостую, а для двухканального — половина. Но проще закрыть на это глаза, чем пытаться максимально забить SSE-регистры полезными данными.



Взгляните еще раз на код выше. В самом начале идет присвоение аккумуляторам константного значения 0,5, которое нужно для округления результата. Для загрузки одного float-значения в весь регистр используются функции _mm_set1_*.


__m128 sss = _mm_set1_ps(0.5);

Обычно последняя часть названия функции означает тип данных, с которым она работает. В нашем случае это _ps, что означает packed single.


Дальше, если мы хотим работать с пикселем как с вектором чисел с плавающей точкой, нужно как-то сконвертировать пиксель в это представление. В SSE нет инструкций, преобразовывающих 8-битные значения сразу в числа одинарной точности. Есть _mm_cvtepi32_ps, которая преобразует 32-битные целые в числа с одинарной точностью, но перед тем как её использовать, нужно распаковать 8-битные числа в 32-битные. Для этого пригодится функция _mm_cvtepu8_epi32. Ей нужно передать адрес на 128-битное значений в памяти.


__m128i pix_epi32 = _mm_cvtepu8_epi32(*(__m128i *) &imIn->image32[y][xx]);
__m128 pix_ps = _mm_cvtepi32_ps(pix_epi32);

Заметьте, как много в SIMD-коде приходится делать явно при загрузке значений. В скалярном коде ничего этого нет, компилятор сам понимает, что раз мы умножаем 8-битное целое на float, то первое тоже нужно конвертировать в float.


Коэффициент для всех каналов одного пикселя одинаков, так что нам снова пригодится _mm_set1_ps.


__m128 mmk = _mm_set1_ps(k[y - ymin]);

Осталось умножить коэффициенты на каналы и сложить с аккумулятором:


__m128 mul = _mm_mul_ps(pix_ps, mmk);
sss = _mm_add_ps(sss, mul);

Теперь в аккумуляторе sss находится значение каналов пикселя, которые правда могут выходить за диапазон [0, 255], а значит нужно как-то эти значения ограничить. Помните функцию clip8 из предыдущей статьи? В ней было два условных перехода. В случае с SIMD у нас нет возможности использовать условные переходы в зависимости от данных, потому что процессор должен выполнять одну и ту же команду для всех данных. Но на самом деле так даже лучше, потому что есть функции _mm_min_epi32 и _mm_max_epi32. Поэтому мы переводим значения в целые 32-битные числа со знаком, а потом обрезаем их в пределах [0, 255].


__m128i mmmax = _mm_set1_epi32(255);
__m128i mmmin = _mm_set1_epi32(0);
__m128i ssi = _mm_cvtps_epi32(sss);
ssi = _mm_max_epi32(mmmin, _mm_min_epi32(mmmax, ssi));

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


__m128i shiftmask = _mm_set_epi8(-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,12,8,4,0);
lineOut[xx] = _mm_cvtsi128_si32(_mm_shuffle_epi8(ssi, shiftmask));

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


Все готово, пришло время запустить тест и узнать результат.


Scale 2560?1600 RGB image
    to 320x200 bil      0,01151 s   355.87 Mpx/s    161.4 %
    to 320x200 bic      0,02005 s   204.27 Mpx/s    158.7 %
    to 320x200 lzs      0,03421 s   119.73 Mpx/s    137.2 %
    to 2048x1280 bil    0,04450 s    92.05 Mpx/s    215.0 %
    to 2048x1280 bic    0,05951 s    68.83 Mpx/s    198.3 %
    to 2048x1280 lzs    0,07804 s    52.49 Mpx/s    189.6 %
    to 5478x3424 bil    0,18615 s    22.00 Mpx/s    215.5 %
    to 5478x3424 bic    0,24039 s    17.04 Mpx/s    210.5 %
    to 5478x3424 lzs    0,30674 s    13.35 Mpx/s    196.2 %

Результат для коммита 8d0412b.


Прирост от двух с половиной до трех раз! Напомню, что тестирую я на трехканальном RGB-изображении, поэтому ускорение в 3 раза в данном случае можно считать эталонным.


Правильная запаковка


Предыдущий вариант был почти 1 в 1 скопирован со скалярного кода. Причем для запаковки и распаковки использовались функции, появившиеся в последних версиях SSE: _mm_cvtepu8_epi32, _mm_max/min_epi32, _mm_shuffle_epi8. Очевидно, что люди как-то справлялись с этими задачами и с более ранними версиями SSE. И действительно, есть целый набор функций по запаковке/распаковке данных, _mm_pack* и _mm_unpack*. И если распаковка тут не сильно помогает (_mm_cvtepu8_epi32 лучше подходит для наших целей), то запаковку можно сильно упростить. Упростить так, что не понадобятся никакие константы для сдвига и обрезания значений (речь об mmmax, mmmin и shiftmask).


Дело в том, что функции запаковки _mm_packs* выполняются с насыщением (saturation), о чем говорит буква s в названии, например _mm_packs_epi32. Насыщение значит, что если при конвертации значение переменной выходит за пределы нового типа, то оно остается экстремальным для данного типа. Например если мы делаем преобразование из 16-битного целого со знаком в 8-битное без знака, то значение 257 преобразуется в 255, а -3 в 0. Получается, что функции упаковки одновременно и сдвигают значения и не дают выйти за границы диапазона.


__m128i ssi = _mm_cvtps_epi32(sss);
ssi = _mm_packs_epi32(ssi, ssi);
ssi = _mm_packus_epi16(ssi, ssi);
lineOut[xx] = _mm_cvtsi128_si32(ssi);

У меня эта оптимизация не дает ускорения, но зато выглядит красиво и не требует дополнительных констант. Смотреть коммит b17cdc9.


AVX-регистры


Если бы собака носила штаны, делала бы она это так или так?



А если бы регистры содержали в два раза больше данных? Когда я узнал, как работает AVX, то сразу вспомнил об этой картинке. При первом рассмотрении AVX инструкции кажутся странными и нелогичными. Это не просто «как SSE, только в 2 раза больше», в них есть какая-то непонятная логика. Взгляните сами на ту же инструкцию перемешивания, которой мы уже пользовались. Вот псевдокод SSE-версии:


FOR j := 0 to 15
    i := j*8
    IF b[i+7] == 1
        dst[i+7:i] := 0
    ELSE
        index[3:0] := b[i+3:i]
        dst[i+7:i] := a[index*8+7:index*8]
    FI
ENDFOR

Логично было бы предположить, что для AVX просто должен увеличиться счетчик с 15 до 31. Но нет, псевдокод AVX-версии намного мудренее:


FOR j := 0 to 15
    i := j*8
    IF b[i+7] == 1
        dst[i+7:i] := 0
    ELSE
        index[3:0] := b[i+3:i]
        dst[i+7:i] := a[index*8+7:index*8]
    FI
    IF b[128+i+7] == 1
        dst[128+i+7:128+i] := 0
    ELSE
        index[3:0] := b[128+i+3:128+i]
        dst[128+i+7:128+i] := a[128+index*8+7:128+index*8]
    FI
ENDFOR

AVX — это не в два раза большее SSE, это два SSE! То есть на AVX-регистры стоит смотреть как на пару векторов. И эти вектора в большинстве команд друг с другом не взаимодействуют. Посмотрите на псевдокод AVX-команды еще раз, там четко видно, что первый блок работает только с младшими 128 битами, а второй только со старшими 128 битами. И нельзя перемешать так, чтобы в старших были младшие байты или наоборот. Причем, для этой инструкции разделение еще не слишком строгое: регистр, который говорит как сдвигать, может по-разному сдвигать верхнюю и нижнюю части. А зачастую бывает, что аргументы операций в обеих частях оказываются одинаковыми. Вот для примера псевдокод _mm256_blend_epi16:


FOR j := 0 to 15
    i := j*16
    IF imm8[j%8]
        dst[i+15:i] := b[i+15:i]
    ELSE
        dst[i+15:i] := a[i+15:i]
    FI
ENDFOR
dst[MAX:256] := 0

Обратите внимание, что j итерируется до 15, а маска берется из байта по модулю восьми: imm8[j%8]. Т.е. у верхней и нижней части регистра всегда будет одинаковая маска. Еще много хлопот доставляет распаковка и запаковка, она тоже происходит независимо по верхним и нижним частям.


PACK_SATURATED(src[127:0]) {
    dst[15:0] := Saturate_Int32_To_UnsignedInt16 (src[31:0])
    dst[31:16] := Saturate_Int32_To_UnsignedInt16 (src[63:32])
    dst[47:32] := Saturate_Int32_To_UnsignedInt16 (src[95:64])
    dst[63:48] := Saturate_Int32_To_UnsignedInt16 (src[127:96])
    RETURN dst[63:0]
}
dst[63:0]    := PACK_SATURATED(a[127:0])
dst[127:64]  := PACK_SATURATED(b[127:0])
dst[191:128] := PACK_SATURATED(a[255:128])
dst[255:192] := PACK_SATURATED(b[255:128])
dst[MAX:256] := 0


Нижние биты результата вычисляются только на основе нижних битов входных параметров. А верхние только на основе верхних. Я еще не встречал ни в одном источнике упоминания этого правила в явном виде, но его понимание сильно упрощает портирование SSE-кода на AVX.


AVX-команды


Есть кое-что, что отличает и сами команды AVX от SSE. Помните проблему с зависимостью данных, рассмотренную в конце первой части? Она была учтена в AVX. Конечно, исправить поведение скалярных инструкций уже нельзя, но зато можно не допустить такой же ошибки при переходе от SSE к AVX. В псевдокоде каждой AVX-инструкции в конце есть строка:


dst[MAX:256] := 0

Это значит, что AVX определяет поведение существующих команд с регистрами будущих поколений (512-битными и более). Но это еще не все. Для кодирования AVX-команд применяется система опкодов VEX. AVX включает в себя возможность кодировать в VEX даже SSE-инструкции. Закодированные таким образом SSE-инструкции также получают гарантии обнуления старших битов. Возможно, вы слышали о том, что существует пенальти около сотни тактов за использование AVX-команд после SSE или наоборот. Хорошая новость в том, что это пенальти не распространяется на SSE-команды, закодированные в VEX. И если пользоваться интринсиками, с флагом -mavx компилятор будет генерировать инструкции в новом формате. Плохая новость в том, что если код собран с -mavx и содержит SSE-команды, но не содержит ни одной AVX-команды, он все равно будет закодирован в VEX и не будет работать на процессорах без AVX. Т.е. не получится в рамках одного модуля сборки использовать SSE-инструкции в старом формате и AVX-инструкции:


if (is_avx_available()) {
    resample_avx();
} else {
    resample_sse();
}

Потому что с флагом -mavx код из функции resample_sse() не запустится на процессоре без AVX, а без этого флага код из функции resample_avx() даже не скомпилируется.


AVX2, вертикальный проход


До сих пор перевод на SIMD был достаточно простым, потому что в один SSE4-регистр влезает ровно один пиксель, представленный в виде четырех чисел с плавающей точкой. Но с AVX2 мы должны обрабатывать сразу 8 значений с плавающей точкой, то есть два пикселя. Но какие именно пиксели брать в один регистр? Тут мне снова хочется вставить картинку с собакой, которая носит штаны. Напомню, как выглядит каркас, например, горизонтальной свертки:


for (xx = 0; xx < xsize; xx++) {
    xmin = xbounds[xx * 2 + 0];
    xmax = xbounds[xx * 2 + 1];
    for (x = xmin; x < xmax; x++) {
        __m128i pix = lineIn[x];
        __m128 mmk = k[x - xmin];
        // Работа с пикселем
    }
}

Можно, например, взять соседние пиксели в строке: lineIn[x] и lineIn[x + 1], это самый очевидный вариант. Но тогда для этих пикселей придется готовить разные коэффициенты (k[x - xmin] и k[x - xmin + 1]). Да и расстояние от xmax до xmin может оказаться нечетным, и чтобы посчитать последний пиксель, нужно будет комбинировать SSE и AVX-код.


Можно взять пиксели в соседних строках: lineIn1[x] и lineIn2[x]. Но тогда загружать и выгружать пиксели придется как-то раздельно, что не очень удобно.


На самом деле любой способ будет иметь какие-то преимущества и недостатки. Скажу прямо, переводить горизонтальный проход на AVX2 не очень-то удобно. Другое дело вертикальный! Посмотрите на него:


for (xx = 0; xx < imIn->xsize; xx++) {
    for (y = ymin; y < ymax; y++) {
        __m128i pix = image32[y][xx];
        __m128 mmk = k[y - ymin];
        // Работа с пикселем
    }
}

Мы можем взять соседние пиксели в строке image32[y][xx] и image32[y][xx + 1] и у них будет одинаковый коэффициент. После выполнения внутреннего цикла в аккумуляторе будет результат для двух соседних пикселей, запаковать его тоже не составит труда. Короче, код можно переписать, просто поменяв все префиксы __m128 на __m256, а _mm_ на _mm256_. Единственное, что действительно отличается — использование функций _mm256_castsi256_si128 и _mm_storel_epi64 в конце. Первая — noop, просто приведение типов. А вторая сохраняет 64-битное значение из регистра в память.


Scale 2560?1600 RGB image
    to 320x200 bil      0.01162 s   352.37 Mpx/s    -0,9 %
    to 320x200 bic      0.02085 s   196.41 Mpx/s    -3,8 %
    to 320x200 lzs      0.03247 s   126.16 Mpx/s     5,4 %
    to 2048x1280 bil    0.03992 s   102.61 Mpx/s    11,5 %
    to 2048x1280 bic    0.05086 s    80.53 Mpx/s    17,0 %
    to 2048x1280 lzs    0.06563 s    62.41 Mpx/s    18,9 %
    to 5478x3424 bil    0.15232 s    26.89 Mpx/s    22,2 %
    to 5478x3424 bic    0.19810 s    20.68 Mpx/s    21,3 %
    to 5478x3424 lzs    0.23601 s    17.36 Mpx/s    30,0 %

Результат для коммита 86fe8a2.


Мы впервые получили небольшой проигрыш по производительности. Это не погрешность измерений, т.к. результат достаточно стабилен. Позже я объясню, почему так получилось. А пока видно, что выигрыш есть в основном при увеличении и не сильном уменьшении изображения. Как не трудно догадаться, так происходит потому, что вертикальный проход делается после горизонтального и поэтому оказывает большее влияние при большем размере конечного изображения. В целом же картина достаточно позитивная.


AVX2, горизонтальный проход


Для горизонтального прохода все же оказывается удобнее взять два соседних пикселя в строке. Тогда для них нужно будет подготовить разные коэффициенты:


__m256 mmk = _mm256_set1_ps(k[x - xmin]);
mmk = _mm256_insertf128_ps(mmk, _mm_set1_ps(k[x - xmin + 1]), 1);

Ну а в конце результат в верхней части 256-битного регистра нужно будет сложить с результатом в нижней:


__m128 sss = _mm_add_ps(
    _mm256_castps256_ps128(sss256),
    _mm256_extractf128_ps(sss256, 1));

Scale 2560?1600 RGB image
    to 320x200 bil      0,00918 s   446.18 Mpx/s    26,6 %
    to 320x200 bic      0,01490 s   274.90 Mpx/s    39,9 %
    to 320x200 lzs      0,02287 s   179.08 Mpx/s    42,0 %
    to 2048x1280 bil    0,04186 s    97.85 Mpx/s    -4,6 %
    to 2048x1280 bic    0,05029 s    81.44 Mpx/s     1,1 %
    to 2048x1280 lzs    0,06178 s    66.30 Mpx/s     6,2 %
    to 5478x3424 bil    0,16219 s    25.25 Mpx/s    -6,1 %
    to 5478x3424 bic    0,19996 s    20.48 Mpx/s    -0,9 %
    to 5478x3424 lzs    0,23377 s    17.52 Mpx/s     1,0 %

Результат для коммита fd82859.


Тут мы снова видим для некоторых размеров небольшой проигрыш относительно предыдущего варианта. Но от обоих оптимизаций в сумме мы получили прирост от 6 до 50 процентов. В среднем AVX2-версия быстрее, чем SSE4-версия на 25%.


Много это или мало? Хотелось бы конечно получить от нового набора инструкций намного большего. Понятно, что на прирост в 100% рассчитывать не приходится, хотя бы 50% в среднем было бы тоже неплохо. Но имеем то, что имеем, и смысла отказываться от даже такого ускорения конечно же нет.


Осталось разобраться, почему же мы получали замедление для каждого шага в отдельности в некоторых случаях. Для этого нужно напомнить, на каком компьютере я запускаю тесты. Это обычный ноутбук с потребительским процессором Intel Core i5-4258U. Процессор имеет жесткие рамки по выделяемой мощности, поэтому для него приоритетна не максимальная производительность, а максимальная производительность на выделяемую мощность.


Как вы возможно знаете, последние лет восемь Интел маркирует свои процессоры двумя тактовыми частотами: номинальной и максимальной. Для i5-4258U это 2.4 ГГц и 2.9 ГГц. Со второй понятно — это максимальная частота, которую в принципе может осилить процессор. А первая — это минимальная частота, до которой может опуститься процессор под нагрузкой. Именно под нагрузкой, потому что без нагрузки в целях экономии энергии частота может опускаться гораздо ниже. Есть утилита, которая в реальном времени показывает частоту, температуру и потребление энергии процессором, а еще строит красивые графики. Она называется Intel Power Gadget. Утилита показывает, что во время работы SSE4-версии частота процессора стабильно держится на отметке 2.9 ГГц. При замене вертикального прохода на AVX2-версию, частота начинает скакать возле отметки 2.75 ГГц. А при полном AVX2-ускорении, частота практически не отклоняется от 2.6 ГГц. Т.е. процессор, выполняя AVX2-код, пасует и сбавляет обороты. А так как выигрыш от отдельных AVX2-проходов есть не для любых размеров изображения, мы можем наблюдать падение производительности из-за падения частоты. Однако при полном использовании AVX2 ускорение от более быстрого набора инструкций перекрывает незначительное падение частоты и мы все равно получаем выигрыш. Что тут можно сказать? Не останавливайтесь на полпути, переводите на AVX2 весь код.


Примечательно, что на сервере с процессором Xeon E5-2680 v2 (та же архитектура Haswell, что и в ноутбуке) подобного эффекта не наблюдается — там включение AVX2-проходов не дает падения частоты и, как следствие, не дает проседания производительности.

Поделиться с друзьями
-->

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


  1. vanburg
    19.04.2017 21:30
    +6

    Снимаю шляпу!


  1. knutov
    19.04.2017 21:42

    Со снижением частоты — хорошо бы посмотреть на графики температуры. Скорее всего это окажется троттлинг.


    1. beeruser
      19.04.2017 22:25

      >> Скорее всего это окажется троттлинг
      Это не троттлинг, а фича — AVX (Turbo) Frequency
      http://repnop.org/pd/slides/PD_Haswell_Architecture.pdf

      Core detects presence of AVX instructions
      AVX instructions draw more current & higher voltage is needed to sustain operating
      conditions
      Not all AVX instructions cause a drop in frequency. Scalar AVX unaffected.
      Need to be at least 128 bit wide.


      1. berezuev
        20.04.2017 12:06

        offtop: Чисто технически, троттлинг — это тоже фича, в принципе))


  1. old_bear
    19.04.2017 22:07

    AVX — это не в два раза большее SSE, это два SSE!

    Судя по контексту, тут (и ещё в некотором количестве заголовков и предложений) должно быть слово AVX2. И то, что его тут нет, сильно вводит в заблуждение тех, кто плохо ориентируется в SIMD-ах.


    1. homm
      19.04.2017 23:19

      Нет, тут речь о любых 256-битных AVX инструкциях, т.е. AVX1 и AVX2.


      1. old_bear
        19.04.2017 23:24

        Упс. Извините, мозг немного болеет гриппом вместе с остальным организмом и совсем забыл про то, что плавающая точка и в первом AVX-е 256-разрядная. Но целочисленные операции то ограничены 128 битами, так что не всё так однозначно. Может сделать какую-то ремарку на эту тему в тексте?


  1. beeruser
    19.04.2017 22:14
    +1

    >> самый быстрый ресайз изображений
    Самый быстрый по сравнению с чем?

    1) Обрабатываете лишь один/два пикселя за раз.
    2) Зачем-то переводите во float. Тут даже у 16 битной данных с фиксированной точкой хватит точности, не говоря уже о 32-bit, где точность выше float.
    3) Работаете с AoS данными.
    «Но проще закрыть на это глаза, чем пытаться максимально забить SSE-регистры полезными данными.»

    Звучит совсем не как «самый быстрый ресайз изображений» в мире.


    1. old_bear
      19.04.2017 22:25
      -2

      Там ещё нулевого пункта не хватает — «asm при написании кода не использовался».
      Посмотрев пару раз, что делает компилятор с этими красивыми последовательностями интринсиков, я пользуюсь ими только в случаях крайней необходимости.


      1. beeruser
        19.04.2017 22:33
        +2

        Можете посмотреть
        https://godbolt.org/g/WRaEFL

        .L4:
        vpmovzxbd xmm0, DWORD PTR [rdx+rax]
        vcvtdq2ps xmm1, xmm0
        vbroadcastss xmm0, DWORD PTR [r8+rax]
        add rax, 4
        cmp r9, rax
        vmulps xmm0, xmm0, xmm1
        vaddps xmm2, xmm2, xmm0
        jne .L4


        Как написано, так и получилось


        1. old_bear
          19.04.2017 22:43
          +3

          Беда в том, что это не всегда работает. Причём это «не всегда» довольно непредсказуемо наступает (вероятность сильно растёт при одновременном использовании 6 и более simd-регистров). Поэтому в какой-то момент я устал проверять дизасемблинг и перешёл на asm.


    1. homm
      19.04.2017 23:16
      +3

      Самый быстрый по сравнению с чем?

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


      Звучит совсем не как «самый быстрый ресайз изображений» в мире.

      Совершенно верно. Звучит как «Как я сделал самый быстрый ресайз изображений. Часть 2». И каждое слово тут важно.


  1. Elsedar
    20.04.2017 09:49

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


    1. shybovycha
      20.04.2017 13:00
      +1

      Ну дык как бы об этом в предыдущей статье было


      1. Elsedar
        20.04.2017 13:28

        Каюсь, не читал предыдущие статьи. Прочитал, осознал — здесь все несколько сложнее.


    1. erwins22
      20.04.2017 16:11

      префетч + запись мимо кеша могут дать больше чем AVX2


  1. AllexIn
    20.04.2017 12:40

    Отчасти так происходит потому, что SIMD-подход объективно сложнее и не кроссплатформенный, а отчасти потому, что по нему мало информации.

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


    1. homm
      20.04.2017 12:44

      В статье есть пример:


      Заметьте, как много в SIMD-коде приходится делать явно при загрузке значений. В скалярном коде ничего этого нет, компилятор сам понимает, что раз мы умножаем 8-битное целое на float, то первое тоже нужно конвертировать в float.


      1. AllexIn
        20.04.2017 12:49

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


        1. Deamon87
          20.04.2017 13:25
          +3

          Сложность SIMD в том, что нужно думать и руками делать:
          1) загрузку переменных в регистры
          2) манипуляции над регистрами
          3) выгрузку результата из регистра в общую память

          А развитие языков программирования всегда было направлено на то, чтобы программист забыл об этих низкоуровневых манипуляциях, и как следствие — как можно больше людей могли писать код. js, php, python, VM based languages — вот это все.


          1. AllexIn
            20.04.2017 13:29

            SIMD — это вообще Ассемблер.
            Инстринкты — по сути кроссплатформенные обертки над ассемблерными командами. А на уровне ассемблеры векторные операции и скалярные по сложности работы не отличаются.
            Ну и по собственному опыту:
            не зная ассемблер, только закончив школу — без особых проблем открыл доки по SSE и перевел свою матлибу на SSE. Там же реально тупо всё: загрузил, выполнил, выгрузил. Ну просто вообще ничего сложного. Разве что сложное слово «регистр» всплывает.

            НУ а вообще я прицепился к «сложно, потому что мало информации».


            1. Deamon87
              20.04.2017 13:47

              Ну я примерно это и имел ввиду. Просто само слово ассемблер некоторых людей вгоняет в ступор. И не низкоуровневые программисты привыкли мыслить переменными. То, что происходит под капотом — вгоняет в ступор.


            1. VioletGiraffe
              20.04.2017 14:32
              +4

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


              1. AllexIn
                20.04.2017 16:03

                Именно так. ПОэтому большая часть прогеров и не заморачивается с векторными операцями.
                Собственно я и сам в далеком 2006 году перевел мат либу на SSE, понял что это не дало вообще ничего. Не потому что плохо перевел, а потому что задачи не так уж часто дергают векторные операции, чтобы приорст был ощутимый. Как говорится оптимизировать надо не всё подряд, а bottle neck. А он очень редко в скорости выполнения операций над векторами. Если у нас не какая нибудь специфичная задача, типа того же ресайза изображений.


  1. AngReload
    20.04.2017 15:55

    Интересно про учет гаммы узнать, насколько сильно замедлился бы ресайз если перед этим значения RGB возводить в квадрат, а после считать корень?


  1. 4144
    20.04.2017 21:59

    $ CC="ccache cc -msse4" python ./setup.py develop
    

    Для gcc вы можете не использовать -msse4 и другие sse ключи компилятора, а использовать атрибут target у функции. Также можете отдать выбор по какой ветке идти с sse, avx или без для компилятора или libc, либо оставить выбор пути как у вас сейчас.

    https://gcc.gnu.org/wiki/FunctionMultiVersioning

    Используя препроцессор и проверку на gcc в нем, вы можете сделать что sse/avx будут работать автоматически в gcc, а для других компиляторов, надо указывать ключики.