company_banner

Intel® Graphics Technology. Часть II: «выгружаем» вычисления на графику


    Продолжаем начатый разговор о Intel® Graphics Technology, а именно о том, что у нас есть в распоряжении с точки зрения написания кода: прагмы offload и offload_attribute для оффлоадинга, атрибуты target(gfx) и target(gfx_kernel), макросы __GFX__ и __INTEL_OFFLOAD, интринсики и набор API функций для асинхронного оффлоада. Это всё, что нужно нам для счастья. Чуть было не забыл: конечно, нам нужен компилятор от Intel и магическая опция /Qoffload.

    Но обо всё по порядку. Одна из основных идей – это относительно легкая модификация существующего кода, выполняемого на CPU для его выполнения на интегрированной в процессор графике.

    Легче всего это показать на простом примере суммирования двух массивов:

    void vector_add(float *a, float *b, float *c){
    for(int i = 0; i < N; i++)
            c[i] = a[i] + b[i];
    return;
    }
    

    С помощью технологии Intel® Cilk™ Plus мы можем легко сделать его параллельным, заменив цикл for на cilk_for:

    void vector_add(float *a, float *b, float *c){
    cilk_for(int i = 0; i < N; i++)
            c[i] = a[i] + b[i];
    return;
    }
    

    Ну и на следующем шаге мы уже отгружаем вычисления на графику с помощью директивы #pragma offload в синхронном режиме:

    void vector_add(float *a, float *b, float *c){
    #pragma offload target(gfx) pin(a, b, c:length(N))
    cilk_for(int i = 0; i < N; i++)
            c[i] = a[i] + b[i];
    return;
    }
    

    Или создаем ядро для асинхронного выполнения, используя спецификатор __declspec(target(gfx_kernel)) перед функцией:

    __declspec(target(gfx_kernel))
    void vector_add(float *a, float *b, float *c){
    cilk_for(int i = 0; i < N; i++)
            c[i] = a[i] + b[i];
    return; 
    }
    

    Кстати, везде фигурирует набор букв GFX, который и должен наводить нас на мысль, что работаем мы именно с интегрированной графикой (GFX – Graphics), а не с GPU, под которой чаще понимают дискретную графику.

    Как вы уже поняли, у процедуры есть ряд особенностей. Ну, во-первых, всё работает только с циклам cilk_for. Понятно, что для хорошей работы должна быть параллельная версия нашего кода, но пока поддерживается именно механизм для работы с циклами из Cilk’а, то есть тот же OpenMP идет мимо кассы. Нужно помнить и о том, что графика не очень работает с 64 битными «флотами» и целыми – особенности «железа», так что ждать высокой производительности с такими операциями не приходится.

    Существуют два основных режима для вычислений на графике: синхронный и асинхронный. Для реализации первого используются директивы компилятора, а для второго – набор API функций, при этом для осуществления оффлоада необходимо будет объявленную таким образом функцию (ядро) «положить» в очередь на выполнение.

    Синхронный режим
    Он осуществляется с помощью использования директивы #pragma offload target(gfx) перед интересующим нас циклом cilk_for.
    В реальном приложении в этом цикле вполне может быть вызов некой функции, поэтому она тоже должна быть объявлена с __declspec(target(gfx)).
    Синхронность заключается в том, что поток, выполняющий код на хосте (CPU), будет ждать окончания вычислений на графике. При этом компилятор генерирует код как для хоста, так и для графики, что позволяет достичь большей гибкости при работе с разным «железом». Если оффлоад не поддерживается, то выполнение всего кода происходит на CPU. В первом посте мы уже говорили о том, как это реализовано.
    У директивы можно указать следующие параметры:
    • if (condition) – код будет выполняться только на графике, если условие истинно
    • in|out|inout|pin(variable_list: length(length_variable_in_elements))
      in, out, или inout – указываем, какие переменные копируем между CPU и графикой
    • pin – выставляем переменные, общие для CPU и графики. В этом случае, копирования данных не происходит, а используемая память не может свопиться.
    • length – необходимая вещь при работе с указателями. Нужно задать размер данных, которые нужно копировать в/из памяти графики, или, которые нужно делить с CPU. Задается в виде числа элементов типа указателя. Для указателя на массив это число соответствующих элементов массива.

    Важное замечание – использование pin может существенно снизить накладные расходы на использование оффлоада. Вместо копирования данных туда-сюда, мы организуем доступ к физической памяти, доступной как хосту (CPU), так и интегрированной графике. Если же размер данных незначителен, то большого прироста мы не увидим.
    Так как ОС не знает, что процессорная графика использует память, то очевидным решением было сделать так, что используемые страницы памяти нельзя свопить, во избежание неприятной ситуации. Поэтому нужно быть аккуратным и много не «пиннить» — иначе получим много страниц, для которых нельзя сделать своп. Естественно, быстродействие системы в целом от этого не увеличится.

    В нашем примере суммирования двух массивов, мы как раз используем параметр pin(a, b, c:length(N)):

    #pragma offload target(gfx) pin(a, b, c:length(N))
    

    То есть массивы a и b не копируются в память графики, а остаются доступными в общей памяти, при этом соответствующая страница не свопится, пока мы не закончим работу.
    Кстати, для игнорирования прагм используется опция /Qoffload-. Ну это если вдруг нам резко надоест оффлоад. Кстати, ifdef’ы никто не отменял, и подобный прием всё ещё весьма актуален:

    #ifdef __INTEL_OFFLOAD
      cout << "\nThis program is built with __INTEL_OFFLOAD.\n" << "The target(gfx) code will be executed on target if it is available\n";
    #else
      cout << "\nThis program is built without __INTEL_OFFLOAD\n"; << "The target(gfx) code will be executed on CPU only.\n";
    #endif 
    

    Асинхронный режим
    Рассмотрим теперь другой режим оффлоада, который основывается на использовании API функций. У графики имеется своя очередь на выполнение, и всё что нам необходимо – это создать ядра (gfx_kernel) и положить их в эту очередь. Ядро можно создать с помощью спецификатора __declspec(target(gfx_kernel)) перед функцией. При этом, когда поток на хосте посылает ядро на выполнение в очередь, он продолжает выполнение. Тем не менее, существует возможность дождаться окончания выполнения на графике с помощью функции _GFX_wait().

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

    Кроме того, в асинхронном режиме не предусмотрена генерация кода как для хоста, так и для графики. Поэтому придется позаботится о реализации кода только для хоста самим.

    Вот как выглядит код для вычисления суммы массивов в асинхронном режиме (асинхронный вариант кода для vec_add был представлен выше):

    	float *a = new float[TOTALSIZE];
    	float *b = new float[TOTALSIZE];
    	float *c = new float[TOTALSIZE];
    	float *d = new float[TOTALSIZE];
    
    	a[0:TOTALSIZE] = 1;
    	b[0:TOTALSIZE] = 1;
    	c[0:TOTALSIZE] = 0;
    	d[0:TOTALSIZE] = 0;
    
    	_GFX_share(a, sizeof(float)*TOTALSIZE);
    	_GFX_share(b, sizeof(float)*TOTALSIZE);
    	_GFX_share(c, sizeof(float)*TOTALSIZE);
    	_GFX_share(d, sizeof(float)*TOTALSIZE);
    
    	_GFX_enqueue("vec_add", c, a, b, TOTALSIZE);
    	_GFX_enqueue("vec_add", d, c, a, TOTALSIZE);
    	_GFX_wait();
    
    	_GFX_unshare(a);
    	_GFX_unshare(b);
    	_GFX_unshare(c);
    	_GFX_unshare(d);
    

    Итак, мы объявляем и инициализируем 4 массива. С помощью функции _GFX_share явно говорим, что эту память (начальный адрес и длина в байтах задаются параметрами функции) нужно пиннить, то есть будем использовать память общую для CPU и графики. После этого кладем в очередь нужную функцию vec_add, которая определена с помощью __declspec(target(gfx_kernel)). В ней, как и всегда, используется цикл cilk_for. Поток на хосте кладёт второй просчёт функции vec_add с новыми параметрами в очередь без ожидания выполнения первой. С помощью _GFX_wait мы ожидаем выполнения всех ядер в очереди. И в конце явно останавливаем пиннинг памяти с помощью _GFX_unshare.

    Не забываем, что для использования API функций нам понадобится заголовочный файл gfx_rt.h. Кроме того, для использования cilk_for нужно подключить cilk/cilk.h.
    Интересный момент заключается в том, что по умолчанию установленный компилятор найти gfx_rt.h не смог – пришлось прописать путь к его папочке (C:\Program Files (x86)\Intel\Composer XE 2015\compiler\include\gfx в моём случае) ручками в настройках проекта.

    Ещё я нашёл одну интересную опцию, о которой не сказал в предыдущем посте, когда говорил о генерации кода компилятором. Так вот, если мы заранее знаем, на какой «железке» будем работать, то можем указать это компилятору явно с помощью опции /Qgpu-arch. Пока варианта всего два: /Qgpu-arch:ivybridge или /Qgpu-arch:haswell. В реультате линкер вызовет компилятор для трансляции кода из vISA архитектуры в нужную нам, и мы сэкономим на JIT’тинге.

    И напоследок важное замечание про работу оффлоада на Windows 7 (и DirectX 9). Критично, чтобы дисплей был активный, иначе ничего не заработает. В Windows 8 такого ограничения нет.

    Ну и помним, что речь идёт об интегрированной в процессор графике. Описанные конструкции не работают с дискретной графикой – для неё используем OpenCL.
    Intel
    Company

    Comments 10

      +1
      Хотелось бы ещё узнать про накладные расходы.
      Начиная с каких объёмов это будет быстрее чем на CPU?
        0
        Понятно что оверхед есть при копировании из памяти в память. При использовании общей памяти он не такой существенный, а распределение нагрузки возложено на планировщик от Cilk'а.
        Здесь сложно дать конкретную оценку, но задача должна быть не очень «тяжелой» для графики.
        Результаты по производительности у меня есть, они однозначно говорят о том, что работает с оффлоадом быстрее, причем в разы, но всё зависит от алгоритма и того, как написан код (попадание в кэши и т.д.). Возможно, я раскрою ещё больше деталей в дополнительном посте о том, как сделать именно эффективный оффлоад. Здесь я только знакомил с технологией.
        +3
        Это вообще быстрей чем на CPU? Особенно если учесть, что Интеловская графика не очень то и быстрая.
          0
          Это на ряде задач существенно быстрее чем только на CPU.
            0
            Я советую посмотреть на пример NBODY, чтобы самому сравнить, что где быстрее.
            0
            Когда Intel C++ начнет поддерживать C++ AMP?
              0
              Я про планы не могу ничего сказать, но уже сейчас интеграция в студию достаточно хорошо дружит со студийным компилятором. У меня, например, есть тестовый пример, где весь проект, за исключением исходника с С++АМР кодом, компилируется Intel C++, а исходник с С++АМР кодом компилируется студийным компилятором. Поскольку студийный рантайм один, всё замечательно уживается.
                0
                Это-то сработает, но что насчет запуска на Linux?
                  0
                  Для кроссплатформенности у нас есть Cilk Plus. Он на линуксе тоже работает.
              +1
              Интересный момент заключается в том, что по умолчанию установленный компилятор найти gfx_rt.h не смог – пришлось прописать путь к его папочке (C:\Program Files (x86)\Intel\Composer XE 2015\compiler\include\gfx в моём случае) ручками в настройках проекта.

              Надо не просто gfx_rt.h подключать, а gfx/gfx_rt.h. Тогда ничего прописывать не надо.

              Only users with full accounts can post comments. Log in, please.