256-битная векторизация через OpenMP SIMD препятствует оптимизации компилятора (скажем, встроенная функция)? - PullRequest
0 голосов
/ 03 июля 2018

Рассмотрим следующий игрушечный пример, где A - это матрица n x 2, хранящаяся в главном порядке столбцов, и я хочу вычислить сумму ее столбцов. sum_0 вычисляет только сумму 1-го столбца, в то время как sum_1 также делает 2-й столбец. Это действительно искусственный пример, так как по существу нет необходимости определять две функции для этой задачи (я могу написать одну функцию с гнездом с двойным циклом, где внешний цикл повторяется от 0 до j). Он создан, чтобы продемонстрировать проблему шаблона, которая у меня есть на самом деле.

/* "test.c" */
#include <stdlib.h>

// j can be 0 or 1
static inline void sum_template (size_t j, size_t n, double *A, double *c) {

  if (n == 0) return;
  size_t i;
  double *a = A, *b = A + n;
  double c0 = 0.0, c1 = 0.0;

  #pragma omp simd reduction (+: c0, c1) aligned (a, b: 32)
  for (i = 0; i < n; i++) {
    c0 += a[i];
    if (j > 0) c1 += b[i];
    }

  c[0] = c0;
  if (j > 0) c[1] = c1;

  }

#define macro_define_sum(FUN, j)            \
void FUN (size_t n, double *A, double *c) { \
  sum_template(j, n, A, c);                 \
  }

macro_define_sum(sum_0, 0)
macro_define_sum(sum_1, 1)

Если я скомпилирую это с

gcc -O2 -mavx test.c

GCC (скажем, последняя версия 8.2) после встраивания, постоянного распространения и удаления мертвого кода оптимизирует код, включающий c1 для функции sum_0 ( Проверьте его на Godbolt ).

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

Однако такое удобство теряется, если я активирую OpenMP 4.0+ с

gcc -O2 -mavx -fopenmp test.c

sum_template больше не указывается и устранение мертвого кода не применяется ( Проверьте это на Годболте ). Но если я уберу флаг -mavx для работы с 128-битной SIMD, оптимизация компилятора будет работать так, как я ожидаю ( Проверьте это на Godbolt ). Так это ошибка? Я на x86-64 (Sandybridge).


Примечание

Использование автоматической векторизации GCC -ftree-vectorize -ffast-math не будет иметь этой проблемы ( Проверьте это на Godbolt ). Но я хочу использовать OpenMP, потому что он позволяет переносить прагму выравнивания между различными компиляторами.

Фон

Я пишу модули для пакета R, который должен быть переносимым между платформами и компиляторами. Для написания расширения R не требуется Makefile. Когда R построен на платформе, он знает, какой компилятор по умолчанию находится на этой платформе, и настраивает набор флагов компиляции по умолчанию. R не имеет флага автоматической векторизации, но имеет флаг OpenMP. Это означает, что использование OpenMP SIMD является идеальным способом использования SIMD в пакете R. См. 1 и 2 для более подробной информации.

Ответы [ 2 ]

0 голосов
/ 08 сентября 2018

Самый простой способ решить эту проблему - __attribute__((always_inline)) или другие переопределения, специфичные для компилятора.

#ifdef __GNUC__
#define ALWAYS_INLINE __attribute__((always_inline)) inline
#elif defined(_MSC_VER)
#define ALWAYS_INLINE __forceinline inline
#else
#define ALWAYS_INLINE  inline  // cross your fingers
#endif


ALWAYS_INLINE
static inline void sum_template (size_t j, size_t n, double *A, double *c) {
 ...
}

Божье доказательство того, что оно работает.

