256-битная векторизация через OpenMP SIMD предотвращает оптимизацию компилятора (скажем, функцию inlining)?

рассмотрим следующий пример игрушки, где A - это n x 2 матрица хранится в порядке столбцов, и я хочу вычислить ее сумму столбцов. sum_0 вычисляет только сумму 1-го столбца, в то время как sum_1 также 2-й колонке. Это действительно искусственный пример, так как нет необходимости определять две функции для этой задачи (я могу написать одну функцию с двойным гнездом цикла, где внешний цикл повторяется из 0 to 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 больше не встроен и не применяется исключение мертвого кода (проверьте это на Godbolt). Но если я удалю флаг -mavx для работы с 128-битным SIMD оптимизация компилятора работает так, как я ожидаю (проверьте его на Godbolt). Так это жучок? Я нахожусь на x86-64 (Sandybridge).


Примечание

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

фон

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

2 ответов


самый простой способ решить эту проблему-с __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) {
 ...
}

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

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

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


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

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

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

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

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

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

(но перспективное выравнивание во время компиляции это отдельная проблема от фактического выравнивания данных во время выполнения. Оба полезны, но перспективное выравнивание во время компиляции делает более жесткий код с gcc7 и ранее или на любом компиляторе без AVX.)


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

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


я планировал используйте inline функции sum_template для версии. В случае успеха, это происходит во время компиляции, когда компилятор выполняет оптимизацию. Однако ПРАГМА OpenMP оказывается неудачной во время компиляции. Затем можно выполнить управление версиями на этапе предварительной обработки с помощью макрос только.

, чтобы избавиться от 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)

в этом макрос-только версии j непосредственно заменяется 0 или 1 at во время расширения макроса. Тогда как в inline функция + макрос подходите в вопросе, у меня только 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).


обновление

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

спасибо Марк Glisse В для меня использование прагмы 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)) по существу не подразумевает выравнивания. преобразования в строку требуется разберите эти маркеры на строки, которые _Pragma требует.

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


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

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

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

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

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

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


на этом Q & A

я думаю, люди будут относиться к этому Q & A более технически, чем я. Они могут быть заинтересованы в использовании атрибутов компилятора или настройке флагов / параметров компилятора для принудительной вставки функций. Поэтому ответ Питера, а также комментарий Марка под ответом по-прежнему очень ценны. Спасибо снова.