256-битная векторизация через OpenMP SIMD предотвращает оптимизацию компилятора (скажем, встроенная функция)?
Рассмотрим следующий игрушечный пример, где 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
( Проверьте это на Годболте).
Мне нравится этот трюк. Путем написания одной функции шаблона и передачи различных параметров конфигурации оптимизирующий компилятор может генерировать разные версии. Это намного чище, чем копировать и вставлять большую часть кода и вручную определять различные версии функций.
Однако такое удобство теряется, если я активирую 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 не требует Makefile. Когда 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
; если компилятор решает не включать его, он может по крайней мере использовать одно и то же определение для всех единиц компиляции.
Обычно 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.)
Я отчаянно нуждался в разрешении этой проблемы, потому что в моем реальном C-проекте, если бы не использовался шаблонный трюк для автоматического генерирования различных версий функций (в дальнейшем просто называемых "версиями"), мне нужно было бы написать в общей сложности 1400 строк кода для 9 разных версий, вместо 200 строк для одного шаблона.
Я смог найти выход, и сейчас выкладываю решение, используя игрушечный пример в вопросе.
Я планировал использовать встроенную функцию sum_template
для управления версиями. В случае успеха это происходит во время компиляции, когда компилятор выполняет оптимизацию. Тем не менее, прагма OpenMP, как оказалось, не позволяет управлять версиями во время компиляции. Затем можно сделать управление версиями на этапе предварительной обработки, используя только макросы.
Чтобы избавиться от встроенной функции 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 во время расширения макроса. В то время как в вопросе встроенная функция + макроподход в вопросе, у меня есть только 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).
Обновить
Спасибо за различные отзывы; все они очень конструктивны (вот почему я люблю Stack Overflow).
Спасибо Marc 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).
Несколько комментариев на информативный ответ Питера Кордеса
Использование атрибутов функции компилятора. Я не профессиональный программист на Си. Мой опыт работы с 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 в выводе Godbolt).
Пропускная способность является хорошей точкой (особенно для арифметики с плавающей точкой, которая имеет относительно большую задержку, но высокую пропускную способность). Мой настоящий C-код, где применяется SIMD, - это гнездо с тремя циклами. Я развертываю два внешних цикла, чтобы увеличить блок кода в самом внутреннем цикле, чтобы повысить пропускную способность. Тогда векторизации самого внутреннего достаточно. С примером игрушки в этом Q & A, где я просто суммирую массив, я могу использовать -funroll-loops
попросить GCC развернуть цикл, используя несколько аккумуляторов для увеличения пропускной способности.
На этом Q & A
Я думаю, что большинство людей будут относиться к этому вопросу более технически, чем я. Их может заинтересовать использование атрибутов компилятора или настройка флагов / параметров компилятора для принудительного встраивания функции. Поэтому и ответ Петра, и комментарий Марка под ответом все еще очень ценны. Еще раз спасибо.