Здравствуйие! Я хочу пройти курс Введение в принципы функционирования и применения современных мультиядерных архитектур (на примере Intel Xeon Phi), в презентации самостоятельной работы №1 указаны логин и пароль для доступ на кластер и выполнения самостоятельных работ, но войти по такой паре логин-пароль не получается. Как предполагается выполнение самосоятельных работ в этом курсе? |
Лекция 5: Элементы оптимизации прикладных программ для Intel Xeon Phi. Intel C/C++ Compiler
Элементарные функции в Intel Cilk Plus
Использование механизма элементарных функций в Intel Cilk Plus является еще одной возможностью векторизации вашего кода.
Вернемся к простому примеру сложения двух массивов и покажем его векторизацию с помощью элементарных функций. Код будет таким:
__declspec(vector) float foo(float *B, float *C, int i) { return B[i] + C[i]; } … for(i=0; i<N; i++) { A[i] = foo(B, C, i); } …
Основная идея здесь состоит в выделении векторизуемой операции в отдельную функцию и вызове этой функции в цикле.
Рассмотрим этот механизм подробнее.
Элементарная функция – это функция, выполняющая вычисления над скалярными элементами данных. Строка __declspec(vector) указывает на необходимость векторизации кода этой функции:
__declspec(vector) float foo(float a, float b, float c, float d) { return a * b + c * d; }
Вызывать элементарную функцию можно следующим образом:
- В цикле для элементов массива:
for (i=0; i<n; i++) { A[i] = foo(B[i], C[i], D[i], E[i]); }
- С использованием технологии Array Notation:
A[:] = foo(B[:], C[:], D[:], E[:]);
- Из другой элементарной функции:
__declspec(vector) float bar(float a, float b, float c, float d) { return sinf(foo(a,b,c,d)); }
- В скалярном коде:
e = foo(a, b, c, d);
При этом во всех случаях кроме последнего будет получен векторизованный код.
Таким образом, программист использует скалярный синтаксис для описания операции над скалярным элементом и затем применяет эту операцию для массива. Использование элементарных функций позволяет задействовать параллелизм как на уровне векторных операций, так и на уровне потоков процессора. Стиль программирования похож на тот, что используется при написании функций ядра в CUDA и OpenCL.
Для обеспечения векторизации кода с использованием элементарных функций программист должен написать обычную C/C++ скалярную функцию, добавить к ней описание __declspec(vector) и вызвать ее в параллельном контексте (одним из трех способов, описанных выше).
При этом компилятор сгенерирует векторную версию данной функции и будет вызывать ее итеративно до тех пор, пока не закончатся обрабатываемые элементы. Эта операция может выполняться как в рамках одного потока, так и в многопоточном режиме.
Заметим, что для правильной работы механизма элементарных функций следует соблюдать следующие правила:
- непрямые вызовы запрещены;
- запрещена передача структур по значению (по ссылке допустимо);
- запрещена синхронизация;
- запрещено использование многопоточных конструкций (_Cilk_spawn/_Cilk_for).
Подробнее об использовании конструкций Intel Cilk Plus можно узнать по ссылкам [5.14],[5.15].
Методы эффективной векторизации кода с использованием Intel C/C++ Compiler
Использование отчетов компилятора
Intel Compiler позволяет получить информацию о том, как компилятор справился с векторизацией вашего кода. Могут быть показаны те места в коде, для которых были попытки векторизации, степень успешности этих попыток и некоторая информация для разработчика о том, почему тот или иной цикл векторизовать не удалось.
Соответственно, на этапе внедрения векторизации в ваш код имеет смысл сначала определить, что не векторизовалось автоматически, и в дальнейшем работать именно с этими проблемными участками кода.
Опция компилятора, отвечающая за отчеты по векторизации, имеет вид -vec-report[n] для Linux и /Qvec-report[n] для Windows версии компилятора.
Здесь n может принимать значения от 0 до 7. По умолчанию используется значение 0, что означает отсутствие каких-либо сообщений. До недавнего времени максимум информации давала опция с n равным 3. А именно, с ее помощью можно было понять, какие циклы успешно векторизовались, а какие нет и почему.
Недавнее появление режима 6 позволяет существенно расширить эти данные за счет более конкретной информации о причинах неудачи компилятора (режим 6 появился в Intel Composer 13) [5.7]. Теперь компилятор говорит не только о том, почему у него не получилось векторизовать цикл, но и о том, что нужно сделать пользователю, чтобы исправить ситуацию.
Например, при использовании опции –vec-report3 вы можете получить сообщение вида:
loop was not vectorized: unsupported data type
А использование –vec-report6 даст уже:
vectorization support: type TTT is not supported for operation OOO
Т.е. разработчик может попытаться изменить тип данных в указанной операции или саму операцию для автоматической векторизации.
Опция –vec-report7 появилась уже в Intel Composer версии 14. Она позволяет получить дополнительную информацию о векторизуемом коде, например, ожидаемое ускорение от векторизации, используемый здесь шаблон доступа к памяти и др.
Выравнивание данных
При работе с векторными регистрами следует обращать внимание на эффективность чтения/записи данных в эти регистры. В силу особенностей архитектуры процессора, операции чтения и записи наиболее эффективно работают с данными, которые являются выровненными по определенной границе байт в оперативной памяти. Иными словами, выровненными считаются данные, адрес начала которых кратен определенному количеству байт. Для Intel Xeon Phi это 64 байта. Отметим, что в данном случае это и размер векторного регистра, и размер линейки кэшей L1 и L2.
Для определения выровненных статических массивов можно использовать следующий синтаксис [5.8]:
__declspec(align(64)) float A[1000]; //Windows float A[1000] __attribute__((aligned(64))); //Linux, Mac
Для выделения выровненной памяти динамически следует воспользоваться функциями _mm_malloc() и _mm_free() вместо обычных malloc() и free(). Второй аргумент функции _mm_malloc() определяет размер выравнивания в байтах:
buf = (char*) _mm_malloc(bufsize, 64);
Помимо выделения выровненных данных в программе, для эффектной векторизации вашего кода необходимо сообщить компилятору о выравнивании в том месте кода, где эти данные непосредственно используются.
Например, при передаче данных в функцию в качестве аргументов нужно сообщить компилятору о том, выровнены ли данные. Допустим, используется цикл, который обращается к массиву A как A[i], и к массиву B как B[i+n1] . Здесь i – это счетчик цикла.
Для того чтобы компилятор использовал команды работы с выровненными данными, ему необходимо сообщить, что:
- Адрес начала массивов A и B кратен 64 байтам. В случае если массивы выделены статически, ничего дополнительно делать не надо. При динамическом выделении памяти нужно дополнительно сказать компилятору о том, что используемые здесь данные выровнены по 64 байта с помощью конструкции __assume_aligned(A, 64) .
- Величина n1 кратна 16 (при размере типа данных в 4 байта). По сути, это опять же говорит компилятору, что адрес (B+n1) кратен 64 байтам. Эта информация может быть указана с помощью конструкции __assume(n1%16==0) .
Рассмотрим пример использования описанных выше конструкций:
__declspec(align(64)) float X[1000], X2[1000]; void foo(float * restrict a, int n, int n1, int n2) { int i; __assume_aligned(a, 64); __assume(n1%16==0); __assume(n2%16==0); for(i=0;i<n;i++) { X[i] += a[i] + a[i+n1] + a[i-n1]+ a[i+n2] + a[i-n2]; } for(i=0;i<n;i++) { X2[i] += X[i]*a[i]; } }
Здесь массивы X и X2 выделены статически, поэтому дополнительно сообщать об их выравнивании не требуется. Массив a передается в функцию по указателю, а значит, компилятор ничего не знает о том, выровнен он или нет. О том, что он выровнен по границе в 64 байта мы и говорим компилятору. Дополнительно сообщаем и о выравнивании адресов со смещениями n1 и n2.
Обратите внимание на ключевое слово restrict в объявлении параметра a. Оно говорит компилятору о том, что обращение к данным по адресу a идет без каких либо перекрытий с данными из других массивов. Это позволяет компилятору определить отсутствие зависимостей в циклах.
Оба представленных в примере цикла будут автоматически векторизованы с использованием "выровненных" операций чтения/записи данных.
В качестве альтернативы предложенным конструкциям можно использовать директиву #pragma vector align перед телом векторизуемого цикла:
void test(float * a, float * b, float * c, int n) { #pragma simd #pragma vector aligned for (int i = 0; i < n; i++) c[i] = a[i] * b[i] + a[i]; }
Обратите внимание, что данная директива относится ко всем массивам, используемым в рамках цикла.
Отдельного рассмотрения заслуживает случай, когда один и тот же цикл одновременно векторизуется и распараллеливается. Даже если исходные данные являются выровненными, при распараллеливании каждый отдельный поток может начать обработку с элемента, адрес которого уже не является кратным 64 байтам. Поэтому в данной ситуации разработчику необходимо самостоятельно позаботиться о корректном разделении данных между потоками.
Пример решения этой задачи приведен ниже. Вариант кода без векторизации следующий:
static double * a; a = _mm_malloc((N+OFFSET) * sizeof(double),64); #pragma omp parallel for for (j = 0; j < N; j++) a[j] = 2.0E0 * a[j];
С векторизацией код будет таким:
static double * a; a = _mm_malloc((N+OFFSET) * sizeof(double),64); #pragma omp parallel { #pragma omp master { num_threads = omp_get_num_threads(); N1 = ((N / num_threads)/8) * num_threads * 8; } } #pragma omp parallel for #pragma vector aligned for (j = 0; j < N1; j++) a[j] = 2.0E0 * a[j]; for (j = N1; j < N; j++) a[j] = 2.0E0 * a[j];
Сначала мы определяем используемое количество потоков, на основании которого вычисляем новый размер цикла, позволяющий выделить каждому потоку участок с нужным выравниванием и размером.
Затем распараллеливаем и векторизуем цикл меньшего размера стандартным образом (в данном случае векторизация будет выполнена автоматически). И в завершение досчитываем оставшиеся итерации.
Подробнее о выравнивании данных для эффективной векторизации можно узнать по ссылке [5.8].