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

Я следил за этим курсом Coursera и в какой-то момент дается приведенный ниже код, и инструктор утверждает, что векторизация выполняется путем включения #pragma omp simd между внутренним и внешним циклами for, поскольку управляемая векторизация сложна. Как я могу самостоятельно векторизовать код, используемый в курсе, и есть ли способ добиться лучшей производительности, чем если бы я просто добавил #pragma omp simd и пошел дальше?

template<typename P>
void ApplyStencil(ImageClass<P> & img_in, ImageClass<P> & img_out) {

  const int width  = img_in.width;
  const int height = img_in.height;

  P * in  = img_in.pixel;
  P * out = img_out.pixel;

  for (int i = 1; i < height-1; i++)
    for (int j = 1; j < width-1; j++) {
      P val = -in[(i-1)*width + j-1] -   in[(i-1)*width + j] - in[(i-1)*width + j+1] 
    -in[(i  )*width + j-1] + 8*in[(i  )*width + j] - in[(i  )*width + j+1] 
    -in[(i+1)*width + j-1] -   in[(i+1)*width + j] - in[(i+1)*width + j+1];

      val = (val < 0   ? 0   : val);
      val = (val > 255 ? 255 : val);

      out[i*width + j] = val;
    }

}

template void ApplyStencil<float>(ImageClass<float> & img_in, ImageClass<float> & img_out);

Я компилирую с помощью gcc с флагами -march=native -fopenmp для поддержки AVX512 на процессоре Skylake.

❯ gcc -march=native -Q --help=target|grep march
  -march=                           skylake

❯ gcc -march=knl -dM -E - < /dev/null | egrep "SSE|AVX" | sort
#define __AVX__ 1
#define __AVX2__ 1
#define __AVX512CD__ 1
#define __AVX512ER__ 1
#define __AVX512F__ 1
#define __AVX512PF__ 1
#define __SSE__ 1
#define __SSE2__ 1
#define __SSE2_MATH__ 1
#define __SSE3__ 1
#define __SSE4_1__ 1
#define __SSE4_2__ 1
#define __SSE_MATH__ 1
#define __SSSE3__ 1