Кроме того, не забудьте использовать -mtune=haswell, а не только -mavx. Обычно это хорошая идея. (Однако многообещающие выровненные данные остановят настройку gcc по умолчанию -mavx256-split-unaligned-load от разделения 256-битных загрузок на 128-битные vmovupd + vinsertf128, поэтому код gen для этой функции подходит для tune = haswell Но обычно вы хотите, чтобы gcc автоматически векторизовал любые другие функции.

Вам на самом деле не нужно static вместе с inline; если компилятор решит не включать его, он может по крайней мере использовать одно и то же определение для всех единиц компиляции.


Обычно gcc принимает решение о включении или отключении в соответствии с эвристикой размера функции. Но даже установка -finline-limit=90000 не дает gcc встроиться в ваш #pragma omp ( Как заставить gcc встроить функцию? ). Я догадывался, что gcc не понимал, что постоянное распространение после встраивания упростит условное выражение, но 90000 «псевдоинструкций» кажется достаточно большим. Может быть другая эвристика.

Возможно, OpenMP по-разному настраивает некоторые функции для функций, которые могут сломать оптимизатор, если он позволяет им встроиться в другие функции. Использование __attribute__((target("avx"))) предотвращает встраивание этой функции в функции, скомпилированные без AVX (так что вы можете безопасно выполнять диспетчеризацию во время выполнения, без встраивания «заражения» других функций инструкциями AVX в условиях if(avx).)

Одна вещь, которую OpenMP делает с обычной авто-векторизацией, которую вы не получаете, это то, что сокращения могут быть векторизованы без включения -ffast-math.

К сожалению, OpenMP до сих пор не удосуживается развернуть с несколькими аккумуляторами или чем-то еще, чтобы скрыть задержку FP. #pragma omp - довольно хороший намек на то, что цикл на самом деле горячий и стоит потратить на размер кода, поэтому gcc действительно должен это делать, даже без -fprofile-use.

Поэтому, особенно если это когда-либо выполняется на данных, которые горячие в кеше L2 или L1 (или, возможно, L3), вы должны сделать что-то для повышения пропускной способности.

И кстати, выравнивание обычно не имеет большого значения для AVX на Haswell. Но 64-байтовое выравнивание на практике имеет большое значение для AVX512 на SKX. Например, может быть замедление на 20% для смещенных данных вместо пары%.

(Но многообещающее выравнивание во время компиляции - это отдельная проблема от фактического выравнивания ваших данных во время выполнения. И то, и другое полезно, но многообещающее выравнивание во время компиляции делает код более плотным с gcc7 и более ранними версиями или с любым компилятором без AVX.)

0 голосов
/ 08 сентября 2018

Мне отчаянно нужно было решить эту проблему, потому что в моем реальном C-проекте, если бы не использовался шаблонный трюк для автоматического генерирования различных версий функций (в дальнейшем просто называемых «версиями»), мне нужно было бы написать в общей сложности 1400 строк. кода для 9 различных версий вместо 200 строк для одного шаблона.

Мне удалось найти выход, и сейчас выкладываю решение, используя пример с игрушкой в ​​вопросе.


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

Чтобы избавиться от функции inline sum_template, я вручную вставил ее в макрос macro_define_sum:

#include <stdlib.h>

// j can be 0 or 1
#define macro_define_sum(FUN, j)                            \
void FUN (size_t n, double *A, double *c) {                 \
  if (n == 0) return;                                       \
  size_t i;                                                 \
  double *a = A, * b = A + n;                               \
  double c0 = 0.0, c1 = 0.0;                                \
  #pragma omp simd reduction (+: c0, c1) aligned (a, b: 32) \
  for (i = 0; i < n; i++) {                                 \
    c0 += a[i];                                             \
    if (j > 0) c1 += b[i];                                  \
    }                                                       \
  c[0] = c0;                                                \
  if (j > 0) c[1] = c1;                                     \
  }

macro_define_sum(sum_0, 0)
macro_define_sum(sum_1, 1)

В этой версии macro , j непосредственно заменяется на 0 или 1 at во время раскрытия макроса. Принимая во внимание, что при подходе inline + macro в вопросе у меня есть только sum_template(0, n, a, b, c) или sum_template(1, n, a, b, c) на этапе предварительной обработки и j в теле sum_template распространяется только во время более поздней компиляции.

К сожалению, приведенный выше макрос выдает ошибку. Я не могу определить или проверить макрос внутри другого (см. 1 , 2 , 3 ). Прагма OpenMP, начинающаяся с #, вызывает здесь проблему. Поэтому я должен разделить этот шаблон на две части: часть до прагмы и часть после.

#include <stdlib.h>

#define macro_before_pragma   \
  if (n == 0) return;         \
  size_t i;                   \
  double *a = A, * b = A + n; \
  double c0 = 0.0, c1 = 0.0;

#define macro_after_pragma(j) \
  for (i = 0; i < n; i++) {   \
    c0 += a[i];               \
    if (j > 0) c1 += b[i];    \
    }                         \
  c[0] = c0;                  \
  if (j > 0) c[1] = c1;

void sum_0 (size_t n, double *A, double *c) {
  macro_before_pragma
  #pragma omp simd reduction (+: c0) aligned (a: 32)
  macro_after_pragma(0)
  }

void sum_1 (size_t n, double *A, double *c) {
  macro_before_pragma
  #pragma omp simd reduction (+: c0, c1) aligned (a, b: 32)
  macro_after_pragma(1)
  }

Мне больше не нужно macro_define_sum. Я могу сразу определить sum_0 и sum_1, используя два определенных макроса. Я также могу настроить прагму соответствующим образом. Здесь вместо функции шаблона у меня есть шаблоны для блоков кода функции, и я могу с легкостью использовать их повторно.

В этом случае выходные данные компилятора соответствуют ожидаемым ( Проверьте это на Godbolt ).


Обновление

Спасибо за различные отзывы; все они очень конструктивны (вот почему я люблю переполнение стека).

Спасибо Марк Глиссе за указание на Использование прагмы openmp внутри # define . Да, это было плохо, что я не искал эту проблему. #pragma - это директива, а не настоящий макрос, поэтому должен быть какой-то способ поместить ее в макрос. Вот аккуратная версия с использованием оператора _Pragma:

/* "neat.c" */
#include <stdlib.h>

// stringizing: https://gcc.gnu.org/onlinedocs/cpp/Stringizing.html
#define str(s) #s

// j can be 0 or 1
#define macro_define_sum(j, alignment)                                   \
void sum_ ## j (size_t n, double *A, double *c) {                        \
  if (n == 0) return;                                                    \
  size_t i;                                                              \
  double *a = A, * b = A + n;                                            \
  double c0 = 0.0, c1 = 0.0;                                             \
  _Pragma(str(omp simd reduction (+: c0, c1) aligned (a, b: alignment))) \
  for (i = 0; i < n; i++) {                                              \
    c0 += a[i];                                                          \
    if (j > 0) c1 += b[i];                                               \
    }                                                                    \
  c[0] = c0;                                                             \
  if (j > 0) c[1] = c1;                                                  \
  }

