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

Векторные расширения Intel Xeon Phi

< Лекция 3 || Лекция 4: 123 || Лекция 5 >

Векторизация в программах на языке высокого уровня

Итак, вычислительные ядра Intel Xeon Phi могут выполнять однотипные вычисления, оперируя векторами из целых чисел или чисел с плавающей запятой, обладают широким спектром математических операций, логических операций, операций с битами, операций работы с памятью. Основной вопрос, волнующий прикладного программиста, желающего применить этот аппарат на практике, формулируется так: как написать программу, чтобы задействовать эти возможности? Попробуем дать ответ на этот вопрос.

Для использования векторных инструкций есть следующие пути:

  1. Использовать высокопроизводительные специализированные библиотеки, эффективно использующие векторные инструкции.

    Это хороший вариант при условии, что все, что нужно программе, реализовано в библиотеке. Такое на практике встречается достаточно редко.

  2. Написать программу на C/C++ или Fortran и откомпилировать ее тем транслятором, который "знает" про соответствующие наборы команд.

    Остановимся на этом варианте подробнее. Итак, если мы рассматриваем вопрос в контексте традиционных центральных процессоров, то мы можем констатировать тот факт, что Intel C/C++ Compiler и Intel Fortran Compiler по-прежнему являются одними из лучших оптимизирующих компиляторов, однако и другие компиляторы постепенно "учатся" использовать векторные расширения SSE и AVX. В частности, это относится к компилятору gcc. Говоря по Intel Xeon Phi, необходимо отметить, что на сегодняшний день только компиляторы Intel могут генерировать код для этого ускорителя, что, в принципе, неудивительно, учитывая, что устройство появилось на рынке только в этом году. Таким, образом, для использования векторных расширений при условии программирования исключительно с использованием обычных возможностей языка высокого уровня нам необходим компилятор Intel.

    Пусть мы используем компилятор Intel. Значит ли это, что все наши вычисления по мановению волшебной палочки станут векторными? Конечно, нет. Мы должны учитывать тот факт, что компилятор умеет векторизовывать (реализовывать с использованием векторных инструкций) те вычисления, которые содержаться в циклах. При этом мы должны стремиться к соблюдению следующих условий:

    • Крайне желательно минимизировать всевозможные зависимости по данным. Чем меньше зависимостей, тем больше потенциала для параллелизма. Чем больше возможностей параллельного выполнения, тем больше шансов, что компилятор распознает этот факт и внедрит соответствующие команды в код. Иногда это проще сказать, чем сделать. Как избавиться от зависимостей, если они возникают из природы метода? Часто, несмотря ни на что, можно переупорядочить и сгруппировать вычисления, чтобы найти независимые фрагменты. Иногда это потребует серьезных усилий.
    • Нежелательно вызывать функции в вычислительно трудоемких циклах. Однако вызов функции тоже не приговор. Компилятор может встроить код функции и успешно векторизовать цикл.
    • Необходимо следить за выравниванием данных в памяти, то есть за их размещением с "правильных" адресов, кратным определенному числу байт (размеру кэш-линии, размеру векторного регистра). Загрузка/выгрузка выровненных данных происходит значительно быстрее, кроме того, мы избавляемся от ситуаций, когда часть информации попадает в одну кэш-линию, а часть – в другую. Последнее не имеет прямого отношения к векторизации, но не становится от этого менее важным. Выравнивания можно добиться как для обычных статических массивов, так и для динамических массивов посредством директив __declspec(aligned) и функций __mm_malloc().
    • Необходимо стараться обеспечить однородный доступ к памяти, когда мы загружаем/выгружаем данные, лежащие последовательно (крайне желательно) либо с одинаковым шагом (допустимо). В этих случаях загрузка/выгрузка может быть выполнена одной командой. В противном случае данные тоже могут быть собраны в векторный регистр, но это потребует нескольких команд, что приведет к большим накладным расходам и, скорее всего, нивелирует весь выигрыш от векторизации, а иногда и вовсе приведет к замедлению.
    • Необходимо сводить к минимуму смешивание объектов разных типов данных в выражениях.
    • Необходимо по возможности избавляться от условных операторов в теле внутреннего цикла. Раньше компилятор в принципе отказывался векторизовывать такие циклы. Сейчас в ряде случаев ему удается это сделать.

      Пример цикла с вычислением максимума:

      #pragma simd
        #pragma vector aligned
        for (int i = 0; i < n; i++)
          s = s + max(a[i],0);
      }
      

      Пример цикла (линейный поиск):

      #pragma simd
        #pragma vector aligned
        for (int i = 0; i < n; i++)
          if (a[i] == key) {
            Index = i;
            break;
          }
      

      Оба цикла успешно векторизуются компилятором. Подробнее данные примеры обсуждаются в лабораторной работе по векторизации.

      Весь возможный список рекомендаций этим не исчерпывается, но приведенные, пожалуй, являются наиболее принципиальными. Среди других достаточно важных тезисов необходимо отметить следующие:

    • по возможности не использовать объектно-ориентированные конструкции в расчетах; объектный подход – мощный инструмент для анализа, проектирования и разработки больших программных комплексов, но часто затрудняющий компиляторную оптимизацию. Тем не менее, существуют способы умелого сочетания высокоуровневой объектно-ориентированной архитектуры и низкоуровневого программирования вычислительно трудоемких участков кода;
    • стремиться к достаточно существенному объему работы во внутренних циклах; заметим, что компилятор преимущественно векторизует именно внутренние циклы, поэтому необходим простор для его деятельности.

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

      void test(float * restrict a, 
                float * restrict b, 
                float * restrict c,
                int n)
      {
        for (int i = 0; i < n; i++)
          c[i] = a[i] * b[i] + a[i];
      }
      

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

  3. Использовать специальные ключи и директивы компилятора.

    Мы можем написать программу на C/C++ или Fortran, подать ее на вход компилятору Intel, указать ключи O2 (O3) – оптимизация по скорости, и, возможно, некоторые циклы в программе будут векторизованы. Как узнать, что именно векторизовалось, что не векторизовалось и почему? Как помочь компилятору? Эти и некоторые другие темы будут подробно рассмотрены в следующей лекции и при выполнении лабораторной работы по векторизации. Пока скажем лишь, что компилятор умеет выдавать отчет о результатах векторизации, а также содержит значительное количество специальных директив для помощи векторизатору, объясняющие ему, что массивы выровнены, зависимостей нет и т.д.

    Пример кода, автоматически векторизуемого компилятором с нашей помощью, может выглядеть следующим образом:

    void test(float * a, float * b, float * c, int n)
    {
      #pragma ivdep
      #pragma vector aligned
      for (int i = 0; i < n; i++)
        c[i] = a[i] * b[i] + a[i];
    }
    

    Или так:

    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];
    }
    

    Указанные директивы (ivdep и simd) говорят компилятору о том, что массивы не пересекаются, т.е. нет зависимостей по данным. Однако возможности директивы simd значительно шире и в настоящий момент для помощи векторизатору рекомендуется использовать именно ее. Подробнее эти вопросы будут рассмотрены в следующей лекции.

    Директива vector aligned в примере указывает, что границы массивов, использованных в теле цикла, выровнены, что позволяет компилятору использовать команды выровненного доступа к памяти.

  4. Использовать возможности Array Notation и Elemental Function в рамках технологии Intel Cilk Plus.

    Технология Intel Cilk Plus позволяет разрабатывать эффективные параллельные программы для систем с общей памятью, по сравнению с OpenMP упрощая обучение начинающих параллельному программированию, а также предоставляя мощные, логичные и достаточно простые средства организации параллелизма с использованием механизма логических задач. Наряду с этим, в Cilk Plus добавлена так называемая Array Notation, что позволяет записывать вычисления в циклах как бы без самих циклов, явно показывая компилятору, что эти вычисления можно "положить" на векторную архитектуру.

    Пример программы может выглядеть следующим образом:

    void test(float * restrict a,
              float * restrict b,
              float * restrict c, 
              int n)
    {
        c[0:n] = a[0:n] * b[0:n] + a[0:n];
    }
    

    Кроме того, в Intel Cilk Plus вводится специальный вид функций, т.н. Elemental Function. Эти функции, описанные специальным образом, могут выполнять операции над единицей данных. Такие функции могут быть использованы для векторизации и распараллеливания циклов.

    Подробнее Array Notation и Elemental Functions рассматриваются в "Элементы оптимизации прикладных программ для Intel Xeon Phi. Intel C/C++ Compiler " .

  5. Использовать классы интринсиков для SIMD.

    В данном методе использования векторных инструкций из языков программирования высокого уровня идет речь о применении специальных классов, написанных на C++ и предназначенных для хранения и обработки упакованных данных. Сами классы могут быть написаны с использованием ассемблерных вставок или интринсиков – специальных функций, в основном однозначно соответствующих инструкциям, присутствующим в наборе команд.

  6. Использовать векторные функции-интринсики.

    Данный метод предполагает использование специальных типов данных, соответствующих типам данных, аппаратно поддерживаемым процессором/сопроцессором, и функций-интринсиков, упомянутых ранее.

  7. Написать реализацию на ассемблере.

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

< Лекция 3 || Лекция 4: 123 || Лекция 5 >
Svetlana Svetlana
Svetlana Svetlana

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