Нижегородский государственный университет им. Н.И.Лобачевского
Опубликован: 30.05.2014 | Доступ: свободный | Студентов: 303 / 35 | Длительность: 11:26:00

Лекция 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 – это счетчик цикла.

Для того чтобы компилятор использовал команды работы с выровненными данными, ему необходимо сообщить, что:

  1. Адрес начала массивов A и B кратен 64 байтам. В случае если массивы выделены статически, ничего дополнительно делать не надо. При динамическом выделении памяти нужно дополнительно сказать компилятору о том, что используемые здесь данные выровнены по 64 байта с помощью конструкции __assume_aligned(A, 64) .
  2. Величина 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].

Svetlana Svetlana
Svetlana Svetlana

Здравствуйие! Я хочу пройти курс Введение в принципы функционирования и применения современных мультиядерных архитектур (на примере Intel Xeon Phi), в презентации самостоятельной работы №1 указаны логин и пароль для доступ на кластер и выполнения самостоятельных работ, но войти по такой паре логин-пароль не получается. Как предполагается выполнение самосоятельных работ в этом курсе?