macro_define_sum(0, 32)
macro_define_sum(1, 32)

Другие изменения включают в себя:

  • Я использовал объединение токенов для генерации имени функции;
  • alignment является аргументом макроса. Для AVX значение 32 означает хорошее выравнивание, в то время как значение 8 (sizeof(double)) по существу не предполагает выравнивания. Stringizing требуется для разбора этих токенов в строки, которые необходимы _Pragma.

Используйте gcc -E neat.c для проверки результата предварительной обработки. Компиляция дает желаемый вывод сборки ( Проверьте это на Годболте ).


Несколько комментариев к информативному ответу Питера Кордеса

Использование атрибутов функции компилятора. Я не профессиональный программист на Си. Мой опыт работы с C основан на написании расширений R. Среда разработки определяет, что я не очень знаком с атрибутами компилятора. Я знаю некоторые, но на самом деле не использую их.

-mavx256-split-unaligned-load не является проблемой в моем приложении, потому что я выделю выровненную память и добавлю заполнение для обеспечения выравнивания. Мне просто нужно пообещать компилятору выравнивания, чтобы он мог генерировать выровненные инструкции загрузки / сохранения. Мне нужно сделать некоторую векторизацию для невыровненных данных, но это вносит вклад в очень ограниченную часть всего вычисления. Даже если я получу снижение производительности при разделенной нагрузке, это не будет замечено в реальности. Я также не компилирую каждый файл C с автоматической векторизацией. Я делаю SIMD только тогда, когда операция выполняется в кеше L1 (т. Е. Она связана с процессором, а не с памятью). Кстати, -mavx256-split-unaligned-load для GCC ; что это за другие компиляторы?

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

OpenMP SIMD может эффективно выполнять сокращение даже без GCC -ffast-math. Однако он не использует горизонтальное сложение для агрегирования результатов в регистре аккумулятора в конце сокращения; он запускает скалярный цикл для сложения каждого двойного слова (см. блок кода .L5 и .L27 в Вывод Годбола ).

Пропускная способность - хорошая точка (особенно для арифметики с плавающей запятой, которая имеет относительно большую задержку, но высокую пропускную способность). Мой настоящий C-код, где применяется SIMD - это гнездо с тройным циклом. Я разверну два внешних цикла, чтобы увеличить блок кода в самом внутреннем цикле, чтобы повысить пропускную способность. Тогда векторизации самого внутреннего достаточно. В примере с игрушкой в ​​этом разделе вопросов и ответов, где я просто суммирую массив, я могу использовать -funroll-loops, чтобы запросить GCC на развертывание цикла, используя несколько аккумуляторов для повышения пропускной способности.


В этом Q & A

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

...