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

в 7:03, , рубрики: Блог компании Intel, Компиляторы, параллельное программирование, Программирование, метки:
Intel® Graphics Technology: «выгружаем» вычисления на графику - 1

Продолжаем начатый разговор о 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 availablen";
#else
  cout << "nThis program is built without __INTEL_OFFLOADn"; << "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)IntelComposer XE 2015compilerincludegfx в моём случае) ручками в настройках проекта.

Ещё я нашёл одну интересную опцию, о которой не сказал в предыдущем посте, когда говорил о генерации кода компилятором. Так вот, если мы заранее знаем, на какой «железке» будем работать, то можем указать это компилятору явно с помощью опции /Qgpu-arch. Пока варианта всего два: /Qgpu-arch:ivybridge или /Qgpu-arch:haswell. В реультате линкер вызовет компилятор для трансляции кода из vISA архитектуры в нужную нам, и мы сэкономим на JIT’тинге.
И напоследок важное замечание про работу оффлоада на Windows 7 (и DirectX 9). Критично, чтобы дисплей был активный, иначе ничего не заработает. В Windows 8 такого ограничения нет.
Ну и помним, что речь идёт об интегрированной в процессор графике. Описанные конструкции не работают с дискретной графикой – для неё используем OpenCL.

Автор: ivorobts

Источник

Поделиться

* - обязательные к заполнению поля