person dearn44    schedule 07.05.2020    source источник
comment
Я думаю, что было бы еще больше возможностей для оптимизации для целочисленного типа. Компиляторы, как правило, плохо расширяют целые числа, а затем снова их сужают. Но функция будет глючной для uint8_t я думаю; на самом деле вы бы хотели auto P = ..., потому что + и - в узком целочисленном P будут повышаться до int val перед зажимом.   -  person Peter Cordes    schedule 07.05.2020
comment
Но даже с float да, вероятно, вы можете что-то сделать с перетасовкой, чтобы выровнять правильные данные из каждой из трех задействованных строк, чтобы вы могли выполнять сочетание загрузки и перетасовки. IDK, если есть много возможностей для повторного использования частичных сумм.   -  person Peter Cordes    schedule 07.05.2020
comment
В любом случае, название вашего вопроса очень общее, как будто вы думаете, что есть какой-то общий ответ. Но ответ на ваш вопрос будет специфичен для векторизации этой проблемы с трафаретом. Вот почему так много работы, чтобы сделать это вручную, а не оставлять это компилятору: вы должны точно знать, что ЦП может/не может делать эффективно, и делать это самостоятельно, используя встроенные функции, такие как _mm512_loadu_ps(&n[(i-1)*width + j-1]). А также выявление закономерностей в доступе к данным.   -  person Peter Cordes    schedule 07.05.2020
comment
Окончательную фиксацию можно выполнить, поместив знаковые биты в регистр маски, а затем выполнив операцию vminps с нулевой маской (иначе _mm512_maskz_min_ps). Это должно иметь меньшую задержку, чем отдельные minps и maxps. _mm512_range_ps (новое в AVX512) интересно, но я думаю, что это не то, что нам здесь нужно; он не может зафиксировать верхнюю и нижнюю границу в одной инструкции, насколько мне известно.   -  person Peter Cordes    schedule 07.05.2020
comment
@PeterCordes нет-нет, я имел в виду это только для этой конкретной проблемы, мне стало любопытно, когда инструктор сказал оставить это компилятору, поэтому я изучаю, как я могу сделать это самостоятельно.   -  person dearn44    schedule 07.05.2020
comment
Очевидным первым шагом было бы посмотреть на вывод компилятора asm и посмотреть, как он автоматически векторизуется. например на godbolt.org с помощью gcc или clang -O3 -march=skylake-avx512 -fopenmp. А может и с -ffast-math. Если они сделали что-то явно ужасное, вы обычно можете избежать этого с помощью встроенных функций.   -  person Peter Cordes    schedule 07.05.2020
comment
-march=native на каком реальном оборудовании? Ноутбук IceLake (-march=skylake-client)? Ксеон Фи (-march=knl)? Или Xeon Scalable Skylake-SP или Cascade Lake (skylake-avx512)? Разница может повлиять на то, какой выбор лучше всего подходит для перетасовки по сравнению с более невыровненными 64-байтовыми загрузками.   -  person Peter Cordes    schedule 07.05.2020
comment
@PeterCordes обновляет вопрос, добавляя более актуальную информацию.   -  person dearn44    schedule 07.05.2020
comment
Согласно вашему последнему обновлению, у вас есть -march=skylake (клиент), поэтому у вас вообще нет AVX512, а есть обычный настольный чип Intel, такой как Skylake, Kaby Lake или Coffee Lake. Выход -march=knl не имеет значения. Это нормально, у вас все еще есть AVX2 + FMA, и на самом деле проще векторизовать, когда максимальная ширина вектора не такая большая. (Максимальная пропускная способность FLOPS вдвое меньше, чем у AVX512, но легче приблизиться к этому пику, когда ширина вектора составляет только половину ширины строки кеша, поэтому невыровненные загрузки иногда не пересекают кеш. граница линии.)   -  person Peter Cordes    schedule 07.05.2020
comment
Кроме того, в этом коде отсутствует определение класса, чтобы его можно было скомпилировать на godbolt.org/z/65Fjco. . Я предполагаю, что это что-то тривиальное, например godbolt.org/z/cjCjhg, возможно, с использованием узкой int ширины и высоты. нравится ваша петля, а не size_t?   -  person Peter Cordes    schedule 07.05.2020
comment
почему это работает, если я устанавливаю флаг -march=skylake-avx512? Да, вы правы, хотя, я думаю, подойдет любой двумерный массив, вы можете взглянуть на точный код в coursera [coursera.org/learn/parallelism-ia/supplement/2d9MO/.   -  person dearn44    schedule 07.05.2020
comment
Вы уверены, что включили -O3 -march=skylake-avx512, чтобы заставить gcc действительно авто-векторизировать? Если вы пропустили -O3 или -O2 -fopenmp в коде с прагмами, gcc обычно не будет использовать какие-либо векторные инструкции, а также не будет использовать версии скалярных инструкций AVX512.   -  person Peter Cordes    schedule 07.05.2020
comment
Кстати, у GCC -O3 -march=skylake-avx512 -ffast-math нет серьезных мозговых пуков: godbolt.org/z/txvwqF. Но он выполняет одну векторную загрузку на математическую операцию FP, и они в основном не выровнены. Повторное использование векторов в итерациях может сэкономить много нагрузки. И если вы делаете несколько строк одновременно, вы даже можете повторно использовать частичные суммы 3 последовательных элементов как i-1, так и i+1 для разных значений i.. (Математика FP не является строго ассоциативной из-за округления промежуточных значений, но она достаточно близка чтобы разрешить изменение порядка.)   -  person Peter Cordes    schedule 07.05.2020


Ответы (1)


Вот непроверенная демонстрационная реализация, которая использует 4 добавления, 1 fmsub и 3 загрузки на пакет (вместо 9 загрузок, 7 добавлений, 1 fmsub для прямой реализации). Я не учел зажим (который для изображений float выглядит, по крайней мере, необычно, а для изображений uint8 ничего не делает, если только вы не измените P val = ... на auto val = ..., как заметил Питер в комментариях) — но вы можете легко добавить это самостоятельно.

Идея этой реализации состоит в том, чтобы суммировать пиксели слева и справа (x0_2), а также все 3 (x012) и сложить их из 3 последовательных строк (a012 + b0_2 + c012), а затем вычесть это из среднего пикселя, умноженного на 8. В конце каждый цикл удаляет содержимое a012 и перемещает bX в aX и cX в bX для следующей итерации.

Функция applyStencil просто применяет первую функцию для каждого столбца из 16 пикселей (начиная с col = 1 и в конце просто выполняет перекрывающиеся вычисления для последних 16 столбцов). Если ваше входное изображение имеет менее 18 столбцов, вам нужно обрабатывать это по-другому (возможно, с помощью маскированных загрузок/хранилищ).

#include <immintrin.h>

void applyStencilColumn(float const *in, float *out, size_t width, size_t height)
{
  if(height < 3) return; // sanity check
  float const* last_in = in + height*width;
  __m512 a012, b012, b0_2, b1;
  __m512 const eight = _mm512_set1_ps(8.0);
  {
    // initialize first rows:
    __m512 a0 = _mm512_loadu_ps(in-1);
    __m512 a1 = _mm512_loadu_ps(in+0);
    __m512 a2 = _mm512_loadu_ps(in+1);
    a012 = _mm512_add_ps(_mm512_add_ps(a0,a2),a1);
    in += width;
    __m512 b0 = _mm512_loadu_ps(in-1);
    b1 = _mm512_loadu_ps(in+0);
    __m512 b2 = _mm512_loadu_ps(in+1);
    b0_2 = _mm512_add_ps(b0,b2);
    b012 = _mm512_add_ps(b0_2,b1);
    in += width;
  }
  // skip first row for output:
  out += width;

  for(; in<last_in; in+=width, out+=width)
  {
    // precalculate sums for next row:
    __m512 c0 = _mm512_loadu_ps(in-1);
    __m512 c1 = _mm512_loadu_ps(in+0);
    __m512 c2 = _mm512_loadu_ps(in+1);
    __m512 c0_2 = _mm512_add_ps(c0,c2);
    __m512 c012 = _mm512_add_ps(c0_2, c1);

    __m512 outer = _mm512_add_ps(_mm512_add_ps(a012,b0_2), c012);
    __m512 result = _mm512_fmsub_ps(eight, b1, outer);

    _mm512_storeu_ps(out, result);
    // shift/rename registers (with some unrolling this can be avoided entirely)
    a012 = b012;
    b0_2 = c0_2; b012 = c012; b1 = c1;
  }
}

void applyStencil(float const *in, float *out, size_t width, size_t height)
{
  if(width < 18) return; // assert("special case of narrow image not implemented");

  for(size_t col = 1; col < width - 18; col += 16)
  {
    applyStencilColumn(in + col, out + col, width, height);
  }
  applyStencilColumn(in + width - 18, out + width - 18, width, height);
}

Возможные улучшения (оставлено в качестве упражнения):

  • applyStencilColumn может воздействовать на столбцы размером 32, 48, 64, ... пикселей для лучшей локализации кеша (если у вас достаточно регистров). Это, конечно, несколько усложняет реализацию обеих функций.
  • Если вы развернете 3 (или 6, 9, ...) итерации цикла for(; in<last_in; in+=width), не будет необходимости фактически перемещать регистры (плюс общее преимущество развертывания).
  • Если ваша ширина кратна 16, вы можете убедиться, что по крайней мере магазины в основном выровнены (за исключением первого и последнего столбцов).
  • Вы можете одновременно перебирать небольшое количество строк (добавив еще один внешний цикл к основной функции и вызвав applyStencilColumn с фиксированной высотой. Убедитесь, что между наборами строк имеется правильное количество перекрытий. (Идеальное число строк, вероятно, зависит от размера вашего изображения).
  • Вы также всегда можете добавить 3 последовательных пикселя, но вместо этого умножить центральный пиксель на 9 (9*b1-outer). Затем (с некоторыми усилиями по бухгалтерскому учету) вы можете добавить row0+(row1+row2) и (row1+row2)+row3, чтобы получить промежуточные результаты row1 и row2 (имея 3 сложения вместо 4). Однако сделать то же самое по горизонтали выглядит сложнее.

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

person chtz    schedule 08.05.2020
comment
Я не уверен, что шагающие вниз столбцы будут работать очень хорошо. Предварительная выборка HW может обрабатывать фиксированные шаги, но вы неизбежно касаетесь одной и той же строки кэша несколько раз при последовательных проходах из-за смещений -1 и +1, поэтому не все ваши нагрузки могут быть выровнены. Параллельное выполнение большего количества столбцов может помочь. Или может быть больно, если одному столбцу строк кэша удалось поместиться в L2 или L1d, например. шаг строки не был большой степенью двойки, что делало их все псевдонимом одного и того же набора. - person Peter Cordes; 09.05.2020
comment
Я думаю, что параллельное выполнение нескольких rows при перемещении по столбцам позволяет получить некоторое повторное использование. Но псевдонимы кеша могут снизить производительность, если у вас слишком много входных потоков для ассоциативности L2 или L1d и неудачная ширина = шаг строки. (Однако на процессорах Intel с AVX512 минимум 8-процессорный. В отличие от Skylake-клиента, который имеет только 4-процессорную ассоциативность L2.) - person Peter Cordes; 09.05.2020
comment
Зажим с плавающей запятой появляется, если я предполагаю, что он насыщает до диапазона значений пикселей uin8_t для возможного дальнейшего расчета в области с плавающей запятой. Если следующим шагом будет обратное преобразование в uint8_t, то да, было бы гораздо разумнее просто преобразовать в int32_t и использовать упаковку с насыщением без знака. - person Peter Cordes; 09.05.2020
comment
@PeterCordes Я добавил еще одно улучшение, касающееся повторения нескольких строк. Я также подумал о том, чтобы где-нибудь сохранить частичную сумму im[-1]+im[0]+im[1] (например, внутри следующих строк целевого массива, затем добавить 3 строки и вычесть это из 9-кратного центрального пикселя). Однако я не буду исследовать это дальше. - person chtz; 09.05.2020
comment
Относительно фиксации: «плавающие» изображения обычно хранятся в диапазоне от 0 до 1.0, поэтому фиксация с 255 здесь не повлияет. Однако зажатие до 0 на самом деле скрывает черные линии на белом фоне (может быть, так и задумано, но необычно, и, возможно, это лучше сделать на следующем этапе обработки). Тем не менее, добавление зажима должно быть простым, если это действительно необходимо. - person chtz; 09.05.2020
comment
Поскольку у нас есть AVX512, это должен делать один vminps с нулевой маской (с маской = инверсия битов знака). Я думаю, что это достаточно неочевидно, чтобы его стоило показать, и на одну математическую операцию FP меньше, чем vmin / vmax, а также меньшая задержка. - person Peter Cordes; 09.05.2020
comment
@PeterCordes Можете ли вы извлечь (обратные) биты знака, не создавая операцию FP? (Честный вопрос, не нашел, но опыта с AVX512 мало). Единственным очевидным способом, который я нашел, было сравнение с 0.0, но я думаю, что это не будет быстрее, чем другой vmaxps (и не уменьшит задержку). Если задержка была проблемой, возможно, сравнение 8*b1 на outer возможно. - person chtz; 09.05.2020
comment
Я думал о vptestnmd против маски set1_epi32(0x80000000), которая, по-видимому, имеет задержку 4c через порт 5 (uops.info) на SKX. VFPCLASSPS то же самое; при правильном выборе немедленного может дать маску, которую мы хотим. VPMOVD2M будет просто извлекать знаковые биты без инвертирования, что я не думаю, что мы можем использовать. Я только что вспомнил, что Skylake-X отключает порт 1, пока 512-битные uop находятся в полете, поэтому предотвращение истинного uop FMA помогает только для 256-битной версии или на процессорах только с одним FMA-модулем. - person Peter Cordes; 09.05.2020
comment
@chtz, когда здесь будет работать блок кода инициализации первых строк? Я имею в виду вычисления для a0, b0 и так далее. - person dearn44; 22.05.2020
comment
@dearn44 Dearn44 Этот блок всегда будет запускаться в начале applyStencilColumn, я использовал блок { } в основном для структурирования (и чтобы выразить, что a0, a1 и т. д. не требуются вне этого блока. - person chtz; 23.05.2020
comment
Тогда как это может быть правильным, если мы загружаем c несколько раз, а a и b только один раз. Не могли бы вы обсудить это в чате chat.stackoverflow.com/rooms/214454/optimization-discussion - person dearn44; 23.05.2020